// ==UserScript== // @name 4chan sounds player // @version 1.1.0 // @namespace rccom // @description Play that faggy music weeb boi // @author RCC // @match *://boards.4chan.org/* // @match *://boards.4channel.org/* // @grant GM.getValue // @grant GM.setValue // @run-at document-start // @downloadURL none // ==/UserScript== (function() { 'use strict'; let isChanX; const ns = 'fc-sounds'; function _logError(message, type = 'error') { console.error(message); document.dispatchEvent(new CustomEvent("CreateNotification", { bubbles: true, detail: { type: type, content: message, lifetime: 5 } })); } function _set(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; } function _get(object, path, dflt) { const props = path.split('.'); return props.reduce((obj, k) => obj && obj[k], object) || dflt; } function toDuration(number) { number = Math.floor(number || 0); let seconds = number % 60; const minutes = Math.floor(number / 60) % 60; const hours = Math.floor(number / 60 / 60); seconds < 10 && (seconds = '0' + seconds); return (hours ? hours + ':' : '') + minutes + ':' + seconds; } function _mix (to, from) { for (let key in from || {}) { if (from[key] && typeof from[key] === 'object' && !Array.isArray(from[key])) { to[key] || (to[key] = {}); _mix(to[key], from[key]); } else { to[key] = from[key]; } } } const settingsConfig = [ { property: 'shuffle', default: false }, { property: 'repeat', default: 'all' }, { property: 'viewStyle', default: 'playlist' }, { property: 'autoshow', default: true, title: 'Autoshow', description: 'Automatically show the player when the thread contains sounds.', showInSettings: true }, { property: 'pauseOnHide', default: true, title: 'Pause on hide', description: 'Pause the player when it\'s hidden.', showInSettings: true }, { property: 'hotkeys', default: 'open', title: 'Hotkeys', description: 'Enable hot keys for controlling the player playback.', showInSettings: true, handler: 'hotkeys.apply', options: [ [ 'always', 'Always' ], [ 'open', 'Only with the player open' ], [ 'never', 'Never' ] ] }, { title: 'Hotkey Bindings', showInSettings: true, format: 'hotkeys.stringifyKey', parse: 'hotkeys.parseKey', class: `${ns}-key-input`, property: 'hotkey_bindings', settings: [ { 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.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: 'allow', default: [ '4cdn.org', 'catbox.moe', 'dmca.gripe', 'lewd.se', 'pomf.cat', 'zz.ht' ], title: 'Allow', description: 'Which domains sources are allowed to be loaded from.', showInSettings: true, split: '\n' }, { title: 'Colors', showInSettings: true, property: 'colors', settings: [ { 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.expander', default: '#808bbf', title: 'Expander Color' } ] } ] const Player = {}; const components = { controls: { atRoot: [ 'togglePlay', 'play', 'pause', 'next', 'previous' ], delegatedEvents: { click: { [`.${ns}-previous-button`]: 'previous', [`.${ns}-play-button`]: 'togglePlay', [`.${ns}-next-button`]: 'next', [`.${ns}-seek-bar`]: 'controls.handleSeek', [`.${ns}-volume-bar`]: 'controls.handleVolume', }, 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' }, playing: { [`.${ns}-video`]: 'controls.syncVideo' }, pause: { [`.${ns}-video`]: 'controls.syncVideo' }, loadeddata: { [`.${ns}-video`]: 'controls.syncVideo' } }, audioEvents: { ended: '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' }, /** * Switching being playing and paused. */ togglePlay: function () { if (Player.audio.paused) { Player.play(); } else { Player.pause(); } }, /** * Start playback. */ play: function (sound) { if (!Player.audio) { return; } try { // If nothing is currently selected to play start playing the first sound. if (!sound && !Player.playing && Player.playOrder.length) { sound = Player.playOrder[0]; } // 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.header.render(); Player.audio.src = sound.src; if (sound.image.endsWith('.webm')) { Player.playlist.playVideo(sound); } else { Player.playlist.showImage(sound); } Player.playlist.render(); } Player.audio.play(); } catch (err) { _logError('There was an error playing the sound. Please check the console for details.'); console.error('[4chan sounds player]', err); } }, /** * Pause playback. */ pause: function () { Player.audio && Player.audio.pause(); }, /** * Play the next sound. */ next: function () { Player.controls._movePlaying(1); }, /** * Play the previous sound. */ previous: function () { Player.controls._movePlaying(-1); }, _movePlaying: function (direction) { if (!Player.audio) { return; } try { // If there's no sound fall out. if (!Player.playOrder.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.playOrder.indexOf(Player.playing); if (currentIndex === -1) { return Player.play(Player.playOrder[0]); } // Get the next index, either repeating the same, wrapping round to repeat all or just moving the index. const nextIndex = Player.config.repeat === 'one' ? currentIndex : Player.config.repeat === 'all' ? ((currentIndex + direction) + Player.playOrder.length) % Player.playOrder.length : currentIndex + direction; const nextSound = Player.playOrder[nextIndex]; nextSound && Player.play(nextSound); } catch (err) { _logError(`There was an error selecting the ${direction > 0 ? 'next': 'previous'} track. Please check the console for details.`); console.error('[4chan sounds player]', err); } }, /** * Handle audio events. Sync the video up, and update the controls. */ handleAudioEvent: function () { Player.controls.syncVideo(); Player.controls.updateDuration(); Player.$(`.${ns}-play-button .${ns}-play-button-display`).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 = Player.$(`.${ns}-video`); if (video) { 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; } Player.ui.currentTime.innerHTML = toDuration(Player.audio.currentTime); Player.ui.duration.innerHTML = ' / ' + toDuration(Player.audio.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 (Player._progressBarStyleSheets[id]) { Player._progressBarStyleSheets[id].innerHTML = `${id} .${ns}-current-bar:after { margin-right: ${-.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(); } }, display: { atRoot: [ 'show', 'hide' ], delegatedEvents: { click: { [`.${ns}-close-button`]: 'hide' } }, /** * Create the player show/hide button in to the 4chan X header. */ initChanX: function () { if (Player.display._initedChanX) { return; } Player.display._initedChanX = true; const shortcuts = document.getElementById('shortcuts'); const showIcon = document.createElement('span'); shortcuts.insertBefore(showIcon, document.getElementById('shortcut-settings')); const attrs = { id: 'shortcut-sounds', class: 'shortcut brackets-wrap', 'data-index': 0 }; for (let attr in attrs) { showIcon.setAttribute(attr, attrs[attr]); } showIcon.innerHTML = 'Sounds'; showIcon.querySelector('a').addEventListener('click', Player.display.toggle); }, /** * Generate the data passed to the templates. */ _tplOptions: function () { return { data: Player.config }; }, /** * Render the player. */ render: async function () { try { if (Player.container) { document.body.removeChild(Player.container); document.head.removeChild(Player.stylesheet); } // Insert the stylesheet. Player.stylesheet = document.createElement('style'); Player.stylesheet.innerHTML = Player.templates.css(Player.display._tplOptions()); document.head.appendChild(Player.stylesheet); // Create the main player. const el = document.createElement('div'); el.innerHTML = Player.templates.body(Player.display._tplOptions()); Player.container = el.querySelector(`#${ns}-container`); document.body.appendChild(Player.container); Player.trigger('rendered'); // Keep track of heavily updated elements. Player.ui.currentTime = Player.$(`.${ns}-current-time`); Player.ui.duration = Player.$(`.${ns}-duration`); 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(Player._progressBarStyleSheets[`.${ns}-seek-bar`] = document.createElement('style')); document.head.appendChild(Player._progressBarStyleSheets[`.${ns}-volume-bar`] = document.createElement('style')); Player.controls.updateDuration(); Player.controls.updateVolume(); } catch (err) { _logError('There was an error rendering the sound player. Please check the console for details.'); console.error('[4chan sounds player]', err); // Can't recover, throw. throw err; } }, /** * Change what view is being shown * @param {Chagn} e */ setViewStyle: function (style) { Player.config.viewStyle = style; Player.container.setAttribute('data-view-style', style); }, /** * 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; } try { e && e.preventDefault(); Player._hiddenWhilePolling = !!Player._loadingPoll; Player.controls.stopPollingForLoading(); Player.container.style.display = 'none'; Player.isHidden = true; Player.trigger('hide'); if (Player.config.pauseOnHide) { Player.pause(); } } catch (err) { _logError('There was an error hiding the sound player. Please check the console for details.'); console.error('[4chan sounds player]', err); } }, /** * Show the player. Reapplies the saved position/size, and resumes loadeing polling if it was paused. * @param {*} e */ show: async function (e) { if (!Player.container) { return; } try { e && e.preventDefault(); if (!Player.container.style.display) { return; } Player._hiddenWhilePolling && Player.controls.pollForLoading(); Player.container.style.display = null; Player.isHidden = false; Player.trigger('show'); // Apply the last position/size const [ top, left ] = (await GM.getValue(ns + '.position') || '').split(':'); const [ width, height ] = (await GM.getValue(ns + '.size') || '').split(':'); +width && +height && Player.position.resize(width, height); +top && +left && Player.position.move(top, left); Player.container.focus(); } catch (err) { _logError('There was an error showing the sound player. Please check the console for details.'); console.error('[4chan sounds player]', err); } } }, events: { atRoot: [ 'on', 'off', 'trigger' ], // Holder of event handlers. _events: { }, _delegatedEvents: { }, _undelegatedEvents: { }, _audioEvents: [ ], initialize: function () { const eventLocations = { Player, ...components }; const delegated = Player.events._delegatedEvents; const undelegated = Player.events._undelegatedEvents; const audio = Player.events._audioEvents; for (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)); } this.on('rendered', function () { // Wire up delegated events on the container. for (let evt in delegated) { Player.container.addEventListener(evt, function (e) { for (let eventList of delegated[evt]) { for (let selector in eventList) { const eventTarget = e.target.closest(selector); if (eventTarget) { e.eventTarget = eventTarget; let handler = Player.events.getHandler(eventList[selector]); if (handler) { return handler(e); } } } } }); } // Wire up undelegated events. Player.events.wireUpUndelegated(); // Wire up audio events. for (let eventList of audio) { for (let evt in eventList) { Player.audio.addEventListener(evt, Player.events.getHandler(eventList[evt])); } } }); }, /** * Set, or reset, directly bound events. */ wireUpUndelegated: function () { const undelegated = Player.events._undelegatedEvents; for (let evt in undelegated) { for (let eventList of undelegated[evt]) { for (let selector in eventList) { document.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) { if (await handler(...data) === false) { return; } } }, /** * 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; } }, header: { options: { repeat: { all: { title: 'Repeat All', text: '[RA]', class: 'fa-repeat' }, one: { title: 'Repeat One', text: '[R1]', class: 'fa-repeat fa-repeat-one' }, none: { title: 'No Repeat', text: '[R0]', class: 'fa-repeat disabled' } }, shuffle: { true: { title: 'Shuffled', text: '[S]', class: 'fa-random' }, false: { title: 'Ordered', text: '[O]', class: 'fa-random disabled' }, }, viewStyle: { playlist: { title: 'Hide Playlist', text: '[+]', class: 'fa-compress' }, image: { title: 'Show Playlist', text: '[-]', class: 'fa-expand' } } }, delegatedEvents: { click: { [`.${ns}-shuffle-button`]: 'header.toggleShuffle', [`.${ns}-repeat-button`]: 'header.toggleRepeat', } }, /** * Render the player header. */ render: function () { if (!Player.container) { return; } Player.$(`.${ns}-title`).innerHTML = Player.templates.header(Player.display._tplOptions()); }, /** * Toggle the repeat style. */ toggleRepeat: function (e) { try { e.preventDefault(); const options = Object.keys(Player.header.options.repeat); const current = options.indexOf(Player.config.repeat); Player.config.repeat = options[(current + 4) % 3]; Player.header.render(); Player.settings.save(); } catch (err) { _logError('There was an error changing the repeat setting. Please check the console for details.', 'warning'); console.error('[4chan sounds player]', err); } }, /** * Toggle the shuffle style. */ toggleShuffle: function (e) { try { e.preventDefault(); Player.config.shuffle = !Player.config.shuffle; Player.header.render(); // Update the play order. if (!Player.config.shuffle) { Player.playOrder = [ ...Player.sounds ]; } else { const playOrder = Player.playOrder; for (let i = playOrder.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [playOrder[i], playOrder[j]] = [playOrder[j], playOrder[i]]; } } Player.settings.save(); } catch (err) { _logError('There was an error changing the shuffle setting. Please check the console for details.', 'warning'); console.error('[4chan sounds player]', err); } } }, hotkeys: { initialize: function () { Player.on('rendered', Player.hotkeys.apply); }, _keyMap: { ' ': 'space', 'arrowleft': 'left', 'arrowright': 'right', 'arrowup': 'up', 'arrowdown': 'down' }, addHandler: () => 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('hide', Player.hotkeys.addHandler); Player.off('show', 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.config.hotkeys === 'open' && Player.isHidden) { 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 _get(Player, 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 + 'Key'] = true); return newValue; }, volumeUp: function () { Player.audio.volume = Math.min(Player.audio.volume + .05, 1); }, volumeDown: function () { Player.audio.volume = Math.max(Player.audio.volume - .05, 0); } } , playlist: { atRoot: [ 'add' ], delegatedEvents: { click: { [`.${ns}-viewStyle-button`]: 'playlist.toggleView', [`.${ns}-list`]: 'playlist.handleSelect' }, }, /** * Render the playlist. */ render: function () { if (!Player.container) { return; } if (Player.$(`.${ns}-list`)) { Player.$(`.${ns}-list`).innerHTML = Player.templates.list(Player.display._tplOptions()); } }, /** * Update the image displayed in the player. */ showImage: function (sound, thumb) { if (!Player.container) { return; } Player.playlist.isVideo = false; try { Player.$(`.${ns}-image`).src = thumb ? sound.thumb : sound.image; Player.$(`.${ns}-image-link`).href = sound.image; Player.$(`.${ns}-image-link`).classList.remove(ns + '-show-video'); } catch (err) { _logError('There was an error display the sound player image. Please check the console for details.'); console.error('[4chan sounds player]', err); } }, /** * Play the video for a sound in place of an image. */ playVideo: function (sound) { if (!Player.container) { return; } Player.playlist.isVideo = true; try { Player.$(`.${ns}-video`).src = sound.image; Player.$(`.${ns}-image-link`).href = sound.image; Player.$(`.${ns}-image-link`).classList.add(ns + '-show-video'); } catch (err) { _logError('There was an error display the sound player image. Please check the console for details.'); console.error('[4chan sounds player]', err); } }, /** * Switch between playlist and image view. */ toggleView: function (e) { if (!Player.container) { return; } e && e.preventDefault(); let style = Player.config.viewStyle === 'playlist' ? 'image' : 'playlist'; try { Player.display.setViewStyle(style); Player.header.render(); Player.settings.save(); } catch (err) { _logError('There was an error switching the view style. Please check the console for details.', 'warning'); console.error('[4chan sounds player]', err); } }, /** * Add a new sound from the thread to the player. */ add: function (title, id, src, thumb, image) { try { // Avoid duplicate additions. if (Player.sounds.find(sound => sound.id === id)) { return; } const sound = { title, src, id, thumb, image }; Player.sounds.push(sound); // Add the sound to the play order at the end, or someone random for shuffled. const index = Player.config.shuffle ? Math.floor(Math.random() * Player.sounds.length - 1) : Player.sounds.length; Player.playOrder.splice(index, 0, sound); if (Player.container) { // Re-render the list. Player.playlist.render(); Player.$(`.${ns}-count`).innerHTML = Player.sounds.length; // If nothing else has been added yet show the image for this sound. if (Player.playOrder.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); } } } catch (err) { _logError('There was an error adding to the sound player. Please check the console for details.'); console.log('[4chan sounds player]', title, id, src, thumb, image); console.error('[4chan sounds player]', err); } }, handleSelect: function (e) { const id = e.target.getAttribute('data-id'); const sound = id && Player.sounds.find(function (sound) { return sound.id === '' + id; }); sound && Player.play(sound); } }, position: { delegatedEvents: { mousedown: { [`.${ns}-title`]: 'position.initMove', [`.${ns}-expander`]: 'position.initResize' } }, /** * Handle the user grabbing the expander. */ initResize: function initDrag(e) { e.preventDefault(); Player._startX = e.clientX; Player._startY = e.clientY; Player._startWidth = parseInt(document.defaultView.getComputedStyle(Player.container).width, 10); Player._startHeight = parseInt(document.defaultView.getComputedStyle(Player.container).height, 10); 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 style = document.defaultView.getComputedStyle(Player.container); document.documentElement.removeEventListener('mousemove', Player.position.doResize, false); document.documentElement.removeEventListener('mouseup', Player.position.stopResize, false); GM.setValue(ns + '.size', parseInt(style.width, 10) + ':' + parseInt(style.height, 10)); }, /** * Resize the player. */ resize: function (width, height) { if (!Player.container) { return; } // Make sure the player isn't going off screen. 40 to give a bit of spacing for the 4chanX header. height = Math.min(height, document.documentElement.clientHeight - 40); Player.container.style.width = width + 'px'; // Change the height of the playlist or image. 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`) : null; const containerHeight = parseInt(document.defaultView.getComputedStyle(Player.container).height, 10); const offset = containerHeight - (parseInt(heightElement.style.height, 10) || 0); heightElement.style.height = Math.max(10, height - offset) + 'px'; }, /** * Handle the user grabbing the header. */ initMove: function (e) { e.preventDefault(); Player.$(`.${ns}-title`).style.cursor = 'grabbing'; // Try to reapply the current sizing to fix oversized winows. const style = document.defaultView.getComputedStyle(Player.container); Player.position.resize(parseInt(style.width, 10), parseInt(style.height, 10)); 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 (e) { document.documentElement.removeEventListener('mousemove', Player.position.doMove, false); document.documentElement.removeEventListener('mouseup', Player.position.stopMove, false); Player.$(`.${ns}-title`).style.cursor = null; GM.setValue(ns + '.position', parseInt(Player.container.style.left, 10) + ':' + parseInt(Player.container.style.top, 10)); }, /** * Move the player. */ move: function (x, y) { if (!Player.container) { return; } const style = document.defaultView.getComputedStyle(Player.container); const maxX = document.documentElement.clientWidth; - parseInt(style.width, 10); const maxY = document.documentElement.clientHeight; - parseInt(style.height, 10); Player.container.style.left = Math.max(0, Math.min(x, maxX)) + 'px'; Player.container.style.top = Math.max(0, Math.min(y, maxY)) + 'px'; } }, settings: { delegatedEvents: { click: { [`.${ns}-config-button`]: 'settings.toggle' }, 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' } }, /** * Persist the player settings. */ save: function () { try { return GM.setValue(ns + '.settings', JSON.stringify(Player.config)); } catch (err) { _logError('There was an error saving the sound player settings. Please check the console for details.'); console.error('[4chan sounds player]', err); } }, /** * Restore the saved player settings. */ load: async function () { try { let settings = await GM.getValue(ns + '.settings'); if (!settings) { return; } try { settings = JSON.parse(settings); } catch(e) { return; } _mix(Player.config, settings); } catch (err) { _logError('There was an error loading the sound player settings. Please check the console for details.'); console.error('[4chan sounds player]', err); } }, /** * Toggle whether the player or settings are displayed. */ toggle: function (e) { try { e.preventDefault(); if (Player.config.viewStyle === 'settings') { Player.display.setViewStyle(Player._preSettingsView || 'playlist'); } else { Player._preSettingsView = Player.config.viewStyle; Player.display.setViewStyle('settings'); } } catch (err) { _logError('There was an error rendering the sound player settings. Please check the console for details.'); console.error('[4chan sounds player]', err); // Can't recover, throw. throw err; } }, /** * Handle the user making a change in the settings view. */ handleChange: function (e) { try { const input = e.eventTarget; const property = input.getAttribute('data-property'); let settingConfig; settingsConfig.find(function searchConfig(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; }); // 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); } if (settingConfig && settingConfig.split) { newValue = newValue.split(decodeURIComponent(settingConfig.split)); } // Not the most stringent check but enough to avoid some spamming. if (currentValue !== newValue) { // Update the setting. _set(Player.config, property, newValue); // Update the stylesheet reflect any changes. Player.stylesheet.innerHTML = Player.templates.css(Player.display._tplOptions()); // Save the new settings. Player.settings.save(); } // Run any handler required by the value changing settingConfig && settingConfig.handler && _get(Player, settingConfig.handler, () => null)(newValue); } catch (err) { _logError('There was an error updating the setting. Please check the console for details.'); console.error('[4chan sounds player]', err); } }, handleKeyChange: function (e) { e.preventDefault(); if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Meta') { return; } e.eventTarget.value = Player.hotkeys.stringifyKey(e); } } , }; // 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]); } Object.assign(Player, { ns, audio: new Audio(), sounds: [], isHidden: true, container: null, ui: {}, _progressBarStyleSheets: {}, config: settingsConfig.reduce(function reduceSettings(config, settingConfig) { if (settingConfig.settings) { return settingConfig.settings.reduce(reduceSettings, config); } return _set(config, settingConfig.property, settingConfig.default); }, {}), $: (...args) => Player.container && Player.container.querySelector(...args), templates: { css: ({ data }) => `audio { width: 100%; } .${ns}-controls { align-items: center; padding: .5rem; border-bottom: solid 1px ${data.colors.border}; background: #3f3f44; } .${ns}-media-control { height: 1.5rem; width: 1.5rem; display: flex; justify-content: center; align-items: center; } .${ns}-media-control > div { height: 1rem; width: .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}-current-time { color: white; } .${ns}-duration { color: #909090; } .${ns}-progress-bar { height: 1.5rem; display: flex; align-items: center; margin: 0 1rem; } .${ns}-progress-bar .${ns}-full-bar { height: .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: .8rem; min-width: .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}-volume-bar { width: 3.5rem; } .${ns}-expander { position: absolute; bottom: 0px; right: 0px; height: .75rem; width: .75rem; cursor: se-resize; background: linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 50%, ${data.colors.expander} 55%, ${data.colors.expander} 100%); } .${ns}-footer { padding: .15rem .25rem; border-top: solid 1px ${data.colors.border}; } .${ns}-title { cursor: grab; text-align: center; border-bottom: solid 1px ${data.colors.border}; padding: .25rem 0; } html.fourchan-x .${ns}-title a { font-size: 0; visibility: hidden; margin: 0 0.15rem; } html.fourchan-x .${ns}-title .fa-repeat.fa-repeat-one::after { content: '1'; font-size: .5rem; visibility: visible; margin-left: -1px; } .${ns}-image-link { text-align: center; display: flex; justify-items: center; justify-content: center; border-bottom: solid 1px ${data.colors.border}; } .${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: ${data.colors.background}; border: 1px solid ${data.colors.border}; min-height: 200px; max-height: calc(100% - 40px); min-width: 100px; } .${ns}-row { display: flex; flex-wrap: wrap; } .${ns}-col-auto { flex: 0 0 auto; width: auto; max-width: 100%; margin-left: 0.25rem; } .${ns}-col { flex-basis: 0; flex-grow: 1; max-width: 100%; width: 100%; } .${ns}-list-container { overflow: auto; } .${ns}-list { display: grid; list-style-type: none; padding: 0; margin: 0; } .${ns}-list-item { list-style-type: none; padding: 0.15rem 0.25rem; white-space: nowrap; cursor: pointer; background: ${data.colors.odd_row}; } .${ns}-list-item.playing { background: ${data.colors.playing} !important; } .${ns}-list-item:nth-child(2n) { background: ${data.colors.even_row}; } .${ns}-settings { display: none; padding: 0 .25rem; height: 100%; overflow: auto; } .${ns}-settings .${ns}-setting-header { font-weight: 600; margin: .5rem 0; } .${ns}-settings textarea { border: solid 1px ${data.colors.border}; min-width: 100%; min-height: 4rem; box-sizing: border-box; } #${ns}-container[data-view-style="settings"] .${ns}-player { display: none; } #${ns}-container[data-view-style="settings"] .${ns}-settings { display: block; } #${ns}-container[data-view-style="image"] .${ns}-list-container { display: none; } #${ns}-container[data-view-style="playlist"] .${ns}-image-link { height: 125px !important; } #${ns}-container[data-view-style="image"] .${ns}-image-link { height: auto; min-height: 125px; } `, body: ({ data }) => ``, header: ({ data }) => `
` + Object.keys(Player.header.options).map(key => { let option = Player.header.options[key][data[key]] || Player.header.options[key][Object.keys(Player.header.options[key])[0]]; return ` ${option.text} ` }).join('') + `
${Player.playing ? Player.playing.title : '4chan Sounds'}
Settings X
`, player: ({ data }) => `
${Player.templates.controls({ data })}
`, controls: ({ data }) => `
0:00 / 0:00
`, list: ({ data }) => Player.sounds.map(sound => `
  • ${sound.title}
  • ` ).join(''), settings: ({ data }) => settingsConfig.filter(setting => setting.showInSettings).map(function addSetting(setting) { let out = `
    ${setting.title}
    `; if (setting.settings) { out += `
    ` + setting.settings.map(subSetting => { return addSetting({ ...setting, settings: null, ...subSetting, isSubSetting: true }) }).join('') + `
    `; return out; } let value = _get(data, setting.property, setting.default); let clss = setting.class ? `class="${setting.class}"` : ''; if (setting.format) { value = _get(Player, setting.format)(value); } let type = typeof value; setting.isSubSetting && (out += `
    `); if (type === 'boolean') { out += ``; } else if (setting.showInSettings === 'textarea' || type === 'object') { if (setting.split) { value = value.join(setting.split); } else if (type === 'object') { value = JSON.stringify(value, null, 4); } out += ``; } else if (setting.options) { out += `'; } else { out += ``; } setting.isSubSetting && (out += `
    `); return out; }).join('') }, /** * Set up the player. */ initialize: async function () { try { Player.sounds = [ ]; Player.playOrder = [ ]; // Load the user settings. await Player.settings.load(); // Run the initialisation for each component. for (let name in components) { components[name].initialize && components[name].initialize(); } // If it's already known that 4chan X is running then setup the button for it. // If not add the the [Sounds] link in the top and bottom nav. if (isChanX) { Player.display.initChanX() } else { document.querySelectorAll('#settingsWindowLink, #settingsWindowLinkBot').forEach(function (link) { const bracket = document.createTextNode('] ['); const showLink = document.createElement('a'); showLink.innerHTML = 'Sounds'; showLink.href = 'javascript;'; link.parentNode.insertBefore(showLink, link); link.parentNode.insertBefore(bracket, link); showLink.addEventListener('click', Player.display.toggle); }); } // Render the player, but not neccessarily show it. Player.display.render(); } catch (err) { _logError('There was an error initialzing the sound player. Please check the console for details.'); console.error('[4chan sounds player]', err); // Can't recover so throw this error. throw err; } } }); document.addEventListener('DOMContentLoaded', async function() { await Player.initialize(); parseFiles(document.body); 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 }); }); document.addEventListener('4chanXInitFinished', function () { isChanX = true; Player.display.initChanX(); }); function parseFiles (target) { target.querySelectorAll('.post').forEach(function (post) { if (post.parentElement.parentElement.id === 'qp' || post.parentElement.classList.contains('noFile')) { return; } post.querySelectorAll('.file').forEach(function (file) { parseFile(file, post); }); }); }; function parseFile(file, post) { try { if (!file.classList.contains('file')) { return; } const fileLink = isChanX ? file.querySelector('.fileText .file-info > a') : file.querySelector('.fileText > a'); if (!fileLink) { return; } if (!fileLink.href) { return; } let fileName = null; if (isChanX) { [ file.querySelector('.fileText .file-info .fnfull'), file.querySelector('.fileText .file-info > a') ].some(function (node) { return node && (fileName = node.textContent); }); } else { [ file.querySelector('.fileText'), file.querySelector('.fileText > a') ].some(function (node) { return node && (fileName = node.title || node.tagName === 'A' && node.textContent); }); } if (!fileName) { return; } fileName = fileName.replace(/\-/, '/'); const match = fileName.match(/^(.*)[\[\(\{](?:audio|sound)[ \=\:\|\$](.*?)[\]\)\}]/i); if (!match) { return; } const id = post.id.slice(1); const name = match[1] || id; const fileThumb = post.querySelector('.fileThumb'); const fullSrc = fileThumb && fileThumb.href; const thumbSrc = fileThumb && fileThumb.querySelector('img').src; let link = match[2]; if (link.includes('%')) { try { link = decodeURIComponent(link); } catch (error) { return; } } if (link.match(/^(https?\:)?\/\//) === null) { link = (location.protocol + '//' + link); } try { link = new URL(link); } catch (error) { return; } for (let item of Player.config.allow) { if (link.hostname.toLowerCase() === item || link.hostname.toLowerCase().endsWith('.' + item)) { return Player.add(name, id, link.href, thumbSrc, fullSrc); } } } catch (err) { _logError('There was an issue parsing the files. Please check the console for details.'); console.log('[4chan sounds player]', post) console.error(err); } }; })();