// ==UserScript== // @name 4chan sounds // @version 0.1.2 // @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 repeatOptions = { all: 'Repeat All', one: 'Repeat One', none: 'No Repeat' }; const Player = { ns: 'fc-sounds', sounds: [], container: null, ui: {}, settings: { shuffle: false, repeat: Object.keys(repeatOptions)[0], autoshow: true, colors: { background: '#d6daf0', border: '#b7c5d9', odd_row: '#d6daf0', even_row: '#b7c5d9', expander: '#808bbf', expander_hover: '#9aa6e1', playing: '#98bff7' }, allow: [ "4cdn.org", "catbox.moe", "dmca.gripe", "lewd.se", "pomf.cat", "zz.ht" ] }, _templates: { css: ({ ns, colors }) => `#${ns}-container { position: fixed; background: ${colors.background}; border: 1px solid ${colors.border}; display: relative; min-height: 200px; min-width: 100px; } .${ns}-show-settings .${ns}-player { display: none; } .${ns}-setting { display: none; } .${ns}-settings { display: none; padding: .25rem; } .${ns}-show-settings .${ns}-settings { display: block; } .${ns}-settings .${ns}-setting-header { font-weight: 600; margin-top: 0.25rem; } .${ns}-settings textarea { border: solid 1px ${colors.border}; min-width: 100%; min-height: 4rem; box-sizing: border-box; } .${ns}-title { cursor: grab; text-align: center; border-bottom: solid 1px ${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 { height: 128px; text-align: center; display: flex; justify-items: center; justify-content: center; border-bottom: solid 1px ${colors.border}; } .${ns}-image-link .${ns}-video { display: none; } .${ns}-image-link.${ns}-show-video .${ns}-video { display: block; } .${ns}-image-link.${ns}-show-video .${ns}-image { display: none; } .${ns}-image, .${ns}-video { max-height: 125px; } .${ns}-audio { width: 100%; } .${ns}-list-container { overflow: scroll; } .${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; } .${ns}-list-item.playing { background: ${colors.playing} !important; } .${ns}-list-item:nth-child(n) { background: ${colors.odd_row}; } .${ns}-list-item:nth-child(2n) { background: ${colors.even_row}; } .${ns}-expander { position: absolute; bottom: 0px; right: 0px; height: 12px; width: 12px; cursor: se-resize; background: linear-gradient(to bottom right, rgba(0,0,0,0), rgba(0,0,0,0) 50%, ${colors.expander} 55%, ${colors.expander} 100%) } .${ns}-expander:hover { background: linear-gradient(to bottom right, rgba(0,0,0,0), rgba(0,0,0,0) 50%, ${colors.expander_hover} 55%, ${colors.expander_hover} 100%) }`, body: ({ ns }) => ``, list: ({ ns }) => Player.sounds.map(sound => `
  • ${sound.title}
  • `).join(''), settings: ({ ns, colors, allow, autoshow }) => `
    Autoshow
    Allow
    Background Color
    Border Color
    Odd Row Color
    Even Row Color
    Playing Row Color
    Expand Color
    Expand Hover Color
    ` }, initialize: async function () { await Player.loadSettings(); Player.sounds = [ ]; Player.playOrder = [ ]; if (isChanX) { 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.toggleDisplay); } 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.toggleDisplay); }); } Player.render(); }, _tplOptions: function () { return { ns: Player.ns, ...Player.settings }; }, render: async function () { 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._tplOptions()); document.head.appendChild(Player.stylesheet); // Create the main player const el = document.createElement('div'); el.innerHTML = Player._templates.body(Player._tplOptions()); Player.container = el.querySelector(`#${Player.ns}-container`); document.body.appendChild(Player.container); // Keep track of various elements Player.ui.title = Player.container.querySelector(`.${Player.ns}-title`); Player.ui.closeButton = Player.container.querySelector(`.${Player.ns}-close-button`); Player.ui.repeatButton = Player.container.querySelector(`.${Player.ns}-repeat-button`); Player.ui.shuffleButton = Player.container.querySelector(`.${Player.ns}-shuffle-button`); Player.ui.configButton = Player.container.querySelector(`.${Player.ns}-config-button`) Player.ui.imageLink = Player.container.querySelector(`.${Player.ns}-image-link`); Player.ui.image = Player.container.querySelector(`.${Player.ns}-image`); Player.ui.video = Player.container.querySelector(`.${Player.ns}-video`); Player.ui.listContainer = Player.container.querySelector(`.${Player.ns}-list-container`); Player.ui.list = Player.container.querySelector(`.${Player.ns}-list`); Player.ui.settingsContainer = Player.container.querySelector(`.${Player.ns}-settings`); Player.ui.expander = Player.container.querySelector(`.${Player.ns}-expander`); Player.audio = Player.container.querySelector(`.${Player.ns}-audio`); // Render the other bits and make sure the buttons states are correct Player.renderList(); Player.renderSettings(); Player.updateRepeatButton(); Player.updateShuffleButton(); // Add the event listeners for selecting a song Player.ui.list.addEventListener('click', 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); }); // Add event listeners for the title buttons Player.ui.closeButton.addEventListener('click', Player.hide); Player.ui.configButton.addEventListener('click', Player.toggleSettings); Player.ui.shuffleButton.addEventListener('click', Player.toggleShuffle); Player.ui.repeatButton.addEventListener('click', Player.toggleRepeat); // Add event listeners for moving/resizing Player.ui.expander.addEventListener('mousedown', Player.initResize, false); Player.ui.title.addEventListener('mousedown', Player.initMove, false); // Add audio event listeners Player.audio.addEventListener('ended', Player.next); Player.audio.addEventListener('pause', () => Player.ui.video.pause()); Player.audio.addEventListener('play', () => { Player.ui.video.currentTime = Player.audio.currentTime; Player.ui.video.play(); }); Player.audio.addEventListener('seeked', () => Player.ui.video.currentTime = Player.audio.currentTime); }, renderList: function () { if (Player.ui.list) { Player.ui.list.innerHTML = Player._templates.list(Player._tplOptions()); } }, renderSettings: function () { if (Player.ui.settingsContainer) { Player.ui.settingsContainer.innerHTML = Player._templates.settings(Player._tplOptions()); Player.ui.settingsContainer.querySelectorAll('input, textarea').forEach(function (input) { input.addEventListener('blur', Player.handleSettingChange); }); Player.ui.settingsContainer.querySelectorAll('input[type=checkbox]').forEach(function (input) { input.addEventListener('change', Player.handleSettingChange); }); } }, hide: function (e) { e && e.preventDefault(); Player.container.style.display = 'none'; }, show: async function (e) { e && e.preventDefault(); if (!Player.container.style.display) { return; } Player.container.style.display = null; return; // Apply the last position/size const [ top, left ] = (await GM.getValue(Player.ns + '.position') || '').split(':'); const [ width, height ] = (await GM.getValue(Player.ns + '.size') || '').split(':'); +width && +height && Player.resizeTo(width, height); +top && +left && Player.moveTo(top, left); }, toggleDisplay: function (e) { e && e.preventDefault(); if (Player.container.style.display === 'none') { Player.show(); } else { Player.hide(); } }, saveSettings: function () { return GM.setValue(Player.ns + '.settings', JSON.stringify(Player.settings)); }, loadSettings: async function () { let settings = await GM.getValue(Player.ns + '.settings'); if (!settings) { return; } try { settings = JSON.parse(settings); } catch(e) { return; } 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]; } } } _mix(Player.settings, settings); }, handleSettingChange: function (e) { const input = e.currentTarget; const property = input.getAttribute('data-property').split('.'); const split = input.getAttribute('data-split'); const currentValue = property.reduce((v, k) => v && v[k], Player.settings); let newValue = input.getAttribute('type') === 'checkbox' ? input.checked : input.value; if (split) { newValue = newValue.split(split === 'linebreak' ? '\n' : split); } // Not the most stringent check but enough to avoid some spamming. if (currentValue !== newValue) { // Update the setting. const lastProp = property.pop(); const setOn = property.reduce((obj, k) => obj && obj[k], Player.settings); setOn && (setOn[lastProp] = newValue); // Update the stylesheet reflect any changes. Player.stylesheet.innerHTML = Player._templates.css(Player._tplOptions()); // Save the new settings. Player.saveSettings(); } }, toggleSettings: function (e) { e.preventDefault(); if (Player.container.classList.contains(Player.ns + '-show-settings')) { Player.container.classList.remove(Player.ns + '-show-settings'); } else { Player.container.classList.add(Player.ns + '-show-settings'); } }, toggleShuffle: function (e) { e.preventDefault(); Player.settings.shuffle = !Player.settings.shuffle; Player.updateShuffleButton(); // Update the play order. if (!Player.settings.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.saveSettings(); }, updateShuffleButton: function () { const action = Player.settings.shuffle ? 'remove' : 'add'; Player.ui.shuffleButton.classList[action]('disabled'); Player.ui.shuffleButton.innerHTML = Player.settings.shuffle ? 'Shuffle' : 'Ordered'; Player.ui.shuffleButton.title = isChanX && Player.ui.shuffleButton.innerHTML; }, toggleRepeat: function (e) { e.preventDefault(); const options = Object.keys(repeatOptions); const current = options.indexOf(Player.settings.repeat); Player.settings.repeat = options[(current + 4) % 3]; Player.updateRepeatButton(); Player.saveSettings(); }, updateRepeatButton: function () { Player.ui.repeatButton.innerHTML = repeatOptions[Player.settings.repeat]; Player.ui.repeatButton.title = isChanX && Player.ui.repeatButton.innerHTML; const disabled = Player.settings.repeat === 'none'; const addOne = Player.settings.repeat === 'one'; Player.ui.repeatButton.classList[disabled ? 'add' : 'remove']('disabled'); Player.ui.repeatButton.classList[addOne ? 'add' : 'remove']('fa-repeat-one'); }, initResize: function initDrag(e) { disableUserSelect(); 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.doResize, false); document.documentElement.addEventListener('mouseup', Player.stopResize, false); }, doResize: function(e) { Player.resizeTo(Player._startWidth + e.clientX - Player._startX, Player._startHeight + e.clientY - Player._startY); }, resizeTo: function (width, height) { Player.container.style.width = width + 'px'; Player.ui.listContainer.style.height = Math.max(10, height - 194) + 'px'; }, stopResize: function(e) { const style = document.defaultView.getComputedStyle(Player.container); document.documentElement.removeEventListener('mousemove', Player.doResize, false); document.documentElement.removeEventListener('mouseup', Player.stopResize, false); enableUserSelect(); GM.setValue(Player.ns + '.size', parseInt(style.width, 10) + ':' + parseInt(style.height, 10)); }, initMove: function (e) { disableUserSelect(); Player.ui.title.style.cursor = 'grabbing'; Player._offsetX = e.clientX - Player.container.offsetLeft; Player._offsetY = e.clientY - Player.container.offsetTop; document.documentElement.addEventListener('mousemove', Player.doMove, false); document.documentElement.addEventListener('mouseup', Player.stopMove, false); }, doMove: function (e) { Player.moveTo(e.clientX - Player._offsetX, e.clientY - Player._offsetY); }, moveTo: function (x, y) { 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'; }, stopMove: function (e) { document.documentElement.removeEventListener('mousemove', Player.doMove, false); document.documentElement.removeEventListener('mouseup', Player.stopMove, false); Player.ui.title.style.cursor = null; enableUserSelect(); GM.setValue(Player.ns + '.position', parseInt(Player.container.style.left, 10) + ':' + parseInt(Player.container.style.top, 10)); }, showThumb: function (sound) { Player.ui.imageLink.classList.remove(Player.ns + '-show-video'); Player.ui.image.src = sound.thumb; Player.ui.imageLink.href = sound.image; }, showImage: function (sound) { Player.ui.imageLink.classList.remove(Player.ns + '-show-video'); Player.ui.image.src = sound.image; Player.ui.imageLink.href = sound.image; }, playVideo: function (sound) { Player.ui.imageLink.classList.add(Player.ns + '-show-video'); Player.ui.video.src = sound.image; Player.ui.video.play(); }, add: function (title, id, src, thumb, image) { 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.settings.shuffle ? Math.floor(Math.random() * Player.sounds.length - 1) : Player.sounds.length; Player.playOrder.splice(index, 0, sound); // Re-render the list Player.renderList(); // 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.settings.autoshow) { Player.show(); } Player.showThumb(sound); } }, play: function (sound) { if (sound) { if (Player.playing) { const currentItem = Player.ui.list.querySelector('.playing'); currentItem && currentItem.classList.remove('playing'); } const item = Player.ui.list.querySelector(`li[data-id="${sound.id}"]`); item && item.classList.add('playing'); Player.playing = sound; Player.audio.src = sound.src; if (sound.image.endsWith('.webm')) { Player.playVideo(sound); } else { Player.showImage(sound); } } Player.audio.play(); }, pause: function () { Player.audio.pause(); }, next: function () { Player._movePlaying(1); }, previous: function () { Player._movePlaying(-1); }, _movePlaying: function (direction) { // 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.playSound(Player.playOrder[0]); } // Get the next index, either repeating the same, wrapping round to repeat all or just moving the index. const nextIndex = Player.settings.repeat === 'one' ? currentIndex : Player.settings.repeat === 'all' ? ((currentIndex + direction) + Player.playOrder.length) % Player.playOrder.length : currentIndex + direction; const nextSound = Player.playOrder[nextIndex]; nextSound && Player.play(nextSound); } }; async function doInit() { 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("DOMContentLoaded", function (event) { setTimeout(function () { if (!isChanX) { doInit(); } }, 1); }); document.addEventListener( "4chanXInitFinished", function (event) { if (document.documentElement.classList.contains("fourchan-x") && document.documentElement.classList.contains("sw-yotsuba")) { isChanX = true; doInit(); } }); 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) { 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.settings.allow) { if (link.hostname.toLowerCase() === item || link.hostname.toLowerCase().endsWith("." + item)) { return Player.add(name, id, link.href, thumbSrc, fullSrc); } } }; function disableUserSelect () { document.body.style.userSelect = 'none'; document.body.style.MozUserSelect = 'none'; } function enableUserSelect () { document.body.style.userSelect = null; document.body.style.MozUserSelect = null; } })();