Greasy Fork

Greasy Fork is available in English.

8chan sounds player

Play that faggy music weeb boi

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         8chan sounds player
// @version      2.3.0_0050
// @namespace    8chanss
// @description  Play that faggy music weeb boi
// @author       original by: RCC; ported to 8chan by: soundboy_1459944
// @website      http://greasyfork.icu/en/scripts/533468
// @match        *://8chan.moe/*/res/*
// @match        *://8chan.se/*/res/*
// @match        *://8chan.cc/*/res/*
// @match        *://alephchvkipd2houttjirmgivro5pxullvcgm4c47ptm7mhubbja6kad.onion/*/res/*
// @match        *://8chan.moe/*/last/*
// @match        *://8chan.se/*/last/*
// @match        *://8chan.cc/*/last/*
// @match        *://alephchvkipd2houttjirmgivro5pxullvcgm4c47ptm7mhubbja6kad.onion/*/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/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiAjZmZmOwogICAgICB9CgogICAgICAuY2xzLTIsIC5jbHMtMyB7CiAgICAgICAgZmlsbDogbm9uZTsKICAgICAgfQoKICAgICAgLmNscy0zIHsKICAgICAgICBzdHJva2U6ICNmZmY7CiAgICAgICAgc3Ryb2tlLWxpbmVjYXA6IHJvdW5kOwogICAgICAgIHN0cm9rZS1saW5lam9pbjogcm91bmQ7CiAgICAgICAgc3Ryb2tlLXdpZHRoOiAycHg7CiAgICAgIH0KCiAgICAgIC5jbHMtNCB7CiAgICAgICAgZmlsbDogIzAzOWJlNTsKICAgICAgfQogICAgPC9zdHlsZT4KICA8L2RlZnM+CiAgPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI4LjcuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDEuMi4wIEJ1aWxkIDE0MikgIC0tPgogIDxnPgogICAgPGcgaWQ9IkxheWVyXzEiPgogICAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0wLDBoMjR2MjRIMFYwWiIvPgogICAgICA8cGF0aCBjbGFzcz0iY2xzLTQiIGQ9Ik0xOS43LDFINC4zYy0xLjgsMC0zLjMsMS41LTMuMywzLjN2MTUuNGMwLDEuOCwxLjUsMy4zLDMuMywzLjNoMTUuNGMxLjgsMCwzLjMtMS41LDMuMy0zLjNWNC4zYzAtMS44LTEuNS0zLjMtMy4zLTMuM1oiLz4KICAgICAgPHJlY3QgY2xhc3M9ImNscy00IiB4PSI2IiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiLz4KICAgICAgPHBhdGggY2xhc3M9ImNscy0zIiBkPSJNMjMsMTIuN3Y0czAsMy4xLDAsMy4xYzAsMS43LTEuNCwzLjEtMy4xLDMuMUg0LjFjLTEuNywwLTMuMS0xLjQtMy4xLTMuMVY0LjFjMC0xLjcsMS40LTMuMSwzLjEtMy4xaDMuMSIvPgogICAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0wLDBoMjR2MjRIMFYwWiIvPgogICAgICA8cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik00LjUsNS4xdjEzLjljMCwuNS40LjkuOS45LjIsMCwuMywwLC41LS4xbDExLjMtNi45Yy40LS4zLjUtLjguMy0xLjIsMC0uMS0uMi0uMi0uMy0uM0w1LjgsNC4zYy0uNC0uMy0uOS0uMS0xLjIuMywwLC4xLS4xLjMtLjEuNVoiLz4KICAgICAgPHBhdGggY2xhc3M9ImNscy0yIiBkPSJNMTUtMWgxMnYxMmgtMTJWLTFaIi8+CiAgICAgIDxwYXRoIGNsYXNzPSJjbHMtMyIgZD0iTTIxLDVoLTFjLS41LDAtMS0uNC0xLTF2LTJjMC0uNS40LTEsMS0xaDJjLjUsMCwxLC40LDEsMXYyYzAsLjUtLjQsMS0xLDFoLTJjLS41LDAtMSwuNC0xLDF2MmMwLC41LjQsMSwxLDFoMmMuNSwwLDEtLjQsMS0xdi0yYzAtLjUtLjQtMS0xLTFoLTFaIi8+CiAgICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTAsMGgyNHYyNEgwVjBaIi8+CiAgICAgIDxsaW5lIGNsYXNzPSJjbHMtMyIgeDE9IjE1IiB5MT0iMSIgeDI9IjYuNyIgeTI9IjEiLz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPg==
// ==/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 = (e) => {
							e && e.preventDefault();
							Player.play(Player.sounds.find(sound => sound.id === id));
							if (Player.container && Player.container.style.display === 'none' && !Player.minimised._showingPIP) {
								Player.show();
							}
						}
					});
					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');
					let dimensions = [null, null];
					if(dimensionLabel) {
						dimensions = dimensionLabel.textContent.trim().split(/x|×/);
					} else {
						const img = new Image();
						img.onload = function() {
							dimensions = [img.width, img.height];
						}
						img.src = thumbImg.src;
					}

					// 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 (dimensions.length === 2 && /^image\/.+$/.test(dataFilemime) && !/^image\/gif$/.test(dataFilemime) ) {
						if (parseInt(dimensions[0]) <= 220 && parseInt(dimensions[1]) <= 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 = "#;";
						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 = (e) => {
							e && e.preventDefault();
							Player.play(Player.sounds.find(sound => sound.id === firstID));
							if (Player.container && Player.container.style.display === 'none' && !Player.minimised._showingPIP) {
								Player.show();
							}
						}

						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);
				console.error(post);
			}
		}

		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: 'gallery',
				options: {
					playlist: 'Playlist',
					image: 'Image',
					gallery: 'Gallery'
				}
			},
			{
				property: 'hoverImages',
				default: true
			},
			{
				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 <span style="font-size: 10px; margin: 0.2em 0;">(Picture-in-picture)</span>',
				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: '<span style="font-size: 14px; margin: 0.2em 0;">Navbar Header Controls</span>',
						description: 'Show playback controls in the header. Customise the template below.',
						default: 'always',
						showInSettings: true,
						options: {
							always: 'Always',
							closed: 'Only with the player closed',
							never: 'Never'
						},
						actions: [{
							title: 'Reset',
							handler: 'settings.reset'
						}]
					}
				]
			},
			{
				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' +
					'	pip-toggle-button\n' +
					'	p:{\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',*/
				title: 'Navbar 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}',*/
				default: 'p:{\n\tprev-button\n\tplay-button\n\tnext-button\n\t&nbsp;sound-current-time sound-duration-slash sound-duration&nbsp;\n}',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				showInSettings: 'textarea'
			},
			{
				title: 'Colors',
				showInSettings: true,
				property: 'colors',
				updateStylesheet: true,
				actions: [{
					title: 'Match Theme',
					handler: 'settings.forceBoardTheme'
				}],
				// These colors will be overriden with the theme defaults at initialization.
				settings: [{
						property: 'colors.text',
						default: '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),
				galleryList: __webpack_require__(31)
			},

			/**
			 * 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>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>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
					const checkSounds = setInterval(() => {
						if (Player.sounds && Player.sounds.length > 0) {
							Player.display.show();
							clearInterval(checkSounds);
						}
					}, 1000);
				} 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')) {
					const li = createElement('<a>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;
		window.mediaStatus = 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() {
				await Player.settings.updateLegacySettings();

				// 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 CSS
				setTimeout(() => {Player.settings.applyBoardTheme()}, 1000);
			},

			updateLegacySettings: async function() {
				try {
					const savedSettings = await GM.getValue('settings');

					// If no settings exist, initialize with default values
					if (!savedSettings) {
						const defaultSettings = {
							viewStyle: "gallery",
							VERSION: GM_info.script.version,
						};
						await GM.setValue('settings', JSON.stringify(defaultSettings));
						return;
					}

					// Parse settings safely (handle malformed JSON)
					let settings;
					try {
						settings = JSON.parse(savedSettings);
					} catch (e) {
						console.error("[8chan sounds player] Failed to parse settings, resetting to defaults.", e);
						settings = {
							viewStyle: "gallery",
							VERSION: GM_info.script.version,
						};
						await GM.setValue('settings', JSON.stringify(settings));
						return;
					}

					// Ensure VERSION exists and is in the correct format
					if (!settings.VERSION) {
						settings.viewStyle = "gallery";
						settings.VERSION = GM_info.script.version;
						await GM.setValue('settings', JSON.stringify(settings));
						return;
					}

					// Safely split and compare version (handle unexpected version formats)
					const versionParts = settings.VERSION.split(/_/, 2);
					if (versionParts.length < 2 || isNaN(versionParts[1])) {
						settings.viewStyle = "gallery";
						settings.VERSION = GM_info.script.version;
						await GM.setValue('settings', JSON.stringify(settings));
						return;
					}

					const versionNumber = parseInt(versionParts[1]);

					if (versionNumber < 48) {
						settings.viewStyle = "gallery";
						settings.VERSION = GM_info.script.version;
						await GM.setValue('settings', JSON.stringify(settings));
					}

					if (versionNumber < 48) {
						// Delete the specified properties
						const propertiesToDelete = [
							'controlsHideOrder',
							'headerTemplate',
							'rowTemplate',
							'footerTemplate',
							'chanXTemplate'
						];

						for (const prop of propertiesToDelete) {
							if (prop in settings) {
								delete settings[prop];
							}
						}

						// Update version to current
						settings.VERSION = GM_info.script.version;
						await GM.setValue('settings', JSON.stringify(settings));
					}
				} catch (error) {
					console.error("[8chan sounds player] Failed to update settings:", error);
				}
			},

			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.container.getAttribute('data-view-style') === '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 = {};
		const pendingRequests = new Set();
		let syncInterval;
		let blobUrl;

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

				// Listen for repeat mode changes through Player events
				Player.on('config:repeat', Player.controls.updateLoop);
				Player.on('show', Player.controls.updateLoop);
				Player.on('hide', Player.controls.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();
					setTimeout(Player.controls.displayMsg, 100);
				});
			},

			displayMsg: async function() {
				const msg = `
					data:image/png;base64,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
				`;
				const video = document.querySelector(`.${ns}-video`);
				const image = document.querySelector(`.${ns}-image`);
				video.src = "";
				video.poster = msg;
				image.src = msg;
			},

			// Initialize loop state based on current repeat mode
			updateLoop: function() {
				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)) {
					if (syncInterval) clearInterval(syncInterval);
					video.loop = true;
					Player.audio.loop = Player.config.repeat === 'one';
					return;
				}

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

			/**
			 * 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, .${ns}-seek-bar`);
				buttons.forEach(button => {
					button.disabled = window.isLoading;
					button.style.opacity = window.isLoading ? '0.5' : '1';
					button.style.cursor = window.isLoading ? 'not-allowed' : 'pointer';
				});
			},

			// 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) => {
					const requestDetails = {
						src,
						resolve,
						reject,
						aborted: false
					};

					pendingRequests.add(requestDetails);

					GM.xmlHttpRequest({
						method: 'GET',
						url: src,
						responseType: 'blob',
						onload: function(response) {
							if (requestDetails.aborted) return; // Skip if aborted
							pendingRequests.delete(requestDetails);

							if (response.status >= 400) {
								console.error(`[8chan sounds player] Failed to fetch media; status: ${response.status}`);
								reject(new Error(`HTTP ${response.status}`));
							} else {
								resolve(response);
							}
						},
						onerror: function(error) {
							pendingRequests.delete(requestDetails);
							reject(error);
						},
						ontimeout: function() {
							pendingRequests.delete(requestDetails);
							reject(new Error('Request timed out'));
						}
					});
				});
			},

			/**
			 * Cancel a specific request by URL
			 */
			cancelRequest: function(src) {
				pendingRequests.forEach(request => {
					if (request.src === src) {
						request.aborted = true;
						request.reject(new Error('Request cancelled by user'));
						pendingRequests.delete(request);
					}
				});
			},

			/**
			 * Cancel all pending requests
			 */
			cancelAllRequests: function() {
				pendingRequests.forEach(request => {
					request.aborted = true;
					request.reject(new Error('All requests cancelled'));
				});
				pendingRequests.clear();
			},

			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.mediaStatus !== "Error") {
					window.Master.currentTime = 0;
					video.currentTime = 0;
					window.Master.play().catch(() => {});
					video.play().catch(() => {});
					Player.controls.handlePlaybackState(); // Resync UI
					return;
				}

				Player.controls.cancelAllRequests();

				window.mediaStatus = undefined; Player.header.render();
				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;

				// 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;
				URL.revokeObjectURL(blobUrl);

				Player.controls.updateLoop();
				Player.controls.updatePlayButtonState();

				try {
					sound.playing = true;
					Player.playing = sound;
					await Player.trigger('playsound', sound);
					video.poster = sound.thumb;
					Player.minimised.updatePipSize();

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

						if (response.status === 200) {
							//const rawBase64 = await Player.controls.BlobReader(response.response);
							//const mimeType = await Player.controls.detectMimeType(sound.src, rawBase64, response.response.type);
							//Player.audio.src = `data:${mimeType};base64,${rawBase64}`;
							blobUrl = URL.createObjectURL(response.response);
							Player.audio.src = blobUrl;
							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.error(new Error('[8chan sounds player] Failed to fetch via GM_xmlhttpRequest, trying fallback:'));
							window.mediaStatus = "Error"; Player.header.render();
							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.error('[8chan sounds player] 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 (response.status === 200) {
							await Player.audio.play();
							Player.controls.syncPlayback();
							// Start sync interval
							if (syncInterval) clearInterval(syncInterval);
							syncInterval = setInterval(Player.controls.syncPlayback, 50);
						}
					}

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

						if (response.status === 200) {
							//const rawBase64 = await Player.controls.BlobReader(response.response);
							//const mimeType = await Player.controls.detectMimeType(sound.src, rawBase64, response.response.type);
							//video.src = `data:${mimeType};base64,${rawBase64}`;
							blobUrl = URL.createObjectURL(response.response);
							video.src = blobUrl;
							video.muted = false;

							// 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.error(new Error('[8chan sounds player] Failed to fetch via GM_xmlhttpRequest, trying fallback:'));
							window.mediaStatus = "Error"; Player.header.render();
							video.muted = false;
							// Fallback to direct video playback
							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();

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

								await video.play().catch(e => {
									console.error('[8chan sounds player] Video playback failed, falling back to empty source:', e);
									video.pause();
									video.removeAttribute('src');
									video.load();
									window.Master = undefined;
								});
							} else {
								video.pause();
								video.removeAttribute('src');
								video.load();
								window.Master = undefined;
							}
						}
					}

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

						// 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) {
						window.mediaStatus = "Loading"; Player.header.render();

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

						// 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('[8chan sounds player] Playback error:', err);
					Player.logError('Could not play sound');
					window.mediaStatus = "Error"; Player.header.render();

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

					//const container = document.querySelector(`.${ns}-media`);
					//container.classList.remove(`${ns}-show-video`);

					if (syncInterval) clearInterval(syncInterval);

					// handlePlaybackState
					Player.controls.handlePlaybackState();

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

				} finally {
					window.isLoading = false;
					if(window.mediaStatus !== "Error") { window.mediaStatus = undefined; Player.header.render(); }
					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('[8chan sounds player] Video error:', video.error);
					Player.logError('Video playback error.');
					window.mediaStatus = "Error"; Player.header.render();
				} else if (Player.audio?.error) {
					console.error('[8chan sounds player] Audio error:', Player.audio.error);
					Player.logError('Audio playback error.');
					window.mediaStatus = "Error"; Player.header.render();
				}
			},
			/**
			 * 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, 50);
			},

			/**
			 * 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 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 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[onclick]')) {
					// Create the sounds button
					const soundsButton = createElement(`
						<a 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.preventWrappingHeaderFooter();
				}
			}
		};


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

			delegatedEvents: {
				click: {
					[`.${ns}-pip-toggle-button`]: 'minimised.updatePipSettings'
				}
			},

			initialize: function() {
				// 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);
				Player.on('config:maxPIPWidth', Player.minimised.updatePipSize);
				Player.on('config:maxPIPHeight', Player.minimised.updatePipSize);
			},

			render: function() {
				if (Player.container) {
					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);*/
						container = createElementBefore(`<span class="${ns}-chan-x-controls ${ns}-col-auto"></span>`, document.querySelector('#navOptionsSpan').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-slash': `<span class="${ns}-duration-slash">/</span>`,
							'sound-duration': `<span class="${ns}-duration">0:00</span>`
						}
					});

					Player.controls.handlePlaybackState(); // Resync UI
				}
			},

			updatePipSettings: function(e) {
				e && e.preventDefault();
				Player.set('pip', !Player.config.pip);
			},

			/**
			 * 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() {
				document.querySelector(`.${ns}-video`).removeAttribute('style');
				document.querySelector(`.${ns}-image`).removeAttribute('style');
				Player.minimised._showingPIP = false;
				const image = document.querySelector(`.${ns}-media`);
				image.style.minWidth = '100%';
				image.style.maxWidth = '100%';
				image.style.maxHeight = '100%';
				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`);
				const videoEl = document.querySelector(`.${ns}-video`);
				const imageEl = document.querySelector(`.${ns}-image`);

				mediaPIP?.removeAttribute('style');
				videoEl?.removeAttribute('style');
				imageEl?.removeAttribute('style');

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

				const pipMaxWidth = Math.max(1, parseInt(Player.config.maxPIPWidth) || 220); // Fallback: 220px
				const pipMaxHeight = Math.max(1, parseInt(Player.config.maxPIPHeight) || 220); // Fallback: 220px
				// Safely get video dimensions (fallback to container size if missing)
				const mediaWidth = Player.playing?.isVideo ? Math.max(1, videoEl.videoWidth || pipMaxWidth) : Math.max(1, imageEl.naturalWidth || pipMaxWidth) ;
				const mediaHeight = Player.playing?.isVideo ? Math.max(1, videoEl.videoHeight || pipMaxHeight) : Math.max(1, imageEl.naturalHeight || pipMaxHeight) ;
				//if(videoEl && Player.playing?.isVideo) console.log('videoEl.videoWidth '+videoEl.videoWidth+' videoEl.videoHeight '+videoEl.videoHeight);
				//if(imageEl && !Player.playing?.isVideo) console.log('imageEl.naturalWidth '+imageEl.naturalWidth+' imageEl.naturalHeight '+imageEl.naturalHeight);
				const mediaAspectRatio = mediaWidth / mediaHeight;
				const maxAspectRatio = pipMaxWidth / pipMaxHeight;

				const applyPipDimensions = (element) => {
					let newWidth, newHeight;

					// Determine scaling factor based on aspect ratio comparison
					if (!isFinite(mediaAspectRatio) || !isFinite(maxAspectRatio)) {
						// Fallback if invalid aspect ratios
						newWidth = pipMaxWidth;
						newHeight = pipMaxHeight;
					} else if (mediaAspectRatio > maxAspectRatio) {
						// Video is wider than container -> scale by width
						newWidth = pipMaxWidth;
						newHeight = pipMaxWidth / mediaAspectRatio;
					} else {
						// Video is taller than container -> scale by height
						newHeight = pipMaxHeight;
						newWidth = pipMaxHeight * mediaAspectRatio;
					}

					element.style.maxWidth = `${newWidth}px`;
					element.style.maxHeight = `${newHeight}px`;
					mediaPIP.style.maxWidth = `${newWidth}px`;
					mediaPIP.style.maxHeight = `${newHeight}px`;
				};

				if (Player.playing) {
					applyPipDimensions(videoEl);
					applyPipDimensions(imageEl);
				} 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.handleSelect',
					[`.${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.handleDragStart'
				},
				dragenter: {
					[`.${ns}-list-item`]: 'playlist.handleDragEnter',
					[`.${ns}-gallery-item`]: 'playlist.handleGalleryDragEnter'
				},
				dragend: {
					[`.${ns}-list-item`]: 'playlist.handleDragEnd',
					[`.${ns}-gallery-item`]: 'playlist.handleDragEnd'
				},
				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');

					Player.playlist.scrollToPlaying();
				});

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

				document.addEventListener('visibilitychange', () => {
					const dataViewStyle = Player.container.getAttribute('data-view-style');
					if (dataViewStyle && dataViewStyle !== undefined) {
						try {
							Player.display.setViewStyle(dataViewStyle);
							Player.set('config:viewStyle', dataViewStyle);
							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);
						}
					}
				});
			},

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

				const galleryContainer = Player.$(`.${ns}-gallery-container`);
				galleryContainer.innerHTML = Player.templates.galleryList(); /* module 31 */

				const playlistContainer = Player.$(`.${ns}-list-container`);
				playlistContainer.innerHTML = Player.templates.list(); /* module 25 */

				Player.events.addUndelegatedListeners(document.body, Player.playlist.undelegatedEvents);
				Player.playlist.applySoundTagFilter();
			},

			/**
			* 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) {

							// Add the sound to the gallery.
							const galleryContainer = Player.$(`.${ns}-gallery-container`);
							let itemContainer = document.createElement('div');
							itemContainer.innerHTML = Player.templates.galleryList({
								sounds: [sound]
							});
							Player.events.addUndelegatedListeners(itemContainer, Player.playlist.undelegatedEvents);
							let item = itemContainer.children[0];
							if (index < Player.sounds.length - 1) {
								const before = Player.$(`.${ns}-gallery-item[data-id="${Player.sounds[index + 1].id}"]`);
								galleryContainer.insertBefore(item, before);
							} else {
								galleryContainer.appendChild(item);
							}

							// 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/')/* && !file.type.startsWith('audio/')*/) {
						console.warn("[8chan sounds player] 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.warn("[8chan sounds player] 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 dataUrl = URL.createObjectURL(file);

						//console.log(file);

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

						//const domain = location.href.split("/").slice(0, 3).join("/");
						const imageSrc = /*(file.type.startsWith('image') || file.type.startsWith('video/')) ?*/ dataUrl /*: `${domain}/audioGenericThumb.png`*/;
						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', () => {
									// setTimeout to avoid black thumbnails
									setTimeout(() => {
										canvas.width = videoTmp.videoWidth;
										canvas.height = videoTmp.videoHeight;
										ctx.drawImage(videoTmp, 0, 0, canvas.width, canvas.height);
										thumbSrc = canvas.toDataURL('image/jpeg');
										resolve();
									}, 100);
								});
								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,
							src: (sound.hasSoundTag) ? sound.src : dataUrl,
							id: 'locF:' + window.localFileCounter,
							local: true,
							type,
						}));

						window.localFileCounter++;
					} catch (error) {
						console.error('[8chan sounds player] 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/gallery item being clicked. Either open/close the menu or play the sound.
			*/
			handleSelect: function(e) {
				// Ignore if a link was clicked.
				if (e.target.nodeName === 'A' || e.target.closest('a')) {
					return;
				}
				e.preventDefault();
				const id = e.eventTarget.getAttribute('data-id');
				const sound = id && Player.sounds.find(sound => sound.id === id);
				sound && Player.play(sound);
			},

			/**
			* Read all the sounds from the thread again.
			*/
			refresh: function() {
				parseFiles(document.body);
			},

			/**
			* Toggle the hoverImages setting
			*/
			toggleHoverImages: function(e) {
				e && e.preventDefault();
				Player.set('hoverImages', !Player.config.hoverImages);
			},

			/**
			* Only show the hover image with the setting enabled, no item menu open, and nothing being dragged.
			*/
			setHoverImageVisibility: function() {
				const container = Player.$(`.${ns}-player`);
				const hideImage = !Player.config.hoverImages ||
					  Player.playlist._dragging ||
					  container.querySelector(`.${ns}-item-menu`);
				container.classList[hideImage ? 'add' : 'remove'](`${ns}-hide-hover-image`);
			},

			/**
			* Set the displayed hover image and reposition.
			*/
			updateHoverImage: function(e) {
				const id = e.currentTarget.getAttribute('data-id');
				const sound = Player.sounds.find(sound => sound.id === id);
				const hoverImage = Player.$(`.${ns}-hover-image`);
				const dataViewStyle = Player.container.getAttribute('data-view-style');
				dataViewStyle === 'gallery' ? hoverImage.style.display = 'none' : hoverImage.style.display = 'block';
				hoverImage.setAttribute('src', sound.thumb);
				Player.playlist.positionHoverImage(e);
			},

			/**
			* Reposition the hover image to follow the cursor.
			*/
			positionHoverImage: function(e) {
				const hoverImage = Player.$(`.${ns}-hover-image`);
				const {
					width,
					height
				} = hoverImage.getBoundingClientRect();
				const maxX = document.documentElement.clientWidth - width - 5;
				hoverImage.style.left = (Math.min(e.clientX, maxX) + 5) + 'px';
				hoverImage.style.top = (e.clientY - height - 10) + 'px';
			},

			/**
			* Hide the hover image when nothing is being hovered over.
			*/
			removeHoverImage: function() {
				const hoverImage = Player.$(`.${ns}-hover-image`);
				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`);
				//setTimeout(() => {e.dataTransfer.setDragImage(new Image(), 0, 0);}, 100);
				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 (and update gallery accordingly)
			*/
			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(`.${ns}-list-item`);
				if (!before || moving === before) return;

				// Get corresponding gallery elements
				const movingGallery = Player.$(`.${ns}-gallery-item[data-id="${id}"]`);
				const beforeId = before.getAttribute('data-id');
				let beforeGallery = Player.$(`.${ns}-gallery-item[data-id="${beforeId}"]`);

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

				// Calculate position
				const position = moving.compareDocumentPosition(before);
				if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
					before = before.nextElementSibling;
					if (beforeGallery) {
						beforeGallery = beforeGallery.nextElementSibling;
					}
				}

				// Safely move elements
				try {
					// Update playlist view
					if (before && list.contains(before)) {
						list.insertBefore(moving, before);
					} else {
						list.appendChild(moving);
					}

					// Update gallery view if elements exist
					if (movingGallery && galleryList) {
						if (beforeGallery && galleryList.contains(beforeGallery)) {
							galleryList.insertBefore(movingGallery, beforeGallery);
						} else {
							galleryList.appendChild(movingGallery);
						}
					}

					// Update sounds array
					const newIndex = before ?
						  Player.sounds.findIndex(s => s.id === before.getAttribute('data-id')) :
					Player.sounds.length;
					const [movedSound] = Player.sounds.splice(movingIdx, 1);
					Player.sounds.splice(newIndex, 0, movedSound);

					Player.trigger('order');
				} catch (err) {
					console.error('[8chan sounds player] Drag operation failed:', err);
				}
			},

			/**
			* Handle gallery item drag over (and update playlist accordingly)
			*/
			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;

				// Get corresponding playlist elements
				const movingPlaylist = Player.$(`.${ns}-list-item[data-id="${id}"]`);
				const beforeId = before.getAttribute('data-id');
				let beforePlaylist = Player.$(`.${ns}-list-item[data-id="${beforeId}"]`);

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

				// Calculate position
				const position = moving.compareDocumentPosition(before);
				if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
					before = before.nextElementSibling;
					if (beforePlaylist) {
						beforePlaylist = beforePlaylist.nextElementSibling;
					}
				}

				// Safely move elements
				try {
					// Update gallery view
					if (before && list.contains(before)) {
						list.insertBefore(moving, before);
					} else {
						list.appendChild(moving);
					}

					// Update playlist view if elements exist
					if (movingPlaylist && playlistList) {
						if (beforePlaylist && playlistList.contains(beforePlaylist)) {
							playlistList.insertBefore(movingPlaylist, beforePlaylist);
						} else {
							playlistList.appendChild(movingPlaylist);
						}
					}

					// Update sounds array
					const newIndex = before ?
						  Player.sounds.findIndex(s => s.id === before.getAttribute('data-id')) :
					Player.sounds.length;
					const [movedSound] = Player.sounds.splice(movingIdx, 1);
					Player.sounds.splice(newIndex, 0, movedSound);

					Player.trigger('order');
				} catch (err) {
					console.error('[8chan sounds player] Drag operation failed:', err);
				}
			},

			/**
			* Start dragging a playlist item.
			*/
			handleDragEnd: function(e) {
				if (!Player.playlist._dragging) {
					return;
				}
				e.preventDefault();
				delete Player.playlist._dragging;
				e.eventTarget.classList.remove(`${ns}-dragging`);
				Player.playlist.setHoverImageVisibility();
			},

			/**
			* Scroll to the playing item.
			*/
			scrollToPlaying: function(type = 'center') {
				const dataViewStyle = Player.container.getAttribute('data-view-style');
				if (dataViewStyle === 'playlist') {
					Player.playlist.scrollToPlayingPlaylist(type);
				} else if (dataViewStyle === '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;

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

				// Focus the playing song
				Player.playlist.scrollToPlaying();
			}
		};
	}),
	/* 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.preventWrappingHeaderFooter();
				}, 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.container.getAttribute('data-view-style') === '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 dataViewStyle = Player.container.getAttribute('data-view-style');
				const heightElement = dataViewStyle === 'playlist' ? Player.$(`.${ns}-list-container`) :
					dataViewStyle === 'gallery' ? Player.$(`.${ns}-gallery-container`) :
					dataViewStyle === 'image' ? Player.$(`.${ns}-media`) :
					dataViewStyle === 'settings' ? Player.$(`.${ns}-settings`) :
					dataViewStyle === '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.preventWrappingHeaderFooter();
			},

			/**
			 * 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.container.getAttribute('data-view-style') === '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}-media-and-controls .${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];
				const currentTime = container.querySelector(`.${ns}-current-time`);
				const durationSlash = container.querySelector(`.${ns}-duration-slash`);
				const duration = container.querySelector(`.${ns}-duration`);

				// Get initial state
				const containerWidth = container.clientWidth + 1; // +1 fix for Penumbra css theme
				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];
					}

					durationSlash.style.display = (currentTime.style.display == "none" || duration.style.display == "none") ? "none" : "";

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

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

				const containerWidth = container.clientWidth;
				const footerUiBrackets = document.querySelectorAll(`.${ns}-footer .${ns}-ui-bracket`);
				const footerText = document.querySelectorAll(`.${ns}-footer .${ns}-footer-text`);
				const headerTitle = document.querySelectorAll(`.${ns}-header.${ns}-row .${ns}-col.${ns}-truncate-text span`);

				// Hide or unhide
				const footerBracketDisplay = containerWidth < 235 ? "none" : "";
				const footerTextDisplay = containerWidth < 345 ? "none" : "";
				const headerTitleDisplay = containerWidth < 268 ? "none" : "";

				footerUiBrackets.forEach(el => el.style.display = footerBracketDisplay);
				footerText.forEach(el => el.style.display = footerTextDisplay);
				headerTitle.forEach(el => el.style.display = headerTitleDisplay);
			},
		};
	}),
	/* 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.container.getAttribute('data-view-style') === '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.container.getAttribute('data-view-style') === '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}-reload-link`]: 'userTemplate._handleReload',
					[`.${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',
					[`#${ns}-codecs-close-button`]: 'userTemplate.codecsInfoClose'
				},
				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 || '';
				let name = (data.template === Player.config.headerTemplate)
								? (data.sound && data.sound.post || data.defaultName)
								: (data.sound && data.sound.title || data.defaultName);
				if(data.template === Player.config.headerTemplate) {
					if(window.mediaStatus === "Loading") name = 'Loading...';
					if(window.mediaStatus === "Error") name = 'error';
				}
				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) {
						// Restore original order - separate local files and post files
						Player.sounds.sort((a, b) => {
							// Local files come after post files
							if (a.id.startsWith('locF:') && !b.id.startsWith('locF:')) return 1;
							if (!a.id.startsWith('locF:') && b.id.startsWith('locF:')) return -1;
							// Both are same type - compare appropriately
							return a.id.startsWith('locF:')
								? parseInt(a.id.split(':')[1]) - parseInt(b.id.split(':')[1]) // Compare local file numbers
							: Player.compareIds(a.id, b.id); // Compare post IDs
						});
					} else {
						// Shuffle the array (Fisher-Yates algorithm)
						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]];
						}
					}

					// Rebuild views while preserving playing state and scroll positions
					const rebuildView = (container, templateFn) => {
						const scrollPos = container.scrollTop;
						const playingId = Player.playing?.id;

						container.innerHTML = templateFn({ sounds: Player.sounds });

						// Restore playing state if needed
						if (playingId) {
							const playingEl = container.querySelector(`[data-id="${playingId}"]`);
							if (playingEl) playingEl.classList.add('playing');
						}

						container.scrollTop = scrollPos;
					};

					rebuildView(Player.$(`.${ns}-list-container`), Player.templates.list);
					rebuildView(Player.$(`.${ns}-gallery-container`), Player.templates.galleryList);

					// Reattach event listeners
					Player.events.addUndelegatedListeners(document.body, Player.playlist.undelegatedEvents);

					Player.trigger('order');
					Player.playlist.scrollToPlaying();
				} 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();
				Player.userTemplate._closeMenus()
				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);
			},

			_handleReload: function(e) {
				const id = e.eventTarget.getAttribute('data-id');
				const sound = id && Player.sounds.find(sound => sound.id === '' + id);
				Player.playing = undefined;
				sound && Player.controls.play(sound);
			},

			codecsInfoClose: function(e) {
				const codecsInfo = document.querySelector(`#${ns}-codecs-info`);
				codecsInfo.style.display = 'none';
			},
		};


	}),
	/* 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 only sound posts ENABLED"'],
						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 DISABLED"'],
						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" />',
					}
				}
			},
			{
				tplName: 'pip-toggle',
				class: `${ns}-ui-button ${ns}-pip-toggle-button`,
				property: 'pip',
				values: {
					true: {
						attrs: ['title="Picture-in-picture ENABLED"'],
						text: '',
						icon: 'width="16px" height="14px" 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-picture-in-picture"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 19h-6a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4" /><path d="M14 14m0 1a1 1 0 0 1 1 -1h5a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-5a1 1 0 0 1 -1 -1z" />',
					},
					false: {
						attrs: ['title="Picture-in-picture DISABLED"'],
						text: '',
						icon: 'width="16px" height="14px" 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-picture-in-picture-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 19h-6a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4" /><path d="M14 14m0 1a1 1 0 0 1 1 -1h5a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-5a1 1 0 0 1 -1 -1z" /><path d="M3 3l18 18" />',
					}
				}
			},
		];
	}),
	/* 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-slash">/</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,
		.${ns}-controls .${ns}-duration,
		.${ns}-controls .${ns}-duration-slash {
			font-size: 14px;
		}
		.${ns}-current-time {
			color: ${Player.config.colors.controls_current_time};
		}
		.${ns}-duration,
		.${ns}-duration-slash {
			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;
			vertical-align: middle;
		}
		.${ns}-chan-x-controls .${ns}-media-control {
			width: 0.9rem;
			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: vertical;
			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: vertical;
			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:hover {
			filter: drop-shadow(0 0 0.1rem ${Player.config.colors.buttons_color});
		}
		.${ns}-media .${ns}-video {
			display: none
		}
		.${ns}-image,
		.${ns}-video {
			object-fit: contain
		}
		#${ns}-container[data-view-style=fullscreen] .${ns}-image,
		#${ns}-container[data-view-style=fullscreen] .${ns}-video {
			height: 100% !important;
			width: 100% !important;
			max-height: 100% !important;
			max-width: 100% !important;
		}
		.${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: 179px;
			width: 375px;
			color:${Player.config.colors.text};
			scrollbar-color: ${Player.config.colors.controls_panel} ${Player.config.colors.background};
		}
		.${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;
			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;
		}
		.${ns}-list-item.${ns}-dragging {
			opacity: 1;
		}

		/*
		 *
		 * 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%;
		}
		#${ns}-codecs-info {
			background: rgba(255,216,0,0.3);
			border-style: solid;
			border-color: rgba(255, 255, 0, 0.9);
			border-width: 3px 0 3px 0;
			text-align: left;
			font-size: 10px;
			font-weight: 600;
			margin: .5rem 0;
			min-width: 92%;
			position: relative;
			padding-right: 20px;
		}
		#${ns}-codecs-close-button {
			position: absolute;
			right: 1px;
			top: 1px;
			padding-bottom: 0.5px;
			background: rgba(255, 255, 0, 0.4);
			border-style: solid;
			border-width: 1px;
			font-weight: bold;
			cursor: pointer;
		}
		#${ns}-codecs-close-button:hover {
			background: rgba(255, 0, 0, 0.4);
			color: black;
			border-color: black;
		}

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

		.${ns}-gallery-container {
			display: grid;
			grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
			grid-auto-rows: 80px;
			gap: 8px;
			padding: 8px;
			overflow-y: auto;
			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-item .${ns}-item-menu-button {
			;
		}

		.${ns}-gallery-thumb-container .${ns}-hover-display {
			text-align: right;
			position: absolute;
			top: 0;
			right: 0;
			bottom: auto;
			left: auto;
			display: none;
			font-size: 12px;
			background: ${Player.config.colors.odd_row};
			border-bottom: solid 1.75px ${Player.config.colors.border};
			border-left: solid 1.75px ${Player.config.colors.border};
			border-radius: 0 0 0 8px;
		}

		.${ns}-gallery-item.playing .${ns}-gallery-thumb-container .${ns}-hover-display {
			background: ${Player.config.colors.progress_bar_loaded};
			border-bottom: solid 3px ${Player.config.colors.buttons_color};
			border-left: solid 3px ${Player.config.colors.buttons_color};
		}

		.${ns}-gallery-thumb-container:hover .${ns}-hover-display {
			display: block
		}

		.${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 3px 4px;
			font-size: 10px;
			text-align: center;
			white-space: nowrap;
			overflow: hidden;
			text-overflow: ellipsis;
			display: none;
		}

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

		.${ns}-gallery-item:hover:not(.${ns}-gallery-item.playing) {
			border-color: ${Player.config.colors.buttons_color};
			filter: drop-shadow(0 0 0.1rem ${Player.config.colors.buttons_color});
		}
		.${ns}-gallery-item:hover:not(.${ns}-gallery-item.playing) .${ns}-gallery-thumb-container .${ns}-hover-display {
			border-color: ${Player.config.colors.buttons_color};
			filter: drop-shadow(0 0 0.1rem ${Player.config.colors.buttons_color});
		}

		.${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.1rem ${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: 1px 3px 2px 3px;
		    background: ${Player.config.colors.buttons_color};
			color: ${Player.config.colors.background};
			border-bottom: solid 1px ${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 3px 1px 3px;
		    background: ${Player.config.colors.buttons_color};
			color: ${Player.config.colors.background};
			border-top: solid 1px ${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;
		}
		.${ns}-gallery-item.${ns}-dragging {
			opacity: 0.5;
		}

		#${ns}-container[data-view-style=gallery] .${ns}-item-menu {
			font-size: calc(${Player.config.fontSize}px);
		}
		`
	}),

	/* 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}-reload-link entry focused" data-id="${data.sound.id}">Reload</a>
											<a class="${ns}-remove-link entry focused" 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" data-src="${data.sound.image}" data-name="${data.sound.filename}">Image</a>
													<a class="${ns}-download-link entry" 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" data-filter="${data.sound.imageMD5}">Image</a>` : ''}
													<a class="${ns}-filter-link entry" 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="http://greasyfork.icu/en/scripts/533468-8chan-sounds-player" target="_blank">${VERSION}</a>
						</div>
						<div id="${ns}-codecs-info">
							<button id="${ns}-codecs-close-button">×</button>
							<b>Some videos WILL NOT PLAY unless the appropriate codecs are installed.</b>
							<br><b>Consider installing them if you haven't already.</b>
							<br>HEVC/h265 codec: <a href="https://apps.microsoft.com/detail/9n4wgh0z6vhq" target="_blank">HEVC Video Extensions from Device Manufacturer</a>
							<br>HEVC/h265 codec: <a href="https://archive.org/download/hevc-video-extensions-arm-64" target="_blank">HEVC Video Extensions</a>
							<br><small>&nbsp;&nbsp;&nbsp;▪&nbsp;&nbsp;There are two different HEVC codecs. I don't know which one is better.</small>
							<br>AV1 codec: <a href="https://apps.microsoft.com/detail/9mvzqvxjbq9v" target="_blank">AV1 Video Extension</a>
							<br>VP8 codec: <a href="https://apps.microsoft.com/detail/9n5tdp8vcmhs" target="_blank">Web Media Extensions</a>
							<br>VP9 codec: <a href="https://apps.microsoft.com/detail/9n4d0msmp0pt" target="_blank">VP9 Video Extensions</a>
							<br>MPEG-2 codec: <a href="https://apps.microsoft.com/detail/9n95q1zzpmh4" target="_blank">MPEG-2 Video Extension</a>
							<br><b>Restart your PC after installing the codecs.</b>
						</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 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">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">${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('')


	}),
	/* 31 - Templates
	   Gallery playlist items */
	(function(module, exports) {

		module.exports = (data = {}) => (data.sounds || Player.sounds).map(sound =>
			`<div class="${ns}-gallery-item ${Player.playing && Player.playing.id === sound.id ? 'playing' : ''}" data-id="${sound.id}" draggable="true" title="${sound.filename}">
				<div class="${ns}-gallery-thumb-container">
					<img class="${ns}-gallery-thumb" src="${sound.thumb}" draggable="false">
					<div class="${ns}-gallery-overlay-top">
						<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>
					<span class="${ns}-hover-display">
						<a data-id="${sound.id}" class="fc-sounds-ui-button fc-sounds-item-menu-button fc-sounds-col-auto" draggable="false"> ▼ </a>
					</span>
				</div>
			</div>
		`).join('')
	})
]);