8chan sounds player

Play that faggy music weeb boi

// ==UserScript==
// @name         8chan sounds player
// @version      2.3.0_0032
// @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/*
// @match        https://8chan.moe/*/last/*
// @match        https://8chan.se/*/last/*
// @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
// @grant        GM_getResourceURL
// @grant        GM_addElement
// @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

(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)[ =:|$](.*?)[\])}]/gi;
		const filenameRE2 = /(\[([^\]]*(?:catbox\.moe)[^\]]*)\])/gi;
		const videoFileExtRE = /\.(webm|mp4|m4v|ogv|avi|mpeg|mpg|mpe|m1v|m2v|mov|wmv)$/i;
		const audioFileExtRE = /\.(mp3|m4a|m4b|flac|ogg|oga|opus|mp2|mpega|wav|aac)$/i;
		const imageMimeRE = /^image\/.+$/;
		const videoMimeRE = /^video\/.+$/;
		const audioMimeRE = /^audio\/.+$/;
		//const playlistExtRE = /\.(m3u|asx)$/i;

		// Function to safely get file extension (handles multiple dots in filename)
		function getFileExtension(filename) {
			// Handle edge cases: no extension, hidden files, or filenames ending with dot
			if (!filename || filename.indexOf('.') === -1 || filename.endsWith('.')) {
				return '';
			}
			return filename.slice(((filename.lastIndexOf('.') - 1) >>> 0) + 2).toLowerCase();
		}

		function determinateMimeType(extension, isVideo, isAudio) {
			let type;
			if (isVideo) {
				switch (extension) {
					case 'webm': type = 'video/webm'; break;
					case 'mp4': type = 'video/mp4'; break;
					case 'm4v': type = 'video/mp4'; break;
					case 'ogv': type = 'video/ogg'; break;
					case 'avi': type = 'video/x-msvideo'; break;
					case 'mpeg': case 'mpg': case 'mpe': case 'm1v': case 'm2v':
						type = 'video/mpeg'; break;
					default: type = 'video/mp4'; // default fallback
				}
			} else if (isAudio) {
				switch (extension) {
					case 'mp3': case 'mpega': case 'mp2': type = 'audio/mpeg'; break;
					case 'm4a': case 'm4b': type = 'audio/mp4'; break;
					case 'flac': type = 'audio/flac'; break;
					case 'ogg': case 'oga': case 'opus': type = 'audio/ogg'; break;
					case 'wav': type = 'audio/wav'; break;
					case 'aac': type = 'audio/aac'; break;
					default: type = 'audio/mpeg'; // default fallback
				}
			} else {
				type = 'audio/mpeg'; // ultimate fallback
			}
			return type;
		}

		function getFullFilename(element) {
			if (element.dataset.fileExt) {
				return element.textContent + element.nextElementSibling.textContent;
			}
			return element.textContent;
		}

		function formatFileTitle(postId, fileIndex, fileSize, filename) {
			// Extract file extension
			const fileExt = filename.split('.').pop().toLowerCase();
			// Get base filename without extension
			let baseName = filename.replace(/\.[^/.]+$/, "");
			if(/^[a-z0-9]{64}$/.test(baseName)) baseName = `<span style="opacity: 0.8; background: transparent !important">${baseName.slice(0, 8)}</span>`; // If the filename is randomly generated text, shorten it.

			// local files case (module 13 addFromFiles())
			if(fileSize == null) return `locF:${localFileCounter} &nbsp; <span class="${ns}-playlist-file-ext">.${fileExt}</span>&nbsp;${baseName}`;

			const displaySize = formatFileSize(fileSize, true);

			return `${postId} &nbsp; ${displaySize} <span class="${ns}-playlist-file-ext">.${fileExt}</span>&nbsp;${baseName}`;
		}

		function formatFileTitle2(postId, fileIndex, fileSize, filename) {
			// Extract file extension
			const fileExt = filename.split('.').pop().toLowerCase();
			// Get base filename without extension
			let baseName = filename.replace(/\.[^/.]+$/, "");
			if(/^[a-z0-9]{64}$/.test(baseName)) baseName = `${baseName.slice(0, 8)}`; // If the filename is randomly generated text, shorten it.

			// local files case (module 13 addFromFiles())
			if(fileSize == null) return `locF:${localFileCounter} &nbsp; .${fileExt}&nbsp;${baseName}`;

			const displaySize = formatFileSize(fileSize, false);

			return `${postId} &nbsp; ${displaySize} .${fileExt}&nbsp;${baseName}`;
		}

		function formatFileSize(fileSize, addSpace = false) {
			// local files case (module 13 addFromFiles())
			if(fileSize == null) return 'NULL';

			// 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 2 digits number like 11.0 MB (11.0 MB → 11 MB).
			let displaySize = sizeInMB > 99 ? "99+ MB" : `${(sizeInMB > 9.9 ? '&puncsp;' + sizeInMB.toFixed(0): sizeInMB.toFixed(1))} MB`;
			if(!addSpace) displaySize = sizeInMB > 99 ? "99+ MB" : `${(sizeInMB > 9.9 ? sizeInMB.toFixed(0): sizeInMB.toFixed(1))} MB`;

			return displaySize;
		}


		function getPostNumber(postElement) {
			// 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, dataFilemime) {
			if (!filename) return [];
			filename = filename.replace(/-/, '/');

			// First check for [sound=URL] tags
			const matches = [];
			let match;
			while (((match = filenameRE.exec(filename)) !== null) || (((match = filenameRE2.exec(filename)) !== null))) {
				matches.push(match);
			}

			// If we found sound tags, process them and ignore video files
			if (matches.length) {
				return matches.reduce((sounds, match, i) => {
					let src = match[2];
					const id = post + ':' + fileIndex;
					//const title = match[1].trim() || defaultName + (matches.length > 1 ? ` (${i + 1})` : '');

					try {
						if (src.includes('_') && !src.includes('%')) src = src.replace(/_/g, '%'); // Fix for Firefox issue: replace underscores that were mistakenly used instead of percent-encoding
						if (src.includes('%')) src = decodeURIComponent(src);
						if (src.match(protocolRE) === null) src = (location.protocol + '//' + src);
					} catch (error) {
						return sounds;
					}

					// Determine if this is a video file based on extension
					const isVideo = videoFileExtRE.test(src) ? true : false;
					const isAudio = audioFileExtRE.test(src) ? true : false;

					// Determine the MIME type based on extension
					const extension = getFileExtension(src);
					let type = determinateMimeType(extension, isVideo, isAudio)

					const sound = {
						src, // external sound
						id,
						title: formatFileTitle(post, fileIndex, fileSize, filename),
						title2: formatFileTitle2(post, fileIndex, fileSize, filename),
						post,
						image, // image or video taked from the post
						filename,
						thumb,
						imageMD5,
						type, // external sound
						isVideo, // is external sound video?
						hasSoundTag: true,
						fileIndex: fileIndex,
						fileSize: formatFileSize(fileSize, false),
						dataFilemime: dataFilemime
					};
					Player.acceptedSound(sound) && sounds.push(sound);
					return sounds;
				}, []);
			}

			// If no sound tags found, check for video files
			const isVideo = videoMimeRE.test(dataFilemime);
			const isAudio = audioMimeRE.test(dataFilemime);
			if (isVideo || isAudio) {
				const id = post + ':' + fileIndex + ':0';

				// Determine the MIME type based on extension
				const extension = getFileExtension(image);
				let type = determinateMimeType(extension, isVideo, false)

				return [{
					src: image, // Use the image URL as src for video files
					id: post + ':' + fileIndex,
					title: formatFileTitle(post, fileIndex, fileSize, filename),
					title2: formatFileTitle2(post, fileIndex, fileSize, filename),
					post,
					image, // image (post file)
					filename,
					thumb,
					imageMD5,
					type, // image (post file)
					isVideo, // is image (post file) video?
					hasSoundTag: false, // external sound
					fileIndex: fileIndex,
					fileSize: formatFileSize(fileSize, false),
					dataFilemime: dataFilemime
				}];
			}

			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();

					// Get file dimensions if available
					const dimensionLabel = container.querySelector('.dimensionLabel'); // e.g. '123x123'

					// 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');
					let thumbSrc = thumbImg && thumbImg.src;
					const md5Match = imageSrc && imageSrc.match(/\/\.media\/([a-f0-9]{64})/i);
					const imageMD5 = md5Match && md5Match[1];
					const dataFilemime = fileThumb && fileThumb.getAttribute('data-filemime');



					// Replace spoiler thumbnail with actual thumbnail if available
					if (/spoiler/.test(thumbImg.src)) {
						const domain = new URL(thumbImg.src).origin;
						thumbSrc = imageSrc && `${domain}/.media/t_${imageMD5}`;
					}

					// Set the full image as the thumbnail for images that are 220x220 pixels or smaller.
					// This is a fix for small images because thumbnails are not generated for them.
					// This crap does not apply to GIFs, GIFs always generate thumbnails.
					if (dimensionLabel && /^image\/.+$/.test(dataFilemime) && !/^image\/gif$/.test(dataFilemime) ) {
						const dimensions = dimensionLabel.textContent.trim().split(/x|×/);
						if (dimensions.length === 2) {
							const width = parseInt(dimensions[0]);
							const height = parseInt(dimensions[1]);
							if (width <= 220 && height <= 220) {
								thumbSrc = imageSrc;
							}
						}
					}

					const sounds = parseFileName(filename, imageSrc, postNumber, thumbSrc, imageMD5, fileIndex, fileSize, dataFilemime);
					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',
				options: {
						playlist: 'Playlist',
						image: 'Image',
						gallery: 'Gallery'
			}
			},
			{
				property: 'hoverImages',
				default: false
			},
			{
				property: 'preventHoverImagesFor',
				default: [],
				save: false
			},
			{
				property: 'volumeValue',
				title: 'Volume value',
				description: 'Stores the volume value from the previous session.',
				showInSettings: false,
				default: '1'
			},
			{
				title: 'Miscellaneous',
				description: 'Variety of different settings',
				showInSettings: true,
				settings: [{
						property: 'fontSize',
						title: 'Font Size',
						description: 'Adjust the font size.',
						default: '13',
						showInSettings: true,
						updateStylesheet: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'autoshow',
						default: true,
						title: 'Autoshow',
						description: 'Automatically show the player when the thread contains sounds.',
						showInSettings: false,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'pauseOnHide',
						default: false,
						title: 'Pause on hide',
						description: 'Pause the player when it\'s hidden.',
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'showSoundTagOnly',
						default: false,
						title: '<span style="margin: 0.2em 0;">Only Show<br>Sound Posts</span>',
						description: 'When enabled, only posts with [sound=URL] tags will be displayed in the playlist.',
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'borderWidth',
						default: '1px',
						title: 'Border Width',
						showInSettings: true,
						updateStylesheet: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.forceBorderWidth',
						}],
					},
				]
			},
			{
				title: 'Media Display Settings',
				description: 'Settings for media display dimensions.',
				showInSettings: true,
				settings: [{
						property: 'minMediaHeight',
						title: 'Minimum Height',
						description: 'Maximum width for the Media Display.',
						default: '25px',
						showInSettings: true,
						updateStylesheet: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'maxMediaHeight',
						title: 'Maximum Height',
						description: 'Maximum height for the Media Display.',
						default: '400px',
						showInSettings: true,
						updateStylesheet: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
				]
			},
			{
				title: 'Minimised Display',
				description: 'Optional displays for when the player is minimised.',
				settings: [{
						property: 'pip',
						title: 'Enabled',
						description: 'Display a fixed Minimised Display of the playing sound in the bottom right of the thread.',
						default: true,
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'maxPIPWidth',
						title: 'Maximum Width',
						description: 'Maximum width for the Minimised Display.',
						default: '200px',
						updateStylesheet: true,
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'maxPIPHeight',
						title: 'Maximum Height',
						description: 'Maximum height for the Minimised Display.',
						default: '250px',
						updateStylesheet: true,
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'offsetBottomPIP',
						title: 'Bottom offset',
						description: 'Changes the bottom offset (position) of the minimized player.',
						default: '10px',
						updateStylesheet: true,
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'offsetRightPIP',
						title: 'Right offset',
						description: 'Changes the right offset (position) of the minimized player.',
						default: '10px',
						updateStylesheet: true,
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'zIndexPIP',
						title: 'Z-Index',
						description: 'Changes the Z-INDEX of the minimized player. Setting the value below 0 will disable the "remaximize on-click" feature. To maximize the player again, click the icon in the header.',
						default: '0',
						updateStylesheet: true,
						showInSettings: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						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'
						}
					}
				]
			},
			{
				title: "Controls",
				displayGroup: "Display",
				showInSettings: true,
				settings: [{
						property: "preventControlWrapping",
						title: "Prevent Wrapping",
						description: "Hide elements from controls to prevent wrapping when the player is too small",
						default: true,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
				},
				{
						property: "controlsHideOrder",
						title: "Hide Order",
						description: 'Order controls are hidden in to prevent wrapping. ' +
							'Available controls are\n' +
							'previous, ' +
							'play, ' +
							'next, ' +
							'seek-bar, ' +
							'time, ' +
							'duration, ' +
							'volume-bar ' +
							'and fullscreen.',
						default: ["fullscreen", "seek-bar", "duration", "time", "volume-bar", "previous", "next"],
						showInSettings: 'textarea',
						attrs: 'style="height:120px;"',
						split: '\n',
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
				}]
			},
			{
				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,
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'minPostWidth',
						title: 'Minimum Width',
						default: '30%',
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}],
					}
				]
			},
			{
				property: 'threadsViewStyle',
				title: 'Threads View',
				description: 'How threads in the threads view are listed.',
				showInSettings: false,
				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: ' '
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.previous',
						title: 'Previous',
						keyHandler: 'previous',
						ignoreRepeat: true,
						default: {
							key: 'arrowleft'
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.next',
						title: 'Next',
						keyHandler: 'next',
						ignoreRepeat: true,
						default: {
							key: 'arrowright'
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.volumeUp',
						title: 'Volume Up',
						keyHandler: 'hotkeys.volumeUp',
						default: {
							shiftKey: true,
							key: 'arrowup'
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.volumeDown',
						title: 'Volume Down',
						keyHandler: 'hotkeys.volumeDown',
						default: {
							shiftKey: true,
							key: 'arrowdown'
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.toggleFullscreen',
						title: 'Toggle Fullscreen',
						keyHandler: 'display.toggleFullScreen',
						default: {
							key: ''
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.togglePlayer',
						title: 'Show/Hide',
						keyHandler: 'display.toggle',
						default: {
							key: 'h'
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.togglePlaylist',
						title: 'Toggle Playlist',
						keyHandler: 'playlist.toggleView',
						default: {
							key: ''
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.scrollToPlaying',
						title: 'Jump To Playing',
						keyHandler: 'playlist.scrollToPlaying',
						default: {
							key: ''
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					},
					{
						property: 'hotkey_bindings.toggleHoverImages',
						title: 'Toggle Hover Images',
						keyHandler: 'playlist.toggleHoverImages',
						default: {
							key: ''
						},
						actions: [{
							title: 'R',
							handler: 'settings.reset'
						}],
					}
				]
			},
			{
				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 &nbsp; \nsound-name &nbsp; \nadd-button reload-button settings-button &nbsp; close-button',
				description: 'Template for the header contents.\n\nElements inside of p:{ &nbsp; } are hidden if no sound is playing.\nElements inside of h:{ &nbsp; } are hidden and appear on hover.',
				showInSettings: 'textarea',
			},
			{
				property: 'rowTemplate',
				title: 'Row Contents',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				default: 'sound-name h:{menu-button}',
				description: 'Template for the row contents.\n\nElements inside of p:{ &nbsp; } are hidden if no sound is playing.\nElements inside of h:{ &nbsp; } are hidden and appear on hover.',
				showInSettings: 'textarea'
			},
			{
				property: 'footerTemplate',
				title: 'Footer Contents',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				default:
					'<div class="fc-sounds-footer-left">\n' +
					'	playing-button:"sound-index /"&nbsp;sound-count ui-files-icon\n' +
					'</div>\n\n' +
					'<div class="fc-sounds-footer-right">\n' +
					'	sound-tag-toggle-button\n' +
					'	p:{\n' +
					'		post-link\n' +
					'		\n' +
					'		<span class="fc-sounds-footer-text">&nbsp;Open:</span>\n' +
					'		ui-bracketL-icon\n' +
					'			image-link sound-link\n' +
					'		ui-bracketR-icon\n' +
					'		\n' +
					'		<span class="fc-sounds-footer-text">Download:</span>\n' +
					'		ui-bracketL-icon\n' +
					'			dl-image-button dl-sound-button\n' +
					'		ui-bracketR-icon\n' +
					'	}\n' +
					'</div>\n',
				description: 'Template for the footer contents.\n\nElements inside of p:{ &nbsp; } are hidden if no sound is playing.\nElements inside of h:{ &nbsp; } are hidden and appear on hover.',
				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'*/
				showInSettings: false,
			},
			{
				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: 'rgba(0, 0, 0, 1)',
						title: 'Text'
					},
					{
						property: 'colors.background',
						default: 'rgba(214, 218, 240, 1)',
						title: 'Background'
					},
					{
						property: 'colors.border',
						default: 'rgba(183, 197, 217, 1)',
						title: 'Border'
					},
					{
						property: 'colors.odd_row',
						default: 'rgba(214, 218, 240, 1)',
						title: 'Odd Row',
					},
					{
						property: 'colors.even_row',
						default: 'rgba(183, 197, 217, 1)',
						title: 'Even Row'
					},
					{
						property: 'colors.playing',
						default: 'rgba(152, 191, 247, 1)',
						title: 'Playing Row'
					},
					{
						property: 'colors.dragging',
						default: 'rgba(195, 150, 200, 1)',
						title: 'Dragging Row'
					},
					{
						property: 'colors.text_playing',
						default: 'rgba(0, 0, 0, 1)',
						title: '<span style="font-size: 13.5px; margin: 0.2em 0;">Text color of the<br>playing/dragging row</span>'
					},
					{
						property: 'colors.controls_panel',
						default: 'rgba(63, 63, 68, 1)',
						title: '<span style="font-size: 13.5px; margin: 0.2em 0;">Playback Controls<br>Panel Background</span>',
					},
					{
						property: 'colors.buttons_color',
						default: 'rgba(255, 255, 255, 1)',
						title: 'Buttons'
					},
					{
						property: 'colors.hover_color',
						default: 'rgba(0, 182, 240, 1)',
						title: 'Hover',
					},
					{
						property: 'colors.controls_current_time',
						default: 'rgba(255, 255, 255, 1)',
						title: 'Current Time'
					},
					{
						property: 'colors.controls_duration',
						default: 'rgba(144, 144, 144, 1)',
						title: 'Duration'
					},
					{
						property: 'colors.progress_bar',
						default: 'rgba(140, 140, 140, 1)',
						title: '<span style="margin: 0.2em 0;">Progress Bar<br>Background</span>',
					},
					{
						property: 'colors.progress_bar_loaded',
						default: 'rgba(90, 90, 91, 1)',
						title: '<span style="margin: 0.25em 0 0.2em 0;">Loaded Bar<br>Background</span>',
					}
				]
			},

		];


	}),
	/* 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();
					// 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);

				// 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
		•	Load in glyphs
	*/
	(function(module, exports, __webpack_require__) {
		// Update globals for 8chan
		window.ns = 'fc-sounds';
		window.is4chan = false;
		window.isChanX = false;
		window.Board = location.pathname.split('/')[1];
		window.localFileCounter = 0;
		window.isLoading = false;
		window.Master = undefined;
		window.Slave = undefined;

		const scriptVersion = GM_info.script.version;
		window.VERSION = scriptVersion ? scriptVersion : 'Version not found';

		// 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);
		};

		window.throttleFc = function(func, limit) {
			let inThrottle;
			return function() {
				const args = arguments;
				const context = this;
				if (!inThrottle) {
					func.apply(context, args);
					inThrottle = true;
					setTimeout(() => inThrottle = false, limit);
				}
			}
		};

		window.debounceFc = function(func, timeout = 300){
			let timer;
			return (...args) => {
				clearTimeout(timer);
				timer = setTimeout(() => { func.apply(this, args); }, timeout);
			};
		};
	}),
	/* 5 - Settings Manager
		•	Manages all user configuration:
			o	load()/save(): Persistent storage
			o	set(): Updates settings with validation
			o	applyBoardTheme(): Matches 8chan'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 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();

				// Apply the default board theme as default.
				Player.settings.applyBoardTheme();

				// 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']
				}));

				// Apply the default board theme as default again just in case the script loaded before the DOM/CSS
				setTimeout(Player.settings.applyBoardTheme(), 1000);
			},

			render: function() {
				if (Player.container) {
					Player.$(`.${ns}-settings`).innerHTML = Player.templates.settings();
				}
			},

			forceBorderWidth: function() {
				Player.settings.applyBorderWidth(true);
				Player.settings.save();
			},

			applyBorderWidth: function(force) {
				const innerPostElement = document.querySelector('.divPosts .postCell:not(.postCell:target) .innerPost');
				const innerPostStyle = (!innerPostElement) ? null : window.getComputedStyle(innerPostElement);

				let borderWidth = (innerPostStyle !== null) ? innerPostStyle.getPropertyValue('border-right-width') : '1px';
				borderWidth = Math.max(0.1, Math.min(2, /*Math.round(*/parseFloat(borderWidth)/*)*/)) + 'px' || '1px';

				Player.set('borderWidth', borderWidth, { bypassSave: true, bypassRender: true });

				// Updated the stylesheet if it exists.
				Player.stylesheet && Player.display.updateStylesheet();
				// Re-render the settings if needed.
				Player.settings.render();
			},

			forceBoardTheme: function() {
				Player.settings.applyBoardTheme(true);
				Player.settings.save();
			},

			applyBoardTheme: function(force) {
				const rootStyles = window.getComputedStyle(document.documentElement);
				//console.log(rootStyles);
				const linkElement = document.querySelector('.panelBacklinks a');
				const innerPostElement = document.querySelector('.divPosts .postCell:not(.postCell:target) .innerPost');
				const linkStyle = (!linkElement) ? null : window.getComputedStyle(linkElement);
				const innerPostStyle = (!innerPostElement) ? null : window.getComputedStyle(innerPostElement);
				const selectedTheme = localStorage.getItem('selectedTheme');

				let textColor = rootStyles.getPropertyValue('--text-color').trim() || 'rgba(0,0,0,1)';
				let linkColor = (linkStyle !== null) ? linkStyle.getPropertyValue('color') : rootStyles.getPropertyValue('--link-color').trim() || 'rgba(152,191,247,1)';
				let backgroundColor = (innerPostStyle !== null) ? innerPostStyle.getPropertyValue('background-color') : rootStyles.getPropertyValue('--contrast-color').trim() || rootStyles.getPropertyValue('--background-color').trim() || 'rgba(255,255,255,1)';
				let borderColor = (innerPostStyle !== null) ? innerPostStyle.getPropertyValue('border-bottom-color') : rootStyles.getPropertyValue('--horizon-sep-color').trim() || rootStyles.getPropertyValue('--border-color').trim() || 'rgba(183,197,217,1)';

				let linkHoverColor = rootStyles.getPropertyValue('--link-hover-color').trim() || 'rgba(53,133,244,1)';
				let windowsColor = rootStyles.getPropertyValue('--windows-focused-background').trim() || null;

				textColor = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(textColor, { h: 0, s: -3, v: -3, a:1 }) : Player.settings.adjustColor(textColor, { h: 0, s: -3, v: 3, a:1 });
				linkColor = Player.settings.adjustColor(linkColor, { h: 0, s: 0, v: 0, a:1 });
				backgroundColor = Player.settings.adjustColor(backgroundColor, { h: 0, s: 0, v: 0, a:1 });
				borderColor = Player.settings.adjustColor(borderColor, { h: 0, s: 0, v: 0, a:1 });
				linkHoverColor = Player.settings.adjustColor(linkHoverColor, { h: 0, s: 0, v: 0, a:1 });

				borderColor = (borderColor === backgroundColor) ? Player.settings.mixColors(borderColor, textColor, 0.3) : borderColor;

				const oddRow = backgroundColor;
				const evenRow = Player.settings.mixColors(textColor, oddRow, 0.94);

				const controlsPanel = Player.settings.mixColors(backgroundColor, textColor, 0.11);
				const buttonsColor = (windowsColor !== null) ? Player.settings.adjustColor(windowsColor, { h: 0, s: 0, v: 0, a:1 }) : Player.settings.mixColors(textColor, linkColor, 0.85);
				const hoverColor = linkHoverColor;
				const controlsCurrentTime = textColor;
				const controlsDuration = Player.settings.mixColors(controlsPanel, textColor, 0.6);

				let textPlaying;
				switch (selectedTheme) {
					case "evita":
						textPlaying = textColor;
						break;
					case "vivian":
						textPlaying = 'rgba(208,208,208,1.0)';
						break;
					case "warosu":
						textPlaying = 'rgba(245,245,245,1.0)';
						break;
					default:
						textPlaying = Player.settings.isLightColor(backgroundColor)
						? (Player.settings.isLightColor(textColor) ? 'rgba(22,22,22,1.0)' : backgroundColor)
						: (Player.settings.isLightColor(textColor) ? 'rgba(218,218,218,1.0)' : backgroundColor);
				}



				const playing = Player.settings.isLightColor(backgroundColor)
									? Player.settings.adjustColor(buttonsColor, { h: 0, s: 0, v: 0, a:0.62 })
									: Player.settings.adjustColor(buttonsColor, { h: 0, s: 0, v: 0, a:0.42 });
				let dragging = Player.settings.mixColors(backgroundColor, buttonsColor, 0.8);
					dragging = Player.settings.adjustColor(dragging, { h: 0, s: 0, v: 0, a:0.7 });

				let progressBarLoaded = Player.settings.mixColors(backgroundColor, buttonsColor, 0.35);
					progressBarLoaded = Player.settings.mixColors(progressBarLoaded, linkColor, 0.05);;
				const progressBar = Player.settings.isLightColor(controlsPanel) ? Player.settings.adjustColor(progressBarLoaded, { h: 0, s: -10, v: -5, a:0.7 }) : Player.settings.adjustColor(progressBarLoaded, { h: 0, s: -10, v: 5, a:0.7 });

				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,
					'colors.text_playing': textPlaying,

					'colors.controls_panel': controlsPanel,
					'colors.buttons_color': buttonsColor,
					'colors.hover_color': hoverColor,
					'colors.controls_current_time': controlsCurrentTime,
					'colors.controls_duration': controlsDuration,
					'colors.progress_bar': progressBar,
					'colors.progress_bar_loaded': progressBarLoaded,
				};

				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();

				Player.settings.applyBorderWidth();
			},

			parseColor: function(color) {
				let result;

				// Named HTML colors to hex mapping
				const htmlColors = {"aliceblue":"#f0f8ff","antiquewhite":"#faebd7","aqua":"#00ffff","aquamarine":"#7fffd4","azure":"#f0ffff","beige":"#f5f5dc","bisque":"#ffe4c4","black":"#000000","blanchedalmond":"#ffebcd","blue":"#0000ff","blueviolet":"#8a2be2","brown":"#a52a2a","burlywood":"#deb887","cadetblue":"#5f9ea0","chartreuse":"#7fff00","chocolate":"#d2691e","coral":"#ff7f50","cornflowerblue":"#6495ed","cornsilk":"#fff8dc","crimson":"#dc143c","cyan":"#00ffff","darkblue":"#00008b","darkcyan":"#008b8b","darkgoldenrod":"#b8860b","darkgray":"#a9a9a9","darkgreen":"#006400","darkkhaki":"#bdb76b","darkmagenta":"#8b008b","darkolivegreen":"#556b2f","darkorange":"#ff8c00","darkorchid":"#9932cc","darkred":"#8b0000","darksalmon":"#e9967a","darkseagreen":"#8fbc8f","darkslateblue":"#483d8b","darkslategray":"#2f4f4f","darkturquoise":"#00ced1","darkviolet":"#9400d3","deeppink":"#ff1493","deepskyblue":"#00bfff","dimgray":"#696969","dodgerblue":"#1e90ff","firebrick":"#b22222","floralwhite":"#fffaf0","forestgreen":"#228b22","fuchsia":"#ff00ff","gainsboro":"#dcdcdc","ghostwhite":"#f8f8ff","gold":"#ffd700","goldenrod":"#daa520","gray":"#808080","green":"#008000","greenyellow":"#adff2f","honeydew":"#f0fff0","hotpink":"#ff69b4","indianred ":"#cd5c5c","indigo":"#4b0082","ivory":"#fffff0","khaki":"#f0e68c","lavender":"#e6e6fa","lavenderblush":"#fff0f5","lawngreen":"#7cfc00","lemonchiffon":"#fffacd","lightblue":"#add8e6","lightcoral":"#f08080","lightcyan":"#e0ffff","lightgoldenrodyellow":"#fafad2","lightgrey":"#d3d3d3","lightgreen":"#90ee90","lightpink":"#ffb6c1","lightsalmon":"#ffa07a","lightseagreen":"#20b2aa","lightskyblue":"#87cefa","lightslategray":"#778899","lightsteelblue":"#b0c4de","lightyellow":"#ffffe0","lime":"#00ff00","limegreen":"#32cd32","linen":"#faf0e6","magenta":"#ff00ff","maroon":"#800000","mediumaquamarine":"#66cdaa","mediumblue":"#0000cd","mediumorchid":"#ba55d3","mediumpurple":"#9370d8","mediumseagreen":"#3cb371","mediumslateblue":"#7b68ee","mediumspringgreen":"#00fa9a","mediumturquoise":"#48d1cc","mediumvioletred":"#c71585","midnightblue":"#191970","mintcream":"#f5fffa","mistyrose":"#ffe4e1","moccasin":"#ffe4b5","navajowhite":"#ffdead","navy":"#000080","oldlace":"#fdf5e6","olive":"#808000","olivedrab":"#6b8e23","orange":"#ffa500","orangered":"#ff4500","orchid":"#da70d6","palegoldenrod":"#eee8aa","palegreen":"#98fb98","paleturquoise":"#afeeee","palevioletred":"#d87093","papayawhip":"#ffefd5","peachpuff":"#ffdab9","peru":"#cd853f","pink":"#ffc0cb","plum":"#dda0dd","powderblue":"#b0e0e6","purple":"#800080","rebeccapurple":"#663399","red":"#ff0000","rosybrown":"#bc8f8f","royalblue":"#4169e1","saddlebrown":"#8b4513","salmon":"#fa8072","sandybrown":"#f4a460","seagreen":"#2e8b57","seashell":"#fff5ee","sienna":"#a0522d","silver":"#c0c0c0","skyblue":"#87ceeb","slateblue":"#6a5acd","slategray":"#708090","snow":"#fffafa","springgreen":"#00ff7f","steelblue":"#4682b4","tan":"#d2b48c","teal":"#008080","thistle":"#d8bfd8","tomato":"#ff6347","turquoise":"#40e0d0","violet":"#ee82ee","wheat":"#f5deb3","white":"#ffffff","whitesmoke":"#f5f5f5","yellow":"#ffff00","yellowgreen":"#9acd32"};

				// Convert named color to hex first if it exists
				if (htmlColors[color.toLowerCase()]) {
					color = htmlColors[color.toLowerCase()];
				}

				// Helper function to validate and clamp RGB values
				const clampRGB = (value) => Math.min(255, Math.max(0, parseInt(value, 10)));

				// Helper function to validate and clamp alpha values
				const clampAlpha = (value) => {
					const num = parseFloat(value);
					return Math.min(1, Math.max(0, isNaN(num) ? 1 : num));
				};

				// Hex formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
				if (/^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(color)) {
					let hex = color.slice(1);
					// Expand shorthand (e.g., #RGBA → #RRGGBBAA)
					if (hex.length === 3 || hex.length === 4) {
						hex = hex.split('').map(x => x + x).join('');
					}
					// Parse to [r, g, b, a] (alpha defaults to 1 if missing)
					const r = clampRGB(parseInt(hex.slice(0, 2), 16));
					const g = clampRGB(parseInt(hex.slice(2, 4), 16));
					const b = clampRGB(parseInt(hex.slice(4, 6), 16));
					const a = hex.length === 8 ? clampAlpha(parseInt(hex.slice(6, 8), 16) / 255) : 1;
					return [r, g, b, a];
				}
				// RGB: rgb(r, g, b) → [r, g, b, 1]
				else if (/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.test(color)) {
					const matches = color.match(/rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)/i);
					const r = clampRGB(matches[1]);
					const g = clampRGB(matches[2]);
					const b = clampRGB(matches[3]);
					return [r, g, b, 1];
				}
				// RGBA: rgba(r, g, b, a) → [r, g, b, a]
				else if (/^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([01]?\.\d+|0|1)\s*\)$/i.test(color)) {
					const matches = color.match(/rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([01]?\.\d+|0|1)\s*\)/i);
					const r = clampRGB(matches[1]);
					const g = clampRGB(matches[2]);
					const b = clampRGB(matches[3]);
					const a = clampAlpha(matches[4]);
					return [r, g, b, a];
				}
				// Return null if format is invalid
				return null;
			},

			isLightColor: function(color) {
				const rgba = Player.settings.parseColor(color);
				if (!rgba) return false;

				// Extract RGB components (ignore alpha for luminance calculation)
				const [r, g, b] = rgba;

				// Calculate luminance
				const luminance = 0.299 * r + 0.587 * g + 0.114 * b;

				// Return true if luminance exceeds threshold (102)
				return luminance > 102;
			},

			/**
			 * Checks if a color's hue is above (greater than) yellow (60°).
			 * @param {string} color - Input color (hex, rgb, rgba, or named color)
			 * @returns {boolean|null} - Returns:
			 *	- `true` if hue > 60° (e.g., greens, blues, purples)
			 *	- `false` if hue ≤ 60° (e.g., reds, oranges, yellows)
			 *	- `null` if color is invalid or grayscale (no hue)
			 */
			isHueAboveYellow: function(color) {
				const rgba = Player.settings.parseColor(color);
				if (!rgba) return null;

				// Convert RGB to HSV to extract hue
				const [r, g, b] = rgba.map(c => c / 255);
				const [hue] = Player.settings.rgbToHsv(r, g, b);

				// Grayscale check (saturation ≈ 0)
				const saturation = Player.settings.rgbToHsv(r, g, b)[1];
				if (saturation < 0.05) return null;

				// Compare hue to yellow (60° in HSV/HSL)
				return (hue * 360) > 60;
			},

			/*
			 * color:				rgba(255, 255, 255, 1)
			 * h: hue,				range (-100 — 100)
			 * s: saturation,		range (-100 — 100)
			 * v: value/brightness,	range (-100 — 100)
			 * a: alpha,	decimal range (  0  —  1 ) and -1 = keep original alpha
			 */
			adjustColor: function(color, { h = 0, s = 0, v = 0, a = -1 } = {}) {
				const rgba = Player.settings.parseColor(color);
				if (!rgba) return color;

				// Normalize RGB to [0, 1] and extract alpha (default: 1)
				let [r, g, b, originalA = 1] = rgba;
				r /= 255; g /= 255; b /= 255;

				// Convert to HSV
				const [hue, sat, val] = Player.settings.rgbToHsv(r, g, b);

				// Adjust Hue (handle negative values by looping)
				let newHue = (hue * 360 + h) % 360; // Apply hue shift
				newHue = newHue < 0 ? newHue + 360 : newHue; // Ensure 0-360 range

				// Adjust Saturation & Value (clamped to 0-1)
				const newSat = Math.min(1, Math.max(0, sat + s / 100));
				const newVal = Math.min(1, Math.max(0, val + v / 100));

				// Handle Alpha (if a=-1, keep original; else clamp to [0, 1])
				const newAlpha = a === -1 ? originalA : Math.min(1, Math.max(0, a));

				// Convert back to RGB
				const [newR, newG, newB] = Player.settings.hsvToRgb(newHue, newSat, newVal);

				// Helper function to validate and clamp alpha values
				const clampAlpha = (value) => {
					const num = parseFloat(value);
					return Math.min(1, Math.max(0, isNaN(num) ? 1 : num));
				};

				// Return as RGBA string
				return `rgba(${Math.round(newR * 255)},${Math.round(newG * 255)},${Math.round(newB * 255)},${clampAlpha(newAlpha.toFixed(2))})`;
			},

			/**
			 * Mixes two rgba colors with optional weighting and blending mode
			 * @param {string} color1 - First color (rgba)
			 * @param {string} color2 - Second color (rgba)
			 * @param {object} options - Mixing options:
						 *   - weight: 0-1 (default 0.5, equal blend)
			 * @returns {string} Mixed color in rgba() format
			 */
			mixColors: function(color1, color2, weight = 0.5) {
				// Parse the input RGBA strings
				const parseRgba = (rgba) => {
					const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),?\s*([0-9.]+)?\)/);
					if (!match) throw new Error("Invalid RGBA format");
					return {
						r: parseInt(match[1]),
						g: parseInt(match[2]),
						b: parseInt(match[3]),
						a: match[4] !== undefined ? parseFloat(match[4]) : 1,
					};
				};

				const c1 = parseRgba(color1);
				const c2 = parseRgba(color2);

				// Linear interpolation function
				const lerp = (a, b, t) => a + (b - a) * t;

				// Mix the colors
				const a = lerp(c1.a, c2.a, weight);
				const r = Math.round(lerp(c1.r * c1.a, c2.r * c2.a, weight) / a);
				const g = Math.round(lerp(c1.g * c1.a, c2.g * c2.a, weight) / a);
				const b = Math.round(lerp(c1.b * c1.a, c2.b * c2.a, weight) / a);

				// Helper function to validate and clamp alpha values
				const clampAlpha = (value) => {
					const num = parseFloat(value);
					return Math.min(1, Math.max(0, isNaN(num) ? 1 : num));
				};

				return `rgba(${r},${g},${b},${clampAlpha(a.toFixed(2))})`;
			},

			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];
			},

			/**
			 * 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);
				Player.display.updateStylesheet();
				//Player.settings.render();
			},

			/**
			 * 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('[8chan 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('[8chan 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('[8chan 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 videoFileExtRE = /\.(webm|mp4|m4v|ogv|avi|mpeg|mpg|mpe|m1v|m2v|mov|wmv)$/i;
		const audioFileExtRE = /\.(mp3|m4a|m4b|flac|ogg|oga|opus|mp2|mpega|wav|aac)$/i;
		const videoMimeRE = /^video\/.+$/;
		const audioMimeRE = /^audio\/.+$/;
		const progressBarStyleSheets = {};
		let syncInterval;
		let responseStatus = undefined;

		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',
					[`.${ns}-media:not(.${ns}-pip) .${ns}-image-link`]: 'togglePlay',
				},
				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',
				playing: 'controls.handlePlaybackState',
				waiting: 'controls.handlePlaybackState',
				timeupdate: 'controls.updateDuration',
				loadedmetadata: 'controls.updateDuration',
				durationchange: 'controls.updateDuration',
				volumechange: 'controls.updateVolume',
				loadstart: 'controls.pollForLoading',
				error: 'controls.handleSoundError',
			},

			audioEvents: {
				ended: 'controls.handleSoundEnded',
				pause: 'controls.handlePlaybackState',
				play: 'controls.handlePlaybackState',
				//seeked: 'controls.handlePlaybackState',
				//playing: 'controls.handlePlaybackState',
				//waiting: 'controls.handlePlaybackState',
				timeupdate: 'controls.updateDuration',
				//loadedmetadata: 'controls.updateDuration',
				durationchange: 'controls.updateDuration',
				//volumechange: 'controls.updateVolume',
				//loadstart: 'controls.pollForLoading',
			},

			initialize: function() {
				Player.on('show', () => Player._hiddenWhilePolling && Player.controls.pollForLoading());
				Player.on('hide', () => {
					Player._hiddenWhilePolling = !!Player._loadingPoll;
					Player.controls.stopPollingForLoading();
				});

				// Initialize loop state based on current repeat mode
				const updateLoop = () => {
					const video = document.querySelector(`.${ns}-video`);

					// if durations don't equal ±2 seconds difference.
					if (window.Slave !== undefined && (Math.abs(window.Master.duration - window.Slave.duration) > 2)) {
						video.loop = true;
						Player.audio.loop = Player.config.repeat === 'one';
						return;
					}

					video.loop = Player.config.repeat === 'one';
					Player.audio.loop = Player.config.repeat === 'one';
				};

				// Listen for repeat mode changes through Player events
				Player.on('config:repeat', updateLoop);

				document.addEventListener('visibilitychange', () => {
					// video starts to lag when window is in background, this should get it back to normal speed on tab in + should fix sync
					if (!document.hidden && Player.playing && window.Master !== undefined && !window.Master.paused) {
						const video = document.querySelector(`.${ns}-video`);

						if (isFinite(window.Master.duration) && window.Slave !== undefined && (Math.abs(window.Master.duration - video.duration) < 2) || isFinite(window.Master.duration) && window.Slave === undefined) {
							// Try to resume playback when tab becomes visible
							const currentTime = window.Master.currentTime;
							window.Master.currentTime = 0;
							video.currentTime = 0;
							window.Master.currentTime = currentTime;
							video.currentTime = currentTime;
						}

						window.Master.play().catch(() => {});
						video.play().catch(() => {});
						Player.controls.handlePlaybackState(); // Resync UI
					}
				});

				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`);

					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]) => {
							// 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]);
					});

					// Update repeat mode when player is rendered
					video.loop = Player.config.repeat === 'one';
					Player.audio.loop = Player.config.repeat === 'one';

					// Restore volume value from the previous session.
					Player.audio.volume = parseFloat(Player.config.volumeValue ) || '1';
					video.volume = parseFloat(Player.config.volumeValue) || '1';

					// Add stylesheets to adjust the progress indicator of the seekbar and volume bar.
					document.head.appendChild(progressBarStyleSheets[`.${ns}-seek-bar`] = document.createElement('style'));
					document.head.appendChild(progressBarStyleSheets[`.${ns}-volume-bar`] = document.createElement('style'));
					Player.controls.updateVolume();
				});
			},

			/**
			 * Switching being playing and paused.
			 */
			togglePlay: function() {
				// Return early if currently loading
				if (window.isLoading) return;

				if (!Player.playing) {
					if (Player.sounds.length) {
						return Player.play(Player.sounds[0]);
					}
					return;
				}

				const video = document.querySelector(`.${ns}-video`);

				if (window.Master !== undefined && window.Master.ended) {
					window.Master.currentTime = 0;
					video.currentTime = 0;
					window.Master.play();
					video.play().catch(() => {});
				} else if (window.Master !== undefined && window.Master.paused) {
					video.currentTime = window.Master.currentTime;
					window.Master.play();
					video.play().catch(() => {});
				} else {
					if (window.Master !== undefined) window.Master.pause();
					if (video) video.pause();
				}

				Player.controls.handlePlaybackState();
			},

			updatePlayButtonState: function() {
				const buttons = document.querySelectorAll(`.${ns}-play-button`);
				buttons.forEach(button => {
					button.disabled = window.isLoading;
					button.style.opacity = window.isLoading ? '0.5' : '1';
					button.style.cursor = window.isLoading ? 'not-allowed' : 'pointer';
				});
			},

			/**
			 * Update the sound name display
			 */
			updateHeaderText: function(status) {
				const soundNameContainers = document.querySelectorAll(`.${ns}-header.${ns}-row .${ns}-col.${ns}-truncate-text`);
				setTimeout(function(){
					soundNameContainers.forEach(container => {
						const span = container.querySelector('span');
						if (!span) return;

						switch (status) {
							case "Loading":
								if (span.innerHTML !== 'error') span.innerHTML = 'Loading...';
								break;
							case "Error":
								span.innerHTML = 'error';
								break;
							case "Reset":
								span.innerHTML = span.title; //Hard Reset
								break;
							default:
								if (span.innerHTML !== 'error') span.innerHTML = span.title; //Soft Reset
								break;
						}
					});
				}, 50);
			},

			// Function to safely get file extension (handles multiple dots in filename)
			getFileExtension: function(filename) {
				// Handle edge cases: no extension, hidden files, or filenames ending with dot
				if (!filename || filename.indexOf('.') === -1 || filename.endsWith('.')) {
					return '';
				}
				return filename.slice(((filename.lastIndexOf('.') - 1) >>> 0) + 2).toLowerCase();
			},

			detectMimeType: function(url, arrayBuffer, responseType) {
				if(audioMimeRE.test(responseType)) return responseType;
				if(videoMimeRE.test(responseType)) return responseType;

				const extension = Player.controls.getFileExtension(url);
				const bytes = new Uint8Array(arrayBuffer);

				// Check by file signature (magic numbers)

				// MKV / WebM
				if (bytes.length >= 4 &&
					bytes[0] === 0x1A && bytes[1] === 0x45 && bytes[2] === 0xDF && bytes[3] === 0xA3) {
					// Ideally parse to find DocType (e.g., webm or matroska)
					return /*extension === 'webm' ? */'video/webm'/* : 'video/x-matroska'*/;
				}

				// MP4/M4A/M4V/M4B (MPEG-4 containers)
				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 for specific MP4 subtypes
					if (bytes.length >= 12) {
						if (bytes[8] === 0x4D && bytes[9] === 0x34 && bytes[10] === 0x41 && bytes[11] === 0x20) {
							return 'audio/mp4'; // M4A
						}
						if (bytes[8] === 0x4D && bytes[9] === 0x34 && bytes[10] === 0x56 && bytes[11] === 0x20) {
							return 'video/mp4'; // M4V
						}
						if (bytes[8] === 0x4D && bytes[9] === 0x34 && bytes[10] === 0x42 && bytes[11] === 0x20) {
							return 'audio/mp4'; // M4B (audiobook format, same as M4A)
						}
						if (bytes[8] === 0x71 && bytes[9] === 0x74 && bytes[10] === 0x20 && bytes[11] === 0x20) {
							return 'video/quicktime'; // MOV (QuickTime)
						}
					}
					return 'video/mp4'; // default MP4
				}

				// FLAC
				if (bytes.length >= 4 &&
					bytes[0] === 0x66 &&
					bytes[1] === 0x4C &&
					bytes[2] === 0x61 &&
					bytes[3] === 0x43) {
					return 'audio/flac';
				}

				// OGG (including OGV, OGA, OPUS)
				if (bytes.length >= 4 &&
					bytes[0] === 0x4F &&
					bytes[1] === 0x67 &&
					bytes[2] === 0x67 &&
					bytes[3] === 0x53) {
					// Could be audio or video OGG
					return extension === 'ogv' ? 'video/ogg' : 'audio/ogg';
				}

				// AVI
				if (bytes.length >= 12 &&
					bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && // RIFF
					bytes[8] === 0x41 && bytes[9] === 0x56 && bytes[10] === 0x49 && bytes[11] === 0x20) { // AVI
					return 'video/x-msvideo';
				}

				// WAV
				if (bytes.length >= 12 &&
					bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && // RIFF
					bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) { // WAVE
					return 'audio/wav';
				}

				// MOV (QuickTime)
				if (bytes.length >= 8 &&
					((bytes[4] === 0x6D && bytes[5] === 0x6F && bytes[6] === 0x6F && bytes[7] === 0x76) || // moov
					 (bytes[4] === 0x66 && bytes[5] === 0x72 && bytes[6] === 0x65 && bytes[7] === 0x65))) { // free
					return 'video/quicktime';
				}

				// WMV/ASF
				if (bytes.length >= 16 &&
					bytes[0] === 0x30 && bytes[1] === 0x26 && bytes[2] === 0xB2 && bytes[3] === 0x75 &&
					bytes[4] === 0x8E && bytes[5] === 0x66 && bytes[6] === 0xCF && bytes[7] === 0x11 &&
					bytes[8] === 0xA6 && bytes[9] === 0xD9 && bytes[10] === 0x00 && bytes[11] === 0xAA &&
					bytes[12] === 0x00 && bytes[13] === 0x62 && bytes[14] === 0xCE && bytes[15] === 0x6C) {
					return extension === 'wmv' ? 'video/x-ms-wmv' : 'video/x-ms-asf';
				}

				// MKV (Matroska)
				if (bytes.length >= 4 &&
					bytes[0] === 0x1A && bytes[1] === 0x45 && bytes[2] === 0xDF && bytes[3] === 0xA3) {
					return 'video/x-matroska';
				}

				// MPEG (MP3, MP2, MPEG video)
				if (bytes.length >= 3) {
					// MP3 with ID3 tag
					if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
						return 'audio/mpeg';
					}

					// MPEG audio (MP3, MP2) - frame sync
					if ((bytes[0] === 0xFF) && ((bytes[1] & 0xE0) === 0xE0)) {
						// Check layer bits (bits 1-2 of byte 1)
						const layer = (bytes[1] & 0x06) >> 1;
						// Layer 3 (MP3) or Layer 2 (MP2)
						return layer === 3 ? 'audio/mpeg' : 'audio/mpeg'; // MP2 also uses audio/mpeg
					}

					// MPEG video
					if (bytes.length >= 4 &&
						bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 &&
						(bytes[3] >= 0xB0 && bytes[3] <= 0xBF)) {
						return 'video/mpeg';
					}
				}

				// 3GP/3G2 (mobile video formats)
				if (bytes.length >= 12 &&
					bytes[4] === 0x66 && bytes[5] === 0x74 &&
					bytes[6] === 0x79 && bytes[7] === 0x70) { // 'ftyp'

					const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);

					// Known 3GP/3G2 brands
					const known3GPBrands = ['3gp4', '3gp5', '3g2a', '3g2b', '3gr6', '3gs7', '3ge6', '3gg6'];

					if (known3GPBrands.includes(brand)) {
						return 'video/3gpp';
					}
				}

				// AAC (Advanced Audio Coding)
				if (bytes.length >= 2 &&
					(bytes[0] === 0xFF && (bytes[1] & 0xF6) === 0xF0)) {
					return 'audio/aac';
				}

				// Fallback to extension-based detection
				switch(extension) {
					case 'webm': return 'video/webm';
					case 'mp4': return 'video/mp4';
					case 'm4a': case 'm4b': return 'audio/mp4';
					case 'm4v': return 'video/mp4';
					case 'flac': return 'audio/flac';
					case 'ogg': case 'oga': return 'audio/ogg';
					case 'ogv': return 'video/ogg';
					case 'opus': return 'audio/ogg';
					case 'avi': return 'video/x-msvideo';
					case 'asx': return 'video/x-ms-asf'; // Advanced Stream Redirector
					case 'mpeg': case 'mpg': case 'mpe': case 'm1v': case 'm2v': return 'video/mpeg';
					case 'mp3': case 'mpega': case 'mp2': return 'audio/mpeg';
					case 'm3u': return 'application/x-mpegurl'; // Playlist file
					default: return 'audio/mpeg'; // default fallback
				}
			},

			BlobXmlHttpRequest: function (src) {
				return new Promise((resolve, reject) => {
					GM.xmlHttpRequest({
						method: 'GET',
						url: src,
						responseType: 'blob',
						onload: function(response) {
							if (response.status >= 400) {
								console.log(new Error(`Failed to fetch media; response.status: ${response.status}, response.responseText: ${response.responseText}`));
							}
							responseStatus = response.status;
							resolve(response.response);
						},
						onerror: reject,
						ontimeout: reject,
						timeout: 60000
					});
				});
			},

			BlobReader: function(blob) {
				return new Promise((resolve, reject) => {
					const reader = new FileReader();
					reader.onload = () => {
						// Extract only the base64 data after the comma
						const dataUrl = reader.result;
						const base64Data = dataUrl.split(',')[1]; // Split at comma and take the second part
						resolve(base64Data);
					};
					reader.onerror = reject;
					reader.readAsDataURL(blob);
				});
			},

			/**
			 * Wait for audio to be ready to play
			 */
			waitForAudioReady: function() {
				return new Promise((resolve, reject) => {
					if (!Player.audio) {
						return reject(new Error('Player.audio element not found'));
					}

					// Check if already ready
					if (Player.audio.readyState >= 3) {
						return resolve();
					}

					const onReady = () => {
						cleanup();
						resolve();
					};

					const onError = (err) => {
						cleanup();
						reject(err);
					};

					const cleanup = () => {
						Player.audio.removeEventListener('loadeddata', onReady);
						Player.audio.removeEventListener('error', onError);
					};

					Player.audio.addEventListener('loadeddata', onReady);
					Player.audio.addEventListener('error', onError);
				});
			},

			/**
			 * Wait for video to be ready to play
			 */
			waitForVideoReady: function() {
				return new Promise((resolve, reject) => {
					const video = document.querySelector(`.${ns}-video`);
					if (!video) {
						return reject(new Error('Video element not found'));
					}

					// Check if already ready
					if (video.readyState >= 4) {
						return resolve();
					}

					const onReady = () => {
						cleanup();
						resolve();
					};

					const onError = (err) => {
						cleanup();
						reject(err);
					};

					const cleanup = () => {
						video.removeEventListener('loadeddata', onReady);
						video.removeEventListener('error', onError);
					};

					video.addEventListener('loadeddata', onReady);
					video.addEventListener('error', onError);
				});
			},


			/**
			 * Start playback.
			 */
			play: async function(sound) {
				const video = document.querySelector(`.${ns}-video`);
				const image = document.querySelector(`.${ns}-image`);

				// if play(sound) and previous play(sound) equal just reset currentTime
				if (Player.playing !== undefined && window.Master !== undefined && sound.id === Player.playing.id) {
					window.Master.currentTime = 0;
					video.currentTime = 0;
					window.Master.play().catch(() => {});
					video.play().catch(() => {});
					Player.controls.handlePlaybackState(); // Resync UI
					return;
				}

				Player.controls.updateHeaderText("Reset");
				window.isLoading = true;

				if (!sound && !Player.playing && Player.sounds.length) {
					sound = Player.sounds[0];
				}
				if (!sound) {
					window.isLoading = false;
					return;
				}

				//console.log(sound);

				window.Master = undefined;
				window.Slave = undefined;
				responseStatus = undefined;

				// Clear previous playback
				if (Player.playing) Player.playing.playing = false;

				// Reset media elements completely
				video.pause();
				video.removeAttribute('src');
				video.load();
				video.currentTime = 0;
				Player.audio.pause();
				Player.audio.removeAttribute('src');
				Player.audio.load();
				Player.audio.currentTime = 0;

				Player.controls.updatePlayButtonState();

				try {
					sound.playing = true;
					Player.playing = sound;
					await Player.trigger('playsound', sound);

					// Case 1: hasSoundTag and the sound tag is audio (.mp3, .ogg, .m4a, ...)
					if (sound.hasSoundTag && !sound.isVideo) {
						await Player.controls.updateHeaderText("Loading");
						// First try with GM.xmlHttpRequest
						const response = await Player.controls.BlobXmlHttpRequest(sound.src);
						//console.log(response); console.log('response.type '+response.type); console.log('responseStatus '+responseStatus);

						if (responseStatus !== undefined && responseStatus < 400) {
							const rawBase64 = await Player.controls.BlobReader(response);
							const mimeType = await Player.controls.detectMimeType(sound.src, rawBase64, response.type);

							Player.audio.src = `data:${mimeType};base64,${rawBase64}`;
							window.Master = Player.audio;
							window.Slave = video;
							video.muted = true;

							// Wait for Player.audio to be ready
							await Player.controls.waitForAudioReady();

							if (!isFinite(window.Master.duration)) {
								// Try to estimate from buffered data
								if (window.Master.buffered && window.Master.buffered.length > 0) {
									window.Master.duration = window.Master.buffered.end(window.Master.buffered.length - 1);
								}
							}

						} else {
							console.log(new Error('Failed to fetch via GM_xmlhttpRequest, trying fallback:'));
							Player.controls.updateHeaderText("Error");
							Player.audio.pause();
							Player.audio.removeAttribute('src');
							Player.audio.load();
							window.Master = video;
							video.muted = false;
							window.Slave = undefined;
						}

						// Handle video/image element carefully for Case 1
						const imageIsVideo = videoFileExtRE.test(sound.filename); // Check if sound.image is actually a supported video format
						if (imageIsVideo) {
							video.src = sound.image; // Use .image for video if it's a supported format

							// Wait for video to be ready
							await Player.controls.waitForVideoReady();

							await video.play().catch(e => {
								console.log('Video playback failed, falling back to empty source:', e);
								video.pause();
								video.removeAttribute('src');
								video.load();
								window.Slave = undefined;
							});
						} else {
							video.pause();
							video.removeAttribute('src');
							video.load();
							window.Slave = undefined;
						}

						// Start playback and Initial sync
						if (responseStatus !== undefined && responseStatus < 400) {
							await Player.audio.play();
							Player.controls.syncPlayback();
							// Start sync interval
							if (syncInterval) clearInterval(syncInterval);
							syncInterval = setInterval(() => Player.controls.syncPlayback(), 1000);
						}
					}

					// Case 2: hasSoundTag and the sound tag is video (.webm, .mp4)
					else if (sound.hasSoundTag && sound.isVideo) {
						await Player.controls.updateHeaderText("Loading");
						// First try with GM.xmlHttpRequest
						const response = await Player.controls.BlobXmlHttpRequest(sound.src);
						//console.log(response); console.log('response.type '+response.type); console.log('responseStatus '+responseStatus);

						if (responseStatus !== undefined && responseStatus < 400) {
							const rawBase64 = await Player.controls.BlobReader(response);
							const mimeType = await Player.controls.detectMimeType(sound.src, rawBase64, response.type);

							video.src = `data:${mimeType};base64,${rawBase64}`;
							video.muted = false;
							window.Master = video;

							// Wait for video to be ready
							await Player.controls.waitForVideoReady();

							if (!isFinite(window.Master.duration)) {
								// Try to estimate from buffered data
								if (window.Master.buffered && window.Master.buffered.length > 0) {
									window.Master.duration = window.Master.buffered.end(window.Master.buffered.length - 1);
								}
							}

							// Start playback
							await video.play();

						} else {
							console.log(new Error('Failed to fetch via GM_xmlhttpRequest, trying fallback:'));
							Player.controls.updateHeaderText("Error");
							// Fallback to direct video playback
							video.src = sound.src;
							video.muted = false;
							window.Master = video;

							// Wait for video to be ready
							await Player.controls.waitForVideoReady();

							if (!isFinite(window.Master.duration)) {
								// Try to estimate from buffered data
								if (window.Master.buffered && window.Master.buffered.length > 0) {
									window.Master.duration = window.Master.buffered.end(window.Master.buffered.length - 1);
								}
							}

							// Start playback
							await video.play();
						}
					}

					// Case 3: doesn't have hasSoundTag and is video
					else if (!sound.hasSoundTag && sound.isVideo) {
						await Player.controls.updateHeaderText("Loading");
						video.src = sound.src;
						video.muted = false;
						window.Master = video;

						// Wait for video to be ready
						await Player.controls.waitForVideoReady();

						if (!isFinite(window.Master.duration)) {
							// Try to estimate from buffered data
							if (window.Master.buffered && window.Master.buffered.length > 0) {
								window.Master.duration = window.Master.buffered.end(window.Master.buffered.length - 1);
							}
						}

						// Start playback
						await video.play();
					}

					// Case 4: just audio
					else if (!sound.hasSoundTag && !sound.isVideo) {
						await Player.controls.updateHeaderText("Loading");

						Player.audio.src = sound.src;
						image.src = sound.thumb;
						window.Master = Player.audio;

						// Wait for Player.audio to be ready
						await Player.controls.waitForAudioReady();

						if (!isFinite(window.Master.duration)) {
							// Try to estimate from buffered data
							if (window.Master.buffered && window.Master.buffered.length > 0) {
								window.Master.duration = window.Master.buffered.end(window.Master.buffered.length - 1);
							}
						}

						// Start playback
						await Player.audio.play();
					}

				  //console.log('Master: '+window.Master);
				  //console.log('Slave: '+window.Slave);

				  // handlePlaybackState
				  Player.controls.handlePlaybackState();

				} catch (err) {
					console.error('Playback error:', err);
					Player.logError('Could not play sound');
					Player.controls.updateHeaderText("Error");

					// Full cleanup
					Player.audio.pause();
					Player.audio.removeAttribute('src');
					Player.audio.load();
					const video = document.querySelector(`.${ns}-video`);
					if (video) {
						video.pause();
						video.removeAttribute('src');
						video.load();
					}
					window.Master = undefined;
					window.Slave = undefined;

					if (syncInterval) clearInterval(syncInterval);

					// handlePlaybackState
					Player.controls.handlePlaybackState();

					return Player.next(); // Skip to next track on error

				} finally {
					window.isLoading = false;
					Player.controls.updateHeaderText();
					Player.controls.updatePlayButtonState();
					Player.minimised.updatePipSize();
				}
			},

			/**
			 * Pause playback.
			 */
			pause: function() {
				const video = document.querySelector(`.${ns}-video`);
				if (window.Master !== undefined) window.Master.pause();
				if (video) video.pause();
				Player.controls.handlePlaybackState();
			},
			/**
			 * Play the next sound.
			 */
			next: function(force = true) {
				Player.controls._movePlaying(1, force);
			},

			/**
			 * Play the previous sound.
			 */
			previous: function(force = true) {
				Player.controls._movePlaying(-1, force);
			},

			_movePlaying: function(direction, force) {
				if (!Player.audio) return;
				if (window.Master === undefined) return;
				if (!window.Master.ended && !force) 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]);

					// Calculate next index based on repeat mode
					let nextIndex;
					if (!force && Player.config.repeat === 'one') return; //let loop handle it
					if (!force && Player.config.repeat === 'none') {
						const video = document.querySelector(`.${ns}-video`);
						Player.pause();
						if (video) video.pause();
						return;
					}
					nextIndex = currentIndex + direction;
					// Handle if (Player.config.repeat === 'all') / Wrap around for 'all' mode
					if (nextIndex >= Player.sounds.length) nextIndex = 0;
					if (nextIndex < 0) nextIndex = Player.sounds.length - 1;

					const nextSound = Player.sounds[nextIndex];
					nextSound && Player.play(nextSound);

					Player.set('showSoundTagOnly', false);
					Player.playlist.applySoundTagFilter();
				} 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 (window.Master === undefined) return;

				const video = document.querySelector(`.${ns}-video`);
				return window.Master ? window.Master.currentTime : 0;
			},

			syncPlayback: async function() {
				if (!Player.playing) return;
				if (window.Master === undefined || window.Slave === undefined) return;
				const video = document.querySelector(`.${ns}-video`);
				if (!isFinite(window.Master.duration)) {
					if (syncInterval) clearInterval(syncInterval);
					return;
				}

				// If nothing is playing or Master isn't available, bail out
				if (!window.Master || window.Master.paused) return;

				// if durations don't equal ±2 seconds difference.
				if (window.Slave && (Math.abs(window.Master.duration - window.Slave.duration) > 2)) {
					if (syncInterval) clearInterval(syncInterval);
					video.loop = true;
					Player.audio.loop = Player.config.repeat === 'one';
					return;
				}

				// Sync Slave to Master if it exists and isn't already in sync
				if (window.Slave && (Math.abs(window.Slave.currentTime - window.Master.currentTime) > 0.8)) {
					window.Slave.currentTime = window.Master.currentTime;
				}
			},

			handlePlaybackState: function() {
				const video = document.querySelector(`.${ns}-video`);
				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();
			},

			handleSoundEnded: function() {
				Player.next(false);
			},
			/**
			 * 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.pause();
					Player.audio.removeAttribute('src');
					Player.audio.load();
				}

				if ((window.Master === video) && video?.error) {
					console.error('Video error:', video.error);
					Player.logError('Video playback error.');
					Player.controls.updateHeaderText("Error");
				} else if (Player.audio?.error) {
					console.error('Audio error:', Player.audio.error);
					Player.logError('Audio playback error.');
					Player.controls.updateHeaderText("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() {
				if (window.Master === undefined) return;

				const video = document.querySelector(`.${ns}-video`);

				let duration = window.Master.duration;
				if (!isFinite(duration)) {
					// Try to estimate from buffered data
					if (window.Master.buffered && window.Master.buffered.length > 0) {
						duration = window.Master.buffered.end(window.Master.buffered.length - 1);
					}
				}

				if (!window.Master || !window.Master.buffered || window.window.Master.buffered.length === 0) return;

				const length = window.Master.buffered.length;
				const size = (window.Master.buffered.end(length - 1) / 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;
				if (window.Master === undefined) return;

				const video = document.querySelector(`.${ns}-video`);

				let duration = window.Master.duration;
				if (!isFinite(duration)) {
					// Try to estimate from buffered data
					if (window.Master.buffered && window.Master.buffered.length > 0) {
						duration = window.Master.buffered.end(window.Master.buffered.length - 1);
					}
				}

				const currentTime = Player.controls.getCurrentPlaybackPosition();

				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();
				if (!Player.playing) return;
				if (Player.playing.playing == false) return;
				if (window.Master === undefined) return;

				const video = document.querySelector(`.${ns}-video`);

				let duration = window.Master.duration;
				if (!isFinite(duration)) {
					// Try to estimate from buffered data
					if (window.Master.buffered && window.Master.buffered.length > 0) {
						duration = window.Master.buffered.end(window.Master.buffered.length - 1);
					}
				}

				if (!window.Master || !isFinite(duration)) return;

				const ratio = e.offsetX / parseInt(document.defaultView.getComputedStyle(e.eventTarget || e.target).width, 10);
				const seekTime = duration * ratio;

				// Update media elements
				window.Master.currentTime = seekTime;
				if (Player.playing?.hasSoundTag) {
					if (video) video.currentTime = seekTime;
				}

				if (!window.Master.paused) {
					window.Master.play();
					video.play().catch(() => {});
				}
				Player.controls.handlePlaybackState(); // Resync UI
			},


			/**
			 * 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;
				}

				// Set the volume value so it can be used for the next session and restore the volume value during the initialization.
				Player.set('volumeValue', Player.audio.volume.toString());

				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-and-controls`]: '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="8chan Sounds Player" class="coloredIcon" ">
							[♫]
						</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">
								♫ Sounds 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="8chan Sounds Player">Sounds 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('[8chan 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();

				const containerStyle = window.getComputedStyle(document.querySelector(`#${ns}-container`));
				const containerWidth = parseFloat(containerStyle.getPropertyValue('width')) || width;

				// 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(containerWidth, 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('[8chan 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('[8chan 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-and-controls`).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;
						document.querySelector(`.${ns}-image-link`).removeAttribute('href');
					}
					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();
					Player.position.preventWrappingFooter();
				}
			}
		};


	}),
	/* 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}-media`);
				document.body.appendChild(image);
				image.classList.add(`${ns}-pip`);
				image.style.bottom = (Player.position.getHeaderOffset().bottom) + 'px';

				Player.minimised.updatePipSize();

				// 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}-media`);
				Player.$(`.${ns}-media-and-controls`).insertBefore(document.querySelector(`.${ns}-media`), Player.$(`.${ns}-controls`));
				image.classList.remove(`${ns}-pip`);
				image.style.bottom = null;
				image.removeEventListener('click', Player.show);
			},

			updatePipSize: function() {
				const mediaPIP = document.querySelector(`.${ns}-media.${ns}-pip`);

				if(!Player.isHidden || !Player.config.pip || !Player.playing || !mediaPIP || mediaPIP === null || mediaPIP === undefined) {
					return;
				}

				const video = document.querySelector(`.${ns}-video`);
				const image = document.querySelector(`.${ns}-image`);

				if(window.Master === video) {
					const compStyles = window.getComputedStyle(video);
					mediaPIP.style.width = compStyles.getPropertyValue("width");
					mediaPIP.style.height = compStyles.getPropertyValue("height");
				} else if(window.Master === Player.audio) {
					const compStyles = window.getComputedStyle(image);
					mediaPIP.style.width = compStyles.getPropertyValue("width");
					mediaPIP.style.height = compStyles.getPropertyValue("height");
				} else {
					mediaPIP.style.width = Player.config.maxPIPWidth;
					mediaPIP.style.height = Player.config.maxPIPHeight;
				}
			}
		};


	}),
	/* 13 - Playlist & Gallery Management
	    • Sound collection:
 	       o add()/remove()
 	       o Drag-and-drop reordering
 	       o Filtering
 	   • Features:
	        o Hover image previews
	        o Video detection
	        o Playlist navigation
	        o Gallery thumbnail view
	*/
	(function(module, exports, __webpack_require__) {
		const videoFileExtRE = /\.(webm|mp4|m4v|ogv|avi|mpeg|mpg|mpe|m1v|m2v|mov|wmv)$/i;
		const videoMimeRE = /^video\/.+$/;

		const {
			parseFiles,
			parseFileName
		} = __webpack_require__(0);

		module.exports = {
			atRoot: ['add', 'remove'],

			delegatedEvents: {
				click: {
					[`.${ns}-list-item`]: 'playlist.handleSelect',
					[`.${ns}-gallery-item`]: 'playlist.handleGallerySelect',
					[`.${ns}-sound-tag-toggle-button`]: 'playlist.toggleSoundTagPosts'
				},
				mousemove: {
					[`.${ns}-list-item`]: 'playlist.positionHoverImage',
					[`.${ns}-gallery-item`]: 'playlist.positionHoverImage'
				},
				/*dragstart: {
					[`.${ns}-list-item`]: 'playlist.handleDragStart',
					[`.${ns}-gallery-item`]: 'playlist.handleGalleryDragStart'
				},
				dragenter: {
					[`.${ns}-list-item`]: 'playlist.handleDragEnter',
					[`.${ns}-gallery-item`]: 'playlist.handleGalleryDragEnter'
				},
				dragend: {
					[`.${ns}-list-item`]: 'playlist.handleDragEnd',
					[`.${ns}-gallery-item`]: 'playlist.handleGalleryDragEnd'
				},
				dragover: {
					[`.${ns}-list-item`]: e => e.preventDefault(),
					[`.${ns}-gallery-item`]: e => e.preventDefault()
				},
				drop: {
					[`.${ns}-list-item`]: e => e.preventDefault(),
					[`.${ns}-gallery-item`]: e => e.preventDefault()
				}*/
			},

			undelegatedEvents: {
				mouseenter: {
					[`.${ns}-list-item`]: 'playlist.updateHoverImage',
					[`.${ns}-gallery-item`]: 'playlist.updateHoverImage'
				},
				mouseleave: {
					[`.${ns}-list-item`]: 'playlist.removeHoverImage',
					[`.${ns}-gallery-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 === 'gallery'
											? Player.config.viewStyle
											: 'playlist';

				Player.on('view', style => {
					// Focus the playing song when switching views
					if (style === 'playlist') {
						Player.playlist.scrollToPlayingPlaylist();
					} else if (style === 'gallery') {
						Player.playlist.scrollToPlayingGallery();
					}
					// Track state
					if (style === 'playlist' || style === 'image' || style === 'gallery') {
						Player.playlist._lastView = style;
					}
					Player.playlist.setHoverImageVisibility();
				});

				// 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.$all(`.${ns}-gallery-item.playing`).forEach(el => el.classList.remove('playing'));
					Player.$(`.${ns}-list-item[data-id="${Player.playing.id}"]`)?.classList.add('playing');
					Player.$(`.${ns}-gallery-item[data-id="${Player.playing.id}"]`)?.classList.add('playing');

					if (Player.config.viewStyle === 'playlist') {
						Player.playlist.scrollToPlayingPlaylist();
					} else if (Player.config.viewStyle === 'gallery') {
						Player.playlist.scrollToPlayingGallery();
					}
				});

				// Listen to anything that can affect the display
				Player.on('config:filters', Player.playlist.applyFilters);
				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);
			},

			/**
			* Render the playlist or gallery based on current view
			*/
			render: function() {
				if (!Player.container) {
					return;
				}

				const container = Player.$(`.${ns}-gallery-container`);
				container.innerHTML = this.buildGallery();

				const container2 = Player.$(`.${ns}-list-container`);
				container2.innerHTML = Player.templates.list();

				Player.events.addUndelegatedListeners(document.body, Player.playlist.undelegatedEvents);
				Player.playlist.hoverImage = Player.$(`.${ns}-hover-image`);
				Player.playlist.applySoundTagFilter();
			},

			/**
			* Build gallery view HTML
			*/
			buildGallery: function() {
				return Player.sounds.map(sound => `
                <div class="${ns}-gallery-item ${Player.playing && Player.playing.id === sound.id ? 'playing' : ''}"
                    data-id="${sound.id}"
                    draggable="false"
                    title="${sound.title2}">
                    <div class="${ns}-gallery-thumb-container">
                        <img class="${ns}-gallery-thumb" src="${sound.thumb}" loading="lazy">
                        <div class="${ns}-gallery-overlay-top" style="display: none">
                            <span class="${ns}-gallery-title">${sound.post} ▪ ${sound.fileSize}</span>
                        </div>
                        <div class="${ns}-gallery-overlay-bottom">
                            <span class="${ns}-gallery-title">${sound.post} ▪ ${sound.fileSize}</span>
                        </div>
                    </div>
                </div>
            `).join('');
			},

			/**
			* 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 && (videoFileExtRE.test(sound.image) || videoMimeRE.test(sound.type));
				try {
					const container = document.querySelector(`.${ns}-media`);
					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;
					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('[8chan 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' ? 'gallery' : Player.config.viewStyle === 'gallery' ? 'image' : 'playlist';
				let style = Player.config.viewStyle === 'playlist' ? 'gallery' : 'playlist';
				try {
					Player.display.setViewStyle(style);
					Player.set('viewStyle', style);
					Player.set('config:viewStyle', style);
					Player.settings.viewStyle = style;
					Player.container.setAttribute('data-view-style', style);
					Player.playlist.setHoverImageVisibility();
				} catch (err) {
					Player.logError('There was an error switching the view style. Please check the console for details.', 'warning');
					console.error('[8chan 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) {

							const galleryContainer = Player.$(`.${ns}-gallery-container`);
							if (galleryContainer) {
								galleryContainer.innerHTML = Player.playlist.buildGallery();
								Player.events.addUndelegatedListeners(document.body, Player.playlist.undelegatedEvents);
							}

							// 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);

						Player.playlist.applySoundTagFilter(); // filter new sounds
					}
				} catch (err) {
					Player.logError('There was an error adding to the sound player. Please check the console for details.');
					console.log('[8chan sounds player]', sound);
					console.error('[8chan sounds player]', err);
				}
			},

			/**
			* Add a new local sound from the users computer to the player.
			*/
			addFromFiles: async function(files) {
				for (const file of files) {
					// Skip non-media files
					if (!file.type.startsWith('image') && !file.type.startsWith('video/')) {
						console.log("localFile is not an image or video");
						return;
					}

					const filenameRE = /(.*?)[[({](?:audio|sound)[ =:|$](.*?)[\])}]/gi;
					const filenameRE2 = /(\[([^\]]*(?:catbox\.moe)[^\]]*)\])/gi;
					if (file.type.startsWith('image')
						&& (!filenameRE.test(file.name) || (!filenameRE2.test(file.name)))) {
						console.log("localFile: image without [sound=URL]");
						return;
					}

					try {
						// Convert file to base64 data URL instead of blob URL
						const dataUrl = await new Promise((resolve) => {
							const reader = new FileReader();
							reader.onload = () => resolve(reader.result);
							reader.readAsDataURL(file);
						});

						const videoFileExtRE = /(webm|mp4|m4v|ogv|avi|mpeg|mpg|mpe|m1v|m2v|mov|wmv)$/i;

						const imageSrc = dataUrl;
						const type = file.type;
						let thumbSrc = imageSrc;
						const fileURL = dataUrl;
						const fileExt = file.name.split('.').pop().toLowerCase();
						const isVideo = videoFileExtRE.test(fileExt);

						if (isVideo) {
							// Create video thumbnail
							const videoTmp = document.createElement('video');
							const canvas = document.createElement('canvas');
							const ctx = canvas.getContext('2d');

							await new Promise((resolve) => {
								videoTmp.addEventListener('loadeddata', () => {
									canvas.width = videoTmp.videoWidth;
									canvas.height = videoTmp.videoHeight;
									ctx.drawImage(videoTmp, 0, 0, canvas.width, canvas.height);
									thumbSrc = canvas.toDataURL('image/jpeg');
									resolve();
								});
								videoTmp.src = dataUrl;
								videoTmp.currentTime = 0.1; // Seek to a small time to get a frame
							});
						}

						function formatFileSize(bytes) {
							if (bytes === 0) return '0 KB';

							const units = ['KB', 'MB', 'GB'];
							const i = Math.floor(Math.log(bytes) / Math.log(1024));

							// Ensure we never return "Bytes" (always at least KB)
							const adjustedSize = i === 0 ? bytes / 1024 : bytes / Math.pow(1024, i);
							const unit = i === 0 ? 'KB' : units[i - 1];

							return adjustedSize.toFixed(2) + ' ' + unit;
						}

						const fileSize = formatFileSize(file.size);

						//function parseFileName(filename, imageSrc, postNumber, thumbSrc, imageMD5, fileIndex, fileSize, dataFilemime);
						parseFileName(file.name, imageSrc, 'locF:'+window.localFileCounter, thumbSrc, null, 'lF'+window.localFileCounter, fileSize, file.type)
							.forEach(sound => Player.add({
							...sound,
							id: 'locF:' + window.localFileCounter,
							local: true,
							type,
						}));

						window.localFileCounter++;
					} catch (error) {
						console.error('Error processing file:', file.name, error);
					}
				}
			},

			/**
			* 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.
				if (index > -1) {
					Player.sounds.splice(index, 1);

					// Clean up blob URLs only for local files
					if (sound.local) {
						if (sound.url?.startsWith('blob:')) URL.revokeObjectURL(sound.url);
						if (sound.image?.startsWith('blob:')) URL.revokeObjectURL(sound.image);
						if (sound.thumb?.startsWith('blob:')) URL.revokeObjectURL(sound.thumb);
					}
				}
				// Remove the item from the list.
				Player.$(`.${ns}-list-container`)?.removeChild(Player.$(`.${ns}-list-item[data-id="${sound.id}"]`));
				Player.$(`.${ns}-gallery-container`)?.removeChild(Player.$(`.${ns}-gallery-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);
			},

			/**
			* Handle gallery item selection
			*/
			handleGallerySelect: function(e) {
				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(s => s.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.config.viewStyle === 'gallery' ? Player.playlist.hoverImage.style.display = 'none' : 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'));
			},

			/**
			* Start dragging a gallery item
			*/
			handleGalleryDragStart: 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');
			},

			/**
			* Handle gallery item drag over
			*/
			handleGalleryDragEnter: 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(`.${ns}-gallery-item`);
				if (!before || moving === before) return;

				const movingIdx = Player.sounds.findIndex(s => s.id === id);
				const list = moving.parentNode;

				const position = moving.compareDocumentPosition(before);
				if (position & 0x04) {
					before = before.nextSibling;
				}

				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();
			},

			/**
			* Handle gallery drag end
			*/
			handleGalleryDragEnd: 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.
			*/
			scrollToPlaying: function(type = 'center') {
				if (Player.config.viewStyle === 'playlist') {
					Player.playlist.scrollToPlayingPlaylist(type);
				} else if (Player.config.viewStyle === 'gallery') {
					Player.playlist.scrollToPlayingGallery(type);
				}
			},

			/**
			* Scroll to the playing item, unless there is an open menu in the playlist.
			*/
			scrollToPlayingPlaylist: function(type = 'center') {
				if (Player.$(`.${ns}-list-container .${ns}-item-menu`)) {
					return;
				}
				const playing = Player.$(`.${ns}-list-item.playing`);
				playing && playing.scrollIntoView({ behavior: 'smooth', block: type });
			},

			/**
			* Scroll to playing item in gallery view
			*/
			scrollToPlayingGallery: function(type = 'center') {
				const playing = Player.$(`.${ns}-gallery-item.playing`);
				playing && playing.scrollIntoView({ behavior: 'smooth', 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.title = showSoundTagOnly ? 'Show all posts' : 'Show only sound posts';
				});

				// Filter playlist items
				const listItems = Player.$all(`.${ns}-list-item`);
				listItems.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' : '';
					}
				});

				// Filter gallery items
				const galleryItems = Player.$all(`.${ns}-gallery-item`);
				galleryItems.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);

					// Ensure player is on screen when shown
					Player.position.ensureOnScreen();

					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
				});*/

				this.debouncedResize = window.debounceFc(() => {
					if (Player.config.limitPostWidths) {
						Player.position.setPostWidths();
					}
					Player.position.preventWrapping();
					Player.position.preventWrappingFooter();
				}, 8);

				window.addEventListener('resize', this.debouncedResize);

				// Document resize observer
				this.resizeObserver = new ResizeObserver(entries => {
					if (Player.container && !Player.isHidden) {
						Player.position.ensureOnScreen();
					}
				});

				this.resizeObserver.observe(document.documentElement);
				this.resizeObserver.observe(document.body);

				// 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(':')));

				Player.on("config:preventControlsWrapping", (e) => !e && Player.position.showAllControls());
				Player.on("config:controlsHideOrder", () => {
					Player.position.setHideOrder();
					Player.position.preventWrapping();
				});
			},

			/**
			 * Applies a max width to posts next to the player so they don't get hidden behind it.
			 */
			setPostWidths: window.throttleFc(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;
				});
			}, 100),

			/**
			 * 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(Math.ceil(width), 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 === 'gallery' ? Player.$(`.${ns}-gallery-container`) :
					Player.config.viewStyle === 'image' ? Player.$(`.${ns}-media`) :
					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';

				// Check control wrapping after resize
				Player.position.preventWrapping();
				Player.position.preventWrappingFooter();
			},

			/**
			 * 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();

				const containerStyle = window.getComputedStyle(document.querySelector(`#${ns}-container`));
				const containerWidth = parseFloat(containerStyle.getPropertyValue('width')) || width;

				Player.position.resize(containerWidth, 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 top = 26;
				const bottom = 0;

				return {
					top,
					bottom
				};
			},

			/**
			 * Ensures the player is within the visible screen area
			 */
			ensureOnScreen: function() {
				if (!Player.container || Player.isHidden || Player.config.viewStyle === 'fullscreen') {
					return;
				}

				const containerRect = Player.container.getBoundingClientRect();
				const viewportWidth = document.documentElement.clientWidth;
				const viewportHeight = document.documentElement.clientHeight;
				const { top: headerTop, bottom: headerBottom } = this.getHeaderOffset();

				// Check if player is completely offscreen
				const isOffscreen =
					containerRect.right < 0 ||
					containerRect.bottom < headerTop ||
					containerRect.left > viewportWidth ||
					containerRect.top > viewportHeight - headerBottom;

				if (isOffscreen) {
					// Move to default position if completely offscreen
					this.move(10, headerTop + 10);
				} else {
					// Adjust position if partially offscreen
					let newLeft = containerRect.left;
					let newTop = containerRect.top;

					if (containerRect.left < 0) {
						newLeft = 0;
					} else if (containerRect.right > viewportWidth) {
						newLeft = viewportWidth - containerRect.width;
					}

					if (containerRect.top < headerTop) {
						newTop = headerTop;
					} else if (containerRect.bottom > viewportHeight - headerBottom) {
						newTop = viewportHeight - headerBottom - containerRect.height;
					}

					if (newLeft !== containerRect.left || newTop !== containerRect.top) {
						this.move(newLeft, newTop);
					}
				}
			},

			showAllControls: function() {
				Player.$all(`.${ns}-controls [data-hide-id]`).forEach((e) => (e.style.display = null));
			},

			preventWrapping: function() {
				// Reset display style first
				Player.position.showAllControls();

				if (!Player.config.preventControlWrapping) return;

				const container = Player.$(`.${ns}-controls`);
				const hideOrder = Player.position.setHideOrder();
				let controls = Array.from(container.children).filter(el => el.hasAttribute('data-hide-id'));
				let lastControl = controls[controls.length - 1];

				// Get initial state
				const containerWidth = container.clientWidth;
				let contentWidth = Array.from(container.children).reduce((sum, el) => sum + el.clientWidth, 0);

				const seekBar = document.querySelector(`.${ns}-seek-bar`);
				const volumeBar = document.querySelector(`.${ns}-volume-bar`);
				if(containerWidth <= 345) {
					seekBar.style.margin = "0 0.4rem";
					volumeBar.style.margin = "0 0.4rem";
				} else {
					seekBar.style.margin = "0 0.8rem";
					volumeBar.style.margin = "0 0.8rem";
				}

				if (contentWidth <= containerWidth) return;

				// Hide controls until content fits
				let hideIndex = 0;
				while (contentWidth > containerWidth && hideIndex < hideOrder.length) {
					const controlToHide = hideOrder[hideIndex];
					if (!controlToHide) continue;

					controlToHide.style.display = "none";
					controls = controls.filter(control => control !== controlToHide);

					if (controlToHide === lastControl && controls.length > 0) {
						lastControl = controls[controls.length - 1];
					}

					contentWidth = Array.from(container.children).reduce((sum, el) => sum + el.clientWidth, 0);
					hideIndex++;
				}
			},

			setHideOrder: function() {
				// Reset to default if not set
				if (!Array.isArray(Player.config.controlsHideOrder)) {
					Player.settings.reset("controlsHideOrder");
				}

				const controlsContainer = Player.$(`.${ns}-controls`);

				// Create priority map based on array position
				const priorityMap = {};
				Player.config.controlsHideOrder.forEach((control, index) => {
					priorityMap[control] = index;
				});

				// Get all hideable controls, filter to only those in priorityMap, and sort by priority
				Player.position.hideOrder = Array.from(controlsContainer.querySelectorAll('[data-hide-id]'))
					.filter(element => element.getAttribute('data-hide-id') in priorityMap)
					.sort((a, b) => {
						const aPriority = priorityMap[a.getAttribute('data-hide-id')];
						const bPriority = priorityMap[b.getAttribute('data-hide-id')];
						return aPriority - bPriority;
					});

				return Player.position.hideOrder;
			},

			preventWrappingFooter: function() {
				const container = Player.$(`.${ns}-footer`);
				if (!container) return;

				const containerWidth = container.clientWidth;
				const uiBrackets = document.querySelectorAll(`.${ns}-ui-bracket`);
				const footerText = document.querySelectorAll(`.${ns}-footer-text`);

				// Hide or unhide
				const bracketDisplay = containerWidth < 225 ? "none" : "";
				const textDisplay = containerWidth < 345 ? "none" : "";

				uiBrackets.forEach(el => el.style.display = bracketDisplay);
				footerText.forEach(el => el.style.display = textDisplay);
			},
		};
	}),
	/* 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|icon)(?:\\:"([^"]+?)")?`, '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.template === Player.config.headerTemplate)
								? (data.sound && data.sound.post || data.defaultName)
								: (data.sound && data.sound.title || data.defaultName);
				const postID = data.sound && data.sound.post || 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 ?
								`<svg xmlns="http://www.w3.org/2000/svg" ${buttonConf.icon}></svg>`+buttonConf.text :
								buttonConf.text;
						}

						if (/-icon$/.test(full)) return `<div ${attrs.join(' ')}>${text}</div>`;
						return `<a ${attrs.join(' ')} draggable="false">${text}</a>`;
					})
					.replace(soundNameRE, name ? `<div class="fc-sounds-col fc-sounds-truncate-text"><span title="${postID}">${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('[8chan 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('[8chan 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}-ui-button ${ns}-repeat-button`,
				values: {
					all: {
					attrs: ['title="Repeat All"'],
					text: '',
					icon: 'width="17.6px" height="16px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-repeat"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" /><path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />'
					},
					one: {
					attrs: ['title="Repeat One"'],
					text: '',
					icon: 'width="17.6px" height="16px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-repeat-once"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" /><path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" /><path d="M11 11l1 -1v4" />'
					},
					none: {
					attrs: ['title="No Repeat"'],
					text: '',
					icon: 'width="17.6px" height="16px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-repeat-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-3c0 -1.336 .873 -2.468 2.08 -2.856m3.92 -.144h10m-3 -3l3 3l-3 3" /><path d="M20 12v3a3 3 0 0 1 -.133 .886m-1.99 1.984a3 3 0 0 1 -.877 .13h-13m3 3l-3 -3l3 -3" /><path d="M3 3l18 18" />'
					}
				}
			},
			{
				property: 'shuffle',
				tplName: 'shuffle',
				class: `${ns}-ui-button ${ns}-shuffle-button`,
				values: {
					true: {
						attrs: ['title="Shuffled"'],
						text: '',
						icon: 'width="17.6px" height="16px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrows-shuffle-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 4l3 3l-3 3" /><path d="M18 20l3 -3l-3 -3" /><path d="M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5" /><path d="M3 17h3a5 5 0 0 0 5 -5a5 5 0 0 1 5 -5h5" />',
					},
					false: {
						attrs: ['title="Ordered"'],
						text: '',
						icon: 'width="17.6px" height="16px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrows-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 17l-18 0" /><path d="M18 4l3 3l-3 3" /><path d="M18 20l3 -3l-3 -3" /><path d="M21 7l-18 0" />',
					}
				}
			},
			{
				property: 'viewStyle',
				tplName: 'playlist',
				class: `${ns}-ui-button ${ns}-viewStyle-button`,
				values: {
					playlist: {
						attrs: ['title="Show Playlist Enabled"'],
						text: '',
						icon: 'width="17.6px" height="16px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-playlist"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M17 17v-13h4" /><path d="M13 5h-10" /><path d="M3 9l10 0" /><path d="M9 13h-6" />',
					},
					gallery: {
						attrs: ['title="Show Gallery Enabled"'],
						text: '',
						icon: 'width="17.6px" height="16px" viewBox="1 0 22 23" fill="none" stroke="currentColor" stroke-width="2"  stroke-linecap="round"  stroke-linejoin="round"  class="icon icon-tabler icons-tabler-outline icon-tabler-library-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 3m0 2.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z" /><path d="M4.012 7.26a2.005 2.005 0 0 0 -1.012 1.737v10c0 1.1 .9 2 2 2h10c.75 0 1.158 -.385 1.5 -1" /><path d="M17 7h.01" /><path d="M7 13l3.644 -3.644a1.21 1.21 0 0 1 1.712 0l3.644 3.644" /><path d="M15 12l1.644 -1.644a1.21 1.21 0 0 1 1.712 0l2.644 2.644" />',
					},
					image: {
						attrs: ['title="Show Playlist Disabled"'],
						text: '',
						icon: 'width="17.6px" height="16px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-playlist-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 14a3 3 0 1 0 3 3" /><path d="M17 13v-9h4" /><path d="M13 5h-4m-4 0h-2" /><path d="M3 9h6" /><path d="M9 13h-6" /><path d="M3 3l18 18" />',
					}
				}
			},
			{
				property: 'hoverImages',
				tplName: 'hover-images',
				class: `${ns}-ui-button ${ns}-hoverImages-button`,
				values: {
					true: {
						attrs: ['title="Hover Images Enabled"'],
						text: '',
						icon: 'width="17.6px" height="16px" viewBox="1 0 22 23" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo-check"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M11.5 21h-5.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v7" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l.5 .5" /><path d="M15 19l2 2l4 -4" />',
					},
					false: {
						attrs: ['title="Hover Images Disabled"'],
						text: '',
						icon: 'width="17.6px" height="16px" viewBox="1 0 22 23" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo-x"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M13 21h-7a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v7" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l3 3" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0" /><path d="M22 22l-5 -5" /><path d="M17 22l5 -5" />',
					}
				}
			},
			{
				tplName: 'add',
				class: `${ns}-ui-button ${ns}-add-button`,
				icon: 'width="17.6px" height="16px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-plus"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" />',
				text: '',
				attrs: ['title="Add local files"'],
			},
			{
				tplName: 'reload',
				class: `${ns}-ui-button ${ns}-reload-button`,
				icon: 'width="17.6px" height="16px" viewBox="2 1 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-reload"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747" /><path d="M20 4v5h-5" />',
				text: '',
				attrs: ['title="Reload the playlist"'],
			},
			{
				tplName: 'settings',
				class: `${ns}-ui-button ${ns}-config-button`,
				icon: 'width="17.6px" height="16px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-settings"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />',
				text: '',
				attrs: ['title="Settings"'],
			},
			{
				tplName: 'threads',
				class: `${ns}-ui-button ${ns}-threads-button`,
				icon: 'width="17.6px" height="16px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-search"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /><path d="M21 21l-6 -6" />',
				text: '',
				attrs: ['title="Threads"'],
			},
			{
				tplName: 'close',
				class: `${ns}-ui-button ${ns}-close-button`,
				icon: 'width="17.6px" height="16px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14z" /><path d="M9 9l6 6m0 -6l-6 6" />',
				text: '',
				attrs: ['title="Hide the player"'],
			},
			{
				tplName: 'playing',
				requireSound: true,
				class: `${ns}-ui-button ${ns}-playing-jump-link`,
				text: 'Playing',
				attrs: ['title="Scroll the playlist currently playing sound."'],
			},
			{
				tplName: 'post',
				class: `${ns}-ui-button ${ns}-post-button`,
				requireSound: true,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-message"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />',
				text: '',
				showIf: data => data.sound.post,
				attrs: data => [
					`href=${'#' + (is4chan ? 'p' : '') + data.sound.post}`,
					'title="Jump to the post for the current sound"',
				],
			},
			{
				tplName: 'image',
				class: `${ns}-ui-button ${ns}-image-button`,
				requireSound: true,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M12 21h-6a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v7" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l3 3" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" />',
				text: '',
				attrs: data => [
					`href=${data.sound.image}`,
					'title="Open the image in a new tab"',
					'target="_blank"',
				],
			},
			{
				tplName: 'sound',
				class: `${ns}-ui-button ${ns}-sound-button`,
				requireSound: true,
				href: data => data.sound.src,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-music"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M9 17v-13h10v9" /><path d="M9 8h10" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" />',
				text: '',
				attrs: data => [
					`href=${data.sound.src}`,
					'title="Open the sound in a new tab"',
					'target="blank"',
				],
			},
			{
				tplName: 'dl-image',
				requireSound: true,
				class: `${ns}-ui-button ${ns}-download-link`,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M12.5 21h-6.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v6.5" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" /><path d="M14 14l1 -1c.653 -.629 1.413 -.815 2.13 -.559" /><path d="M19 16v6" /><path d="M22 19l-3 3l-3 -3" />',
				text: '',
				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}-ui-button ${ns}-download-link`,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-music-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M9 17v-13h10v8" /><path d="M9 8h10" /><path d="M19 16v6" /><path d="M22 19l-3 3l-3 -3" />',
				text: '',
				attrs: data => [
					'title="Download the sound"',
					`data-src="${data.sound.src}"`,
				],
			},
			{
				tplName: 'filter-image',
				requireSound: true,
				class: `${ns}-ui-button ${ns}-ui-button ${ns}-filter-link`,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-filter"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227z" />',
				text: '',
				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}-ui-button ${ns}-filter-link`,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-filter"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227z" />',
				text: '',
				attrs: data => [
					'title="Add the sound URL to the filters."',
					`data-filter="${data.sound.src.replace(/^(https?:)?\/\//, '')}"`,
				],
			},
			{
				tplName: 'remove',
				requireSound: true,
				class: `${ns}-ui-button ${ns}-remove-link`,
				icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-trash"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 7l16 0" /><path d="M10 11l0 6" /><path d="M14 11l0 6" /><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" /><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />',
				text: '',
				attrs: data => [
					'title="Filter the image."',
					`data-id="${data.sound.id}"`,
				],
			},
			{
				tplName: 'menu',
				requireSound: true,
				class: `${ns}-ui-button ${ns}-item-menu-button`,
				icon: '',
				text: '▼',
				attrs: data => [`data-id=${data.sound.id}`],
			},
			{
				tplName: 'ui-bracketL',
				class: `${ns}-ui-icon ${ns}-ui-bracket ${ns}-ui-bracketL-icon`,
				icon: 'width="12.6px" height="14px" viewBox="0 4 16 16" fill="none"  stroke="currentColor"  stroke-width="1.5"  stroke-linecap="round"  stroke-linejoin="round"  class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-compact-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M13 20l-3 -8l3 -8" />',
				text: '',
			},
			{
				tplName: 'ui-bracketR',
				class: `${ns}-ui-icon ${ns}-ui-bracket ${ns}-ui-bracketR-icon`,
				icon: 'width="12.6px" height="14px" viewBox="8 4 16 16" fill="none"  stroke="currentColor"  stroke-width="1.5"  stroke-linecap="round"  stroke-linejoin="round"  class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-compact-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 4l3 8l-3 8" />',
				text: '',
			},
			{
				tplName: 'ui-files',
				class: `${ns}-ui-icon ${ns}-ui-files-icon`,
				icon: 'width="12px" height="14px" viewBox="1 0 22 22" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"  stroke-linejoin="round"  class="icon icon-tabler icons-tabler-outline icon-tabler-files"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 3v4a1 1 0 0 0 1 1h4" /><path d="M18 17h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h4l5 5v7a2 2 0 0 1 -2 2z" /><path d="M16 17v2a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h2" />',
				text: '',
				attrs: data => [
					'title="Files"',
				],
			},
			{
				tplName: 'sound-tag-toggle',
				class: `${ns}-ui-button ${ns}-sound-tag-toggle-button`,
				property: 'showSoundTagOnly',
				values: {
					true: {
						attrs: ['title="Show all posts"'],
						text: '',
						icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="currentColor" stroke="currentColor" stroke-width="-0.1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-filled icon-tabler-filter"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M20 3h-16a1 1 0 0 0 -1 1v2.227l.008 .223a3 3 0 0 0 .772 1.795l4.22 4.641v8.114a1 1 0 0 0 1.316 .949l6 -2l.108 -.043a1 1 0 0 0 .576 -.906v-6.586l4.121 -4.12a3 3 0 0 0 .879 -2.123v-2.171a1 1 0 0 0 -1 -1z" />',
					},
					false: {
						attrs: ['title="Show only sound posts"'],
						text: '',
						icon: 'width="16px" height="14px" viewBox="1 1 22 22" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-filter"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227z" />',
					}
				}
			},
		];
	}),
	/* 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" style="padding: 0 0 0 0.25rem;">
											<div class="${ns}-media-control ${ns}-previous-button" data-hide-id="previous">
												<div class="${ns}-previous-button-display"></div>
											</div>
											<div class="${ns}-media-control ${ns}-play-button" data-hide-id="play">
												<div class="${ns}-play-button-display ${!Player.audio || Player.audio.paused ? `${ns}-play` : ''}"></div>
											</div>
											<div class="${ns}-media-control ${ns}-next-button" data-hide-id="next">
												<div class="${ns}-next-button-display"></div>
											</div>
										</div>
										<div class="${ns}-col" data-hide-id="seek-bar">
											<div class="${ns}-seek-bar ${ns}-progress-bar" style="margin: 0 0.8rem;">
												<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" data-hide-id="time" style="margin: 0 auto; padding: 0 0.25rem;">
											<span class="${ns}-current-time">0:00</span> <span class="${ns}-duration" data-hide-id="duration">/0:00</span>
										</div>
										<div class="${ns}-col-auto" data-hide-id="volume-bar">
											<div class="${ns}-volume-bar ${ns}-progress-bar" style="margin: 0 0.8rem;">
												<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" data-hide-id="fullscreen" style="margin: 0 auto;">
											<div class="${ns}-media-control ${ns}-fullscreen-button">
												<div class="${ns}-fullscreen-button-display"></div>
											</div>
										</div>
										<div class="${ns}-col-auto" style="padding: 0 0.25rem 0 0;"">
										</div>`
	}),
	/* 21 - Templates
	   CSS */
	(function(module, exports) {

		module.exports = (data = {}) => `

		/*
		 *
		 * CONTROLS CSS
		 *
		 */

		.${ns}-controls {
			align-items: center;
			padding: 0.5rem 0;
			position: relative;
			justify-content: space-between;
			background: ${Player.config.colors.controls_panel};
			border-top: solid ${Player.config.borderWidth} ${Player.config.colors.border};
			border-bottom: solid ${Player.config.borderWidth} ${Player.config.colors.border};
		}
		.${ns}-media-control {
			height: 1.2rem;
			width: 1.5rem;
			display: flex;
			justify-content: center;
			align-items: center;
			cursor: pointer
		}
		.${ns}-media-control .${ns}-col-auto {
			padding: 0 0.5rem;
		}
		.${ns}-media-control>div {
			height: 1rem;
			width: .8rem;
			background: ${Player.config.colors.buttons_color};
		}
		.${ns}-media-control:hover>div {
			background: ${Player.config.colors.hover_color};
		}
		.${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 {
			font-size: 14px;
			color: ${Player.config.colors.controls_current_time};
		}
		.${ns}-duration {
			font-size: 14px;
			color: ${Player.config.colors.controls_duration};
		}
		.${ns}-progress-bar {
			min-width: 3.5rem;
			height: 1.2rem;
			display: flex;
			align-items: center;
		}
		.${ns}-progress-bar .${ns}-full-bar {
			height: .3rem;
			width: 100%;
			background: ${Player.config.colors.progress_bar};
			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: ${Player.config.colors.progress_bar_loaded};
		}
		.${ns}-progress-bar .${ns}-full-bar .${ns}-current-bar {
			background: ${Player.config.colors.buttons_color};
			display: flex;
			justify-content: flex-end;
			align-items: center
		}
		.${ns}-progress-bar .${ns}-full-bar .${ns}-current-bar:after {
			content: "";
			background: ${Player.config.colors.buttons_color};
			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: ${Player.config.colors.hover_color};
		}
		.${ns}-seek-bar .${ns}-current-bar {
			background: ${Player.config.colors.hover_color};
		}
		.${ns}-volume-bar .${ns}-current-bar {
			background: ${Player.config.colors.controls_current_time};
		}
		.${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 ${Player.config.borderWidth} ${Player.config.colors.border};
			font-size: 13px;
		}
		.${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) 65%, ${Player.config.colors.buttons_color} 65%, ${Player.config.colors.buttons_color} 100%)
		}
		.${ns}-footer .${ns}-expander:hover {
			background:linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 65%, ${Player.config.colors.hover_color} 65%, ${Player.config.colors.hover_color} 100%)
		}
		.${ns}-footer:hover .${ns}-hover-display {
			display: inline-block
		}
		.${ns}-footer .${ns}-footer-right {
			float: right;
			margin-right: 0.25rem;
			display: flex;
			justify-content: center; /* Horizontal center */
			/*align-items: center;*/ /* Vertical center */
			text-align: center; /* Optional: center text inside the box */
		}
		.${ns}-footer .${ns}-footer-left {
			float: left;
			display: flex;
			justify-content: center; /* Horizontal center */
			/*align-items: center;*/ /* Vertical center */
			text-align: center; /* Optional: center text inside the box */
		}
		/*
		 *
		 * HEADER CSS
		 *
		 */

		.${ns}-header {
			cursor: grab;
			text-align: center;
			border-bottom:solid ${Player.config.borderWidth} ${Player.config.colors.border};
			padding: .25rem;
		}
		.${ns}-header:hover .${ns}-hover-display {
			display: flex
		}
		.${ns}-header.${ns}-row .${ns}-col.${ns}-truncate-text {
			display: flex;
			justify-content: center; /* Horizontal center */
			align-items: center; /* Vertical center */
			text-align: center; /* Optional: center text inside the box */
			font-size: calc(${Player.config.fontSize}px);
		}
		html.fourchan-x .fa-repeat.fa-repeat-one::after {
			content: "1";
			font-size: .5rem;
			visibility: visible;
			margin-left: -1px
		}

		/*
		 *
		 * UI CSS
		 *
		 */

		.${ns}-ui-button {
			color:${Player.config.colors.buttons_color} !important;
		}
		.${ns}-ui-button:hover {
			color:${Player.config.colors.hover_color} !important;
		}
		.${ns}-ui-icon {
			color:${Player.config.colors.text} !important;
		}
		.${ns}-ui-icon:hover {
			color:${Player.config.colors.text} !important;
		}

		/*
		 *
		 * IMAGE CSS
		 *
		 */

		#${ns}-container[data-view-style=fc-sounds-playing] .${ns}-view-container .${ns}-media:not(.${ns}-pip),
		#${ns}-container[data-view-style=playlist] .${ns}-view-container .${ns}-media:not(.${ns}-pip),
		#${ns}-container[data-view-style=gallery] .${ns}-view-container .${ns}-media:not(.${ns}-pip),
		#${ns}-container[data-view-style=image] .${ns}-view-container .${ns}-media:not(.${ns}-pip) {
			text-align: center;
			display: flex;
			justify-items: center;
			justify-content: center;
			position: relative;
			resize: both;
			overflow: hidden;
			min-height: ${Player.config.minMediaHeight} !important;
			max-height: ${Player.config.maxMediaHeight} !important;
			min-width: 100%;
			max-width: 100%;
		}
		#${ns}-container[data-view-style=fullscreen] .${ns}-view-container .${ns}-media:not(.${ns}-pip) {
			text-align: center;
			display: flex;
			justify-items: center;
			justify-content: center;
			position: relative;
			resize: both;
			overflow: hidden;
			min-width: 100%;
			max-width: 100%;
		}
		.${ns}-media.${ns}-pip {
			text-align: right;
			position: fixed !important;
			right: ${Player.config.offsetRightPIP} !important;
			bottom: ${Player.config.offsetBottomPIP} !important;
			left: auto !important;
			top: auto !important;
			z-index: ${Player.config.zIndexPIP};
		}
		.${ns}-media.${ns}-pip .${ns}-image,
		.${ns}-media.${ns}-pip .${ns}-video {
			height: initial;
			width: initial;
			object-fit: contain;
			max-height: ${Player.config.maxPIPHeight} !important;
			max-width: ${Player.config.maxPIPWidth} !important;
		}
		.${ns}-media .${ns}-video {
			display: none
		}
		.${ns}-image,
		.${ns}-video {
			object-fit: contain
		}
		.${ns}-media.${ns}-show-video .${ns}-video {
			display: block
		}
		.${ns}-media.${ns}-show-video .${ns}-image {
			display: none
		}
		.${ns}-media img,
		.${ns}-media video {
			object-fit: contain;
			pointer-events: none; /* Disable clicks on the link */
		}
		.${ns}-resize-handle {
			position: absolute;
			right: 0;
			bottom: 0;
			width: 5px;
			height: 5px;
			cursor: se-resize;
			/*z-index: 3;*/
		}
		.${ns}-image-link {
			display: block;
			position: absolute;
			width: 80% !important;
			height: 94% !important;
			opacity: 0;
		}
		.${ns}-media.${ns}-pip .${ns}-image-link {
			display: block;
			position: absolute;
			width: 100% !important;
			height: 100% !important;
			opacity: 0;
		}

		/*
		 *
		 * LAYOUT CSS
		 *
		 */

		#${ns}-container {
			position: fixed;
			background:${Player.config.colors.background};
			border: solid ${Player.config.borderWidth} ${Player.config.colors.border};
			min-width: 168px;
			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 .icon {
			font-size: 0;
			visibility: hidden;
			margin: 0 .15rem
		}
		.${ns}-truncate-text {
			white-space: nowrap;
			text-overflow: clip;
			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;
			scrollbar-color: ${Player.config.colors.controls_panel} ${Player.config.colors.background};
			height: 200px;
			font-size: calc(${Player.config.fontSize}px)
		}
		.${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: calc(${Player.config.fontSize * 0.1}rem);
			font-size: calc(${Player.config.fontSize}px)
		}
		.${ns}-list-container .${ns}-list-item.playing {
		    background: ${Player.config.colors.playing} !important;
			color: ${Player.config.colors.text_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} !important;
			color: ${Player.config.colors.text_playing} !important
		}
		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
		}
		.${ns}-list-container .${ns}-list-item .${ns}-col.${ns}-truncate-text {
			background: transparent !important
		}
		.${ns}-list-container .${ns}-list-item .${ns}-col.${ns}-truncate-text span {
			background: transparent !important
		}
		.${ns}-playlist-file-ext {
			display: inline-block;
			min-width: calc(${Player.config.fontSize * 4}px);
			text-align: left;
			background: transparent !important;
		}

		/*
		 *
		 * SETTINGS CSS
		 *
		 */

		.${ns}-settings textarea {
			border: solid ${Player.config.borderWidth} ${Player.config.colors.border};
			min-width: 100%;
			min-height: 4rem;
			box-sizing: border-box;
			white-space: pre
		}
		.${ns}-settings .${ns}-sub-settings .${ns}-col {
			min-height: 1.55rem;
			display: flex;
			align-items: center;
			align-content: center;
			white-space: nowrap
		}
		.${ns}-settings .${ns}-heading-action  {
			font-size: 12px;
		}
		.${ns}-settings .${ns}-col {
			font-size: 16px;
		}
		.${ns}-settings .${ns}-col select {
			font-size: 12px;
		}
		.${ns}-settings .${ns}-heading {
			font-size: 19px;
		}
		.${ns}-settings .${ns}-heading::before {
			content: "";
			display: block;
			border-top: solid ${Player.config.borderWidth};
			opacity: 0.2;
			margin-bottom: 0.7em;
			width: 100%;
		}

		/*
		 *
		 * 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 ${Player.config.borderWidth} ${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 ${Player.config.borderWidth} ${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=gallery] .${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}-media {
			height: auto
		}
		#${ns}-container[data-view-style=playlist] .${ns}-media,
		#${ns}-container[data-view-style=gallery] .${ns}-media {
			height: 125px
		}
		#${ns}-container[data-view-style=fullscreen] .${ns}-media {
			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
		}

		/*
		 *
		 * GALLERY CSS
		 *
		 */

		.fc-sounds-gallery-container {
			display: grid;
			grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
			grid-auto-rows: 80px;
			gap: 8px;
			padding: 8px;
			overflow-y: auto;
			scrollbar-color: ${Player.config.colors.controls_panel} ${Player.config.colors.background};
			height: 100%;
			box-sizing: border-box;
		}

		.${ns}-gallery-item {
			position: relative;
			height: 100%; /* Take full height of grid cell */
			width: 100%; /* Take full width of grid cell */
			min-height: 0; /* Critical for grid item sizing */
			min-width: 0; /* Critical for grid item sizing */
			box-sizing: border-box;
			-moz-box-sizing: border-box;
			-webkit-box-sizing: border-box;
			border: 1.75px solid ${Player.config.colors.border};
			border-radius: 3px;
			overflow: hidden;
			cursor: pointer;
			background: ${Player.config.colors.odd_row};
		}

		.${ns}-gallery-item:nth-child(2n) {
			background: ${Player.config.colors.odd_row};
		}

		.${ns}-gallery-thumb-container {
			width: 100%;
			height: 100%;
			min-height: 0;
			display: flex;
			align-items: center;
			justify-content: center;
		}

		.${ns}-gallery-thumb {
			max-height: 80%;
			object-fit: contain;
			bottom: 16px;
			position: absolute;
			/*overflow: hidden;*/
		}

		.${ns}-gallery-overlay-top {
			position: absolute;
			top: 0;
			left: 0;
			right: 0;
			background: rgba(0, 0, 0, 0.7);
			color: white;
			padding: 2px 4px;
			font-size: 10px;
			text-align: center;
			white-space: nowrap;
			overflow: hidden;
			text-overflow: ellipsis;
		}

		.${ns}-gallery-overlay-bottom {
			position: absolute;
			bottom: 0;
			left: 0;
			right: 0;
			background: rgba(0, 0, 0, 0.65);
			color: white;
			padding: 2px 4px;
			font-size: 10px;
			text-align: center;
			white-space: nowrap;
			overflow: hidden;
			text-overflow: ellipsis;
		}

		.${ns}-gallery-item.playing {
			box-sizing: border-box;
			-moz-box-sizing: border-box;
			-webkit-box-sizing: border-box;
			background: ${Player.config.colors.progress_bar_loaded} !important;
			border: 3px solid ${Player.config.colors.buttons_color};
			border-radius: 2px;
			filter: drop-shadow(0 0 0.25rem ${Player.config.colors.buttons_color});
		}

		.${ns}-gallery-item.playing .${ns}-gallery-overlay-top {
			box-sizing: border-box;
			-moz-box-sizing: border-box;
			-webkit-box-sizing: border-box;
		    padding: 2px 4px 2px 4px;
		    background: ${Player.config.colors.buttons_color};
			color: ${Player.config.colors.background};
			border-bottom: solid 1.75px ${Player.config.colors.buttons_color};
		}

		.${ns}-gallery-item.playing .${ns}-gallery-overlay-bottom {
			box-sizing: border-box;
			-moz-box-sizing: border-box;
			-webkit-box-sizing: border-box;
		    padding: 2px 4px 2px 4px;
		    background: ${Player.config.colors.buttons_color};
			color: ${Player.config.colors.background};
			border-top: solid 3px ${Player.config.colors.buttons_color};
		}

		.${ns}-gallery-item.playing .${ns}-gallery-thumb-container {
		    background: ${Player.config.colors.progress_bar_loaded};
		}

		.${ns}-gallery-item.playing .${ns}-gallery-thumb {
		    bottom: 15px
		}

		#${ns}-container[data-view-style=gallery] .${ns}-gallery-container {
			display: grid !important;
		}

		#${ns}-container[data-view-style=gallery] .${ns}-list-container {
			display: none;
		}

		#${ns}-container[data-view-style=fc-sounds-playing] .${ns}-gallery-container,
		#${ns}-container[data-view-style=playlist] .${ns}-gallery-container,
		#${ns}-container[data-view-style=image] .${ns}-gallery-container {
			display: none;
		}
		`
	}),

	/* 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-and-controls">
								<div class="${ns}-media">
									<a class="${ns}-image-link" target="_blank"></a>
									<img class="${ns}-image"></img>
									<video class="${ns}-video"></video>
								</div>
								<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>
								<div class="${ns}-gallery-container" style="height: 100px">

								</div>
								<img class="${ns}-hover-image" style="display: none">`

	}),
	/* 27 - Templates
	   Settings panel */
	(function(module, exports, __webpack_require__) {

		module.exports = (data = {}) => {
			const settingsConfig = __webpack_require__(1);

			let tpl = `
						<div style="text-align: right; font-size: 10px; font-weight: 600; margin: .5rem 0; min-width: 100%"><b>Version</b>
							<a href="https://greasyfork.org/en/scripts/533468-8chan-sounds-player" target="_blank">${VERSION}</a>
						</div>
						<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, '&quot;')}"` : ''}>
						${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('')


	})
]);