// ==UserScript==
// @name 8chan sounds player
// @version 2.3.0_0010
// @namespace 8chanss
// @description Play that faggy music weeb boi
// @author original by: RCC; ported to 8chan by: soundboy_1459944
// @website https://greasyfork.org/en/scripts/533468-8chan-sounds-player
// @match https://8chan.moe/*/res/*
// @match https://8chan.se/*/res/*
// @connect 4chan.org
// @connect 4channel.org
// @connect a.4cdn.org
// @connect 8chan.moe
// @connect 8chan.se
// @connect desu-usergeneratedcontent.xyz
// @connect arch-img.b4k.co
// @connect archive-media-0.nyafuu.org
// @connect 4cdn.org
// @connect a.pomf.cat
// @connect pomf.cat
// @connect litter.catbox.moe
// @connect files.catbox.moe
// @connect catbox.moe
// @connect share.dmca.gripe
// @connect z.zz.ht
// @connect z.zz.fo
// @connect zz.ht
// @connect too.lewd.se
// @connect lewd.se
// @connect *
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.xmlHttpRequest
// @grant GM_addValueChangeListener
// @run-at document-start
// @license CC0 1.0
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAOwAAADsABataJCQAAABh0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMS42/U4J6AAAAs9JREFUaEPdmlFu20AMRIX2FL56c73mDgWM1GWXjwI9srQro5apPsxHlhyOPchPPjJ9//g8l779+Gman+crIPofC0wJWRVUp4Ah26dF3AKxmVisoGZ5mzDeI569ImWFcaeRzX/98jZhXCC2XSJinSec+OVtwvgIcY6L+3WecOKXtwnjNN1uN35KiHlEXAYWO8PoUQEcAdO9BfKlI+YRcdnwzBmmLy3gMGqIvyvOFt/JYHFAAYNpQ062xc3bCxgsGnK1IQ6qFXDk9qGwVijgsA7kfCl8ZQsYkiDCVKeAgSMhIVk4ShVw8AWSM4t1wQIG1kCiXOxqFnA4aEiaiUXlAgY3wWAgiwoFDM6CkUAWRQo4HDc88HK5+BNHwudG99OZHlDA4L5hgVbAO7BOuMfofjrTYwoYRATegV2CdcECBimNUxYwCGqcsoBBVsA0YFq5gEFcg1HAtHgBg8QGowaj+gUMQhuMzlXAIPe8BQyiI5zH6X4Dhky6n870jQUIbTA6SwHiAqYNRpULkBUwDZiWLUBQwDTBomYBUoKT/S1ERND+GD1JAY4DC/Rvbz/jSLjH6H4601cX4DLwQP/2BqaEz43upzN9aQHOgpFAFv+wAK4Gp2NwEzzMxJpgUa3AWibWBIsXFbh+cdyFg4YEmlgcXMBuBgtw0JA0F7uaBXAHkuZid3ABg9MVMCUkahbrUgVwJCQnC8cxBUwY1wuwTkiCCFORAuwScr4UvoMLcHePrzJy+1BYjymAa/PDHDncEAcDBXg0cARMdxW4fv32Y4dFQ662xc36dzKyzcEUMN1VIMO0ISddcfauArwTctIVZ9P068qvlHewZpth0ZhjudJ3sLx0xD8iLuOb8UgsbVlMgzmWK31vIuZBcbzOoM2YM2ftKCDOXSLiHvGYWKwgZtdoAbHtFSkJMcxivUBss/oFxFBN1b9fV5P/N3v+h/Yz6ePzD965YtNhR5uIAAAAAElFTkSuQmCC
// ==/UserScript==
//kudos to the original sound player by RCC: https://github.com/rcc11/4chan-sounds-player
// config
const media_display_min_height = '25px';
const media_display_max_height = '400px';
const minimized_display_max_height = '200px';
const minimized_display_max_width = '250px';
(function(modules) { // webpackBootstrap
'use strict';
// 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 = 3);
})
([
/* 0 - File Parser
• parseFileName(): Extracts sound URLs from filenames using regex pattern [sound=URL]
• parsePost(): Processes individual posts to find sound files and create play buttons
• parseFiles(): Scans the page or specific elements for posts containing sounds
• Key Features:
o Handles URL decoding
o Creates unique IDs for each sound
o Generates play links next to sound files
*/
(function(module, exports) {
const protocolRE = /^(https?:)?\/\//;
const filenameRE = /(.*?)[[({](?:audio|sound)[ =:|$](.*?)[\])}]/g;
const videoFileExtRE = /\.(webm|mp4)$/i;
let localCounter = 0;
function getFullFilename(element) {
if (element.dataset.fileExt) {
return element.textContent + element.nextElementSibling.textContent;
}
return element.textContent;
}
function formatFileTitle(postId, fileIndex, fileSize, filename) {
// Convert fileSize (assumed to be a string like "99.50 KB" or "1.82 MB") into MB
let sizeValue = parseFloat(fileSize);
let sizeInMB = 0;
if (fileSize.toLowerCase().includes("kb")) {
sizeInMB = sizeValue / 1024;
} else if (fileSize.toLowerCase().includes("mb")) {
sizeInMB = sizeValue;
}
// Round up to 1 decimal place
sizeInMB = Math.ceil(sizeInMB * 10) / 10;
// Cap anything over 99.5 MB
// Omit the .0 when it's a whole number like 11.0 MB (11.0 MB → 11 MB).
let displaySize = sizeInMB > 99.5
? "99+ MB"
: `${(sizeInMB > 9.9 ? ' ' + sizeInMB.toFixed(0): sizeInMB.toFixed(1))} MB`;
// Extract file extension
const fileExt = filename.split('.').pop().toLowerCase();
// Get base filename without extension
const baseName = filename.replace(/\.[^/.]+$/, "");
let spaceVar = '0';
if (fileExt.length > 3) {
spaceVar = ' ';
} else if (fileExt.length === 3) {
spaceVar = ' ';
} else if (fileExt.length < 3) {
spaceVar = ' ; ';
}
return `${postId} ${displaySize} .${fileExt} ${spaceVar} ${baseName}`;
}
function getPostNumber(postElement) {
// First try to get it from the element's ID
/*if (postElement.id && /^\d+$/.test(postElement.id)) {
return postElement.id;
}*/
// If not found in ID, look for the linkQuote element
const linkQuote = postElement.querySelector('.linkQuote');
if (linkQuote && linkQuote.textContent && /^\d+$/.test(linkQuote.textContent)) {
return linkQuote.textContent;
}
// Fallback to a generated ID if nothing else works
return 'idGrabFailed';
}
function parseFileName(filename, image, post, thumb, imageMD5, fileIndex, fileSize) {
if (!filename) return [];
filename = filename.replace(/-/, '/');
// First check for [sound=URL] tags
const matches = [];
let match;
while ((match = filenameRE.exec(filename)) !== null) {
matches.push(match);
}
// If we found sound tags, process them and ignore video files
if (matches.length) {
const defaultName = formatFileTitle(post, fileIndex, fileSize, filename);
return matches.reduce((sounds, match, i) => {
let src = match[2];
const id = post + ':' + fileIndex + ':' + i;
const title = match[1].trim() || defaultName + (matches.length > 1 ? ` (${i + 1})` : '');
try {
// Fix for Firefox issue: replace underscores that were mistakenly used instead of percent-encoding
if (src.includes('_') && !src.includes('%')) {
src = src.replace(/_/g, '%');
}
if (src.includes('%')) {
src = decodeURIComponent(src);
}
if (src.match(protocolRE) === null) {
src = (location.protocol + '//' + src);
}
} catch (error) {
return sounds;
}
const sound = {
src,
id,
title: formatFileTitle(post, fileIndex, fileSize, filename),
post,
image,
filename,
thumb,
imageMD5,
isVideo: false,
hasSoundTag: true,
fileIndex: fileIndex,
};
Player.acceptedSound(sound) && sounds.push(sound);
return sounds;
}, []);
}
// If no sound tags found, check for video files
const isVideoFile = videoFileExtRE.test(filename);
if (isVideoFile) {
const id = post + ':' + fileIndex + ':0';
return [{
src: image, // Use the image URL as src for video files
id,
title: formatFileTitle(post, fileIndex, fileSize, filename),
post,
image,
filename,
thumb,
imageMD5,
type: filename.endsWith('.webm') ? 'video/webm' : 'video/mp4',
isVideo: true,
hasSoundTag: false,
fileIndex: fileIndex
}];
}
return [];
}
function parsePost(post, skipRender) {
try {
// Get the actual post number for this post
const postNumber = getPostNumber(post);
if (!postNumber) return;
// If there are existing play links, just reconnect their handlers
const existingLinks = post.querySelectorAll(`.${ns}-play-link`);
if (existingLinks.length > 0) {
existingLinks.forEach(link => {
const id = link.getAttribute('data-id');
link.onclick = () => Player.play(Player.sounds.find(sound => sound.id === id));
});
return;
}
// Get all file containers in the post
const fileContainers = post.querySelectorAll('.uploadCell');
if (!fileContainers || fileContainers.length === 0) return;
let allSounds = [];
// Process each file in the post
fileContainers.forEach((container, fileIndex) => {
let filename = null;
let fileLink = null;
let fileSize = "0 KB";
// Try to get filename from various locations
const originalNameLink = container.querySelector('.originalNameLink');
if (originalNameLink) {
filename = getFullFilename(originalNameLink);
}
// Get file size if available
const sizeLabel = container.querySelector('.sizeLabel');
if (sizeLabel) {
fileSize = sizeLabel.textContent.trim();
}
// If no filename found via standard selectors, try to find file links
if (!filename) {
const fileLinkEl = container.querySelector('.nameLink');
if (fileLinkEl) {
fileLink = fileLinkEl.href;
filename = fileLink.split('/').pop();
}
}
if (!filename) return;
const fileThumb = container.querySelector('.imgLink');
const imageSrc = fileThumb && fileThumb.href;
const thumbImg = fileThumb && fileThumb.querySelector('img');
const thumbSrc = thumbImg && thumbImg.src;
const md5Match = thumbImg && thumbImg.src.match(/\/\.media\/(t_)?([a-f0-9]+)/);
const imageMD5 = md5Match && md5Match[2];
const sounds = parseFileName(filename, imageSrc, postNumber, thumbSrc, imageMD5, fileIndex, fileSize);
if (!sounds.length) return;
allSounds = allSounds.concat(sounds);
// Create play link for this file
const firstID = sounds[0].id;
const text = '▶︎';
const clss = `${ns}-play-link`;
let playLinkParent = container.querySelector('.uploadDetails') ||
container.querySelector('.fileLink') ||
container.querySelector('.fileText') ||
container; // Fallback to the container itself
if (playLinkParent) {
const playLink = document.createElement('a');
playLink.href = "javascript:;";
playLink.className = clss;
playLink.setAttribute('data-id', firstID);
playLink.textContent = text;
playLink.title = 'play';
playLink.style.display = 'inline-block'; // Ensure the link is displayed inline
playLink.style.marginLeft = '3px'; // Add some spacing
playLink.onclick = () => Player.play(Player.sounds.find(sound => sound.id === firstID));
playLinkParent.appendChild(document.createTextNode(' '));
playLinkParent.appendChild(playLink);
}
});
if (allSounds.length === 0) return;
allSounds.forEach(sound => Player.add(sound, skipRender));
return allSounds.length > 0;
} catch (err) {
console.error('[8chan sounds player] Error parsing post:', err);
}
}
function parseFiles(target, postRender) {
let addedSounds = false;
let posts = target.classList && target.classList.contains('postCell') ?
[target] :
target.querySelectorAll('.innerOP, .innerPost');
posts.forEach(post => parsePost(post, postRender) && (addedSounds = true));
if (addedSounds && postRender && Player.container) {
Player.playlist.render();
}
}
module.exports = {
parseFiles,
parsePost,
parseFileName
};
}),
/* 1 - Settings Configuration
• Contains all default configuration options for the player:
o Playback settings (shuffle, repeat)
o UI settings (view styles, hover images)
o Keybindings
o Allowed hosts list
o Color schemes
o Template layouts
• Defines the structure for:
o Header/footer/row templates
o Hotkey bindings
o Player appearance settings
*/
(function(module, exports) {
module.exports = [{
property: 'shuffle',
default: false
},
{
property: 'repeat',
default: 'all'
},
{
property: 'viewStyle',
default: 'playlist'
},
{
property: 'hoverImages',
default: false
},
{
property: 'preventHoverImagesFor',
default: [],
save: false
},
{
property: 'autoshow',
default: true,
title: 'Autoshow',
description: 'Automatically show the player when the thread contains sounds.',
showInSettings: true,
settings: [{
title: 'Enabled'
}]
},
{
property: 'pauseOnHide',
default: true,
title: 'Pause on hide',
description: 'Pause the player when it\'s hidden.',
showInSettings: true,
settings: [{
title: 'Enabled'
}]
},
{
title: 'Minimised Display',
description: 'Optional displays for when the player is minimised.',
settings: [{
property: 'pip',
title: 'Thumbnail',
description: 'Display a fixed thumbnail of the playing sound in the bottom right of the thread.',
default: true,
showInSettings: true
},
{
property: 'maxPIPWidth',
title: 'Max Width',
description: 'Maximum width for the thumbnail.',
default: '150px',
updateStylesheet: true,
showInSettings: true
},
{
property: 'chanXControls',
title: '4chan X Header Controls',
description: 'Show playback controls in the 4chan X header. Customise the template below.',
showInSettings: isChanX,
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.',
showInSettings: true,
settings: [{
property: 'limitPostWidths',
title: 'Enabled',
default: false
},
{
property: 'minPostWidth',
title: 'Minimum Width',
default: '50%'
}
]
},
{
property: 'showSoundTagOnly',
default: false,
title: 'Show Sound Tag Posts Only',
description: 'When enabled, only posts with [sound=URL] tags will be displayed',
showInSettings: true,
settings: [{
title: 'Enabled'
}]
},
{
property: 'threadsViewStyle',
title: 'Threads View',
description: 'How threads in the threads view are listed.',
showInSettings: true,
settings: [{
title: 'Display',
default: 'table',
options: {
table: 'Table',
board: 'Board'
}
}]
},
{
title: 'Keybinds',
showInSettings: true,
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.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.scrollToPlaying',
title: 'Jump To Playing',
keyHandler: 'playlist.scrollToPlaying',
default: {
key: ''
}
},
{
property: 'hotkey_bindings.toggleHoverImages',
title: 'Toggle Hover Images',
keyHandler: 'playlist.toggleHoverImages',
default: {
key: ''
}
}
]
},
{
property: 'allow',
title: 'Allowed Hosts',
description: 'Which domains sources are allowed to be loaded from.',
default: [
'4cdn.org',
'8chan.se',
'8chan.moe',
'catbox.moe',
'dmca.gripe',
'lewd.se',
'pomf.cat',
'zz.ht'
],
actions: [{
title: 'Reset',
handler: 'settings.reset'
}],
showInSettings: true,
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'
}],
showInSettings: true,
split: '\n'
},
{
property: 'headerTemplate',
title: 'Header Contents',
actions: [{
title: 'Reset',
handler: 'settings.reset'
}],
//default: 'repeat-button shuffle-button hover-images-button playlist-button\nsound-name\nadd-button reload-button threads-button settings-button close-button',
default: 'repeat-button | shuffle-button | hover-images-button | playlist-button\nsound-name\n reload-button settings-button close-button',
showInSettings: 'textarea',
},
{
property: 'rowTemplate',
title: 'Row Contents',
actions: [{
title: 'Reset',
handler: 'settings.reset'
}],
default: 'sound-name h:{menu-button}',
showInSettings: 'textarea'
},
{
property: 'footerTemplate',
title: 'Footer Contents',
actions: [{
title: 'Reset',
handler: 'settings.reset'
}],
default: 'playing-button:"sound-index /" sound-count files\n' +
'p:{\n' +
' <div style="float: right; margin-right: .5rem">\n' +
' sound-tag-toggle-button:"[ST]"' +
' post-link\n' +
' Open [ image-link sound-link ]\n' +
' Download [ dl-image-button dl-sound-button ]\n' +
' </div>\n' +
'}',
description: 'Template for the footer contents',
showInSettings: 'textarea',
attrs: 'style="height:120px;"'
},
{
property: 'chanXTemplate',
title: '4chan X Header Controls',
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'
}],
showInSettings: 'textarea'
},
{
title: 'Colors',
showInSettings: true,
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'
}
]
/*
settings: [{
property: 'colors.text',
default: '#FFFFFF',
title: 'Text Color'
},
{
property: 'colors.background',
default: '#282A2E',
title: 'Background Color'
},
{
property: 'colors.border',
default: '#C5C8C6',
title: 'Border Color'
},
{
property: 'colors.odd_row',
default: '#232323',
title: 'Odd Row Color',
},
{
property: 'colors.even_row',
default: '#3A3A3A',
title: 'Even Row Color'
},
{
property: 'colors.playing',
default: '#1B4444',
title: 'Playing Row Color'
},
{
property: 'colors.dragging',
default: '#22AAAA',
title: 'Dragging Row Color'
}
]*/
},
];
}),
/* 2 - Core Player Setup
• Initializes the main Player object with:
o Component references (controls, playlist, etc.)
o Template system
o Event system
• Key functions:
o initialize(): Bootstraps all components
o compareIds(): For sorting sounds
o acceptedSound(): Validates URLs against allowlist
o syncTab(): Handles cross-tab synchronization
*/
(function(module, exports, __webpack_require__) {
const components = {
// Settings must be first.
settings: __webpack_require__(5),
controls: __webpack_require__(6),
display: __webpack_require__(7),
events: __webpack_require__(8),
footer: __webpack_require__(9),
header: __webpack_require__(10),
hotkeys: __webpack_require__(11),
minimised: __webpack_require__(12),
playlist: __webpack_require__(13),
position: __webpack_require__(14),
threads: __webpack_require__(15),
userTemplate: __webpack_require__(17)
};
// 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__(19),
controls: __webpack_require__(20),
css: __webpack_require__(21),
footer: __webpack_require__(22),
header: __webpack_require__(23),
itemMenu: __webpack_require__(24),
list: __webpack_require__(25),
player: __webpack_require__(26),
settings: __webpack_require__(27),
threads: __webpack_require__(28),
threadBoards: __webpack_require__(29),
threadList: __webpack_require__(30)
},
/**
* 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('<li><a href="javascript:;">Sounds</a></li>', 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('<a href="javascript:;">Sounds</a>', 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();
// Add this line to automatically show the player
Player.display.show();
} catch (err) {
Player.logError('There was an error initialzing the sound player. Please check the console for details.');
console.error('[8chan sounds player]', 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) => GM_addValueChangeListener(property, (_prop, oldValue, newValue, remote) => {
remote && callback(newValue, oldValue);
}),
/**
* Send an error notification event.
*/
logError: function(message, type = 'error') {
console.error(message);
document.dispatchEvent(new CustomEvent('CreateNotification', {
bubbles: true,
detail: {
type: type,
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]);
}
}),
/* 3 - Main Entry Point
• Initialization sequence:
a. Waits for DOM/4chan X readiness
b. Sets up mutation observer for dynamic content
c. Triggers initial page scan
• Handles both:
o Native 4chan interface
o 4chan X extension environment
*/
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
const _globals__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4);
const _player__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
const _file_parser__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(0);
async function doInit() {
setTimeout(async function() {
await _player__WEBPACK_IMPORTED_MODULE_1__.initialize();
Player.set('showSoundTagOnly', false); // Add this line
// Initialize header and footer buttons
_player__WEBPACK_IMPORTED_MODULE_1__.display.initHeader();
_player__WEBPACK_IMPORTED_MODULE_1__.display.initFooter();
// Parse existing posts
_file_parser__WEBPACK_IMPORTED_MODULE_2__.parseFiles(document.body, true);
// Add sounds link to 8chan navigation
const nav = document.querySelector('.threadBottom .innerUtility');
if (nav && !document.querySelector('.innerUtility a[href="javascript:;"]')) {
const li = createElement('<a href="javascript:;">Sounds</a>', nav);
nav.insertBefore(document.createTextNode(' ['), li);
nav.insertBefore(li, nav.querySelector('.archiveLinkThread'));
nav.insertBefore(document.createTextNode('] '), nav.querySelector('.archiveLinkThread'));
li.addEventListener('click', _player__WEBPACK_IMPORTED_MODULE_1__.display.toggle);
}
_file_parser__WEBPACK_IMPORTED_MODULE_2__.parseFiles(document.body, true);
// Set up mutation observer
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) {
_file_parser__WEBPACK_IMPORTED_MODULE_2__.parseFiles(node);
}
});
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}, 0);
}
document.addEventListener('DOMContentLoaded', doInit);
}),
/* 4 - Globals & Utilities
• Defines shared utilities:
o _set()/_get(): Deep object property access
o toDuration(): Formats time (00:00)
o timeAgo(): Relative time formatting
o createElement(): DOM creation helper
o noDefault(): Event handler wrapper
• Sets global constants:
o ns: Namespace prefix
o is4chan/isChanX: Environment detection
o Board: Current board name
o VERSION
*/
(function(module, exports) {
// Update globals for 8chan
window.ns = 'fc-sounds';
window.is4chan = false;
window.isChanX = false;
window.Board = location.pathname.split('/')[1];
const scriptVersion = GM_info.script.version;
window.VERSION = scriptVersion ? scriptVersion : 'Version not found';
// Load in some glyphs for GUI
if (!document.querySelector('style[data-open-iconic]')) {
const style = document.createElement('style');
style.setAttribute('data-open-iconic', 'true');
style.textContent = `
@font-face {
font-family: 'open-iconic';
src: url('https://8chan.moe/.static/css/fonts/open-iconic.woff') format('woff');
font-weight: normal;
font-style: normal;
}
.oi {
font-family: 'open-iconic' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: inline-block;
vertical-align: middle;
}
/* Individual icon definitions */
.oi-account-login:before {content:"\\e000";}
.oi-account-logout:before {content:"\\e001";}
.oi-action-redo:before {content:"\\e002";}
.oi-action-undo:before {content:"\\e003";}
.oi-align-center:before {content:"\\e004";}
.oi-align-left:before {content:"\\e005";}
.oi-align-right:before {content:"\\e006";}
.oi-aperture:before {content:"\\e007";}
.oi-arrow-bottom:before {content:"\\e008";}
.oi-arrow-circle-bottom:before {content:"\\e009";}
.oi-arrow-circle-left:before {content:"\\e00a";}
.oi-arrow-circle-right:before {content:"\\e00b";}
.oi-arrow-circle-top:before {content:"\\e00c";}
.oi-arrow-left:before {content:"\\e00d";}
.oi-arrow-right:before {content:"\\e00e";}
.oi-arrow-thick-bottom:before {content:"\\e00f";}
.oi-arrow-thick-left:before {content:"\\e010";}
.oi-arrow-thick-right:before {content:"\\e011";}
.oi-arrow-thick-top:before {content:"\\e012";}
.oi-arrow-top:before {content:"\\e013";}
.oi-audio-spectrum:before {content:"\\e014";}
.oi-audio:before {content:"\\e015";}
.oi-badge:before {content:"\\e016";}
.oi-ban:before {content:"\\e017";}
.oi-bar-chart:before {content:"\\e018";}
.oi-basket:before {content:"\\e019";}
.oi-battery-empty:before {content:"\\e01a";}
.oi-battery-full:before {content:"\\e01b";}
.oi-beaker:before {content:"\\e01c";}
.oi-bell:before {content:"\\e01d";}
.oi-bluetooth:before {content:"\\e01e";}
.oi-bold:before {content:"\\e01f";}
.oi-bolt:before {content:"\\e020";}
.oi-book:before {content:"\\e021";}
.oi-bookmark:before {content:"\\e022";}
.oi-box:before {content:"\\e023";}
.oi-briefcase:before {content:"\\e024";}
.oi-british-pound:before {content:"\\e025";}
.oi-browser:before {content:"\\e026";}
.oi-brush:before {content:"\\e027";}
.oi-bug:before {content:"\\e028";}
.oi-bullhorn:before {content:"\\e029";}
.oi-calculator:before {content:"\\e02a";}
.oi-calendar:before {content:"\\e02b";}
.oi-camera-slr:before {content:"\\e02c";}
.oi-caret-bottom:before {content:"\\e02d";}
.oi-caret-left:before {content:"\\e02e";}
.oi-caret-right:before {content:"\\e02f";}
.oi-caret-top:before {content:"\\e030";}
.oi-cart:before {content:"\\e031";}
.oi-chat:before {content:"\\e032";}
.oi-check:before {content:"\\e033";}
.oi-chevron-bottom:before {content:"\\e034";}
.oi-chevron-left:before {content:"\\e035";}
.oi-chevron-right:before {content:"\\e036";}
.oi-chevron-top:before {content:"\\e037";}
.oi-circle-check:before {content:"\\e038";}
.oi-circle-x:before {content:"\\e039";}
.oi-clipboard:before {content:"\\e03a";}
.oi-clock:before {content:"\\e03b";}
.oi-cloud-download:before {content:"\\e03c";}
.oi-cloud-upload:before {content:"\\e03d";}
.oi-cloud:before {content:"\\e03e";}
.oi-cloudy:before {content:"\\e03f";}
.oi-code:before {content:"\\e040";}
.oi-cog:before {content:"\\e041";}
.oi-collapse-down:before {content:"\\e042";}
.oi-collapse-left:before {content:"\\e043";}
.oi-collapse-right:before {content:"\\e044";}
.oi-collapse-up:before {content:"\\e045";}
.oi-command:before {content:"\\e046";}
.oi-comment-square:before {content:"\\e047";}
.oi-compass:before {content:"\\e048";}
.oi-contrast:before {content:"\\e049";}
.oi-copywriting:before {content:"\\e04a";}
.oi-credit-card:before {content:"\\e04b";}
.oi-crop:before {content:"\\e04c";}
.oi-dashboard:before {content:"\\e04d";}
.oi-data-transfer-download:before {content:"\\e04e";}
.oi-data-transfer-upload:before {content:"\\e04f";}
.oi-delete:before {content:"\\e050";}
.oi-dial:before {content:"\\e051";}
.oi-document:before {content:"\\e052";}
.oi-dollar:before {content:"\\e053";}
.oi-double-quote-sans-left:before {content:"\\e054";}
.oi-double-quote-sans-right:before {content:"\\e055";}
.oi-double-quote-serif-left:before {content:"\\e056";}
.oi-double-quote-serif-right:before {content:"\\e057";}
.oi-droplet:before {content:"\\e058";}
.oi-eject:before {content:"\\e059";}
.oi-elevator:before {content:"\\e05a";}
.oi-ellipses:before {content:"\\e05b";}
.oi-envelope-closed:before {content:"\\e05c";}
.oi-envelope-open:before {content:"\\e05d";}
.oi-euro:before {content:"\\e05e";}
.oi-excerpt:before {content:"\\e05f";}
.oi-expand-down:before {content:"\\e060";}
.oi-expand-left:before {content:"\\e061";}
.oi-expand-right:before {content:"\\e062";}
.oi-expand-up:before {content:"\\e063";}
.oi-external-link:before {content:"\\e064";}
.oi-eye:before {content:"\\e065";}
.oi-eyedropper:before {content:"\\e066";}
.oi-file:before {content:"\\e067";}
.oi-fire:before {content:"\\e068";}
.oi-flag:before {content:"\\e069";}
.oi-flash:before {content:"\\e06a";}
.oi-folder:before {content:"\\e06b";}
.oi-fork:before {content:"\\e06c";}
.oi-fullscreen-enter:before {content:"\\e06d";}
.oi-fullscreen-exit:before {content:"\\e06e";}
.oi-globe:before {content:"\\e06f";}
.oi-graph:before {content:"\\e070";}
.oi-grid-four-up:before {content:"\\e071";}
.oi-grid-three-up:before {content:"\\e072";}
.oi-grid-two-up:before {content:"\\e073";}
.oi-hard-drive:before {content:"\\e074";}
.oi-header:before {content:"\\e075";}
.oi-headphones:before {content:"\\e076";}
.oi-heart:before {content:"\\e077";}
.oi-home:before {content:"\\e078";}
.oi-image:before {content:"\\e079";}
.oi-inbox:before {content:"\\e07a";}
.oi-infinity:before {content:"\\e07b";}
.oi-info:before {content:"\\e07c";}
.oi-italic:before {content:"\\e07d";}
.oi-justify-center:before {content:"\\e07e";}
.oi-justify-left:before {content:"\\e07f";}
.oi-justify-right:before {content:"\\e080";}
.oi-key:before {content:"\\e081";}
.oi-laptop:before {content:"\\e082";}
.oi-layers:before {content:"\\e083";}
.oi-lightbulb:before {content:"\\e084";}
.oi-link-broken:before {content:"\\e085";}
.oi-link-intact:before {content:"\\e086";}
.oi-list-rich:before {content:"\\e087";}
.oi-list:before {content:"\\e088";}
.oi-location:before {content:"\\e089";}
.oi-lock-locked:before {content:"\\e08a";}
.oi-lock-unlocked:before {content:"\\e08b";}
.oi-loop-circular:before {content:"\\e08c";}
.oi-loop-square:before {content:"\\e08d";}
.oi-loop:before {content:"\\e08e";}
.oi-magnifying-glass:before {content:"\\e08f";}
.oi-map-marker:before {content:"\\e090";}
.oi-map:before {content:"\\e091";}
.oi-media-pause:before {content:"\\e092";}
.oi-media-play:before {content:"\\e093";}
.oi-media-record:before {content:"\\e094";}
.oi-media-skip-backward:before {content:"\\e095";}
.oi-media-skip-forward:before {content:"\\e096";}
.oi-media-step-backward:before {content:"\\e097";}
.oi-media-step-forward:before {content:"\\e098";}
.oi-media-stop:before {content:"\\e099";}
.oi-medical-cross:before {content:"\\e09a";}
.oi-menu:before {content:"\\e09b";}
.oi-microphone:before {content:"\\e09c";}
.oi-minus:before {content:"\\e09d";}
.oi-monitor:before {content:"\\e09e";}
.oi-moon:before {content:"\\e09f";}
.oi-move:before {content:"\\e0a0";}
.oi-musical-note:before {content:"\\e0a1";}
.oi-paperclip:before {content:"\\e0a2";}
.oi-pencil:before {content:"\\e0a3";}
.oi-people:before {content:"\\e0a4";}
.oi-person:before {content:"\\e0a5";}
.oi-phone:before {content:"\\e0a6";}
.oi-pie-chart:before {content:"\\e0a7";}
.oi-pin:before {content:"\\e0a8";}
.oi-play-circle:before {content:"\\e0a9";}
.oi-plus:before {content:"\\e0aa";}
.oi-power-standby:before {content:"\\e0ab";}
.oi-print:before {content:"\\e0ac";}
.oi-project:before {content:"\\e0ad";}
.oi-pulse:before {content:"\\e0ae";}
.oi-puzzle-piece:before {content:"\\e0af";}
.oi-question-mark:before {content:"\\e0b0";}
.oi-rain:before {content:"\\e0b1";}
.oi-random:before {content:"\\e0b2";}
.oi-reload:before {content:"\\e0b3";}
.oi-resize-both:before {content:"\\e0b4";}
.oi-resize-height:before {content:"\\e0b5";}
.oi-resize-width:before {content:"\\e0b6";}
.oi-rss-alt:before {content:"\\e0b7";}
.oi-rss:before {content:"\\e0b8";}
.oi-script:before {content:"\\e0b9";}
.oi-share-boxed:before {content:"\\e0ba";}
.oi-share:before {content:"\\e0bb";}
.oi-shield:before {content:"\\e0bc";}
.oi-signal:before {content:"\\e0bd";}
.oi-signpost:before {content:"\\e0be";}
.oi-sort-ascending:before {content:"\\e0bf";}
.oi-sort-descending:before {content:"\\e0c0";}
.oi-spreadsheet:before {content:"\\e0c1";}
.oi-star:before {content:"\\e0c2";}
.oi-sun:before {content:"\\e0c3";}
.oi-tablet:before {content:"\\e0c4";}
.oi-tag:before {content:"\\e0c5";}
.oi-tags:before {content:"\\e0c6";}
.oi-target:before {content:"\\e0c7";}
.oi-task:before {content:"\\e0c8";}
.oi-terminal:before {content:"\\e0c9";}
.oi-text:before {content:"\\e0ca";}
.oi-thumb-down:before {content:"\\e0cb";}
.oi-thumb-up:before {content:"\\e0cc";}
.oi-timer:before {content:"\\e0cd";}
.oi-transfer:before {content:"\\e0ce";}
.oi-trash:before {content:"\\e0cf";}
.oi-underline:before {content:"\\e0d0";}
.oi-vertical-align-bottom:before {content:"\\e0d1";}
.oi-vertical-align-center:before {content:"\\e0d2";}
.oi-vertical-align-top:before {content:"\\e0d3";}
.oi-video:before {content:"\\e0d4";}
.oi-volume-high:before {content:"\\e0d5";}
.oi-volume-low:before {content:"\\e0d6";}
.oi-volume-off:before {content:"\\e0d7";}
.oi-warning:before {content:"\\e0d8";}
.oi-wifi:before {content:"\\e0d9";}
.oi-wrench:before {content:"\\e0da";}
.oi-x:before {content:"\\e0db";}
.oi-yen:before {content:"\\e0dc";}
.oi-zoom-in:before {content:"\\e0dd";}
.oi-zoom-out:before {content:"\\e0de";}
`;
document.head.appendChild(style);
}
// Keep rest of original globals.js content
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) {
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;
};
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(...args);
};
}),
/* 5 - Settings Manager
• Manages all user configuration:
o load()/save(): Persistent storage
o set(): Updates settings with validation
o applyBoardTheme(): Matches 4chan's colors
• Handles:
o Settings UI rendering
o Change detection
o Cross-tab synchronization
*/
(function(module, exports, __webpack_require__) {
const settingsConfig = __webpack_require__(1);
module.exports = {
atRoot: ['set'],
delegatedEvents: {
click: {
[`.${ns}-settings .${ns}-heading-action`]: 'settings.handleAction',
},
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',
},
keyup: {
[`.${ns}-encoded-input`]: 'settings._handleEncoded',
[`.${ns}-decoded-input`]: 'settings._handleDecoded'
}
},
initialize: async function() {
// 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();
// 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) {
const rootStyles = getComputedStyle(document.documentElement);
const textColor = rootStyles.getPropertyValue('--text-color').trim();
let backgroundColor = rootStyles.getPropertyValue('--contrast-color').trim() || rootStyles.getPropertyValue('--background-color').trim() || '#FFFFFF';
backgroundColor = Player.settings.adjustColor(backgroundColor, { h: 0, s: 0, v: 0 }); // turn to hex to drop alpha from rgba
const borderColor = rootStyles.getPropertyValue('--border-color').trim();
const oddRow = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 30, s: 15, v: 0 }) : Player.settings.adjustColor(backgroundColor, { h: 30, s: 0, v: 0 });
const evenRow = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 0, s: 0, v: 0 }) : Player.settings.adjustColor(backgroundColor, { h: 0, s: 0, v: -6 });
const playing = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 45, s: 40, v: -20 }) : Player.settings.adjustColor(backgroundColor, { h: -60, s: 20, v: 30 });
const dragging = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 75, s: 40, v: -20 }) : Player.settings.adjustColor(backgroundColor, { h: -120, s: 20, v: 40 });
const colorSettingMap = {
'colors.text': textColor,
'colors.background': backgroundColor,
'colors.border': borderColor,
'colors.odd_row': oddRow,
'colors.even_row': evenRow,
'colors.playing': playing,
'colors.dragging': dragging
};
settingsConfig.find(s => s.property === 'colors').settings.forEach(setting => {
const updateConfig = force || (setting.default === _get(Player.config, setting.property));
colorSettingMap[setting.property] && (setting.default = colorSettingMap[setting.property]);
updateConfig && Player.set(setting.property, setting.default, {
bypassSave: true,
bypassRender: true
});
});
// Updated the stylesheet if it exists.
Player.stylesheet && Player.display.updateStylesheet();
// Re-render the settings if needed.
Player.settings.render();
},
parseColor: function(color) {
let result;
// Check if it's in hex format; Hex: #RGB or #RRGGBB
if (/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/.test(color)) {
let hex = color.slice(1);
if (hex.length === 3) {
hex = hex.split('').map(x => x + x).join('');
}
result = [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16)
];
}
// Check if it's in rgb format; RGB: rgb(r, g, b)
else if (/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.test(color)) {
result = color.match(/\d+/g).map(Number);
}
// Check if it's in rgba format; RGBA: rgba(r, g, b, a)
else if (/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*(0|1|0?\.\d+)\s*\)$/.test(color)) {
let matches = color.match(/\d+(\.\d+)?/g).map(Number);
result = matches.slice(0, 3); // Drop alpha
}
return result;
},
isLightColor: function(color) {
const rgb = Player.settings.parseColor(color);
if (!rgb) return false;
const [r, g, b] = rgb;
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
return luminance > 186;
},
adjustColor: function(color, { h = 0, s = 0, v = 0 } = {}) {
const rgb = Player.settings.parseColor(color);
if (!rgb) return color;
const [r, g, b] = rgb.map(c => c / 255);
const [hVal, sVal, vVal] = Player.settings.rgbToHsv(r, g, b);
// Adjust HSV
const newHVal = (hVal * 360 + h) % 360;
const newSVal = Math.min(1, Math.max(0, sVal + s / 100));
const newVVal = Math.min(1, Math.max(0, vVal + v / 100));
// HSV to RGB
const [r1, g1, b1] = Player.settings.hsvToRgb(newHVal, newSVal, newVVal);
return `#${Player.settings.toHex(r1)}${Player.settings.toHex(g1)}${Player.settings.toHex(b1)}`;
},
rgbToHsv: function(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let hVal, sVal, vVal = max;
const d = max - min;
sVal = max === 0 ? 0 : d / max;
if (d === 0) {
hVal = 0;
} else {
switch (max) {
case r: hVal = (g - b) / d + (g < b ? 6 : 0); break;
case g: hVal = (b - r) / d + 2; break;
case b: hVal = (r - g) / d + 4; break;
}
hVal /= 6;
}
return [hVal, sVal, vVal];
},
hsvToRgb: function(h, s, v) {
const c = v * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = v - c;
let r1, g1, b1;
if (h < 60) [r1, g1, b1] = [c, x, 0];
else if (h < 120) [r1, g1, b1] = [x, c, 0];
else if (h < 180) [r1, g1, b1] = [0, c, x];
else if (h < 240) [r1, g1, b1] = [0, x, c];
else if (h < 300) [r1, g1, b1] = [x, 0, c];
else [r1, g1, b1] = [c, 0, x];
return [r1 + m, g1 + m, b1 + m];
},
toHex: function(c) {
return Math.round(c * 255).toString(16).padStart(2, '0');
},
/**
* Update a setting.
*/
set: function(property, value, {
bypassSave,
bypassRender,
silent
} = {}) {
const previousValue = _get(Player.config, property);
if (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).showInSettings && 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 have 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 {
const userVal = _get(Player.config, setting.property);
if (userVal !== undefined && userVal !== setting.default) {
_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 = window.VERSION;
// Save the settings.
return GM.setValue('settings', JSON.stringify(settings));
} catch (err) {
Player.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('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. Please check the console for details.');
console.error('[4chan sounds player]', err);
}
},
apply: function(settings, opts = {}) {
if (typeof settings === 'string') {
settings = JSON.parse(settings);
}
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(opts.property)) {
return;
}
const value = _get(settings, setting.property, opts.applyDefault ? setting.default : undefined);
if (value !== undefined) {
Player.set(setting.property, value, opts);
}
});
},
/**
* 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(e) {
e && e.preventDefault();
// Blur anything focused so the change is applied.
let focused = Player.$(`.${ns}-settings :focus`);
focused && focused.blur();
if (Player.config.viewStyle === 'settings') {
Player.playlist.restore();
} else {
Player.display.setViewStyle('settings');
}
},
/**
* 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);
}
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.
Player.set(property, newValue, {
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. Please check the console for details.');
console.error('[4chan sounds player]', 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);
},
/**
* 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);
}
};
}),
/* 6 - Playback Controls
• Core audio functions:
o play()/pause()/togglePlay()
o next()/previous(): Track navigation
o _movePlaying(): Handles repeat modes
• UI controls:
o Seek bar handling
o Volume control
o Progress updates
• Video sync for webm files
*/
(function(module, exports) {
const progressBarStyleSheets = {};
let syncInterval;
let playbackStartTime = null;
let mediaStartTime = 0;
let lastSyncTime = null;
let playbackRate = 1.0;
let isLoading = false;
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: {
ended: {
[`.${ns}-video`]: 'controls.handleSoundEnded'
},
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;
}
}
},
soundEvents: {
ended: 'controls.handleSoundEnded',
pause: 'controls.handlePlaybackState',
play: 'controls.handlePlaybackState',
seeked: 'controls.handlePlaybackState',
waiting: 'controls.handlePlaybackState',
timeupdate: 'controls.updateDuration',
loadedmetadata: 'controls.updateDuration',
durationchange: 'controls.updateDuration',
volumechange: 'controls.updateVolume',
loadstart: 'controls.pollForLoading',
error: 'controls.handleSoundError',
waiting: () => {
isLoading = true;
Player.controls.updatePlayButtonState();
},
canplay: () => {
isLoading = false;
Player.controls.updatePlayButtonState();
},
},
audioEvents: {
ended: () => {
if (Player.config.repeat === 'one') {
Player.controls.handleSoundEnded();
} else {
Player.next();
}
},
timeupdate: 'controls.updateDuration',
loadedmetadata: 'controls.updateDuration',
durationchange: 'controls.updateDuration',
volumechange: 'controls.updateVolume',
loadstart: 'controls.pollForLoading',
waiting: () => {
isLoading = true;
Player.controls.updatePlayButtonState();
},
canplay: () => {
isLoading = false;
Player.controls.updatePlayButtonState();
},
},
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`);
// Check for M4A support
Player.supportsM4A = Player.controls.checkM4ASupport();
Player.on('rendered', () => {
Player.ui.currentTimeBar = Player.$(`.${ns}-seek-bar .${ns}-current-bar`);
Player.ui.loadedBar = Player.$(`.${ns}-seek-bar .${ns}-loaded-bar`);
});
// video event listeners
/*const video = document.querySelector(`.${ns}-video`);
if (video) {
Object.entries(Player.controls.soundEvents).forEach(([event, handler]) => {
video.addEventListener(event, Player.controls[handler] || Player.controls[handler.split('.')[1]]);
});
}*/
const video = document.querySelector(`.${ns}-video`);
if (video) {
Object.entries(Player.controls.soundEvents).forEach(([event, handler]) => {
// Handle both string paths and direct function references
const handlerFn = typeof handler === 'function'
? handler
: _get(Player, handler);
video.addEventListener(event, handlerFn);
});
}
// audio element event listeners
Object.entries(Player.controls.soundEvents).forEach(([event, handler]) => {
Player.audio.addEventListener(event, Player.controls[handler]);
});
// 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'));
// Start sync loop
if (!syncInterval) {
syncInterval = setInterval(Player.controls.syncPlayback, 50);
}
Player.controls.updateVolume();
});
},
/**
* Switching being playing and paused.
*/
togglePlay: function() {
// Return early if currently loading
if (Player.controls.isLoading) {
return;
}
if (!Player.playing) {
if (Player.sounds.length) {
return Player.play(Player.sounds[0]);
}
return;
}
const video = document.querySelector(`.${ns}-video`);
const isStaticImage = Player.playing.hasSoundTag &&
!Player.playing.isVideo &&
!['.webm', '.mp4'].some(ext =>
Player.playing.image.toLowerCase().endsWith(ext));
// Set loading state and update UI
Player.controls.isLoading = true;
Player.controls.updatePlayButtonState();
// For Case 1 with static images, only control the audio element
if (isStaticImage) {
if (Player.audio.paused) {
Player.controls.mediaStartTime = Player.audio.currentTime;
Player.controls.playbackStartTime = Date.now();
Player.audio.play()
.catch(console.error)
.finally(() => {
Player.controls.isLoading = false;
Player.controls.updatePlayButtonState();
});
} else {
Player.audio.pause();
Player.controls.playbackStartTime = null;
Player.controls.isLoading = false;
Player.controls.updatePlayButtonState();
}
}
// For all other cases, control both audio and video as appropriate
else {
let sound;
if (Player.playing.hasSoundTag) {
sound = Player.playing.isVideo ? video : Player.audio;
} else {
sound = Player.playing.isVideo ? video : Player.audio;
}
if (sound.paused) {
Player.controls.mediaStartTime = sound.currentTime;
Player.controls.playbackStartTime = Date.now();
const playPromises = [sound.play().catch(console.error)];
if (video && Player.playing.hasSoundTag && !Player.playing.isVideo) {
video.currentTime = sound.currentTime;
if (['.webm', '.mp4', '.ogg'].some(ext =>
Player.playing.image.toLowerCase().endsWith(ext))) {
playPromises.push(video.play().catch(console.error));
}
}
Promise.all(playPromises)
.finally(() => {
Player.controls.isLoading = false;
Player.controls.updatePlayButtonState();
});
} else {
sound.pause();
if (video) video.pause();
Player.controls.playbackStartTime = null;
Player.controls.isLoading = false;
Player.controls.updatePlayButtonState();
}
}
Player.controls.handlePlaybackState();
},
updatePlayButtonState: function() {
const buttons = document.querySelectorAll(`.${ns}-play-button`);
buttons.forEach(button => {
button.disabled = isLoading;
button.style.opacity = isLoading ? '0.5' : '1';
button.style.cursor = isLoading ? 'not-allowed' : 'pointer';
});
},
arrayBufferToBase64: function (buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
},
detectMimeType: function(url, arrayBuffer) {
const extension = url.split('.').pop().toLowerCase();
// Simple detection based on file signatures
const bytes = new Uint8Array(arrayBuffer);
// Check by file signature (magic numbers)
// WebM
if (bytes.length >= 4 &&
bytes[0] === 0x1A &&
bytes[1] === 0x45 &&
bytes[2] === 0xDF &&
bytes[3] === 0xA3) {
return 'video/webm';
}
// MP4/M4A
if (bytes.length >= 8 &&
((bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) || // ftyp
(bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x00 &&
(bytes[3] === 0x18 || bytes[3] === 0x20) && bytes[4] === 0x66 &&
bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70))) {
// Check if it's audio-only (M4A)
if (bytes.length >= 12 &&
bytes[8] === 0x4D && bytes[9] === 0x34 && bytes[10] === 0x41 && bytes[11] === 0x20) {
return 'audio/mp4'; // M4A
}
return 'video/mp4';
}
// FLAC
if (bytes.length >= 4 &&
bytes[0] === 0x66 &&
bytes[1] === 0x4C &&
bytes[2] === 0x61 &&
bytes[3] === 0x43) {
return 'audio/flac';
}
// OGG
if (bytes.length >= 4 &&
bytes[0] === 0x4F &&
bytes[1] === 0x67 &&
bytes[2] === 0x67 &&
bytes[3] === 0x53) {
return 'audio/ogg';
}
// Fallback to extension-based detection
switch(extension) {
case 'webm': return 'video/webm';
case 'mp4': return 'video/mp4';
case 'm4a': return 'audio/mp4';
case 'flac': return 'audio/flac';
case 'ogg':
case 'oga':
case 'opus': return 'audio/ogg';
default: return 'audio/mpeg'; // default fallback
}
},
checkM4ASupport: function() {
try {
return MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') ||
MediaSource.isTypeSupported('audio/mp4');
} catch (e) {
return false;
}
},
/**
* Start playback.
*/
play: async function(sound) {
if (!sound && !Player.playing && Player.sounds.length) {
sound = Player.sounds[0];
}
if (!sound) return;
isLoading = true;
Player.controls.updatePlayButtonState();
try {
// Clear previous playback
if (Player.playing) {
Player.playing.playing = false;
const prevSound = Player.playing.isVideo ? document.querySelector(`.${ns}-video`) : Player.audio;
prevSound?.pause();
// Reset media elements completely
prevSound.src = '';
prevSound.load();
}
sound.playing = true;
Player.playing = sound;
await Player.trigger('playsound', sound);
const video = document.querySelector(`.${ns}-video`);
video.preload = 'auto';
if (video) {
video.loop = true;
}
// Case 1: hasSoundTag and the sound tag is audio (.mp3, .ogg, .m4a, ...)
if (sound.hasSoundTag && !sound.isVideo) {
try {
// First try with GM.xmlHttpRequest
const response = await new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url: sound.src,
responseType: 'arraybuffer',
headers: {
'Accept': '*/*'
},
onload: resolve,
onerror: reject,
ontimeout: reject,
timeout: 60000
});
});
if (response.status >= 400) {
throw new Error(`Failed to fetch media: ${response.statusText}`);
}
// Detect MIME type and convert to data URL
const mimeType = await Player.controls.detectMimeType(sound.src, response.response);
const base64 = await Player.controls.arrayBufferToBase64(response.response);
const dataUrl = `data:${mimeType};base64,${base64}`;
Player.controls.mediaStartTime = 0;
Player.controls.playbackStartTime = Date.now();
// Special handling for M4A files on some browsers
if (mimeType === 'audio/mp4' && !MediaSource.isTypeSupported('audio/mp4')) {
// Fallback to regular audio element for unsupported M4A
Player.audio.src = sound.src;
} else {
Player.audio.src = dataUrl;
}
// For OGG files, ensure we're using the correct codec
if (mimeType === 'audio/ogg') {
Player.audio.type = 'audio/ogg; codecs="vorbis"';
}
// Play audio
await Player.audio.play();
// Handle video/image element carefully for Case 1
try {
// Check if the image is actually a supported video format
const imageExt = sound.image.split('.').pop().toLowerCase();
if (['webm', 'mp4', 'ogg'].includes(imageExt)) {
video.src = sound.image; // Use .image for video if it's a supported format
video.muted = true;
video.currentTime = Player.audio.currentTime;
video.play().catch(e => {
console.log('Video playback failed, falling back to empty source:', e);
video.src = '';
});
} else {
// For unsupported formats like GIF, don't try to play them
video.src = '';
}
} catch (videoErr) {
console.log('Error setting up video element:', videoErr);
video.src = '';
}
} catch (err) {
console.error('Failed to fetch via GM_xmlhttpRequest, trying fallback:', err);
// Fallback to direct audio playback
Player.audio.src = sound.src;
Player.controls.mediaStartTime = 0;
Player.controls.playbackStartTime = Date.now();
await Player.audio.play();
video.src = ''; // Don't try to play unsupported formats in fallback
}
}
// Case 2: hasSoundTag and the sound tag is video (.webm, .mp4)
else if (sound.hasSoundTag && sound.isVideo) {
try {
// First try with GM.xmlHttpRequest
const response = await new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url: sound.src,
responseType: 'arraybuffer',
headers: {
'Accept': '*/*'
},
onload: resolve,
onerror: reject,
ontimeout: reject,
timeout: 60000
});
});
if (response.status >= 400) {
throw new Error(`Failed to fetch media: ${response.statusText}`);
}
// Detect MIME type and convert to data URL
const mimeType = await Player.controls.detectMimeType(sound.src, response.response);
const base64 = await Player.controls.arrayBufferToBase64(response.response);
const dataUrl = `data:${mimeType};base64,${base64}`;
Player.controls.mediaStartTime = 0;
Player.controls.playbackStartTime = Date.now();
// Set video source and play
video.src = dataUrl;
video.muted = false;
video.loop = false; // We'll handle looping manually
try {
await video.play();
} catch (videoErr) {
console.log('Error with video playback, trying muted:', videoErr);
video.muted = true;
await video.play();
}
// Set audio source to same as video
Player.audio.src = dataUrl;
Player.audio.loop = false; // We'll handle looping manually
Player.audio.play().catch(() => {});
} catch (err) {
console.error('Failed to fetch via GM_xmlhttpRequest, trying fallback:', err);
// Fallback to direct video playback
video.src = sound.src;
Player.controls.mediaStartTime = 0;
Player.controls.playbackStartTime = Date.now();
video.muted = false;
video.loop = false; // We'll handle looping manually
try {
await video.play();
} catch (videoErr) {
console.log('Error with video playback, trying muted:', videoErr);
video.muted = true;
await video.play();
}
Player.audio.src = sound.src;
Player.audio.loop = false; // We'll handle looping manually
Player.audio.play().catch(() => {});
}
}
// Case 3: doesn't have hasSoundTag and is .webm or .mp4
else if (!sound.hasSoundTag && (sound.src.endsWith('.webm') || sound.src.endsWith('.mp4'))) {
// Handle video playback normally
Player.audio.src = '';
video.src = sound.src;
video.muted = false;
Player.controls.mediaStartTime = 0;
Player.controls.playbackStartTime = Date.now();
try {
await video.play();
} catch (err) {
console.log('Error with video playback, trying muted:', err);
video.muted = true;
await video.play();
}
// Use .image for video and audio (though audio is empty in this case)
Player.audio.src = sound.image || '';
}
// Fallback for other cases (audio files without sound tag)
else {
Player.audio.src = sound.src;
Player.controls.mediaStartTime = 0;
Player.controls.playbackStartTime = Date.now();
await Player.audio.play();
video.src = '';
}
Player.controls.handlePlaybackState();
} catch (err) {
console.error('Playback error:', err);
Player.logError('Could not play sound');
// Full cleanup
Player.audio.src = '';
Player.audio.load();
const video = document.querySelector(`.${ns}-video`);
if (video) {
video.src = '';
video.load();
}
Player.controls.handlePlaybackState();
return Player.next(); // Skip to next track on error
}
},
/**
* Pause playback.
*/
pause: function() {
const video = document.querySelector(`.${ns}-video`);
let sound;
// Determine which element to control based on the cases
if (Player.playing.hasSoundTag) {
// Case 1 or 2: hasSoundTag
sound = Player.playing.isVideo ? video : Player.audio;
} else {
// Case 3: no sound tag and is video
sound = Player.playing.isVideo ? video : Player.audio;
}
sound?.pause();
if (video) video.pause();
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
Player.controls.playbackStartTime = null;
Player.controls.handlePlaybackState();
},
/**
* Play the next sound.
*/
next: function(force) {
if (Player.config.repeat === 'one') {
const source = Player.controls.getActiveSound();
if (!source || !isFinite(source.duration)) return;
const seekTime = 0;
// Update playback timing
Player.controls.mediaStartTime = seekTime;
Player.controls.playbackStartTime = Date.now();
// Update media elements
source.currentTime = seekTime;
if (Player.playing?.hasSoundTag) {
const video = document.querySelector(`.${ns}-video`);
if (video) video.currentTime = seekTime;
}
source.play().catch(console.error);
Player.controls.handlePlaybackState();
return;
}
Player.controls._movePlaying(1, force);
},
/**
* Play the previous sound.
*/
previous: function(force) {
if (Player.config.repeat === 'one') {
const source = Player.controls.getActiveSound();
if (!source || !isFinite(source.duration)) return;
const seekTime = 0;
// Update playback timing
Player.controls.mediaStartTime = seekTime;
Player.controls.playbackStartTime = Date.now();
// Update media elements
source.currentTime = seekTime;
if (Player.playing?.hasSoundTag) {
const video = document.querySelector(`.${ns}-video`);
if (video) video.currentTime = seekTime;
}
source.play().catch(console.error);
Player.controls.handlePlaybackState();
return;
}
Player.controls._movePlaying(-1, force);
},
_movePlaying: function(direction, force) {
if (!Player.audio) {
return;
}
try {
// 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.
const nextIndex = !force && Player.config.repeat === 'one' ?
currentIndex :
Player.config.repeat === 'all' ?
((currentIndex + direction) + Player.sounds.length) % Player.sounds.length :
currentIndex + direction;
const nextSound = Player.sounds[nextIndex];
nextSound && Player.play(nextSound);
} catch (err) {
Player.logError(`There was an error selecting the ${direction > 0 ? 'next' : 'previous'} track. Please check the console for details.`);
console.error('[8chan sounds player]', err);
}
},
getCurrentPlaybackPosition: function() {
if (!playbackStartTime) {
const active = Player.controls.getActiveSound();
return active ? active.currentTime : 0;
}
const elapsed = (Date.now() - playbackStartTime) / 1000;
return mediaStartTime + (elapsed * playbackRate);
},
syncPlayback: function() {
if (!playbackStartTime) return;
const currentTime = Player.controls.getCurrentPlaybackPosition();
// Only sync if difference is significant
const syncThreshold = 0.2;
// Sync audio element
if (Player.audio && !Player.audio.paused) {
if (Math.abs(Player.audio.currentTime - currentTime) > syncThreshold) {
Player.audio.currentTime = currentTime;
}
}
// Sync video element
const video = document.querySelector(`.${ns}-video`);
if (video && !video.paused) {
const videoDiff = Math.abs(video.currentTime - currentTime);
if (videoDiff > 0.1) {
//Player.audio.currentTime = currentTime;
video.currentTime = currentTime;
}
}
},
handlePlaybackState: function() {
const video = document.querySelector(`.${ns}-video`);
//const isPlaying = playbackStartTime !== null;
const isPlaying = !Player.audio.paused || (video && !video.paused);
// Update all play buttons
document.querySelectorAll(`.${ns}-play-button .${ns}-play-button-display`).forEach(el => {
el.classList.toggle(`${ns}-play`, !isPlaying);
});
// Update container state if needed
if (Player.container) {
Player.container.classList.toggle(`${ns}-playing`, isPlaying);
Player.container.classList.toggle(`${ns}-paused`, !isPlaying);
}
Player.controls.updateDuration();
},
getActiveSound: function() {
const video = document.querySelector(`.${ns}-video`);
// Case 1: hasSoundTag and is audio - use audio element
if (Player.playing?.hasSoundTag && !Player.playing.isVideo) {
return Player.audio;
}
// Case 2: hasSoundTag and is video - use video element
if (Player.playing?.hasSoundTag && Player.playing.isVideo) {
return video;
}
// Case 3: no sound tag and is video - use video element
if (!Player.playing?.hasSoundTag && Player.playing?.isVideo) {
return video;
}
// Fallback to audio element
return Player.audio;
},
handleSoundEnded: function() {
/*if (Player.config.repeat === 'one') {
const active = Player.controls.getActiveSound();
active.currentTime = 0;
Player.controls.mediaStartTime = 0;
Player.controls.playbackStartTime = Date.now();
active.play().catch(console.error);
return;
}*/
if (Player.config.repeat === 'one') {
const source = Player.controls.getActiveSound();
if (!source || !isFinite(source.duration)) return;
const seekTime = 0;
// Update playback timing
Player.controls.mediaStartTime = seekTime;
Player.controls.playbackStartTime = Date.now();
// Update media elements
source.currentTime = seekTime;
if (Player.playing?.hasSoundTag) {
const video = document.querySelector(`.${ns}-video`);
if (video) video.currentTime = seekTime;
}
source.play().catch(console.error);
Player.controls.handlePlaybackState();
return;
}
Player.next();
},
/**
* Handle sound errors
*/
handleSoundError: function() {
const video = document.querySelector(`.${ns}-video`);
// Clean up blob URLs on error
if (Player.audio.src && Player.audio.src.startsWith('blob:')) {
URL.revokeObjectURL(Player.audio.src);
Player.audio.src = '';
}
if (Player.playing?.isVideo && video?.error) {
console.error('Video error:', video.error);
Player.logError('Video playback error.');
} else if (Player.audio?.error) {
console.error('Audio error:', Player.audio.error);
Player.logError('Audio playback error.');
}
},
/**
* 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 active = Player.controls.getActiveSound();
if (!active || !active.buffered || active.buffered.length === 0) return;
const length = active.buffered.length;
const size = (active.buffered.end(length - 1) / active.duration) * 100;
if (size === 100) {
Player.controls.stopPollingForLoading();
}
if (Player.ui.loadedBar) {
Player.ui.loadedBar.style.width = size + '%';
}
},
/**
* Update the seek bar and the duration labels.
*/
updateDuration: function() {
if (!Player.container) return;
const currentTime = playbackStartTime ? Player.controls.getCurrentPlaybackPosition() :
(Player.controls.getActiveSound()?.currentTime || 0);
const duration = Player.controls.getActiveSound()?.duration || 0;
document.querySelectorAll(`.${ns}-current-time`).forEach(el =>
el.innerHTML = toDuration(currentTime));
document.querySelectorAll(`.${ns}-duration`).forEach(el =>
el.innerHTML = toDuration(duration));
Player.controls.updateProgressBarPosition(
`.${ns}-seek-bar`,
Player.ui.currentTimeBar,
currentTime,
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();
const source = Player.controls.getActiveSound();
if (!source || !isFinite(source.duration)) return;
const ratio = e.offsetX / parseInt(document.defaultView.getComputedStyle(e.eventTarget || e.target).width, 10);
const seekTime = source.duration * ratio;
// Update playback timing
Player.controls.mediaStartTime = seekTime;
Player.controls.playbackStartTime = Date.now();
// Update media elements
source.currentTime = seekTime;
if (Player.playing?.hasSoundTag) {
const video = document.querySelector(`.${ns}-video`);
if (video) video.currentTime = seekTime;
}
},
/**
* 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));
const video = document.querySelector(`.${ns}-video`);
if (video) {
video.volume = Player.audio.volume;
}
Player.controls.updateVolume();
}
};
}),
/* 7 - Display Management
• Player UI lifecycle:
o render(): Creates player DOM
o show()/hide(): Visibility control
o toggleFullScreen()
• Handles:
o 4chan X integration
o View style switching
o Drag-and-drop for files
*/
(function(module, exports) {
module.exports = {
atRoot: ['show', 'hide'],
delegatedEvents: {
click: {
[`.${ns}-close-button`]: 'hide'
},
fullscreenchange: {
[`.${ns}-media`]: 'display._handleFullScreenChange'
},
drop: {
[`#${ns}-container`]: 'display._handleDrop'
}
},
/**
* Create the player show/hide button in the 8chan header
*/
initHeader: function() {
if (Player.display._initedHeader) {
return;
}
// Find the header navigation container
const navOptions = document.querySelector('#navOptionsSpan');
if (!navOptions) {
return;
}
Player.display._initedHeader = true;
// Create the sounds button
const soundsButton = createElement(`
<span>
<span>/</span>
<a href="javascript:;" title="Toggle sound player" class="coloredIcon" ">
Sound Player
</a>
</span>
`);
// Insert before the closing bracket
navOptions.insertBefore(soundsButton, navOptions.lastElementChild);
// Add click handler
soundsButton.querySelector('a').addEventListener('click', Player.display.toggle);
// Also add to mobile menu
const mobileMenu = document.querySelector('#sidebar-menu ul');
if (mobileMenu) {
const mobileItem = createElement(`
<li>
<a href="javascript:;" class="coloredIcon">
Sound Player
</a>
</li>
`);
mobileMenu.appendChild(mobileItem);
mobileItem.querySelector('a').addEventListener('click', Player.display.toggle);
}
},
/**
* Initialize footer elements
*/
initFooter: function() {
if (Player.display._initedFooter) {
return;
}
// Find the footer navigation container
const threadBottom = document.querySelector('.threadBottom .innerUtility');
if (!threadBottom) {
return;
}
Player.display._initedFooter = true;
// Check if sounds link already exists
if (!threadBottom.querySelector('a[href="javascript:;"][onclick]')) {
// Create the sounds button
const soundsButton = createElement(`
<a href="javascript:;" title="Toggle sound player">Sound Player</a>
`);
// Insert after Catalog link
const catalogLink = threadBottom.querySelector('a[href$="catalog.html"]');
if (catalogLink) {
threadBottom.insertBefore(document.createTextNode(' '), catalogLink.nextSibling);
threadBottom.insertBefore(soundsButton, catalogLink.nextSibling);
threadBottom.insertBefore(document.createTextNode(' '), catalogLink.nextSibling);
} else {
// Fallback if catalog link not found
threadBottom.insertBefore(document.createTextNode(' '), threadBottom.firstChild);
threadBottom.insertBefore(soundsButton, threadBottom.firstChild);
}
// Add click handler
soundsButton.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. Please check the console for details.');
console.error('[4chan sounds player]', err);
// Can't recover, throw.
throw err;
}
},
updateStylesheet: function() {
// Insert the stylesheet if it doesn't exist.
Player.stylesheet = Player.stylesheet || createElement('<style></style>', 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;
}
try {
e && e.preventDefault();
Player.container.style.display = 'none';
Player.isHidden = true;
Player.trigger('hide');
} catch (err) {
Player.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 loaded amount polling if it was paused.
*/
show: async function(e) {
if (!Player.container) {
return;
}
try {
e && e.preventDefault();
if (!Player.container.style.display) {
return;
}
Player.container.style.display = null;
Player.isHidden = false;
await Player.trigger('show');
} catch (err) {
Player.logError('There was an error showing the sound player. Please check the console for details.');
console.error('[4chan sounds player]', err);
}
},
/**
* 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();
}
}
};
}),
/* 8 - Event System
• Custom event bus with:
o Delegated event handling
o Audio event bindings
o Pub/sub pattern (on/off/trigger)
• Manages all player interactions
*/
(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;
}
};
}),
/* 9 - Footer Components
• Template rendering for:
o Footer (status info)
• Uses the user-defined templates
*/
(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();
}
}
};
}),
/* 10 - Header Components
• Template rendering for:
o Player header (controls)
• Uses the user-defined templates
*/
(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();
}
}
};
}),
/* 11 - Hotkey System
• Keyboard control:
o Binding management
o Key event handling
o Modifier key support
• Configurable activation modes
*/
(function(module, exports, __webpack_require__) {
const settingsConfig = __webpack_require__(1);
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 _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.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);
}
};
}),
/* 12 - Minimized UI
• Picture-in-picture mode:
o Thumbnail display
o 4chan X header controls
• Handles compact view states
*/
(function(module, exports) {
module.exports = {
_showingPIP: false,
initialize: function() {
if (isChanX) {
// Create a reply element to gather the style from
const a = createElement('<a></a>', document.body);
const style = document.defaultView.getComputedStyle(a);
createElement(`<style>.${ns}-chan-x-controls .${ns}-media-control > div { background: ${style.color} }</style>`, 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(`<span class="${ns}-chan-x-controls ${ns}-col-auto"></span>`, 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': `<div class="${ns}-media-control ${ns}-previous-button"><div class="${ns}-previous-button-display"></div></div>`,
'play-button': `<div class="${ns}-media-control ${ns}-play-button"><div class="${ns}-play-button-display ${!Player.audio || Player.audio.paused ? `${ns}-play` : ''}"></div></div>`,
'next-button': `<div class="${ns}-media-control ${ns}-next-button"><div class="${ns}-next-button-display"></div></div>`,
'sound-current-time': `<span class="${ns}-current-time">0:00</span>`,
'sound-duration': `<span class="${ns}-duration">0:00</span>`
}
});
}
},
/**
* 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);
}
};
}),
/* 13 - Playlist Management
• Sound collection:
o add()/remove()
o Drag-and-drop reordering
o Filtering
• Features:
o Hover image previews
o Video detection
o Playlist navigation
*/
(function(module, exports, __webpack_require__) {
const {
parseFiles,
parseFileName
} = __webpack_require__(0);
module.exports = {
atRoot: ['add', 'remove'],
delegatedEvents: {
click: {
[`.${ns}-list-item`]: 'playlist.handleSelect',
[`.${ns}-sound-tag-toggle-button`]: 'playlist.toggleSoundTagPosts'
},
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()
}
},
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;
}
});
// 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);
Player.on('config:showSoundTagOnly', Player.playlist.applySoundTagFilter);
// 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`);
Player.playlist.applySoundTagFilter(); // Apply filter after rendering
},
/**
* 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.image.endsWith('.mp4') || sound.type === 'video/webm' || sound.type === 'video/mp4');
try {
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;
// Remove this line to prevent href from being added
// if (Player.config.viewStyle !== 'fullscreen') {
// container.href = sound.image;
// }
container.classList[isVideo ? 'add' : 'remove'](ns + '-show-video');
} catch (err) {
Player.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);
} catch (err) {
Player.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(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.');
console.log('[4chan sounds player]', sound);
console.error('[4chan sounds player]', err);
}
},
addFromFiles: function(files) {
// Check each of the files for sounds.
[...files].forEach(file => {
if (!file.type.startsWith('image') && file.type !== 'video/webm' && file.type !== 'video/mp4') {
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 (file.type !== 'video/mp4') {
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).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.pause();
Player.next(true);
}
// 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}-item-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}-item-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);
},
toggleSoundTagPosts: function(e) {
e && e.preventDefault();
Player.set('showSoundTagOnly', !Player.config.showSoundTagOnly);
Player.playlist.applySoundTagFilter();
},
applySoundTagFilter: function() {
const showSoundTagOnly = Player.config.showSoundTagOnly;
// Update button text
const buttons = document.querySelectorAll(`.${ns}-sound-tag-toggle-button`);
buttons.forEach(button => {
button.textContent = showSoundTagOnly ? '[All]' : '[ST]';
button.title = showSoundTagOnly ? 'Show all posts' : 'Show only posts with sound tag';
});
// Filter playlist items
const items = Player.$all(`.${ns}-list-item`);
items.forEach(item => {
const id = item.getAttribute('data-id');
const sound = Player.sounds.find(s => s.id === id);
if (sound) {
item.style.display = showSoundTagOnly && !sound.hasSoundTag ? 'none' : '';
}
});
}
};
}),
/* 14 - Positioning
• Player window:
o Draggable header
o Resizable
o Smart post width limiting
• Handles:
o Saved position/size
o Viewport constraints
o 4chan X header offsets
*/
(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 = '.innerPost';
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`) : 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 header.
*/
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('#dynamicHeaderThread').getBoundingClientRect().height : 0;
const top = hasChanXHeader && docClasses.contains('navHeader') ? headerHeight : 0;
const bottom = hasChanXHeader && docClasses.contains('bottom-header') ? headerHeight : 0;*/
const top = 26;
const bottom = 0;
return {
top,
bottom
};
}
};
}),
/* 15 - Thread Search
• Catalog scanning:
o Board selection
o Sound thread detection
• Displays:
o Table view (metadata)
o Board-style view (4chan X only)
*/
(function(module, exports, __webpack_require__) {
const {
parseFileName
} = __webpack_require__(0);
const {
get
} = __webpack_require__(16);
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: 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);
},
/**
* 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(`<div class="boardBanner"><div class="boardTitle">${boardTitle}</div></div>`, 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('<hr style="clear: both">', list);
}
}
} catch (err) {
Player.logError('Unable to display the threads board view.', '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() {
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: function(e) {
const board = e.eventTarget.value;
const selected = e.eventTarget.checked;
if (selected) {
!Player.threads.selectedBoards.includes(board) && Player.threads.selectedBoards.push(board);
} else {
Player.threads.selectedBoards = Player.threads.selectedBoards.filter(b => b !== board);
}
},
/**
* 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) {
Player.logError('Failed to fetch the boards configuration.');
console.error(err);
return;
}
}
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);
return sounds.length;
});
} catch (err) {
Player.logError('Failed to search for sounds threads.');
console.error(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();
}
};
}),
/* 16 - Network Utilities
• Cached requests:
o get(): GM_xmlHttpRequest wrapper
o Conditional requests
o JSON handling
*/
(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
});
});
}
}),
/* 17 - Template System
• Dynamic UI generation:
o Button definitions
o Template parsing
o Conditional rendering
• Handles all user-customizable layouts
*/
(function(module, exports, __webpack_require__) {
const buttons = __webpack_require__(18);
// 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._handleMenu',
[`.${ns}-threads-button`]: 'threads.toggle',
[`.${ns}-config-button`]: 'settings.toggle'
},
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, `<span class="${ns}-hover-display ${outerClass}">$1</span>`)
.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 ?
`<span class="fa ${buttonConf.icon}">${buttonConf.text}</span>` :
buttonConf.text;
}
return `<a ${attrs.join(' ')}>${text}</a>`;
})
.replace(soundNameRE, name ? `<div class="fc-sounds-col fc-sounds-truncate-text"><span title="${name}">${name}</span></div>` : '')
.replace(soundIndexRE, data.sound ? Player.sounds.indexOf(data.sound) + 1 : 0)
.replace(soundCountRE, Player.sounds.length)
.replace(/%v/g, "2.3.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) {
try {
e.preventDefault();
const values = ['all', 'one', 'none'];
const current = values.indexOf(Player.config.repeat);
Player.set('repeat', values[(current + 4) % 3]);
} catch (err) {
Player.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.
*/
_handleShuffle: function(e) {
try {
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');
} catch (err) {
Player.logError('There was an error changing the shuffle setting. Please check the console for details.', 'warning');
console.error('[4chan sounds player]', err);
}
},
/**
* Display an item menu.
*/
_handleMenu: function(e) {
e.preventDefault();
e.stopPropagation();
const x = e.clientX;
const y = e.clientY;
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({
x,
y,
sound
}), parent);
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}-item-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 href="${URL.createObjectURL(response.response)}" download="${name}" rel="noopener" target="_blank"></a>`);
a.click();
URL.revokeObjectURL(a.href);
},
onerror: () => Player.logError('There was an error downloading.', '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);
},
};
}),
/* 18 - Button Definitions
• All control buttons:
o Icons
o Behavior flags
o State variants
• Organized by function (playback, navigation, etc.)
*/
(function(module, exports) {
module.exports = [{
property: 'repeat',
tplName: 'repeat',
class: `${ns}-repeat-button`,
values: {
all: {
attrs: ['title="Repeat All"'],
text: '𝐀  ',
icon: 'oi oi-loop-circular'
},
one: {
attrs: ['title="Repeat One"'],
text: ' 𝟭  ',
icon: 'oi oi-loop-circular'
},
none: {
attrs: ['title="No Repeat"'],
text: ' 𝟬  ',
icon: 'oi oi-loop-circular'
}
}
},
{
property: 'shuffle',
tplName: 'shuffle',
class: `${ns}-shuffle-button`,
values: {
true: {
attrs: ['title="Shuffled"'],
text: '✔  ',
icon: 'oi oi-random'
},
false: {
attrs: ['title="Ordered"'],
text: '✘ ',
icon: 'oi oi-random'
}
}
},
{
property: 'viewStyle',
tplName: 'playlist',
class: `${ns}-viewStyle-button`,
values: {
playlist: {
attrs: ['title="Hide Playlist"'],
text: ' ',
icon: 'oi oi-collapse-up'
},
image: {
attrs: ['title="Show Playlist"'],
text: ' ',
icon: 'oi oi-expand-down'
}
}
},
{
property: 'hoverImages',
tplName: 'hover-images',
class: `${ns}-hoverImages-button`,
values: {
true: {
attrs: ['title="Hover Images Enabled"'],
text: ' ✔ ',
icon: 'oi oi-image'
},
false: {
attrs: ['title="Hover Images Disabled"'],
text: ' ✘',
icon: 'oi oi-image'
}
}
},
{
tplName: 'add',
class: `${ns}-add-button`,
icon: 'oi oi-plus',
text: '  ',
attrs: ['title="Add local files"']
},
{
tplName: 'reload',
class: `${ns}-reload-button`,
icon: 'oi oi-reload',
text: '  ',
attrs: ['title="Reload the playlist"']
},
{
tplName: 'settings',
class: `${ns}-config-button`,
icon: 'oi oi-wrench',
text: '  ',
attrs: ['title="Settings"']
},
{
tplName: 'threads',
class: `${ns}-threads-button`,
icon: 'oi oi-list-rich',
text: '  ',
attrs: ['title="Threads"']
},
{
tplName: 'close',
class: `${ns}-close-button`,
icon: 'oi oi-x',
text: ' ',
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: 's',
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: 's',
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: 'sound-tag-toggle',
class: `${ns}-sound-tag-toggle-button`,
text: '[ST]',
attrs: ['title="Toggle showing only sound tag posts"']
}
];
}),
/* 19 - Templates
Main player structure */
(function(module, exports) {
module.exports = (data = {}) => `<div id="${ns}-container" data-view-style="${Player.config.viewStyle}" style="top: 30px; left: 0px; width: 350px; display: none;">
<div class="${ns}-header ${ns}-row">
${Player.templates.header(data)}
</div>
<div class="${ns}-view-container">
<div class="${ns}-player ${!Player.config.hoverImages ? `${ns}-hide-hover-image` : ''}" ">
${Player.templates.player(data)}
</div>
<div class="${ns}-settings ${ns}-panel" style="height: 400px">
${Player.templates.settings(data)}
</div>
<div class="${ns}-threads ${ns}-panel" style="height: 400px">
${Player.templates.threads(data)}
</div>
</div>
<div class="${ns}-footer">
${Player.templates.footer(data)}
</div>
<input class="${ns}-file-input" type="file" style="display: none" accept="image/*,.webm,.mp4" multiple>
</div>`
}),
/* 20 - Templates
Control bars */
(function(module, exports) {
module.exports = (data = {}) => `<div class="${ns}-col-auto ${ns}-col-auto">
<div class="${ns}-media-control ${ns}-previous-button">
<div class="${ns}-previous-button-display"></div>
</div>
<div class="${ns}-media-control ${ns}-play-button">
<div class="${ns}-play-button-display ${!Player.audio || Player.audio.paused ? `${ns}-play` : ''}"></div>
</div>
<div class="${ns}-media-control ${ns}-next-button">
<div class="${ns}-next-button-display"></div>
</div>
</div>
<div class="${ns}-col">
<div class="${ns}-seek-bar ${ns}-progress-bar">
<div class="${ns}-full-bar">
<div class="${ns}-loaded-bar"></div>
<div class="${ns}-current-bar"></div>
</div>
</div>
</div>
<div class="${ns}-col-auto">
<span class="${ns}-current-time">0:00</span> / <span class="${ns}-duration">0:00</span>
</div>
<div class="${ns}-col-auto">
<div class="${ns}-volume-bar ${ns}-progress-bar">
<div class="${ns}-full-bar">
<div class="${ns}-current-bar" style="width: ${Player.audio.volume * 100}%"></div>
</div>
</div>
</div>
<div class="${ns}-col-auto">
<div class="${ns}-media-control ${ns}-fullscreen-button">
<div class="${ns}-fullscreen-button-display"></div>
</div>
</div>`
}),
/* 21 - Templates
CSS */
(function(module, exports) {
module.exports = (data = {}) => `
/*
*
* CONTROLS CSS
*
*/
.${ns}-controls {
align-items: center;
padding: .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: .8rem;
background: #fff
}
.${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: #fff
}
.${ns}-duration {
color: #909090
}
.${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: .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: #fff;
height: .8rem;
min-width: .8rem;
border-radius: 1rem;
box-shadow: rgba(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: #fff
}
.${ns}-chan-x-controls {
align-items: inherit
}
.${ns}-chan-x-controls .${ns}-current-time,
.${ns}-chan-x-controls .${ns}-duration {
margin: 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: .7rem;
width: .5rem
}
/*
*
* FOOTER CSS
*
*/
.${ns}-footer {
padding: .15rem .25rem;
border-top:solid 1px ${Player.config.colors.border}
}
.${ns}-footer .${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%, ${Player.config.colors.text} 55%, ${Player.config.colors.text} 100%)
}
.${ns}-footer:hover .${ns}-hover-display {
display: inline-block
}
/*
*
* HEADER CSS
*
*/
.${ns}-header {
cursor: grab;
text-align: center;
border-bottom:solid 1px ${Player.config.colors.border};
padding: .25rem
}
.${ns}-header:hover .${ns}-hover-display {
display: flex
}
html.fourchan-x .fa-repeat.fa-repeat-one::after {
content: "1";
font-size: .5rem;
visibility: visible;
margin-left: -1px
}
/*
*
* IMAGE CSS
*
*/
.${ns}-image-link {
text-align: center;
display: flex;
justify-items: center;
justify-content: center;
position: relative;
resize: both;
overflow: hidden;
min-height: ${media_display_min_height} !important;
max-height: ${media_display_max_height} !important;
min-width: 100%;
max-width: 100%;
}
.${ns}-image-link.${ns}-pip {
align-items: end;
position: fixed !important;
right: 10px !important;
bottom: 10px !important;
left: auto !important;
top: auto !important;
max-height: ${minimized_display_max_height} !important;
max-width: ${minimized_display_max_width} !important;
align-items: end;
z-index: 9999; /* Ensure it's above other elements */
}
.${ns}-image-link.${ns}-pip .${ns}-image,
.${ns}-image-link.${ns}-pip .${ns}-video {
height: initial;
width: initial;
object-fit: contain;
position: fixed !important;
right: 10px !important;
bottom: 10px !important;
left: auto !important;
top: auto !important;
max-height: 150px !important;
max-width: 200px !important;
}
.${ns}-image-link .${ns}-video {
display: none
}
.${ns}-image,
.${ns}-video {
height: 100% !important;
width: 100% !important;
object-fit: contain
}
.${ns}-image-link.${ns}-show-video .${ns}-video {
display: block
}
.${ns}-image-link.${ns}-show-video .${ns}-image {
display: none
}
.${ns}-image-link img,
.${ns}-image-link video {
max-height: 100% !important;
max-width: 100% !important;
object-fit: contain;
}
.${ns}-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 15px;
height: 15px;
cursor: se-resize;
z-index: 3;
}
.${ns}-image-link video {
pointer-events: none; /* Disable clicks on the link */
}
/*
*
* LAYOUT CSS
*
*/
#${ns}-container {
position: fixed;
background:${Player.config.colors.background};
border:1px solid ${Player.config.colors.border};
min-width: 375px;
color:${Player.config.colors.text}
}
.${ns}-panel {
padding: 0 .25rem;
height: 100%;
width: calc(100% - .5rem);
overflow: auto
}
.${ns}-heading {
font-weight: 600;
margin: .5rem 0;
min-width: 100%
}
.${ns}-has-description {
cursor: help
}
.${ns}-heading-action {
font-weight: normal;
text-decoration: underline;
margin-left: .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%
}
html.fourchan-x #${ns}-container .fa {
font-size: 0;
visibility: hidden;
margin: 0 .15rem
}
.${ns}-truncate-text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden
}
.${ns}-hover-display {
display: none
}
/*
*
* LIST CSS
*
*/
.${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;
height: 200px;
}
.${ns}-list-container .${ns}-list-item {
list-style-type: none;
padding: .15rem .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: .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}
}
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, .15);
border-radius: 3px;
padding-top: 1px;
padding-bottom: 3px
}
html:not(.fourchan-x) .${ns}-item-menu .entry {
position: relative;
display: block;
padding: .125rem .5rem;
min-width: 70px;
white-space: nowrap
}
html:not(.fourchan-x) .${ns}-item-menu .has-submenu::after {
content: "";
border-left: .5em solid;
border-top: .3em solid transparent;
border-bottom: .3em solid transparent;
display: inline-block;
margin: .35em;
position: absolute;
right: 3px
}
html:not(.fourchan-x) .${ns}-item-menu .submenu {
position: absolute;
display: none
}
html:not(.fourchan-x) .${ns}-item-menu .focused>.submenu {
display: block
}
/*
*
* SETTINGS CSS
*
*/
.${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
}
/*
*
* THREADS CSS
*
*/
.${ns}-threads .${ns}-thread-board-list label {
display: inline-block;
width: 4rem
}
.${ns}-threads .${ns}-thread-list {
margin: 1rem -0.25rem 0;
padding: .5rem 1rem;
border-top:solid 1px ${Player.config.colors.border}
}
.${ns}-threads .${ns}-thread-list .boardBanner {
margin: 1rem 0
}
.${ns}-threads table {
margin-top: .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: .25rem
}
.${ns}-threads table tr {
padding: .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}-threads,
.${ns}-settings,
.${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=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 {
display: none
}
#${ns}-container[data-view-style=image] .${ns}-image-link {
height: auto
}
#${ns}-container[data-view-style=playlist] .${ns}-image-link {
height: 125px
}
#${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
}
`
}),
/* 22 - Templates
Footer */
(function(module, exports) {
module.exports = (data = {}) => Player.userTemplate.build({
template: Player.config.footerTemplate,
sound: Player.playing
}) +
`<div class="${ns}-expander"></div>`
}),
/* 23 - Templates
Header */
(function(module, exports) {
module.exports = (data = {}) => Player.userTemplate.build({
template: Player.config.headerTemplate,
sound: Player.playing,
defaultName: '8chan Sounds',
outerClass: `${ns}-col-auto`
});
}),
/* 24 - Templates
Context menus */
(function(module, exports) {
module.exports = (data = {}) => `<div class="${ns}-item-menu dialog" id="menu" tabindex="0" data-type="post" style="position: fixed; top: ${data.y}px; left: ${data.x}px;">
<a class="${ns}-remove-link entry focused" href="javascript:;" data-id="${data.sound.id}">Remove</a>
${data.sound.post ? `<a class="entry" href="#${(is4chan ? 'p' : '') + data.sound.post}">Show Post</a>` : ''}
<div class="entry has-submenu">
Open
<div class="dialog submenu" style="inset: 0px auto auto 100%;">
<a class="entry" href="${data.sound.image}" target="_blank">Image</a>
<a class="entry" href="${data.sound.src}" target="_blank">Sound</a>
</div>
</div>
<div class="entry has-submenu">
Download
<div class="dialog submenu" style="inset: 0px auto auto 100%;">
<a class="${ns}-download-link entry" href="javascript:;" data-src="${data.sound.image}" data-name="${data.sound.filename}">Image</a>
<a class="${ns}-download-link entry" href="javascript:;" data-src="${data.sound.src}">Sound</a>
</div>
</div>
<div class="entry has-submenu">
Filter
<div class="dialog submenu" style="inset: 0px auto auto 100%;">
${data.sound.imageMD5 ? `<a class="${ns}-filter-link entry" href="javascript:;" data-filter="${data.sound.imageMD5}">Image</a>` : ''}
<a class="${ns}-filter-link entry" href="javascript:;" data-filter="${data.sound.src.replace(/^(https?\:)?\/\//, '')}">Sound</a>
</div>
</div>
</div>`
}),
/* 25 - Templates
Playlist items */
(function(module, exports) {
module.exports = (data = {}) => (data.sounds || Player.sounds).map(sound =>
`<div class="${ns}-list-item ${ns}-row ${sound.playing ? 'playing' : ''}" data-id="${sound.id}" draggable="true">
${Player.userTemplate.build({
template: Player.config.rowTemplate,
sound,
outerClass: `${ns}-col-auto`
})}
</div>`
).join('')
}),
/* 26 - Templates
Media display */
(function(module, exports) {
module.exports = (data = {}) => `<div class="${ns}-media">
<a class="${ns}-image-link" target="_blank">
<img class="${ns}-image"></img>
<video class="${ns}-video"></video>
</a>
<div class="${ns}-controls ${ns}-row">
${Player.templates.controls(data)}
</div>
</div>
<div class="${ns}-list-container style="height: 100px">
${Player.templates.list(data)}
</div>
<img class="${ns}-hover-image">`
}),
/* 27 - Templates
Settings panel */
(function(module, exports, __webpack_require__) {
module.exports = (data = {}) => {
const settingsConfig = __webpack_require__(1);
let tpl = `
<div class="${ns}-heading">Version</div>
<a href="https://greasyfork.org/en/scripts/533468-8chan-sounds-player" target="_blank">${VERSION}</a>
<div class="${ns}-heading">Encode / Decode URL</div>
<div class="${ns}-row">
<input type="text" class="${ns}-decoded-input ${ns}-col" placeholder="https://">
<input type="text" class="${ns}-encoded-input ${ns}-col" placeholder="https%3A%2F%2F">
</div>
`;
settingsConfig.forEach(function addSetting(setting) {
// Filter settings that aren't flagged to be displayed.
if (!setting.showInSettings && !(setting.settings || []).find(s => s.showInSettings)) {
return;
}
const desc = setting.description;
tpl += `
<div class="${ns}-row ${setting.isSubSetting ? `${ns}-sub-settings` : ''}">
<div class="${ns}-col ${!setting.isSubSetting ? `${ns}-heading` : ''} ${desc ? `${ns}-has-description` : ''}" ${desc ? `title="${desc.replace(/"/g, '"')}"` : ''}>
${setting.title}
${(setting.actions || []).map(action => `<a href="javascript:;" class="${ns}-heading-action" data-handler="${action.handler}" data-property="${setting.property}">${action.title}</a>`)}
</div>`;
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 type = typeof value;
if (setting.split) {
value = value.join(setting.split);
} else if (type === 'object') {
value = JSON.stringify(value, null, 4);
}
tpl += `
<div class="${ns}-col">
${
type === 'boolean'
? `<input type="checkbox" ${attrs} ${value ? 'checked' : ''}></input>`
: setting.showInSettings === 'textarea' || type === 'object'
? `<textarea ${attrs}>${value}</textarea>`
: setting.options
? `<select ${attrs}>
${Object.keys(setting.options).map(k => `<option value="${k}" ${value === k ? 'selected' : ''}>
${setting.options[k]}
</option>`).join('')}
</select>`
: `<input type="text" ${attrs} value="${value}"></input>`
}
</div>`;
}
tpl += '</div>';
});
return tpl;
}
}),
/* 28 - Templates
Thread browser */
(function(module, exports) {
module.exports = (data = {}) => `<div class="${ns}-heading ${ns}-has-description" title="Search for threads with a sound OP">
Active Threads
${!Player.threads.loading ? `- <a class="${ns}-fetch-threads-link ${ns}-heading-action" href="javascript:;">Update</a>` : ''}
</div>
<div style="display: ${Player.threads.loading ? 'block' : 'none'}">Loading</div>
<div style="display: ${Player.threads.loading ? 'none' : 'block'}">
<div class="${ns}-heading ${ns}-has-description" title="Only includes threads containing the search.">
Filter
</div>
<input type="text" class="${ns}-threads-filter" value="${Player.threads.filterValue || ''}"></input>
<div class="${ns}-heading">
Boards - <a class="${ns}-all-boards-link ${ns}-heading-action" href="javascript:;">${Player.threads.showAllBoards ? 'Selected Only' : 'Show All'}</a>
</div>
<div class="${ns}-thread-board-list">
${Player.templates.threadBoards(data)}
</div>
${
!Player.threads.hasParser || Player.config.threadsViewStyle === 'table'
? `<table style="width: 100%">
<tr>
<th>Thread</th>
<th>Subject</th>
<th>Replies/Images</th>
<th>Started</th>
<th>Updated</th>
<tr>
<tbody class="${ns}-threads-body"></tbody>
</table>`
: `<div class="${ns}-thread-list"></div>`
}
</div>`
}),
/* 29 - Templates
Thread browser */
(function(module, exports) {
module.exports = (data = {}) => (Player.threads.boardList || []).map(board => {
let checked = Player.threads.selectedBoards.includes(board.board);
return !checked && !Player.threads.showAllBoards ?
'' :
`<label>
<input type="checkbox" value="${board.board}" ${checked ? 'checked' : ''}>
/${board.board}/
</label>`
}).join('')
}),
/* 30 - Templates
Thread browser */
(function(module, exports) {
module.exports = (data = {}) => Object.keys(Player.threads.displayThreads).reduce((rows, board) => {
return rows.concat(Player.threads.displayThreads[board].map(thread => `
<tr>
<td>
<a class="quotelink" href="//boards.${thread.ws_board ? '4channel' : '4chan'}.org/${thread.board}/thread/${thread.no}#p${thread.no}" target="_blank">
>>>/${thread.board}/${thread.no}
</a>
</td>
<td>${thread.sub || ''}</td>
<td>${thread.replies} / ${thread.images}</td>
<td>${timeAgo(thread.time)}</td>
<td>${timeAgo(thread.last_modified)}</td>
</tr>
`))
}, []).join('')
})
]);