8chan sounds player

Play that faggy music weeb boi

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

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

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

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

	// The module cache
	var installedModules = {};

	// The require function
	function __webpack_require__(moduleId) {

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

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

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

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


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

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

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

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

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

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

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

	// __webpack_public_path__
	__webpack_require__.p = "";

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

        let localCounter = 0;

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

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

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

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

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

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

            let spaceVar = '0';

            if (fileExt.length > 3) {
                spaceVar = ' ';
            } else if (fileExt.length === 3) {
                spaceVar = '    ';
            } else if (fileExt.length < 3) {
                spaceVar = '&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="https://greasyfork.org/en/scripts/533468-8chan-sounds-player" target="_blank">${VERSION}</a>

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

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

				tpl += `
					<div class="${ns}-row ${setting.isSubSetting ? `${ns}-sub-settings` : ''}">
					<div class="${ns}-col ${!setting.isSubSetting ? `${ns}-heading` : ''} ${desc ? `${ns}-has-description` : ''}" ${desc ? `title="${desc.replace(/"/g, '&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('')


	})
]);