// ==UserScript== // @name 4chan sounds player // @version 3.1.0 // @namespace rccom // @description A player designed for 4chan sounds threads. // @author RCC // @match *://boards.4chan.org/* // @match *://boards.4channel.org/* // @match *://desuarchive.org/* // @match *://arch.b4k.co/* // @match *://archived.moe/* // @grant GM.getValue // @grant GM.setValue // @grant GM.xmlHttpRequest // @grant GM_addValueChangeListener // @connect 4chan.org // @connect 4channel.org // @connect a.4cdn.org // @connect desu-usergeneratedcontent.xyz // @connect arch-img.b4k.co // @connect 4cdn.org // @connect a.pomf.cat // @connect pomf.cat // @connect files.catbox.moe // @connect catbox.moe // @connect share.dmca.gripe // @connect z.zz.ht // @connect zz.ht // @connect too.lewd.se // @connect lewd.se // @connect * // @run-at document-start // @downloadURL none // ==/UserScript== /******/ (function(modules) { // webpackBootstrap /******/ // The module cache /******/ var installedModules = {}; /******/ /******/ // The require function /******/ function __webpack_require__(moduleId) { /******/ /******/ // Check if module is in cache /******/ if(installedModules[moduleId]) { /******/ return installedModules[moduleId].exports; /******/ } /******/ // Create a new module (and put it into the cache) /******/ var module = installedModules[moduleId] = { /******/ i: moduleId, /******/ l: false, /******/ exports: {} /******/ }; /******/ /******/ // Execute the module function /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); /******/ /******/ // Flag the module as loaded /******/ module.l = true; /******/ /******/ // Return the exports of the module /******/ return module.exports; /******/ } /******/ /******/ /******/ // expose the modules object (__webpack_modules__) /******/ __webpack_require__.m = modules; /******/ /******/ // expose the module cache /******/ __webpack_require__.c = installedModules; /******/ /******/ // define getter function for harmony exports /******/ __webpack_require__.d = function(exports, name, getter) { /******/ if(!__webpack_require__.o(exports, name)) { /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); /******/ } /******/ }; /******/ /******/ // define __esModule on exports /******/ __webpack_require__.r = function(exports) { /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); /******/ } /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ /******/ // create a fake namespace object /******/ // mode & 1: value is a module id, require it /******/ // mode & 2: merge all properties of value into the ns /******/ // mode & 4: return value when already ns object /******/ // mode & 8|1: behave like require /******/ __webpack_require__.t = function(value, mode) { /******/ if(mode & 1) value = __webpack_require__(value); /******/ if(mode & 8) return value; /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; /******/ var ns = Object.create(null); /******/ __webpack_require__.r(ns); /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); /******/ return ns; /******/ }; /******/ /******/ // getDefaultExport function for compatibility with non-harmony modules /******/ __webpack_require__.n = function(module) { /******/ var getter = module && module.__esModule ? /******/ function getDefault() { return module['default']; } : /******/ function getModuleExports() { return module; }; /******/ __webpack_require__.d(getter, 'a', getter); /******/ return getter; /******/ }; /******/ /******/ // Object.prototype.hasOwnProperty.call /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; /******/ /******/ // __webpack_public_path__ /******/ __webpack_require__.p = ""; /******/ /******/ /******/ // Load entry module and return exports /******/ return __webpack_require__(__webpack_require__.s = "./src/main.js"); /******/ }) /************************************************************************/ /******/ ({ /***/ "./src/api.js": /*!********************!*\ !*** ./src/api.js ***! \********************/ /*! no static exports found */ /***/ (function(module, exports) { const cache = {}; module.exports = { get }; async function get(url) { return new Promise(function (resolve, reject) { const headers = {}; if (cache[url]) { headers['If-Modified-Since'] = cache[url].lastModified; } GM.xmlHttpRequest({ method: 'GET', url, headers, responseType: 'json', onload: response => { if (response.status >= 200 && response.status < 300) { cache[url] = { lastModified: response.responseHeaders['last-modified'], response: response.response }; } resolve(response.status === 304 ? cache[url].response : response.response); }, onerror: reject }); }); } /***/ }), /***/ "./src/components/controls.js": /*!************************************!*\ !*** ./src/components/controls.js ***! \************************************/ /*! no static exports found */ /***/ (function(module, exports) { const progressBarStyleSheets = {}; module.exports = { atRoot: [ 'togglePlay', 'play', 'pause', 'next', 'previous' ], delegatedEvents: { click: { [`.${ns}-previous-button`]: () => Player.previous(), [`.${ns}-play-button`]: 'togglePlay', [`.${ns}-next-button`]: () => Player.next(), [`.${ns}-seek-bar`]: 'controls.handleSeek', [`.${ns}-volume-bar`]: 'controls.handleVolume', [`.${ns}-fullscreen-button`]: 'display.toggleFullScreen' }, mousedown: { [`.${ns}-seek-bar`]: () => Player._seekBarDown = true, [`.${ns}-volume-bar`]: () => Player._volumeBarDown = true }, mousemove: { [`.${ns}-seek-bar`]: e => Player._seekBarDown && Player.controls.handleSeek(e), [`.${ns}-volume-bar`]: e => Player._volumeBarDown && Player.controls.handleVolume(e) } }, undelegatedEvents: { mouseleave: { [`.${ns}-seek-bar`]: e => Player._seekBarDown && Player.controls.handleSeek(e), [`.${ns}-volume-bar`]: e => Player._volumeBarDown && Player.controls.handleVolume(e) }, mouseup: { body: () => { Player._seekBarDown = false; Player._volumeBarDown = false; } }, play: { [`.${ns}-video`]: 'controls.syncVideo' }, pause: { [`.${ns}-video`]: 'controls.syncVideo' } }, audioEvents: { ended: () => Player.next(), pause: 'controls.handleAudioEvent', play: 'controls.handleAudioEvent', seeked: 'controls.handleAudioEvent', waiting: 'controls.handleAudioEvent', timeupdate: 'controls.updateDuration', loadedmetadata: 'controls.updateDuration', durationchange: 'controls.updateDuration', volumechange: 'controls.updateVolume', loadstart: 'controls.pollForLoading' }, initialize: function () { Player.on('show', () => Player._hiddenWhilePolling && Player.controls.pollForLoading()); Player.on('hide', () => { Player._hiddenWhilePolling = !!Player._loadingPoll; Player.controls.stopPollingForLoading(); }); Player.on('rendered', () => { // Keep track of heavily updated elements. Player.ui.currentTimeBar = Player.$(`.${ns}-seek-bar .${ns}-current-bar`); Player.ui.loadedBar = Player.$(`.${ns}-seek-bar .${ns}-loaded-bar`); // Add stylesheets to adjust the progress indicator of the seekbar and volume bar. document.head.appendChild(progressBarStyleSheets[`.${ns}-seek-bar`] = document.createElement('style')); document.head.appendChild(progressBarStyleSheets[`.${ns}-volume-bar`] = document.createElement('style')); Player.controls.updateDuration(); Player.controls.updateVolume(); }); }, /** * Switching being playing and paused. */ togglePlay: function () { if (Player.audio.paused) { Player.play(); } else { Player.pause(); } }, /** * Start playback. */ play: async function (sound, { paused } = {}) { try { // If nothing is currently selected to play start playing the first sound. if (!sound && !Player.playing && Player.sounds.length) { sound = Player.sounds[0]; } const video = document.querySelector(`.${ns}-video`); video.removeEventListener('loadeddata', Player.controls.playOnceLoaded); // If a new sound is being played update the display. if (sound) { if (Player.playing) { Player.playing.playing = false; } sound.playing = true; Player.playing = sound; Player.audio.src = sound.src; await Player.trigger('playsound', sound); } if (!paused) { // If there's a video wait for it and the sound to load before playing. if (Player.playlist.isVideo && (video.readyState < 3 || Player.audio.readyState < 3)) { video.addEventListener('loadeddata', Player.controls._playOnceLoaded); Player.audio.addEventListener('loadeddata', Player.controls._playOnceLoaded); } else { Player.audio.play(); } } } catch (err) { Player.logError('There was an error playing the sound. Please check the console for details.', err); } }, /** * Handler to start playback once the video and audio are both loaded. */ _playOnceLoaded: function () { const video = document.querySelector(`.${ns}-video`); if (video.readyState > 2 && Player.audio.readyState > 2) { video.removeEventListener('loadeddata', Player.controls._playOnceLoaded); Player.audio.removeEventListener('loadeddata', Player.controls._playOnceLoaded); Player.audio.play(); } }, /** * Pause playback. */ pause: function () { Player.audio && Player.audio.pause(); }, /** * Play the next sound. */ next: function (opts) { Player.controls._movePlaying(1, opts); }, /** * Play the previous sound. */ previous: function (opts) { Player.controls._movePlaying(-1, opts); }, _movePlaying: function (direction, { force, group, paused } = {}) { if (!Player.audio) { return; } // If there's no sound fall out. if (!Player.sounds.length) { return; } // If there's no sound currently playing or it's not in the list then just play the first sound. const currentIndex = Player.sounds.indexOf(Player.playing); if (currentIndex === -1) { return Player.play(Player.sounds[0]); } // Get the next index, either repeating the same, wrapping round to repeat all or just moving the index. let nextSound; if (!force && Player.config.repeat === 'one') { nextSound = Player.sounds[currentIndex]; } else { let newIndex = currentIndex; // Get the next index wrapping round if repeat all is selected // Keep going if it's group move, there's still more sounds to check, and the next sound is still in the same group. do { newIndex = Player.config.repeat === 'all' ? ((newIndex + direction) + Player.sounds.length) % Player.sounds.length : newIndex + direction; nextSound = Player.sounds[newIndex]; } while (group && nextSound && newIndex !== currentIndex && (!nextSound.post || nextSound.post === Player.playing.post)); } nextSound && Player.play(nextSound, { paused }); }, /** * Handle audio events. Sync the video up, and update the controls. */ handleAudioEvent: function () { Player.controls.syncVideo(); Player.controls.updateDuration(); document.querySelectorAll(`.${ns}-play-button .${ns}-play-button-display`).forEach(el => { el.classList[Player.audio.paused ? 'add' : 'remove'](`${ns}-play`); }); }, /** * Sync the webm to the audio. Matches the videos time and play state to the audios. */ syncVideo: function () { if (Player.playlist.isVideo) { const paused = Player.audio.paused; const video = document.querySelector(`.${ns}-video`); if (video) { if (Player.audio.currentTime < video.duration) { video.currentTime = Player.audio.currentTime; } if (paused) { video.pause(); } else { video.play(); } } } }, /** * Poll for how much has loaded. I know there's the progress event but it unreliable. */ pollForLoading: function () { Player._loadingPoll = Player._loadingPoll || setInterval(Player.controls.updateLoaded, 1000); }, /** * Stop polling for how much has loaded. */ stopPollingForLoading: function () { Player._loadingPoll && clearInterval(Player._loadingPoll); Player._loadingPoll = null; }, /** * Update the loading bar. */ updateLoaded: function () { const length = Player.audio.buffered.length; const size = length > 0 ? (Player.audio.buffered.end(length - 1) / Player.audio.duration) * 100 : 0; // If it's fully loaded then stop polling. size === 100 && Player.controls.stopPollingForLoading(); Player.ui.loadedBar.style.width = size + '%'; }, /** * Update the seek bar and the duration labels. */ updateDuration: function () { if (!Player.container) { return; } const currentTime = toDuration(Player.audio.currentTime); const duration = toDuration(Player.audio.duration); document.querySelectorAll(`.${ns}-current-time`).forEach(el => el.innerHTML = currentTime); document.querySelectorAll(`.${ns}-duration`).forEach(el => el.innerHTML = duration); Player.controls.updateProgressBarPosition(`.${ns}-seek-bar`, Player.ui.currentTimeBar, Player.audio.currentTime, Player.audio.duration); }, /** * Update the volume bar. */ updateVolume: function () { Player.controls.updateProgressBarPosition(`.${ns}-volume-bar`, Player.$(`.${ns}-volume-bar .${ns}-current-bar`), Player.audio.volume, 1); }, /** * Update a progress bar width. Adjust the margin of the circle so it's contained within the bar at both ends. */ updateProgressBarPosition: function (id, bar, current, total) { current || (current = 0); total || (total = 0); const ratio = !total ? 0 : Math.max(0, Math.min(((current || 0) / total), 1)); bar.style.width = (ratio * 100) + '%'; if (progressBarStyleSheets[id]) { progressBarStyleSheets[id].innerHTML = `${id} .${ns}-current-bar:after { margin-right: ${-0.8 * (1 - ratio)}rem; }`; } }, /** * Handle the user interacting with the seek bar. */ handleSeek: function (e) { e.preventDefault(); if (Player.container && Player.audio.duration && Player.audio.duration !== Infinity) { const ratio = e.offsetX / parseInt(document.defaultView.getComputedStyle(e.eventTarget || e.target).width, 10); Player.audio.currentTime = Player.audio.duration * ratio; } }, /** * Handle the user interacting with the volume bar. */ handleVolume: function (e) { e.preventDefault(); if (!Player.container) { return; } const ratio = e.offsetX / parseInt(document.defaultView.getComputedStyle(e.eventTarget || e.target).width, 10); Player.audio.volume = Math.max(0, Math.min(ratio, 1)); Player.controls.updateVolume(); } }; /***/ }), /***/ "./src/components/display.js": /*!***********************************!*\ !*** ./src/components/display.js ***! \***********************************/ /*! no static exports found */ /***/ (function(module, exports) { const dismissedContentCache = {}; const dismissedRestoreCache = {}; module.exports = { atRoot: [ 'show', 'hide' ], delegatedEvents: { click: { [`.${ns}-close-button`]: 'hide', [`.${ns}-dismiss-link`]: 'display._handleDismiss', [`.${ns}-restore-link`]: 'display._handleRestore' }, fullscreenchange: { [`.${ns}-media`]: 'display._handleFullScreenChange' }, drop: { [`#${ns}-container`]: 'display._handleDrop' } }, initialize: async function () { try { Player.display.dismissed = (await GM.getValue('dismissed')).split(','); } catch (err) { Player.display.dismissed = []; } }, /** * Create the player show/hide button in to the 4chan X header. */ initChanX: function () { if (Player.display._initedChanX) { return; } const shortcuts = document.getElementById('shortcuts'); if (!shortcuts) { return; } Player.display._initedChanX = true; const showIcon = createElement(` Sounds `); shortcuts.insertBefore(showIcon, document.getElementById('shortcut-settings')); showIcon.querySelector('a').addEventListener('click', Player.display.toggle); }, /** * 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 isThread = document.body.classList.contains('is_thread'); const parent = isThread && !isChanX && document.body.querySelector('.board') || document.body; Player.container = createElement(Player.templates.body(), parent); 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. Player.stylesheet = Player.stylesheet || createElement('', document.head); Player.stylesheet.innerHTML = Player.templates.css(); }, /** * Change what view is being shown */ setViewStyle: function (style) { // Get the size and style prior to switching. const previousStyle = Player.config.viewStyle; const { width, height } = Player.container.getBoundingClientRect(); // 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); // Try to reapply the pre change sizing unless it was fullscreen. if (previousStyle !== 'fullscreen' || style === 'fullscreen') { Player.position.resize(parseInt(width, 10), parseInt(height, 10)); } Player.trigger('view', style, previousStyle); }, /** * Togle the display status of the player. */ toggle: function (e) { e && e.preventDefault(); 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 (e) { if (!Player.container) { return; } e && e.preventDefault(); 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 (e) { if (!Player.container) { return; } e && e.preventDefault(); if (!Player.container.style.display) { return; } Player.container.style.display = null; Player.isHidden = false; await Player.trigger('show'); }, /** * 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}-media`).requestFullscreen(); } else if (document.exitFullscreen) { document.exitFullscreen(); } }, /** * Handle file/s being dropped on the player. */ _handleDrop: function (e) { e.preventDefault(); e.stopPropagation(); Player.playlist.addFromFiles(e.dataTransfer.files); }, /** * 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(); } }, _handleRestore: async function (e) { e.preventDefault(); const restore = e.eventTarget.getAttribute('data-restore'); const restoreIndex = Player.display.dismissed.indexOf(restore); if (restore && restoreIndex > -1) { Player.display.dismissed.splice(restoreIndex, 1); Player.$all(`[data-restore="${restore}"]`).forEach(el => { createElementBefore(dismissedContentCache[restore], el); el.parentNode.removeChild(el); }); await GM.setValue('dismissed', Player.display.dismissed.join(',')); } }, _handleDismiss: async function (e) { e.preventDefault(); const dismiss = e.eventTarget.getAttribute('data-dismiss'); if (dismiss && !Player.display.dismissed.includes(dismiss)) { Player.display.dismissed.push(dismiss); Player.$all(`[data-dismiss-id="${dismiss}"]`).forEach(el => { createElementBefore(`${dismissedRestoreCache[dismiss]}`, el); 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; } }; /***/ }), /***/ "./src/components/events.js": /*!**********************************!*\ !*** ./src/components/events.js ***! \**********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = { atRoot: [ 'on', 'off', 'trigger' ], // Holder of event handlers. _events: { }, _delegatedEvents: { }, _undelegatedEvents: { }, _audioEvents: [ ], initialize: function () { const eventLocations = { Player, ...Player.components }; const delegated = Player.events._delegatedEvents; const undelegated = Player.events._undelegatedEvents; const audio = Player.events._audioEvents; for (let name in eventLocations) { const comp = eventLocations[name]; for (let evt in comp.delegatedEvents || {}) { delegated[evt] || (delegated[evt] = []); delegated[evt].push(comp.delegatedEvents[evt]); } for (let evt in comp.undelegatedEvents || {}) { undelegated[evt] || (undelegated[evt] = []); undelegated[evt].push(comp.undelegatedEvents[evt]); } comp.audioEvents && (audio.push(comp.audioEvents)); } Player.on('rendered', function () { // Wire up delegated events on the container. Player.events.addDelegatedListeners(Player.container, delegated); // Wire up undelegated events. Player.events.addUndelegatedListeners(document, undelegated); // Wire up audio events. for (let eventList of audio) { for (let evt in eventList) { Player.audio.addEventListener(evt, Player.events.getHandler(eventList[evt])); } } }); }, /** * Set delegated events listeners on a target */ addDelegatedListeners(target, events) { for (let evt in events) { target.addEventListener(evt, function (e) { let nodes = [ e.target ]; while (nodes[nodes.length - 1] !== target) { nodes.push(nodes[nodes.length - 1].parentNode); } for (let node of nodes) { for (let eventList of [].concat(events[evt])) { for (let selector in eventList) { if (node.matches && node.matches(selector)) { e.eventTarget = node; let handler = Player.events.getHandler(eventList[selector]); // If the handler returns false stop propogation if (handler && handler(e) === false) { return; } } } } } }); } }, /** * Set, or reset, directly bound events. */ addUndelegatedListeners: function (target, events) { for (let evt in events) { for (let eventList of [].concat(events[evt])) { for (let selector in eventList) { target.querySelectorAll(selector).forEach(element => { const handler = Player.events.getHandler(eventList[selector]); element.removeEventListener(evt, handler); element.addEventListener(evt, handler); }); } } } }, /** * 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); } }, /** * Returns the function of Player referenced by name or a given handler function. * @param {String|Function} handler Name to function on Player or a handler function. */ getHandler: function (handler) { return typeof handler === 'string' ? _get(Player, handler) : handler; } }; /***/ }), /***/ "./src/components/footer.js": /*!**********************************!*\ !*** ./src/components/footer.js ***! \**********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = { initialize: function () { Player.userTemplate.maintain(Player.footer, 'footerTemplate'); }, render: function () { if (Player.container) { Player.$(`.${ns}-footer`).innerHTML = Player.templates.footer(); } } }; /***/ }), /***/ "./src/components/header.js": /*!**********************************!*\ !*** ./src/components/header.js ***! \**********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = { initialize: function () { Player.userTemplate.maintain(Player.header, 'headerTemplate'); }, render: function () { if (Player.container) { Player.$(`.${ns}-header`).innerHTML = Player.templates.header(); } } }; /***/ }), /***/ "./src/components/hotkeys.js": /*!***********************************!*\ !*** ./src/components/hotkeys.js ***! \***********************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js"); module.exports = { initialize: function () { Player.on('rendered', Player.hotkeys.apply); }, _keyMap: { ' ': 'space', arrowleft: 'left', arrowright: 'right', arrowup: 'up', arrowdown: 'down' }, 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 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 for (let key in bindings) { const keyDef = bindings[key]; const bindingConfig = k === keyDef.key && (!!keyDef.shiftKey === !!e.shiftKey) && (!!keyDef.ctrlKey === !!e.ctrlKey) && (!!keyDef.metaKey === !!e.metaKey) && (!keyDef.ignoreRepeat || !e.repeat) && settingsConfig.find(s => s.property === 'hotkey_bindings').settings.find(s => s.property === 'hotkey_bindings.' + key); if (bindingConfig) { e.preventDefault(); return Player.events.getHandler(bindingConfig.keyHandler)(); } } }, /** * 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; }, volumeUp: function () { Player.audio.volume = Math.min(Player.audio.volume + 0.05, 1); }, volumeDown: function () { Player.audio.volume = Math.max(Player.audio.volume - 0.05, 0); } }; /***/ }), /***/ "./src/components/minimised.js": /*!*************************************!*\ !*** ./src/components/minimised.js ***! \*************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = { _showingPIP: false, initialize: function () { if (isChanX) { // Create a reply element to gather the style from const a = createElement('', document.body); const style = document.defaultView.getComputedStyle(a); createElement(``, document.head); // Clean up the element. document.body.removeChild(a); // Set up the contents and maintain user template changes. Player.userTemplate.maintain(Player.minimised, 'chanXTemplate', [ 'chanXControls' ], [ 'show', 'hide' ]); } Player.on('rendered', Player.minimised.render); Player.on('show', Player.minimised.hidePIP); Player.on('hide', Player.minimised.showPIP); 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 = createElementBefore(``, document.querySelector('#shortcuts').firstElementChild); Player.events.addDelegatedListeners(container, { click: [ Player.userTemplate.delegatedEvents.click, Player.controls.delegatedEvents.click ] }); } if (Player.config.chanXControls === 'never' || Player.config.chanXControls === 'closed' && !Player.isHidden) { return container.innerHTML = ''; } // Render the contents. container.innerHTML = Player.userTemplate.build({ template: Player.config.chanXTemplate, sound: Player.playing, replacements: { 'prev-button': `
`, 'play-button': `
`, 'next-button': `
`, '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.show); }, /** * Move the image back to the player. */ hidePIP: function () { Player.minimised._showingPIP = false; const image = document.querySelector(`.${ns}-image-link`); Player.$(`.${ns}-media`).insertBefore(document.querySelector(`.${ns}-image-link`), Player.$(`.${ns}-controls`)); image.classList.remove(`${ns}-pip`); image.style.bottom = null; image.removeEventListener('click', Player.show); } }; /***/ }), /***/ "./src/components/playlist.js": /*!************************************!*\ !*** ./src/components/playlist.js ***! \************************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { const { parseFiles, parseFileName } = __webpack_require__(/*! ../file_parser */ "./src/file_parser.js"); module.exports = { atRoot: [ 'add', 'remove' ], delegatedEvents: { click: { [`.${ns}-list-item`]: 'playlist.handleSelect' }, mousemove: { [`.${ns}-list-item`]: 'playlist.positionHoverImage' }, dragstart: { [`.${ns}-list-item`]: 'playlist.handleDragStart' }, dragenter: { [`.${ns}-list-item`]: 'playlist.handleDragEnter' }, dragend: { [`.${ns}-list-item`]: 'playlist.handleDragEnd' }, dragover: { [`.${ns}-list-item`]: e => e.preventDefault() }, drop: { [`.${ns}-list-item`]: e => e.preventDefault() }, keyup: { [`.${ns}-playlist-search`]: 'playlist._handleSearch' } }, undelegatedEvents: { mouseenter: { [`.${ns}-list-item`]: 'playlist.updateHoverImage' }, mouseleave: { [`.${ns}-list-item`]: 'playlist.removeHoverImage' } }, 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 of the hover image element. Player.on('rendered', () => Player.playlist.hoverImage = Player.$(`.${ns}-hover-image`)); // Update the UI when a new sound plays, and scroll to it. Player.on('playsound', sound => { Player.playlist.showImage(sound); Player.$all(`.${ns}-list-item.playing`).forEach(el => el.classList.remove('playing')); Player.$(`.${ns}-list-item[data-id="${Player.playing.id}"]`).classList.add('playing'); Player.playlist.scrollToPlaying('nearest'); }); // 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); // Maintain changes to the user templates it's dependent values Player.userTemplate.maintain(Player.playlist, 'rowTemplate', [ 'shuffle' ]); }, /** * Render the playlist. */ render: function () { if (!Player.container) { return; } const container = Player.$(`.${ns}-list-container`); container.innerHTML = Player.templates.list(); Player.events.addUndelegatedListeners(document.body, Player.playlist.undelegatedEvents); 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, thumb) { if (!Player.container) { return; } let isVideo = Player.playlist.isVideo = !thumb && (sound.image.endsWith('.webm') || sound.type === 'video/webm'); const container = document.querySelector(`.${ns}-image-link`); const img = container.querySelector(`.${ns}-image`); const video = container.querySelector(`.${ns}-video`); img.src = ''; img.src = isVideo || thumb ? sound.thumb : sound.image; video.src = isVideo ? sound.image : undefined; if (Player.config.viewStyle !== 'fullscreen') { container.href = sound.image; } container.classList[isVideo ? 'add' : 'remove'](ns + '-show-video'); }, /** * Switch between playlist and image view. */ toggleView: function (e) { if (!Player.container) { return; } e && e.preventDefault(); let style = Player.config.viewStyle === 'playlist' ? 'image' : 'playlist'; 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 = document.createElement('div'); rowContainer.innerHTML = Player.templates.list({ sounds: [ sound ] }); Player.events.addUndelegatedListeners(rowContainer, Player.playlist.undelegatedEvents); let row = rowContainer.children[0]; if (index < Player.sounds.length - 1) { const before = Player.$(`.${ns}-list-item[data-id="${Player.sounds[index + 1].id}"]`); list.insertBefore(row, before); } else { list.appendChild(row); } } // If nothing else has been added yet show the image for this sound. if (Player.sounds.length === 1) { // If we're on a thread with autoshow enabled then make sure the player is displayed if (/\/thread\//.test(location.href) && Player.config.autoshow) { Player.show(); } Player.playlist.showImage(sound); } Player.trigger('add', sound); } } catch (err) { Player.logError('There was an error adding to the sound player. Please check the console for details.', err); console.log('[4chan sounds player]', sound); } }, addFromFiles: function (files) { // Check each of the files for sounds. [ ...files ].forEach(file => { if (!file.type.startsWith('image') && file.type !== 'video/webm') { return; } const imageSrc = URL.createObjectURL(file); const type = file.type; let thumbSrc = imageSrc; // If it's not a webm just use the full image as the thumbnail if (file.type !== 'video/webm') { return _continue(); } // If it's a webm grab the first frame as the thumbnail const canvas = document.createElement('canvas'); const video = document.createElement('video'); const context = canvas.getContext('2d'); video.addEventListener('loadeddata', function () { context.drawImage(video, 0, 0); thumbSrc = canvas.toDataURL(); _continue(); }); video.src = imageSrc; function _continue() { parseFileName(file.name, imageSrc, null, thumbSrc, null, true).forEach(sound => Player.add({ ...sound, local: true, type })); } }); }, /** * Remove a sound */ remove: function (sound) { const index = Player.sounds.indexOf(sound); // If the playing sound is being removed then play the next sound. if (Player.playing === sound) { Player.next({ force: true, paused: Player.audio.paused }); } // Remove the sound from the the list and play order. index > -1 && Player.sounds.splice(index, 1); // Remove the item from the list. Player.$(`.${ns}-list-container`).removeChild(Player.$(`.${ns}-list-item[data-id="${sound.id}"]`)); Player.trigger('remove', sound); }, /** * Handle an playlist item being clicked. Either open/close the menu or play the sound. */ handleSelect: function (e) { // Ignore if a link was clicked. if (e.target.nodeName === 'A' || e.target.closest('a')) { return; } e.preventDefault(); const id = e.eventTarget.getAttribute('data-id'); const sound = id && Player.sounds.find(sound => sound.id === id); sound && Player.play(sound); }, /** * Read all the sounds from the thread again. */ refresh: function () { parseFiles(document.body); }, /** * Toggle the hoverImages setting */ toggleHoverImages: function (e) { e && e.preventDefault(); Player.set('hoverImages', !Player.config.hoverImages); }, /** * Only show the hover image with the setting enabled, no item menu open, and nothing being dragged. */ setHoverImageVisibility: function () { const container = Player.$(`.${ns}-player`); const hideImage = !Player.config.hoverImages || Player.playlist._dragging || container.querySelector(`.${ns}-menu`); container.classList[hideImage ? 'add' : 'remove'](`${ns}-hide-hover-image`); }, /** * Set the displayed hover image and reposition. */ updateHoverImage: function (e) { const id = e.currentTarget.getAttribute('data-id'); const sound = Player.sounds.find(sound => sound.id === id); Player.playlist.hoverImage.style.display = 'block'; Player.playlist.hoverImage.setAttribute('src', sound.thumb); Player.playlist.positionHoverImage(e); }, /** * Reposition the hover image to follow the cursor. */ positionHoverImage: function (e) { const { width, height } = Player.playlist.hoverImage.getBoundingClientRect(); const maxX = document.documentElement.clientWidth - width - 5; Player.playlist.hoverImage.style.left = (Math.min(e.clientX, maxX) + 5) + 'px'; Player.playlist.hoverImage.style.top = (e.clientY - height - 10) + 'px'; }, /** * Hide the hover image when nothing is being hovered over. */ removeHoverImage: function () { Player.playlist.hoverImage.style.display = 'none'; }, /** * Start dragging a playlist item. */ handleDragStart: function (e) { Player.playlist._dragging = e.eventTarget; Player.playlist.setHoverImageVisibility(); e.eventTarget.classList.add(`${ns}-dragging`); e.dataTransfer.setDragImage(new Image(), 0, 0); e.dataTransfer.dropEffect = 'move'; e.dataTransfer.setData('text/plain', e.eventTarget.getAttribute('data-id')); }, /** * Swap a playlist item when it's dragged over another item. */ handleDragEnter: function (e) { if (!Player.playlist._dragging) { return; } e.preventDefault(); const moving = Player.playlist._dragging; const id = moving.getAttribute('data-id'); let before = e.target.closest && e.target.closest(`.${ns}-list-item`); if (!before || moving === before) { return; } const movingIdx = Player.sounds.findIndex(s => s.id === id); const list = moving.parentNode; // If the item is being moved down it need inserting before the node after the one it's dropped on. const position = moving.compareDocumentPosition(before); if (position & 0x04) { before = before.nextSibling; } // Move the element and sound. // If there's nothing to go before then append. if (before) { const beforeId = before.getAttribute('data-id'); const beforeIdx = Player.sounds.findIndex(s => s.id === beforeId); const insertIdx = movingIdx < beforeIdx ? beforeIdx - 1 : beforeIdx; list.insertBefore(moving, before); Player.sounds.splice(insertIdx, 0, Player.sounds.splice(movingIdx, 1)[0]); } else { Player.sounds.push(Player.sounds.splice(movingIdx, 1)[0]); list.appendChild(moving); } Player.trigger('order'); }, /** * Start dragging a playlist item. */ handleDragEnd: function (e) { if (!Player.playlist._dragging) { return; } e.preventDefault(); delete Player.playlist._dragging; e.eventTarget.classList.remove(`${ns}-dragging`); Player.playlist.setHoverImageVisibility(); }, /** * Scroll to the playing item, unless there is an open menu in the playlist. */ scrollToPlaying: function (type = 'center') { if (Player.$(`.${ns}-list-container .${ns}-menu`)) { return; } const playing = Player.$(`.${ns}-list-item.playing`); playing && playing.scrollIntoView({ block: type }); }, /** * Remove any user filtered items from the playlist. */ applyFilters: function () { Player.sounds.filter(sound => !Player.acceptedSound(sound)).forEach(Player.playlist.remove); }, /** * Search the playlist */ _handleSearch: function (e) { Player.playlist.search(e.eventTarget.value.toLowerCase()); }, search: function (v) { const lastSearch = Player.playlist._lastSearch; Player.playlist._lastSearch = v; if (v === lastSearch) { return; } if (!v) { return Player.$all(`.${ns}-list-item`).forEach(el => el.style.display = null); } Player.sounds.forEach(sound => { const row = Player.$(`.${ns}-list-item[data-id="${sound.id}"]`); row && (row.style.display = Player.playlist.matchesSearch(sound) ? null : 'none'); }); }, matchesSearch: function (sound) { const v = Player.playlist._lastSearch; return !v || sound.title.toLowerCase().includes(v) || String(sound.post.toLowerCase()).includes(v) || String(sound.src.toLowerCase()).includes(v); }, toggleSearch: function (show) { const input = Player.$(`.${ns}-playlist-search`); !show && Player.playlist._lastSearch && Player.playlist.search(); input.style.display = show ? null : 'none'; show && input.focus(); } }; /***/ }), /***/ "./src/components/position.js": /*!************************************!*\ !*** ./src/components/position.js ***! \************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = { delegatedEvents: { mousedown: { [`.${ns}-header`]: 'position.initMove', [`.${ns}-expander`]: 'position.initResize' } }, initialize: function () { // Apply the last position/size, and post width limiting, when the player is shown. Player.on('show', async function () { const [ top, left ] = (await GM.getValue('position') || '').split(':'); const [ width, height ] = (await GM.getValue('size') || '').split(':'); +top && +left && Player.position.move(top, left, true); +width && +height && Player.position.resize(width, height); if (Player.config.limitPostWidths) { Player.position.setPostWidths(); window.addEventListener('scroll', Player.position.setPostWidths); } }); // Remove post width limiting when the player is hidden. Player.on('hide', function () { Player.position.setPostWidths(); window.removeEventListener('scroll', Player.position.setPostWidths); }); // Reapply the post width limiting config values when they're changed. Player.on('config', prop => { if (prop === 'limitPostWidths' || prop === 'minPostWidth') { window.removeEventListener('scroll', Player.position.setPostWidths); Player.position.setPostWidths(); if (Player.config.limitPostWidths) { window.addEventListener('scroll', Player.position.setPostWidths); } } }); // Remove post width limit from inline quotes new MutationObserver(function () { document.querySelectorAll('#hoverUI .postContainer, .inline .postContainer, .backlink_container article').forEach(post => { post.style.maxWidth = null; post.style.minWidth = null; }); }).observe(document.body, { childList: true, subtree: true }); // Listen for changes from other tabs Player.syncTab('position', value => Player.position.move(...value.split(':').concat(true))); Player.syncTab('size', value => Player.position.resize(...value.split(':'))); }, /** * Applies a max width to posts next to the player so they don't get hidden behind it. */ setPostWidths: function () { const offset = (document.documentElement.clientWidth - Player.container.offsetLeft) + 10; const selector = is4chan ? '.thread > .postContainer' : '.posts > article.post'; const enabled = !Player.isHidden && Player.config.limitPostWidths; const startY = Player.container.offsetTop; const endY = Player.container.getBoundingClientRect().height + startY; document.querySelectorAll(selector).forEach(post => { const rect = enabled && post.getBoundingClientRect(); const limitWidth = enabled && rect.top + rect.height > startY && rect.top < endY; post.style.maxWidth = limitWidth ? `calc(100% - ${offset}px)` : null; post.style.minWidth = limitWidth && Player.config.minPostWidth ? `${Player.config.minPostWidth}` : null; }); }, /** * Handle the user grabbing the expander. */ initResize: function initDrag(e) { e.preventDefault(); Player._startX = e.clientX; Player._startY = e.clientY; let { width, height } = Player.container.getBoundingClientRect(); Player._startWidth = width; Player._startHeight = height; document.documentElement.addEventListener('mousemove', Player.position.doResize, false); document.documentElement.addEventListener('mouseup', Player.position.stopResize, false); }, /** * Handle the user dragging the expander. */ doResize: function (e) { e.preventDefault(); Player.position.resize(Player._startWidth + e.clientX - Player._startX, Player._startHeight + e.clientY - Player._startY); }, /** * Handle the user releasing the expander. */ stopResize: function () { const { width, height } = Player.container.getBoundingClientRect(); document.documentElement.removeEventListener('mousemove', Player.position.doResize, false); document.documentElement.removeEventListener('mouseup', Player.position.stopResize, false); GM.setValue('size', width + ':' + height); }, /** * Resize the player. */ resize: function (width, height) { if (!Player.container || Player.config.viewStyle === 'fullscreen') { return; } const { bottom } = Player.position.getHeaderOffset(); // Make sure the player isn't going off screen. height = Math.min(height, document.documentElement.clientHeight - Player.container.offsetTop - bottom); width = Math.min(width - 2, document.documentElement.clientWidth - Player.container.offsetLeft); Player.container.style.width = width + 'px'; // Which element to change the height of depends on the view being displayed. const heightElement = Player.config.viewStyle === 'playlist' ? Player.$(`.${ns}-list-container`) : Player.config.viewStyle === 'image' ? Player.$(`.${ns}-image-link`) : Player.config.viewStyle === 'settings' ? Player.$(`.${ns}-settings`) : Player.config.viewStyle === 'threads' ? Player.$(`.${ns}-threads`) : Player.config.viewStyle === 'tools' ? Player.$(`.${ns}-tools`) : null; if (!heightElement) { return; } const offset = Player.container.getBoundingClientRect().height - heightElement.getBoundingClientRect().height; heightElement.style.height = (height - offset) + 'px'; }, /** * Handle the user grabbing the header. */ initMove: function (e) { e.preventDefault(); Player.$(`.${ns}-header`).style.cursor = 'grabbing'; // Try to reapply the current sizing to fix oversized winows. const { width, height } = Player.container.getBoundingClientRect(); Player.position.resize(width, height); Player._offsetX = e.clientX - Player.container.offsetLeft; Player._offsetY = e.clientY - Player.container.offsetTop; document.documentElement.addEventListener('mousemove', Player.position.doMove, false); document.documentElement.addEventListener('mouseup', Player.position.stopMove, false); }, /** * Handle the user dragging the header. */ doMove: function (e) { e.preventDefault(); Player.position.move(e.clientX - Player._offsetX, e.clientY - Player._offsetY); }, /** * Handle the user releasing the heaer. */ stopMove: function () { document.documentElement.removeEventListener('mousemove', Player.position.doMove, false); document.documentElement.removeEventListener('mouseup', Player.position.stopMove, false); Player.$(`.${ns}-header`).style.cursor = null; GM.setValue('position', parseInt(Player.container.style.left, 10) + ':' + parseInt(Player.container.style.top, 10)); }, /** * Move the player. */ move: function (x, y, allowOffscreen) { if (!Player.container) { return; } const { top, bottom } = Player.position.getHeaderOffset(); // Ensure the player stays fully within the window. const { width, height } = Player.container.getBoundingClientRect(); const maxX = allowOffscreen ? Infinity : document.documentElement.clientWidth - width; const maxY = allowOffscreen ? Infinity : document.documentElement.clientHeight - height - bottom; // Move the window. Player.container.style.left = Math.max(0, Math.min(x, maxX)) + 'px'; Player.container.style.top = Math.max(top, Math.min(y, maxY)) + 'px'; if (Player.config.limitPostWidths) { Player.position.setPostWidths(); } }, /** * Get the offset from the top or bottom required for the 4chan X header. */ getHeaderOffset: function () { const docClasses = document.documentElement.classList; const hasChanXHeader = docClasses.contains('fixed'); const headerHeight = hasChanXHeader ? document.querySelector('#header-bar').getBoundingClientRect().height : 0; const top = hasChanXHeader && docClasses.contains('top-header') ? headerHeight : 0; const bottom = hasChanXHeader && docClasses.contains('bottom-header') ? headerHeight : 0; return { top, bottom }; } }; /***/ }), /***/ "./src/components/settings.js": /*!************************************!*\ !*** ./src/components/settings.js ***! \************************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js"); const migrations = __webpack_require__(/*! ../migrations */ "./src/migrations.js"); module.exports = { atRoot: [ 'set' ], delegatedEvents: { click: { [`.${ns}-settings .${ns}-heading-action`]: 'settings._handleAction', [`.${ns}-settings-tab`]: 'settings._handleTab' }, focusout: { [`.${ns}-settings input, .${ns}-settings textarea`]: 'settings._handleChange' }, change: { [`.${ns}-settings input[type=checkbox], .${ns}-settings select`]: 'settings._handleChange' }, keydown: { [`.${ns}-key-input`]: 'settings.handleKeyChange', } }, initialize: async function () { Player.settings.view = 'Display'; // Apply the default board theme as default. Player.settings.applyBoardTheme(); // Apply the default config. Player.config = settingsConfig.reduce(function reduceSettings(config, setting) { if (setting.settings) { setting.settings.forEach(subSetting => { let _setting = { ...setting, ...subSetting }; _set(config, _setting.property, _setting.default); }); return config; } return _set(config, setting.property, setting.default); }, {}); // Load the user config. await Player.settings.load(); // Run any migrations. await Player.settings.migrate(Player.config.VERSION); // Listen for the player closing to apply the pause on hide setting. Player.on('hide', function () { if (Player.config.pauseOnHide) { Player.pause(); } }); // Listen for changes from other tabs Player.syncTab('settings', value => Player.settings.apply(value, { bypassSave: true, applyDefault: true, ignore: [ 'viewStyle' ] })); }, render: function () { if (Player.container) { Player.$(`.${ns}-settings`).innerHTML = Player.templates.settings(); } }, forceBoardTheme: function () { Player.settings.applyBoardTheme(true); Player.settings.save(); }, applyBoardTheme: function (force) { // Create a reply element to gather the style from const div = createElement(`
`, document.body); const style = document.defaultView.getComputedStyle(div); // Apply the computed style to the color config. const colorSettingMap = { 'colors.text': 'color', 'colors.background': 'backgroundColor', 'colors.odd_row': 'backgroundColor', 'colors.border': 'borderBottomColor', // If the border is the same color as the text don't use it as a background color. 'colors.even_row': style.borderBottomColor === style.color ? 'backgroundColor' : 'borderBottomColor' }; settingsConfig.find(s => s.property === 'colors').settings.forEach(setting => { const updateConfig = force || (setting.default === _get(Player.config, setting.property)); colorSettingMap[setting.property] && (setting.default = style[colorSettingMap[setting.property]]); updateConfig && Player.set(setting.property, setting.default, { bypassSave: true, bypassRender: true }); }); // Clean up the element. document.body.removeChild(div); // Updated the stylesheet if it exists. Player.stylesheet && Player.display.updateStylesheet(); // Re-render the settings if needed. Player.settings.render(); }, /** * Update a setting. */ set: function (property, value, { bypassValidation, bypassSave, bypassRender, silent } = {}) { const previousValue = _get(Player.config, property); if (!bypassValidation && _isEqual(previousValue, value)) { return; } _set(Player.config, property, value); !silent && Player.trigger('config', property, value, previousValue); !silent && Player.trigger('config:' + property, value, previousValue); !bypassSave && Player.settings.save(); !bypassRender && Player.settings.findDefault(property).displayGroup && Player.settings.render(); }, /** * Reset a setting to the default value */ reset: function (property) { let settingConfig = Player.settings.findDefault(property); Player.set(property, settingConfig.default); }, /** * Persist the player settings. */ save: function () { try { // Filter settings that haven't been modified from the default. const settings = settingsConfig.reduce(function _handleSetting(settings, setting) { if (setting.settings) { setting.settings.forEach(subSetting => _handleSetting(settings, { property: setting.property, default: setting.default, ...subSetting })); } else { let userVal = _get(Player.config, setting.property); if (userVal !== undefined && !_isEqual(userVal, setting.default)) { // If the setting is a mixed in object only store items that differ from the default. if (setting.mix) { userVal = Object.keys(userVal).reduce((changed, key) => { if (!_isEqual(setting.default[key], userVal[key])) { changed[key] = userVal[key]; } return changed; }, {}); } _set(settings, setting.property, userVal); } } return settings; }, {}); // Show the playlist or image view on load, whichever was last shown. settings.viewStyle = Player.playlist._lastView; // Store the player version with the settings. settings.VERSION = "3.1.0"; // Save the settings. return GM.setValue('settings', JSON.stringify(settings)); } catch (err) { Player.logError('There was an error saving the sound player settings.', err); } }, /** * Restore the saved player settings. */ load: async function () { try { let settings = await GM.getValue('settings') || await GM.getValue(ns + '.settings'); if (settings) { Player.settings.apply(settings, { bypassSave: true, silent: true }); } } catch (err) { Player.logError('There was an error loading the sound player settings.', err); } }, apply: function (settings, opts = {}) { if (typeof settings === 'string') { settings = JSON.parse(settings); } settings.VERSION && (Player.config.VERSION = settings.VERSION); settingsConfig.forEach(function _handleSetting(setting) { if (setting.settings) { return setting.settings.forEach(subSetting => _handleSetting({ property: setting.property, default: setting.default, ...subSetting })); } if (opts.ignore && opts.ignore.includes(setting.property)) { return; } let value = _get(settings, setting.property, opts.applyDefault ? setting.default : undefined); if (value !== undefined) { if (setting.mix) { // Mix in default. value = { ...setting.default, ...(value || {}) }; } Player.set(setting.property, value, opts); } }); }, /** * Run migrations when the player is updated. */ migrate: async function (fromVersion) { // Fall out if the player hasn't updated. if (!fromVersion || fromVersion === "3.1.0") { return; } for (let i = 0; i < migrations.length; i++) { let mig = migrations[i]; if (Player.settings.compareVersions(fromVersion, mig.version) < 0) { try { console.log('[4chan sound player] Migrate:', mig.name); await mig.run(); } catch (err) { console.error(err); } } } Player.settings.save(); }, /** * Compare two semver strings. */ compareVersions: function (a, b) { const [ aVer, aHash ] = a.split('-'); const [ bVer, bHash ] = b.split('-'); const aParts = aVer.split('.'); const bParts = bVer.split('.'); for (let i = 0; i < 3; i++) { if (+aParts[i] > +bParts[i]) { return 1; } if (+aParts[i] < +bParts[i]) { return -1; } } return aHash !== bHash; }, /** * Find a setting in the default configuration. */ findDefault: function (property) { let settingConfig; settingsConfig.find(function (setting) { if (setting.property === property) { return settingConfig = setting; } if (setting.settings) { let subSetting = setting.settings.find(_setting => _setting.property === property); return subSetting && (settingConfig = { ...setting, settings: null, ...subSetting }); } return false; }); return settingConfig || { property }; }, /** * Toggle whether the player or settings are displayed. */ toggle: function (group) { // Blur anything focused so the change is applied. let focused = Player.$(`.${ns}-settings :focus`); focused && focused.blur(); // Restore the playlist if there's no group given and the settings are already open. if (!group && Player.config.viewStyle === 'settings') { return Player.playlist.restore(); } // Switch to the settings view if it's not already showing. if (Player.config.viewStyle !== 'settings') { Player.display.setViewStyle('settings'); } // Switch to a given group. if (group && group !== Player.settings.view) { Player.settings.showGroup(group); } }, /** * Switch the displayed group */ _handleTab: function (e) { const group = e.eventTarget.getAttribute('data-group'); if (group) { e.preventDefault(); Player.settings.showGroup(group); } }, showGroup: function (group) { Player.settings.view = group; const currentGroup = Player.$(`.${ns}-settings-group.active`); const currentTab = Player.$(`.${ns}-settings-tab.active`); currentGroup && currentGroup.classList.remove('active'); currentTab && currentTab.classList.remove('active'); Player.$(`.${ns}-settings-group[data-group="${group}"]`).classList.add('active'); Player.$(`.${ns}-settings-tab[data-group="${group}"]`).classList.add('active'); }, /** * Handle the user making a change in the settings view. */ _handleChange: function (e) { try { const input = e.eventTarget; const property = input.getAttribute('data-property'); if (!property) { return; } let settingConfig = Player.settings.findDefault(property); // Get the new value of the setting. const currentValue = _get(Player.config, property); let newValue = input[input.getAttribute('type') === 'checkbox' ? 'checked' : 'value']; if (settingConfig.parse) { newValue = _get(Player, settingConfig.parse)(newValue, currentValue, e); } if (settingConfig && settingConfig.split) { newValue = newValue.split(decodeURIComponent(settingConfig.split)); } // Not the most stringent check but enough to avoid some spamming. if (!_isEqual(currentValue, newValue, !settingConfig.looseCompare)) { // Update the setting. Player.set(property, newValue, { bypassValidation: true, bypassRender: true }); // Update the stylesheet reflect any changes. if (settingConfig.updateStylesheet) { Player.display.updateStylesheet(); } } // Run any handler required by the value changing settingConfig && settingConfig.handler && _get(Player, settingConfig.handler, () => null)(newValue); } catch (err) { Player.logError('There was an error updating the setting.', err); } }, /** * Converts a key event in an input to a string representation set as the input value. */ handleKeyChange: function (e) { e.preventDefault(); if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Meta') { return; } e.eventTarget.value = Player.hotkeys.stringifyKey(e); }, /** * Handle an action link next to a heading being clicked. */ _handleAction: function (e) { e.preventDefault(); const property = e.eventTarget.getAttribute('data-property'); const handlerName = e.eventTarget.getAttribute('data-handler'); const handler = _get(Player, handlerName); handler && handler(property, e); }, renderHosts: function (_value) { return `
` + Object.keys(Player.config.uploadHosts).map(Player.templates.hostInput).join('') + '
'; }, parseHosts: function (newValue, hosts, e) { hosts = { ...hosts }; const container = e.eventTarget.closest(`.${ns}-host-input`); let name = container.getAttribute('data-host-name'); let host = hosts[name] = { ...hosts[name] }; const changedField = e.eventTarget.getAttribute('name'); try { // If the name was changed then reassign in hosts and update the data-host-name attribute. if (changedField === 'name' && newValue !== name) { if (!newValue || hosts[newValue]) { throw new PlayerError('A unique name for the host is required.', 'warning'); } container.setAttribute('data-host-name', newValue); hosts[newValue] = host; delete hosts[name]; name = newValue; } // Validate URL if (changedField === 'url' || changedField === 'soundUrl') { try { (changedField === 'url' || newValue) && new URL(newValue); } catch (err) { throw new PlayerError('The value must be a valid URL.', 'warning'); } } // Parse the data if (changedField === 'data') { try { newValue = JSON.parse(newValue); } catch (err) { throw new PlayerError('The data must be valid JSON.', 'warning'); } } if (changedField === 'headers') { try { newValue = newValue ? JSON.parse(newValue) : undefined; } catch (err) { throw new PlayerError('The headers must be valid JSON.', 'warning'); } } } catch (err) { host.invalid = true; container.classList.add('invalid'); throw err; } if (newValue === undefined) { delete host[changedField]; } else { host[changedField] = newValue; } try { const soundUrlValue = container.querySelector('[name=soundUrl]').value; const headersValue = container.querySelector('[name=headers]').value; if (name && JSON.parse(container.querySelector('[name=data]').value) && new URL(container.querySelector('[name=url]').value) && (!soundUrlValue || new URL(soundUrlValue)) && (!headersValue || JSON.parse(headersValue))) { delete host.invalid; container.classList.remove('invalid'); } } catch (err) { // leave it invalid } return hosts; }, addUploadHost: function () { const hosts = Player.config.uploadHosts; const container = Player.$(`.${ns}-host-inputs`); let name = 'New Host'; let i = 1; while (Player.config.uploadHosts[name]) { name = name + ' ' + ++i; } hosts[name] = { invalid: true, data: { file: '$file' } }; if (container.children[0]) { createElementBefore(Player.templates.hostInput(name), container.children[0]); } else { createElement(Player.templates.hostInput(name), container); } Player.settings.set('uploadHosts', hosts, { bypassValidation: true, bypassRender: true, silent: true }); }, removeHost: function (prop, e) { const hosts = Player.config.uploadHosts; const container = e.eventTarget.closest(`.${ns}-host-input`); const name = container.getAttribute('data-host-name'); // For hosts in the defaults set null so we know to not include them on load if (Player.settings.findDefault('uploadHosts').default[name]) { hosts[name] = null; } else { delete hosts[name]; } container.parentNode.removeChild(container); Player.settings.set('uploadHosts', hosts, { bypassValidation: true, bypassRender: true }); }, setDefaultHost: function (_new, _current, e) { const selected = e.eventTarget.closest(`.${ns}-host-input`).getAttribute('data-host-name'); if (selected === Player.config.defaultUploadHost) { return selected; } Object.keys(Player.config.uploadHosts).forEach(name => { const checkbox = Player.$(`.${ns}-host-input[data-host-name="${name}"] input[data-property="defaultUploadHost"]`); checkbox && (checkbox.checked = name === selected); }); return selected; }, restoreDefaultHosts: function () { Object.assign(Player.config.uploadHosts, Player.settings.findDefault('uploadHosts').default); Player.set('uploadHosts', Player.config.uploadHosts, { bypassValidation: true }); } }; /***/ }), /***/ "./src/components/threads.js": /*!***********************************!*\ !*** ./src/components/threads.js ***! \***********************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { const { parseFileName } = __webpack_require__(/*! ../file_parser */ "./src/file_parser.js"); const { get } = __webpack_require__(/*! ../api */ "./src/api.js"); const maxSavedBoards = 10; const boardsURL = 'https://a.4cdn.org/boards.json'; const catalogURL = 'https://a.4cdn.org/%s/catalog.json'; module.exports = { boardList: null, soundThreads: null, displayThreads: {}, selectedBoards: Board ? [ Board ] : [ 'a' ], showAllBoards: false, delegatedEvents: { click: { [`.${ns}-fetch-threads-link`]: 'threads.fetch', [`.${ns}-all-boards-link`]: 'threads.toggleBoardList' }, keyup: { [`.${ns}-threads-filter`]: e => Player.threads.filter(e.eventTarget.value) }, change: { [`.${ns}-threads input[type=checkbox]`]: 'threads.toggleBoard' } }, initialize: async function () { Player.threads.hasParser = is4chan && typeof Parser !== 'undefined'; // If the native Parser hasn't been intialised chuck customSpoiler on it so we can call it for threads. // You shouldn't do things like this. We can fall back to the table view if it breaks though. if (Player.threads.hasParser && !Parser.customSpoiler) { Parser.customSpoiler = {}; } Player.on('show', Player.threads._initialFetch); Player.on('view', Player.threads._initialFetch); Player.on('rendered', Player.threads.afterRender); Player.on('config:threadsViewStyle', Player.threads.render); try { const savedBoards = await GM.getValue('threads_board_selection'); savedBoards && (Player.threads.selectedBoards = savedBoards.split(',')); } catch (err) { // Leave it deafulted to the current board. } }, /** * Fetch the threads when the threads view is opened for the first time. */ _initialFetch: function () { if (Player.container && Player.config.viewStyle === 'threads' && Player.threads.boardList === null) { Player.threads.fetchBoards(true); } }, render: function () { if (Player.container) { Player.$(`.${ns}-threads`).innerHTML = Player.templates.threads(); Player.threads.afterRender(); } }, /** * Render the threads and apply the board styling after the view is rendered. */ afterRender: function () { const threadList = Player.$(`.${ns}-thread-list`); if (threadList) { const bodyStyle = document.defaultView.getComputedStyle(document.body); threadList.style.background = bodyStyle.backgroundColor; threadList.style.backgroundImage = bodyStyle.backgroundImage; threadList.style.backgroundRepeat = bodyStyle.backgroundRepeat; threadList.style.backgroundPosition = bodyStyle.backgroundPosition; } Player.threads.renderThreads(); }, /** * Render just the threads. */ renderThreads: function () { if (!Player.threads.hasParser || Player.config.threadsViewStyle === 'table') { Player.$(`.${ns}-threads-body`).innerHTML = Player.templates.threadList(); } else { try { const list = Player.$(`.${ns}-thread-list`); for (let board in Player.threads.displayThreads) { // Create a board title const boardConf = Player.threads.boardList.find(boardConf => boardConf.board === board); const boardTitle = `/${boardConf.board}/ - ${boardConf.title}`; createElement(`
${boardTitle}
`, list); // Add each thread for the board const threads = Player.threads.displayThreads[board]; for (let i = 0; i < threads.length; i++) { list.appendChild(Parser.buildHTMLFromJSON.call(Parser, threads[i], threads[i].board, true, true)); // Add a line under each thread createElement('
', list); } } } catch (err) { Player.logError('Unable to display the threads board view.', err, 'warning'); // If there was an error fall back to the table view. Player.set('threadsViewStyle', 'table'); Player.renderThreads(); } } }, /** * Render just the board selection. */ renderBoards: function () { Player.$(`.${ns}-thread-board-list`).innerHTML = Player.templates.threadBoards(); }, /** * Toggle the threads view. */ toggle: function (e) { e && e.preventDefault(); if (Player.config.viewStyle === 'threads') { Player.playlist.restore(); } else { Player.display.setViewStyle('threads'); } }, /** * Switch between showing just the selected boards and all boards. */ toggleBoardList: function (e) { e.preventDefault(); Player.threads.showAllBoards = !Player.threads.showAllBoards; Player.$(`.${ns}-all-boards-link`).innerHTML = Player.threads.showAllBoards ? 'Selected Only' : 'Show All'; Player.threads.renderBoards(); }, /** * Select/deselect a board. */ toggleBoard: async function (e) { const board = e.eventTarget.value; const selected = e.eventTarget.checked; if (selected) { !Player.threads.selectedBoards.includes(board) && Player.threads.selectedBoards.unshift(board); } else { Player.threads.selectedBoards = Player.threads.selectedBoards.filter(b => b !== board); } await GM.setValue('threads_board_selection', Player.threads.selectedBoards.slice(0, maxSavedBoards).join(',')); }, /** * Fetch the board list from the 4chan API. */ fetchBoards: async function (fetchThreads) { Player.threads.loading = true; Player.threads.render(); Player.threads.boardList = (await get(boardsURL)).boards; if (fetchThreads) { Player.threads.fetch(); } else { Player.threads.loading = false; Player.threads.render(); } }, /** * Fetch the catalog for each selected board and search for sounds in OPs. */ fetch: async function (e) { e && e.preventDefault(); Player.threads.loading = true; Player.threads.render(); if (!Player.threads.boardList) { try { await Player.threads.fetchBoards(); } catch (err) { return Player.logError('Failed fetching the boards list.', err); } } const allThreads = []; try { await Promise.all(Player.threads.selectedBoards.map(async board => { const boardConf = Player.threads.boardList.find(boardConf => boardConf.board === board); if (!boardConf) { return; } const pages = boardConf && await get(catalogURL.replace('%s', board)); (pages || []).forEach(({ page, threads }) => { allThreads.push(...threads.map(thread => Object.assign(thread, { board, page, ws_board: boardConf.ws_board }))); }); })); Player.threads.soundThreads = allThreads.filter(thread => { const sounds = parseFileName(thread.filename, `https://i.4cdn.org/${thread.board}/${thread.tim}${thread.ext}`, thread.no, `https://i.4cdn.org/${thread.board}/${thread.tim}s${thread.ext}`, thread.md5, true); return sounds.length; }); } catch (err) { Player.logError('Failed searching for sounds threads.', err); } Player.threads.loading = false; Player.threads.filter(Player.$(`.${ns}-threads-filter`).value, true); Player.threads.render(); }, /** * Apply the filter input to the already fetched threads. */ filter: function (search, skipRender) { Player.threads.filterValue = search || ''; if (Player.threads.soundThreads === null) { return; } Player.threads.displayThreads = Player.threads.soundThreads.reduce((threadsByBoard, thread) => { if (!search || thread.sub && thread.sub.includes(search) || thread.com && thread.com.includes(search)) { threadsByBoard[thread.board] || (threadsByBoard[thread.board] = []); threadsByBoard[thread.board].push(thread); } return threadsByBoard; }, {}); !skipRender && Player.threads.renderThreads(); } }; /***/ }), /***/ "./src/components/tools.js": /*!*********************************!*\ !*** ./src/components/tools.js ***! \*********************************/ /*! no static exports found */ /***/ (function(module, exports) { const ffmpegVersionUrl = 'https://raw.githubusercontent.com/rcc11/4chan-sounds-player/master/dist/4chan-sounds-player-with-ffmpeg.user.js'; const promoteFFmpegVersion = false; // Seems to be the cut off point for file names const maxFilenameLength = 218; module.exports = { hasFFmpeg: typeof ffmpeg === 'function', _uploadIdx: 0, createStatusText: '', delegatedEvents: { click: { [`.${ns}-create-button`]: 'tools._handleCreate', [`.${ns}-create-sound-post-link`]: 'tools._addCreatedToQR', [`.${ns}-create-sound-add-link`]: 'tools._addCreatedToPlayer', [`.${ns}-toggle-sound-input`]: 'tools._handleToggleSoundInput', [`.${ns}-host-setting-link`]: noDefault(() => Player.settings.toggle('Hosts')), [`.${ns}-remove-file`]: noDefault(e => Player.tools._handleFileRemove(e)) }, change: { [`.${ns}-create-sound-img`]: 'tools._handleImageSelect', [`.${ns}-create-sound-form input[type=file]`]: e => Player.tools._handleFileSelect(e.eventTarget), [`.${ns}-use-video`]: 'tools._handleWebmSoundChange' }, drop: { [`.${ns}-create-sound-form`]: 'tools._handleCreateSoundDrop' }, keyup: { [`.${ns}-encoded-input`]: 'tools._handleEncoded', [`.${ns}-decoded-input`]: 'tools._handleDecoded' } }, initialize: function () { Player.on('config:uploadHosts', Player.tools.render); Player.on('config:defaultUploadHost', newValue => Player.$(`.${ns}-create-sound-host`).value = newValue); Player.on('rendered', Player.tools.afterRender); }, render: function () { Player.$(`.${ns}-tools`).innerHTML = Player.templates.tools(); Player.tools.afterRender(); }, afterRender: function () { Player.tools.status = Player.$(`.${ns}-create-sound-status`); Player.tools.imgInput = Player.$(`.${ns}-create-sound-img`); Player.tools.sndInput = Player.$(`.${ns}-create-sound-snd`); }, toggle: function (e) { e && e.preventDefault(); if (Player.config.viewStyle === 'tools') { Player.playlist.restore(); } else { Player.display.setViewStyle('tools'); } }, updateCreateStatus: function (text) { Player.tools.status.style.display = text ? 'inherit' : 'none'; Player.tools.status.innerHTML = Player.tools.createStatusText = text; }, /** * Encode the decoded input. */ _handleDecoded: function (e) { Player.$(`.${ns}-encoded-input`).value = encodeURIComponent(e.eventTarget.value); }, /** * Decode the encoded input. */ _handleEncoded: function (e) { Player.$(`.${ns}-decoded-input`).value = decodeURIComponent(e.eventTarget.value); }, /** * Show/hide the "Use webm" checkbox when an image is selected. */ _handleImageSelect: async function (e) { const input = e && e.eventTarget || Player.tools.imgInput; const image = input.files[0]; const isVideo = image.type === 'video/webm'; let placeholder = image.name.replace(/\.[^/.]+$/, ''); if (Player.tools.hasFFmpeg) { // Show the Use Webm label if the image is a webm file Player.$(`.${ns}-use-video-label`).style.display = isVideo ? 'inherit' : 'none'; const webmCheckbox = Player.$(`.${ns}-use-video`); // If the image is a video and Copy Video is selected then update the sound input as well webmCheckbox.checked && isVideo && Player.tools._handleFileSelect(Player.tools.sndInput, [ image ]); // If the image isn't a webm make sure Copy Video is deselected (click to fire change event) webmCheckbox.checked && !isVideo && webmCheckbox.click(); } else if (await Player.tools.hasAudio(image)) { Player.logError('Audio not allowed for the image webm.', null, 'warning'); } // Show the image name as the placeholder for the name input since it's the default Player.$(`.${ns}-create-sound-name`).setAttribute('placeholder', placeholder); }, /** * Update the custom file input display when the input changes */ _handleFileSelect: function (input, files) { const container = input.closest(`.${ns}-file-input`); const fileText = container.querySelector('.text'); const fileList = container.querySelector(`.${ns}-file-list`); files || (files = [ ...input.files ]); container.classList[files.length ? 'remove' : 'add']('placeholder'); fileText.innerHTML = files.length > 1 ? files.length + ' files' : files[0] && files[0].name || ''; fileList && (fileList.innerHTML = files.length < 2 ? '' : files.map((file, i) => `
${file.name}
X
` ).join('')); }, /** * Handle a file being removed from a multi input */ _handleFileRemove: function (e) { const idx = +e.eventTarget.getAttribute('data-idx'); const input = e.eventTarget.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: function (e) { const sound = Player.tools.sndInput; const image = Player.tools.imgInput; Player.tools._handleFileSelect(sound, e.eventTarget.checked && [ image.files[0] ]); }, _handleToggleSoundInput: function (e) { e.preventDefault(); const showURL = e.eventTarget.getAttribute('data-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: function (e) { e.preventDefault(); e.stopPropagation(); 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(); } }); return false; }, /** * Handle the create button. * Extracts video/audio if required, uploads the sound, and creates an image file names with [sound=url]. */ _handleCreate: async function (e) { e && e.preventDefault(); // Revoke the URL for an existing created image. Player.tools._createdImageURL && URL.revokeObjectURL(Player.tools._createdImageURL); Player.tools._createdImage = null; Player.tools.updateCreateStatus('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'); } if (image.type.startsWith('video') && await Player.tools.hasAudio(image)) { // If ffmpeg is not available fall out. if (!Player.tools.hasFFmpeg) { Player.tools.updateCreateStatus(Player.tools.createStatusText + '
' + (promoteFFmpegVersion ? 'This version of the player does not enable webm splitting.' : 'Audio not allowed for the image webm.') + '
Remove the audio from the webm and try again.' + (promoteFFmpegVersion ? `
Alternatively install the ffmpeg version to extract video/audio automatically.` : '')); throw new PlayerError('Audio not allowed for the image webm.', 'warning'); } // If the image is a webm with audio then extract just the video. image = await Player.tools.extract(image, 'video'); } 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 and extract it if possible. 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'); } if (Player.tools.hasFFmpeg) { return await Player.tools.extract(sound, 'audio'); } } 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]; const soundImage = new File([ image ], filename + '.' + ext, { type: image.type }); // Keep track of the create image and a url to it. Player.tools._createdImage = soundImage; Player.tools._createdImageURL = URL.createObjectURL(soundImage); // Complete! with some action links Player.tools.updateCreateStatus(Player.tools.createStatusText + '
Complete!
' + (is4chan ? `Post - ` : '') + ` Add - ` + ` Download` ); } catch (err) { Player.tools.updateCreateStatus(Player.tools.createStatusText + '
Failed! ' + (err instanceof PlayerError ? err.reason : '')); Player.logError('Failed to create sound image', err); } Player.$(`.${ns}-create-button`).disabled = false; }, hasAudio: function (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; }); }, /** * Extract just the audio or video from a file. */ extract: async function (file, type) { Player.tools.updateCreateStatus(Player.tools.createStatusText + '
Extracting ' + type); if (typeof ffmpeg !== 'function') { return file; } const name = file.name.replace(/\.[^/.]+$/, '') + (type === 'audio' ? '.ogg' : '.webm'); const result = ffmpeg({ MEMFS: [ { name: '_' + file.name, data: await new Response(file).arrayBuffer() } ], arguments: type === 'audio' ? [ '-i', '_' + file.name, '-vn', '-c', 'copy', name ] : [ '-i', '_' + file.name, '-an', '-c', 'copy', name ] }); return new File([ result.MEMFS[0].data ], name, { type: type === 'audio' ? 'audio/ogg' : 'video/webm' }); }, /** * Upload the sound file and return a link to it. */ postFile: async function (file, host) { const idx = Player.tools._uploadIdx++; if (!host || host.invalid) { throw new PlayerError('Invalid upload host: ' + hostId, '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]); } }); Player.tools.updateCreateStatus(Player.tools.createStatusText + `
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}`; Player.tools.createStatusText = Player.tools.status.innerHTML; 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: function (e) { e.preventDefault(); Player.playlist.addFromFiles([ Player.tools._createdImage ]); }, /** * Open the QR window and add the created sound image to it. */ _addCreatedToQR: function (e) { if (!is4chan) { return; } e.preventDefault(); // 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.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/user-template/buttons.js": /*!*************************************************!*\ !*** ./src/components/user-template/buttons.js ***! \*************************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = [ { property: 'repeat', tplName: 'repeat', class: `${ns}-repeat-button`, values: { all: { attrs: [ 'title="Repeat All"' ], text: '[RA]', icon: 'fa-repeat' }, one: { attrs: [ 'title="Repeat One"' ], text: '[R1]', icon: 'fa-repeat fa-repeat-one' }, none: { attrs: [ 'title="No Repeat"' ], text: '[R0]', icon: 'fa-repeat disabled' } } }, { property: 'shuffle', tplName: 'shuffle', class: `${ns}-shuffle-button`, values: { true: { attrs: [ 'title="Shuffled"' ], text: '[S]', icon: 'fa-random' }, false: { attrs: [ 'title="Ordered"' ], text: '[O]', icon: 'fa-random disabled' } } }, { property: 'viewStyle', tplName: 'playlist', class: `${ns}-viewStyle-button`, values: { playlist: { attrs: [ 'title="Hide Playlist"' ], text: '[+]', icon: 'fa-compress' }, image: { attrs: [ 'title="Show Playlist"' ], text: '[-]', icon: 'fa-expand' } } }, { property: 'hoverImages', tplName: 'hover-images', class: `${ns}-hoverImages-button`, values: { true: { attrs: [ 'title="Hover Images Enabled"' ], text: '[H]', icon: 'fa-picture-o' }, false: { attrs: [ 'title="Hover Images Disabled"' ], text: '[-]', icon: 'fa-picture-o disabled' } } }, { tplName: 'add', class: `${ns}-add-button`, icon: 'fa-plus', text: '+', attrs: [ 'title="Add local files"' ] }, { tplName: 'reload', class: `${ns}-reload-button`, icon: 'fa-refresh', text: '[R]', attrs: [ 'title="Reload the playlist"' ] }, { tplName: 'settings', class: `${ns}-config-button`, icon: 'fa-wrench', text: '[S]', attrs: [ 'title="Settings"' ] }, { tplName: 'threads', class: `${ns}-threads-button`, icon: 'fa-search', text: '[T]', attrs: [ 'title="Threads"' ] }, { tplName: 'tools', class: `${ns}-tools-button`, icon: 'fa-gears', text: '[T]', attrs: [ 'title="Tools"' ] }, { tplName: 'close', class: `${ns}-close-button`, icon: 'fa-times', text: 'X', attrs: [ 'title="Hide the player"' ] }, { tplName: 'playing', requireSound: true, class: `${ns}-playing-jump-link`, text: 'Playing', attrs: [ 'title="Scroll the playlist currently playing sound."' ] }, { tplName: 'post', requireSound: true, icon: 'fa-comment-o', text: 'Post', showIf: data => data.sound.post, attrs: data => [ `href=${'#' + (is4chan ? 'p' : '') + data.sound.post}`, 'title="Jump to the post for the current sound"' ] }, { tplName: 'image', requireSound: true, icon: 'fa-image', text: 'i', attrs: data => [ `href=${data.sound.image}`, 'title="Open the image in a new tab"', 'target="_blank"' ] }, { tplName: 'sound', requireSound: true, href: data => data.sound.src, icon: 'fa-volume-up', text: 's', attrs: data => [ `href=${data.sound.src}`, 'title="Open the sound in a new tab"', 'target="_blank"' ] }, { tplName: 'dl-image', requireSound: true, class: `${ns}-download-link`, icon: 'fa-file-image-o', text: 'i', attrs: data => [ 'title="Download the image with the original filename"', `data-src="${data.sound.image}"`, `data-name="${data.sound.filename}"` ] }, { tplName: 'dl-sound', requireSound: true, class: `${ns}-download-link`, icon: 'fa-file-sound-o', text: 's', attrs: data => [ 'title="Download the sound"', `data-src="${data.sound.src}"` ] }, { tplName: 'filter-image', requireSound: true, class: `${ns}-filter-link`, icon: 'fa-filter', text: 'i', showIf: data => data.sound.imageMD5, attrs: data => [ 'title="Add the image MD5 to the filters."', `data-filter="${data.sound.imageMD5}"` ] }, { tplName: 'filter-sound', requireSound: true, class: `${ns}-filter-link`, icon: 'fa-filter', text: 's', attrs: data => [ 'title="Add the sound URL to the filters."', `data-filter="${data.sound.src.replace(/^(https?:)?\/\//, '')}"` ] }, { tplName: 'remove', requireSound: true, class: `${ns}-remove-link`, icon: 'fa-trash-o', text: 'r', attrs: data => [ 'title="Filter the image."', `data-id="${data.sound.id}"` ] }, { tplName: 'menu', requireSound: true, class: `${ns}-item-menu-button`, icon: 'fa-angle-down', text: 'â–¼', attrs: data => [ `data-id=${data.sound.id}` ] }, { tplName: 'view-menu', class: `${ns}-view-menu-button`, icon: 'fa-angle-down', text: 'â–¾', attrs: [ 'href="javascript:;"' ] } ]; /***/ }), /***/ "./src/components/user-template/index.js": /*!***********************************************!*\ !*** ./src/components/user-template/index.js ***! \***********************************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { const buttons = __webpack_require__(/*! ./buttons */ "./src/components/user-template/buttons.js"); // Regex for replacements const playingRE = /p: ?{([^}]*)}/g; const hoverRE = /h: ?{([^}]*)}/g; const buttonRE = new RegExp(`(${buttons.map(option => option.tplName).join('|')})-(?:button|link)(?:\\:"([^"]+?)")?`, 'g'); const soundNameRE = /sound-name/g; const soundIndexRE = /sound-index/g; const soundCountRE = /sound-count/g; // Hold information on which config values components templates depend on. const componentDeps = [ ]; module.exports = { buttons, delegatedEvents: { click: { [`.${ns}-playing-jump-link`]: () => Player.playlist.scrollToPlaying('center'), [`.${ns}-viewStyle-button`]: 'playlist.toggleView', [`.${ns}-hoverImages-button`]: 'playlist.toggleHoverImages', [`.${ns}-remove-link`]: 'userTemplate._handleRemove', [`.${ns}-filter-link`]: 'userTemplate._handleFilter', [`.${ns}-download-link`]: 'userTemplate._handleDownload', [`.${ns}-shuffle-button`]: 'userTemplate._handleShuffle', [`.${ns}-repeat-button`]: 'userTemplate._handleRepeat', [`.${ns}-reload-button`]: noDefault('playlist.refresh'), [`.${ns}-add-button`]: noDefault(() => Player.$(`.${ns}-file-input`).click()), [`.${ns}-item-menu-button`]: 'userTemplate._handleItemMenu', [`.${ns}-view-menu-button`]: 'userTemplate._handleViewsMenu', [`.${ns}-threads-button`]: 'threads.toggle', [`.${ns}-tools-button`]: 'tools.toggle', [`.${ns}-config-button`]: noDefault(() => Player.settings.toggle()), [`.${ns}-player-button`]: 'playlist.restore' }, change: { [`.${ns}-file-input`]: 'userTemplate._handleFileSelect' } }, undelegatedEvents: { click: { body: 'userTemplate._closeMenus' }, keydown: { body: e => e.key === 'Escape' && Player.userTemplate._closeMenus() } }, initialize: function () { Player.on('config', Player.userTemplate._handleConfig); Player.on('playsound', () => Player.userTemplate._handleEvent('playsound')); Player.on('add', () => Player.userTemplate._handleEvent('add')); Player.on('remove', () => Player.userTemplate._handleEvent('remove')); Player.on('order', () => Player.userTemplate._handleEvent('order')); Player.on('show', () => Player.userTemplate._handleEvent('show')); Player.on('hide', () => Player.userTemplate._handleEvent('hide')); }, /** * Build a user template. */ build: function (data) { const outerClass = data.outerClass || ''; const name = data.sound && data.sound.title || data.defaultName; // Apply common template replacements let html = data.template .replace(playingRE, Player.playing && Player.playing === data.sound ? '$1' : '') .replace(hoverRE, `$1`) .replace(buttonRE, function (full, type, text) { let buttonConf = buttons.find(conf => conf.tplName === type); 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 is to use is taken from the `property` in the base config of the player config. // This gives us different state displays. if (buttonConf.values) { buttonConf = { ...buttonConf, ...buttonConf.values[_get(Player.config, buttonConf.property)] || buttonConf.values[Object.keys(buttonConf.values)[0]] }; } const attrs = typeof buttonConf.attrs === 'function' ? buttonConf.attrs(data) : buttonConf.attrs || []; attrs.some(attr => attr.startsWith('href')) || attrs.push('href="javascript:;"'); (buttonConf.class || outerClass) && attrs.push(`class="${buttonConf.class || ''} ${outerClass || ''}"`); if (!text) { text = buttonConf.icon ? `${buttonConf.text}` : buttonConf.text; } return `${text}`; }) .replace(soundNameRE, name ? `
${name}
` : '') .replace(soundIndexRE, data.sound ? Player.sounds.indexOf(data.sound) + 1 : 0) .replace(soundCountRE, Player.sounds.length) .replace(/%v/g, "3.1.0"); // 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 should render templates showing the playing sounds name/index or dependent on something playing. // order should render templates showing a sounds index. const hasCount = soundCountRE.test(template); const hasName = soundNameRE.test(template); const hasIndex = soundIndexRE.test(template); const hasPlaying = playingRE.test(template); hasCount && events.push('add', 'remove'); (hasPlaying || property !== 'rowTemplate' && (hasName || hasIndex)) && events.push('playsound'); 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 type = match[1]; let buttonConf = buttons.find(conf => conf.tplName === type); if (buttonConf.property) { config.push(buttonConf.property); } } } 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(); } }); }, /** * Add local files. */ _handleFileSelect: function (e) { e.preventDefault(); const input = e.eventTarget; Player.playlist.addFromFiles(input.files); }, /** * Toggle the repeat style. */ _handleRepeat: function (e) { e.preventDefault(); const values = [ 'all', 'one', 'none' ]; const current = values.indexOf(Player.config.repeat); Player.set('repeat', values[(current + 4) % 3]); }, /** * Toggle the shuffle style. */ _handleShuffle: function (e) { e.preventDefault(); Player.set('shuffle', !Player.config.shuffle); Player.header.render(); // Update the play order. if (!Player.config.shuffle) { Player.sounds.sort((a, b) => Player.compareIds(a.id, b.id)); } else { const sounds = Player.sounds; for (let i = sounds.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [ sounds[i], sounds[j] ] = [ sounds[j], sounds[i] ]; } } Player.trigger('order'); }, /** * Display an item menu. */ _handleItemMenu: function (e) { e.preventDefault(); e.stopPropagation(); const id = e.eventTarget.getAttribute('data-id'); const sound = Player.sounds.find(s => s.id === id); // Add row item menus to the list container. Append to the container otherwise. const listContainer = e.eventTarget.closest(`.${ns}-list-container`); const parent = listContainer || Player.container; // Create the menu. const dialog = createElement(Player.templates.itemMenu({ sound }), parent); Player.userTemplate._showMenu(e.clientX, e.clientY, dialog, parent); }, _handleViewsMenu: function (e) { e.preventDefault(); e.stopPropagation(); const dialog = createElement(Player.templates.viewsMenu()); Player.userTemplate._showMenu(e.clientX, e.clientY, dialog); }, _showMenu: function (x, y, dialog, parent) { Player.userTemplate._closeMenus(); dialog.style.top = y + 'px'; dialog.style.left = x + 'px'; parent || (parent = Player.container); parent.appendChild(dialog); // Make sure it's within the page. const style = document.defaultView.getComputedStyle(dialog); const width = parseInt(style.width, 10); const height = parseInt(style.height, 10); // Show the dialog to the left of the cursor, if there's room. if (x - width > 0) { dialog.style.left = x - width + 'px'; } // Move the dialog above the cursor if it's off screen. if (y + height > document.documentElement.clientHeight - 40) { dialog.style.top = y - height + 'px'; } // Add the focused class handler dialog.querySelectorAll('.entry').forEach(el => { el.addEventListener('mouseenter', Player.userTemplate._setFocusedMenuItem); el.addEventListener('mouseleave', Player.userTemplate._unsetFocusedMenuItem); }); Player.trigger('menu-open', dialog); }, /** * Close any open menus, except for one belonging to an item that was clicked. */ _closeMenus: function () { document.querySelectorAll(`.${ns}-menu`).forEach(menu => { menu.parentNode.removeChild(menu); Player.trigger('menu-close', menu); }); }, _setFocusedMenuItem: function (e) { e.currentTarget.classList.add('focused'); const submenu = e.currentTarget.querySelector('.submenu'); // Move the menu to the other side if there isn't room. if (submenu && submenu.getBoundingClientRect().right > document.documentElement.clientWidth) { submenu.style.inset = '0px auto auto -100%'; } }, _unsetFocusedMenuItem: function (e) { e.currentTarget.classList.remove('focused'); }, _handleFilter: function (e) { e.preventDefault(); let filter = e.eventTarget.getAttribute('data-filter'); if (filter) { Player.set('filters', Player.config.filters.concat(filter)); } }, _handleDownload: function (e) { const src = e.eventTarget.getAttribute('data-src'); const name = e.eventTarget.getAttribute('data-name') || new URL(src).pathname.split('/').pop(); GM.xmlHttpRequest({ method: 'GET', url: src, responseType: 'blob', onload: response => { const a = createElement(``); a.click(); URL.revokeObjectURL(a.href); }, onerror: response => Player.logError('There was an error downloading.', response, 'warning') }); }, _handleRemove: function (e) { const id = e.eventTarget.getAttribute('data-id'); const sound = id && Player.sounds.find(sound => sound.id === '' + id); sound && Player.remove(sound); }, }; /***/ }), /***/ "./src/config/display.js": /*!*******************************!*\ !*** ./src/config/display.js ***! \*******************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = [ { property: 'autoshow', default: true, title: 'Autoshow', description: 'Automatically show the player when the thread contains sounds.', displayGroup: 'Display', settings: [ { title: 'Enabled' } ] }, { property: 'pauseOnHide', default: true, title: 'Pause on hide', description: 'Pause the player when it\'s hidden.', displayGroup: 'Display', settings: [ { title: 'Enabled' } ] }, { title: 'Minimised Display', description: 'Optional displays for when the player is minimised.', displayGroup: 'Display', 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 Templates.', displayMethod: isChanX || null, options: { always: 'Always', closed: 'Only with the player closed', never: 'Never' } } ] }, { property: 'limitPostWidths', title: 'Limit Post Width', description: 'Limit the width of posts so they aren\'t hidden under the player.', displayGroup: 'Display', settings: [ { property: 'limitPostWidths', title: 'Enabled', default: true }, { property: 'minPostWidth', title: 'Minimum Width', default: '50%' } ] }, { property: 'threadsViewStyle', title: 'Threads View', description: 'How threads in the threads view are listed.', displayGroup: 'Display', settings: [ { title: 'Display', default: 'table', options: { table: 'Table', board: 'Board' } } ] }, { title: 'Colors', displayGroup: 'Display', property: 'colors', updateStylesheet: true, actions: [ { title: 'Match Theme', handler: 'settings.forceBoardTheme' } ], // These colors will be overriden with the theme defaults at initialization. settings: [ { property: 'colors.text', default: '#000000', title: 'Text Color' }, { property: 'colors.background', default: '#d6daf0', title: 'Background Color' }, { property: 'colors.border', default: '#b7c5d9', title: 'Border Color' }, { property: 'colors.odd_row', default: '#d6daf0', title: 'Odd Row Color', }, { property: 'colors.even_row', default: '#b7c5d9', title: 'Even Row Color' }, { property: 'colors.playing', default: '#98bff7', title: 'Playing Row Color' }, { property: 'colors.dragging', default: '#c396c8', title: 'Dragging Row Color' } ] } ]; /***/ }), /***/ "./src/config/filter.js": /*!******************************!*\ !*** ./src/config/filter.js ***! \******************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = [ { property: 'allow', title: 'Allowed Hosts', description: 'Which domains sources are allowed to be loaded from.', default: [ '4cdn.org', 'catbox.moe', 'dmca.gripe', 'lewd.se', 'pomf.cat', 'zz.ht' ], actions: [ { title: 'Reset', handler: 'settings.reset' } ], displayGroup: 'Filter', split: '\n' }, { property: 'filters', default: [ '# Image MD5 or sound URL' ], title: 'Filters', description: 'List of URLs or image MD5s to filter, one per line.\nLines starting with a # will be ignored.', actions: [ { title: 'Reset', handler: 'settings.reset' } ], displayGroup: 'Filter', split: '\n' } ]; /***/ }), /***/ "./src/config/hosts.js": /*!*****************************!*\ !*** ./src/config/hosts.js ***! \*****************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = [ { property: 'defaultUploadHost', default: 'catbox', parse: 'settings.setDefaultHost' }, { property: 'uploadHosts', title: 'Hosts', actions: [ { title: 'Add', handler: 'settings.addUploadHost' }, { title: 'Restore Defaults', handler: 'settings.restoreDefaultHosts' }, ], displayGroup: 'Hosts', displayMethod: 'settings.renderHosts', parse: 'settings.parseHosts', looseCompare: true, dismissTextId: 'uplodHostSettings', dismissRestoreText: 'Show Help', text: 'Properties' + '
Name: A unique identifier.' + '
URL: The URL to post the file to.' + '
Response Path/Match: A key path or regular expression to locate the uploaded filename in the response.' + '
File URL Format: The URL format for uploaded sounds. %s is replaced with the result of response path/match if given or the full response.' + '
Data: The form data for the upload (as JSON). Specify the file using $file.', mix: true, default: { catbox: { default: true, url: 'https://catbox.moe/user/api.php', data: { reqtype: 'fileupload', fileToUpload: '$file', userhash: null }, filenameLength: 29 }, pomf: { url: 'https://pomf.cat/upload.php', data: { 'files[]': '$file' }, responsePath: 'files.0.url', filenameLength: 23 }, zz: { url: 'https://zz.ht/api/upload', responsePath: 'files.0.url', data: { 'files[]': '$file' }, headers: { token: null }, filenameLength: 19 }, lewd: { url: 'https://lewd.se/upload', data: { file: '$file' }, headers: { token: null, shortUrl: true }, responsePath: 'data.link', filenameLength: 30 } } } ]; /***/ }), /***/ "./src/config/index.js": /*!*****************************!*\ !*** ./src/config/index.js ***! \*****************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { module.exports = [ // Order the groups appear in. ...__webpack_require__(/*! ./display */ "./src/config/display.js"), ...__webpack_require__(/*! ./filter */ "./src/config/filter.js"), ...__webpack_require__(/*! ./keybinds */ "./src/config/keybinds.js"), ...__webpack_require__(/*! ./templates */ "./src/config/templates.js"), ...__webpack_require__(/*! ./hosts */ "./src/config/hosts.js"), { property: 'shuffle', default: false }, { property: 'repeat', default: 'all' }, { property: 'viewStyle', default: 'playlist' }, { property: 'hoverImages', default: false }, { property: 'showPlaylistSearch', deafult: true } ]; /***/ }), /***/ "./src/config/keybinds.js": /*!********************************!*\ !*** ./src/config/keybinds.js ***! \********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = [ { title: 'Keybinds', displayGroup: 'Keybinds', description: 'Enable keyboard shortcuts.', format: 'hotkeys.stringifyKey', parse: 'hotkeys.parseKey', class: `${ns}-key-input`, property: 'hotkey_bindings', settings: [ { property: 'hotkeys', default: 'open', handler: 'hotkeys.apply', title: 'Enabled', format: null, parse: null, class: null, options: { always: 'Always', open: 'Only with the player open', never: 'Never' } }, { property: 'hotkey_bindings.playPause', title: 'Play/Pause', keyHandler: 'togglePlay', ignoreRepeat: true, default: { key: ' ' } }, { property: 'hotkey_bindings.previous', title: 'Previous', keyHandler: 'previous', ignoreRepeat: true, default: { key: 'arrowleft' } }, { property: 'hotkey_bindings.next', title: 'Next', keyHandler: 'next', ignoreRepeat: true, default: { key: 'arrowright' } }, { property: 'hotkey_bindings.previousGroup', title: 'Previous Group', keyHandler: () => Player.previous({ group: true }), ignoreRepeat: true, default: { shiftKey: true, key: 'arrowleft' } }, { property: 'hotkey_bindings.nextGroup', title: 'Next Group', keyHandler: () => Player.next({ group: true }), ignoreRepeat: true, default: { shiftKey: true, key: 'arrowright' } }, { property: 'hotkey_bindings.volumeUp', title: 'Volume Up', keyHandler: 'hotkeys.volumeUp', default: { shiftKey: true, key: 'arrowup' } }, { property: 'hotkey_bindings.volumeDown', title: 'Volume Down', keyHandler: 'hotkeys.volumeDown', default: { shiftKey: true, key: 'arrowdown' } }, { property: 'hotkey_bindings.toggleFullscreen', title: 'Toggle Fullscreen', keyHandler: 'display.toggleFullScreen', default: { key: '' } }, { property: 'hotkey_bindings.togglePlayer', title: 'Show/Hide', keyHandler: 'display.toggle', default: { key: 'h' } }, { property: 'hotkey_bindings.togglePlaylist', title: 'Toggle Playlist', keyHandler: 'playlist.toggleView', default: { key: '' } }, { property: 'hotkey_bindings.toggleSearch', title: 'Toggle Playlist Search', keyHandler: () => Player.set('showPlaylistSearch', !Player.config.showPlaylistSearch), default: { key: '' } }, { property: 'hotkey_bindings.scrollToPlaying', title: 'Jump To Playing', keyHandler: 'playlist.scrollToPlaying', default: { key: '' } }, { property: 'hotkey_bindings.toggleHoverImages', title: 'Toggle Hover Images', keyHandler: 'playlist.toggleHoverImages', default: { key: '' } } ] } ]; /***/ }), /***/ "./src/config/templates.js": /*!*********************************!*\ !*** ./src/config/templates.js ***! \*********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = [ { property: 'headerTemplate', title: 'Header', actions: [ { title: 'Reset', handler: 'settings.reset' } ], default: 'repeat-button shuffle-button hover-images-button playlist-button\nsound-name\nview-menu-button add-button reload-button close-button', displayGroup: 'Templates', displayMethod: 'textarea' }, { property: 'rowTemplate', title: 'Row', actions: [ { title: 'Reset', handler: 'settings.reset' } ], default: 'sound-name h:{menu-button}', displayGroup: 'Templates', displayMethod: 'textarea' }, { property: 'footerTemplate', title: 'Footer', actions: [ { title: 'Reset', handler: 'settings.reset' } ], default: 'playing-button:"sound-index /" sound-count sounds\n' + 'p:{\n' + '
\n' + ' post-link\n' + ' Open [ image-link sound-link ]\n' + ' Download [ dl-image-button dl-sound-button ]\n' + '
\n' + '}', description: 'Template for the footer contents', displayGroup: 'Templates', displayMethod: 'textarea', attrs: 'style="height:120px;"' }, { property: 'chanXTemplate', title: '4chan X Header', default: 'p:{\n\tpost-link:"sound-name"\n\tprev-button\n\tplay-button\n\tnext-button\n\tsound-current-time / sound-duration\n}', actions: [ { title: 'Reset', handler: 'settings.reset' } ], displayGroup: 'Templates', displayMethod: 'textarea' } ]; /***/ }), /***/ "./src/file_parser.js": /*!****************************!*\ !*** ./src/file_parser.js ***! \****************************/ /*! no static exports found */ /***/ (function(module, exports) { const protocolRE = /^(https?:)?\/\//; const filenameRE = /(.*?)[[({](?:audio|sound)[ =:|$](.*?)[\])}]/g; let localCounter = 0; module.exports = { parseFiles, parsePost, parseFileName }; function parseFiles(target, postRender) { let addedSounds = false; let posts = target.classList.contains('post') ? [ target ] : target.querySelectorAll('.post'); posts.forEach(post => parsePost(post, postRender) && (addedSounds = true)); if (addedSounds && postRender && Player.container) { Player.playlist.render(); } } function parsePost(post, skipRender) { try { if (post.classList.contains('style-fetcher')) { return; } const parentParent = post.parentElement.parentElement; if (parentParent.id === 'qp' || post.parentElement.classList.contains('noFile')) { return; } // If there's a play button this post has already been parsed. Just wire up the link. let playLink = post.querySelector(`.${ns}-play-link`); if (playLink) { const id = playLink.getAttribute('data-id'); playLink.onclick = () => Player.play(Player.sounds.find(sound => sound.id === id)); return; } let filename = null; let filenameLocations; // For the archive there's just the one place to check. // For 4chan there's native / 4chan X / 4chan X with file info formatting if (!is4chan) { filenameLocations = { '.post_file_filename': 'title' }; } else { filenameLocations = { '.fileText .file-info .fnfull': 'textContent', '.fileText .file-info > a': 'textContent', '.fileText > a': 'title', '.fileText': 'textContent' }; } Object.keys(filenameLocations).some(function (selector) { const node = post.querySelector(selector); return node && (filename = node[filenameLocations[selector]]); }); if (!filename) { return; } const postID = post.id.slice(is4chan ? 1 : 0); const fileThumb = post.querySelector(is4chan ? '.fileThumb' : '.thread_image_link'); const imageSrc = fileThumb && fileThumb.href; const thumbImg = fileThumb && fileThumb.querySelector('img'); const thumbSrc = thumbImg && thumbImg.src; const imageMD5 = thumbImg && thumbImg.getAttribute('data-md5'); const sounds = parseFileName(filename, imageSrc, postID, thumbSrc, imageMD5); if (!sounds.length) { return; } // Create a play link const firstID = sounds[0].id; const text = is4chan ? 'play' : 'Play'; const clss = `${ns}-play-link` + (is4chan ? '' : ' btnr'); let playLinkParent; if (is4chan) { playLinkParent = post.querySelector('.fileText'); playLinkParent.appendChild(document.createTextNode(' ')); } else { playLinkParent = post.querySelector('.post_controls'); } playLink = createElement(`${text}`, playLinkParent); playLink.onclick = () => Player.play(Player.sounds.find(sound => sound.id === firstID)); // Don't add sounds from inline quotes of posts in the thread sounds.forEach(sound => Player.add(sound, skipRender)); return sounds.length > 0; } catch (err) { Player.logError('There was an issue parsing the files. Please check the console for details.', err); console.log('[4chan sounds player]', post); } } function parseFileName(filename, image, post, thumb, imageMD5, bypassVerification) { if (!filename) { return []; } filename = filename.replace(/-/, '/'); const matches = []; let match; while ((match = filenameRE.exec(filename)) !== null) { matches.push(match); } const defaultName = matches[0] && matches[0][1] || post || 'Local Sound ' + localCounter; matches.length && !post && localCounter++; return matches.reduce((sounds, match, i) => { let src = match[2]; const id = (post || 'local' + localCounter) + ':' + i; const title = match[1].trim() || defaultName + (matches.length > 1 ? ` (${i + 1})` : ''); try { if (src.includes('%')) { src = decodeURIComponent(src); } if (!src.startsWith('blob:') && src.match(protocolRE) === null) { src = (location.protocol + '//' + src); } } catch (error) { return sounds; } const sound = { src, id, title, post, image, filename, thumb, imageMD5 }; if (bypassVerification || Player.acceptedSound(sound)) { sounds.push(sound); } return sounds; }, []); } /***/ }), /***/ "./src/globals.js": /*!************************!*\ !*** ./src/globals.js ***! \************************/ /*! no static exports found */ /***/ (function(module, exports) { /** * Global variables and helpers. */ window.ns = 'fc-sounds'; window.is4chan = location.hostname.includes('4chan.org') || location.hostname.includes('4channel.org'); window.isChanX = document.documentElement.classList.contains('fourchan-x'); window.Board = location.pathname.split('/')[1]; window._set = function (object, path, value) { const props = path.split('.'); const lastProp = props.pop(); const setOn = props.reduce((obj, k) => obj[k] || (obj[k] = {}), object); setOn && (setOn[lastProp] = value); return object; }; window._get = function (object, path, dflt) { if (typeof path !== 'string') { return dflt; } const props = path.split('.'); const lastProp = props.pop(); const parent = props.reduce((obj, k) => obj && obj[k], object); return parent && Object.prototype.hasOwnProperty.call(parent, lastProp) ? parent[lastProp] : dflt; }; /** * Check two values are equal. Arrays/Objects are deep checked. */ window._isEqual = function (a, b, strict = true) { if (typeof a !== typeof b) { return false; } if (Array.isArray(a, b)) { return a === b || a.length === b.length && a.every((_a, i) => _isEqual(_a, b[i])); } if (a && b && typeof a === 'object' && a !== b) { const allKeys = Object.keys(a); allKeys.push(...Object.keys(b).filter(k => !allKeys.includes(k))); return allKeys.every(key => _isEqual(a[key], b[key])); } return strict ? a === b : a == b; }; window.toDuration = function (number) { number = Math.floor(number || 0); let [ seconds, minutes, hours ] = _duration(0, number); seconds < 10 && (seconds = '0' + seconds); return (hours ? hours + ':' : '') + minutes + ':' + seconds; }; window.timeAgo = function (date) { const [ seconds, minutes, hours, days, weeks ] = _duration(Math.floor(date), Math.floor(Date.now() / 1000)); /* _eslint-disable indent */ return weeks > 1 ? weeks + ' weeks ago' : days > 0 ? days + (days === 1 ? ' day' : ' days') + ' ago' : hours > 0 ? hours + (hours === 1 ? ' hour' : ' hours') + ' ago' : minutes > 0 ? minutes + (minutes === 1 ? ' minute' : ' minutes') + ' ago' : seconds + (seconds === 1 ? ' second' : ' seconds') + ' ago'; /* eslint-enable indent */ }; function _duration(from, to) { const diff = Math.max(0, to - from); return [ diff % 60, Math.floor(diff / 60) % 60, Math.floor(diff / 60 / 60) % 24, Math.floor(diff / 60 / 60 / 24) % 7, Math.floor(diff / 60 / 60 / 24 / 7) ]; } window.createElement = function (html, parent, events = {}) { const container = document.createElement('div'); container.innerHTML = html; const el = container.children[0]; parent && parent.appendChild(el); for (let event in events) { el.addEventListener(event, events[event]); } return el; }; window.createElementBefore = function (html, before, events = {}) { const el = createElement(html, null, events); before.parentNode.insertBefore(el, before); return el; }; window.noDefault = (f, ...args) => e => { e.preventDefault(); const func = typeof f === 'function' ? f : _get(Player, f); func(e, ...args); }; class PlayerError extends Error { constructor(msg, type, err) { super(msg); this.reason = msg; this.type = type; this.error = err; } } window.PlayerError = PlayerError; /***/ }), /***/ "./src/main.js": /*!*********************!*\ !*** ./src/main.js ***! \*********************/ /*! no exports provided */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony import */ var _globals__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./globals */ "./src/globals.js"); /* harmony import */ var _globals__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_globals__WEBPACK_IMPORTED_MODULE_0__); async function doInit() { // Wait for 4chan X if it's installed and not finished initialising. if (!isChanX && (isChanX = document.documentElement.classList.contains('fourchan-x'))) { return; } // Require these here so every other require is sure of the 4chan X state. const Player = __webpack_require__(/*! ./player */ "./src/player.js"); const { parseFiles } = __webpack_require__(/*! ./file_parser */ "./src/file_parser.js"); // The player tends to be all black without this timeout. // Something with the timing of the stylesheet loading and applying the board theme. setTimeout(async function () { await Player.initialize(); parseFiles(document.body, true); const observer = new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { if (mutation.type === 'childList') { mutation.addedNodes.forEach(function (node) { if (node.nodeType === Node.ELEMENT_NODE) { parseFiles(node); } }); } }); }); observer.observe(document.body, { childList: true, subtree: true }); }, 0); } document.addEventListener('4chanXInitFinished', function () { const wasChanX = isChanX; isChanX = true; if (wasChanX) { doInit(); } Player.display.initChanX(); }); // If it's already known 4chan X is installed this can be skipped. if (!isChanX) { if (document.readyState !== 'loading') { doInit(); } else { document.addEventListener('DOMContentLoaded', doInit); } } /***/ }), /***/ "./src/migrations.js": /*!***************************!*\ !*** ./src/migrations.js ***! \***************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = [ { version: '3.0.0', name: 'hosts-filename-length', async run() { const defaultHosts = Player.settings.findDefault('uploadHosts').default; Object.keys(defaultHosts).forEach(host => { Player.config.uploadHosts[host].filenameLength = defaultHosts[host].filenameLength; }); } } ]; /***/ }), /***/ "./src/player.js": /*!***********************!*\ !*** ./src/player.js ***! \***********************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { const components = { // Settings must be first. settings: __webpack_require__(/*! ./components/settings */ "./src/components/settings.js"), controls: __webpack_require__(/*! ./components/controls */ "./src/components/controls.js"), display: __webpack_require__(/*! ./components/display */ "./src/components/display.js"), events: __webpack_require__(/*! ./components/events */ "./src/components/events.js"), footer: __webpack_require__(/*! ./components/footer */ "./src/components/footer.js"), header: __webpack_require__(/*! ./components/header */ "./src/components/header.js"), hotkeys: __webpack_require__(/*! ./components/hotkeys */ "./src/components/hotkeys.js"), minimised: __webpack_require__(/*! ./components/minimised */ "./src/components/minimised.js"), playlist: __webpack_require__(/*! ./components/playlist */ "./src/components/playlist.js"), position: __webpack_require__(/*! ./components/position */ "./src/components/position.js"), threads: __webpack_require__(/*! ./components/threads */ "./src/components/threads.js"), tools: __webpack_require__(/*! ./components/tools */ "./src/components/tools.js"), userTemplate: __webpack_require__(/*! ./components/user-template */ "./src/components/user-template/index.js") }; // Create a global ref to the player. const Player = window.Player = module.exports = { ns, audio: new Audio(), sounds: [], isHidden: true, container: null, ui: {}, // Build the config from the default config: {}, // Helper function to query elements in the player. $: (...args) => Player.container && Player.container.querySelector(...args), $all: (...args) => Player.container && Player.container.querySelectorAll(...args), // Store a ref to the components so they can be iterated. components, // Get all the templates. templates: { body: __webpack_require__(/*! ./templates/body.tpl */ "./src/templates/body.tpl"), controls: __webpack_require__(/*! ./templates/controls.tpl */ "./src/templates/controls.tpl"), css: __webpack_require__(/*! ./scss/style.scss */ "./src/scss/style.scss"), footer: __webpack_require__(/*! ./templates/footer.tpl */ "./src/templates/footer.tpl"), header: __webpack_require__(/*! ./templates/header.tpl */ "./src/templates/header.tpl"), hostInput: __webpack_require__(/*! ./templates/host_input.tpl */ "./src/templates/host_input.tpl"), itemMenu: __webpack_require__(/*! ./templates/item_menu.tpl */ "./src/templates/item_menu.tpl"), list: __webpack_require__(/*! ./templates/list.tpl */ "./src/templates/list.tpl"), player: __webpack_require__(/*! ./templates/player.tpl */ "./src/templates/player.tpl"), settings: __webpack_require__(/*! ./templates/settings.tpl */ "./src/templates/settings.tpl"), threads: __webpack_require__(/*! ./templates/threads.tpl */ "./src/templates/threads.tpl"), threadBoards: __webpack_require__(/*! ./templates/thread_boards.tpl */ "./src/templates/thread_boards.tpl"), threadList: __webpack_require__(/*! ./templates/thread_list.tpl */ "./src/templates/thread_list.tpl"), tools: __webpack_require__(/*! ./templates/tools.tpl */ "./src/templates/tools.tpl"), viewsMenu: __webpack_require__(/*! ./templates/views_menu.tpl */ "./src/templates/views_menu.tpl") }, /** * Set up the player. */ initialize: async function initialize() { if (Player.initialized) { return; } Player.initialized = true; try { Player.sounds = [ ]; // Run the initialisation for each component. for (let name in components) { components[name].initialize && await components[name].initialize(); } if (!is4chan) { // Add a sounds link in the nav for archives const nav = document.querySelector('.navbar-inner .nav:nth-child(2)'); const li = createElement('
  • Sounds
  • ', nav); li.children[0].addEventListener('click', Player.display.toggle); } else if (isChanX) { // If it's already known that 4chan X is running then setup the button for it. Player.display.initChanX(); } else { // Add the [Sounds] link in the top and bottom nav. document.querySelectorAll('#settingsWindowLink, #settingsWindowLinkBot').forEach(function (link) { const showLink = createElement('Sounds', null, { click: Player.display.toggle }); link.parentNode.insertBefore(showLink, link); link.parentNode.insertBefore(document.createTextNode('] ['), link); }); } // Render the player, but not neccessarily show it. Player.display.render(); } catch (err) { Player.logError('There was an error initialzing the sound player. Please check the console for details.', err); // Can't recover so throw this error. throw err; } }, /** * Compare two ids for sorting. */ compareIds: function (a, b) { const [ aPID, aSID ] = a.split(':'); const [ bPID, bSID ] = b.split(':'); const postDiff = aPID - bPID; return postDiff !== 0 ? postDiff : aSID - bSID; }, /** * Check whether a sound src and image are allowed and not filtered. */ acceptedSound: function ({ src, imageMD5 }) { try { const link = new URL(src); const host = link.hostname.toLowerCase(); return !Player.config.filters.find(v => v === imageMD5 || v === host + link.pathname) && Player.config.allow.find(h => host === h || host.endsWith('.' + h)); } catch (err) { return false; } }, /** * Listen for changes */ syncTab: (property, callback) => typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener(property, (_prop, oldValue, newValue, remote) => { remote && callback(newValue, oldValue); }), /** * Send an error notification event. */ logError: function (message, error, type) { console.error('[4chan sounds player]', message, error); if (error instanceof PlayerError) { error.error && console.error('[4chan sound player]', error.error); message = error.reason; type = error.type || type; } document.dispatchEvent(new CustomEvent('CreateNotification', { bubbles: true, detail: { type: type || 'error', content: message, lifetime: 5 } })); } }; // Add each of the components to the player. for (let name in components) { Player[name] = components[name]; (Player[name].atRoot || []).forEach(k => Player[k] = Player[name][k]); } /***/ }), /***/ "./src/scss/style.scss": /*!*****************************!*\ !*** ./src/scss/style.scss ***! \*****************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `.${ns}-text-grey { color: #909090; } .${ns}-controls { align-items: center; padding: 0.5rem; background: #3f3f44; } .${ns}-media-control { height: 1.5rem; width: 1.5rem; display: flex; justify-content: center; align-items: center; cursor: pointer; } .${ns}-media-control > div { height: 1rem; width: 0.8rem; background: white; } .${ns}-media-control:hover > div { background: #00b6f0; } .${ns}-play-button-display { clip-path: polygon(10% 10%, 10% 90%, 35% 90%, 35% 10%, 65% 10%, 65% 90%, 90% 90%, 90% 10%, 10% 10%); } .${ns}-play-button-display.${ns}-play { clip-path: polygon(0 0, 0 100%, 100% 50%, 0 0); } .${ns}-previous-button-display, .${ns}-next-button-display { clip-path: polygon(10% 10%, 10% 90%, 30% 90%, 30% 50%, 90% 90%, 90% 10%, 30% 50%, 30% 10%, 10% 10%); } .${ns}-next-button-display { transform: scale(-1, 1); } .${ns}-fullscreen-button-display { width: 1rem !important; clip-path: polygon(0% 35%, 0% 0%, 35% 0%, 35% 15%, 15% 15%, 15% 35%, 0% 35%, 0% 100%, 35% 100%, 35% 85%, 15% 85%, 15% 65%, 0% 65%, 100% 65%, 100% 100%, 65% 100%, 65% 85%, 85% 85%, 85% 15%, 65% 15%, 65% 0%, 100% 0%, 100% 35%, 85% 35%, 85% 65%, 0% 65%); } .${ns}-controls .${ns}-current-time { color: white; } .${ns}-progress-bar { min-width: 3.5rem; height: 1.5rem; display: flex; align-items: center; margin: 0 1rem; } .${ns}-progress-bar .${ns}-full-bar { height: 0.3rem; width: 100%; background: #131314; border-radius: 1rem; position: relative; } .${ns}-progress-bar .${ns}-full-bar > div { position: absolute; top: 0; bottom: 0; border-radius: 1rem; } .${ns}-progress-bar .${ns}-full-bar .${ns}-loaded-bar { background: #5a5a5b; } .${ns}-progress-bar .${ns}-full-bar .${ns}-current-bar { display: flex; justify-content: flex-end; align-items: center; } .${ns}-progress-bar .${ns}-full-bar .${ns}-current-bar:after { content: ""; background: white; height: 0.8rem; min-width: 0.8rem; border-radius: 1rem; box-shadow: rgba(0, 0, 0, 0.76) 0 0 3px 0; } .${ns}-progress-bar:hover .${ns}-current-bar:after { background: #00b6f0; } .${ns}-seek-bar .${ns}-current-bar { background: #00b6f0; } .${ns}-volume-bar .${ns}-current-bar { background: white; } .${ns}-chan-x-controls { align-items: inherit; } .${ns}-chan-x-controls .${ns}-current-time, .${ns}-chan-x-controls .${ns}-duration { margin: 0 0.25rem; } .${ns}-chan-x-controls .${ns}-media-control { width: 1rem; height: auto; margin-top: -1px; } .${ns}-chan-x-controls .${ns}-media-control > div { height: 0.7rem; width: 0.5rem; } .${ns}-footer { padding: 0.15rem 0.25rem; border-top: solid 1px ${Player.config.colors.border}; } .${ns}-footer .${ns}-expander { position: absolute; bottom: 0px; right: 0px; height: 0.75rem; width: 0.75rem; cursor: se-resize; background: linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 50%, ${Player.config.colors.border} 55%, ${Player.config.colors.border} 100%); } .${ns}-footer:hover .${ns}-hover-display { display: inline-block; } .${ns}-header { cursor: grab; text-align: center; border-bottom: solid 1px ${Player.config.colors.border}; padding: 0.25rem 0.125rem; } .${ns}-header:hover .${ns}-hover-display { display: flex; } html:not(.fourchan-x) .${ns}-header .${ns}-col-auto { margin: 0 0.075rem; } html.fourchan-x .fa-repeat.fa-repeat-one::after { content: "1"; font-size: 0.5rem; visibility: visible; margin-left: -1px; } .${ns}-image-link { text-align: center; display: flex; justify-items: center; justify-content: center; } .${ns}-image-link.${ns}-pip { position: fixed; right: 10px; height: ${Player.config.maxPIPWidth} !important; max-width: ${Player.config.maxPIPWidth}; align-items: end; } .${ns}-image-link.${ns}-pip .${ns}-image, .${ns}-image-link.${ns}-pip .${ns}-video { max-height: 100%; height: initial; width: initial; object-fit: contain; } .${ns}-image-link .${ns}-video { display: none; } .${ns}-image, .${ns}-video { height: 100%; width: 100%; object-fit: contain; } .${ns}-image-link.${ns}-show-video .${ns}-video { display: block; } .${ns}-image-link.${ns}-show-video .${ns}-image { display: none; } #${ns}-container { position: fixed; background: ${Player.config.colors.background}; border: 1px solid ${Player.config.colors.border}; min-width: 100px; color: ${Player.config.colors.text}; } .${ns}-panel { padding: 0 0.25rem; height: 100%; width: calc(100% - .5rem); overflow: auto; } .${ns}-heading { font-weight: 600; margin: 0.5rem 0; min-width: 100%; } .${ns}-has-description { cursor: help; } .${ns}-heading-action { font-weight: normal; text-decoration: underline; margin-left: 0.25rem; } .${ns}-row { display: flex; flex-wrap: wrap; min-width: 100%; box-sizing: border-box; } .${ns}-col-auto { flex: 0 0 auto; width: auto; max-width: 100%; display: inline-flex; } .${ns}-col { flex-basis: 0; flex-grow: 1; max-width: 100%; width: 100%; } .${ns}-align-center { align-items: center; align-content: center; align-self: center; } html.fourchan-x #${ns}-container .fa { font-size: 0; visibility: hidden; margin: 0 0.15rem; } .${ns}-truncate-text { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .${ns}-hover-display { display: none; } html:not(.fourchan-x) .dialog { background: ${Player.config.colors.background}; background: ${Player.config.colors.background}; border-color: ${Player.config.colors.border}; border-radius: 3px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); border-radius: 3px; padding-top: 1px; padding-bottom: 3px; } html:not(.fourchan-x) .${ns}-menu .entry { position: relative; display: block; padding: 0.125rem 0.5rem; min-width: 70px; white-space: nowrap; } html:not(.fourchan-x) .${ns}-menu .has-submenu::after { content: ""; border-left: 0.5em solid; border-top: 0.3em solid transparent; border-bottom: 0.3em solid transparent; display: inline-block; margin: 0.35em; position: absolute; right: 3px; } html:not(.fourchan-x) .${ns}-menu .submenu { position: absolute; display: none; } html:not(.fourchan-x) .${ns}-menu .focused > .submenu { display: block; } .${ns}-player .${ns}-hover-image { position: fixed; max-height: 125px; max-width: 125px; } .${ns}-player.${ns}-hide-hover-image .${ns}-hover-image { display: none !important; } .${ns}-list-container { overflow-y: auto; } .${ns}-list-container .${ns}-list-item { list-style-type: none; padding: 0.15rem 0.25rem; white-space: nowrap; text-overflow: ellipsis; cursor: pointer; background: ${Player.config.colors.odd_row}; overflow: hidden; height: 1.3rem; } .${ns}-list-container .${ns}-list-item.playing { background: ${Player.config.colors.playing} !important; } .${ns}-list-container .${ns}-list-item:nth-child(2n) { background: ${Player.config.colors.even_row}; } .${ns}-list-container .${ns}-list-item .${ns}-item-menu-button { right: 0.25rem; } .${ns}-list-container .${ns}-list-item:hover .${ns}-hover-display { display: flex; } .${ns}-list-container .${ns}-list-item.${ns}-dragging { background: ${Player.config.colors.dragging}; } .${ns}-settings textarea { border: solid 1px ${Player.config.colors.border}; min-width: 100%; min-height: 4rem; box-sizing: border-box; white-space: pre; } .${ns}-settings .${ns}-sub-settings .${ns}-col { min-height: 1.55rem; display: flex; align-items: center; align-content: center; white-space: nowrap; } .${ns}-settings .${ns}-settings-tabs { align-items: center; align-content: center; justify-content: center; } .${ns}-settings .${ns}-settings-tab { margin: 0.25rem; text-decoration: underline; text-align: center; } .${ns}-settings .${ns}-settings-tab.active { font-weight: bold; } .${ns}-settings .${ns}-settings-group { display: none; } .${ns}-settings .${ns}-settings-group.active { display: block; } .${ns}-settings .${ns}-host-input { margin: 0.5rem 0; border-top: solid 1px ${Player.config.colors.border}; } .${ns}-settings .${ns}-host-input.invalid { border: solid 1px red; } .${ns}-settings .${ns}-host-input .${ns}-host-controls { align-items: center; justify-content: space-between; margin: 0.125rem 0; } .${ns}-settings .${ns}-host-input input[type=text] { min-width: 100%; box-sizing: border-box; } .${ns}-threads .${ns}-thread-board-list label { display: inline-block; width: 4rem; } .${ns}-threads .${ns}-thread-list { margin: 1rem -0.25rem 0; padding: 0.5rem 1rem; border-top: solid 1px ${Player.config.colors.border}; } .${ns}-threads .${ns}-thread-list .boardBanner { margin: 1rem 0; } .${ns}-threads table { margin-top: 0.5rem; border-collapse: collapse; } .${ns}-threads table th { border-bottom: solid 1px ${Player.config.colors.border}; } .${ns}-threads table th, .${ns}-threads table td { text-align: left; padding: 0.25rem; } .${ns}-threads table tr { padding: 0.25rem 0; } .${ns}-threads table .${ns}-threads-body tr { background: ${Player.config.colors.even_row}; } .${ns}-threads table .${ns}-threads-body tr:nth-child(2n) { background: ${Player.config.colors.odd_row}; } .${ns}-create-sound-status { margin-top: 0.5rem; border: solid 1px ${Player.config.colors.border}; border-radius: 5px; padding: 0.25rem; } .${ns}-file-input, .${ns}-tools input[type=text] { width: 100%; color: black; } .${ns}-file-overlay, .${ns}-tools input[type=text] { box-sizing: border-box; height: 1.5rem; border: solid 1px ${Player.config.colors.border}; min-width: 5rem; display: flex; align-items: center; padding: 0 0.25rem; } .${ns}-file-input.placeholder span, .${ns}-create-sound-form input[type=text]::placeholder { color: #AAA; opacity: 1; } .${ns}-file-input .${ns}-file-overlay { position: relative; background: white; } .${ns}-file-input .placeholder-text { display: none; } .${ns}-file-input.placeholder .placeholder-text { display: inherit; } .${ns}-file-input span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .${ns}-file-input input[type=file] { width: 100%; box-sizing: border-box; height: 100%; position: absolute; left: 0; opacity: 0; } .${ns}-file-input .overfile { z-index: 9999; } .${ns}-file-input .${ns}-file-list { padding: 0 0.25rem; } .${ns}-file-input .${ns}-file-list:empty { display: none; } .${ns}-input-append { position: absolute; display: flex; align-items: center; background: white; padding-left: 0.25rem; right: 0.125rem; } .${ns}-threads, .${ns}-settings, .${ns}-tools, .${ns}-player { display: none; } #${ns}-container[data-view-style=settings] .${ns}-settings { display: block; } #${ns}-container[data-view-style=threads] .${ns}-threads { display: block; } #${ns}-container[data-view-style=tools] .${ns}-tools { display: block; } #${ns}-container[data-view-style=image] .${ns}-player, #${ns}-container[data-view-style=playlist] .${ns}-player, #${ns}-container[data-view-style=fullscreen] .${ns}-player { display: block; } #${ns}-container[data-view-style=image] .${ns}-list-container, #${ns}-container[data-view-style=image] .${ns}-playlist-search { display: none; } #${ns}-container[data-view-style=image] .${ns}-image-link { height: auto; } #${ns}-container[data-view-style=playlist] .${ns}-image-link { height: 125px !important; } #${ns}-container[data-view-style=fullscreen] .${ns}-image-link { height: calc(100% - .4rem) !important; } #${ns}-container[data-view-style=fullscreen] .${ns}-controls { position: absolute; left: 0; right: 0; bottom: calc(-2.5rem + .4rem); } #${ns}-container[data-view-style=fullscreen] .${ns}-controls:hover { bottom: 0; }` /***/ }), /***/ "./src/templates/body.tpl": /*!********************************!*\ !*** ./src/templates/body.tpl ***! \********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `` /***/ }), /***/ "./src/templates/controls.tpl": /*!************************************!*\ !*** ./src/templates/controls.tpl ***! \************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `
    0:00 / 0:00
    ` /***/ }), /***/ "./src/templates/footer.tpl": /*!**********************************!*\ !*** ./src/templates/footer.tpl ***! \**********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => Player.userTemplate.build({ template: Player.config.footerTemplate, sound: Player.playing }) + `
    ` /***/ }), /***/ "./src/templates/header.tpl": /*!**********************************!*\ !*** ./src/templates/header.tpl ***! \**********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => Player.userTemplate.build({ template: Player.config.headerTemplate, sound: Player.playing, defaultName: '4chan Sounds', outerClass: `${ns}-col-auto` }); /***/ }), /***/ "./src/templates/host_input.tpl": /*!**************************************!*\ !*** ./src/templates/host_input.tpl ***! \**************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => { // data is the host name const host = Player.config.uploadHosts[data]; if (!host) { return ''; } return `
    `; } /***/ }), /***/ "./src/templates/item_menu.tpl": /*!*************************************!*\ !*** ./src/templates/item_menu.tpl ***! \*************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `` /***/ }), /***/ "./src/templates/list.tpl": /*!********************************!*\ !*** ./src/templates/list.tpl ***! \********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => (data.sounds || Player.sounds).map(sound => `
    ${Player.userTemplate.build({ template: Player.config.rowTemplate, sound, outerClass: `${ns}-col-auto` })}
    ` ).join('') /***/ }), /***/ "./src/templates/player.tpl": /*!**********************************!*\ !*** ./src/templates/player.tpl ***! \**********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `
    ${Player.templates.controls(data)}
    ${Player.templates.list(data)}
    ` /***/ }), /***/ "./src/templates/settings.tpl": /*!************************************!*\ !*** ./src/templates/settings.tpl ***! \************************************/ /*! no static exports found */ /***/ (function(module, exports, __webpack_require__) { module.exports = (data = {}) => { const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js"); const groups = settingsConfig.reduce((groups, setting) => { if (setting.displayGroup) { groups[setting.displayGroup] || (groups[setting.displayGroup] = []); groups[setting.displayGroup].push(setting); } return groups; }, {}); let tpl = `
    ${Object.keys(groups).map(name => `${name}` ).join(' | ')} | v${"3.1.0"}
    `; Object.keys(groups).forEach(name => { tpl += `
    `; groups[name].forEach(function addSetting(setting) { // Filter settings with a null display method; if (setting.displayMethod === null) { return; } const desc = setting.description; tpl += `
    ${setting.title} ${(setting.actions || []).map(action => `${action.title}`).join(' ')}
    `; if (setting.text) { tpl += setting.dismissTextId ? `
    ` + Player.display.ifNotDismissed( setting.dismissTextId, setting.dismissRestoreText, `
    ` + setting.text + `Dismiss` + `
    ` ) + `
    ` : setting.text; }; if (setting.settings) { setting.settings.forEach(subSetting => addSetting({ ...setting, actions: null, settings: null, description: null, ...subSetting, isSubSetting: true })); } else { let value = _get(Player.config, setting.property, setting.default), attrs = (setting.attrs || '') + (setting.class ? ` class="${setting.class}"` : '') + ` data-property="${setting.property}"`; if (setting.format) { value = _get(Player, setting.format)(value); } let displayMethod = setting.displayMethod; let displayMethodFunction = _get(Player, displayMethod); let type = typeof value; if (setting.split) { value = value.join(setting.split); } else if (type === 'object') { value = JSON.stringify(value, null, 4); } tpl += `
    ${typeof displayMethodFunction === 'function' ? displayMethodFunction(value, attrs) : type === 'boolean' ? `` : displayMethod === 'textarea' || type === 'object' ? `` : setting.options ? `` : ``}
    `; } tpl += '
    '; }); tpl += '
    '; }); return tpl; } /***/ }), /***/ "./src/templates/thread_boards.tpl": /*!*****************************************!*\ !*** ./src/templates/thread_boards.tpl ***! \*****************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => (Player.threads.boardList || []).map(board => { let checked = Player.threads.selectedBoards.includes(board.board); return !checked && !Player.threads.showAllBoards ? '' : `` }).join('') /***/ }), /***/ "./src/templates/thread_list.tpl": /*!***************************************!*\ !*** ./src/templates/thread_list.tpl ***! \***************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => Object.keys(Player.threads.displayThreads).reduce((rows, board) => { return rows.concat(Player.threads.displayThreads[board].map(thread => ` >>>/${thread.board}/${thread.no} ${thread.sub || ''} ${thread.replies} / ${thread.images} ${timeAgo(thread.time)} ${timeAgo(thread.last_modified)} `)) }, []).join('') /***/ }), /***/ "./src/templates/threads.tpl": /*!***********************************!*\ !*** ./src/templates/threads.tpl ***! \***********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `
    Active Threads ${!Player.threads.loading ? `- Update` : ''}
    Loading
    Filter
    Boards - ${Player.threads.showAllBoards ? 'Selected Only' : 'Show All'}
    ${Player.templates.threadBoards(data)}
    ${ !Player.threads.hasParser || Player.config.threadsViewStyle === 'table' ? `
    Thread Subject Replies/Images Started Updated
    ` : `
    ` }
    ` /***/ }), /***/ "./src/templates/tools.tpl": /*!*********************************!*\ !*** ./src/templates/tools.tpl ***! \*********************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `
    Encode / Decode URL
    Create Sound Image
    ${Player.display.ifNotDismissed('createSoundDetails', 'Show Help', `
    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. If you have an account for a host that you would like to use then make the required changes in the host config. That typically means providing a user token in the data or headers.
    ${Player.tools.hasFFmpeg ? 'Selecting a webm with audio as the image will split it into a video only webm to be posted and ogg audio file to be uploaded.' : 'For a webm with audio first split the webm into a separate video and audio file and select them both.' }
    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
    `)}
    Host
    Data
    Select/Drop Image
    Select/Drop Sound/s
    ${Player.tools.hasFFmpeg && `` || ''} U
    ${Player.tools.createStatusText}
    ` /***/ }), /***/ "./src/templates/views_menu.tpl": /*!**************************************!*\ !*** ./src/templates/views_menu.tpl ***! \**************************************/ /*! no static exports found */ /***/ (function(module, exports) { module.exports = (data = {}) => `` /***/ }) /******/ });