', nav);
li.children[0].addEventListener('click', Player.display.toggle);
} else if (Site === 'Fuuka') {
const br = document.querySelector('body > div > br');
br.parentNode.insertBefore(document.createTextNode('['), br);
_.element('Sounds', br, 'beforebegin');
br.parentNode.insertBefore(document.createTextNode(']'), br);
} else if (isChanX) {
// Add a button in the header for 4chan X.
_.element(`Sounds`, document.getElementById('shortcut-settings'), 'beforebegin');
} else {
// Add a [Sounds] link in the top and bottom nav for native 4chan.
document.querySelectorAll('#settingsWindowLink, #settingsWindowLinkBot').forEach(function (link) {
_.element('Sounds', link, 'beforebegin');
link.parentNode.insertBefore(document.createTextNode('] ['), link);
});
}
},
/**
* Render the player.
*/
render: async function () {
try {
if (Player.container) {
document.body.removeChild(Player.container);
document.head.removeChild(Player.stylesheet);
}
// Create the main stylesheet.
Player.display.updateStylesheet();
// Create the main player. For native threads put it in the threads to get free quote previews.
const parent = Thread && !isChanX && document.body.querySelector('.board') || document.body;
Player.container = _.element(Player.display.template(), parent);
await Player.trigger('rendered');
} catch (err) {
Player.logError('There was an error rendering the sound player.', err);
// Can't recover, throw.
throw err;
}
},
updateStylesheet: function () {
// Insert the stylesheet if it doesn't exist. 4chan X polyfill, sound player styling, and user styling.
Player.stylesheet = Player.stylesheet || _.element('', document.head);
Player.stylesheet.innerHTML = (!isChanX ? '/* 4chanX Polyfill */\n\n' + css4chanXPolyfillTemplate() : '')
+ '\n\n/* Sounds Player CSS */\n\n' + cssTemplate();
},
/**
* Change what view is being shown
*/
setViewStyle: async function (style) {
// Get the size and style prior to switching.
const previousStyle = Player.config.viewStyle;
// Exit fullscreen before changing to a different view.
if (style !== 'fullscreen') {
document.fullscreenElement && document.exitFullscreen();
}
// Change the style.
Player.set('viewStyle', style);
Player.container.setAttribute('data-view-style', style);
if (style === 'playlist' || style === 'image') {
Player.controls.preventWrapping();
}
// Try to reapply the pre change sizing unless it was fullscreen.
if (previousStyle !== 'fullscreen' || style === 'fullscreen') {
const [ width, height ] = (await GM.getValue('size') || '').split(':');
width && height && Player.position.resize(parseInt(width, 10), parseInt(height, 10));
Player.position.setPostWidths();
}
Player.trigger('view', style, previousStyle);
},
/**
* Togle the display status of the player.
*/
toggle: function () {
if (Player.container.style.display === 'none') {
Player.show();
} else {
Player.hide();
}
},
/**
* Hide the player. Stops polling for changes, and pauses the aduio if set to.
*/
hide: function () {
Player.container.style.display = 'none';
Player.isHidden = true;
Player.trigger('hide');
},
/**
* Show the player. Reapplies the saved position/size, and resumes loaded amount polling if it was paused.
*/
show: async function () {
if (!Player.container.style.display) {
return;
}
Player.container.style.display = null;
Player.isHidden = false;
await Player.trigger('show');
},
/**
* Stop playback and close the player.
*/
close: async function () {
Player.stop();
Player.hide();
},
/**
* Toggle the video/image and controls fullscreen state
*/
toggleFullScreen: async function () {
if (!document.fullscreenElement) {
// Make sure the player (and fullscreen contents) are visible first.
if (Player.isHidden) {
Player.show();
}
Player.$(`.${ns}-player`).requestFullscreen();
document.body.addEventListener('pointermove', Player.display._fullscreenMouseMove);
Player.display._fullscreenMouseMove();
} else if (document.exitFullscreen) {
document.exitFullscreen();
document.body.removeEventListener('pointermove', Player.display._fullscreenMouseMove);
}
},
_fullscreenMouseMove: function () {
Player.container.classList.remove('cursor-inactive');
Player.display.fullscreenCursorTO && clearTimeout(Player.display.fullscreenCursorTO);
Player.display.fullscreenCursorTO = setTimeout(function () {
Player.container.classList.add('cursor-inactive');
}, 2000);
},
/**
* Handle the fullscreen state being changed
*/
_handleFullScreenChange: function () {
if (document.fullscreenElement) {
Player.display.setViewStyle('fullscreen');
document.querySelector(`.${ns}-image-link`).removeAttribute('href');
} else {
if (Player.playing) {
document.querySelector(`.${ns}-image-link`).href = Player.playing.image;
}
Player.playlist.restore();
}
Player.controls.preventWrapping();
},
restore: async function (restore) {
const restoreIndex = Player.display.dismissed.indexOf(restore);
if (restore && restoreIndex > -1) {
Player.display.dismissed.splice(restoreIndex, 1);
Player.$all(`[\\@click^='display.restore("${restore}")']`).forEach(el => {
_.element(dismissedContentCache[restore], el, 'beforebegin');
el.parentNode.removeChild(el);
});
await GM.setValue('dismissed', Player.display.dismissed.join(','));
}
},
dismiss: async function (dismiss) {
if (dismiss && !Player.display.dismissed.includes(dismiss)) {
Player.display.dismissed.push(dismiss);
Player.$all(`[data-dismiss-id="${dismiss}"]`).forEach(el => {
_.element(`${dismissedRestoreCache[dismiss]}`, el, 'beforebegin');
el.parentNode.removeChild(el);
});
await GM.setValue('dismissed', Player.display.dismissed.join(','));
}
},
ifNotDismissed: function (name, restore, text) {
dismissedContentCache[name] = text;
dismissedRestoreCache[name] = restore;
return Player.display.dismissed.includes(name)
? `${restore}`
: text;
},
/**
* Display a menu
*/
showMenu: function (relative, menu, parent) {
const dialog = typeof menu === 'string' ? _.element(menus[menu]()) : menu;
Player.display.closeDialogs();
parent || (parent = Player.container);
parent.appendChild(dialog);
// Position the menu.
Player.position.showRelativeTo(dialog, relative);
// Add the focused class handler
dialog.querySelectorAll('.entry').forEach(el => {
el.addEventListener('mouseenter', e => {
Player.display._setFocusedMenuItem(e);
el.dispatchEvent(new CustomEvent('entry-focus'));
});
});
// Allow clicks of sub menus
dialog._keepOpenFor = Array.from(dialog.querySelectorAll('.entry.has-submenu'));
dialog._closeFor = Array.from(dialog.querySelectorAll('.submenu'));
Player.trigger('menu-open', dialog);
},
_setFocusedMenuItem: function (e) {
const submenu = e.currentTarget.querySelector('.submenu');
const menu = e.currentTarget.closest('.dialog');
const currentFocus = menu.querySelectorAll('.focused');
currentFocus.forEach(el => {
el.classList.remove('focused');
el.dispatchEvent(new CustomEvent('entry-blur'));
});
e.currentTarget.classList.add('focused');
// Move the menu to the other side if there isn't room.
if (submenu && submenu.getBoundingClientRect().right > document.documentElement.clientWidth) {
submenu.style.inset = '0px 100% auto auto';
}
},
/**
* Close any open menus.
*/
closeDialogs: function (e) {
document.querySelectorAll(`.${ns}-dialog`).forEach(dialog => {
const clickableElements = (dialog._keepOpenFor || []).concat(dialog.dataset.allowClick ? dialog : []);
// Close the dialog if there's no event...
const closeDialog = !e
// ...the event was not for an element that allows the dialog to stay open
|| !clickableElements.find(el => el === e.target || el.contains(e.target))
// ...or the event was for an element explicitly set to close the dialog.
|| (dialog._closeFor || []).find(el => el === e.target || el.contains(e.target));
if (closeDialog) {
dialog.parentNode.removeChild(dialog);
Player.trigger('menu-close', dialog);
}
});
},
runTitleMarquee: async function () {
Player.display._marqueeTO = setTimeout(Player.display.runTitleMarquee, 1000);
document.querySelectorAll(`.${ns}-title-marquee`).forEach(title => {
const offset = title.parentNode.getBoundingClientRect().width - (title.scrollWidth + 1);
const location = title.getAttribute('data-location');
// Fall out if the title is fully visible.
if (offset >= 0) {
return title.style.marginLeft = null;
}
const data = Player.display._marquees[location] = Player.display._marquees[location] || {
direction: 1,
position: parseInt(title.style.marginLeft, 10) || 0
};
// Pause at each end.
if (data.pause > 0) {
data.pause--;
return;
}
data.position -= (20 * data.direction);
// Pause then reverse direction when the end is reached.
if (data.position > 0 || data.position < offset) {
data.position = Math.min(0, Math.max(data.position, offset));
data.direction *= -1;
data.pause = 1;
}
title.style.marginLeft = data.position + 'px';
});
},
_popoverMouseEnter: e => {
const icon = e.currentTarget;
if (!icon.infoEl || !Player.container.contains(icon.infoEl)) {
icon.infoEl = _.element(`
${icon.dataset.content}
`, Player.container);
icon.infoEl._keepOpenFor = [ icon ];
Player.position.showRelativeTo(icon.infoEl, icon);
}
},
_popoverMouseLeave: e => {
const icon = e.currentTarget;
if (icon.infoEl && !icon.infoEl._clicked) {
icon.infoEl.parentNode.removeChild(icon.infoEl);
delete icon.infoEl;
}
},
_popoverClick: e => {
const icon = e.currentTarget;
const openPopover = icon.infoEl && Player.container.contains(icon.infoEl);
if (!openPopover) {
Player.display._popoverMouseEnter(e);
} else if (!(icon.infoEl._clicked = !icon.infoEl._clicked)) {
Player.display._popoverMouseLeave(e);
}
},
_initNoSleep: newValue => {
const action = newValue ? 'addEventListener' : 'removeEventListener';
if (!noSleep || !!newValue === Player.display._noSleepEnabled) {
return;
}
Player.audio[action]('play', enableNoSleep);
Player.audio[action]('pause', disableNoSleep);
Player.audio[action]('ended', disableNoSleep);
Player.display._noSleepEnabled = !!newValue;
if (!Player.audio.paused) {
noSleep[newValue ? 'enable' : 'disable']();
}
},
untz() {
const container = Player.$(`.${ns}-image-link`);
Player.untzing = !Player.untzing;
Player.audio.playbackRate = Player.audio.defaultPlaybackRate = Player.untzing ? 1.3 : 1;
Player.container.classList[Player.untzing ? 'add' : 'remove']('untz');
if (Player.untzing) {
const overlay = Player.$('.image-color-overlay');
let rotate = 0;
overlay.style.filter = `brightness(1.5); hue-rotate(${rotate}deg)`;
(function color() {
overlay.style.filter = `hue-rotate(${rotate = 360 - rotate}deg)`;
Player.untzColorTO = setTimeout(color, 500);
}());
(function bounce() {
if (Player.untzing) {
container.style.transform = `scale(${1 + Math.random() * 0.05})`;
container.style.filter = `brightness(${1 + Math.random() * 0.5}) blur(${Math.random() * 3}px)`;
Player.untzBounceTO = setTimeout(bounce, 200);
}
}());
} else {
container.style.transform = null;
container.style.filter = null;
clearTimeout(Player.untzBounceTO);
clearTimeout(Player.untzColorTO);
}
}
};
/***/ }),
/***/ "./src/components/events/index.js":
/*!****************************************!*\
!*** ./src/components/events/index.js ***!
\****************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
const eventNames = [
// Inputs/Forms
'change', 'focus', 'blur', 'focusin', 'focusout', 'reset', 'submit',
// View
'fullscreenchange', 'fullscreenerror', 'resize', 'scroll',
// Keyboard
'keydown', 'keyup',
// Clicks
'auxclick', 'click', 'contextmenu', 'dblclick',
// Mouse
'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseup',
// Custom pointer dragging
'pointdrag', 'pointdragstart', 'pointdragend',
// Touch
'touchcancel', 'touchend', 'touchmove', 'touchstart',
// Dragging
'drag', 'dragend', 'dragenter', 'dragstart', 'dragleave', 'dragover', 'drop',
// Media
'canplay', 'canplaythrough', 'complete', 'duration-change', 'emptied', 'ended', 'loadeddata', 'loadedmetadata',
'pause', 'play', 'playing', 'ratechange', 'seeked', 'seeking', 'stalled', 'suspend', 'timeupdate', 'volumechange', 'waiting',
// Custom
'entry-focus', 'entry-blur'
];
const evtSelector = eventNames.map(e => `[\\@${e}]`).join(',');
module.exports = {
atRoot: [ 'on', 'off', 'trigger' ],
// Holder of event handlers.
_events: { },
initialize: function () {
const eventLocations = { Player, ...Player.components };
const audio = [];
for (let comp of Object.values(eventLocations)) {
comp.audioEvents && audio.push(comp.audioEvents);
}
// Clear mousedown listeners when the mouse/touch is released.
document.body.addEventListener('pointerup', Player.events.clearMousedown);
document.body.addEventListener('pointercancel', Player.events.clearMousedown);
Player.on('rendered', function () {
// Wire up audio events.
for (let eventList of audio) {
for (let evt in eventList) {
let handlers = Array.isArray(eventList[evt]) ? eventList[evt] : [ eventList[evt] ];
handlers.forEach(handler => Player.audio.addEventListener(evt, Player.getHandler(handler)));
}
}
});
},
/**
* Add event listeners from event attributes on an elements and all it's decendents.
*
* @param {Element} element The element to set event listeners for.
*/
apply: function (element) {
// Find all elements with event attributes, including the given element.
const els = Array.from(element.querySelectorAll(evtSelector));
element.matches(evtSelector) && els.unshift(element);
els.forEach(el => {
for (let { name, value } of el.attributes) {
const evt = name[0] === '@' && name.slice(1);
if (evt) {
Player.events.set(el, evt, value);
}
}
});
},
set(el, evt, value) {
// Remove listeners already set.
let listeners = el._eventListeners || (el._eventListeners = {});
listeners[evt] || (listeners[evt] = []);
listeners[evt].forEach(l => el.removeEventListener(evt, l));
// Events are defined in the format `func1("arg1",...):mod1:modN`
for (let spec of value.split(/\s*;\s*/)) {
const [ _spec, handler, argString, modsString ] = spec.match(/^([^(:]+)?(\(.*\))?(?::(.*))?$/);
const mods = modsString && modsString.split(':').reduce((m, n) => {
const isArgs = n[0] === '[';
m[isArgs ? 'args' : n] = isArgs ? JSON.parse(n) : n;
return m;
}, {}) || {};
// Args are any JSON value, where "evt.property" signifies the event being passed and an optional property path.
const args = argString && JSON.parse('[' + argString.slice(1, -1) + ']');
const eventArgs = (args || []).reduce((a, arg, i) => a.concat(arg.startsWith && arg.startsWith('evt') ? [ [ i, arg.slice(4) ] ] : []), []);
const f = handler && Player.getHandler(handler.trim());
// Wrap the handler to handle prevent/stop/args.
const needsWrapping = mods.prevent || mods.stop || mods.disabled || args;
const listener = !needsWrapping ? f : e => {
if (mods.disabled && e.currentTarget.classList.contains('disabled')) {
return;
}
mods.prevent && e.preventDefault();
mods.stop && e.stopPropagation();
eventArgs.forEach(([ idx, path ]) => args.splice(idx, 1, _.get(e, path)));
f && f.apply(null, args || [ e ]);
};
if (!listener || handler && !f) {
console.error('[4chan sounds player] Invalid event', evt, spec, el);
}
// Point drag is a special case to handle pointer dragging.
if (evt === 'pointdrag') {
const downListener = e => {
el._pointdragstart && el._pointdragstart(e);
if (!e.preventDrag) {
el.setPointerCapture(e.pointerId);
Player._mousedown = el;
Player._mousedownListener = listener;
Player._mousedownMoveEl = mods.unbound ? document.documentElement : el;
Player._mousedownMoveEl.addEventListener('pointermove', listener, mods);
el.addEventListener('pointerleave', listener, mods);
mods.boxed && el.addEventListener('pointerleave', Player.events.clearMousedown);
!mods.move && listener(e);
}
};
el.addEventListener('pointerdown', downListener);
listeners.pointerdown || (listeners.pointerdown = []);
listeners.pointerdown.push(downListener);
} else if (evt === 'pointdragstart' || evt === 'pointdragend') {
el[`_${evt}`] = listener;
} else {
el.addEventListener(evt, listener, mods);
listeners[evt].push(listener);
}
}
},
/**
* Create an event listener on the player.
*
* @param {String} evt The name of the events.
* @param {function} handler The handler function.
*/
on: function (evt, handler) {
Player.events._events[evt] || (Player.events._events[evt] = []);
Player.events._events[evt].push(handler);
},
/**
* Remove an event listener on the player.
*
* @param {String} evt The name of the events.
* @param {function} handler The handler function.
*/
off: function (evt, handler) {
const index = Player.events._events[evt] && Player.events._events[evt].indexOf(handler);
if (index > -1) {
Player.events._events[evt].splice(index, 1);
}
},
/**
* Trigger an event on the player.
*
* @param {String} evt The name of the events.
* @param {*} data Data passed to the handler.
*/
trigger: async function (evt, ...data) {
const events = Player.events._events[evt] || [];
for (let handler of events) {
await handler(...data);
}
},
clearMousedown: function (e) {
if (Player._mousedown) {
Player._mousedown.releasePointerCapture(e.pointerId);
Player._mousedownMoveEl.removeEventListener('pointermove', Player._mousedownListener);
Player._mousedown.removeEventListener('pointerleave', Player._mousedownListener);
Player._mousedown._pointdragend && Player._mousedown._pointdragend(e);
Player._mousedown = Player._mousedownListener = null;
}
}
};
/***/ }),
/***/ "./src/components/footer/index.js":
/*!****************************************!*\
!*** ./src/components/footer/index.js ***!
\****************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
module.exports = {
template: () => Player.userTemplate.build({
template: Player.config.footerTemplate
+ ``,
location: 'footer',
sound: Player.playing,
defaultName: '4chan Sounds',
outerClass: `${ns}-col-auto`
}),
initialize: function () {
Player.userTemplate.maintain(Player.footer, 'footerTemplate');
},
render: function () {
_.elementHTML(Player.$(`.${ns}-footer`), Player.footer.template());
}
};
/***/ }),
/***/ "./src/components/header/index.js":
/*!****************************************!*\
!*** ./src/components/header/index.js ***!
\****************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
module.exports = {
template: () => Player.userTemplate.build({
template: Player.config.headerTemplate
+ ``,
location: 'header',
sound: Player.playing,
defaultName: '4chan Sounds',
outerClass: `${ns}-col-auto`
}),
initialize: function () {
Player.userTemplate.maintain(Player.header, 'headerTemplate');
},
render: function () {
_.elementHTML(Player.$(`.${ns}-header`), Player.header.template());
}
};
/***/ }),
/***/ "./src/components/hotkeys/index.js":
/*!*****************************************!*\
!*** ./src/components/hotkeys/index.js ***!
\*****************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js");
let keyConfigs;
module.exports = {
_keyMap: {
' ': 'space',
arrowleft: 'left',
arrowright: 'right',
arrowup: 'up',
arrowdown: 'down'
},
initialize: function () {
Player.on('rendered', Player.hotkeys.apply);
Player.on('config:hotkeys', Player.hotkeys.apply);
keyConfigs = settingsConfig.reduce((c, s) => {
s.property === 'hotkey_bindings' && s.settings.forEach(s => c[s.property.slice(16)] = s);
return c;
}, {});
// Setup up hardware media keys.
if ('mediaSession' in navigator && Player.config.hardwareMediaKeys) {
const actions = [
[ 'play', () => Player.play() ],
[ 'pause', () => Player.pause() ],
[ 'stop', () => Player.pause() ],
[ 'previoustrack', () => Player.previous() ],
[ 'nexttrack', () => Player.next() ],
[ 'seekbackward', evt => Player.audio.currentTime -= evt.seekOffset || 10 ],
[ 'seekforward', evt => Player.audio.currentTime += evt.seekOffset || 10 ],
[ 'seekto', evt => Player.audio.currentTime += evt.seekTime ]
];
for (let [ type, handler ] of actions) {
try {
navigator.mediaSession.setActionHandler(type, handler);
} catch (err) {
// not enabled...
}
}
// Keep the media metadata updated.
Player.audio.addEventListener('pause', () => navigator.mediaSession.playbackState = 'paused');
Player.audio.addEventListener('ended', () => navigator.mediaSession.playbackState = 'paused');
Player.audio.addEventListener('play', Player.hotkeys.setMediaMetadata);
Player.audio.addEventListener('ratechange', Player.hotkeys.setMediaPosition);
Player.audio.addEventListener('seeked', Player.hotkeys.setMediaPosition);
Player.on('tags-loaded', sound => sound === Player.playing && Player.hotkeys.setMediaMetadata());
}
},
async setMediaMetadata() {
const sound = Player.playing;
const tags = sound.tags || {};
navigator.mediaSession.playbackState = 'playing';
const metadata = {
title: [ tags.title, sound.name ].filter(Boolean).join(' ~ ') || sound.title,
artist: [ tags.artist, `/${Board}/ - ${Thread || '4chan Sounds Player'}` ].filter(Boolean).join(' ~ '),
album: tags.album || document.title,
artwork: [
{
src: Player.playing.thumb,
sizes: '125x125'
}
]
};
// If it's not a video add the full image to artwork. (TODO: grab the first frame for videos)
// If we have the dimensions already add the artwork, otherwise load them then reset the metadata.
if (!Player.isVideo) {
if (sound._fullDimension) {
metadata.artwork.push({
src: Player.playing.image,
sizes: sound._fullDimension
});
} else {
const img = new Image();
img.onload = function () {
sound._fullDimension = img.width + 'x' + img.height;
Player.hotkeys.setMediaMetadata();
};
img.src = Player.playing.image;
}
}
navigator.mediaSession.metadata = new MediaMetadata(metadata);
Player.hotkeys.setMediaPosition();
},
setMediaPosition() {
navigator.mediaSession.setPositionState({
duration: Player.audio.duration || 0,
playbackRate: Player.audio.playbackRate,
position: Player.audio.currentTime
});
},
addHandler: () => {
Player.hotkeys.removeHandler();
document.body.addEventListener('keydown', Player.hotkeys.handle);
},
removeHandler: () => {
document.body.removeEventListener('keydown', Player.hotkeys.handle);
},
/**
* Apply the selecting hotkeys option
*/
apply: function () {
const type = Player.config.hotkeys;
Player.hotkeys.removeHandler();
Player.off('show', Player.hotkeys.addHandler);
Player.off('hide', Player.hotkeys.removeHandler);
if (type === 'always') {
// If hotkeys are always enabled then just set the handler.
Player.hotkeys.addHandler();
} else if (type === 'open') {
// If hotkeys are only enabled with the player open toggle the handler as the player opens/closes.
// If the player is already open set the handler now.
if (!Player.isHidden) {
Player.hotkeys.addHandler();
}
Player.on('show', Player.hotkeys.addHandler);
Player.on('hide', Player.hotkeys.removeHandler);
}
},
/**
* Handle a keydown even on the body
*/
handle: function (e) {
// Ignore events on inputs so you can still type.
const ignoreFor = [ 'INPUT', 'SELECT', 'TEXTAREA', 'INPUT' ];
if (ignoreFor.includes(e.target.nodeName) || Player.isHidden && (Player.config.hotkeys !== 'always' || !Player.sounds.length)) {
return;
}
const k = e.key.toLowerCase();
const bindings = Player.config.hotkey_bindings || {};
// Look for a matching hotkey binding
Object.entries(bindings).find(function checkBinding([ name, keyDef ]) {
if (Array.isArray(keyDef)) {
return keyDef.find(_def => checkBinding([ name, _def ]));
}
const bindingConfig = k === keyDef.key
&& (!!keyDef.shiftKey === !!e.shiftKey) && (!!keyDef.ctrlKey === !!e.ctrlKey) && (!!keyDef.metaKey === !!e.metaKey)
&& (!keyDef.ignoreRepeat || !e.repeat)
&& keyConfigs[name];
if (bindingConfig) {
e.preventDefault();
e._binding = keyDef;
Player.getHandler(bindingConfig.keyHandler)(e);
return true;
}
return false;
});
},
/**
* Turn a hotkey definition or key event into an input string.
*/
stringifyKey: function (key) {
let k = key.key.toLowerCase();
Player.hotkeys._keyMap[k] && (k = Player.hotkeys._keyMap[k]);
return (key.ctrlKey ? 'Ctrl+' : '') + (key.shiftKey ? 'Shift+' : '') + (key.metaKey ? 'Meta+' : '') + k;
},
/**
* Turn an input string into a hotkey definition object.
*/
parseKey: function (str) {
const keys = str.split('+');
let key = keys.pop();
Object.keys(Player.hotkeys._keyMap).find(k => Player.hotkeys._keyMap[k] === key && (key = k));
const newValue = { key };
keys.forEach(key => newValue[key.toLowerCase() + 'Key'] = true);
return newValue;
}
};
/***/ }),
/***/ "./src/components/inline/index.js":
/*!****************************************!*\
!*** ./src/components/inline/index.js ***!
\****************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
const selectors = __webpack_require__(/*! ../../selectors */ "./src/selectors.js");
const controlsTemplate = __webpack_require__(/*! ../controls/templates/controls.tpl */ "./src/components/controls/templates/controls.tpl");
module.exports = {
idx: 0,
audio: { },
expandedNodes: [ ],
// Similar but not exactly the audio events in the controls component.
mediaEvents: {
ended: evt => Player.inline._movePlaying(evt.currentTarget.dataset.id, +(Player.config.expandedRepeat !== 'one')),
pause: 'controls.handleMediaEvent',
play: 'controls.handleMediaEvent',
seeked: 'controls.handleMediaEvent',
waiting: 'controls.handleMediaEvent',
timeupdate: 'controls.updateDuration',
loadedmetadata: 'controls.updateDuration',
durationchange: 'controls.updateDuration',
volumechange: 'controls.updateVolume'
},
initialize() {
if (!is4chan) {
return;
}
Player.inline.observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(Player.inline.handleAddedNode);
mutation.removedNodes.forEach(Player.inline.handleRemovedNode);
});
});
Player.on('config:playExpandedImages', Player.inline._handleConfChange);
Player.on('config:playHoveredImages', Player.inline._handleConfChange);
Player.inline._handleConfChange();
},
/**
* Start/stop observing for hover images when a dependent conf is changed.
*/
_handleConfChange() {
if (Player.config.playExpandedImages || Player.config.playHoveredImages) {
Player.inline.start();
} else {
Player.inline.stop();
}
},
/**
* Check if an added node is an expanded/hover sound image and play the audio.
*
* @param {Element} node Added node.
*/
handleAddedNode(node) {
try {
if (node.nodeName !== 'IMG' && node.nodeName !== 'VIDEO') {
return;
}
const isExpandedImage = Player.config.playExpandedImages && node.matches(selectors.expandedImage);
const isHoverImage = Player.config.playHoveredImages && node.matches(selectors.hoverImage);
if (isExpandedImage || isHoverImage) {
const isVideo = node.nodeName === 'VIDEO';
let id;
try {
// 4chan X images have the id set. Handy.
// Otherwise get the parent post, looking up the image link for native hover images, and the id from it.
id = isChanX
? node.dataset.fileID.split('.')[1]
: (isExpandedImage ? node : document.querySelector(`a[href$="${node.src.replace(/^https?:/, '')}"]`))
.closest(selectors.posts).id.slice(selectors.postIdPrefix.length);
} catch (err) {
return;
}
// Check for sounds added to the player.
const sounds = id && Player.sounds.filter(s => s.post === id && !s.standaloneVideo);
if (!sounds.length) {
return;
}
// Create a new audio element.
const audio = new Audio(sounds[0].src);
const aId = audio.dataset.id = Player.inline.idx++;
const master = isVideo && Player.config.expandedLoopMaster === 'video' ? node : audio;
Player.inline.audio[aId] = audio;
// Remember this node is playing audio.
Player.inline.expandedNodes.push(node);
// Add some data and cross link the nodes.
node.classList.add(`${ns}-has-inline-audio`);
node._inlineAudio = audio;
audio._inlinePlayer = node._inlinePlayer = {
master,
video: node,
isVideo,
audio,
sounds,
index: 0
};
// Link video & audio so they sync.
if (isVideo) {
node._linked = audio;
audio._linked = node;
}
// Start from the beginning taking the volume from the main player.
audio.src = sounds[0].src;
audio.volume = Player.audio.volume;
audio.currentTime = 0;
// Add the sync handlers to which source is master.
Player.inline.updateSyncListeners(master, 'add');
// Show the player controls for expanded images/videos.
const showPlayerControls = isExpandedImage && Player.config.expandedControls;
if (isVideo && showPlayerControls) {
// Remove the default controls, and remove them again when 4chan X tries to add them.
node.controls = false;
node.controlsObserver = new MutationObserver(() => node.controls = false);
node.controlsObserver.observe(node, { attributes: true });
// Play/pause the audio instead when the video is clicked.
node.addEventListener('click', () => Player.inline.playPause(aId));
}
// For videos wait for both to load before playing.
if (isVideo && (node.readyState < 3 || audio.readyState < 3)) {
audio.pause();
node.pause();
// Set the add controls function so playOnceLoaded can run it when it's ready.
node._inlinePlayer.pendingControls = showPlayerControls && addControls;
node.addEventListener('canplaythrough', Player.actions.playOnceLoaded);
audio.addEventListener('canplaythrough', Player.actions.playOnceLoaded);
} else {
showPlayerControls && addControls();
audio.play();
}
function addControls() {
delete node._inlinePlayer.pendingControls;
node.parentNode.classList.add(`${ns}-has-controls`);
// Create the controls and store the bars on the audio node for reference. Avoid checking the DOM.
const controls = audio._inlinePlayer.controls = _.element(controlsTemplate({
audio,
multiple: sounds.length > 1,
audioId: aId,
inline: true,
actions: {
previous: `inline.previous("${aId}"):disabled`,
playPause: `inline.playPause("${aId}")`,
next: `inline.next("${aId}"):disabled`,
seek: `controls.handleSeek("evt", "${aId}"):prevent`,
mute: `inline.mute("${aId}")`,
volume: `controls.handleVolume("evt", "${aId}"):prevent`
}
}), node.parentNode);
// Don't want to close the expanded image or open the image when the controls are clicked.
controls.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
});
audio.volumeBar = controls.querySelector(`.${ns}-volume-bar .${ns}-current-bar`);
audio.currentTimeBar = controls.querySelector(`.${ns}-seek-bar .${ns}-current-bar`);
Player.controls.updateProgressBarPosition(audio.volumeBar, audio.volume, 1);
}
}
} catch (err) {
Player.logError('Failed to play sound.', err);
}
},
/**
* Check if a removed node is an expanded/hover sound image and stop the audio.
*
* @param {Element} node Added node.
*/
handleRemovedNode(node) {
const nodes = [ node ];
node.querySelectorAll && nodes.push(...node.querySelectorAll(`.${ns}-has-inline-audio`));
nodes.forEach(node => {
if (node._inlineAudio) {
Player.inline._removeForNode(node);
}
});
},
_removeForNode(node) {
// Stop removing controls.
node.controlsObserver && node.controlsObserver.disconnect();
// Stop listening for media events.
Player.inline.updateSyncListeners(node._inlinePlayer.master, 'remove');
// Remove controls.
const controls = node._inlineAudio._inlinePlayer.controls;
if (controls) {
controls.parentNode.classList.remove(`${ns}-has-controls`);
controls.parentNode.removeChild(controls);
}
// Stop the audio and cleanup the data.
node._inlineAudio.pause();
delete Player.inline.audio[node._inlineAudio.dataset.id];
delete node._inlineAudio;
Player.inline.expandedNodes = Player.inline.expandedNodes.filter(n => n !== node);
},
/**
* Set audio/video sync listeners on a video for an inline sound webm.
*
* @param {Element} video Video node.
* @param {String} action add or remove.
*/
updateSyncListeners(node, action) {
if (node.nodeName === 'VIDEO' || node.nodeName === 'AUDIO') {
const audio = node._inlineAudio || node;
if (action === 'remove') {
const video = audio._inlinePlayer.video;
video.removeEventListener('canplaythrough', Player.actions.playOnceLoaded);
audio.removeEventListener('canplaythrough', Player.actions.playOnceLoaded);
}
Object.entries(Player.inline.mediaEvents).forEach(([ event, handler ]) => {
node[`${action}EventListener`](event, Player.getHandler(handler));
});
}
},
/**
* Start observing for expanded/hover images.
*/
start() {
Player.inline.observer.observe(document.body, {
childList: true,
subtree: true
});
},
/**
* Stop observing for expanded/hover images.
*/
stop() {
Player.inline.observer.disconnect();
Player.inline.expandedNodes.forEach(Player.inline._removeForNode);
Player.inline.expandedNodes = [];
},
/**
* Handle previous click for inline controls.
*
* @param {String} audioId Identifier of the inline audio.
*/
previous(audioId) {
const audio = Player.inline.audio[audioId];
if (audio.currentTime > 3) {
audio.currentTime = 0;
} else {
Player.inline._movePlaying(audioId, -1);
}
},
/**
* Handle next click for inline controls.
*
* @param {String} audioId Identifier of the inline audio.
*/
next(audioId) {
Player.inline._movePlaying(audioId, 1);
},
_movePlaying(audioId, dir) {
const audio = Player.inline.audio[audioId];
const data = audio && audio._inlinePlayer;
const count = data.sounds.length;
const repeat = Player.config.expandedRepeat;
if (data && (repeat !== 'none' || data.index + dir >= 0 && data.index + dir < count)) {
data.index = (data.index + dir + count) % count;
audio.src = data.sounds[data.index].src;
if (data.controls) {
const prev = data.controls.querySelector(`.${ns}-previous-button`);
const next = data.controls.querySelector(`.${ns}-next-button`);
prev && prev.classList[repeat !== 'all' && data.index === 0 ? 'add' : 'remove']('disabled');
next && next.classList[repeat !== 'all' && data.index === count - 1 ? 'add' : 'remove']('disabled');
}
// For videos wait for both to load before playing.
if (data.isVideo && (data.video.readyState < 3 || audio.readyState < 3)) {
data.master.currentTime = 0;
data.master.pause();
data.video.pause();
data.video.addEventListener('canplaythrough', Player.actions.playOnceLoaded);
audio.addEventListener('canplaythrough', Player.actions.playOnceLoaded);
} else {
data.master.currentTime = 0;
data.master.play();
}
}
},
/**
* Handle play/pause click for inline controls.
*
* @param {String} audioId Identifier of the inline audio.
*/
playPause(audioId) {
const audio = Player.inline.audio[audioId];
audio && audio[audio.paused ? 'play' : 'pause']();
},
/**
* Handle mute click for inline controls.
*
* @param {String} audioId Identifier of the inline audio.
*/
mute(audioId) {
const audio = Player.inline.audio[audioId];
audio && (audio.volume = (Player._lastVolume || 0.5) * !audio.volume);
}
};
/***/ }),
/***/ "./src/components/minimised/index.js":
/*!*******************************************!*\
!*** ./src/components/minimised/index.js ***!
\*******************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
/* provided dependency */ var Icons = __webpack_require__(/*! ./src/icons */ "./src/icons.js");
module.exports = {
_showingPIP: false,
initialize: function () {
if (isChanX) {
Player.userTemplate.maintain(Player.minimised, 'chanXTemplate', [ 'chanXControls' ], [ 'show', 'hide', 'stop' ]);
}
Player.on('rendered', Player.minimised.render);
Player.on('show', Player.minimised.hidePIP);
Player.on('hide', Player.minimised.showPIP);
Player.on('stop', Player.minimised.hidePIP);
Player.on('playsound', Player.minimised.showPIP);
},
render: function () {
if (Player.container && isChanX) {
let container = document.querySelector(`.${ns}-chan-x-controls`);
// Create the element if it doesn't exist.
// Set the user template and control events on it to make all the buttons work.
if (!container) {
container = _.element(``, document.querySelector('#shortcuts'), 'afterbegin');
}
if (Player.config.chanXControls === 'never' || Player.config.chanXControls === 'closed' && !Player.isHidden) {
return container.innerHTML = '';
}
const audioId = Player.audio.dataset.id;
// Render the contents.
_.elementHTML(container, Player.userTemplate.build({
template: Player.config.chanXTemplate,
location: '4chan-X-controls',
sound: Player.playing,
replacements: {
'prev-button': `${Icons.skipStart} ${Icons.skipStartFill}`,
'play-button': `${Icons.play} ${Icons.pause} ${Icons.playFill} ${Icons.pauseFill}`,
'next-button': `${Icons.skipEnd} ${Icons.skipEndFill} `,
'sound-current-time': `0:00`,
'sound-duration': `0:00`
}
}));
}
},
/**
* Move the image to a picture in picture like thumnail.
*/
showPIP: function () {
if (!Player.isHidden || !Player.config.pip || !Player.playing || Player.minimised._showingPIP) {
return;
}
Player.minimised._showingPIP = true;
const image = document.querySelector(`.${ns}-image-link`);
document.body.appendChild(image);
image.classList.add(`${ns}-pip`);
image.style.bottom = (Player.position.getHeaderOffset().bottom + 10) + 'px';
// Show the player again when the image is clicked.
image.addEventListener('click', Player.minimised._handleImageClick);
},
/**
* Move the image back to the player.
*/
hidePIP: function () {
Player.minimised._showingPIP = false;
const image = document.querySelector(`.${ns}-image-link`);
const controls = Player.$(`.${ns}-controls`);
controls.parentNode.insertBefore(document.querySelector(`.${ns}-image-link`), controls);
image.classList.remove(`${ns}-pip`);
image.style.bottom = null;
image.removeEventListener('click', Player.minimised._handleImageClick);
},
_handleImageClick: e => {
e.preventDefault();
Player.show();
}
};
/***/ }),
/***/ "./src/components/playlist/index.js":
/*!******************************************!*\
!*** ./src/components/playlist/index.js ***!
\******************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
/* provided dependency */ var Icons = __webpack_require__(/*! ./src/icons */ "./src/icons.js");
const { parseFiles, parseFileName } = __webpack_require__(/*! ../../file_parser */ "./src/file_parser.js");
const { postIdPrefix } = __webpack_require__(/*! ../../selectors */ "./src/selectors.js");
const xhrReplacer = __webpack_require__(/*! ../../xhr-replace */ "./src/xhr-replace.js");
const itemMenuTemplate = __webpack_require__(/*! ./templates/item_menu.tpl */ "./src/components/playlist/templates/item_menu.tpl");
module.exports = {
atRoot: [ 'add', 'remove' ],
public: [ 'search' ],
tagLoadTO: {},
template: __webpack_require__(/*! ./templates/player.tpl */ "./src/components/playlist/templates/player.tpl"),
listTemplate: __webpack_require__(/*! ./templates/list.tpl */ "./src/components/playlist/templates/list.tpl"),
tagsDialogTemplate: __webpack_require__(/*! ./templates/tags_dialog.tpl */ "./src/components/playlist/templates/tags_dialog.tpl"),
initialize: function () {
// Keep track of the last view style so we can return to it.
Player.playlist._lastView = Player.config.viewStyle === 'playlist' || Player.config.viewStyle === 'image'
? Player.config.viewStyle
: 'playlist';
Player.on('view', style => {
// Focus the playing song when switching to the playlist.
style === 'playlist' && Player.playlist.scrollToPlaying();
// Track state.
if (style === 'playlist' || style === 'image') {
Player.playlist._lastView = style;
}
});
// Keey track of the hover image element.
Player.on('rendered', Player.playlist.afterRender);
// Various things to do when a new sound plays.
Player.on('playsound', sound => {
// Update the image/video.
Player.playlist.showImage(sound);
// Update the previously and the new playing rows.
Player.$all(`.${ns}-list-item.playing, .${ns}-list-item[data-id="${Player.playing.id}"]`).forEach(el => {
const newItem = Player.playlist.listTemplate({ sounds: [ Player.sounds.find(s => s.id === el.dataset.id) ] });
_.element(newItem, el, 'beforebegin');
el.parentNode.removeChild(el);
});
// If the player isn't fullscreen scroll to the playing item.
Player.config.viewStyle !== 'fullscreen' && Player.playlist.scrollToPlaying('nearest');
// Scroll the thread to the playing post.
Player.config.autoScrollThread && sound.post && (location.href = location.href.split('#')[0] + '#' + postIdPrefix + sound.post);
// Load tags from the audio file.
Player.playlist.loadTags(Player.playing.id);
});
// Reset to the placeholder image when the player is stopped.
Player.on('stop', () => {
Player.$all(`.${ns}-list-item.playing`).forEach(el => el.classList.remove('playing'));
const container = Player.$(`.${ns}-image-link`);
container.href = '#';
Player.$(`.${ns}-background-image`).src = Player.video.src = '';
Player.$(`.${ns}-image`).src = `data:image/svg+xml;base64,${btoa(Icons.fcSounds)}`;
container.classList.remove(`${ns}-show-video`);
});
// Reapply filters when they change
Player.on('config:filters', Player.playlist.applyFilters);
// Listen to anything that can affect the display of hover images
Player.on('config:hoverImages', Player.playlist.setHoverImageVisibility);
Player.on('menu-open', Player.playlist.setHoverImageVisibility);
Player.on('menu-close', Player.playlist.setHoverImageVisibility);
// Listen to the search display being toggled
Player.on('config:showPlaylistSearch', Player.playlist.toggleSearch);
// Listen for the playlist being shuffled/ordered.
Player.on('config:shuffle', Player.playlist._handleShuffle);
// Update an open tags info dialog when tags are loaded for a sound.
Player.on('tags-loaded', sound => {
const dialog = Player.$(`.tags-dialog[data-sound-id="${sound.id}"]`);
dialog && _.elementHTML(dialog, Player.playlist.tagsDialogTemplate(sound));
});
// Maintain changes to the user templates it's dependent values
Player.userTemplate.maintain(Player.playlist, 'rowTemplate', [ 'shuffle' ]);
},
/**
* Render the playlist.
*/
render: function () {
_.elementHTML(Player.$(`.${ns}-list-container`), Player.playlist.listTemplate());
Player.playlist.afterRender();
},
afterRender: function () {
Player.playlist.hoverImage = Player.$(`.${ns}-hover-image`);
},
/**
* Restore the last playlist or image view.
*/
restore: function () {
Player.display.setViewStyle(Player.playlist._lastView || 'playlist');
},
/**
* Update the image displayed in the player.
*/
showImage: function (sound) {
const container = document.querySelector(`.${ns}-image-link`);
const img = container.querySelector(`.${ns}-image`);
const background = container.querySelector(`.${ns}-background-image`);
img.src = background.src = '';
img.src = background.src = sound.imageOrThumb;
Player.video.src = Player.isVideo ? sound.image : undefined;
if (Player.config.viewStyle !== 'fullscreen') {
container.href = sound.image;
}
container.classList[Player.isVideo ? 'add' : 'remove'](ns + '-show-video');
},
/**
* Switch between playlist and image view.
*/
toggleView: function (e) {
e && e.preventDefault();
let style = Player.config.viewStyle === 'playlist' ? 'image'
: Player.config.viewStyle === 'image' ? 'playlist'
: Player.playlist._lastView;
Player.display.setViewStyle(style);
},
/**
* Add a new sound from the thread to the player.
*/
add: function (sound, skipRender) {
try {
const id = sound.id;
// Make sure the sound is not a duplicate.
if (Player.sounds.find(sound => sound.id === id)) {
return;
}
// Add the sound with the location based on the shuffle settings.
let index = Player.config.shuffle
? Math.floor(Math.random() * Player.sounds.length - 1)
: Player.sounds.findIndex(s => Player.compareIds(s.id, id) > 1);
index < 0 && (index = Player.sounds.length);
Player.sounds.splice(index, 0, sound);
if (Player.container) {
if (!skipRender) {
// Add the sound to the playlist.
const list = Player.$(`.${ns}-list-container`);
let rowContainer = _.element(`
`
).join('')));
},
/**
* Handle a file being removed from a multi input
*/
handleFileRemove(e) {
const idx = +e.currentTarget.getAttribute('data-idx');
const input = e.currentTarget.closest(`.${ns}-file-input`).querySelector('input[type="file"]');
const dataTransfer = new DataTransfer();
for (let i = 0; i < input.files.length; i++) {
i !== idx && dataTransfer.items.add(input.files[i]);
}
input.files = dataTransfer.files;
Player.tools.handleFileSelect(input);
},
/**
* Show/hide the sound input when "Use webm" is changed.
*/
handleWebmSoundChange(e) {
const sound = Player.tools.sndInput;
const image = Player.tools.imgInput;
Player.tools.handleFileSelect(sound, e.currentTarget.checked && [ image.files[0] ]);
},
toggleSoundInput(type) {
const showURL = type === 'url';
Player.$(`.${ns}-create-sound-snd-url`).closest(`.${ns}-row`).style.display = showURL ? null : 'none';
Player.$(`.${ns}-create-sound-snd`).closest(`.${ns}-file-input`).style.display = showURL ? 'none' : null;
Player.tools.useSoundURL = showURL;
},
/**
* Handle files being dropped on the create sound section.
*/
handleCreateSoundDrop(e) {
const targetInput = e.target.nodeName === 'INPUT' && e.target.getAttribute('type') === 'file' && e.target;
[ ...e.dataTransfer.files ].forEach(file => {
const isVideo = file.type.startsWith('video');
const isImage = file.type.startsWith('image') || file.type === 'video/webm';
const isSound = file.type.startsWith('audio');
if (isVideo || isImage || isSound) {
const input = file.type === 'video/webm' && targetInput
? targetInput
: isImage
? Player.tools.imgInput
: Player.tools.sndInput;
const dataTransfer = new DataTransfer();
if (input.multiple) {
[ ...input.files ].forEach(file => dataTransfer.items.add(file));
}
dataTransfer.items.add(file);
input.files = dataTransfer.files;
Player.tools.handleFileSelect(input);
input === Player.tools.imgInput && Player.tools.handleImageSelect();
// Make sure sound file input is shown if a sound file is dropped
if (input === Player.tools.sndInput && Player.tools.useSoundURL) {
Player.tools.toggleSoundInput('file');
}
}
});
return false;
},
/**
* Handle the create button.
* Extracts video/audio if required, uploads the sound, and creates an image file names with [sound=url].
*/
async handleCreate() {
// Revoke the URL for an existing created image.
Player.tools._createdImageURL && URL.revokeObjectURL(Player.tools._createdImageURL);
Player.tools._createdImage = null;
createTool.status.style.display = 'block';
createTool.status.innerHTML = 'Creating sound image';
Player.$(`.${ns}-create-button`).disabled = true;
// Gather the input values.
const host = Player.config.uploadHosts[Player.$(`.${ns}-create-sound-host`).value];
const useSoundURL = Player.tools.useSoundURL;
let image = Player.tools.imgInput.files[0];
let soundURLs = useSoundURL && Player.$(`.${ns}-create-sound-snd-url`).value.split(',').map(v => v.trim()).filter(v => v);
let sounds = !(Player.$(`.${ns}-use-video`) || {}).checked || !image || !image.type.startsWith('video')
? [ ...Player.tools.sndInput.files ]
: image && [ image ];
const customName = Player.$(`.${ns}-create-sound-name`).value;
// Only split a given name if there's multiple sounds.
const names = customName
? ((soundURLs || sounds).length > 1 ? customName.split(',') : [ customName ]).map(v => v.trim())
: image && [ image.name.replace(/\.[^/.]+$/, '') ];
try {
if (!image) {
throw new PlayerError('Select an image or webm.', 'warning');
}
// No audio allowed for the "image" webm.
if (image.type.startsWith('video') && await Player.tools.hasAudio(image)) {
createTool.status.innerHTML += ' Audio not allowed for the image webm.'
+ ' Remove the audio from the webm and try again.';
throw new PlayerError('Audio not allowed for the image webm.', 'warning');
}
const soundlessLength = names.join('').length + (soundURLs || sounds).length * 8;
if (useSoundURL) {
try {
// Make sure each url is valid and strip the protocol.
soundURLs = soundURLs.map(url => new URL(url) && url.replace(/^(https?:)?\/\//, ''));
} catch (err) {
throw new PlayerError('The provided sound URL is invalid.', 'warning');
}
if (maxFilenameLength < soundlessLength + soundURLs.join('').length) {
throw new PlayerError('The generated image filename is too long.', 'warning');
}
} else {
if (!sounds || !sounds.length) {
throw new PlayerError('Select a sound.', 'warning');
}
// Check the final filename length if the URL length is known for the host.
// Limit to 8 otherwise. zz.ht is as small as you're likely to get and that can only fit 8.
const tooManySounds = host.filenameLength
? maxFilenameLength < soundlessLength + (host.filenameLength) * sounds.length
: sounds.length > 8;
if (tooManySounds) {
throw new PlayerError('The generated image filename is too long.', 'warning');
}
// Check videos have audio.
sounds = await Promise.all(sounds.map(async sound => {
if (sound.type.startsWith('video')) {
if (!await Player.tools.hasAudio(sound)) {
throw new PlayerError(`The selected video has no audio. (${sound.name})`, 'warning');
}
}
return sound;
}));
// Upload the sounds.
try {
soundURLs = await Promise.all(sounds.map(async sound => Player.tools.postFile(sound, host)));
} catch (err) {
throw new PlayerError('Upload failed.', 'error', err);
}
}
if (!soundURLs.length) {
throw new PlayerError('No sounds selected.', 'warning');
}
// Create a new file that includes [sound=url] in the name.
let filename = '';
for (let i = 0; i < soundURLs.length; i++) {
filename += (names[i] || '') + '[sound=' + encodeURIComponent(soundURLs[i].replace(/^(https?:)?\/\//, '')) + ']';
}
const ext = image.name.match(/\.([^/.]+)$/)[1];
// Keep track of the create image and a url to it.
Player.tools._createdImage = new File([ image ], filename + '.' + ext, { type: image.type });
Player.tools._createdImageURL = URL.createObjectURL(Player.tools._createdImage);
// Complete! with some action links
_.element(completeTemplate(), createTool.status);
} catch (err) {
createTool.status.innerHTML += ' Failed! ' + (err instanceof PlayerError ? err.reason : '');
Player.logError('Failed to create sound image', err);
}
Player.$(`.${ns}-create-button`).disabled = false;
},
hasAudio(file) {
if (!file.type.startsWith('audio') && !file.type.startsWith('video')) {
return false;
}
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.addEventListener('loadeddata', () => {
URL.revokeObjectURL(url);
resolve(video.mozHasAudio || !!video.webkitAudioDecodedByteCount);
});
video.addEventListener('error', reject);
video.src = url;
});
},
/**
* Upload the sound file and return a link to it.
*/
async postFile(file, host) {
const idx = Player.tools._uploadIdx++;
if (!host || host.invalid) {
throw new PlayerError('Invalid upload host.', 'error');
}
const formData = new FormData();
Object.keys(host.data).forEach(key => {
if (host.data[key] !== null) {
formData.append(key, host.data[key] === '$file' ? file : host.data[key]);
}
});
createTool.status.innerHTML += ` Uploading ${file.name}`;
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'POST',
url: host.url,
data: formData,
responseType: host.responsePath ? 'json' : 'text',
headers: host.headers,
onload: async response => {
if (response.status < 200 || response.status >= 300) {
return reject(response);
}
const responseVal = host.responsePath
? _.get(response.response, host.responsePath)
: host.responseMatch
? (response.responseText.match(new RegExp(host.responseMatch)) || [])[1]
: response.responseText;
const uploadedUrl = host.soundUrl ? host.soundUrl.replace('%s', responseVal) : responseVal;
Player.$(`.${ns}-upload-status-${idx}`).innerHTML = `Uploaded ${file.name} to ${uploadedUrl}`;
resolve(uploadedUrl);
},
upload: {
onprogress: response => {
const total = response.total > 0 ? response.total : file.size;
Player.$(`.${ns}-upload-status-${idx}`).innerHTML = `Uploading ${file.name} - ${Math.floor(response.loaded / total * 100)}%`;
}
},
onerror: reject
});
});
},
/**
* Add the created sound image to the player.
*/
addCreatedToPlayer() {
Player.playlist.addFromFiles([ Player.tools._createdImage ]);
},
/**
* Open the QR window and add the created sound image to it.
*/
addCreatedToQR() {
if (!is4chan) {
return;
}
// Open the quick reply window.
const qrLink = document.querySelector(isChanX ? '.qr-link' : '.open-qr-link');
const dataTransfer = new DataTransfer();
dataTransfer.items.add(Player.tools._createdImage);
// 4chan X, drop the file on the qr window.
if (isChanX && qrLink) {
qrLink.click();
const event = new CustomEvent('drop', { view: window, bubbles: true, cancelable: true });
event.dataTransfer = dataTransfer;
document.querySelector('#qr').dispatchEvent(event);
// Native, set the file input value. Check for a quick reply
} else if (qrLink) {
qrLink.click();
document.querySelector('#qrFile').files = dataTransfer.files;
} else {
document.querySelector('#togglePostFormLink a').click();
document.querySelector('#postFile').files = dataTransfer.files;
document.querySelector('.postForm').scrollIntoView();
}
}
};
/***/ }),
/***/ "./src/components/tools/download.js":
/*!******************************************!*\
!*** ./src/components/tools/download.js ***!
\******************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
const progressBarsTemplate = __webpack_require__(/*! ./templates/download-progress.tpl */ "./src/components/tools/templates/download-progress.tpl");
const get = (src, opts) => {
let xhr;
// Wrap so aborting rejects.
let p = new Promise((resolve, reject) => {
xhr = GM.xmlHttpRequest({
method: 'GET',
url: src,
responseType: 'blob',
onload: response => resolve(response.response),
onerror: response => reject(response),
onabort: response => {
response.aborted = true;
reject(response);
},
...(opts || {})
});
});
if (opts && opts.catch) {
p = p.catch(opts.catch);
}
p.abort = xhr.abort;
return p;
};
/**
* This component is mixed into tools so these function are under `Player.tools`.
*/
const downloadTool = module.exports = {
downloadTemplate: __webpack_require__(/*! ./templates/download.tpl */ "./src/components/tools/templates/download.tpl"),
_downloading: null,
/**
* Update the view when the hosts are updated.
*/
initialize() {
Player.on('rendered', downloadTool.afterRender);
},
/**
* Store references to various elements.
*/
afterRender() {
downloadTool.resetDownloadButtons();
},
async _handleDownloadCancel() {
if (Player.tools._downloading) {
Player.tools._downloadAllCanceled = true;
Player.tools._downloading.forEach(dls => dls.forEach(dl => dl && dl.abort()));
}
},
async _handleDownload(e) {
Player.tools._downloadAllCanceled = false;
e.currentTarget.style.display = 'none';
Player.$(`.${ns}-download-all-cancel`).style.display = null;
await Player.tools.downloadThread({
includeImages: Player.$('.download-all-images').checked,
includeSounds: Player.$('.download-all-audio').checked,
ignoreDownloaded: Player.$('.download-all-ignore-downloaded').checked,
maxSounds: +Player.$('.download-all-max-sounds').value || 0,
concurrency: Math.max(1, +Player.$('.download-all-concurrency').value || 1),
compression: Math.max(0, Math.min(+Player.$('.download-all-compression').value || 0, 9)),
status: Player.$(`.${ns}-download-all-status`)
}).catch(() => { /* it's logged */ });
Player.tools.resetDownloadButtons();
},
resetDownloadButtons() {
Player.$(`.${ns}-download-all-start`).style.display = Player.tools._downloading ? 'none' : null;
Player.$(`.${ns}-download-all-cancel`).style.display = Player.tools._downloading ? null : 'none';
Player.$(`.${ns}-download-all-save`).style.display = Player.tools.threadDownloadBlob ? null : 'none';
Player.$(`.${ns}-download-all-clear`).style.display = Player.tools.threadDownloadBlob ? null : 'none';
Player.$(`.${ns}-ignore-downloaded`).style.display = Player.sounds.some(s => s.downloaded) ? null : 'none';
},
/**
* Trigger a download for a file using GM.xmlHttpRequest to avoid cors issues.
*
* @param {String} src URL of the field to download.
* @param {String} name Name to save the file as.
*/
async download(src, name) {
try {
const blob = await get(src);
const a = _.element(``);
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
Player.logError('There was an error downloading.', err, 'warning');
}
},
/**
* Download the images and/or sounds in the thread as zip file.
*
* @param {Boolean} includeImages Whether images should be included in the download.
* @param {Boolean} includeSounds Whether audio files should be included in the download.
* @param {Boolean} ignoreDownloaded Whether sounds previously downloaded should be omitted from the download.
* @param {Boolean} maxSounds The maximum number of sounds to download.
* @param {Boolean} concurrency How many sounds can be download at the same time.
* @param {Boolean} compression Compression level.
* @param {Element} [status] Element in which to display the ongoing status of the download.
*/
async downloadThread({ includeImages, includeSounds, ignoreDownloaded, maxSounds, concurrency, compression, status }) {
const zip = new JSZip();
!(maxSounds > 0) && (maxSounds = Infinity);
const toDownload = Player.sounds.filter(s => s.post && (!ignoreDownloaded || !s.downloaded)).slice(0, maxSounds);
const count = toDownload.length;
status && (status.style.display = 'block');
if (!count || !includeImages && !includeSounds) {
return status && (status.innerHTML = 'Nothing to download.');
}
Player.tools._downloading = [];
status && (status.innerHTML = `Downloading ${count} sound images.
This may take a while. You can leave it running in the background, but if you background the tab your browser will slow it down.
You'll be prompted to download the zip file once complete.
`);
const elementsArr = new Array(concurrency).fill(0).map(() => {
// Show currently downloading files with progress bars.
const el = status && _.element(progressBarsTemplate({ includeSounds, includeImages }), status);
const dlRef = [];
Player.tools._downloading.push(dlRef);
// Allow each download to be canceled individually. In case there's a huge download you don't want to include.
el && (el.querySelector(`.${ns}-cancel-download`).onclick = () => dlRef.forEach(dl => dl && dl.abort()));
return {
dlRef,
el,
status: el && el.querySelector(`.${ns}-current-status`),
image: el && el.querySelector(`.${ns}-image-bar`),
sound: el && el.querySelector(`.${ns}-sound-bar`)
};
});
let running = 0;
// Download arg builder. Update progress bars, and catch errors to log and continue.
const getArgs = (data, sound, type) => ({
responseType: 'arraybuffer',
onprogress: data[type] && (rsp => data[type].style.width = ((rsp.loaded / rsp.total) * 100) + '%'),
catch: err => {
if (err.aborted) {
return 'aborted';
}
if (!err.aborted && !Player.tools._downloadAllCanceled) {
console.error('[4chan sounds player] Download failed', err);
status && _.element(`
Failed to download ${sound.title} ${type}!
`, elementsArr[0].el, 'beforebegin');
}
}
});
await Promise.all(elementsArr.map(async function downloadNext(data) {
const sound = toDownload.shift();
// Fall out if all downlads were canceled.
if (!sound || Player.tools._downloadAllCanceled) {
data.el && status.removeChild(data.el);
return;
}
const i = ++running;
// Show the name and reset the progress bars.
if (data.el) {
data.status.textContent = `${i} / ${count}: ${sound.title}`;
data.image.style.width = data.sound.style.width = '0';
}
// Create a folder per post if images and sounds are being downloaded.
const prefix = includeImages && includeSounds ? sound.post + '/' : '';
// Download image and sound as selected.
const [ imageRsp, soundRsp ] = await Promise.all([
data.dlRef[0] = includeImages && get(sound.image, getArgs(data, sound, 'image')),
data.dlRef[1] = includeSounds && get(sound.src, getArgs(data, sound, 'sound'))
]);
// No post-handling if the whole download was canceled.
if (!Player.tools._downloadAllCanceled) {
if (imageRsp === 'aborted' || soundRsp === 'aborted') {
// Show which sounds were individually aborted.
status && _.element(`
Skipped ${sound.title}.
`, elementsArr[0].el, 'beforebegin');
} else {
// Add the downloaded files to the zip.
imageRsp && zip.file(`${prefix}${sound.filename}`, imageRsp);
soundRsp && zip.file(`${prefix}${encodeURIComponent(sound.src)}`, soundRsp);
// Flag the sound as downloaded.
sound.downloaded = true;
}
}
// Move on to the next sound.
await downloadNext(data);
}));
// Show where we canceled at, if we did cancel.
Player.tools._downloadAllCanceled && _.element(`Canceled at ${running} / ${count}.`, status);
// Generate the zip file.
const zipProgress = status && _.element('
Generating zip file...
', status);
try {
const zipOptions = {
type: 'blob',
compression: compression ? 'DEFLATE' : 'STORE',
compressionOptions: {
level: compression
}
};
Player.tools.threadDownloadBlob = await zip.generateAsync(zipOptions, metadata => {
status && (zipProgress.textContent = `Generating zip file (${metadata.percent.toFixed(2)}%)...`);
});
// Update the display and prompt to download.
status && _.element('Complete!', status);
Player.tools.saveThreadDownload();
} catch (err) {
console.error('[4chan sounds player] Failed to generate zip', err);
status && (zipProgress.textContent = 'Failed to generate zip file!');
}
Player.tools._downloading = null;
Player.tools.resetDownloadButtons();
},
saveThreadDownload() {
const threadNum = Thread || '-';
const a = _.element(``);
a.click();
URL.revokeObjectURL(a.href);
},
clearDownloadBlob() {
delete Player.tools.threadDownloadBlob;
Player.tools.resetDownloadButtons();
}
};
/***/ }),
/***/ "./src/components/tools/index.js":
/*!***************************************!*\
!*** ./src/components/tools/index.js ***!
\***************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
const createTool = __webpack_require__(/*! ./create */ "./src/components/tools/create.js");
const downloadTool = __webpack_require__(/*! ./download */ "./src/components/tools/download.js");
module.exports = {
template: __webpack_require__(/*! ./templates/tools.tpl */ "./src/components/tools/templates/tools.tpl"),
...createTool,
...downloadTool,
initialize() {
createTool.initialize();
downloadTool.initialize();
},
render() {
_.elementHTML(Player.$(`.${ns}-tools`).innerHTML, Player.tools.template());
createTool.afterRender();
downloadTool.afterRender();
},
toggle() {
if (Player.config.viewStyle === 'tools') {
Player.playlist.restore();
} else {
Player.display.setViewStyle('tools');
}
},
/**
* Encode the decoded input.
*/
handleDecoded(e) {
Player.$(`.${ns}-encoded-input`).value = encodeURIComponent(e.currentTarget.value);
},
/**
* Decode the encoded input.
*/
handleEncoded(e) {
Player.$(`.${ns}-decoded-input`).value = decodeURIComponent(e.currentTarget.value);
}
};
/***/ }),
/***/ "./src/components/user-template/buttons.js":
/*!*************************************************!*\
!*** ./src/components/user-template/buttons.js ***!
\*************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var Icons = __webpack_require__(/*! ./src/icons */ "./src/icons.js");
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
const { postIdPrefix } = __webpack_require__(/*! ../../selectors */ "./src/selectors.js");
module.exports = [
{
property: 'repeat',
tplName: 'repeat',
action: 'playlist.toggleRepeat:prevent',
values: {
all: { attrs: [ 'title="Repeat All"' ], icon: Icons.arrowRepeat },
one: { attrs: [ 'title="Repeat One"' ], icon: Icons.arrowClockwise },
none: { attrs: [ 'title="No Repeat"' ], class: 'muted', icon: Icons.arrowRepeat }
}
},
{
property: 'shuffle',
tplName: 'shuffle',
action: 'playlist.toggleShuffle:prevent',
values: {
true: { attrs: [ 'title="Shuffled"' ], icon: Icons.shuffle },
false: { attrs: [ 'title="Ordered"' ], class: 'muted', icon: Icons.shuffle }
}
},
{
property: 'viewStyle',
tplName: 'playlist',
action: 'playlist.toggleView',
values: {
default: { attrs: [ 'title="Player"' ], class: 'muted', icon: () => (Player.playlist._lastView === 'playlist' ? Icons.arrowsExpand : Icons.arrowsCollapse) },
playlist: { attrs: [ 'title="Hide Playlist"' ], icon: Icons.arrowsExpand },
image: { attrs: [ 'title="Show Playlist"' ], icon: Icons.arrowsCollapse }
}
},
{
property: 'hoverImages',
tplName: 'hover-images',
action: 'playlist.toggleHoverImages',
values: {
true: { attrs: [ 'title="Hover Images Enabled"' ], icon: Icons.image },
false: { attrs: [ 'title="Hover Images Disabled"' ], class: 'muted', icon: Icons.image }
}
},
{
tplName: 'add',
action: 'playlist.selectLocalFiles:prevent',
icon: Icons.plus,
attrs: [ 'title="Add local files"' ]
},
{
tplName: 'reload',
action: 'playlist.refresh:prevent',
icon: Icons.reboot,
attrs: [ 'title="Reload the playlist"' ]
},
{
property: 'viewStyle',
tplName: 'settings',
action: 'settings.toggle():prevent',
icon: Icons.gear,
attrs: [ 'title="Settings"' ],
values: {
default: { class: 'muted' },
settings: { }
}
},
{
property: 'viewStyle',
tplName: 'threads',
action: 'threads.toggle:prevent',
icon: Icons.search,
attrs: [ 'title="Threads"' ],
values: {
default: { class: 'muted' },
threads: { }
}
},
{
property: 'viewStyle',
tplName: 'tools',
action: 'tools.toggle:prevent',
icon: Icons.tools,
attrs: [ 'title="Tools"' ],
values: {
default: { class: 'muted' },
tools: { }
}
},
{
tplName: 'close',
action: 'hide:prevent',
icon: Icons.close,
attrs: [ 'title="Hide the player"' ]
},
{
tplName: 'playing',
requireSound: true,
action: 'playlist.scrollToPlaying("center"):prevent',
icon: Icons.musicNoteList,
attrs: [ 'title="Scroll the playlist currently playing sound."' ]
},
{
tplName: 'post',
requireSound: true,
icon: Icons.chatRightQuote,
showIf: data => data.sound.post,
attrs: data => [
`href=${'#' + postIdPrefix + data.sound.post}`,
'title="Jump to the post for the current sound"'
]
},
{
tplName: 'image',
requireSound: true,
icon: Icons.image,
attrs: data => [
`href=${data.sound.image}`,
'title="Open the image in a new tab"',
'target="_blank"'
]
},
{
tplName: 'sound',
requireSound: true,
icon: Icons.soundwave,
attrs: data => [
`href=${data.sound.src}`,
'title="Open the sound in a new tab"',
'target="_blank"'
]
},
{
tplName: /dl-(image|sound)/,
requireSound: true,
action: data => {
const src = data.sound[data.tplNameMatch[1] === 'image' ? 'image' : 'src'];
const name = data.sound[data.tplNameMatch[1] === 'image' ? 'filename' : 'name'] || '';
return `tools.download("${_.escAttr(src, true)}", "${_.escAttr(name, true)}"):prevent`;
},
icon: data => data.tplNameMatch[1] === 'image'
? Icons.fileEarmarkImage
: Icons.fileEarmarkMusic,
attrs: data => [
`title="${data.tplNameMatch[1] === 'image' ? 'Download the image with the original filename' : 'Download the sound'}"`
]
},
{
tplName: /filter-(image|sound)/,
requireSound: true,
action: data => `playlist.addFilter("${data.tplNameMatch[1] === 'image' ? data.sound.imageMD5 : data.sound.src.replace(/^(https?:)?\/\//, '')}"):prevent`,
icon: Icons.filter,
showIf: data => data.tplNameMatch[1] === 'sound' || data.sound.imageMD5,
attrs: data => [
`title="Add the ${data.tplNameMatch[1] === 'image' ? 'image MD5' : 'sound URL'} to the filters."`,
]
},
{
tplName: 'remove',
requireSound: true,
action: data => `remove("${data.sound.id}")`,
icon: Icons.trash,
attrs: data => [
'title="Filter the image."',
`data-id="${data.sound.id}"`
]
},
{
tplName: 'menu',
requireSound: true,
class: `${ns}-item-menu-button`,
action: data => `playlist.handleItemMenu("evt", "${data.sound.id}"):prevent:stop`,
icon: Icons.chevronDown
},
{
tplName: 'view-menu',
action: 'display.showMenu("evt.currentTarget", "views"):prevent:stop',
icon: Icons.chevronDown,
attrs: [ 'title="Switch View"' ]
},
{
tplName: 'theme-menu',
action: 'display.showMenu("evt.currentTarget", "themes"):prevent:stop',
icon: Icons.layoutTextWindow,
attrs: [ 'title="Switch Theme"' ]
},
{
tplName: 'untz',
action: 'display.untz',
icon: Icons.speaker,
attrs: [ 'title="UNTZ"' ]
}
];
/***/ }),
/***/ "./src/components/user-template/index.js":
/*!***********************************************!*\
!*** ./src/components/user-template/index.js ***!
\***********************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
/* provided dependency */ var _ = __webpack_require__(/*! ./src/_ */ "./src/_.js");
const buttons = __webpack_require__(/*! ./buttons */ "./src/components/user-template/buttons.js");
// Regex for replacements
const playingRE = /p: ?{([^}]*)}/g;
const hoverRE = /h: ?{([^}]*)}/g;
// Create a regex to find buttons/links, ignore matches if the button/link name is itself a regex.
const tplNames = buttons.map(conf => `${conf.tplName.source && conf.tplName.source.replace(/\(/g, '(?:') || conf.tplName}`);
const buttonRE = new RegExp(`(${tplNames.join('|')})-(?:button|link)(?:\\:"([^"]+?)")?`, 'g');
const soundTitleRE = /sound-title/g;
const soundTitleMarqueeRE = /sound-title-marquee/g;
const soundIndexRE = /sound-index/g;
const soundCountRE = /sound-count/g;
const soundPropRE = /sound-(src|id|name|post|imageOrThumb|image|thumb|filename|imageMD5)/g;
const configRE = /\$config\[([^\]]+)\]/g;
// Hold information on which config values components templates depend on.
const componentDeps = [ ];
module.exports = {
buttons,
initialize: function () {
Player.on('config', Player.userTemplate._handleConfig);
Player.on('playsound', () => Player.userTemplate._handleEvent('playsound'));
[ 'add', 'remove', 'order', 'show', 'hide', 'stop' ].forEach(evt => {
Player.on(evt, Player.userTemplate._handleEvent.bind(null, evt));
});
},
/**
* Build a user template.
*/
build: function (data) {
const outerClass = data.outerClass || '';
const name = data.sound && data.sound.title || data.defaultName;
let _data = { ...data };
const _confFuncOrText = v => (typeof v === 'function' ? v(_data) : v);
// Apply common template replacements, unless they are opted out.
let html = data.template.replace(configRE, (...args) => _.get(Player.config, args[1]));
!data.ignoreDisplayBlocks && (html = html
.replace(playingRE, Player.playing && Player.playing === data.sound ? '$1' : '')
.replace(hoverRE, `$1`));
!data.ignoreButtons && (html = html.replace(buttonRE, function (full, type, text) {
let buttonConf = Player.userTemplate._findButtonConf(type);
_data.tplNameMatch = buttonConf.tplNameMatch;
if (buttonConf.requireSound && !data.sound || buttonConf.showIf && !buttonConf.showIf(_data)) {
return '';
}
// If the button config has sub values then extend the base config with the selected sub value.
// Which value to use is taken from the `property` in the base config of the player config.
// This gives us different state displays.
if (buttonConf.values) {
let topConf = buttonConf;
const valConf = buttonConf.values[_.get(Player.config, buttonConf.property)] || buttonConf.values[Object.keys(buttonConf.values)[0]];
buttonConf = { ...topConf, ...valConf };
}
const attrs = [ ...(_confFuncOrText(buttonConf.attrs) || []) ];
attrs.some(attr => attr.startsWith('href')) || attrs.push('href="javascript:;"');
(buttonConf.class || outerClass) && attrs.push(`class="${buttonConf.class || ''} ${outerClass || ''}"`);
buttonConf.action && attrs.push(`@click='${_confFuncOrText(buttonConf.action)}'`);
// Replace spaces with non breaking spaces in user text to prevent collapsing.
return `${text && text.replace(/ /g, 'Â ') || _confFuncOrText(buttonConf.icon) || _confFuncOrText(buttonConf.text)}`;
}));
!data.ignoreSoundName && (html = html
.replace(soundTitleMarqueeRE, name ? `
${name}
` : '')
.replace(soundTitleRE, name ? `
${name}
` : ''));
!data.ignoreSoundProperties && (html = html
.replace(soundPropRE, (...args) => data.sound ? data.sound[args[1]] : '')
.replace(soundIndexRE, data.sound ? Player.sounds.indexOf(data.sound) + 1 : 0)
.replace(soundCountRE, Player.sounds.length));
!data.ignoreVersion && (html = html.replace(/%v/g, "3.4.5"));
// Apply any specific replacements
if (data.replacements) {
for (let k of Object.keys(data.replacements)) {
html = html.replace(new RegExp(k, 'g'), data.replacements[k]);
}
}
return html;
},
/**
* Sets up a components to render when the template or values within it are changed.
*/
maintain: function (component, property, alwaysRenderConfigs = [], alwaysRenderEvents = []) {
componentDeps.push({
component,
property,
...Player.userTemplate.findDependencies(property, null),
alwaysRenderConfigs,
alwaysRenderEvents
});
},
/**
* Find all the config dependent values in a template.
*/
findDependencies: function (property, template) {
template || (template = _.get(Player.config, property));
// Figure out what events should trigger a render.
const events = [];
// add/remove should render templates showing the count.
// playsound/stop should render templates either showing properties of the playing sound or dependent on something playing.
// order should render templates showing a sounds index.
const hasCount = soundCountRE.test(template);
const hasSoundProp = soundTitleRE.test(template) || soundPropRE.test(template);
const hasIndex = soundIndexRE.test(template);
const hasPlaying = playingRE.test(template);
hasCount && events.push('add', 'remove');
// The row template handles this itself to avoid a full playlist render.
property !== 'rowTemplate' && (hasSoundProp || hasIndex || hasPlaying) && events.push('playsound', 'stop');
hasIndex && events.push('order');
// Find which buttons the template includes that are dependent on config values.
const config = [];
let match;
while ((match = buttonRE.exec(template)) !== null) {
// If user text is given then the display doesn't change.
if (!match[2]) {
let buttonConf = Player.userTemplate._findButtonConf(match[1]);
if (buttonConf.property) {
config.push(buttonConf.property);
}
}
}
// Find config references.
while ((match = configRE.exec(template)) !== null) {
config.push(match[1]);
}
return { events, config };
},
/**
* When a config value is changed check if any component dependencies are affected.
*/
_handleConfig: function (property, value) {
// Check if a template for a components was updated.
componentDeps.forEach(depInfo => {
if (depInfo.property === property) {
Object.assign(depInfo, Player.userTemplate.findDependencies(property, value));
depInfo.component.render();
}
});
// Check if any components are dependent on the updated property.
componentDeps.forEach(depInfo => {
if (depInfo.alwaysRenderConfigs.includes(property) || depInfo.config.includes(property)) {
depInfo.component.render();
}
});
},
/**
* When a player event is triggered check if any component dependencies are affected.
*/
_handleEvent: function (type) {
// Check if any components are dependent on the updated property.
componentDeps.forEach(depInfo => {
if (depInfo.alwaysRenderEvents.includes(type) || depInfo.events.includes(type)) {
depInfo.component.render();
}
});
},
_findButtonConf: type => {
let tplNameMatch;
let buttonConf = buttons.find(conf => {
if (conf.tplName === type) {
return tplNameMatch = [ type ];
}
return tplNameMatch = conf.tplName.test && type.match(conf.tplName);
});
return buttonConf && { ...buttonConf, tplNameMatch };
}
};
/***/ }),
/***/ "./src/config/display.js":
/*!*******************************!*\
!*** ./src/config/display.js ***!
\*******************************/
/***/ ((module) => {
module.exports = [
{
property: 'autoshow',
default: true,
title: 'Autoshow',
description: 'Automatically show the player when the thread contains sounds.',
displayGroup: 'Display'
},
{
property: 'pauseOnHide',
default: true,
title: 'Pause On Hide',
description: 'Pause the player when it\'s hidden.',
displayGroup: 'Display',
allowInTheme: true
},
{
property: 'showUpdatedNotification',
default: true,
title: 'Show Update Notifications',
description: 'Show notifications when the player is successfully updated.',
displayGroup: 'Display'
},
{
property: 'hoverImages',
title: 'Hover Images',
default: false,
allowInTheme: true
},
{
title: 'Controls',
displayGroup: 'Display',
allowInTheme: true,
settings: [
{
property: 'preventControlWrapping',
title: 'Prevent Wrapping',
description: 'Hide controls to prevent wrapping when the player is too small',
default: true
},
{
property: 'controlsHideOrder',
title: 'Hide Order',
description: 'Order controls are hidden in to prevent wrapping. '
+ 'Available controls are '
+ '
previous
'
+ '
next
'
+ '
seek-bar
'
+ '
time
'
+ '
duration
'
+ '
volume
'
+ '
volume-button
'
+ '
volume-bar
'
+ 'and
fullscreen
.',
default: [ 'fullscreen', 'duration', 'volume-bar', 'seek-bar', 'time', 'previous' ],
displayMethod: 'textarea',
inlineTextarea: true,
format: v => v.join('\n'),
parse: v => v.split(/\s+/)
}
]
},
{
title: 'Minimised Display',
description: 'Optional displays for when the player is minimised.',
displayGroup: 'Display',
allowInTheme: true,
settings: [
{
property: 'pip',
title: 'Thumbnail',
description: 'Display a fixed thumbnail of the playing sound in the bottom right of the thread.',
default: true
},
{
property: 'maxPIPWidth',
title: 'Max Width',
description: 'Maximum width for the thumbnail.',
default: '150px',
updateStylesheet: true
},
{
property: 'chanXControls',
title: '4chan X Header Controls',
description: 'Show playback controls in the 4chan X header. The display can be customised in Settings>Theme.',
displayMethod: isChanX || null,
default: 'closed',
options: {
always: 'Always',
closed: 'Only with the player closed',
never: 'Never'
}
}
]
},
{
title: 'Thread',
displayGroup: 'Display',
allowInTheme: true,
settings: [
{
property: 'autoScrollThread',
description: 'Automatically scroll the thread to posts as sounds play.',
title: 'Auto Scroll',
default: false
},
{
property: 'limitPostWidths',
description: 'Limit the width of posts so they aren\'t hidden under the player.',
title: 'Limit Post Widths',
default: true
},
{
property: 'minPostWidth',
title: 'Minimum Width',
default: '50%'
}
]
},
{
property: 'threadsViewStyle',
title: 'Threads View',
description: 'How threads in the threads view are listed.',
settings: [ {
title: 'Display',
default: 'table',
options: {
table: 'Table',
board: 'Board'
}
} ]
},
{
title: 'Colors',
displayGroup: 'Display',
property: 'colors',
updateStylesheet: true,
allowInTheme: true,
class: `${ns}-colorpicker-input`,
attrs: '@focusout="colorpicker._updatePreview" @click="colorpicker.create:prevent:stop"',
displayMethod: ({ value, attrs }) => `
Select an image and sound to combine as a sound image.
The sound will be uploaded to the selected file host and the url will be added to the image filename.
Multiple sound files, or a comma-separated list of sound URLs, can be given for a single image.
If you do have multiple sounds the name will also be a considered comma-separated list. Dismiss