Greasy Fork

Greasy Fork is available in English.

8chan sounds player

Play that faggy music weeb boi

当前为 2025-04-27 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         8chan sounds player
// @version      2.3.0_0010
// @namespace    8chanss
// @description  Play that faggy music weeb boi
// @author       original by: RCC; ported to 8chan by: soundboy_1459944
// @website      http://greasyfork.icu/en/scripts/533468-8chan-sounds-player
// @match        https://8chan.moe/*/res/*
// @match        https://8chan.se/*/res/*
// @connect      4chan.org
// @connect      4channel.org
// @connect      a.4cdn.org
// @connect      8chan.moe
// @connect      8chan.se
// @connect      desu-usergeneratedcontent.xyz
// @connect      arch-img.b4k.co
// @connect      archive-media-0.nyafuu.org
// @connect      4cdn.org
// @connect      a.pomf.cat
// @connect      pomf.cat
// @connect      litter.catbox.moe
// @connect      files.catbox.moe
// @connect      catbox.moe
// @connect      share.dmca.gripe
// @connect      z.zz.ht
// @connect      z.zz.fo
// @connect      zz.ht
// @connect      too.lewd.se
// @connect      lewd.se
// @connect      *
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.xmlHttpRequest
// @grant        GM_addValueChangeListener
// @run-at       document-start
// @license      CC0 1.0
// @icon         data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAOwAAADsABataJCQAAABh0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMS42/U4J6AAAAs9JREFUaEPdmlFu20AMRIX2FL56c73mDgWM1GWXjwI9srQro5apPsxHlhyOPchPPjJ9//g8l779+Gman+crIPofC0wJWRVUp4Ah26dF3AKxmVisoGZ5mzDeI569ImWFcaeRzX/98jZhXCC2XSJinSec+OVtwvgIcY6L+3WecOKXtwnjNN1uN35KiHlEXAYWO8PoUQEcAdO9BfKlI+YRcdnwzBmmLy3gMGqIvyvOFt/JYHFAAYNpQ062xc3bCxgsGnK1IQ6qFXDk9qGwVijgsA7kfCl8ZQsYkiDCVKeAgSMhIVk4ShVw8AWSM4t1wQIG1kCiXOxqFnA4aEiaiUXlAgY3wWAgiwoFDM6CkUAWRQo4HDc88HK5+BNHwudG99OZHlDA4L5hgVbAO7BOuMfofjrTYwoYRATegV2CdcECBimNUxYwCGqcsoBBVsA0YFq5gEFcg1HAtHgBg8QGowaj+gUMQhuMzlXAIPe8BQyiI5zH6X4Dhky6n870jQUIbTA6SwHiAqYNRpULkBUwDZiWLUBQwDTBomYBUoKT/S1ERND+GD1JAY4DC/Rvbz/jSLjH6H4601cX4DLwQP/2BqaEz43upzN9aQHOgpFAFv+wAK4Gp2NwEzzMxJpgUa3AWibWBIsXFbh+cdyFg4YEmlgcXMBuBgtw0JA0F7uaBXAHkuZid3ABg9MVMCUkahbrUgVwJCQnC8cxBUwY1wuwTkiCCFORAuwScr4UvoMLcHePrzJy+1BYjymAa/PDHDncEAcDBXg0cARMdxW4fv32Y4dFQ662xc36dzKyzcEUMN1VIMO0ISddcfauArwTctIVZ9P068qvlHewZpth0ZhjudJ3sLx0xD8iLuOb8UgsbVlMgzmWK31vIuZBcbzOoM2YM2ftKCDOXSLiHvGYWKwgZtdoAbHtFSkJMcxivUBss/oFxFBN1b9fV5P/N3v+h/Yz6ePzD965YtNhR5uIAAAAAElFTkSuQmCC
// ==/UserScript==

//kudos to the original sound player by RCC: https://github.com/rcc11/4chan-sounds-player

// config
const media_display_min_height = '25px';
const media_display_max_height = '400px';
const minimized_display_max_height = '200px';
const minimized_display_max_width = '250px';

(function(modules) { // webpackBootstrap
    'use strict';

	// The module cache
	var installedModules = {};

	// The require function
	function __webpack_require__(moduleId) {

		// Check if module is in cache
		if (installedModules[moduleId]) {
			return installedModules[moduleId].exports;
		}
		// Create a new module (and put it into the cache)
		var module = installedModules[moduleId] = {
			i: moduleId,
			l: false,
			exports: {}
		};

		// Execute the module function
		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

		// Flag the module as loaded
		module.l = true;

		// Return the exports of the module
		return module.exports;
	}


	// expose the modules object (__webpack_modules__)
	__webpack_require__.m = modules;

	// expose the module cache
	__webpack_require__.c = installedModules;

	// define getter function for harmony exports
	__webpack_require__.d = function(exports, name, getter) {
		if (!__webpack_require__.o(exports, name)) {
			Object.defineProperty(exports, name, {
				enumerable: true,
				get: getter
			});
		}
	};

	// define __esModule on exports
	__webpack_require__.r = function(exports) {
		if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
			Object.defineProperty(exports, Symbol.toStringTag, {
				value: 'Module'
			});
		}
		Object.defineProperty(exports, '__esModule', {
			value: true
		});
	};

	// create a fake namespace object
	// mode & 1: value is a module id, require it
	// mode & 2: merge all properties of value into the ns
	// mode & 4: return value when already ns object
	// mode & 8|1: behave like require
	__webpack_require__.t = function(value, mode) {
		if (mode & 1) value = __webpack_require__(value);
		if (mode & 8) return value;
		if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
		var ns = Object.create(null);
		__webpack_require__.r(ns);
		Object.defineProperty(ns, 'default', {
			enumerable: true,
			value: value
		});
		if (mode & 2 && typeof value != 'string')
			for (var key in value) __webpack_require__.d(ns, key, function(key) {
				return value[key];
			}.bind(null, key));
		return ns;
	};

	// getDefaultExport function for compatibility with non-harmony modules
	__webpack_require__.n = function(module) {
		var getter = module && module.__esModule ?
			function getDefault() {
				return module['default'];
			} :
			function getModuleExports() {
				return module;
			};
		__webpack_require__.d(getter, 'a', getter);
		return getter;
	};

	// Object.prototype.hasOwnProperty.call
	__webpack_require__.o = function(object, property) {
		return Object.prototype.hasOwnProperty.call(object, property);
	};

	// __webpack_public_path__
	__webpack_require__.p = "";

    // Load entry module and return exports
    return __webpack_require__(__webpack_require__.s = 3);
})
([
    /* 0 - File Parser
        •	parseFileName(): Extracts sound URLs from filenames using regex pattern [sound=URL]
        •	parsePost(): Processes individual posts to find sound files and create play buttons
        •	parseFiles(): Scans the page or specific elements for posts containing sounds
        •	Key Features:
            o	Handles URL decoding
            o	Creates unique IDs for each sound
            o	Generates play links next to sound files
    */
    (function(module, exports) {
        const protocolRE = /^(https?:)?\/\//;
        const filenameRE = /(.*?)[[({](?:audio|sound)[ =:|$](.*?)[\])}]/g;
        const videoFileExtRE = /\.(webm|mp4)$/i;

        let localCounter = 0;

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

        function formatFileTitle(postId, fileIndex, fileSize, filename) {
            // Convert fileSize (assumed to be a string like "99.50 KB" or "1.82 MB") into MB
            let sizeValue = parseFloat(fileSize);
            let sizeInMB = 0;

            if (fileSize.toLowerCase().includes("kb")) {
                sizeInMB = sizeValue / 1024;
            } else if (fileSize.toLowerCase().includes("mb")) {
                sizeInMB = sizeValue;
            }

            // Round up to 1 decimal place
            sizeInMB = Math.ceil(sizeInMB * 10) / 10;

            // Cap anything over 99.5 MB
            // Omit the .0 when it's a whole number like 11.0 MB (11.0 MB → 11 MB).
            let displaySize = sizeInMB > 99.5
            ? "99+ MB"
            : `${(sizeInMB > 9.9 ? ' ' + sizeInMB.toFixed(0): sizeInMB.toFixed(1))} MB`;

            // Extract file extension
            const fileExt = filename.split('.').pop().toLowerCase();
            // Get base filename without extension
            const baseName = filename.replace(/\.[^/.]+$/, "");

            let spaceVar = '0';

            if (fileExt.length > 3) {
                spaceVar = ' ';
            } else if (fileExt.length === 3) {
                spaceVar = '    ';
            } else if (fileExt.length < 3) {
                spaceVar = '&nbsp;&nbsp;&nbsp;&nbsp;;&nbsp;&nbsp;&nbsp;';
            }

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


        function getPostNumber(postElement) {
            // First try to get it from the element's ID
            /*if (postElement.id && /^\d+$/.test(postElement.id)) {
                return postElement.id;
            }*/

            // If not found in ID, look for the linkQuote element
            const linkQuote = postElement.querySelector('.linkQuote');
            if (linkQuote && linkQuote.textContent && /^\d+$/.test(linkQuote.textContent)) {
                return linkQuote.textContent;
            }

            // Fallback to a generated ID if nothing else works
            return 'idGrabFailed';
        }

        function parseFileName(filename, image, post, thumb, imageMD5, fileIndex, fileSize) {
            if (!filename) return [];
            filename = filename.replace(/-/, '/');

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

            // If we found sound tags, process them and ignore video files
            if (matches.length) {
                const defaultName = formatFileTitle(post, fileIndex, fileSize, filename);

                return matches.reduce((sounds, match, i) => {
                    let src = match[2];
                    const id = post + ':' + fileIndex + ':' + i;
                    const title = match[1].trim() || defaultName + (matches.length > 1 ? ` (${i + 1})` : '');

                    try {
                        // Fix for Firefox issue: replace underscores that were mistakenly used instead of percent-encoding
                        if (src.includes('_') && !src.includes('%')) {
                            src = src.replace(/_/g, '%');
                        }
                        if (src.includes('%')) {
                            src = decodeURIComponent(src);
                        }

                        if (src.match(protocolRE) === null) {
                            src = (location.protocol + '//' + src);
                        }
                    } catch (error) {
                        return sounds;
                    }

                    const sound = {
                        src,
                        id,
                        title: formatFileTitle(post, fileIndex, fileSize, filename),
                        post,
                        image,
                        filename,
                        thumb,
                        imageMD5,
                        isVideo: false,
                        hasSoundTag: true,
                        fileIndex: fileIndex,
                    };
                    Player.acceptedSound(sound) && sounds.push(sound);
                    return sounds;
                }, []);
            }

            // If no sound tags found, check for video files
            const isVideoFile = videoFileExtRE.test(filename);
            if (isVideoFile) {
                const id = post + ':' + fileIndex + ':0';
                return [{
                    src: image, // Use the image URL as src for video files
                    id,
                    title: formatFileTitle(post, fileIndex, fileSize, filename),
                    post,
                    image,
                    filename,
                    thumb,
                    imageMD5,
                    type: filename.endsWith('.webm') ? 'video/webm' : 'video/mp4',
                    isVideo: true,
                    hasSoundTag: false,
                    fileIndex: fileIndex
                }];
            }

            return [];
        }

        function parsePost(post, skipRender) {
            try {
                // Get the actual post number for this post
                const postNumber = getPostNumber(post);
                if (!postNumber) return;

                // If there are existing play links, just reconnect their handlers
                const existingLinks = post.querySelectorAll(`.${ns}-play-link`);
                if (existingLinks.length > 0) {
                    existingLinks.forEach(link => {
                        const id = link.getAttribute('data-id');
                        link.onclick = () => Player.play(Player.sounds.find(sound => sound.id === id));
                    });
                    return;
                }

                // Get all file containers in the post
                const fileContainers = post.querySelectorAll('.uploadCell');
                if (!fileContainers || fileContainers.length === 0) return;

                let allSounds = [];

                // Process each file in the post
                fileContainers.forEach((container, fileIndex) => {
                    let filename = null;
                    let fileLink = null;
                    let fileSize = "0 KB";

                    // Try to get filename from various locations
                    const originalNameLink = container.querySelector('.originalNameLink');
                    if (originalNameLink) {
                        filename = getFullFilename(originalNameLink);
                    }

                    // Get file size if available
                    const sizeLabel = container.querySelector('.sizeLabel');
                    if (sizeLabel) {
                        fileSize = sizeLabel.textContent.trim();
                    }

                    // If no filename found via standard selectors, try to find file links
                    if (!filename) {
                        const fileLinkEl = container.querySelector('.nameLink');
                        if (fileLinkEl) {
                            fileLink = fileLinkEl.href;
                            filename = fileLink.split('/').pop();
                        }
                    }

                    if (!filename) return;

                    const fileThumb = container.querySelector('.imgLink');
                    const imageSrc = fileThumb && fileThumb.href;
                    const thumbImg = fileThumb && fileThumb.querySelector('img');
                    const thumbSrc = thumbImg && thumbImg.src;
                    const md5Match = thumbImg && thumbImg.src.match(/\/\.media\/(t_)?([a-f0-9]+)/);
                    const imageMD5 = md5Match && md5Match[2];

                    const sounds = parseFileName(filename, imageSrc, postNumber, thumbSrc, imageMD5, fileIndex, fileSize);
                    if (!sounds.length) return;

                    allSounds = allSounds.concat(sounds);

                    // Create play link for this file
                    const firstID = sounds[0].id;
                    const text = '▶︎';
                    const clss = `${ns}-play-link`;
                    let playLinkParent = container.querySelector('.uploadDetails') ||
                        container.querySelector('.fileLink') ||
                        container.querySelector('.fileText') ||
                        container; // Fallback to the container itself

                    if (playLinkParent) {
                        const playLink = document.createElement('a');
                        playLink.href = "javascript:;";
                        playLink.className = clss;
                        playLink.setAttribute('data-id', firstID);
                        playLink.textContent = text;
                        playLink.title = 'play';
                        playLink.style.display = 'inline-block'; // Ensure the link is displayed inline
                        playLink.style.marginLeft = '3px'; // Add some spacing
                        playLink.onclick = () => Player.play(Player.sounds.find(sound => sound.id === firstID));

                        playLinkParent.appendChild(document.createTextNode(' '));
                        playLinkParent.appendChild(playLink);
                    }
                });

                if (allSounds.length === 0) return;

                allSounds.forEach(sound => Player.add(sound, skipRender));
                return allSounds.length > 0;
            } catch (err) {
                console.error('[8chan sounds player] Error parsing post:', err);
            }
        }

        function parseFiles(target, postRender) {
            let addedSounds = false;
            let posts = target.classList && target.classList.contains('postCell') ?
                [target] :
            target.querySelectorAll('.innerOP, .innerPost');

            posts.forEach(post => parsePost(post, postRender) && (addedSounds = true));

            if (addedSounds && postRender && Player.container) {
                Player.playlist.render();
            }
        }

        module.exports = {
            parseFiles,
            parsePost,
            parseFileName
        };
    }),
	/* 1 - Settings Configuration
        •	Contains all default configuration options for the player:
            o	Playback settings (shuffle, repeat)
            o	UI settings (view styles, hover images)
            o	Keybindings
            o	Allowed hosts list
            o	Color schemes
            o	Template layouts
        •	Defines the structure for:
            o	Header/footer/row templates
            o	Hotkey bindings
            o	Player appearance settings
    */
	(function(module, exports) {

		module.exports = [{
				property: 'shuffle',
				default: false
			},
			{
				property: 'repeat',
				default: 'all'
			},
			{
				property: 'viewStyle',
				default: 'playlist'
			},
			{
				property: 'hoverImages',
				default: false
			},
			{
				property: 'preventHoverImagesFor',
				default: [],
				save: false
			},
			{
				property: 'autoshow',
				default: true,
				title: 'Autoshow',
				description: 'Automatically show the player when the thread contains sounds.',
				showInSettings: true,
				settings: [{
					title: 'Enabled'
				}]
			},
			{
				property: 'pauseOnHide',
				default: true,
				title: 'Pause on hide',
				description: 'Pause the player when it\'s hidden.',
				showInSettings: true,
				settings: [{
					title: 'Enabled'
				}]
			},
			{
				title: 'Minimised Display',
				description: 'Optional displays for when the player is minimised.',
				settings: [{
						property: 'pip',
						title: 'Thumbnail',
						description: 'Display a fixed thumbnail of the playing sound in the bottom right of the thread.',
						default: true,
						showInSettings: true
					},
					{
						property: 'maxPIPWidth',
						title: 'Max Width',
						description: 'Maximum width for the thumbnail.',
						default: '150px',
						updateStylesheet: true,
						showInSettings: true
					},
					{
						property: 'chanXControls',
						title: '4chan X Header Controls',
						description: 'Show playback controls in the 4chan X header. Customise the template below.',
						showInSettings: isChanX,
						options: {
							always: 'Always',
							closed: 'Only with the player closed',
							never: 'Never'
						}
					}
				]
			},
			{
				property: 'limitPostWidths',
				title: 'Limit Post Width',
				description: 'Limit the width of posts so they aren\'t hidden under the player.',
				showInSettings: true,
				settings: [{
						property: 'limitPostWidths',
						title: 'Enabled',
						default: false
					},
					{
						property: 'minPostWidth',
						title: 'Minimum Width',
						default: '50%'
					}
				]
			},
			{
				property: 'showSoundTagOnly',
				default: false,
				title: 'Show Sound Tag Posts Only',
				description: 'When enabled, only posts with [sound=URL] tags will be displayed',
				showInSettings: true,
				settings: [{
					title: 'Enabled'
				}]
			},
			{
				property: 'threadsViewStyle',
				title: 'Threads View',
				description: 'How threads in the threads view are listed.',
				showInSettings: true,
				settings: [{
					title: 'Display',
					default: 'table',
					options: {
						table: 'Table',
						board: 'Board'
					}
				}]
			},
			{
				title: 'Keybinds',
				showInSettings: true,
				description: 'Enable keyboard shortcuts.',
				format: 'hotkeys.stringifyKey',
				parse: 'hotkeys.parseKey',
				class: `${ns}-key-input`,
				property: 'hotkey_bindings',
				settings: [{
						property: 'hotkeys',
						default: 'open',
						handler: 'hotkeys.apply',
						title: 'Enabled',
						format: null,
						parse: null,
						class: null,
						options: {
							always: 'Always',
							open: 'Only with the player open',
							never: 'Never'
						}
					},
					{
						property: 'hotkey_bindings.playPause',
						title: 'Play/Pause',
						keyHandler: 'togglePlay',
						ignoreRepeat: true,
						default: {
							key: ' '
						}
					},
					{
						property: 'hotkey_bindings.previous',
						title: 'Previous',
						keyHandler: 'previous',
						ignoreRepeat: true,
						default: {
							key: 'arrowleft'
						}
					},
					{
						property: 'hotkey_bindings.next',
						title: 'Next',
						keyHandler: 'next',
						ignoreRepeat: true,
						default: {
							key: 'arrowright'
						}
					},
					{
						property: 'hotkey_bindings.volumeUp',
						title: 'Volume Up',
						keyHandler: 'hotkeys.volumeUp',
						default: {
							shiftKey: true,
							key: 'arrowup'
						}
					},
					{
						property: 'hotkey_bindings.volumeDown',
						title: 'Volume Down',
						keyHandler: 'hotkeys.volumeDown',
						default: {
							shiftKey: true,
							key: 'arrowdown'
						}
					},
					{
						property: 'hotkey_bindings.toggleFullscreen',
						title: 'Toggle Fullscreen',
						keyHandler: 'display.toggleFullScreen',
						default: {
							key: ''
						}
					},
					{
						property: 'hotkey_bindings.togglePlayer',
						title: 'Show/Hide',
						keyHandler: 'display.toggle',
						default: {
							key: 'h'
						}
					},
					{
						property: 'hotkey_bindings.togglePlaylist',
						title: 'Toggle Playlist',
						keyHandler: 'playlist.toggleView',
						default: {
							key: ''
						}
					},
					{
						property: 'hotkey_bindings.scrollToPlaying',
						title: 'Jump To Playing',
						keyHandler: 'playlist.scrollToPlaying',
						default: {
							key: ''
						}
					},
					{
						property: 'hotkey_bindings.toggleHoverImages',
						title: 'Toggle Hover Images',
						keyHandler: 'playlist.toggleHoverImages',
						default: {
							key: ''
						}
					}
				]
			},
			{
				property: 'allow',
				title: 'Allowed Hosts',
				description: 'Which domains sources are allowed to be loaded from.',
				default: [
					'4cdn.org',
                    '8chan.se',
                    '8chan.moe',
					'catbox.moe',
					'dmca.gripe',
					'lewd.se',
					'pomf.cat',
					'zz.ht'
				],
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				showInSettings: true,
				split: '\n'
			},
			{
				property: 'filters',
				default: ['# Image MD5 or sound URL'],
				title: 'Filters',
				description: 'List of URLs or image MD5s to filter, one per line.\nLines starting with a # will be ignored.',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				showInSettings: true,
				split: '\n'
			},
			{
				property: 'headerTemplate',
				title: 'Header Contents',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				//default: 'repeat-button shuffle-button hover-images-button playlist-button\nsound-name\nadd-button reload-button threads-button settings-button close-button',
				default: 'repeat-button |&nbsp; shuffle-button |&nbsp; hover-images-button |&nbsp; playlist-button\nsound-name\n reload-button settings-button close-button',
				showInSettings: 'textarea',
			},
			{
				property: 'rowTemplate',
				title: 'Row Contents',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				default: 'sound-name h:{menu-button}',
				showInSettings: 'textarea'
			},
			{
				property: 'footerTemplate',
				title: 'Footer Contents',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				default: 'playing-button:"sound-index /" sound-count files\n' +
					'p:{\n' +
					'	<div style="float: right; margin-right: .5rem">\n' +
                    '      sound-tag-toggle-button:"[ST]"' +
					'		post-link\n' +
					'		Open [ image-link sound-link ]\n' +
					'		Download [ dl-image-button dl-sound-button ]\n' +
					'	</div>\n' +
                    '}',
				description: 'Template for the footer contents',
				showInSettings: 'textarea',
				attrs: 'style="height:120px;"'
			},
			{
				property: 'chanXTemplate',
				title: '4chan X Header Controls',
				default: 'p:{\n\tpost-link:"sound-name"\n\tprev-button\n\tplay-button\n\tnext-button\n\tsound-current-time / sound-duration\n}',
				actions: [{
					title: 'Reset',
					handler: 'settings.reset'
				}],
				showInSettings: 'textarea'
			},
			{
				title: 'Colors',
				showInSettings: true,
				property: 'colors',
				updateStylesheet: true,
				actions: [{
					title: 'Match Theme',
					handler: 'settings.forceBoardTheme'
				}],
				// These colors will be overriden with the theme defaults at initialization.
				settings: [{
						property: 'colors.text',
						default: '#000000',
						title: 'Text Color'
					},
					{
						property: 'colors.background',
						default: '#d6daf0',
						title: 'Background Color'
					},
					{
						property: 'colors.border',
						default: '#b7c5d9',
						title: 'Border Color'
					},
					{
						property: 'colors.odd_row',
						default: '#d6daf0',
						title: 'Odd Row Color',
					},
					{
						property: 'colors.even_row',
						default: '#b7c5d9',
						title: 'Even Row Color'
					},
					{
						property: 'colors.playing',
						default: '#98bff7',
						title: 'Playing Row Color'
					},
					{
						property: 'colors.dragging',
						default: '#c396c8',
						title: 'Dragging Row Color'
					}
				]
/*
                settings: [{
						property: 'colors.text',
						default: '#FFFFFF',
						title: 'Text Color'
					},
					{
						property: 'colors.background',
						default: '#282A2E',
						title: 'Background Color'
					},
					{
						property: 'colors.border',
						default: '#C5C8C6',
						title: 'Border Color'
					},
					{
						property: 'colors.odd_row',
						default: '#232323',
						title: 'Odd Row Color',
					},
					{
						property: 'colors.even_row',
						default: '#3A3A3A',
						title: 'Even Row Color'
					},
					{
						property: 'colors.playing',
						default: '#1B4444',
						title: 'Playing Row Color'
					},
					{
						property: 'colors.dragging',
						default: '#22AAAA',
						title: 'Dragging Row Color'
					}
				]*/
			},

		];


	}),
	/* 2 - Core Player Setup
        •	Initializes the main Player object with:
            o	Component references (controls, playlist, etc.)
            o	Template system
            o	Event system
        •	Key functions:
            o	initialize(): Bootstraps all components
            o	compareIds(): For sorting sounds
            o	acceptedSound(): Validates URLs against allowlist
            o	syncTab(): Handles cross-tab synchronization
    */
	(function(module, exports, __webpack_require__) {

		const components = {
			// Settings must be first.
			settings: __webpack_require__(5),
			controls: __webpack_require__(6),
			display: __webpack_require__(7),
			events: __webpack_require__(8),
			footer: __webpack_require__(9),
			header: __webpack_require__(10),
			hotkeys: __webpack_require__(11),
			minimised: __webpack_require__(12),
			playlist: __webpack_require__(13),
			position: __webpack_require__(14),
			threads: __webpack_require__(15),
			userTemplate: __webpack_require__(17)
		};

		// Create a global ref to the player.
		const Player = window.Player = module.exports = {
			//ns,
			audio: new Audio(),
			sounds: [],
			isHidden: true,
			container: null,
			ui: {},

			// Build the config from the default
			config: {},

			// Helper function to query elements in the player.
			$: (...args) => Player.container && Player.container.querySelector(...args),
			$all: (...args) => Player.container && Player.container.querySelectorAll(...args),

			// Store a ref to the components so they can be iterated.
			components,

			// Get all the templates.
			templates: {
				body: __webpack_require__(19),
				controls: __webpack_require__(20),
				css: __webpack_require__(21),
				footer: __webpack_require__(22),
				header: __webpack_require__(23),
				itemMenu: __webpack_require__(24),
				list: __webpack_require__(25),
				player: __webpack_require__(26),
				settings: __webpack_require__(27),
				threads: __webpack_require__(28),
				threadBoards: __webpack_require__(29),
				threadList: __webpack_require__(30)
			},

			/**
			 * Set up the player.
			 */
			initialize: async function initialize() {
				if (Player.initialized) {
					return;
				}
				Player.initialized = true;
				try {
					Player.sounds = [];
					// Run the initialisation for each component.
					for (let name in components) {
						components[name].initialize && await components[name].initialize();
					}

					if (!is4chan) {
						// Add a sounds link in the nav for archives
						const nav = document.querySelector('.navbar-inner .nav:nth-child(2)');
						const li = createElement('<li><a href="javascript:;">Sounds</a></li>', nav);
						li.children[0].addEventListener('click', Player.display.toggle);
					} else if (isChanX) {
						// If it's already known that 4chan X is running then setup the button for it.
						Player.display.initChanX();
					} else {
						// Add the [Sounds] link in the top and bottom nav.
						document.querySelectorAll('#settingsWindowLink, #settingsWindowLinkBot').forEach(function(link) {
							const showLink = createElement('<a href="javascript:;">Sounds</a>', null, {
								click: Player.display.toggle
							});
							link.parentNode.insertBefore(showLink, link);
							link.parentNode.insertBefore(document.createTextNode('] ['), link);
						});
					}

					// Render the player, but not neccessarily show it.
					Player.display.render();
                    // Add this line to automatically show the player
                    Player.display.show();
				} catch (err) {
					Player.logError('There was an error initialzing the sound player. Please check the console for details.');
					console.error('[8chan sounds player]', err);
					// Can't recover so throw this error.
					throw err;
				}
			},

			/**
			 * Compare two ids for sorting.
			 */
			compareIds: function(a, b) {
				const [aPID, aSID] = a.split(':');
				const [bPID, bSID] = b.split(':');
				const postDiff = aPID - bPID;
				return postDiff !== 0 ? postDiff : aSID - bSID;
			},

			/**
			 * Check whether a sound src and image are allowed and not filtered.
			 */
			acceptedSound: function({
				src,
				imageMD5
			}) {
				try {
					const link = new URL(src);
					const host = link.hostname.toLowerCase();
					return !Player.config.filters.find(v => v === imageMD5 || v === host + link.pathname) &&
						Player.config.allow.find(h => host === h || host.endsWith('.' + h));
				} catch (err) {
					return false;
				}
			},

			/**
			 * Listen for changes
			 */
			syncTab: (property, callback) => GM_addValueChangeListener(property, (_prop, oldValue, newValue, remote) => {
				remote && callback(newValue, oldValue);
			}),

			/**
			 * Send an error notification event.
			 */
			logError: function(message, type = 'error') {
				console.error(message);
				document.dispatchEvent(new CustomEvent('CreateNotification', {
					bubbles: true,
					detail: {
						type: type,
						content: message,
						lifetime: 5
					}
				}));
			}
		};

		// Add each of the components to the player.
		for (let name in components) {
			Player[name] = components[name];
			(Player[name].atRoot || []).forEach(k => Player[k] = Player[name][k]);
		}


	}),
    /* 3 - Main Entry Point
        •	Initialization sequence:
            a.	Waits for DOM/4chan X readiness
            b.	Sets up mutation observer for dynamic content
            c.	Triggers initial page scan
        •	Handles both:
            o	Native 4chan interface
            o	4chan X extension environment
    */
    (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        __webpack_require__.r(__webpack_exports__);
        const _globals__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4);
        const _player__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
        const _file_parser__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(0);

        async function doInit() {
            setTimeout(async function() {
                await _player__WEBPACK_IMPORTED_MODULE_1__.initialize();
                Player.set('showSoundTagOnly', false); // Add this line

                // Initialize header and footer buttons
                _player__WEBPACK_IMPORTED_MODULE_1__.display.initHeader();
                _player__WEBPACK_IMPORTED_MODULE_1__.display.initFooter();

                // Parse existing posts
                _file_parser__WEBPACK_IMPORTED_MODULE_2__.parseFiles(document.body, true);

                // Add sounds link to 8chan navigation
                const nav = document.querySelector('.threadBottom .innerUtility');
                if (nav && !document.querySelector('.innerUtility a[href="javascript:;"]')) {
                    const li = createElement('<a href="javascript:;">Sounds</a>', nav);
                    nav.insertBefore(document.createTextNode(' ['), li);
                    nav.insertBefore(li, nav.querySelector('.archiveLinkThread'));
                    nav.insertBefore(document.createTextNode('] '), nav.querySelector('.archiveLinkThread'));
                    li.addEventListener('click', _player__WEBPACK_IMPORTED_MODULE_1__.display.toggle);
                }

                _file_parser__WEBPACK_IMPORTED_MODULE_2__.parseFiles(document.body, true);

                // Set up mutation observer
                const observer = new MutationObserver(function(mutations) {
                    mutations.forEach(function(mutation) {
                        if (mutation.type === 'childList') {
                            mutation.addedNodes.forEach(function(node) {
                                if (node.nodeType === Node.ELEMENT_NODE) {
                                    _file_parser__WEBPACK_IMPORTED_MODULE_2__.parseFiles(node);
                                }
                            });
                        }
                    });
                });

                observer.observe(document.body, {
                    childList: true,
                    subtree: true
                });
            }, 0);
        }

        document.addEventListener('DOMContentLoaded', doInit);
    }),
	/* 4 - Globals & Utilities
        •	Defines shared utilities:
            o	_set()/_get(): Deep object property access
            o	toDuration(): Formats time (00:00)
            o	timeAgo(): Relative time formatting
            o	createElement(): DOM creation helper
            o	noDefault(): Event handler wrapper
        •	Sets global constants:
            o	ns: Namespace prefix
            o	is4chan/isChanX: Environment detection
            o	Board: Current board name
            o	VERSION
    */
    (function(module, exports) {
        // Update globals for 8chan
        window.ns = 'fc-sounds';
        window.is4chan = false;
        window.isChanX = false;
        window.Board = location.pathname.split('/')[1];

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

		// Load in some glyphs for GUI
		if (!document.querySelector('style[data-open-iconic]')) {
			const style = document.createElement('style');
			style.setAttribute('data-open-iconic', 'true');
			style.textContent = `
				@font-face {
					font-family: 'open-iconic';
					src: url('https://8chan.moe/.static/css/fonts/open-iconic.woff') format('woff');
					font-weight: normal;
					font-style: normal;
				}
				.oi {
					font-family: 'open-iconic' !important;
					speak: none;
					font-style: normal;
					font-weight: normal;
					font-variant: normal;
					text-transform: none;
					line-height: 1;
					-webkit-font-smoothing: antialiased;
					-moz-osx-font-smoothing: grayscale;
					display: inline-block;
					vertical-align: middle;
				}
				/* Individual icon definitions */
				.oi-account-login:before {content:"\\e000";}
				.oi-account-logout:before {content:"\\e001";}
				.oi-action-redo:before {content:"\\e002";}
				.oi-action-undo:before {content:"\\e003";}
				.oi-align-center:before {content:"\\e004";}
				.oi-align-left:before {content:"\\e005";}
				.oi-align-right:before {content:"\\e006";}
				.oi-aperture:before {content:"\\e007";}
				.oi-arrow-bottom:before {content:"\\e008";}
				.oi-arrow-circle-bottom:before {content:"\\e009";}
				.oi-arrow-circle-left:before {content:"\\e00a";}
				.oi-arrow-circle-right:before {content:"\\e00b";}
				.oi-arrow-circle-top:before {content:"\\e00c";}
				.oi-arrow-left:before {content:"\\e00d";}
				.oi-arrow-right:before {content:"\\e00e";}
				.oi-arrow-thick-bottom:before {content:"\\e00f";}
				.oi-arrow-thick-left:before {content:"\\e010";}
				.oi-arrow-thick-right:before {content:"\\e011";}
				.oi-arrow-thick-top:before {content:"\\e012";}
				.oi-arrow-top:before {content:"\\e013";}
				.oi-audio-spectrum:before {content:"\\e014";}
				.oi-audio:before {content:"\\e015";}
				.oi-badge:before {content:"\\e016";}
				.oi-ban:before {content:"\\e017";}
				.oi-bar-chart:before {content:"\\e018";}
				.oi-basket:before {content:"\\e019";}
				.oi-battery-empty:before {content:"\\e01a";}
				.oi-battery-full:before {content:"\\e01b";}
				.oi-beaker:before {content:"\\e01c";}
				.oi-bell:before {content:"\\e01d";}
				.oi-bluetooth:before {content:"\\e01e";}
				.oi-bold:before {content:"\\e01f";}
				.oi-bolt:before {content:"\\e020";}
				.oi-book:before {content:"\\e021";}
				.oi-bookmark:before {content:"\\e022";}
				.oi-box:before {content:"\\e023";}
				.oi-briefcase:before {content:"\\e024";}
				.oi-british-pound:before {content:"\\e025";}
				.oi-browser:before {content:"\\e026";}
				.oi-brush:before {content:"\\e027";}
				.oi-bug:before {content:"\\e028";}
				.oi-bullhorn:before {content:"\\e029";}
				.oi-calculator:before {content:"\\e02a";}
				.oi-calendar:before {content:"\\e02b";}
				.oi-camera-slr:before {content:"\\e02c";}
				.oi-caret-bottom:before {content:"\\e02d";}
				.oi-caret-left:before {content:"\\e02e";}
				.oi-caret-right:before {content:"\\e02f";}
				.oi-caret-top:before {content:"\\e030";}
				.oi-cart:before {content:"\\e031";}
				.oi-chat:before {content:"\\e032";}
				.oi-check:before {content:"\\e033";}
				.oi-chevron-bottom:before {content:"\\e034";}
				.oi-chevron-left:before {content:"\\e035";}
				.oi-chevron-right:before {content:"\\e036";}
				.oi-chevron-top:before {content:"\\e037";}
				.oi-circle-check:before {content:"\\e038";}
				.oi-circle-x:before {content:"\\e039";}
				.oi-clipboard:before {content:"\\e03a";}
				.oi-clock:before {content:"\\e03b";}
				.oi-cloud-download:before {content:"\\e03c";}
				.oi-cloud-upload:before {content:"\\e03d";}
				.oi-cloud:before {content:"\\e03e";}
				.oi-cloudy:before {content:"\\e03f";}
				.oi-code:before {content:"\\e040";}
				.oi-cog:before {content:"\\e041";}
				.oi-collapse-down:before {content:"\\e042";}
				.oi-collapse-left:before {content:"\\e043";}
				.oi-collapse-right:before {content:"\\e044";}
				.oi-collapse-up:before {content:"\\e045";}
				.oi-command:before {content:"\\e046";}
				.oi-comment-square:before {content:"\\e047";}
				.oi-compass:before {content:"\\e048";}
				.oi-contrast:before {content:"\\e049";}
				.oi-copywriting:before {content:"\\e04a";}
				.oi-credit-card:before {content:"\\e04b";}
				.oi-crop:before {content:"\\e04c";}
				.oi-dashboard:before {content:"\\e04d";}
				.oi-data-transfer-download:before {content:"\\e04e";}
				.oi-data-transfer-upload:before {content:"\\e04f";}
				.oi-delete:before {content:"\\e050";}
				.oi-dial:before {content:"\\e051";}
				.oi-document:before {content:"\\e052";}
				.oi-dollar:before {content:"\\e053";}
				.oi-double-quote-sans-left:before {content:"\\e054";}
				.oi-double-quote-sans-right:before {content:"\\e055";}
				.oi-double-quote-serif-left:before {content:"\\e056";}
				.oi-double-quote-serif-right:before {content:"\\e057";}
				.oi-droplet:before {content:"\\e058";}
				.oi-eject:before {content:"\\e059";}
				.oi-elevator:before {content:"\\e05a";}
				.oi-ellipses:before {content:"\\e05b";}
				.oi-envelope-closed:before {content:"\\e05c";}
				.oi-envelope-open:before {content:"\\e05d";}
				.oi-euro:before {content:"\\e05e";}
				.oi-excerpt:before {content:"\\e05f";}
				.oi-expand-down:before {content:"\\e060";}
				.oi-expand-left:before {content:"\\e061";}
				.oi-expand-right:before {content:"\\e062";}
				.oi-expand-up:before {content:"\\e063";}
				.oi-external-link:before {content:"\\e064";}
				.oi-eye:before {content:"\\e065";}
				.oi-eyedropper:before {content:"\\e066";}
				.oi-file:before {content:"\\e067";}
				.oi-fire:before {content:"\\e068";}
				.oi-flag:before {content:"\\e069";}
				.oi-flash:before {content:"\\e06a";}
				.oi-folder:before {content:"\\e06b";}
				.oi-fork:before {content:"\\e06c";}
				.oi-fullscreen-enter:before {content:"\\e06d";}
				.oi-fullscreen-exit:before {content:"\\e06e";}
				.oi-globe:before {content:"\\e06f";}
				.oi-graph:before {content:"\\e070";}
				.oi-grid-four-up:before {content:"\\e071";}
				.oi-grid-three-up:before {content:"\\e072";}
				.oi-grid-two-up:before {content:"\\e073";}
				.oi-hard-drive:before {content:"\\e074";}
				.oi-header:before {content:"\\e075";}
				.oi-headphones:before {content:"\\e076";}
				.oi-heart:before {content:"\\e077";}
				.oi-home:before {content:"\\e078";}
				.oi-image:before {content:"\\e079";}
				.oi-inbox:before {content:"\\e07a";}
				.oi-infinity:before {content:"\\e07b";}
				.oi-info:before {content:"\\e07c";}
				.oi-italic:before {content:"\\e07d";}
				.oi-justify-center:before {content:"\\e07e";}
				.oi-justify-left:before {content:"\\e07f";}
				.oi-justify-right:before {content:"\\e080";}
				.oi-key:before {content:"\\e081";}
				.oi-laptop:before {content:"\\e082";}
				.oi-layers:before {content:"\\e083";}
				.oi-lightbulb:before {content:"\\e084";}
				.oi-link-broken:before {content:"\\e085";}
				.oi-link-intact:before {content:"\\e086";}
				.oi-list-rich:before {content:"\\e087";}
				.oi-list:before {content:"\\e088";}
				.oi-location:before {content:"\\e089";}
				.oi-lock-locked:before {content:"\\e08a";}
				.oi-lock-unlocked:before {content:"\\e08b";}
				.oi-loop-circular:before {content:"\\e08c";}
				.oi-loop-square:before {content:"\\e08d";}
				.oi-loop:before {content:"\\e08e";}
				.oi-magnifying-glass:before {content:"\\e08f";}
				.oi-map-marker:before {content:"\\e090";}
				.oi-map:before {content:"\\e091";}
				.oi-media-pause:before {content:"\\e092";}
				.oi-media-play:before {content:"\\e093";}
				.oi-media-record:before {content:"\\e094";}
				.oi-media-skip-backward:before {content:"\\e095";}
				.oi-media-skip-forward:before {content:"\\e096";}
				.oi-media-step-backward:before {content:"\\e097";}
				.oi-media-step-forward:before {content:"\\e098";}
				.oi-media-stop:before {content:"\\e099";}
				.oi-medical-cross:before {content:"\\e09a";}
				.oi-menu:before {content:"\\e09b";}
				.oi-microphone:before {content:"\\e09c";}
				.oi-minus:before {content:"\\e09d";}
				.oi-monitor:before {content:"\\e09e";}
				.oi-moon:before {content:"\\e09f";}
				.oi-move:before {content:"\\e0a0";}
				.oi-musical-note:before {content:"\\e0a1";}
				.oi-paperclip:before {content:"\\e0a2";}
				.oi-pencil:before {content:"\\e0a3";}
				.oi-people:before {content:"\\e0a4";}
				.oi-person:before {content:"\\e0a5";}
				.oi-phone:before {content:"\\e0a6";}
				.oi-pie-chart:before {content:"\\e0a7";}
				.oi-pin:before {content:"\\e0a8";}
				.oi-play-circle:before {content:"\\e0a9";}
				.oi-plus:before {content:"\\e0aa";}
				.oi-power-standby:before {content:"\\e0ab";}
				.oi-print:before {content:"\\e0ac";}
				.oi-project:before {content:"\\e0ad";}
				.oi-pulse:before {content:"\\e0ae";}
				.oi-puzzle-piece:before {content:"\\e0af";}
				.oi-question-mark:before {content:"\\e0b0";}
				.oi-rain:before {content:"\\e0b1";}
				.oi-random:before {content:"\\e0b2";}
				.oi-reload:before {content:"\\e0b3";}
				.oi-resize-both:before {content:"\\e0b4";}
				.oi-resize-height:before {content:"\\e0b5";}
				.oi-resize-width:before {content:"\\e0b6";}
				.oi-rss-alt:before {content:"\\e0b7";}
				.oi-rss:before {content:"\\e0b8";}
				.oi-script:before {content:"\\e0b9";}
				.oi-share-boxed:before {content:"\\e0ba";}
				.oi-share:before {content:"\\e0bb";}
				.oi-shield:before {content:"\\e0bc";}
				.oi-signal:before {content:"\\e0bd";}
				.oi-signpost:before {content:"\\e0be";}
				.oi-sort-ascending:before {content:"\\e0bf";}
				.oi-sort-descending:before {content:"\\e0c0";}
				.oi-spreadsheet:before {content:"\\e0c1";}
				.oi-star:before {content:"\\e0c2";}
				.oi-sun:before {content:"\\e0c3";}
				.oi-tablet:before {content:"\\e0c4";}
				.oi-tag:before {content:"\\e0c5";}
				.oi-tags:before {content:"\\e0c6";}
				.oi-target:before {content:"\\e0c7";}
				.oi-task:before {content:"\\e0c8";}
				.oi-terminal:before {content:"\\e0c9";}
				.oi-text:before {content:"\\e0ca";}
				.oi-thumb-down:before {content:"\\e0cb";}
				.oi-thumb-up:before {content:"\\e0cc";}
				.oi-timer:before {content:"\\e0cd";}
				.oi-transfer:before {content:"\\e0ce";}
				.oi-trash:before {content:"\\e0cf";}
				.oi-underline:before {content:"\\e0d0";}
				.oi-vertical-align-bottom:before {content:"\\e0d1";}
				.oi-vertical-align-center:before {content:"\\e0d2";}
				.oi-vertical-align-top:before {content:"\\e0d3";}
				.oi-video:before {content:"\\e0d4";}
				.oi-volume-high:before {content:"\\e0d5";}
				.oi-volume-low:before {content:"\\e0d6";}
				.oi-volume-off:before {content:"\\e0d7";}
				.oi-warning:before {content:"\\e0d8";}
				.oi-wifi:before {content:"\\e0d9";}
				.oi-wrench:before {content:"\\e0da";}
				.oi-x:before {content:"\\e0db";}
				.oi-yen:before {content:"\\e0dc";}
				.oi-zoom-in:before {content:"\\e0dd";}
				.oi-zoom-out:before {content:"\\e0de";}
			`;
			document.head.appendChild(style);
		}

        // Keep rest of original globals.js content
        window._set = function(object, path, value) {
            const props = path.split('.');
            const lastProp = props.pop();
            const setOn = props.reduce((obj, k) => obj[k] || (obj[k] = {}), object);
            setOn && (setOn[lastProp] = value);
            return object;
        };

		window._get = function(object, path, dflt) {
			const props = path.split('.');
			const lastProp = props.pop();
			const parent = props.reduce((obj, k) => obj && obj[k], object);
			return parent && Object.prototype.hasOwnProperty.call(parent, lastProp) ?
				parent[lastProp] :
				dflt;
		};

		window.toDuration = function(number) {
			number = Math.floor(number || 0);
			let [seconds, minutes, hours] = _duration(0, number);
			seconds < 10 && (seconds = '0' + seconds);
			return (hours ? hours + ':' : '') + minutes + ':' + seconds;
		};

		window.timeAgo = function(date) {
			const [seconds, minutes, hours, days, weeks] = _duration(Math.floor(date), Math.floor(Date.now() / 1000));
			/* _eslint-disable indent */
			return weeks > 1 ? weeks + ' weeks ago' :
				days > 0 ? days + (days === 1 ? ' day' : ' days') + ' ago' :
				hours > 0 ? hours + (hours === 1 ? ' hour' : ' hours') + ' ago' :
				minutes > 0 ? minutes + (minutes === 1 ? ' minute' : ' minutes') + ' ago' :
				seconds + (seconds === 1 ? ' second' : ' seconds') + ' ago';
			/* eslint-enable indent */
		};

		function _duration(from, to) {
			const diff = Math.max(0, to - from);
			return [
				diff % 60,
				Math.floor(diff / 60) % 60,
				Math.floor(diff / 60 / 60) % 24,
				Math.floor(diff / 60 / 60 / 24) % 7,
				Math.floor(diff / 60 / 60 / 24 / 7)
			];
		}

		window.createElement = function(html, parent, events = {}) {
			const container = document.createElement('div');
			container.innerHTML = html;
			const el = container.children[0];
			parent && parent.appendChild(el);
			for (let event in events) {
				el.addEventListener(event, events[event]);
			}
			return el;
		};

		window.createElementBefore = function(html, before, events = {}) {
			const el = createElement(html, null, events);
			before.parentNode.insertBefore(el, before);
			return el;
		};

		window.noDefault = (f, ...args) => e => {
			e.preventDefault();
			const func = typeof f === 'function' ? f : _get(Player, f);
			func(...args);
		};
	}),
	/* 5 - Settings Manager
        •	Manages all user configuration:
            o	load()/save(): Persistent storage
            o	set(): Updates settings with validation
            o	applyBoardTheme(): Matches 4chan's colors
        •	Handles:
            o	Settings UI rendering
            o	Change detection
            o	Cross-tab synchronization
    */
	(function(module, exports, __webpack_require__) {

		const settingsConfig = __webpack_require__(1);

		module.exports = {
			atRoot: ['set'],

			delegatedEvents: {
				click: {
					[`.${ns}-settings .${ns}-heading-action`]: 'settings.handleAction',
				},
				focusout: {
					[`.${ns}-settings input, .${ns}-settings textarea`]: 'settings.handleChange'
				},
				change: {
					[`.${ns}-settings input[type=checkbox], .${ns}-settings select`]: 'settings.handleChange'
				},
				keydown: {
					[`.${ns}-key-input`]: 'settings.handleKeyChange',
				},
				keyup: {
					[`.${ns}-encoded-input`]: 'settings._handleEncoded',
					[`.${ns}-decoded-input`]: 'settings._handleDecoded'
				}
			},

			initialize: async function() {
				// Apply the default board theme as default.
				Player.settings.applyBoardTheme();

				// Apply the default config.
				Player.config = settingsConfig.reduce(function reduceSettings(config, setting) {
					if (setting.settings) {
						setting.settings.forEach(subSetting => {
							let _setting = {
								...setting,
								...subSetting
							};
							_set(config, _setting.property, _setting.default);
						});
						return config;
					}
					return _set(config, setting.property, setting.default);
				}, {});

				// Load the user config.
				await Player.settings.load();

				// Listen for the player closing to apply the pause on hide setting.
				Player.on('hide', function() {
					if (Player.config.pauseOnHide) {
						Player.pause();
					}
				});

				// Listen for changes from other tabs
				Player.syncTab('settings', value => Player.settings.apply(value, {
					bypassSave: true,
					applyDefault: true,
					ignore: ['viewStyle']
				}));
			},

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

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

			applyBoardTheme: function(force) {
                const rootStyles = getComputedStyle(document.documentElement);

                const textColor = rootStyles.getPropertyValue('--text-color').trim();
                let backgroundColor = rootStyles.getPropertyValue('--contrast-color').trim() || rootStyles.getPropertyValue('--background-color').trim() || '#FFFFFF';
                backgroundColor = Player.settings.adjustColor(backgroundColor, { h: 0, s: 0, v: 0 }); // turn to hex to drop alpha from rgba

                const borderColor = rootStyles.getPropertyValue('--border-color').trim();
                const oddRow = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 30, s: 15, v: 0 }) : Player.settings.adjustColor(backgroundColor, { h: 30, s: 0, v: 0 });
                const evenRow = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 0, s: 0, v: 0 }) : Player.settings.adjustColor(backgroundColor, { h: 0, s: 0, v: -6 });
                const playing = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 45, s: 40, v: -20 }) : Player.settings.adjustColor(backgroundColor, { h: -60, s: 20, v: 30 });
                const dragging = Player.settings.isLightColor(backgroundColor) ? Player.settings.adjustColor(backgroundColor, { h: 75, s: 40, v: -20 }) : Player.settings.adjustColor(backgroundColor, { h: -120, s: 20, v: 40 });

                const colorSettingMap = {
                    'colors.text': textColor,
                    'colors.background': backgroundColor,
                    'colors.border': borderColor,
                    'colors.odd_row': oddRow,
                    'colors.even_row': evenRow,
                    'colors.playing': playing,
                    'colors.dragging': dragging
                };

				settingsConfig.find(s => s.property === 'colors').settings.forEach(setting => {
					const updateConfig = force || (setting.default === _get(Player.config, setting.property));
					colorSettingMap[setting.property] && (setting.default = colorSettingMap[setting.property]);
					updateConfig && Player.set(setting.property, setting.default, {
						bypassSave: true,
						bypassRender: true
					});
				});

				// Updated the stylesheet if it exists.
				Player.stylesheet && Player.display.updateStylesheet();

				// Re-render the settings if needed.
				Player.settings.render();
			},

            parseColor: function(color) {
                let result;
                // Check if it's in hex format; Hex: #RGB or #RRGGBB
                if (/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/.test(color)) {
                    let hex = color.slice(1);
                    if (hex.length === 3) {
                        hex = hex.split('').map(x => x + x).join('');
                    }
                    result = [
                        parseInt(hex.slice(0, 2), 16),
                        parseInt(hex.slice(2, 4), 16),
                        parseInt(hex.slice(4, 6), 16)
                    ];
                }
                // Check if it's in rgb format; RGB: rgb(r, g, b)
                else if (/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.test(color)) {
                    result = color.match(/\d+/g).map(Number);
                }
                // Check if it's in rgba format; RGBA: rgba(r, g, b, a)
                else if (/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*(0|1|0?\.\d+)\s*\)$/.test(color)) {
                    let matches = color.match(/\d+(\.\d+)?/g).map(Number);
                    result = matches.slice(0, 3); // Drop alpha
                }
                return result;
            },

            isLightColor: function(color) {
                const rgb = Player.settings.parseColor(color);
                if (!rgb) return false;
                const [r, g, b] = rgb;
                const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
                return luminance > 186;
            },

            adjustColor: function(color, { h = 0, s = 0, v = 0 } = {}) {
                const rgb = Player.settings.parseColor(color);
                if (!rgb) return color;

                const [r, g, b] = rgb.map(c => c / 255);
                const [hVal, sVal, vVal] = Player.settings.rgbToHsv(r, g, b);

                // Adjust HSV
                const newHVal = (hVal * 360 + h) % 360;
                const newSVal = Math.min(1, Math.max(0, sVal + s / 100));
                const newVVal = Math.min(1, Math.max(0, vVal + v / 100));

                // HSV to RGB
                const [r1, g1, b1] = Player.settings.hsvToRgb(newHVal, newSVal, newVVal);

                return `#${Player.settings.toHex(r1)}${Player.settings.toHex(g1)}${Player.settings.toHex(b1)}`;
            },

            rgbToHsv: function(r, g, b) {
                const max = Math.max(r, g, b);
                const min = Math.min(r, g, b);
                let hVal, sVal, vVal = max;
                const d = max - min;

                sVal = max === 0 ? 0 : d / max;

                if (d === 0) {
                    hVal = 0;
                } else {
                    switch (max) {
                        case r: hVal = (g - b) / d + (g < b ? 6 : 0); break;
                        case g: hVal = (b - r) / d + 2; break;
                        case b: hVal = (r - g) / d + 4; break;
                    }
                    hVal /= 6;
                }

                return [hVal, sVal, vVal];
            },

            hsvToRgb: function(h, s, v) {
                const c = v * s;
                const x = c * (1 - Math.abs((h / 60) % 2 - 1));
                const m = v - c;

                let r1, g1, b1;
                if (h < 60) [r1, g1, b1] = [c, x, 0];
                else if (h < 120) [r1, g1, b1] = [x, c, 0];
                else if (h < 180) [r1, g1, b1] = [0, c, x];
                else if (h < 240) [r1, g1, b1] = [0, x, c];
                else if (h < 300) [r1, g1, b1] = [x, 0, c];
                else [r1, g1, b1] = [c, 0, x];

                return [r1 + m, g1 + m, b1 + m];
            },

            toHex: function(c) {
                return Math.round(c * 255).toString(16).padStart(2, '0');
            },


			/**
			 * Update a setting.
			 */
			set: function(property, value, {
				bypassSave,
				bypassRender,
				silent
			} = {}) {
				const previousValue = _get(Player.config, property);
				if (previousValue === value) {
					return;
				}
				_set(Player.config, property, value);
				!silent && Player.trigger('config', property, value, previousValue);
				!silent && Player.trigger('config:' + property, value, previousValue);
				!bypassSave && Player.settings.save();
				!bypassRender && Player.settings.findDefault(property).showInSettings && Player.settings.render();
			},

			/**
			 * Reset a setting to the default value
			 */
			reset: function(property) {
				let settingConfig = Player.settings.findDefault(property);
				Player.set(property, settingConfig.default);
			},

			/**
			 * Persist the player settings.
			 */
			save: function() {
				try {
					// Filter settings that have been modified from the default.
					const settings = settingsConfig.reduce(function _handleSetting(settings, setting) {
						if (setting.settings) {
							setting.settings.forEach(subSetting => _handleSetting(settings, {
								property: setting.property,
								default: setting.default,
								...subSetting
							}));
						} else {
							const userVal = _get(Player.config, setting.property);
							if (userVal !== undefined && userVal !== setting.default) {
								_set(settings, setting.property, userVal);
							}
						}
						return settings;
					}, {});
					// Show the playlist or image view on load, whichever was last shown.
					settings.viewStyle = Player.playlist._lastView;
					// Store the player version with the settings.
					settings.VERSION = window.VERSION;
					// Save the settings.
					return GM.setValue('settings', JSON.stringify(settings));
				} catch (err) {
					Player.logError('There was an error saving the sound player settings. Please check the console for details.');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Restore the saved player settings.
			 */
			load: async function() {
				try {
					let settings = await GM.getValue('settings') || await GM.getValue(ns + '.settings');
					if (settings) {
						Player.settings.apply(settings, {
							bypassSave: true,
							silent: true
						});
					}
				} catch (err) {
					Player.logError('There was an error loading the sound player settings. Please check the console for details.');
					console.error('[4chan sounds player]', err);
				}
			},

			apply: function(settings, opts = {}) {
				if (typeof settings === 'string') {
					settings = JSON.parse(settings);
				}
				settingsConfig.forEach(function _handleSetting(setting) {
					if (setting.settings) {
						return setting.settings.forEach(subSetting => _handleSetting({
							property: setting.property,
							default: setting.default,
							...subSetting
						}));
					}
					if (opts.ignore && opts.ignore.includes(opts.property)) {
						return;
					}
					const value = _get(settings, setting.property, opts.applyDefault ? setting.default : undefined);
					if (value !== undefined) {
						Player.set(setting.property, value, opts);
					}
				});
			},

			/**
			 * Find a setting in the default configuration.
			 */
			findDefault: function(property) {
				let settingConfig;
				settingsConfig.find(function(setting) {
					if (setting.property === property) {
						return settingConfig = setting;
					}
					if (setting.settings) {
						let subSetting = setting.settings.find(_setting => _setting.property === property);
						return subSetting && (settingConfig = {
							...setting,
							settings: null,
							...subSetting
						});
					}
					return false;
				});
				return settingConfig || {
					property
				};
			},

			/**
			 * Toggle whether the player or settings are displayed.
			 */
			toggle: function(e) {
				e && e.preventDefault();
				// Blur anything focused so the change is applied.
				let focused = Player.$(`.${ns}-settings :focus`);
				focused && focused.blur();
				if (Player.config.viewStyle === 'settings') {
					Player.playlist.restore();
				} else {
					Player.display.setViewStyle('settings');
				}
			},

			/**
			 * Handle the user making a change in the settings view.
			 */
			handleChange: function(e) {
				try {
					const input = e.eventTarget;
					const property = input.getAttribute('data-property');
					if (!property) {
						return;
					}
					let settingConfig = Player.settings.findDefault(property);

					// Get the new value of the setting.
					const currentValue = _get(Player.config, property);
					let newValue = input[input.getAttribute('type') === 'checkbox' ? 'checked' : 'value'];

					if (settingConfig.parse) {
						newValue = _get(Player, settingConfig.parse)(newValue);
					}
					if (settingConfig && settingConfig.split) {
						newValue = newValue.split(decodeURIComponent(settingConfig.split));
					}

					// Not the most stringent check but enough to avoid some spamming.
					if (currentValue !== newValue) {
						// Update the setting.
						Player.set(property, newValue, {
							bypassRender: true
						});

						// Update the stylesheet reflect any changes.
						if (settingConfig.updateStylesheet) {
							Player.display.updateStylesheet();
						}
					}

					// Run any handler required by the value changing
					settingConfig && settingConfig.handler && _get(Player, settingConfig.handler, () => null)(newValue);
				} catch (err) {
					Player.logError('There was an error updating the setting. Please check the console for details.');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Converts a key event in an input to a string representation set as the input value.
			 */
			handleKeyChange: function(e) {
				e.preventDefault();
				if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Meta') {
					return;
				}
				e.eventTarget.value = Player.hotkeys.stringifyKey(e);
			},

			/**
			 * Handle an action link next to a heading being clicked.
			 */
			handleAction: function(e) {
				e.preventDefault();
				const property = e.eventTarget.getAttribute('data-property');
				const handlerName = e.eventTarget.getAttribute('data-handler');
				const handler = _get(Player, handlerName);
				handler && handler(property);
			},

			/**
			 * Encode the decoded input.
			 */
			_handleDecoded: function(e) {
				Player.$(`.${ns}-encoded-input`).value = encodeURIComponent(e.eventTarget.value);
			},

			/**
			 * Decode the encoded input.
			 */
			_handleEncoded: function(e) {
				Player.$(`.${ns}-decoded-input`).value = decodeURIComponent(e.eventTarget.value);
			}
		};


	}),
    /* 6 - Playback Controls
        •	Core audio functions:
            o	play()/pause()/togglePlay()
            o	next()/previous(): Track navigation
            o	_movePlaying(): Handles repeat modes
        •	UI controls:
            o	Seek bar handling
            o	Volume control
            o	Progress updates
        •	Video sync for webm files
    */
    (function(module, exports) {
        const progressBarStyleSheets = {};
        let syncInterval;
        let playbackStartTime = null;
        let mediaStartTime = 0;
        let lastSyncTime = null;
        let playbackRate = 1.0;
        let isLoading = false;

        module.exports = {
            atRoot: ['togglePlay', 'play', 'pause', 'next', 'previous'],

            delegatedEvents: {
                click: {
                    [`.${ns}-previous-button`]: () => Player.previous(),
                    [`.${ns}-play-button`]: 'togglePlay',
                    [`.${ns}-next-button`]: () => Player.next(),
                    [`.${ns}-seek-bar`]: 'controls.handleSeek',
                    [`.${ns}-volume-bar`]: 'controls.handleVolume',
                    [`.${ns}-fullscreen-button`]: 'display.toggleFullScreen'
                },
                mousedown: {
                    [`.${ns}-seek-bar`]: () => Player._seekBarDown = true,
                    [`.${ns}-volume-bar`]: () => Player._volumeBarDown = true
                },
                mousemove: {
                    [`.${ns}-seek-bar`]: e => Player._seekBarDown && Player.controls.handleSeek(e),
                    [`.${ns}-volume-bar`]: e => Player._volumeBarDown && Player.controls.handleVolume(e)
                }
            },

            undelegatedEvents: {
                ended: {
                    [`.${ns}-video`]: 'controls.handleSoundEnded'
                },
                mouseleave: {
                    [`.${ns}-seek-bar`]: e => Player._seekBarDown && Player.controls.handleSeek(e),
                    [`.${ns}-volume-bar`]: e => Player._volumeBarDown && Player.controls.handleVolume(e)
                },
                mouseup: {
                    body: () => {
                        Player._seekBarDown = false;
                        Player._volumeBarDown = false;
                    }
                }
            },

            soundEvents: {
                ended: 'controls.handleSoundEnded',
                pause: 'controls.handlePlaybackState',
                play: 'controls.handlePlaybackState',
                seeked: 'controls.handlePlaybackState',
                waiting: 'controls.handlePlaybackState',
                timeupdate: 'controls.updateDuration',
                loadedmetadata: 'controls.updateDuration',
                durationchange: 'controls.updateDuration',
                volumechange: 'controls.updateVolume',
                loadstart: 'controls.pollForLoading',
                error: 'controls.handleSoundError',
                waiting: () => {
                    isLoading = true;
                    Player.controls.updatePlayButtonState();
                },
                canplay: () => {
                    isLoading = false;
                    Player.controls.updatePlayButtonState();
                },
            },

            audioEvents: {
                ended: () => {
                    if (Player.config.repeat === 'one') {
                        Player.controls.handleSoundEnded();
                    } else {
                        Player.next();
                    }
                },
                timeupdate: 'controls.updateDuration',
                loadedmetadata: 'controls.updateDuration',
                durationchange: 'controls.updateDuration',
                volumechange: 'controls.updateVolume',
                loadstart: 'controls.pollForLoading',
                waiting: () => {
                    isLoading = true;
                    Player.controls.updatePlayButtonState();
                },
                canplay: () => {
                    isLoading = false;
                    Player.controls.updatePlayButtonState();
                },
            },

            initialize: function() {
                Player.on('show', () => Player._hiddenWhilePolling && Player.controls.pollForLoading());
                Player.on('hide', () => {
                    Player._hiddenWhilePolling = !!Player._loadingPoll;
                    Player.controls.stopPollingForLoading();
                });
                Player.on('rendered', () => {
                    // Keep track of heavily updated elements.
                    Player.ui.currentTimeBar = Player.$(`.${ns}-seek-bar .${ns}-current-bar`);
                    Player.ui.loadedBar = Player.$(`.${ns}-seek-bar .${ns}-loaded-bar`);

                    // Check for M4A support
                    Player.supportsM4A = Player.controls.checkM4ASupport();

                    Player.on('rendered', () => {
                        Player.ui.currentTimeBar = Player.$(`.${ns}-seek-bar .${ns}-current-bar`);
                        Player.ui.loadedBar = Player.$(`.${ns}-seek-bar .${ns}-loaded-bar`);
                    });

                    // video event listeners
                    /*const video = document.querySelector(`.${ns}-video`);
                    if (video) {
                        Object.entries(Player.controls.soundEvents).forEach(([event, handler]) => {
                            video.addEventListener(event, Player.controls[handler] || Player.controls[handler.split('.')[1]]);
                        });
                    }*/
                    const video = document.querySelector(`.${ns}-video`);
                    if (video) {
                        Object.entries(Player.controls.soundEvents).forEach(([event, handler]) => {
                            // Handle both string paths and direct function references
                            const handlerFn = typeof handler === 'function'
                            ? handler
                            : _get(Player, handler);
                            video.addEventListener(event, handlerFn);
                        });
                    }

                    // audio element event listeners
                    Object.entries(Player.controls.soundEvents).forEach(([event, handler]) => {
                        Player.audio.addEventListener(event, Player.controls[handler]);
                    });

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

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

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

                const video = document.querySelector(`.${ns}-video`);
                const isStaticImage = Player.playing.hasSoundTag &&
                      !Player.playing.isVideo &&
                      !['.webm', '.mp4'].some(ext =>
                                              Player.playing.image.toLowerCase().endsWith(ext));

                // Set loading state and update UI
                Player.controls.isLoading = true;
                Player.controls.updatePlayButtonState();

                // For Case 1 with static images, only control the audio element
                if (isStaticImage) {
                    if (Player.audio.paused) {
                        Player.controls.mediaStartTime = Player.audio.currentTime;
                        Player.controls.playbackStartTime = Date.now();
                        Player.audio.play()
                            .catch(console.error)
                            .finally(() => {
                            Player.controls.isLoading = false;
                            Player.controls.updatePlayButtonState();
                        });
                    } else {
                        Player.audio.pause();
                        Player.controls.playbackStartTime = null;
                        Player.controls.isLoading = false;
                        Player.controls.updatePlayButtonState();
                    }
                }
                // For all other cases, control both audio and video as appropriate
                else {
                    let sound;
                    if (Player.playing.hasSoundTag) {
                        sound = Player.playing.isVideo ? video : Player.audio;
                    } else {
                        sound = Player.playing.isVideo ? video : Player.audio;
                    }

                    if (sound.paused) {
                        Player.controls.mediaStartTime = sound.currentTime;
                        Player.controls.playbackStartTime = Date.now();

                        const playPromises = [sound.play().catch(console.error)];

                        if (video && Player.playing.hasSoundTag && !Player.playing.isVideo) {
                            video.currentTime = sound.currentTime;
                            if (['.webm', '.mp4', '.ogg'].some(ext =>
                                                               Player.playing.image.toLowerCase().endsWith(ext))) {
                                playPromises.push(video.play().catch(console.error));
                            }
                        }

                        Promise.all(playPromises)
                            .finally(() => {
                            Player.controls.isLoading = false;
                            Player.controls.updatePlayButtonState();
                        });
                    } else {
                        sound.pause();
                        if (video) video.pause();
                        Player.controls.playbackStartTime = null;
                        Player.controls.isLoading = false;
                        Player.controls.updatePlayButtonState();
                    }
                }

                Player.controls.handlePlaybackState();
            },

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

            arrayBufferToBase64: function (buffer) {
                let binary = '';
                const bytes = new Uint8Array(buffer);
                for (let i = 0; i < bytes.byteLength; i++) {
                    binary += String.fromCharCode(bytes[i]);
                }
                return btoa(binary);
            },

            detectMimeType: function(url, arrayBuffer) {

                const extension = url.split('.').pop().toLowerCase();
                // Simple detection based on file signatures
                const bytes = new Uint8Array(arrayBuffer);

                // Check by file signature (magic numbers)
                // WebM
                if (bytes.length >= 4 &&
                    bytes[0] === 0x1A &&
                    bytes[1] === 0x45 &&
                    bytes[2] === 0xDF &&
                    bytes[3] === 0xA3) {
                    return 'video/webm';
                }

                // MP4/M4A
                if (bytes.length >= 8 &&
                    ((bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) || // ftyp
                     (bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x00 &&
                      (bytes[3] === 0x18 || bytes[3] === 0x20) && bytes[4] === 0x66 &&
                      bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70))) {
                    // Check if it's audio-only (M4A)
                    if (bytes.length >= 12 &&
                        bytes[8] === 0x4D && bytes[9] === 0x34 && bytes[10] === 0x41 && bytes[11] === 0x20) {
                        return 'audio/mp4'; // M4A
                    }
                    return 'video/mp4';
                }

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

                // OGG
                if (bytes.length >= 4 &&
                    bytes[0] === 0x4F &&
                    bytes[1] === 0x67 &&
                    bytes[2] === 0x67 &&
                    bytes[3] === 0x53) {
                    return 'audio/ogg';
                }

                // Fallback to extension-based detection
                switch(extension) {
                    case 'webm': return 'video/webm';
                    case 'mp4': return 'video/mp4';
                    case 'm4a': return 'audio/mp4';
                    case 'flac': return 'audio/flac';
                    case 'ogg':
                    case 'oga':
                    case 'opus': return 'audio/ogg';
                    default: return 'audio/mpeg'; // default fallback
                }
            },

            checkM4ASupport: function() {
                try {
                    return MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') ||
                        MediaSource.isTypeSupported('audio/mp4');
                } catch (e) {
                    return false;
                }
            },

            /**
			 * Start playback.
			 */
            play: async function(sound) {
                if (!sound && !Player.playing && Player.sounds.length) {
                    sound = Player.sounds[0];
                }
                if (!sound) return;

                isLoading = true;
                Player.controls.updatePlayButtonState();

                try {
                    // Clear previous playback
                    if (Player.playing) {
                        Player.playing.playing = false;
                        const prevSound = Player.playing.isVideo ? document.querySelector(`.${ns}-video`) : Player.audio;
                        prevSound?.pause();

                        // Reset media elements completely
                        prevSound.src = '';
                        prevSound.load();
                    }

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

                    const video = document.querySelector(`.${ns}-video`);
                    video.preload = 'auto';
                    if (video) {
                        video.loop = true;
                    }

                    // Case 1: hasSoundTag and the sound tag is audio (.mp3, .ogg, .m4a, ...)
                    if (sound.hasSoundTag && !sound.isVideo) {
                        try {
                            // First try with GM.xmlHttpRequest
                            const response = await new Promise((resolve, reject) => {
                                GM.xmlHttpRequest({
                                    method: 'GET',
                                    url: sound.src,
                                    responseType: 'arraybuffer',
                                    headers: {
                                        'Accept': '*/*'
                                    },
                                    onload: resolve,
                                    onerror: reject,
                                    ontimeout: reject,
                                    timeout: 60000
                                });
                            });

                            if (response.status >= 400) {
                                throw new Error(`Failed to fetch media: ${response.statusText}`);
                            }

                            // Detect MIME type and convert to data URL
                            const mimeType = await Player.controls.detectMimeType(sound.src, response.response);
                            const base64 = await Player.controls.arrayBufferToBase64(response.response);
                            const dataUrl = `data:${mimeType};base64,${base64}`;

                            Player.controls.mediaStartTime = 0;
                            Player.controls.playbackStartTime = Date.now();

                            // Special handling for M4A files on some browsers
                            if (mimeType === 'audio/mp4' && !MediaSource.isTypeSupported('audio/mp4')) {
                                // Fallback to regular audio element for unsupported M4A
                                Player.audio.src = sound.src;
                            } else {
                                Player.audio.src = dataUrl;
                            }

                            // For OGG files, ensure we're using the correct codec
                            if (mimeType === 'audio/ogg') {
                                Player.audio.type = 'audio/ogg; codecs="vorbis"';
                            }

                            // Play audio
                            await Player.audio.play();

                            // Handle video/image element carefully for Case 1
                            try {
                                // Check if the image is actually a supported video format
                                const imageExt = sound.image.split('.').pop().toLowerCase();
                                if (['webm', 'mp4', 'ogg'].includes(imageExt)) {
                                    video.src = sound.image; // Use .image for video if it's a supported format
                                    video.muted = true;
                                    video.currentTime = Player.audio.currentTime;
                                    video.play().catch(e => {
                                        console.log('Video playback failed, falling back to empty source:', e);
                                        video.src = '';
                                    });
                                } else {
                                    // For unsupported formats like GIF, don't try to play them
                                    video.src = '';
                                }
                            } catch (videoErr) {
                                console.log('Error setting up video element:', videoErr);
                                video.src = '';
                            }
                        } catch (err) {
                            console.error('Failed to fetch via GM_xmlhttpRequest, trying fallback:', err);
                            // Fallback to direct audio playback
                            Player.audio.src = sound.src;
                            Player.controls.mediaStartTime = 0;
                            Player.controls.playbackStartTime = Date.now();
                            await Player.audio.play();
                            video.src = ''; // Don't try to play unsupported formats in fallback
                        }
                    }
                    // Case 2: hasSoundTag and the sound tag is video (.webm, .mp4)
                    else if (sound.hasSoundTag && sound.isVideo) {
                        try {
                            // First try with GM.xmlHttpRequest
                            const response = await new Promise((resolve, reject) => {
                                GM.xmlHttpRequest({
                                    method: 'GET',
                                    url: sound.src,
                                    responseType: 'arraybuffer',
                                    headers: {
                                        'Accept': '*/*'
                                    },
                                    onload: resolve,
                                    onerror: reject,
                                    ontimeout: reject,
                                    timeout: 60000
                                });
                            });

                            if (response.status >= 400) {
                                throw new Error(`Failed to fetch media: ${response.statusText}`);
                            }

                            // Detect MIME type and convert to data URL
                            const mimeType = await Player.controls.detectMimeType(sound.src, response.response);
                            const base64 = await Player.controls.arrayBufferToBase64(response.response);
                            const dataUrl = `data:${mimeType};base64,${base64}`;

                            Player.controls.mediaStartTime = 0;
                            Player.controls.playbackStartTime = Date.now();

                            // Set video source and play
                            video.src = dataUrl;
                            video.muted = false;
                            video.loop = false; // We'll handle looping manually

                            try {
                                await video.play();
                            } catch (videoErr) {
                                console.log('Error with video playback, trying muted:', videoErr);
                                video.muted = true;
                                await video.play();
                            }

                            // Set audio source to same as video
                            Player.audio.src = dataUrl;
                            Player.audio.loop = false; // We'll handle looping manually
                            Player.audio.play().catch(() => {});

                        } catch (err) {
                            console.error('Failed to fetch via GM_xmlhttpRequest, trying fallback:', err);
                            // Fallback to direct video playback
                            video.src = sound.src;
                            Player.controls.mediaStartTime = 0;
                            Player.controls.playbackStartTime = Date.now();
                            video.muted = false;
                            video.loop = false; // We'll handle looping manually

                            try {
                                await video.play();
                            } catch (videoErr) {
                                console.log('Error with video playback, trying muted:', videoErr);
                                video.muted = true;
                                await video.play();
                            }

                            Player.audio.src = sound.src;
                            Player.audio.loop = false; // We'll handle looping manually
                            Player.audio.play().catch(() => {});
                        }
                    }
                    // Case 3: doesn't have hasSoundTag and is .webm or .mp4
                    else if (!sound.hasSoundTag && (sound.src.endsWith('.webm') || sound.src.endsWith('.mp4'))) {
                        // Handle video playback normally
                        Player.audio.src = '';
                        video.src = sound.src;
                        video.muted = false;

                        Player.controls.mediaStartTime = 0;
                        Player.controls.playbackStartTime = Date.now();
                        try {
                            await video.play();
                        } catch (err) {
                            console.log('Error with video playback, trying muted:', err);
                            video.muted = true;
                            await video.play();
                        }
                        // Use .image for video and audio (though audio is empty in this case)
                        Player.audio.src = sound.image || '';
                    }
                    // Fallback for other cases (audio files without sound tag)
                    else {
                        Player.audio.src = sound.src;

                        Player.controls.mediaStartTime = 0;
                        Player.controls.playbackStartTime = Date.now();
                        await Player.audio.play();
                        video.src = '';
                    }

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

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

                    Player.controls.handlePlaybackState();
                    return Player.next(); // Skip to next track on error
                }
            },

            /**
			 * Pause playback.
			 */
            pause: function() {

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

                // Determine which element to control based on the cases
                if (Player.playing.hasSoundTag) {
                    // Case 1 or 2: hasSoundTag
                    sound = Player.playing.isVideo ? video : Player.audio;
                } else {
                    // Case 3: no sound tag and is video
                    sound = Player.playing.isVideo ? video : Player.audio;
                }

                sound?.pause();
                if (video) video.pause();

                if (syncInterval) {
                    clearInterval(syncInterval);
                    syncInterval = null;
                }
                Player.controls.playbackStartTime = null;
                Player.controls.handlePlaybackState();
            },
            /**
			 * Play the next sound.
			 */
            next: function(force) {
                if (Player.config.repeat === 'one') {
                    const source = Player.controls.getActiveSound();
                    if (!source || !isFinite(source.duration)) return;
                    const seekTime = 0;
                    // Update playback timing
                    Player.controls.mediaStartTime = seekTime;
                    Player.controls.playbackStartTime = Date.now();
                    // Update media elements
                    source.currentTime = seekTime;
                    if (Player.playing?.hasSoundTag) {
                        const video = document.querySelector(`.${ns}-video`);
                        if (video) video.currentTime = seekTime;
                    }
                    source.play().catch(console.error);
                    Player.controls.handlePlaybackState();
                    return;
                }
                Player.controls._movePlaying(1, force);
            },

            /**
			 * Play the previous sound.
			 */
            previous: function(force) {
                if (Player.config.repeat === 'one') {
                    const source = Player.controls.getActiveSound();
                    if (!source || !isFinite(source.duration)) return;
                    const seekTime = 0;
                    // Update playback timing
                    Player.controls.mediaStartTime = seekTime;
                    Player.controls.playbackStartTime = Date.now();
                    // Update media elements
                    source.currentTime = seekTime;
                    if (Player.playing?.hasSoundTag) {
                        const video = document.querySelector(`.${ns}-video`);
                        if (video) video.currentTime = seekTime;
                    }
                    source.play().catch(console.error);
                    Player.controls.handlePlaybackState();
                    return;
                }
                Player.controls._movePlaying(-1, force);
            },

            _movePlaying: function(direction, force) {
                if (!Player.audio) {
                    return;
                }
                try {
                    // If there's no sound fall out.
                    if (!Player.sounds.length) {
                        return;
                    }
                    // If there's no sound currently playing or it's not in the list then just play the first sound.
                    const currentIndex = Player.sounds.indexOf(Player.playing);
                    if (currentIndex === -1) {
                        return Player.play(Player.sounds[0]);
                    }
                    // Get the next index, either repeating the same, wrapping round to repeat all or just moving the index.
                    const nextIndex = !force && Player.config.repeat === 'one' ?
                          currentIndex :
                    Player.config.repeat === 'all' ?
                          ((currentIndex + direction) + Player.sounds.length) % Player.sounds.length :
                    currentIndex + direction;
                    const nextSound = Player.sounds[nextIndex];
                    nextSound && Player.play(nextSound);
                } catch (err) {
                    Player.logError(`There was an error selecting the ${direction > 0 ? 'next' : 'previous'} track. Please check the console for details.`);
                    console.error('[8chan sounds player]', err);
                }
            },

            getCurrentPlaybackPosition: function() {
                if (!playbackStartTime) {
                    const active = Player.controls.getActiveSound();
                    return active ? active.currentTime : 0;
                }
                const elapsed = (Date.now() - playbackStartTime) / 1000;
                return mediaStartTime + (elapsed * playbackRate);
            },

            syncPlayback: function() {
                if (!playbackStartTime) return;

                const currentTime = Player.controls.getCurrentPlaybackPosition();

                // Only sync if difference is significant
                const syncThreshold = 0.2;

                // Sync audio element
                if (Player.audio && !Player.audio.paused) {
                    if (Math.abs(Player.audio.currentTime - currentTime) > syncThreshold) {
                        Player.audio.currentTime = currentTime;
                    }
                }

                // Sync video element
                const video = document.querySelector(`.${ns}-video`);
                if (video && !video.paused) {
                    const videoDiff = Math.abs(video.currentTime - currentTime);
                    if (videoDiff > 0.1) {
                        //Player.audio.currentTime = currentTime;
                        video.currentTime = currentTime;
                    }
                }
            },

            handlePlaybackState: function() {
                const video = document.querySelector(`.${ns}-video`);
                //const isPlaying = playbackStartTime !== null;
                const isPlaying = !Player.audio.paused || (video && !video.paused);


                // Update all play buttons
                document.querySelectorAll(`.${ns}-play-button .${ns}-play-button-display`).forEach(el => {
                    el.classList.toggle(`${ns}-play`, !isPlaying);
                });

                // Update container state if needed
                if (Player.container) {
                    Player.container.classList.toggle(`${ns}-playing`, isPlaying);
                    Player.container.classList.toggle(`${ns}-paused`, !isPlaying);
                }

                Player.controls.updateDuration();
            },

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

                // Case 1: hasSoundTag and is audio - use audio element
                if (Player.playing?.hasSoundTag && !Player.playing.isVideo) {
                    return Player.audio;
                }

                // Case 2: hasSoundTag and is video - use video element
                if (Player.playing?.hasSoundTag && Player.playing.isVideo) {
                    return video;
                }

                // Case 3: no sound tag and is video - use video element
                if (!Player.playing?.hasSoundTag && Player.playing?.isVideo) {
                    return video;
                }

                // Fallback to audio element
                return Player.audio;
            },

            handleSoundEnded: function() {
              /*if (Player.config.repeat === 'one') {
                    const active = Player.controls.getActiveSound();
                    active.currentTime = 0;
                    Player.controls.mediaStartTime = 0;
                    Player.controls.playbackStartTime = Date.now();
                    active.play().catch(console.error);
                    return;
                }*/
                if (Player.config.repeat === 'one') {
                    const source = Player.controls.getActiveSound();
                    if (!source || !isFinite(source.duration)) return;
                    const seekTime = 0;
                    // Update playback timing
                    Player.controls.mediaStartTime = seekTime;
                    Player.controls.playbackStartTime = Date.now();
                    // Update media elements
                    source.currentTime = seekTime;
                    if (Player.playing?.hasSoundTag) {
                        const video = document.querySelector(`.${ns}-video`);
                        if (video) video.currentTime = seekTime;
                    }
                    source.play().catch(console.error);
                    Player.controls.handlePlaybackState();
                    return;
                }
                Player.next();
            },
            /**
             * Handle sound errors
             */
            handleSoundError: function() {
                const video = document.querySelector(`.${ns}-video`);

                // Clean up blob URLs on error
                if (Player.audio.src && Player.audio.src.startsWith('blob:')) {
                    URL.revokeObjectURL(Player.audio.src);
                    Player.audio.src = '';
                }

                if (Player.playing?.isVideo && video?.error) {
                    console.error('Video error:', video.error);
                    Player.logError('Video playback error.');
                } else if (Player.audio?.error) {
                    console.error('Audio error:', Player.audio.error);
                    Player.logError('Audio playback error.');
                }
            },
            /**
			 * Poll for how much has loaded. I know there's the progress event but it unreliable.
			 */
            pollForLoading: function() {
                Player._loadingPoll = Player._loadingPoll || setInterval(Player.controls.updateLoaded, 1000);
            },

            /**
			 * Stop polling for how much has loaded.
			 */
            stopPollingForLoading: function() {
                Player._loadingPoll && clearInterval(Player._loadingPoll);
                Player._loadingPoll = null;
            },

            /**
			 * Update the loading bar.
			 */
            updateLoaded: function() {
                const active = Player.controls.getActiveSound();
                if (!active || !active.buffered || active.buffered.length === 0) return;

                const length = active.buffered.length;
                const size = (active.buffered.end(length - 1) / active.duration) * 100;

                if (size === 100) {
                    Player.controls.stopPollingForLoading();
                }

                if (Player.ui.loadedBar) {
                    Player.ui.loadedBar.style.width = size + '%';
                }
            },

            /**
			 * Update the seek bar and the duration labels.
			 */
            updateDuration: function() {
                if (!Player.container) return;

                const currentTime = playbackStartTime ? Player.controls.getCurrentPlaybackPosition() :
                (Player.controls.getActiveSound()?.currentTime || 0);
                const duration = Player.controls.getActiveSound()?.duration || 0;

                document.querySelectorAll(`.${ns}-current-time`).forEach(el =>
                                                                         el.innerHTML = toDuration(currentTime));
                document.querySelectorAll(`.${ns}-duration`).forEach(el =>
                                                                     el.innerHTML = toDuration(duration));

                Player.controls.updateProgressBarPosition(
                    `.${ns}-seek-bar`,
                    Player.ui.currentTimeBar,
                    currentTime,
                    duration
                );
            },

            /**
			 * Update the volume bar.
			 */
            updateVolume: function() {
                Player.controls.updateProgressBarPosition(`.${ns}-volume-bar`, Player.$(`.${ns}-volume-bar .${ns}-current-bar`), Player.audio.volume, 1);
            },

            /**
			 * Update a progress bar width. Adjust the margin of the circle so it's contained within the bar at both ends.
			 */
            updateProgressBarPosition: function(id, bar, current, total) {
                current || (current = 0);
                total || (total = 0);
                const ratio = !total ? 0 : Math.max(0, Math.min(((current || 0) / total), 1));
                bar.style.width = (ratio * 100) + '%';
                if (progressBarStyleSheets[id]) {
                    progressBarStyleSheets[id].innerHTML = `${id} .${ns}-current-bar:after {
                        margin-right: ${-0.8 * (1 - ratio)}rem;
                    }`;
                }
            },

            /**
			 * Handle the user interacting with the seek bar.
			 */
            handleSeek: function(e) {
                e.preventDefault();
                const source = Player.controls.getActiveSound();
                if (!source || !isFinite(source.duration)) return;

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

                // Update playback timing
                Player.controls.mediaStartTime = seekTime;
                Player.controls.playbackStartTime = Date.now();

                // Update media elements
                source.currentTime = seekTime;
                if (Player.playing?.hasSoundTag) {
                    const video = document.querySelector(`.${ns}-video`);
                    if (video) video.currentTime = seekTime;
                }
            },

            /**
			 * Handle the user interacting with the volume bar.
			 */
            handleVolume: function(e) {
                e.preventDefault();
                if (!Player.container) {
                    return;
                }
                const ratio = e.offsetX / parseInt(document.defaultView.getComputedStyle(e.eventTarget || e.target).width, 10);
                Player.audio.volume = Math.max(0, Math.min(ratio, 1));
                const video = document.querySelector(`.${ns}-video`);
                if (video) {
                    video.volume = Player.audio.volume;
                }
                Player.controls.updateVolume();
            }
        };
    }),
	/* 7 - Display Management
        •	Player UI lifecycle:
            o	render(): Creates player DOM
            o	show()/hide(): Visibility control
            o	toggleFullScreen()
        •	Handles:
            o	4chan X integration
            o	View style switching
            o	Drag-and-drop for files
    */
    (function(module, exports) {
        module.exports = {
            atRoot: ['show', 'hide'],

            delegatedEvents: {
                click: {
                    [`.${ns}-close-button`]: 'hide'
                },
                fullscreenchange: {
                    [`.${ns}-media`]: 'display._handleFullScreenChange'
                },
                drop: {
                    [`#${ns}-container`]: 'display._handleDrop'
                }
            },

            /**
             * Create the player show/hide button in the 8chan header
             */
            initHeader: function() {
                if (Player.display._initedHeader) {
                    return;
                }

                // Find the header navigation container
                const navOptions = document.querySelector('#navOptionsSpan');
                if (!navOptions) {
                    return;
                }

                Player.display._initedHeader = true;

                // Create the sounds button
                const soundsButton = createElement(`
                    <span>
                        <span>/</span>
                        <a href="javascript:;" title="Toggle sound player" class="coloredIcon" ">
                            Sound Player
                        </a>
                    </span>
                `);

                // Insert before the closing bracket
                navOptions.insertBefore(soundsButton, navOptions.lastElementChild);

                // Add click handler
                soundsButton.querySelector('a').addEventListener('click', Player.display.toggle);

                // Also add to mobile menu
                const mobileMenu = document.querySelector('#sidebar-menu ul');
                if (mobileMenu) {
                    const mobileItem = createElement(`
                        <li>
                            <a href="javascript:;" class="coloredIcon">
                                Sound Player
                            </a>
                        </li>
                    `);
                    mobileMenu.appendChild(mobileItem);
                    mobileItem.querySelector('a').addEventListener('click', Player.display.toggle);
                }
            },

            /**
             * Initialize footer elements
             */
            initFooter: function() {
                if (Player.display._initedFooter) {
                    return;
                }

                // Find the footer navigation container
                const threadBottom = document.querySelector('.threadBottom .innerUtility');
                if (!threadBottom) {
                    return;
                }

                Player.display._initedFooter = true;

                // Check if sounds link already exists
                if (!threadBottom.querySelector('a[href="javascript:;"][onclick]')) {
                    // Create the sounds button
                    const soundsButton = createElement(`
                        <a href="javascript:;" title="Toggle sound player">Sound Player</a>
                    `);

                    // Insert after Catalog link
                    const catalogLink = threadBottom.querySelector('a[href$="catalog.html"]');
                    if (catalogLink) {
                        threadBottom.insertBefore(document.createTextNode(' '), catalogLink.nextSibling);
                        threadBottom.insertBefore(soundsButton, catalogLink.nextSibling);
                        threadBottom.insertBefore(document.createTextNode(' '), catalogLink.nextSibling);
                    } else {
                        // Fallback if catalog link not found
                        threadBottom.insertBefore(document.createTextNode(' '), threadBottom.firstChild);
                        threadBottom.insertBefore(soundsButton, threadBottom.firstChild);
                    }

                    // Add click handler
                    soundsButton.addEventListener('click', Player.display.toggle);
                }
            },

			/**
			 * Render the player.
			 */
			render: async function() {
				try {
					if (Player.container) {
						document.body.removeChild(Player.container);
						document.head.removeChild(Player.stylesheet);
					}

					// Create the main stylesheet.
					Player.display.updateStylesheet();

					// Create the main player. For native threads put it in the threads to get free quote previews.
					const isThread = document.body.classList.contains('is_thread');
					const parent = isThread && !isChanX && document.body.querySelector('.board') || document.body;
					Player.container = createElement(Player.templates.body(), parent);

					Player.trigger('rendered');
				} catch (err) {
					Player.logError('There was an error rendering the sound player. Please check the console for details.');
					console.error('[4chan sounds player]', err);
					// Can't recover, throw.
					throw err;
				}
			},

			updateStylesheet: function() {
				// Insert the stylesheet if it doesn't exist.
				Player.stylesheet = Player.stylesheet || createElement('<style></style>', document.head);
				Player.stylesheet.innerHTML = Player.templates.css();
			},

			/**
			 * Change what view is being shown
			 */
			setViewStyle: function(style) {
				// Get the size and style prior to switching.
				const previousStyle = Player.config.viewStyle;
				const {
					width,
					height
				} = Player.container.getBoundingClientRect();

				// Exit fullscreen before changing to a different view.
				if (style !== 'fullscreen') {
					document.fullscreenElement && document.exitFullscreen();
				}

				// Change the style.
				Player.set('viewStyle', style);
				Player.container.setAttribute('data-view-style', style);

				// Try to reapply the pre change sizing unless it was fullscreen.
				if (previousStyle !== 'fullscreen' || style === 'fullscreen') {
					Player.position.resize(parseInt(width, 10), parseInt(height, 10));
				}
				Player.trigger('view', style, previousStyle);
			},

			/**
			 * Togle the display status of the player.
			 */
			toggle: function(e) {
				e && e.preventDefault();
				if (Player.container.style.display === 'none') {
					Player.show();
				} else {
					Player.hide();
				}
			},

			/**
			 * Hide the player. Stops polling for changes, and pauses the aduio if set to.
			 */
			hide: function(e) {
				if (!Player.container) {
					return;
				}
				try {
					e && e.preventDefault();
					Player.container.style.display = 'none';

					Player.isHidden = true;
					Player.trigger('hide');
				} catch (err) {
					Player.logError('There was an error hiding the sound player. Please check the console for details.');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Show the player. Reapplies the saved position/size, and resumes loaded amount polling if it was paused.
			 */
			show: async function(e) {
				if (!Player.container) {
					return;
				}
				try {
					e && e.preventDefault();
					if (!Player.container.style.display) {
						return;
					}
					Player.container.style.display = null;

					Player.isHidden = false;
					await Player.trigger('show');
				} catch (err) {
					Player.logError('There was an error showing the sound player. Please check the console for details.');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Toggle the video/image and controls fullscreen state
			 */
			toggleFullScreen: async function() {
				if (!document.fullscreenElement) {
					// Make sure the player (and fullscreen contents) are visible first.
					if (Player.isHidden) {
						Player.show();
					}
					Player.$(`.${ns}-media`).requestFullscreen();
				} else if (document.exitFullscreen) {
					document.exitFullscreen();
				}
			},

			/**
			 * Handle file/s being dropped on the player.
			 */
			_handleDrop: function(e) {
				e.preventDefault();
				e.stopPropagation();
				Player.playlist.addFromFiles(e.dataTransfer.files);
			},

			/**
			 * Handle the fullscreen state being changed
			 */
			_handleFullScreenChange: function() {
				if (document.fullscreenElement) {
					Player.display.setViewStyle('fullscreen');
					document.querySelector(`.${ns}-image-link`).removeAttribute('href');
				} else {
					if (Player.playing) {
						document.querySelector(`.${ns}-image-link`).href = Player.playing.image;
					}
					Player.playlist.restore();
				}
			}
		};
	}),
	/* 8 - Event System
        •	Custom event bus with:
            o	Delegated event handling
            o	Audio event bindings
            o	Pub/sub pattern (on/off/trigger)
        •	Manages all player interactions
    */
	(function(module, exports) {

		module.exports = {
			atRoot: ['on', 'off', 'trigger'],

			// Holder of event handlers.
			_events: {},
			_delegatedEvents: {},
			_undelegatedEvents: {},
			_audioEvents: [],

			initialize: function() {
				const eventLocations = {
					Player,
					...Player.components
				};
				const delegated = Player.events._delegatedEvents;
				const undelegated = Player.events._undelegatedEvents;
				const audio = Player.events._audioEvents;

				for (let name in eventLocations) {
					const comp = eventLocations[name];
					for (let evt in comp.delegatedEvents || {}) {
						delegated[evt] || (delegated[evt] = []);
						delegated[evt].push(comp.delegatedEvents[evt]);
					}
					for (let evt in comp.undelegatedEvents || {}) {
						undelegated[evt] || (undelegated[evt] = []);
						undelegated[evt].push(comp.undelegatedEvents[evt]);
					}
					comp.audioEvents && (audio.push(comp.audioEvents));
				}

				Player.on('rendered', function() {
					// Wire up delegated events on the container.
					Player.events.addDelegatedListeners(Player.container, delegated);

					// Wire up undelegated events.
					Player.events.addUndelegatedListeners(document, undelegated);

					// Wire up audio events.
					for (let eventList of audio) {
						for (let evt in eventList) {
							Player.audio.addEventListener(evt, Player.events.getHandler(eventList[evt]));
						}
					}
				});
			},

			/**
			 * Set delegated events listeners on a target
			 */
			addDelegatedListeners(target, events) {
				for (let evt in events) {
					target.addEventListener(evt, function(e) {
						let nodes = [e.target];
						while (nodes[nodes.length - 1] !== target) {
							nodes.push(nodes[nodes.length - 1].parentNode);
						}
						for (let node of nodes) {
							for (let eventList of [].concat(events[evt])) {
								for (let selector in eventList) {
									if (node.matches && node.matches(selector)) {
										e.eventTarget = node;
										let handler = Player.events.getHandler(eventList[selector]);
										// If the handler returns false stop propogation
										if (handler && handler(e) === false) {
											return;
										}
									}
								}
							}
						}
					});
				}
			},

			/**
			 * Set, or reset, directly bound events.
			 */
			addUndelegatedListeners: function(target, events) {
				for (let evt in events) {
					for (let eventList of [].concat(events[evt])) {
						for (let selector in eventList) {
							target.querySelectorAll(selector).forEach(element => {
								const handler = Player.events.getHandler(eventList[selector]);
								element.removeEventListener(evt, handler);
								element.addEventListener(evt, handler);
							});
						}
					}
				}
			},

			/**
			 * Create an event listener on the player.
			 *
			 * @param {String} evt The name of the events.
			 * @param {function} handler The handler function.
			 */
			on: function(evt, handler) {
				Player.events._events[evt] || (Player.events._events[evt] = []);
				Player.events._events[evt].push(handler);
			},

			/**
			 * Remove an event listener on the player.
			 *
			 * @param {String} evt The name of the events.
			 * @param {function} handler The handler function.
			 */
			off: function(evt, handler) {
				const index = Player.events._events[evt] && Player.events._events[evt].indexOf(handler);
				if (index > -1) {
					Player.events._events[evt].splice(index, 1);
				}
			},

			/**
			 * Trigger an event on the player.
			 *
			 * @param {String} evt The name of the events.
			 * @param {*} data Data passed to the handler.
			 */
			trigger: async function(evt, ...data) {
				const events = Player.events._events[evt] || [];
				for (let handler of events) {
					await handler(...data);
				}
			},

			/**
			 * Returns the function of Player referenced by name or a given handler function.
			 * @param {String|Function} handler Name to function on Player or a handler function.
			 */
			getHandler: function(handler) {
				return typeof handler === 'string' ? _get(Player, handler) : handler;
			}
		};


	}),
	/* 9 - Footer Components
        •	Template rendering for:
            o	Footer (status info)
        •	Uses the user-defined templates
    */
	(function(module, exports) {

		module.exports = {
			initialize: function() {
				Player.userTemplate.maintain(Player.footer, 'footerTemplate');
			},

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


	}),
	/* 10 - Header Components
        •	Template rendering for:
            o	Player header (controls)
        •	Uses the user-defined templates
    */
	(function(module, exports) {

		module.exports = {
			initialize: function() {
				Player.userTemplate.maintain(Player.header, 'headerTemplate');
			},

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


	}),
	/* 11 - Hotkey System
        •	Keyboard control:
            o	Binding management
            o	Key event handling
            o	Modifier key support
        •	Configurable activation modes
    */
	(function(module, exports, __webpack_require__) {

		const settingsConfig = __webpack_require__(1);

		module.exports = {
			initialize: function() {
				Player.on('rendered', Player.hotkeys.apply);
			},

			_keyMap: {
				' ': 'space',
				arrowleft: 'left',
				arrowright: 'right',
				arrowup: 'up',
				arrowdown: 'down'
			},

			addHandler: () => {
				Player.hotkeys.removeHandler();
				document.body.addEventListener('keydown', Player.hotkeys.handle);
			},
			removeHandler: () => {
				document.body.removeEventListener('keydown', Player.hotkeys.handle);
			},

			/**
			 * Apply the selecting hotkeys option
			 */
			apply: function() {
				const type = Player.config.hotkeys;
				Player.hotkeys.removeHandler();
				Player.off('show', Player.hotkeys.addHandler);
				Player.off('hide', Player.hotkeys.removeHandler);

				if (type === 'always') {
					// If hotkeys are always enabled then just set the handler.
					Player.hotkeys.addHandler();
				} else if (type === 'open') {
					// If hotkeys are only enabled with the player toggle the handler as the player opens/closes.
					// If the player is already open set the handler now.
					if (!Player.isHidden) {
						Player.hotkeys.addHandler();
					}
					Player.on('show', Player.hotkeys.addHandler);
					Player.on('hide', Player.hotkeys.removeHandler);
				}
			},

			/**
			 * Handle a keydown even on the body
			 */
			handle: function(e) {
				// Ignore events on inputs so you can still type.
				const ignoreFor = ['INPUT', 'SELECT', 'TEXTAREA', 'INPUT'];
				if (ignoreFor.includes(e.target.nodeName) || Player.isHidden && (Player.config.hotkeys !== 'always' || !Player.sounds.length)) {
					return;
				}
				const k = e.key.toLowerCase();
				const bindings = Player.config.hotkey_bindings || {};

				// Look for a matching hotkey binding
				for (let key in bindings) {
					const keyDef = bindings[key];
					const bindingConfig = k === keyDef.key &&
						(!!keyDef.shiftKey === !!e.shiftKey) && (!!keyDef.ctrlKey === !!e.ctrlKey) && (!!keyDef.metaKey === !!e.metaKey) &&
						(!keyDef.ignoreRepeat || !e.repeat) &&
						settingsConfig.find(s => s.property === 'hotkey_bindings').settings.find(s => s.property === 'hotkey_bindings.' + key);

					if (bindingConfig) {
						e.preventDefault();
						return _get(Player, bindingConfig.keyHandler)();
					}
				}
			},

			/**
			 * Turn a hotkey definition or key event into an input string.
			 */
			stringifyKey: function(key) {
				let k = key.key.toLowerCase();
				Player.hotkeys._keyMap[k] && (k = Player.hotkeys._keyMap[k]);
				return (key.ctrlKey ? 'Ctrl+' : '') + (key.shiftKey ? 'Shift+' : '') + (key.metaKey ? 'Meta+' : '') + k;
			},

			/**
			 * Turn an input string into a hotkey definition object.
			 */
			parseKey: function(str) {
				const keys = str.split('+');
				let key = keys.pop();
				Object.keys(Player.hotkeys._keyMap).find(k => Player.hotkeys._keyMap[k] === key && (key = k));
				const newValue = {
					key
				};
				keys.forEach(key => newValue[key.toLowerCase() + 'Key'] = true);
				return newValue;
			},

			volumeUp: function() {
				Player.audio.volume = Math.min(Player.audio.volume + 0.05, 1);
			},

			volumeDown: function() {
				Player.audio.volume = Math.max(Player.audio.volume - 0.05, 0);
			}
		};


	}),
	/* 12 - Minimized UI
        •	Picture-in-picture mode:
            o	Thumbnail display
            o	4chan X header controls
        •	Handles compact view states
    */
	(function(module, exports) {

		module.exports = {
			_showingPIP: false,

			initialize: function() {
				if (isChanX) {
					// Create a reply element to gather the style from
					const a = createElement('<a></a>', document.body);
					const style = document.defaultView.getComputedStyle(a);
					createElement(`<style>.${ns}-chan-x-controls .${ns}-media-control > div { background: ${style.color} }</style>`, document.head);
					// Clean up the element.
					document.body.removeChild(a);

					// Set up the contents and maintain user template changes.
					Player.userTemplate.maintain(Player.minimised, 'chanXTemplate', ['chanXControls'], ['show', 'hide']);
				}
				Player.on('rendered', Player.minimised.render);
				Player.on('show', Player.minimised.hidePIP);
				Player.on('hide', Player.minimised.showPIP);
				Player.on('playsound', Player.minimised.showPIP);
			},

			render: function() {
				if (Player.container && isChanX) {
					let container = document.querySelector(`.${ns}-chan-x-controls`);
					// Create the element if it doesn't exist.
					// Set the user template and control events on it to make all the buttons work.
					if (!container) {
						container = createElementBefore(`<span class="${ns}-chan-x-controls ${ns}-col-auto"></span>`, document.querySelector('#shortcuts').firstElementChild);
						Player.events.addDelegatedListeners(container, {
							click: [Player.userTemplate.delegatedEvents.click, Player.controls.delegatedEvents.click]
						});
					}

					if (Player.config.chanXControls === 'never' || Player.config.chanXControls === 'closed' && !Player.isHidden) {
						return container.innerHTML = '';
					}

					// Render the contents.
					container.innerHTML = Player.userTemplate.build({
						template: Player.config.chanXTemplate,
						sound: Player.playing,
						replacements: {
							'prev-button': `<div class="${ns}-media-control ${ns}-previous-button"><div class="${ns}-previous-button-display"></div></div>`,
							'play-button': `<div class="${ns}-media-control ${ns}-play-button"><div class="${ns}-play-button-display ${!Player.audio || Player.audio.paused ? `${ns}-play` : ''}"></div></div>`,
							'next-button': `<div class="${ns}-media-control ${ns}-next-button"><div class="${ns}-next-button-display"></div></div>`,
							'sound-current-time': `<span class="${ns}-current-time">0:00</span>`,
							'sound-duration': `<span class="${ns}-duration">0:00</span>`
						}
					});
				}
			},

			/**
			 * Move the image to a picture in picture like thumnail.
			 */
			showPIP: function() {
				if (!Player.isHidden || !Player.config.pip || !Player.playing || Player.minimised._showingPIP) {
					return;
				}
				Player.minimised._showingPIP = true;
				const image = document.querySelector(`.${ns}-image-link`);
				document.body.appendChild(image);
				image.classList.add(`${ns}-pip`);
				image.style.bottom = (Player.position.getHeaderOffset().bottom + 10) + 'px';
				// Show the player again when the image is clicked.
				image.addEventListener('click', Player.show);
			},

			/**
			 * Move the image back to the player.
			 */
			hidePIP: function() {
				Player.minimised._showingPIP = false;
				const image = document.querySelector(`.${ns}-image-link`);
				Player.$(`.${ns}-media`).insertBefore(document.querySelector(`.${ns}-image-link`), Player.$(`.${ns}-controls`));
				image.classList.remove(`${ns}-pip`);
				image.style.bottom = null;
				image.removeEventListener('click', Player.show);
			}
		};


	}),
	/* 13 - Playlist Management
        •	Sound collection:
            o	add()/remove()
            o	Drag-and-drop reordering
            o	Filtering
        •	Features:
            o	Hover image previews
            o	Video detection
            o	Playlist navigation
    */
	(function(module, exports, __webpack_require__) {

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

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

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

			undelegatedEvents: {
				mouseenter: {
					[`.${ns}-list-item`]: 'playlist.updateHoverImage'
				},
				mouseleave: {
					[`.${ns}-list-item`]: 'playlist.removeHoverImage'
				}
			},

			initialize: function() {
				// Keep track of the last view style so we can return to it.
				Player.playlist._lastView = Player.config.viewStyle === 'playlist' || Player.config.viewStyle === 'image' ?
					Player.config.viewStyle :
					'playlist';

				Player.on('view', style => {
					// Focus the playing song when switching to the playlist.
					style === 'playlist' && Player.playlist.scrollToPlaying();
					// Track state.
					if (style === 'playlist' || style === 'image') {
						Player.playlist._lastView = style;
					}
				});

				// Update the UI when a new sound plays, and scroll to it.
				Player.on('playsound', sound => {
					Player.playlist.showImage(sound);
					Player.$all(`.${ns}-list-item.playing`).forEach(el => el.classList.remove('playing'));
					Player.$(`.${ns}-list-item[data-id="${Player.playing.id}"]`).classList.add('playing');
					Player.playlist.scrollToPlaying('nearest');
				});

				// Reapply filters when they change
				Player.on('config:filters', Player.playlist.applyFilters);

				// Listen to anything that can affect the display of hover images
				Player.on('config:hoverImages', Player.playlist.setHoverImageVisibility);
				Player.on('menu-open', Player.playlist.setHoverImageVisibility);
				Player.on('menu-close', Player.playlist.setHoverImageVisibility);

                Player.on('config:showSoundTagOnly', Player.playlist.applySoundTagFilter);

				// Maintain changes to the user templates it's dependent values
				Player.userTemplate.maintain(Player.playlist, 'rowTemplate', ['shuffle']);
			},

			/**
			 * Render the playlist.
			 */
			render: function() {
				if (!Player.container) {
					return;
				}
				const container = Player.$(`.${ns}-list-container`);
				container.innerHTML = Player.templates.list();
				Player.events.addUndelegatedListeners(document.body, Player.playlist.undelegatedEvents);
				Player.playlist.hoverImage = Player.$(`.${ns}-hover-image`);
                Player.playlist.applySoundTagFilter(); // Apply filter after rendering
            },

			/**
			 * Restore the last playlist or image view.
			 */
			restore: function() {
				Player.display.setViewStyle(Player.playlist._lastView || 'playlist');
			},

			/**
			 * Update the image displayed in the player.
			 */
			showImage: function(sound, thumb) {
				if (!Player.container) {
					return;
				}
				let isVideo = Player.playlist.isVideo = !thumb && (sound.image.endsWith('.webm') || sound.image.endsWith('.mp4') || sound.type === 'video/webm' || sound.type === 'video/mp4');
				try {
					const container = document.querySelector(`.${ns}-image-link`);
					const img = container.querySelector(`.${ns}-image`);
					const video = container.querySelector(`.${ns}-video`);
					img.src = '';
					img.src = isVideo || thumb ? sound.thumb : sound.image;
					video.src = isVideo ? sound.image : undefined;
                    // Remove this line to prevent href from being added
                    // if (Player.config.viewStyle !== 'fullscreen') {
                    //   container.href = sound.image;
                    // }
					container.classList[isVideo ? 'add' : 'remove'](ns + '-show-video');
				} catch (err) {
					Player.logError('There was an error display the sound player image. Please check the console for details.');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Switch between playlist and image view.
			 */
			toggleView: function(e) {
				if (!Player.container) {
					return;
				}
				e && e.preventDefault();
				let style = Player.config.viewStyle === 'playlist' ? 'image' : 'playlist';
				try {
					Player.display.setViewStyle(style);
				} catch (err) {
					Player.logError('There was an error switching the view style. Please check the console for details.', 'warning');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Add a new sound from the thread to the player.
			 */
			add: function(sound, skipRender) {
				try {
					const id = sound.id;
					// Make sure the sound is not a duplicate.
					if (Player.sounds.find(sound => sound.id === id)) {
						return;
					}

					// Add the sound with the location based on the shuffle settings.
					let index = Player.config.shuffle ?
						Math.floor(Math.random() * Player.sounds.length - 1) :
						Player.sounds.findIndex(s => Player.compareIds(s.id, id) > 1);
					index < 0 && (index = Player.sounds.length);
					Player.sounds.splice(index, 0, sound);

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

						// If nothing else has been added yet show the image for this sound.
						if (Player.sounds.length === 1) {
							// If we're on a thread with autoshow enabled then make sure the player is displayed
							if (/\/thread\//.test(location.href) && Player.config.autoshow) {
								Player.show();
							}
							Player.playlist.showImage(sound);
						}
						Player.trigger('add', sound);
					}
				} catch (err) {
					Player.logError('There was an error adding to the sound player. Please check the console for details.');
					console.log('[4chan sounds player]', sound);
					console.error('[4chan sounds player]', err);
				}
			},

			addFromFiles: function(files) {
				// Check each of the files for sounds.
				[...files].forEach(file => {
					if (!file.type.startsWith('image') && file.type !== 'video/webm' && file.type !== 'video/mp4') {
						return;
					}
					const imageSrc = URL.createObjectURL(file);
					const type = file.type;
					let thumbSrc = imageSrc;

					// If it's not a webm just use the full image as the thumbnail
					if (file.type !== 'video/webm') {
						return _continue();
					}
					if (file.type !== 'video/mp4') {
						return _continue();
					}
					// If it's a webm grab the first frame as the thumbnail
					const canvas = document.createElement('canvas');
					const video = document.createElement('video');
					const context = canvas.getContext('2d');
					video.addEventListener('loadeddata', function() {
						context.drawImage(video, 0, 0);
						thumbSrc = canvas.toDataURL();
						_continue();
					});
					video.src = imageSrc;

					function _continue() {
						parseFileName(file.name, imageSrc, null, thumbSrc).forEach(sound => Player.add({
							...sound,
							local: true,
							type
						}));
					}
				});
			},

			/**
			 * Remove a sound
			 */
			remove: function(sound) {
				const index = Player.sounds.indexOf(sound);

				// If the playing sound is being removed then play the next sound.
				if (Player.playing === sound) {
					Player.pause();
					Player.next(true);
				}
				// Remove the sound from the the list and play order.
				index > -1 && Player.sounds.splice(index, 1);

				// Remove the item from the list.
				Player.$(`.${ns}-list-container`).removeChild(Player.$(`.${ns}-list-item[data-id="${sound.id}"]`));
				Player.trigger('remove', sound);
			},

			/**
			 * Handle an playlist item being clicked. Either open/close the menu or play the sound.
			 */
			handleSelect: function(e) {
				// Ignore if a link was clicked.
				if (e.target.nodeName === 'A' || e.target.closest('a')) {
					return;
				}
				e.preventDefault();
				const id = e.eventTarget.getAttribute('data-id');
				const sound = id && Player.sounds.find(sound => sound.id === id);
				sound && Player.play(sound);
			},

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

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

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

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

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

			/**
			 * Hide the hover image when nothing is being hovered over.
			 */
			removeHoverImage: function() {
				Player.playlist.hoverImage.style.display = 'none';
			},

			/**
			 * Start dragging a playlist item.
			 */
			handleDragStart: function(e) {
				Player.playlist._dragging = e.eventTarget;
				Player.playlist.setHoverImageVisibility();
				e.eventTarget.classList.add(`${ns}-dragging`);
				e.dataTransfer.setDragImage(new Image(), 0, 0);
				e.dataTransfer.dropEffect = 'move';
				e.dataTransfer.setData('text/plain', e.eventTarget.getAttribute('data-id'));
			},

			/**
			 * Swap a playlist item when it's dragged over another item.
			 */
			handleDragEnter: function(e) {
				if (!Player.playlist._dragging) {
					return;
				}
				e.preventDefault();
				const moving = Player.playlist._dragging;
				const id = moving.getAttribute('data-id');
				let before = e.target.closest && e.target.closest(`.${ns}-list-item`);
				if (!before || moving === before) {
					return;
				}
				const movingIdx = Player.sounds.findIndex(s => s.id === id);
				const list = moving.parentNode;

				// If the item is being moved down it need inserting before the node after the one it's dropped on.
				const position = moving.compareDocumentPosition(before);
				if (position & 0x04) {
					before = before.nextSibling;
				}

				// Move the element and sound.
				// If there's nothing to go before then append.
				if (before) {
					const beforeId = before.getAttribute('data-id');
					const beforeIdx = Player.sounds.findIndex(s => s.id === beforeId);
					const insertIdx = movingIdx < beforeIdx ? beforeIdx - 1 : beforeIdx;
					list.insertBefore(moving, before);
					Player.sounds.splice(insertIdx, 0, Player.sounds.splice(movingIdx, 1)[0]);
				} else {
					Player.sounds.push(Player.sounds.splice(movingIdx, 1)[0]);
					list.appendChild(moving);
				}
				Player.trigger('order');
			},

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

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

			/**
			 * Remove any user filtered items from the playlist.
			 */
			applyFilters: function() {
				Player.sounds.filter(sound => !Player.acceptedSound(sound)).forEach(Player.playlist.remove);
			},

            toggleSoundTagPosts: function(e) {
                e && e.preventDefault();
                Player.set('showSoundTagOnly', !Player.config.showSoundTagOnly);
                Player.playlist.applySoundTagFilter();
            },

            applySoundTagFilter: function() {
                const showSoundTagOnly = Player.config.showSoundTagOnly;

                // Update button text
                const buttons = document.querySelectorAll(`.${ns}-sound-tag-toggle-button`);
                buttons.forEach(button => {
                    button.textContent = showSoundTagOnly ? '[All]' : '[ST]';
                    button.title = showSoundTagOnly ? 'Show all posts' : 'Show only posts with sound tag';
                });

                // Filter playlist items
                const items = Player.$all(`.${ns}-list-item`);
                items.forEach(item => {
                    const id = item.getAttribute('data-id');
                    const sound = Player.sounds.find(s => s.id === id);
                    if (sound) {
                        item.style.display = showSoundTagOnly && !sound.hasSoundTag ? 'none' : '';
                    }
                });
            }
		};


	}),
	/* 14 - Positioning
        •	Player window:
            o	Draggable header
            o	Resizable
            o	Smart post width limiting
        •	Handles:
            o	Saved position/size
            o	Viewport constraints
            o	4chan X header offsets
    */
	(function(module, exports) {

		module.exports = {
			delegatedEvents: {
				mousedown: {
					[`.${ns}-header`]: 'position.initMove',
					[`.${ns}-expander`]: 'position.initResize'
				}
			},

			initialize: function() {
				// Apply the last position/size, and post width limiting, when the player is shown.
				Player.on('show', async function() {
					const [top, left] = (await GM.getValue('position') || '').split(':');
					const [width, height] = (await GM.getValue('size') || '').split(':'); +
					top && +left && Player.position.move(top, left, true); +
					width && +height && Player.position.resize(width, height);

					if (Player.config.limitPostWidths) {
						Player.position.setPostWidths();
						window.addEventListener('scroll', Player.position.setPostWidths);
					}
				});

				// Remove post width limiting when the player is hidden.
				Player.on('hide', function() {
					Player.position.setPostWidths();
					window.removeEventListener('scroll', Player.position.setPostWidths);
				});

				// Reapply the post width limiting config values when they're changed.
				Player.on('config', prop => {
					if (prop === 'limitPostWidths' || prop === 'minPostWidth') {
						window.removeEventListener('scroll', Player.position.setPostWidths);
						Player.position.setPostWidths();
						if (Player.config.limitPostWidths) {
							window.addEventListener('scroll', Player.position.setPostWidths);
						}
					}
				});

				// Remove post width limit from inline quotes
				new MutationObserver(function() {
					document.querySelectorAll('#hoverUI .postContainer, .inline .postContainer, .backlink_container article').forEach(post => {
						post.style.maxWidth = null;
						post.style.minWidth = null;
					});
				}).observe(document.body, {
					childList: true,
					subtree: true
				});

				// Listen for changes from other tabs
				Player.syncTab('position', value => Player.position.move(...value.split(':').concat(true)));
				Player.syncTab('size', value => Player.position.resize(...value.split(':')));
			},

			/**
			 * Applies a max width to posts next to the player so they don't get hidden behind it.
			 */
			setPostWidths: function() {
				const offset = (document.documentElement.clientWidth - Player.container.offsetLeft) + 10;
				const selector = '.innerPost';
				const enabled = !Player.isHidden && Player.config.limitPostWidths;
				const startY = Player.container.offsetTop;
				const endY = Player.container.getBoundingClientRect().height + startY;

				document.querySelectorAll(selector).forEach(post => {
					const rect = enabled && post.getBoundingClientRect();
					const limitWidth = enabled && rect.top + rect.height > startY && rect.top < endY;
					post.style.maxWidth = limitWidth ? `calc(100% - ${offset}px)` : null;
					post.style.minWidth = limitWidth && Player.config.minPostWidth ? `${Player.config.minPostWidth}` : null;
				});
			},

			/**
			 * Handle the user grabbing the expander.
			 */
			initResize: function initDrag(e) {
				e.preventDefault();
				Player._startX = e.clientX;
				Player._startY = e.clientY;
				let {
					width,
					height
				} = Player.container.getBoundingClientRect();
				Player._startWidth = width;
				Player._startHeight = height;
				document.documentElement.addEventListener('mousemove', Player.position.doResize, false);
				document.documentElement.addEventListener('mouseup', Player.position.stopResize, false);
			},

			/**
			 * Handle the user dragging the expander.
			 */
			doResize: function(e) {
				e.preventDefault();
				Player.position.resize(Player._startWidth + e.clientX - Player._startX, Player._startHeight + e.clientY - Player._startY);
			},

			/**
			 * Handle the user releasing the expander.
			 */
			stopResize: function() {
				const {
					width,
					height
				} = Player.container.getBoundingClientRect();
				document.documentElement.removeEventListener('mousemove', Player.position.doResize, false);
				document.documentElement.removeEventListener('mouseup', Player.position.stopResize, false);
				GM.setValue('size', width + ':' + height);
			},

			/**
			 * Resize the player.
			 */
			resize: function(width, height) {
				if (!Player.container || Player.config.viewStyle === 'fullscreen') {
					return;
				}
				const {
					bottom
				} = Player.position.getHeaderOffset();
				// Make sure the player isn't going off screen.
				height = Math.min(height, document.documentElement.clientHeight - Player.container.offsetTop - bottom);
				width = Math.min(width - 2, document.documentElement.clientWidth - Player.container.offsetLeft);

				Player.container.style.width = width + 'px';

				// Which element to change the height of depends on the view being displayed.
				const heightElement = Player.config.viewStyle === 'playlist' ? Player.$(`.${ns}-list-container`) :
					Player.config.viewStyle === 'image' ? Player.$(`.${ns}-image-link`) :
					Player.config.viewStyle === 'settings' ? Player.$(`.${ns}-settings`) :
					Player.config.viewStyle === 'threads' ? Player.$(`.${ns}-threads`) : null;

				if (!heightElement) {
					return;
				}

				const offset = Player.container.getBoundingClientRect().height - heightElement.getBoundingClientRect().height;
				heightElement.style.height = (height - offset) + 'px';
			},

			/**
			 * Handle the user grabbing the header.
			 */
			initMove: function(e) {
				e.preventDefault();
				Player.$(`.${ns}-header`).style.cursor = 'grabbing';

				// Try to reapply the current sizing to fix oversized winows.
				const {
					width,
					height
				} = Player.container.getBoundingClientRect();
				Player.position.resize(width, height);

				Player._offsetX = e.clientX - Player.container.offsetLeft;
				Player._offsetY = e.clientY - Player.container.offsetTop;
				document.documentElement.addEventListener('mousemove', Player.position.doMove, false);
				document.documentElement.addEventListener('mouseup', Player.position.stopMove, false);
			},

			/**
			 * Handle the user dragging the header.
			 */
			doMove: function(e) {
				e.preventDefault();
				Player.position.move(e.clientX - Player._offsetX, e.clientY - Player._offsetY);
			},

			/**
			 * Handle the user releasing the header.
			 */
			stopMove: function() {
				document.documentElement.removeEventListener('mousemove', Player.position.doMove, false);
				document.documentElement.removeEventListener('mouseup', Player.position.stopMove, false);
				Player.$(`.${ns}-header`).style.cursor = null;
				GM.setValue('position', parseInt(Player.container.style.left, 10) + ':' + parseInt(Player.container.style.top, 10));
			},

			/**
			 * Move the player.
			 */
			move: function(x, y, allowOffscreen) {
				if (!Player.container) {
					return;
				}

				const {
					top,
					bottom
				} = Player.position.getHeaderOffset();

				// Ensure the player stays fully within the window.
				const {
					width,
					height
				} = Player.container.getBoundingClientRect();
				const maxX = allowOffscreen ? Infinity : document.documentElement.clientWidth - width;
				const maxY = allowOffscreen ? Infinity : document.documentElement.clientHeight - height - bottom;

				// Move the window.
				Player.container.style.left = Math.max(0, Math.min(x, maxX)) + 'px';
				Player.container.style.top = Math.max(top, Math.min(y, maxY)) + 'px';

				if (Player.config.limitPostWidths) {
					Player.position.setPostWidths();
				}
			},

			/**
			 * Get the offset from the top or bottom required for the 4chan X header.
			 */
			getHeaderOffset: function() {
				/*const docClasses = document.documentElement.classList;
				const hasChanXHeader = docClasses.contains('fixed');
				const headerHeight = hasChanXHeader ? document.querySelector('#dynamicHeaderThread').getBoundingClientRect().height : 0;
				const top = hasChanXHeader && docClasses.contains('navHeader') ? headerHeight : 0;
				const bottom = hasChanXHeader && docClasses.contains('bottom-header') ? headerHeight : 0;*/

                const top = 26;
                const bottom = 0;

				return {
					top,
					bottom
				};
			}
		};


	}),
	/* 15 - Thread Search
        •	Catalog scanning:
            o	Board selection
            o	Sound thread detection
        •	Displays:
            o	Table view (metadata)
            o	Board-style view (4chan X only)
    */
	(function(module, exports, __webpack_require__) {

		const {
			parseFileName
		} = __webpack_require__(0);
		const {
			get
		} = __webpack_require__(16);

		const boardsURL = /*'https://a.4cdn.org/boards.json'*/'';
		const catalogURL = /*'https://a.4cdn.org/%s/catalog.json'*/'';

		module.exports = {
			boardList: null,
			soundThreads: null,
			displayThreads: {},
			selectedBoards: Board ? [Board] : ['a'],
			showAllBoards: false,

			delegatedEvents: {
				click: {
					[`.${ns}-fetch-threads-link`]: 'threads.fetch',
					[`.${ns}-all-boards-link`]: 'threads.toggleBoardList'
				},
				keyup: {
					[`.${ns}-threads-filter`]: e => Player.threads.filter(e.eventTarget.value)
				},
				change: {
					[`.${ns}-threads input[type=checkbox]`]: 'threads.toggleBoard'
				}
			},

			initialize: function() {
				Player.threads.hasParser = is4chan && typeof Parser !== 'undefined';
				// If the native Parser hasn't been intialised chuck customSpoiler on it so we can call it for threads.
				// You shouldn't do things like this. We can fall back to the table view if it breaks though.
				if (Player.threads.hasParser && !Parser.customSpoiler) {
					Parser.customSpoiler = {};
				}

				Player.on('show', Player.threads._initialFetch);
				Player.on('view', Player.threads._initialFetch);
				Player.on('rendered', Player.threads.afterRender);
				Player.on('config:threadsViewStyle', Player.threads.render);
			},

			/**
			 * Fetch the threads when the threads view is opened for the first time.
			 */
			_initialFetch: function() {
				if (Player.container && Player.config.viewStyle === 'threads' && Player.threads.boardList === null) {
					Player.threads.fetchBoards(true);
				}
			},

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

			/**
			 * Render the threads and apply the board styling after the view is rendered.
			 */
			afterRender: function() {
				const threadList = Player.$(`.${ns}-thread-list`);
				if (threadList) {
					const bodyStyle = document.defaultView.getComputedStyle(document.body);
					threadList.style.background = bodyStyle.backgroundColor;
					threadList.style.backgroundImage = bodyStyle.backgroundImage;
					threadList.style.backgroundRepeat = bodyStyle.backgroundRepeat;
					threadList.style.backgroundPosition = bodyStyle.backgroundPosition;
				}
				Player.threads.renderThreads();
			},

			/**
			 * Render just the threads.
			 */
			renderThreads: function() {
				if (!Player.threads.hasParser || Player.config.threadsViewStyle === 'table') {
					Player.$(`.${ns}-threads-body`).innerHTML = Player.templates.threadList();
				} else {
					try {
						const list = Player.$(`.${ns}-thread-list`);
						for (let board in Player.threads.displayThreads) {
							// Create a board title
							const boardConf = Player.threads.boardList.find(boardConf => boardConf.board === board);
							const boardTitle = `/${boardConf.board}/ - ${boardConf.title}`;
							createElement(`<div class="boardBanner"><div class="boardTitle">${boardTitle}</div></div>`, list);

							// Add each thread for the board
							const threads = Player.threads.displayThreads[board];
							for (let i = 0; i < threads.length; i++) {
								list.appendChild(Parser.buildHTMLFromJSON.call(Parser, threads[i], threads[i].board, true, true));

								// Add a line under each thread
								createElement('<hr style="clear: both">', list);
							}
						}
					} catch (err) {
						Player.logError('Unable to display the threads board view.', 'warning');
						// If there was an error fall back to the table view.
						Player.set('threadsViewStyle', 'table');
						Player.renderThreads();
					}
				}
			},

			/**
			 * Render just the board selection.
			 */
			renderBoards: function() {
				Player.$(`.${ns}-thread-board-list`).innerHTML = Player.templates.threadBoards();
			},

			/**
			 * Toggle the threads view.
			 */
			toggle: function(e) {
				e && e.preventDefault();
				if (Player.config.viewStyle === 'threads') {
					Player.playlist.restore();
				} else {
					Player.display.setViewStyle('threads');
				}
			},

			/**
			 * Switch between showing just the selected boards and all boards.
			 */
			toggleBoardList: function() {
				Player.threads.showAllBoards = !Player.threads.showAllBoards;
				Player.$(`.${ns}-all-boards-link`).innerHTML = Player.threads.showAllBoards ? 'Selected Only' : 'Show All';
				Player.threads.renderBoards();
			},

			/**
			 * Select/deselect a board.
			 */
			toggleBoard: function(e) {
				const board = e.eventTarget.value;
				const selected = e.eventTarget.checked;
				if (selected) {
					!Player.threads.selectedBoards.includes(board) && Player.threads.selectedBoards.push(board);
				} else {
					Player.threads.selectedBoards = Player.threads.selectedBoards.filter(b => b !== board);
				}
			},

			/**
			 * Fetch the board list from the 4chan API.
			 */
			fetchBoards: async function(fetchThreads) {
				Player.threads.loading = true;
				Player.threads.render();
				Player.threads.boardList = (await get(boardsURL)).boards;
				if (fetchThreads) {
					Player.threads.fetch();
				} else {
					Player.threads.loading = false;
					Player.threads.render();
				}
			},

			/**
			 * Fetch the catalog for each selected board and search for sounds in OPs.
			 */
			fetch: async function(e) {
				e && e.preventDefault();
				Player.threads.loading = true;
				Player.threads.render();
				if (!Player.threads.boardList) {
					try {
						await Player.threads.fetchBoards();
					} catch (err) {
						Player.logError('Failed to fetch the boards configuration.');
						console.error(err);
						return;
					}
				}
				const allThreads = [];
				try {
					await Promise.all(Player.threads.selectedBoards.map(async board => {
						const boardConf = Player.threads.boardList.find(boardConf => boardConf.board === board);
						if (!boardConf) {
							return;
						}
						const pages = boardConf && await get(catalogURL.replace('%s', board));
						(pages || []).forEach(({
							page,
							threads
						}) => {
							allThreads.push(...threads.map(thread => Object.assign(thread, {
								board,
								page,
								ws_board: boardConf.ws_board
							})));
						});
					}));

					Player.threads.soundThreads = allThreads.filter(thread => {
						const sounds = parseFileName(thread.filename, `https://i.4cdn.org/${thread.board}/${thread.tim}${thread.ext}`, thread.no, `https://i.4cdn.org/${thread.board}/${thread.tim}s${thread.ext}`, thread.md5);
						return sounds.length;
					});
				} catch (err) {
					Player.logError('Failed to search for sounds threads.');
					console.error(err);
				}
				Player.threads.loading = false;
				Player.threads.filter(Player.$(`.${ns}-threads-filter`).value, true);
				Player.threads.render();
			},

			/**
			 * Apply the filter input to the already fetched threads.
			 */
			filter: function(search, skipRender) {
				Player.threads.filterValue = search || '';
				if (Player.threads.soundThreads === null) {
					return;
				}
				Player.threads.displayThreads = Player.threads.soundThreads.reduce((threadsByBoard, thread) => {
					if (!search || thread.sub && thread.sub.includes(search) || thread.com && thread.com.includes(search)) {
						threadsByBoard[thread.board] || (threadsByBoard[thread.board] = []);
						threadsByBoard[thread.board].push(thread);
					}
					return threadsByBoard;
				}, {});
				!skipRender && Player.threads.renderThreads();
			}
		};


	}),
	/* 16 - Network Utilities
        •	Cached requests:
            o	get(): GM_xmlHttpRequest wrapper
            o	Conditional requests
            o	JSON handling
    */
	(function(module, exports) {

		const cache = {};

		module.exports = {
			get
		};

		async function get(url) {
			return new Promise(function(resolve, reject) {
				const headers = {};
				if (cache[url]) {
					headers['If-Modified-Since'] = cache[url].lastModified;
				}
				GM.xmlHttpRequest({
					method: 'GET',
					url,
					headers,
					responseType: 'json',
					onload: response => {
						if (response.status >= 200 && response.status < 300) {
							cache[url] = {
								lastModified: response.responseHeaders['last-modified'],
								response: response.response
							};
						}
						resolve(response.status === 304 ? cache[url].response : response.response);
					},
					onerror: reject
				});
			});
		}


	}),
	/* 17 - Template System
        •	Dynamic UI generation:
            o	Button definitions
            o	Template parsing
            o	Conditional rendering
        •	Handles all user-customizable layouts
    */
	(function(module, exports, __webpack_require__) {

		const buttons = __webpack_require__(18);

		// Regex for replacements
		const playingRE = /p: ?{([^}]*)}/g;
		const hoverRE = /h: ?{([^}]*)}/g;
		const buttonRE = new RegExp(`(${buttons.map(option => option.tplName).join('|')})-(?:button|link)(?:\\:"([^"]+?)")?`, 'g');
		const soundNameRE = /sound-name/g;
		const soundIndexRE = /sound-index/g;
		const soundCountRE = /sound-count/g;

		// Hold information on which config values components templates depend on.
		const componentDeps = [];

		module.exports = {
			buttons,

			delegatedEvents: {
				click: {
					[`.${ns}-playing-jump-link`]: () => Player.playlist.scrollToPlaying('center'),
					[`.${ns}-viewStyle-button`]: 'playlist.toggleView',
					[`.${ns}-hoverImages-button`]: 'playlist.toggleHoverImages',
					[`.${ns}-remove-link`]: 'userTemplate._handleRemove',
					[`.${ns}-filter-link`]: 'userTemplate._handleFilter',
					[`.${ns}-download-link`]: 'userTemplate._handleDownload',
					[`.${ns}-shuffle-button`]: 'userTemplate._handleShuffle',
					[`.${ns}-repeat-button`]: 'userTemplate._handleRepeat',
					[`.${ns}-reload-button`]: noDefault('playlist.refresh'),
					[`.${ns}-add-button`]: noDefault(() => Player.$(`.${ns}-file-input`).click()),
					[`.${ns}-item-menu-button`]: 'userTemplate._handleMenu',
					[`.${ns}-threads-button`]: 'threads.toggle',
					[`.${ns}-config-button`]: 'settings.toggle'
				},
				change: {
					[`.${ns}-file-input`]: 'userTemplate._handleFileSelect'
				}
			},

			undelegatedEvents: {
				click: {
					body: 'userTemplate._closeMenus'
				},
				keydown: {
					body: e => e.key === 'Escape' && Player.userTemplate._closeMenus()
				}
			},

			initialize: function() {
				Player.on('config', Player.userTemplate._handleConfig);
				Player.on('playsound', () => Player.userTemplate._handleEvent('playsound'));
				Player.on('add', () => Player.userTemplate._handleEvent('add'));
				Player.on('remove', () => Player.userTemplate._handleEvent('remove'));
				Player.on('order', () => Player.userTemplate._handleEvent('order'));
				Player.on('show', () => Player.userTemplate._handleEvent('show'));
				Player.on('hide', () => Player.userTemplate._handleEvent('hide'));
			},

			/**
			 * Build a user template.
			 */
			build: function(data) {
				const outerClass = data.outerClass || '';
				const name = data.sound && data.sound.title || data.defaultName;

				// Apply common template replacements
				let html = data.template
					.replace(playingRE, Player.playing && Player.playing === data.sound ? '$1' : '')
					.replace(hoverRE, `<span class="${ns}-hover-display ${outerClass}">$1</span>`)
					.replace(buttonRE, function(full, type, text) {
						let buttonConf = buttons.find(conf => conf.tplName === type);
						if (buttonConf.requireSound && !data.sound || buttonConf.showIf && !buttonConf.showIf(data)) {
							return '';
						}
						// If the button config has sub values then extend the base config with the selected sub value.
						// Which value is to use is taken from the `property` in the base config of the player config.
						// This gives us different state displays.
						if (buttonConf.values) {
							buttonConf = {
								...buttonConf,
								...buttonConf.values[_get(Player.config, buttonConf.property)] || buttonConf.values[Object.keys(buttonConf.values)[0]]
							};
						}
						const attrs = typeof buttonConf.attrs === 'function' ? buttonConf.attrs(data) : buttonConf.attrs || [];
						attrs.some(attr => attr.startsWith('href')) || attrs.push('href=javascript:;');
						(buttonConf.class || outerClass) && attrs.push(`class="${buttonConf.class || ''} ${outerClass || ''}"`);

						if (!text) {
							text = buttonConf.icon ?
								`<span class="fa ${buttonConf.icon}">${buttonConf.text}</span>` :
								buttonConf.text;
						}

						return `<a ${attrs.join(' ')}>${text}</a>`;
					})
					.replace(soundNameRE, name ? `<div class="fc-sounds-col fc-sounds-truncate-text"><span title="${name}">${name}</span></div>` : '')
					.replace(soundIndexRE, data.sound ? Player.sounds.indexOf(data.sound) + 1 : 0)
					.replace(soundCountRE, Player.sounds.length)
					.replace(/%v/g, "2.3.0");

				// Apply any specific replacements
				if (data.replacements) {
					for (let k of Object.keys(data.replacements)) {
						html = html.replace(new RegExp(k, 'g'), data.replacements[k]);
					}
				}

				return html;
			},

			/**
			 * Sets up a components to render when the template or values within it are changed.
			 */
			maintain: function(component, property, alwaysRenderConfigs = [], alwaysRenderEvents = []) {
				componentDeps.push({
					component,
					property,
					...Player.userTemplate.findDependencies(property, null),
					alwaysRenderConfigs,
					alwaysRenderEvents
				});
			},

			/**
			 * Find all the config dependent values in a template.
			 */
			findDependencies: function(property, template) {
				template || (template = _get(Player.config, property));
				// Figure out what events should trigger a render.
				const events = [];

				// add/remove should render templates showing the count.
				// playsound should render templates showing the playing sounds name/index or dependent on something playing.
				// order should render templates showing a sounds index.
				const hasCount = soundCountRE.test(template);
				const hasName = soundNameRE.test(template);
				const hasIndex = soundIndexRE.test(template);
				const hasPlaying = playingRE.test(template);
				hasCount && events.push('add', 'remove');
				(hasPlaying || property !== 'rowTemplate' && (hasName || hasIndex)) && events.push('playsound');
				hasIndex && events.push('order');

				// Find which buttons the template includes that are dependent on config values.
				const config = [];
				let match;
				while ((match = buttonRE.exec(template)) !== null) {
					// If user text is given then the display doesn't change.
					if (!match[2]) {
						let type = match[1];
						let buttonConf = buttons.find(conf => conf.tplName === type);
						if (buttonConf.property) {
							config.push(buttonConf.property);
						}
					}
				}

				return {
					events,
					config
				};
			},

			/**
			 * When a config value is changed check if any component dependencies are affected.
			 */
			_handleConfig: function(property, value) {
				// Check if a template for a components was updated.
				componentDeps.forEach(depInfo => {
					if (depInfo.property === property) {
						Object.assign(depInfo, Player.userTemplate.findDependencies(property, value));
						depInfo.component.render();
					}
				});
				// Check if any components are dependent on the updated property.
				componentDeps.forEach(depInfo => {
					if (depInfo.alwaysRenderConfigs.includes(property) || depInfo.config.includes(property)) {
						depInfo.component.render();
					}
				});
			},

			/**
			 * When a player event is triggered check if any component dependencies are affected.
			 */
			_handleEvent: function(type) {
				// Check if any components are dependent on the updated property.
				componentDeps.forEach(depInfo => {
					if (depInfo.alwaysRenderEvents.includes(type) || depInfo.events.includes(type)) {
						depInfo.component.render();
					}
				});
			},

			/**
			 * Add local files.
			 */
			_handleFileSelect: function(e) {
				e.preventDefault();
				const input = e.eventTarget;
				Player.playlist.addFromFiles(input.files);
			},

			/**
			 * Toggle the repeat style.
			 */
			_handleRepeat: function(e) {
				try {
					e.preventDefault();
					const values = ['all', 'one', 'none'];
					const current = values.indexOf(Player.config.repeat);
					Player.set('repeat', values[(current + 4) % 3]);
				} catch (err) {
					Player.logError('There was an error changing the repeat setting. Please check the console for details.', 'warning');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Toggle the shuffle style.
			 */
			_handleShuffle: function(e) {
				try {
					e.preventDefault();
					Player.set('shuffle', !Player.config.shuffle);
					Player.header.render();

					// Update the play order.
					if (!Player.config.shuffle) {
						Player.sounds.sort((a, b) => Player.compareIds(a.id, b.id));
					} else {
						const sounds = Player.sounds;
						for (let i = sounds.length - 1; i > 0; i--) {
							const j = Math.floor(Math.random() * (i + 1));
							[sounds[i], sounds[j]] = [sounds[j], sounds[i]];
						}
					}
					Player.trigger('order');
				} catch (err) {
					Player.logError('There was an error changing the shuffle setting. Please check the console for details.', 'warning');
					console.error('[4chan sounds player]', err);
				}
			},

			/**
			 * Display an item menu.
			 */
			_handleMenu: function(e) {
				e.preventDefault();
				e.stopPropagation();
				const x = e.clientX;
				const y = e.clientY;
				const id = e.eventTarget.getAttribute('data-id');
				const sound = Player.sounds.find(s => s.id === id);

				// Add row item menus to the list container. Append to the container otherwise.
				const listContainer = e.eventTarget.closest(`.${ns}-list-container`);
				const parent = listContainer || Player.container;

				// Create the menu.
				const dialog = createElement(Player.templates.itemMenu({
					x,
					y,
					sound
				}), parent);

				parent.appendChild(dialog);

				// Make sure it's within the page.
				const style = document.defaultView.getComputedStyle(dialog);
				const width = parseInt(style.width, 10);
				const height = parseInt(style.height, 10);
				// Show the dialog to the left of the cursor, if there's room.
				if (x - width > 0) {
					dialog.style.left = x - width + 'px';
				}
				// Move the dialog above the cursor if it's off screen.
				if (y + height > document.documentElement.clientHeight - 40) {
					dialog.style.top = y - height + 'px';
				}
				// Add the focused class handler
				dialog.querySelectorAll('.entry').forEach(el => {
					el.addEventListener('mouseenter', Player.userTemplate._setFocusedMenuItem);
					el.addEventListener('mouseleave', Player.userTemplate._unsetFocusedMenuItem);
				});

				Player.trigger('menu-open', dialog);
			},

			/**
			 * Close any open menus, except for one belonging to an item that was clicked.
			 */
			_closeMenus: function() {
				document.querySelectorAll(`.${ns}-item-menu`).forEach(menu => {
					menu.parentNode.removeChild(menu);
					Player.trigger('menu-close', menu);
				});
			},

			_setFocusedMenuItem: function(e) {
				e.currentTarget.classList.add('focused');
				const submenu = e.currentTarget.querySelector('.submenu');
				// Move the menu to the other side if there isn't room.
				if (submenu && submenu.getBoundingClientRect().right > document.documentElement.clientWidth) {
					submenu.style.inset = '0px auto auto -100%';
				}
			},

			_unsetFocusedMenuItem: function(e) {
				e.currentTarget.classList.remove('focused');
			},

			_handleFilter: function(e) {
				e.preventDefault();
				let filter = e.eventTarget.getAttribute('data-filter');
				if (filter) {
					Player.set('filters', Player.config.filters.concat(filter));
				}
			},

			_handleDownload: function(e) {
				const src = e.eventTarget.getAttribute('data-src');
				const name = e.eventTarget.getAttribute('data-name') || new URL(src).pathname.split('/').pop();

				GM.xmlHttpRequest({
					method: 'GET',
					url: src,
					responseType: 'blob',
					onload: response => {
						const a = createElement(`<a href="${URL.createObjectURL(response.response)}" download="${name}" rel="noopener" target="_blank"></a>`);
						a.click();
						URL.revokeObjectURL(a.href);
					},
					onerror: () => Player.logError('There was an error downloading.', 'warning')
				});
			},

			_handleRemove: function(e) {
				const id = e.eventTarget.getAttribute('data-id');
				const sound = id && Player.sounds.find(sound => sound.id === '' + id);
				sound && Player.remove(sound);
			},
		};


	}),
	/* 18 - Button Definitions
        •	All control buttons:
            o	Icons
            o	Behavior flags
            o	State variants
        •	Organized by function (playback, navigation, etc.)
    */
    (function(module, exports) {

		module.exports = [{
				property: 'repeat',
				tplName: 'repeat',
				class: `${ns}-repeat-button`,
				values: {
					all: {
						attrs: ['title="Repeat All"'],
						text: '𝐀&nbsp&#x200A;',
                        icon: 'oi oi-loop-circular'
					},
					one: {
						attrs: ['title="Repeat One"'],
						text: '&#x200A;𝟭&nbsp&#x200A;',
                        icon: 'oi oi-loop-circular'
					},
					none: {
						attrs: ['title="No Repeat"'],
						text: '&#x200A;𝟬&nbsp&#x200A;',
                        icon: 'oi oi-loop-circular'
					}
				}
			},
			{
				property: 'shuffle',
				tplName: 'shuffle',
				class: `${ns}-shuffle-button`,
				values: {
					true: {
						attrs: ['title="Shuffled"'],
						text: '✔&nbsp&#x200A;',
                        icon: 'oi oi-random'
					},
					false: {
						attrs: ['title="Ordered"'],
						text: '✘&nbsp',
						icon: 'oi oi-random'
					}
				}
			},
			{
				property: 'viewStyle',
				tplName: 'playlist',
				class: `${ns}-viewStyle-button`,
				values: {
					playlist: {
						attrs: ['title="Hide Playlist"'],
						text: '&nbsp',
                        icon: 'oi oi-collapse-up'
					},
					image: {
						attrs: ['title="Show Playlist"'],
						text: '&nbsp',
                        icon: 'oi oi-expand-down'
					}
				}
			},
			{
				property: 'hoverImages',
				tplName: 'hover-images',
				class: `${ns}-hoverImages-button`,
				values: {
					true: {
						attrs: ['title="Hover Images Enabled"'],
						text: '&#x200A;✔&#x200A;',
                        icon: 'oi oi-image'
					},
					false: {
						attrs: ['title="Hover Images Disabled"'],
						text: '&#x200A;✘',
                        icon: 'oi oi-image'
					}
				}
			},
			{
				tplName: 'add',
				class: `${ns}-add-button`,
				icon: 'oi oi-plus',
				text: '&nbsp&nbsp',
				attrs: ['title="Add local files"']
			},
			{
				tplName: 'reload',
				class: `${ns}-reload-button`,
                icon: 'oi oi-reload',
				text: '&nbsp&nbsp',
				attrs: ['title="Reload the playlist"']
			},
			{
				tplName: 'settings',
				class: `${ns}-config-button`,
                icon: 'oi oi-wrench',
				text: '&nbsp&nbsp',
				attrs: ['title="Settings"']
			},
			{
				tplName: 'threads',
				class: `${ns}-threads-button`,
                icon: 'oi oi-list-rich',
				text: '&nbsp&nbsp',
				attrs: ['title="Threads"']
			},
			{
				tplName: 'close',
				class: `${ns}-close-button`,
                icon: 'oi oi-x',
				text: '&nbsp',
				attrs: ['title="Hide the player"']
			},
			{
				tplName: 'playing',
				requireSound: true,
				class: `${ns}-playing-jump-link`,
				text: 'Playing',
				attrs: ['title="Scroll the playlist currently playing sound."']
			},
			{
				tplName: 'post',
				requireSound: true,
				icon: 'fa-comment-o',
				text: 'Post',
				showIf: data => data.sound.post,
				attrs: data => [
					`href=${'#' + (is4chan ? 'p' : '') + data.sound.post}`,
					'title="Jump to the post for the current sound"'
				]
			},
			{
				tplName: 'image',
				requireSound: true,
				icon: 'fa-image',
				text: 'i',
				attrs: data => [
					`href=${data.sound.image}`,
					'title="Open the image in a new tab"',
					'target="_blank"'
				]
			},
			{
				tplName: 'sound',
				requireSound: true,
				href: data => data.sound.src,
				icon: 'fa-volume-up',
				text: 's',
				attrs: data => [
					`href=${data.sound.src}`,
					'title="Open the sound in a new tab"',
					'target="blank"'
				]
			},
			{
				tplName: 'dl-image',
				requireSound: true,
				class: `${ns}-download-link`,
				icon: 'fa-file-image-o',
				text: 'i',
				attrs: data => [
					'title="Download the image with the original filename"',
					`data-src="${data.sound.image}"`,
					`data-name="${data.sound.filename}"`
				]
			},
			{
				tplName: 'dl-sound',
				requireSound: true,
				class: `${ns}-download-link`,
				icon: 'fa-file-sound-o',
				text: 's',
				attrs: data => [
					'title="Download the sound"',
					`data-src="${data.sound.src}"`
				]
			},
			{
				tplName: 'filter-image',
				requireSound: true,
				class: `${ns}-filter-link`,
				icon: 'fa-filter',
				text: 's',
				showIf: data => data.sound.imageMD5,
				attrs: data => [
					'title="Add the image MD5 to the filters."',
					`data-filter="${data.sound.imageMD5}"`
				]
			},
			{
				tplName: 'filter-sound',
				requireSound: true,
				class: `${ns}-filter-link`,
				icon: 'fa-filter',
				text: 's',
				attrs: data => [
					'title="Add the sound URL to the filters."',
					`data-filter="${data.sound.src.replace(/^(https?:)?\/\//, '')}"`
				]
			},
			{
				tplName: 'remove',
				requireSound: true,
				class: `${ns}-remove-link`,
				icon: 'fa-trash-o',
				text: 's',
				attrs: data => [
					'title="Filter the image."',
					`data-id="${data.sound.id}"`
				]
			},
			{
				tplName: 'menu',
				requireSound: true,
				class: `${ns}-item-menu-button`,
				icon: 'fa-angle-down',
				text: '▼',
				attrs: data => [`data-id=${data.sound.id}`]
			},
			{
				tplName: 'sound-tag-toggle',
				class: `${ns}-sound-tag-toggle-button`,
				text: '[ST]',
				attrs: ['title="Toggle showing only sound tag posts"']
			}
		];


	}),
	/* 19 - Templates
       Main player structure */
	(function(module, exports) {

		module.exports = (data = {}) => `<div id="${ns}-container" data-view-style="${Player.config.viewStyle}" style="top: 30px; left: 0px; width: 350px; display: none;">
											<div class="${ns}-header ${ns}-row">
												${Player.templates.header(data)}
											</div>
											<div class="${ns}-view-container">
												<div class="${ns}-player ${!Player.config.hoverImages ? `${ns}-hide-hover-image` : ''}" ">
													${Player.templates.player(data)}
												</div>
												<div class="${ns}-settings ${ns}-panel" style="height: 400px">
													${Player.templates.settings(data)}
												</div>
												<div class="${ns}-threads ${ns}-panel" style="height: 400px">
													${Player.templates.threads(data)}
												</div>
											</div>
											<div class="${ns}-footer">
												${Player.templates.footer(data)}
											</div>
											<input class="${ns}-file-input" type="file" style="display: none" accept="image/*,.webm,.mp4" multiple>
										</div>`

	}),
	/* 20 - Templates
       Control bars */
	(function(module, exports) {

		module.exports = (data = {}) => `<div class="${ns}-col-auto ${ns}-col-auto">
											<div class="${ns}-media-control ${ns}-previous-button">
												<div class="${ns}-previous-button-display"></div>
											</div>
											<div class="${ns}-media-control ${ns}-play-button">
												<div class="${ns}-play-button-display ${!Player.audio || Player.audio.paused ? `${ns}-play` : ''}"></div>
											</div>
											<div class="${ns}-media-control ${ns}-next-button">
												<div class="${ns}-next-button-display"></div>
											</div>
										</div>
										<div class="${ns}-col">
											<div class="${ns}-seek-bar ${ns}-progress-bar">
												<div class="${ns}-full-bar">
													<div class="${ns}-loaded-bar"></div>
													<div class="${ns}-current-bar"></div>
												</div>
											</div>
										</div>
										<div class="${ns}-col-auto">
											<span class="${ns}-current-time">0:00</span> / <span class="${ns}-duration">0:00</span>
										</div>
										<div class="${ns}-col-auto">
											<div class="${ns}-volume-bar ${ns}-progress-bar">
												<div class="${ns}-full-bar">
													<div class="${ns}-current-bar" style="width: ${Player.audio.volume * 100}%"></div>
												</div>
											</div>
										</div>
										<div class="${ns}-col-auto">
											<div class="${ns}-media-control ${ns}-fullscreen-button">
												<div class="${ns}-fullscreen-button-display"></div>
											</div>
										</div>`

	}),
	/* 21 - Templates
       CSS */
	(function(module, exports) {

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

        /*
         *
         * CONTROLS CSS
         *
         */

		.${ns}-controls {
			align-items: center;
			padding: .5rem;
			background: #3f3f44
		}

		.${ns}-media-control {
			height: 1.5rem;
			width: 1.5rem;
			display: flex;
			justify-content: center;
			align-items: center;
			cursor: pointer
		}

		.${ns}-media-control>div {
			height: 1rem;
			width: .8rem;
			background: #fff
		}

		.${ns}-media-control:hover>div {
			background: #00b6f0
		}

		.${ns}-play-button-display {
			clip-path: polygon(10% 10%, 10% 90%, 35% 90%, 35% 10%, 65% 10%, 65% 90%, 90% 90%, 90% 10%, 10% 10%)
		}

		.${ns}-play-button-display.${ns}-play {
			clip-path: polygon(0 0, 0 100%, 100% 50%, 0 0)
		}

		.${ns}-previous-button-display,
		.${ns}-next-button-display {
			clip-path: polygon(10% 10%, 10% 90%, 30% 90%, 30% 50%, 90% 90%, 90% 10%, 30% 50%, 30% 10%, 10% 10%)
		}

		.${ns}-next-button-display {
			transform: scale(-1, 1)
		}

		.${ns}-fullscreen-button-display {
			width: 1rem !important;
			clip-path: polygon(0% 35%, 0% 0%, 35% 0%, 35% 15%, 15% 15%, 15% 35%, 0% 35%, 0% 100%, 35% 100%, 35% 85%, 15% 85%, 15% 65%, 0% 65%, 100% 65%, 100% 100%, 65% 100%, 65% 85%, 85% 85%, 85% 15%, 65% 15%, 65% 0%, 100% 0%, 100% 35%, 85% 35%, 85% 65%, 0% 65%)
		}

		.${ns}-controls .${ns}-current-time {
			color: #fff
		}

		.${ns}-duration {
			color: #909090
		}

		.${ns}-progress-bar {
			min-width: 3.5rem;
			height: 1.5rem;
			display: flex;
			align-items: center;
			margin: 0 1rem
		}

		.${ns}-progress-bar .${ns}-full-bar {
			height: .3rem;
			width: 100%;
			background: #131314;
			border-radius: 1rem;
			position: relative
		}

		.${ns}-progress-bar .${ns}-full-bar>div {
			position: absolute;
			top: 0;
			bottom: 0;
			border-radius: 1rem
		}

		.${ns}-progress-bar .${ns}-full-bar .${ns}-loaded-bar {
			background: #5a5a5b
		}

		.${ns}-progress-bar .${ns}-full-bar .${ns}-current-bar {
			display: flex;
			justify-content: flex-end;
			align-items: center
		}

		.${ns}-progress-bar .${ns}-full-bar .${ns}-current-bar:after {
			content: "";
			background: #fff;
			height: .8rem;
			min-width: .8rem;
			border-radius: 1rem;
			box-shadow: rgba(0, 0, 0, .76) 0 0 3px 0
		}

		.${ns}-progress-bar:hover .${ns}-current-bar:after {
			background: #00b6f0
		}

		.${ns}-seek-bar .${ns}-current-bar {
			background: #00b6f0
		}

		.${ns}-volume-bar .${ns}-current-bar {
			background: #fff
		}

		.${ns}-chan-x-controls {
			align-items: inherit
		}

		.${ns}-chan-x-controls .${ns}-current-time,
		.${ns}-chan-x-controls .${ns}-duration {
			margin: 0 .25rem
		}

		.${ns}-chan-x-controls .${ns}-media-control {
			width: 1rem;
			height: auto;
			margin-top: -1px
		}

		.${ns}-chan-x-controls .${ns}-media-control>div {
			height: .7rem;
			width: .5rem
		}

        /*
         *
         * FOOTER CSS
         *
         */

		.${ns}-footer {
			padding: .15rem .25rem;
			border-top:solid 1px ${Player.config.colors.border}
		}

		.${ns}-footer .${ns}-expander {
			position: absolute;
			bottom: 0px;
			right: 0px;
			height: .75rem;
			width: .75rem;
			cursor: se-resize;
			background:linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 50%, ${Player.config.colors.text} 55%, ${Player.config.colors.text} 100%)
		}

		.${ns}-footer:hover .${ns}-hover-display {
			display: inline-block
		}

        /*
         *
         * HEADER CSS
         *
         */

		.${ns}-header {
			cursor: grab;
			text-align: center;
			border-bottom:solid 1px ${Player.config.colors.border};
			padding: .25rem
		}

		.${ns}-header:hover .${ns}-hover-display {
			display: flex
		}

		html.fourchan-x .fa-repeat.fa-repeat-one::after {
			content: "1";
			font-size: .5rem;
			visibility: visible;
			margin-left: -1px
		}

        /*
         *
         * IMAGE CSS
         *
         */

		.${ns}-image-link {
			text-align: center;
			display: flex;
			justify-items: center;
			justify-content: center;
			position: relative;
			resize: both;
			overflow: hidden;
			min-height: ${media_display_min_height} !important;
			max-height: ${media_display_max_height} !important;
			min-width: 100%;
			max-width: 100%;
		}

		.${ns}-image-link.${ns}-pip {
			align-items: end;
			position: fixed !important;
			right: 10px !important;
			bottom: 10px !important;
			left: auto !important;
			top: auto !important;
			max-height: ${minimized_display_max_height} !important;
			max-width: ${minimized_display_max_width} !important;
			align-items: end;
			z-index: 9999; /* Ensure it's above other elements */
		}

		.${ns}-image-link.${ns}-pip .${ns}-image,
		.${ns}-image-link.${ns}-pip .${ns}-video {
			height: initial;
			width: initial;
			object-fit: contain;
			position: fixed !important;
			right: 10px !important;
			bottom: 10px !important;
			left: auto !important;
			top: auto !important;
			max-height: 150px !important;
			max-width: 200px !important;
		}

		.${ns}-image-link .${ns}-video {
			display: none
		}

		.${ns}-image,
		.${ns}-video {
			height: 100% !important;
			width: 100% !important;
			object-fit: contain
		}

		.${ns}-image-link.${ns}-show-video .${ns}-video {
			display: block
		}

		.${ns}-image-link.${ns}-show-video .${ns}-image {
			display: none
		}

        .${ns}-image-link img,
        .${ns}-image-link video {
			max-height: 100% !important;
			max-width: 100% !important;
			object-fit: contain;
        }
        .${ns}-resize-handle {
			position: absolute;
			right: 0;
			bottom: 0;
			width: 15px;
			height: 15px;
			cursor: se-resize;
			z-index: 3;
        }
        .${ns}-image-link video {
			pointer-events: none; /* Disable clicks on the link */
        }

        /*
         *
         * LAYOUT CSS
         *
         */

		#${ns}-container {
			position: fixed;
			background:${Player.config.colors.background};
			border:1px solid ${Player.config.colors.border};
			min-width: 375px;
			color:${Player.config.colors.text}
		}

		.${ns}-panel {
			padding: 0 .25rem;
			height: 100%;
			width: calc(100% - .5rem);
			overflow: auto
		}

		.${ns}-heading {
			font-weight: 600;
			margin: .5rem 0;
			min-width: 100%
		}

		.${ns}-has-description {
			cursor: help
		}

		.${ns}-heading-action {
			font-weight: normal;
			text-decoration: underline;
			margin-left: .25rem
		}

		.${ns}-row {
			display: flex;
			flex-wrap: wrap;
			min-width: 100%;
			box-sizing: border-box
		}

		.${ns}-col-auto {
			flex: 0 0 auto;
			width: auto;
			max-width: 100%;
			display: inline-flex
		}

		.${ns}-col {
			flex-basis: 0;
			flex-grow: 1;
			max-width: 100%;
			width: 100%
		}

		html.fourchan-x #${ns}-container .fa {
			font-size: 0;
			visibility: hidden;
			margin: 0 .15rem
		}

		.${ns}-truncate-text {
			white-space: nowrap;
			text-overflow: ellipsis;
			overflow: hidden
		}

		.${ns}-hover-display {
			display: none
		}

        /*
         *
         * LIST CSS
         *
         */

		.${ns}-player .${ns}-hover-image {
			position: fixed;
			max-height: 125px;
			max-width: 125px
		}

		.${ns}-player.${ns}-hide-hover-image .${ns}-hover-image {
			display: none !important
		}

		.${ns}-list-container {
			overflow-y: auto;
            height: 200px;
		}

		.${ns}-list-container .${ns}-list-item {
			list-style-type: none;
			padding: .15rem .25rem;
			white-space: nowrap;
			text-overflow: ellipsis;
			cursor: pointer;
			background:${Player.config.colors.odd_row};
			overflow: hidden;
			height: 1.3rem
		}

		.${ns}-list-container .${ns}-list-item.playing {
			background:${Player.config.colors.playing} !important
		}

		.${ns}-list-container .${ns}-list-item:nth-child(2n) {
			background:${Player.config.colors.even_row}
		}

		.${ns}-list-container .${ns}-list-item .${ns}-item-menu-button {
			right: .25rem
		}

		.${ns}-list-container .${ns}-list-item:hover .${ns}-hover-display {
			display: flex
		}

		.${ns}-list-container .${ns}-list-item.${ns}-dragging {
			background:${Player.config.colors.dragging}
		}

		html:not(.fourchan-x) .dialog {
			background:${Player.config.colors.background};
			background:${Player.config.colors.background};
			border-color:${Player.config.colors.border};
			border-radius: 3px;
			box-shadow: 0 1px 2px rgba(0, 0, 0, .15);
			border-radius: 3px;
			padding-top: 1px;
			padding-bottom: 3px
		}

		html:not(.fourchan-x) .${ns}-item-menu .entry {
			position: relative;
			display: block;
			padding: .125rem .5rem;
			min-width: 70px;
			white-space: nowrap
		}

		html:not(.fourchan-x) .${ns}-item-menu .has-submenu::after {
			content: "";
			border-left: .5em solid;
			border-top: .3em solid transparent;
			border-bottom: .3em solid transparent;
			display: inline-block;
			margin: .35em;
			position: absolute;
			right: 3px
		}

		html:not(.fourchan-x) .${ns}-item-menu .submenu {
			position: absolute;
			display: none
		}

		html:not(.fourchan-x) .${ns}-item-menu .focused>.submenu {
			display: block
		}

        /*
         *
         * SETTINGS CSS
         *
         */

		.${ns}-settings textarea {
			border:solid 1px ${Player.config.colors.border};
			min-width: 100%;
			min-height: 4rem;
			box-sizing: border-box;
			white-space: pre
		}

		.${ns}-settings .${ns}-sub-settings .${ns}-col {
			min-height: 1.55rem;
			display: flex;
			align-items: center;
			align-content: center;
			white-space: nowrap
		}

        /*
         *
         * THREADS CSS
         *
         */

		.${ns}-threads .${ns}-thread-board-list label {
			display: inline-block;
			width: 4rem
		}

		.${ns}-threads .${ns}-thread-list {
			margin: 1rem -0.25rem 0;
			padding: .5rem 1rem;
			border-top:solid 1px ${Player.config.colors.border}
		}

		.${ns}-threads .${ns}-thread-list .boardBanner {
			margin: 1rem 0
		}

		.${ns}-threads table {
			margin-top: .5rem;
			border-collapse: collapse
		}

		.${ns}-threads table th {
			border-bottom:solid 1px ${Player.config.colors.border}
		}

		.${ns}-threads table th,
		.${ns}-threads table td {
			text-align: left;
			padding: .25rem
		}

		.${ns}-threads table tr {
			padding: .25rem 0
		}

		.${ns}-threads table .${ns}-threads-body tr {
			background:${Player.config.colors.even_row}
		}

		.${ns}-threads table .${ns}-threads-body tr:nth-child(2n) {
			background:${Player.config.colors.odd_row}
		}

		.${ns}-threads,
		.${ns}-settings,
		.${ns}-player {
			display: none
		}

		#${ns}-container[data-view-style=settings] .${ns}-settings {
			display: block
		}

		#${ns}-container[data-view-style=threads] .${ns}-threads {
			display: block
		}

		#${ns}-container[data-view-style=image] .${ns}-player,
		#${ns}-container[data-view-style=playlist] .${ns}-player,
		#${ns}-container[data-view-style=fullscreen] .${ns}-player {
			display: block
		}

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

		#${ns}-container[data-view-style=image] .${ns}-image-link {
			height: auto
		}

		#${ns}-container[data-view-style=playlist] .${ns}-image-link {
			height: 125px
		}

		#${ns}-container[data-view-style=fullscreen] .${ns}-image-link {
			height: calc(100% - .4rem) !important
		}

		#${ns}-container[data-view-style=fullscreen] .${ns}-controls {
			position: absolute;
			left: 0;
			right: 0;
			bottom: calc(-2.5rem + .4rem)
		}

		#${ns}-container[data-view-style=fullscreen] .${ns}-controls:hover {
			bottom: 0
		}

        `
	}),

	/* 22 - Templates
       Footer */
	(function(module, exports) {

		module.exports = (data = {}) => Player.userTemplate.build({
				template: Player.config.footerTemplate,
				sound: Player.playing
			}) +
			`<div class="${ns}-expander"></div>`

	}),
	/* 23 - Templates
       Header */
	(function(module, exports) {

		module.exports = (data = {}) => Player.userTemplate.build({
			template: Player.config.headerTemplate,
			sound: Player.playing,
			defaultName: '8chan Sounds',
			outerClass: `${ns}-col-auto`
		});


	}),
	/* 24 - Templates
       Context menus */
	(function(module, exports) {

		module.exports = (data = {}) => `<div class="${ns}-item-menu dialog" id="menu" tabindex="0" data-type="post" style="position: fixed; top: ${data.y}px; left: ${data.x}px;">
											<a class="${ns}-remove-link entry focused" href="javascript:;" data-id="${data.sound.id}">Remove</a>
											${data.sound.post ? `<a class="entry" href="#${(is4chan ? 'p' : '') + data.sound.post}">Show Post</a>` : ''}
											<div class="entry has-submenu">
												Open
												<div class="dialog submenu" style="inset: 0px auto auto 100%;">
													<a class="entry" href="${data.sound.image}" target="_blank">Image</a>
													<a class="entry" href="${data.sound.src}" target="_blank">Sound</a>
												</div>
											</div>
											<div class="entry has-submenu">
												Download
												<div class="dialog submenu" style="inset: 0px auto auto 100%;">
													<a class="${ns}-download-link entry" href="javascript:;" data-src="${data.sound.image}" data-name="${data.sound.filename}">Image</a>
													<a class="${ns}-download-link entry" href="javascript:;" data-src="${data.sound.src}">Sound</a>
												</div>
											</div>
											<div class="entry has-submenu">
												Filter
												<div class="dialog submenu" style="inset: 0px auto auto 100%;">
													${data.sound.imageMD5 ? `<a class="${ns}-filter-link entry" href="javascript:;" data-filter="${data.sound.imageMD5}">Image</a>` : ''}
													<a class="${ns}-filter-link entry" href="javascript:;" data-filter="${data.sound.src.replace(/^(https?\:)?\/\//, '')}">Sound</a>
												</div>
											</div>
										</div>`


	}),
	/* 25 - Templates
       Playlist items */
	(function(module, exports) {

		module.exports = (data = {}) => (data.sounds || Player.sounds).map(sound =>
			`<div class="${ns}-list-item ${ns}-row ${sound.playing ? 'playing' : ''}" data-id="${sound.id}" draggable="true">
				${Player.userTemplate.build({
					template: Player.config.rowTemplate,
					sound,
					outerClass: `${ns}-col-auto`
				})}
			</div>`
		).join('')

	}),
	/* 26 - Templates
       Media display */
	(function(module, exports) {

		module.exports = (data = {}) => `<div class="${ns}-media">
										<a class="${ns}-image-link" target="_blank">
											<img class="${ns}-image"></img>
											<video class="${ns}-video"></video>
										</a>
										<div class="${ns}-controls ${ns}-row">
											${Player.templates.controls(data)}
										</div>
										</div>
										<div class="${ns}-list-container style="height: 100px">
											${Player.templates.list(data)}
										</div>
										<img class="${ns}-hover-image">`

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

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

			let tpl = `
						<div class="${ns}-heading">Version</div>
						<a href="http://greasyfork.icu/en/scripts/533468-8chan-sounds-player" target="_blank">${VERSION}</a>

						<div class="${ns}-heading">Encode / Decode URL</div>
						<div class="${ns}-row">
							<input type="text" class="${ns}-decoded-input ${ns}-col" placeholder="https://">
							<input type="text" class="${ns}-encoded-input ${ns}-col" placeholder="https%3A%2F%2F">
						</div>
					`;

			settingsConfig.forEach(function addSetting(setting) {
				// Filter settings that aren't flagged to be displayed.
				if (!setting.showInSettings && !(setting.settings || []).find(s => s.showInSettings)) {
					return;
				}
				const desc = setting.description;

				tpl += `
					<div class="${ns}-row ${setting.isSubSetting ? `${ns}-sub-settings` : ''}">
					<div class="${ns}-col ${!setting.isSubSetting ? `${ns}-heading` : ''} ${desc ? `${ns}-has-description` : ''}" ${desc ? `title="${desc.replace(/"/g, '&quot;')}"` : ''}>
						${setting.title}
						${(setting.actions || []).map(action => `<a href="javascript:;" class="${ns}-heading-action" data-handler="${action.handler}" data-property="${setting.property}">${action.title}</a>`)}
					</div>`;

				if (setting.settings) {
					setting.settings.forEach(subSetting => addSetting({
						...setting,
						actions: null,
						settings: null,
						description: null,
						...subSetting,
						isSubSetting: true
					}));
				} else {

					let value = _get(Player.config, setting.property, setting.default),
						attrs = (setting.attrs || '') + (setting.class ? ` class="${setting.class}"` : '') + ` data-property="${setting.property}"`;

					if (setting.format) {
						value = _get(Player, setting.format)(value);
					}
					let type = typeof value;

					if (setting.split) {
						value = value.join(setting.split);
					} else if (type === 'object') {
						value = JSON.stringify(value, null, 4);
					}

					tpl += `
				<div class="${ns}-col">
				${
					type === 'boolean'
						? `<input type="checkbox" ${attrs} ${value ? 'checked' : ''}></input>`

					: setting.showInSettings === 'textarea' || type === 'object'
						? `<textarea ${attrs}>${value}</textarea>`

					: setting.options
						? `<select ${attrs}>
							${Object.keys(setting.options).map(k => `<option value="${k}" ${value === k ? 'selected' : ''}>
								${setting.options[k]}
							</option>`).join('')}
						</select>`

					: `<input type="text" ${attrs} value="${value}"></input>`
				}
				</div>`;
				}
				tpl += '</div>';
			});

			return tpl;
		}

	}),
	/* 28 - Templates
       Thread browser */
	(function(module, exports) {

		module.exports = (data = {}) => `<div class="${ns}-heading ${ns}-has-description" title="Search for threads with a sound OP">
	Active Threads
	${!Player.threads.loading ? `- <a class="${ns}-fetch-threads-link ${ns}-heading-action" href="javascript:;">Update</a>` : ''}
									</div>
									<div style="display: ${Player.threads.loading ? 'block' : 'none'}">Loading</div>
									<div style="display: ${Player.threads.loading ? 'none' : 'block'}">
										<div class="${ns}-heading ${ns}-has-description" title="Only includes threads containing the search.">
											Filter
										</div>
										<input type="text" class="${ns}-threads-filter" value="${Player.threads.filterValue || ''}"></input>
										<div class="${ns}-heading">
											Boards - <a class="${ns}-all-boards-link ${ns}-heading-action" href="javascript:;">${Player.threads.showAllBoards ? 'Selected Only' : 'Show All'}</a>
										</div>
										<div class="${ns}-thread-board-list">
											${Player.templates.threadBoards(data)}
										</div>
										${
											!Player.threads.hasParser || Player.config.threadsViewStyle === 'table'
											? `<table style="width: 100%">
													<tr>
														<th>Thread</th>
														<th>Subject</th>
														<th>Replies/Images</th>
														<th>Started</th>
														<th>Updated</th>
													<tr>
													<tbody class="${ns}-threads-body"></tbody>
												</table>`
											: `<div class="${ns}-thread-list"></div>`
										}
									</div>`


	}),
	/* 29 - Templates
       Thread browser */
	(function(module, exports) {

		module.exports = (data = {}) => (Player.threads.boardList || []).map(board => {
			let checked = Player.threads.selectedBoards.includes(board.board);
			return !checked && !Player.threads.showAllBoards ?
				'' :
				`<label>
			<input type="checkbox" value="${board.board}" ${checked ? 'checked' : ''}>
			/${board.board}/
		</label>`
		}).join('')

	}),
	/* 30 - Templates
       Thread browser */
	(function(module, exports) {

		module.exports = (data = {}) => Object.keys(Player.threads.displayThreads).reduce((rows, board) => {
			return rows.concat(Player.threads.displayThreads[board].map(thread => `
		<tr>
			<td>
				<a class="quotelink" href="//boards.${thread.ws_board ? '4channel' : '4chan'}.org/${thread.board}/thread/${thread.no}#p${thread.no}" target="_blank">
					>>>/${thread.board}/${thread.no}
				</a>
			</td>
			<td>${thread.sub || ''}</td>
			<td>${thread.replies} / ${thread.images}</td>
			<td>${timeAgo(thread.time)}</td>
			<td>${timeAgo(thread.last_modified)}</td>
		</tr>
	`))
		}, []).join('')


	})
]);