4chan sounds player

A player designed for 4chan sounds threads.

Install this script?
Author's suggested script

You may also like 4chan External Sounds.

Install this script
// ==UserScript==
// @name         4chan sounds player
// @version      3.1.2
// @namespace    rccom
// @description  A player designed for 4chan sounds threads.
// @author       RCC
// @match        *://boards.4chan.org/*
// @match        *://boards.4channel.org/*
// @match        *://desuarchive.org/*
// @match        *://arch.b4k.co/*
// @match        *://archived.moe/*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.xmlHttpRequest
// @grant        GM_addValueChangeListener
// @connect      4chan.org
// @connect      4channel.org
// @connect      a.4cdn.org
// @connect      desu-usergeneratedcontent.xyz
// @connect      arch-img.b4k.co
// @connect      4cdn.org
// @connect      a.pomf.cat
// @connect      pomf.cat
// @connect      files.catbox.moe
// @connect      catbox.moe
// @connect      share.dmca.gripe
// @connect      z.zz.ht
// @connect      zz.ht
// @connect      too.lewd.se
// @connect      lewd.se
// @connect      *
// @run-at       document-start
// ==/UserScript==


/******/ (function(modules) { // webpackBootstrap
/******/ 	// 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 = "./src/main.js");
/******/ })
/************************************************************************/
/******/ ({

/***/ "./src/api.js":
/*!********************!*\
  !*** ./src/api.js ***!
  \********************/
/*! no static exports found */
/***/ (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
		});
	});
}


/***/ }),

/***/ "./src/components/controls.js":
/*!************************************!*\
  !*** ./src/components/controls.js ***!
  \************************************/
/*! no static exports found */
/***/ (function(module, exports) {

const progressBarStyleSheets = {};

module.exports = {
	atRoot: [ 'togglePlay', 'play', 'pause', 'next', 'previous' ],
	public: [ '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: {
		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;
			}
		},
		play: { [`.${ns}-video`]: 'controls.syncVideo' },
		pause: { [`.${ns}-video`]: 'controls.syncVideo' }
	},

	audioEvents: {
		ended: () => Player.next(),
		pause: 'controls.handleAudioEvent',
		play: 'controls.handleAudioEvent',
		seeked: 'controls.handleAudioEvent',
		waiting: 'controls.handleAudioEvent',
		timeupdate: 'controls.updateDuration',
		loadedmetadata: 'controls.updateDuration',
		durationchange: 'controls.updateDuration',
		volumechange: 'controls.updateVolume',
		loadstart: 'controls.pollForLoading'
	},

	initialize: function () {
		Player.on('show', () => Player._hiddenWhilePolling && Player.controls.pollForLoading());
		Player.on('hide', () => {
			Player._hiddenWhilePolling = !!Player._loadingPoll;
			Player.controls.stopPollingForLoading();
		});
		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`);

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

	/**
	 * Switching being playing and paused.
	 */
	togglePlay: function () {
		if (Player.audio.paused) {
			Player.play();
		} else {
			Player.pause();
		}
	},

	/**
	 * Start playback.
	 */
	play: async function (sound, { paused } = {}) {
		try {
			// If nothing is currently selected to play start playing the first sound.
			if (!sound && !Player.playing && Player.sounds.length) {
				sound = Player.sounds[0];
			}

			const video = document.querySelector(`.${ns}-video`);
			video.removeEventListener('canplaythrough', Player.controls.playOnceLoaded);
			Player.audio.removeEventListener('canplaythrough', Player.controls._playOnceLoaded);

			// If a new sound is being played update the display.
			if (sound) {
				if (Player.playing) {
					Player.playing.playing = false;
				}
				sound.playing = true;
				Player.playing = sound;
				Player.audio.src = sound.src;
				await Player.trigger('playsound', sound);
			}

			if (!paused) {
				// If there's a video wait for it and the sound to load before playing.
				if (Player.playlist.isVideo && (video.readyState < 3 || Player.audio.readyState < 3)) {
					video.addEventListener('canplaythrough', Player.controls._playOnceLoaded);
					Player.audio.addEventListener('canplaythrough', Player.controls._playOnceLoaded);
				} else {
					Player.audio.play();
				}
			}
		} catch (err) {
			Player.logError('There was an error playing the sound. Please check the console for details.', err);
		}
	},

	/**
	 * Handler to start playback once the video and audio are both loaded.
	 */
	_playOnceLoaded: function () {
		const video = document.querySelector(`.${ns}-video`);
		if (video.readyState > 2 && Player.audio.readyState > 2) {
			video.removeEventListener('canplaythrough', Player.controls._playOnceLoaded);
			Player.audio.removeEventListener('canplaythrough', Player.controls._playOnceLoaded);
			Player.audio.play();
			// Sometimes it just doesn't sync when the playback starts. Give it a second and then force a sync.
			setTimeout(() => Player.controls.syncVideo, 100);
		}
	},

	/**
	 * Pause playback.
	 */
	pause: function () {
		Player.audio && Player.audio.pause();
	},

	/**
	 * Play the next sound.
	 */
	next: function (opts) {
		Player.controls._movePlaying(1, opts);
	},

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

	_movePlaying: function (direction, { force, group, paused } = {}) {
		if (!Player.audio) {
			return;
		}
		// 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.
		let nextSound;
		if (!force && Player.config.repeat === 'one') {
			nextSound = Player.sounds[currentIndex];
		} else {
			let newIndex = currentIndex;
			// Get the next index wrapping round if repeat all is selected
			// Keep going if it's group move, there's still more sounds to check, and the next sound is still in the same group.
			do {
				newIndex = Player.config.repeat === 'all'
					? ((newIndex + direction) + Player.sounds.length) % Player.sounds.length
					: newIndex + direction;
				nextSound = Player.sounds[newIndex];
			} while (group && nextSound && newIndex !== currentIndex && (!nextSound.post || nextSound.post === Player.playing.post));
		}
		nextSound && Player.play(nextSound, { paused });
	},

	/**
	 * Handle audio events. Sync the video up, and update the controls.
	 */
	handleAudioEvent: function () {
		Player.controls.syncVideo();
		Player.controls.updateDuration();
		document.querySelectorAll(`.${ns}-play-button .${ns}-play-button-display`).forEach(el => {
			el.classList[Player.audio.paused ? 'add' : 'remove'](`${ns}-play`);
		});
	},

	/**
	 * Sync the webm to the audio. Matches the videos time and play state to the audios.
	 */
	syncVideo: function () {
		if (Player.playlist.isVideo) {
			const paused = Player.audio.paused;
			const video = document.querySelector(`.${ns}-video`);
			if (video) {
				video.currentTime = Player.audio.currentTime % video.duration;
				if (paused) {
					video.pause();
				} else {
					video.play();
				}
			}
		}
	},

	/**
	 * 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 length = Player.audio.buffered.length;
		const size = length > 0
			? (Player.audio.buffered.end(length - 1) / Player.audio.duration) * 100
			: 0;
		// If it's fully loaded then stop polling.
		size === 100 && Player.controls.stopPollingForLoading();
		Player.ui.loadedBar.style.width = size + '%';
	},

	/**
	 * Update the seek bar and the duration labels.
	 */
	updateDuration: function () {
		if (!Player.container) {
			return;
		}
		const currentTime = toDuration(Player.audio.currentTime);
		const duration = toDuration(Player.audio.duration);
		document.querySelectorAll(`.${ns}-current-time`).forEach(el => el.innerHTML = currentTime);
		document.querySelectorAll(`.${ns}-duration`).forEach(el => el.innerHTML = duration);
		Player.controls.updateProgressBarPosition(`.${ns}-seek-bar`, Player.ui.currentTimeBar, Player.audio.currentTime, Player.audio.duration);
	},

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

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

	/**
	 * Handle the user interacting with the seek bar.
	 */
	handleSeek: function (e) {
		e.preventDefault();
		if (Player.container && Player.audio.duration && Player.audio.duration !== Infinity) {
			const ratio = e.offsetX / parseInt(document.defaultView.getComputedStyle(e.eventTarget || e.target).width, 10);
			Player.audio.currentTime = Player.audio.duration * ratio;
		}
	},

	/**
	 * 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));
		Player.controls.updateVolume();
	}
};


/***/ }),

/***/ "./src/components/display.js":
/*!***********************************!*\
  !*** ./src/components/display.js ***!
  \***********************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js");

const dismissedContentCache = {};
const dismissedRestoreCache = {};

module.exports = {
	atRoot: [ 'show', 'hide' ],
	public: [ 'show', 'hide' ],

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

	initialize: async function () {
		try {
			Player.display.dismissed = (await GM.getValue('dismissed')).split(',');
		} catch (err) {
			Player.display.dismissed = [];
		}
	},

	/**
	 * Create the player show/hide button in to the 4chan X header.
	 */
	initChanX: function () {
		if (Player.display._initedChanX) {
			return;
		}
		const shortcuts = document.getElementById('shortcuts');
		if (!shortcuts) {
			return;
		}
		Player.display._initedChanX = true;
		const showIcon = createElement(`<span id="shortcut-sounds" class="shortcut brackets-wrap" data-index="0">
			<a href="javascript:;" title="Sounds" class="fa fa-play-circle">Sounds</a>
		</span>`);
		shortcuts.insertBefore(showIcon, document.getElementById('shortcut-settings'));
		showIcon.querySelector('a').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.', err);
			// Can't recover, throw.
			throw err;
		}
	},

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

	applyBoardTheme: function (force) {
		// Create a reply element to gather the style from
		const div = createElement(`<div class="${is4chan ? 'post reply style-fetcher' : 'post_wrapper'}"></div>`, document.body);
		const style = document.defaultView.getComputedStyle(div);

		// Apply the computed style to the color config.
		const colorSettingMap = {
			'colors.text': 'color',
			'colors.background': 'backgroundColor',
			'colors.odd_row': 'backgroundColor',
			'colors.border': 'borderBottomColor',
			// If the border is the same color as the text don't use it as a background color.
			'colors.even_row': style.borderBottomColor === style.color ? 'backgroundColor' : 'borderBottomColor'
		};
		settingsConfig.find(s => s.property === 'colors').settings.forEach(setting => {
			const updateConfig = force || (setting.default === _get(Player.config, setting.property));
			colorSettingMap[setting.property] && (setting.default = style[colorSettingMap[setting.property]]);
			updateConfig && Player.set(setting.property, setting.default, { bypassSave: true, bypassRender: true, bypassStylesheet: true});
		});

		// Clean up the element.
		document.body.removeChild(div);

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

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

	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;
		}
		e && e.preventDefault();
		Player.container.style.display = 'none';

		Player.isHidden = true;
		Player.trigger('hide');
	},

	/**
	 * 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;
		}
		e && e.preventDefault();
		if (!Player.container.style.display) {
			return;
		}
		Player.container.style.display = null;

		Player.isHidden = false;
		await Player.trigger('show');
	},

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

	_handleRestore: async function (e) {
		e.preventDefault();
		const restore = e.eventTarget.getAttribute('data-restore');
		const restoreIndex = Player.display.dismissed.indexOf(restore);
		if (restore && restoreIndex > -1) {
			Player.display.dismissed.splice(restoreIndex, 1);
			Player.$all(`[data-restore="${restore}"]`).forEach(el => {
				createElementBefore(dismissedContentCache[restore], el);
				el.parentNode.removeChild(el);
			});
			await GM.setValue('dismissed', Player.display.dismissed.join(','));
		}
	},

	_handleDismiss: async function (e) {
		e.preventDefault();
		const dismiss = e.eventTarget.getAttribute('data-dismiss');
		if (dismiss && !Player.display.dismissed.includes(dismiss)) {
			Player.display.dismissed.push(dismiss);
			Player.$all(`[data-dismiss-id="${dismiss}"]`).forEach(el => {
				createElementBefore(`<a href="#" class="${ns}-restore-link" data-restore="${dismiss}">${dismissedRestoreCache[dismiss]}</a>`, el);
				el.parentNode.removeChild(el);
			});
			await GM.setValue('dismissed', Player.display.dismissed.join(','));
		}
	},

	ifNotDismissed: function (name, restore, text) {
		dismissedContentCache[name] = text;
		dismissedRestoreCache[name] = restore;
		return Player.display.dismissed.includes(name)
			? `<a href="#" class="${ns}-restore-link" data-restore="${name}">${restore}</a>`
			: text;
	}
};


/***/ }),

/***/ "./src/components/events.js":
/*!**********************************!*\
  !*** ./src/components/events.js ***!
  \**********************************/
/*! no static exports found */
/***/ (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;
	}
};


/***/ }),

/***/ "./src/components/footer.js":
/*!**********************************!*\
  !*** ./src/components/footer.js ***!
  \**********************************/
/*! no static exports found */
/***/ (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();
		}
	}
};


/***/ }),

/***/ "./src/components/header.js":
/*!**********************************!*\
  !*** ./src/components/header.js ***!
  \**********************************/
/*! no static exports found */
/***/ (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();
		}
	}
};


/***/ }),

/***/ "./src/components/hotkeys.js":
/*!***********************************!*\
  !*** ./src/components/hotkeys.js ***!
  \***********************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js");

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

		// Setup up hardware media keys.
		if ('mediaSession' in navigator && Player.config.hardwareMediaKeys) {
			const actions = [
				[ 'play', () => Player.play() ],
				[ 'pause', () => Player.pause() ],
				[ 'stop', () => Player.pause() ],
				[ 'previoustrack', () => Player.previous() ],
				[ 'nexttrack', () => Player.next() ],
				[ 'seekbackward', evt => Player.audio.currentTime -= evt.seekOffset || 10 ],
				[ 'seekforward', evt => Player.audio.currentTime += evt.seekOffset || 10 ],
				[ 'seekto', evt => Player.audio.currentTime += evt.seekTime ]
			]
			for (let [ type, handler ] of actions) {
				try {
					navigator.mediaSession.setActionHandler(type, handler);
				} catch(err) {
					// not enabled...
				}
			}

			// Keep the media metadata updated.
			Player.audio.addEventListener('pause', () => navigator.mediaSession.playbackState = 'paused');
			Player.audio.addEventListener('ended', () => navigator.mediaSession.playbackState = 'paused');
			Player.audio.addEventListener('play', function() {
				navigator.mediaSession.playbackState = 'playing';
				navigator.mediaSession.metadata = new MediaMetadata({
					title: Player.playing.title,
					artist: '4chan Sounds Player',
					album: document.title,
					artwork: [ { src: Player.playing.thumb } ]
				});
			});
		}
	},

	_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 Player.events.getHandler(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);
	}
};


/***/ }),

/***/ "./src/components/minimised.js":
/*!*************************************!*\
  !*** ./src/components/minimised.js ***!
  \*************************************/
/*! no static exports found */
/***/ (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);
	}
};


/***/ }),

/***/ "./src/components/playlist.js":
/*!************************************!*\
  !*** ./src/components/playlist.js ***!
  \************************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

const { parseFiles, parseFileName } = __webpack_require__(/*! ../file_parser */ "./src/file_parser.js");

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

	delegatedEvents: {
		click: { [`.${ns}-list-item`]: 'playlist.handleSelect' },
		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() },
		keyup: { [`.${ns}-playlist-search`]: 'playlist._handleSearch' }
	},

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

		// Keey track of  of the hover image element.
		Player.on('rendered', () => Player.playlist.hoverImage = Player.$(`.${ns}-hover-image`));

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

		// Listen to the search display being toggled
		Player.on('config:showPlaylistSearch', Player.playlist.toggleSearch);

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

	/**
	 * 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.type === 'video/webm');
		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;
		if (Player.config.viewStyle !== 'fullscreen') {
			container.href = sound.image;
		}
		container.classList[isVideo ? 'add' : 'remove'](ns + '-show-video');
	},

	/**
	 * Switch between playlist and image view.
	 */
	toggleView: function (e) {
		if (!Player.container) {
			return;
		}
		e && e.preventDefault();
		let style = Player.config.viewStyle === 'playlist' ? 'image' : 'playlist';
		Player.display.setViewStyle(style);
	},

	/**
	 * 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.', err);
			console.log('[4chan sounds player]', sound);
		}
	},

	addFromFiles: function (files) {
		// Check each of the files for sounds.
		[ ...files ].forEach(file => {
			if (!file.type.startsWith('image') && file.type !== 'video/webm') {
				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 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, null, true).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.next({ force: true, paused: Player.audio.paused });
		}
		// 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}-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}-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);
	},

	/**
	 * Search the playlist
	 */
	_handleSearch: function (e) {
		Player.playlist.search(e.eventTarget.value.toLowerCase());
	},

	search: function (v) {
		const lastSearch = Player.playlist._lastSearch;
		Player.playlist._lastSearch = v;
		if (v === lastSearch) {
			return;
		}
		if (!v) {
			return Player.$all(`.${ns}-list-item`).forEach(el => el.style.display = null);
		}
		Player.sounds.forEach(sound => {
			const row = Player.$(`.${ns}-list-item[data-id="${sound.id}"]`);
			row && (row.style.display = Player.playlist.matchesSearch(sound) ? null : 'none');
		});
	},

	matchesSearch: function (sound) {
		const v = Player.playlist._lastSearch;
		return !v
			|| sound.title.toLowerCase().includes(v)
			|| String(sound.post.toLowerCase()).includes(v)
			|| String(sound.src.toLowerCase()).includes(v);
	},

	toggleSearch: function (show) {
		const input = Player.$(`.${ns}-playlist-search`);
		!show && Player.playlist._lastSearch && Player.playlist.search();
		input.style.display = show ? null : 'none';
		show && input.focus();
	}
};


/***/ }),

/***/ "./src/components/position.js":
/*!************************************!*\
  !*** ./src/components/position.js ***!
  \************************************/
/*! no static exports found */
/***/ (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 = is4chan ? '.thread > .postContainer' : '.posts > article.post';
		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`)
			: Player.config.viewStyle === 'tools' ? Player.$(`.${ns}-tools`) : 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 heaer.
	 */
	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('#header-bar').getBoundingClientRect().height : 0;
		const top = hasChanXHeader && docClasses.contains('top-header') ? headerHeight : 0;
		const bottom = hasChanXHeader && docClasses.contains('bottom-header') ? headerHeight : 0;

		return { top, bottom };
	}
};


/***/ }),

/***/ "./src/components/settings.js":
/*!************************************!*\
  !*** ./src/components/settings.js ***!
  \************************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js");
const migrations = __webpack_require__(/*! ../migrations */ "./src/migrations.js");

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

	changelog: 'https://github.com/rcc11/4chan-sounds-player/releases',

	delegatedEvents: {
		click: {
			[`.${ns}-settings .${ns}-heading-action`]: 'settings._handleAction',
			[`.${ns}-settings-tab`]: 'settings._handleTab'
		},
		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',
		}
	},

	initialize: async function () {
		Player.settings.view = 'Display';

		// Apply the default board theme as default.
		Player.display.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();

		if (Player.config.showUpdatedNotification && Player.config.VERSION && Player.config.VERSION !== "3.1.2") {
			Player.alert(`4chan Sounds Player has been updated to <a href="${Player.settings.changelog}" target="_blank">version ${"3.1.2"}</a>.`);
		}

		// Run any migrations.
		await Player.settings.migrate(Player.config.VERSION);

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

	/**
	 * Update a setting.
	 */
	set: function (property, value, { bypassValidation, bypassSave, bypassRender, silent, bypassStylesheet, settingConfig } = {}) {
		settingConfig = settingConfig || Player.settings.findDefault(property);
		const previousValue = _get(Player.config, property);

		// Check if the value has actually changed.
		if (!bypassValidation && _isEqual(previousValue, value)) {
			return;
		}

		// Set the new value.
		_set(Player.config, property, value);

		// Trigger events, unless they are disabled in opts.
		!bypassStylesheet && settingConfig && settingConfig.updateStylesheet && Player.display.updateStylesheet()
		!silent && Player.trigger('config', property, value, previousValue);
		!silent && Player.trigger('config:' + property, value, previousValue);
		!bypassSave && Player.settings.save();
		!bypassRender && Player.settings.findDefault(property).displayGroup && Player.settings.render();
	},

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

	/**
	 * Persist the player settings.
	 */
	save: function () {
		try {
			// Filter settings that haven't 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 {
					let userVal = _get(Player.config, setting.property);
					if (userVal !== undefined && !_isEqual(userVal, setting.default)) {
						// If the setting is a mixed in object only store items that differ from the default.
						if (setting.mix) {
							userVal = Object.keys(userVal).reduce((changed, key) => {
								if (!_isEqual(setting.default[key], userVal[key])) {
									changed[key] = userVal[key];
								}
								return changed;
							}, {});
						}
						_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 = "3.1.2";
			// Save the settings.
			return GM.setValue('settings', JSON.stringify(settings));
		} catch (err) {
			Player.logError('There was an error saving the sound player settings.', 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.', err);
		}
	},

	apply: function (settings, opts = {}) {
		if (typeof settings === 'string') {
			settings = JSON.parse(settings);
		}
		settings.VERSION && (Player.config.VERSION = settings.VERSION);
		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(setting.property)) {
				return;
			}
			let value = _get(settings, setting.property, opts.applyDefault ? setting.default : undefined);
			if (value !== undefined) {
				if (setting.mix) {
					// Mix in default.
					value = { ...setting.default, ...(value || {}) };
				}
				Player.set(setting.property, value, { ...opts, settingConfig: setting });
			}
		});
	},

	/**
	 * Run migrations when the player is updated.
	 */
	migrate: async function (fromVersion) {
		// Fall out if the player hasn't updated.
		if (!fromVersion || fromVersion === "3.1.2") {
			return;
		}
		for (let i = 0; i < migrations.length; i++) {
			let mig = migrations[i];
			if (Player.settings.compareVersions(fromVersion, mig.version) < 0) {
				try {
					console.log('[4chan sound player] Migrate:', mig.name);
					await mig.run();
				} catch (err) {
					console.error(err);
				}
			}
		}
		Player.settings.save();
	},

	/**
	 * Compare two semver strings.
	 */
	compareVersions: function (a, b) {
		const [ aVer, aHash ] = a.split('-');
		const [ bVer, bHash ] = b.split('-');
		const aParts = aVer.split('.');
		const bParts = bVer.split('.');
		for (let i = 0; i < 3; i++) {
			if (+aParts[i] > +bParts[i]) {
				return 1;
			}
			if (+aParts[i] < +bParts[i]) {
				return -1;
			}
		}
		return aHash !== bHash;
	},

	/**
	 * 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 (group) {
		// Blur anything focused so the change is applied.
		let focused = Player.$(`.${ns}-settings :focus`);
		focused && focused.blur();

		// Restore the playlist if there's no group given and the settings are already open.
		if (!group && Player.config.viewStyle === 'settings') {
			return Player.playlist.restore();
		}
		// Switch to the settings view if it's not already showing.
		if (Player.config.viewStyle !== 'settings') {
			Player.display.setViewStyle('settings');
		}
		// Switch to a given group.
		if (group && group !== Player.settings.view) {
			Player.settings.showGroup(group);
		}
	},

	/**
	 * Switch the displayed group
	 */
	_handleTab: function (e) {
		const group = e.eventTarget.getAttribute('data-group');
		if (group) {
			e.preventDefault();
			Player.settings.showGroup(group);
		}
	},

	showGroup: function (group) {
		Player.settings.view = group;
		const currentGroup = Player.$(`.${ns}-settings-group.active`);
		const currentTab = Player.$(`.${ns}-settings-tab.active`);
		currentGroup && currentGroup.classList.remove('active');
		currentTab && currentTab.classList.remove('active');
		Player.$(`.${ns}-settings-group[data-group="${group}"]`).classList.add('active');
		Player.$(`.${ns}-settings-tab[data-group="${group}"]`).classList.add('active');
	},

	/**
	 * 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, currentValue, e);
			}
			if (settingConfig && settingConfig.split) {
				newValue = newValue.split(decodeURIComponent(settingConfig.split));
			}

			// Not the most stringent check but enough to avoid some spamming.
			if (!_isEqual(currentValue, newValue, !settingConfig.looseCompare)) {
				// Update the setting.
				Player.set(property, newValue, { bypassValidation: true, bypassRender: true, settingConfig });
			}
		} catch (err) {
			Player.logError('There was an error updating the setting.', 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, e);
	},

	renderHosts: function (_value) {
		return `<div class="${ns}-host-inputs">`
			+ Object.keys(Player.config.uploadHosts).map(Player.templates.hostInput).join('')
		+ '</div>';
	},

	parseHosts: function (newValue, hosts, e) {
		hosts = { ...hosts };
		const container = e.eventTarget.closest(`.${ns}-host-input`);
		let name = container.getAttribute('data-host-name');
		let host = hosts[name] = { ...hosts[name] };
		const changedField = e.eventTarget.getAttribute('name');

		try {
			// If the name was changed then reassign in hosts and update the data-host-name attribute.
			if (changedField === 'name' && newValue !== name) {
				if (!newValue || hosts[newValue]) {
					throw new PlayerError('A unique name for the host is required.', 'warning');
				}
				container.setAttribute('data-host-name', newValue);
				hosts[newValue] = host;
				delete hosts[name];
				name = newValue;
			}

			// Validate URL
			if (changedField === 'url' || changedField === 'soundUrl') {
				try {
					(changedField === 'url' || newValue) && new URL(newValue);
				} catch (err) {
					throw new PlayerError('The value must be a valid URL.', 'warning');
				}
			}

			// Parse the data
			if (changedField === 'data') {
				try {
					newValue = JSON.parse(newValue);
				} catch (err) {
					throw new PlayerError('The data must be valid JSON.', 'warning');
				}
			}

			if (changedField === 'headers') {
				try {
					newValue = newValue ? JSON.parse(newValue) : undefined;
				} catch (err) {
					throw new PlayerError('The headers must be valid JSON.', 'warning');
				}
			}
		} catch (err) {
			host.invalid = true;
			container.classList.add('invalid');
			throw err;
		}

		if (newValue === undefined) {
			delete host[changedField];
		} else {
			host[changedField] = newValue;
		}

		try {
			const soundUrlValue = container.querySelector('[name=soundUrl]').value;
			const headersValue = container.querySelector('[name=headers]').value;
			if (name
				&& JSON.parse(container.querySelector('[name=data]').value)
				&& new URL(container.querySelector('[name=url]').value)
				&& (!soundUrlValue || new URL(soundUrlValue))
				&& (!headersValue || JSON.parse(headersValue))) {

				delete host.invalid;
				container.classList.remove('invalid');
			}
		} catch (err) {
			// leave it invalid
		}

		return hosts;
	},

	addUploadHost: function () {
		const hosts = Player.config.uploadHosts;
		const container = Player.$(`.${ns}-host-inputs`);
		let name = 'New Host';
		let i = 1;
		while (Player.config.uploadHosts[name]) {
			name = name + ' ' + ++i;
		}
		hosts[name] = { invalid: true, data: { file: '$file' } };
		if (container.children[0]) {
			createElementBefore(Player.templates.hostInput(name), container.children[0]);
		} else {
			createElement(Player.templates.hostInput(name), container);
		}
		Player.settings.set('uploadHosts', hosts, { bypassValidation: true, bypassRender: true, silent: true });
	},

	removeHost: function (prop, e) {
		const hosts = Player.config.uploadHosts;
		const container = e.eventTarget.closest(`.${ns}-host-input`);
		const name = container.getAttribute('data-host-name');
		// For hosts in the defaults set null so we know to not include them on load
		if (Player.settings.findDefault('uploadHosts').default[name]) {
			hosts[name] = null;
		} else {
			delete hosts[name];
		}
		container.parentNode.removeChild(container);
		Player.settings.set('uploadHosts', hosts, { bypassValidation: true, bypassRender: true });
	},

	setDefaultHost: function (_new, _current, e) {
		const selected = e.eventTarget.closest(`.${ns}-host-input`).getAttribute('data-host-name');
		if (selected === Player.config.defaultUploadHost) {
			return selected;
		}

		Object.keys(Player.config.uploadHosts).forEach(name => {
			const checkbox = Player.$(`.${ns}-host-input[data-host-name="${name}"] input[data-property="defaultUploadHost"]`);
			checkbox && (checkbox.checked = name === selected);
		});
		return selected;
	},

	restoreDefaultHosts: function () {
		Object.assign(Player.config.uploadHosts, Player.settings.findDefault('uploadHosts').default);
		Player.set('uploadHosts', Player.config.uploadHosts, { bypassValidation: true });
	}
};


/***/ }),

/***/ "./src/components/threads.js":
/*!***********************************!*\
  !*** ./src/components/threads.js ***!
  \***********************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

const { parseFileName } = __webpack_require__(/*! ../file_parser */ "./src/file_parser.js");
const { get } = __webpack_require__(/*! ../api */ "./src/api.js");

const maxSavedBoards = 10;
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: async 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);
		try {
			const savedBoards = await GM.getValue('threads_board_selection');
			savedBoards && (Player.threads.selectedBoards = savedBoards.split(','));
		} catch (err) {
			// Leave it deafulted to the current board.
		}
	},

	/**
	 * 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.', err, '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 (e) {
		e.preventDefault();
		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: async function (e) {
		const board = e.eventTarget.value;
		const selected = e.eventTarget.checked;
		if (selected) {
			!Player.threads.selectedBoards.includes(board) && Player.threads.selectedBoards.unshift(board);
		} else {
			Player.threads.selectedBoards = Player.threads.selectedBoards.filter(b => b !== board);
		}
		await GM.setValue('threads_board_selection', Player.threads.selectedBoards.slice(0, maxSavedBoards).join(','));
	},

	/**
	 * 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) {
				return Player.logError('Failed fetching the boards list.', err);
			}
		}
		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, true);
				return sounds.length;
			});
		} catch (err) {
			Player.logError('Failed searching for sounds threads.', 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();
	}
};


/***/ }),

/***/ "./src/components/tools.js":
/*!*********************************!*\
  !*** ./src/components/tools.js ***!
  \*********************************/
/*! no static exports found */
/***/ (function(module, exports) {

const ffmpegVersionUrl = 'https://raw.githubusercontent.com/rcc11/4chan-sounds-player/master/dist/4chan-sounds-player-with-ffmpeg.user.js';
const promoteFFmpegVersion = false;
// Seems to be the cut off point for file names
const maxFilenameLength = 218;

module.exports = {
	hasFFmpeg: typeof ffmpeg === 'function',
	_uploadIdx: 0,
	createStatusText: '',

	delegatedEvents: {
		click: {
			[`.${ns}-create-button`]: 'tools._handleCreate',
			[`.${ns}-create-sound-post-link`]: 'tools._addCreatedToQR',
			[`.${ns}-create-sound-add-link`]: 'tools._addCreatedToPlayer',
			[`.${ns}-toggle-sound-input`]: 'tools._handleToggleSoundInput',
			[`.${ns}-host-setting-link`]: noDefault(() => Player.settings.toggle('Hosts')),
			[`.${ns}-remove-file`]: noDefault(e => Player.tools._handleFileRemove(e))
		},
		change: {
			[`.${ns}-create-sound-img`]: 'tools._handleImageSelect',
			[`.${ns}-create-sound-form input[type=file]`]: e => Player.tools._handleFileSelect(e.eventTarget),
			[`.${ns}-use-video`]: 'tools._handleWebmSoundChange'
		},
		drop: {
			[`.${ns}-create-sound-form`]: 'tools._handleCreateSoundDrop'
		},
		keyup: {
			[`.${ns}-encoded-input`]: 'tools._handleEncoded',
			[`.${ns}-decoded-input`]: 'tools._handleDecoded'
		}
	},

	initialize: function () {
		Player.on('config:uploadHosts', Player.tools.render);
		Player.on('config:defaultUploadHost', newValue => Player.$(`.${ns}-create-sound-host`).value = newValue);
		Player.on('rendered', Player.tools.afterRender);
	},

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

	afterRender: function () {
		Player.tools.status = Player.$(`.${ns}-create-sound-status`);
		Player.tools.imgInput = Player.$(`.${ns}-create-sound-img`);
		Player.tools.sndInput = Player.$(`.${ns}-create-sound-snd`);
	},

	toggle: function (e) {
		e && e.preventDefault();
		if (Player.config.viewStyle === 'tools') {
			Player.playlist.restore();
		} else {
			Player.display.setViewStyle('tools');
		}
	},

	updateCreateStatus: function (text) {
		Player.tools.status.style.display = text ? 'inherit' : 'none';
		Player.tools.status.innerHTML = Player.tools.createStatusText = text;
	},

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

	/**
	 * Show/hide the "Use webm" checkbox when an image is selected.
	 */
	_handleImageSelect: async function (e) {
		const input = e && e.eventTarget || Player.tools.imgInput;
		const image = input.files[0];
		const isVideo = image.type === 'video/webm';
		let placeholder = image.name.replace(/\.[^/.]+$/, '');

		if (Player.tools.hasFFmpeg) {
			// Show the Use Webm label if the image is a webm file
			Player.$(`.${ns}-use-video-label`).style.display = isVideo ? 'inherit' : 'none';

			const webmCheckbox = Player.$(`.${ns}-use-video`);
			// If the image is a video and Copy Video is selected then update the sound input as well
			webmCheckbox.checked && isVideo && Player.tools._handleFileSelect(Player.tools.sndInput, [ image ]);
			// If the image isn't a webm make sure Copy Video is deselected (click to fire change event)
			webmCheckbox.checked && !isVideo && webmCheckbox.click();
		} else if (await Player.tools.hasAudio(image)) {
			Player.logError('Audio not allowed for the image webm.', null, 'warning');
		}

		// Show the image name as the placeholder for the name input since it's the default
		Player.$(`.${ns}-create-sound-name`).setAttribute('placeholder', placeholder);
	},

	/**
	 * Update the custom file input display when the input changes
	 */
	_handleFileSelect: function (input, files) {
		const container = input.closest(`.${ns}-file-input`);
		const fileText = container.querySelector('.text');
		const fileList = container.querySelector(`.${ns}-file-list`);
		files || (files = [ ...input.files ]);
		container.classList[files.length ? 'remove' : 'add']('placeholder');
		fileText.innerHTML = files.length > 1
			? files.length + ' files'
			: files[0] && files[0].name || '';
		fileList && (fileList.innerHTML = files.length < 2 ? '' : files.map((file, i) =>
			`<div class="${ns}-row">
				<div class="${ns}-col ${ns}-truncate-text">${file.name}</div>
				<a class="${ns}-col-auto ${ns}-remove-file" href="#" data-idx="${i}"><span class="fa fa-times">X</span></a>
			</div>`
		).join(''));
	},

	/**
	 * Handle a file being removed from a multi input
	 */
	_handleFileRemove: function (e) {
		const idx = +e.eventTarget.getAttribute('data-idx');
		const input = e.eventTarget.closest(`.${ns}-file-input`).querySelector('input[type="file"]');
		const dataTransfer = new DataTransfer();
		for (let i = 0; i < input.files.length; i++) {
			i !== idx && dataTransfer.items.add(input.files[i]);
		}
		input.files = dataTransfer.files;
		Player.tools._handleFileSelect(input);
	},

	/**
	 * Show/hide the sound input when "Use webm" is changed.
	 */
	_handleWebmSoundChange: function (e) {
		const sound = Player.tools.sndInput;
		const image = Player.tools.imgInput;
		Player.tools._handleFileSelect(sound, e.eventTarget.checked && [ image.files[0] ]);
	},

	_handleToggleSoundInput: function (e) {
		e.preventDefault();
		const showURL = e.eventTarget.getAttribute('data-type') === 'url';
		Player.$(`.${ns}-create-sound-snd-url`).closest(`.${ns}-row`).style.display = showURL ? null : 'none';
		Player.$(`.${ns}-create-sound-snd`).closest(`.${ns}-file-input`).style.display = showURL ? 'none' : null;
		Player.tools.useSoundURL = showURL;
	},

	/**
	 * Handle files being dropped on the create sound section.
	 */
	_handleCreateSoundDrop: function (e) {
		e.preventDefault();
		e.stopPropagation();
		const targetInput = e.target.nodeName === 'INPUT' && e.target.getAttribute('type') === 'file' && e.target;
		[ ...e.dataTransfer.files ].forEach(file => {
			const isVideo = file.type.startsWith('video');
			const isImage = file.type.startsWith('image') || file.type === 'video/webm';
			const isSound = file.type.startsWith('audio');
			if (isVideo || isImage || isSound) {
				const input = file.type === 'video/webm' && targetInput
					? targetInput
					: isImage
						? Player.tools.imgInput
						: Player.tools.sndInput;
				const dataTransfer = new DataTransfer();
				if (input.multiple) {
					[ ...input.files ].forEach(file => dataTransfer.items.add(file));
				}
				dataTransfer.items.add(file);
				input.files = dataTransfer.files;
				Player.tools._handleFileSelect(input);
				input === Player.tools.imgInput && Player.tools._handleImageSelect();
			}
		});
		return false;
	},

	/**
	 * Handle the create button.
	 * Extracts video/audio if required, uploads the sound, and creates an image file names with [sound=url].
	 */
	_handleCreate: async function (e) {
		e && e.preventDefault();
		// Revoke the URL for an existing created image.
		Player.tools._createdImageURL && URL.revokeObjectURL(Player.tools._createdImageURL);
		Player.tools._createdImage = null;

		Player.tools.updateCreateStatus('Creating sound image');

		Player.$(`.${ns}-create-button`).disabled = true;

		// Gather the input values.
		const host =  Player.config.uploadHosts[Player.$(`.${ns}-create-sound-host`).value];
		const useSoundURL = Player.tools.useSoundURL;
		let image = Player.tools.imgInput.files[0];
		let soundURLs = useSoundURL && Player.$(`.${ns}-create-sound-snd-url`).value.split(',').map(v => v.trim()).filter(v => v);
		let sounds = !(Player.$(`.${ns}-use-video`) || {}).checked || !image || !image.type.startsWith('video')
			? [ ...Player.tools.sndInput.files ]
			: image && [ image ];
		const customName = Player.$(`.${ns}-create-sound-name`).value;
		// Only split a given name if there's multiple sounds.
		const names = customName
			? ((soundURLs || sounds).length > 1 ? customName.split(',') : [ customName ]).map(v => v.trim())
			: image && [ image.name.replace(/\.[^/.]+$/, '') ];

		try {
			if (!image) {
				throw new PlayerError('Select an image or webm.', 'warning');
			}

			if (image.type.startsWith('video') && await Player.tools.hasAudio(image)) {
				// If ffmpeg is not available fall out.
				if (!Player.tools.hasFFmpeg) {
					Player.tools.updateCreateStatus(Player.tools.createStatusText
						+ '<br>' + (promoteFFmpegVersion ? 'This version of the player does not enable webm splitting.' : 'Audio not allowed for the image webm.')
						+ '<br>Remove the audio from the webm and try again.'
						+ (promoteFFmpegVersion ? `<br>Alternatively install the <a href="${ffmpegVersionUrl}">ffmpeg version</a> to extract video/audio automatically.` : ''));
					throw new PlayerError('Audio not allowed for the image webm.', 'warning');
				}

				// If the image is a webm with audio then extract just the video.
				image = await Player.tools.extract(image, 'video');
			}

			const soundlessLength = names.join('').length + (soundURLs || sounds).length * 8;
			if (useSoundURL) {
				try {
					// Make sure each url is valid and strip the protocol.
					soundURLs = soundURLs.map(url => new URL(url) && url.replace(/^(https?:)?\/\//, ''));
				} catch (err) {
					throw new PlayerError('The provided sound URL is invalid.', 'warning');
				}
				if (maxFilenameLength < soundlessLength + soundURLs.join('').length) {
					throw new PlayerError('The generated image filename is too long.', 'warning');
				}
			} else {
				if (!sounds || !sounds.length) {
					throw new PlayerError('Select a sound.', 'warning');
				}

				// Check the final filename length if the URL length is known for the host.
				// Limit to 8 otherwise. zz.ht is as small as you're likely to get and that can only fit 8.
				const tooManySounds = host.filenameLength
					? maxFilenameLength < soundlessLength + (host.filenameLength) * sounds.length
					: sounds.length > 8;
				if (tooManySounds) {
					throw new PlayerError('The generated image filename is too long.', 'warning');
				}

				// Check videos have audio and extract it if possible.
				sounds = await Promise.all(sounds.map(async sound => {
					if (sound.type.startsWith('video')) {
						if (!await Player.tools.hasAudio(sound)) {
							throw new PlayerError(`The selected video has no audio. (${sound.name})`, 'warning');
						}
						if (Player.tools.hasFFmpeg) {
							return await Player.tools.extract(sound, 'audio');
						}
					}
					return sound;
				}));

				// Upload the sounds.
				try {
					soundURLs = await Promise.all(sounds.map(async sound => Player.tools.postFile(sound, host)));
				} catch (err) {
					throw new PlayerError('Upload failed.', 'error', err);
				}
			}

			if (!soundURLs.length) {
				throw new PlayerError('No sounds selected.', 'warning');
			}

			// Create a new file that includes [sound=url] in the name.
			let filename = '';
			for (let i = 0; i < soundURLs.length; i++) {
				filename += (names[i] || '') + '[sound=' + encodeURIComponent(soundURLs[i].replace(/^(https?:)?\/\//, '')) + ']';
			}
			const ext = image.name.match(/\.([^/.]+)$/)[1];
			const soundImage = new File([ image ], filename + '.' + ext, { type: image.type });

			// Keep track of the create image and a url to it.
			Player.tools._createdImage = soundImage;
			Player.tools._createdImageURL = URL.createObjectURL(soundImage);

			// Complete! with some action links
			Player.tools.updateCreateStatus(Player.tools.createStatusText
				+ '<br>Complete!<br>'
				+ (is4chan ? `<a href="#" class="${ns}-create-sound-post-link">Post</a> - ` : '')
				+ ` <a href="#" class="${ns}-create-sound-add-link">Add</a> - `
				+ ` <a href="${Player.tools._createdImageURL}" download="${soundImage.name}" title="${soundImage.name}">Download</a>`
			);
		} catch (err) {
			Player.tools.updateCreateStatus(Player.tools.createStatusText
				+ '<br>Failed! ' + (err instanceof PlayerError ? err.reason : ''));
			Player.logError('Failed to create sound image', err);
		}
		Player.$(`.${ns}-create-button`).disabled = false;
	},

	hasAudio: function (file) {
		if (!file.type.startsWith('audio') && !file.type.startsWith('video')) {
			return false;
		}
		return new Promise((resolve, reject) => {
			const url = URL.createObjectURL(file);
			const video = document.createElement('video');
			video.addEventListener('loadeddata', () => {
				URL.revokeObjectURL(url);
				resolve(video.mozHasAudio || !!video.webkitAudioDecodedByteCount);
			});
			video.addEventListener('error', reject);
			video.src = url;
		});
	},

	/**
	 * Extract just the audio or video from a file.
	 */
	extract: async function (file, type) {
		Player.tools.updateCreateStatus(Player.tools.createStatusText + '<br>Extracting ' + type);
		if (typeof ffmpeg !== 'function') {
			return file;
		}
		const name = file.name.replace(/\.[^/.]+$/, '') + (type === 'audio' ? '.ogg' : '.webm');

		const result = ffmpeg({
			MEMFS: [ { name: '_' + file.name, data: await new Response(file).arrayBuffer() } ],
			arguments: type === 'audio'
				? [ '-i', '_' + file.name, '-vn', '-c', 'copy', name ]
				: [ '-i', '_' + file.name, '-an', '-c', 'copy', name ]
		});

		return new File([ result.MEMFS[0].data ], name, { type: type === 'audio' ? 'audio/ogg' : 'video/webm' });
	},

	/**
	 * Upload the sound file and return a link to it.
	 */
	postFile: async function (file, host) {
		const idx = Player.tools._uploadIdx++;

		if (!host || host.invalid) {
			throw new PlayerError('Invalid upload host: ' + hostId, 'error');
		}

		const formData = new FormData();
		Object.keys(host.data).forEach(key => {
			if (host.data[key] !== null) {
				formData.append(key, host.data[key] === '$file' ? file : host.data[key]);
			}
		});

		Player.tools.updateCreateStatus(Player.tools.createStatusText + `<br><span class="${ns}-upload-status-${idx}">Uploading ${file.name}</span>`);

		return new Promise((resolve, reject) => {
			GM.xmlHttpRequest({
				method: 'POST',
				url: host.url,
				data: formData,
				responseType: host.responsePath ? 'json' : 'text',
				headers: host.headers,
				onload: async response => {
					if (response.status < 200 || response.status >= 300) {
						return reject(response);
					}
					const responseVal = host.responsePath
						? _get(response.response, host.responsePath)
						: host.responseMatch
							? (response.responseText.match(new RegExp(host.responseMatch)) || [])[1]
							: response.responseText;
					const uploadedUrl = host.soundUrl ? host.soundUrl.replace('%s', responseVal) : responseVal;
					Player.$(`.${ns}-upload-status-${idx}`).innerHTML = `Uploaded ${file.name} to <a href="${uploadedUrl}" target="_blank">${uploadedUrl}</a>`;
					Player.tools.createStatusText = Player.tools.status.innerHTML;
					resolve(uploadedUrl);
				},
				upload: {
					onprogress: response => {
						const total = response.total > 0 ? response.total : file.size;
						Player.$(`.${ns}-upload-status-${idx}`).innerHTML = `Uploading ${file.name} - ${Math.floor(response.loaded / total * 100)}%`;
					}
				},
				onerror: reject
			});
		});
	},

	/**
	 * Add the created sound image to the player.
	 */
	_addCreatedToPlayer: function (e) {
		e.preventDefault();
		Player.playlist.addFromFiles([ Player.tools._createdImage ]);
	},

	/**
	 * Open the QR window and add the created sound image to it.
	 */
	_addCreatedToQR: function (e) {
		if (!is4chan) {
			return;
		}
		e.preventDefault();
		// Open the quick reply window.
		const qrLink = document.querySelector(isChanX ? '.qr-link' : '.open-qr-link');

		const dataTransfer = new DataTransfer();
		dataTransfer.items.add(Player.tools._createdImage);

		// 4chan X, drop the file on the qr window.
		if (isChanX) {
			qrLink.click();
			const event = new CustomEvent('drop', { view: window, bubbles: true, cancelable: true });
			event.dataTransfer = dataTransfer;
			document.querySelector('#qr').dispatchEvent(event);

		// Native, set the file input value. Check for a quick reply
		} else if (qrLink) {
			qrLink.click();
			document.querySelector('#qrFile').files = dataTransfer.files;
		} else {
			document.querySelector('#togglePostFormLink a').click();
			document.querySelector('#postFile').files = dataTransfer.files;
			document.querySelector('.postForm').scrollIntoView();
		}
	},
};


/***/ }),

/***/ "./src/components/user-template/buttons.js":
/*!*************************************************!*\
  !*** ./src/components/user-template/buttons.js ***!
  \*************************************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = [
	{
		property: 'repeat',
		tplName: 'repeat',
		class: `${ns}-repeat-button`,
		values: {
			all: { attrs: [ 'title="Repeat All"' ], text: '[RA]', icon: 'fa-repeat' },
			one: { attrs: [ 'title="Repeat One"' ], text: '[R1]', icon: 'fa-repeat fa-repeat-one' },
			none: { attrs: [ 'title="No Repeat"' ], text: '[R0]', icon: 'fa-repeat disabled' }
		}
	},
	{
		property: 'shuffle',
		tplName: 'shuffle',
		class: `${ns}-shuffle-button`,
		values: {
			true: { attrs: [ 'title="Shuffled"' ], text: '[S]', icon: 'fa-random' },
			false: { attrs: [ 'title="Ordered"' ], text: '[O]', icon: 'fa-random disabled' }
		}
	},
	{
		property: 'viewStyle',
		tplName: 'playlist',
		class: `${ns}-viewStyle-button`,
		values: {
			playlist: { attrs: [ 'title="Hide Playlist"' ], text: '[+]', icon: 'fa-compress' },
			image: { attrs: [ 'title="Show Playlist"' ], text: '[-]', icon: 'fa-expand' }
		}
	},
	{
		property: 'hoverImages',
		tplName: 'hover-images',
		class: `${ns}-hoverImages-button`,
		values: {
			true: { attrs: [ 'title="Hover Images Enabled"' ], text: '[H]', icon: 'fa-picture-o' },
			false: { attrs: [ 'title="Hover Images Disabled"' ], text: '[-]', icon: 'fa-picture-o disabled' }
		}
	},
	{
		tplName: 'add',
		class: `${ns}-add-button`,
		icon: 'fa-plus',
		text: '+',
		attrs: [ 'title="Add local files"' ]
	},
	{
		tplName: 'reload',
		class: `${ns}-reload-button`,
		icon: 'fa-refresh',
		text: '[R]',
		attrs: [ 'title="Reload the playlist"' ]
	},
	{
		tplName: 'settings',
		class: `${ns}-config-button`,
		icon: 'fa-wrench',
		text: '[S]',
		attrs: [ 'title="Settings"' ]
	},
	{
		tplName: 'threads',
		class: `${ns}-threads-button`,
		icon: 'fa-search',
		text: '[T]',
		attrs: [ 'title="Threads"' ]
	},
	{
		tplName: 'tools',
		class: `${ns}-tools-button`,
		icon: 'fa-gears',
		text: '[T]',
		attrs: [ 'title="Tools"' ]
	},
	{
		tplName: 'close',
		class: `${ns}-close-button`,
		icon: 'fa-times',
		text: 'X',
		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: 'i',
		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: 'r',
		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: 'view-menu',
		class: `${ns}-view-menu-button`,
		icon: 'fa-angle-down',
		text: '▾',
		attrs: [ 'href="javascript:;"' ]
	}
];


/***/ }),

/***/ "./src/components/user-template/index.js":
/*!***********************************************!*\
  !*** ./src/components/user-template/index.js ***!
  \***********************************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

const buttons = __webpack_require__(/*! ./buttons */ "./src/components/user-template/buttons.js");

// 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}-add-local-file-input`).click()),
			[`.${ns}-item-menu-button`]: 'userTemplate._handleItemMenu',
			[`.${ns}-view-menu-button`]: 'userTemplate._handleViewsMenu',
			[`.${ns}-threads-button`]: 'threads.toggle',
			[`.${ns}-tools-button`]: 'tools.toggle',
			[`.${ns}-config-button`]: noDefault(() => Player.settings.toggle()),
			[`.${ns}-favorites-button`]: 'favorites.toggle',
			[`.${ns}-player-button`]: 'playlist.restore'
		},
		change: {
			[`.${ns}-add-local-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, "3.1.2");

		// 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) {
		e.preventDefault();
		const values = [ 'all', 'one', 'none' ];
		const current = values.indexOf(Player.config.repeat);
		Player.set('repeat', values[(current + 4) % 3]);
	},

	/**
	 * Toggle the shuffle style.
	 */
	_handleShuffle: function (e) {
		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');
	},

	/**
	 * Display an item menu.
	 */
	_handleItemMenu: function (e) {
		e.preventDefault();
		e.stopPropagation();
		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({ sound }), parent);
		Player.userTemplate._showMenu(e.clientX, e.clientY, dialog, parent);
	},

	_handleViewsMenu: function (e) {
		e.preventDefault();
		e.stopPropagation();
		const dialog = createElement(Player.templates.viewsMenu());
		Player.userTemplate._showMenu(e.clientX, e.clientY, dialog);
	},

	_showMenu: function (x, y, dialog, parent) {
		Player.userTemplate._closeMenus();
		dialog.style.top = y + 'px';
		dialog.style.left = x + 'px';
		parent || (parent = Player.container);
		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}-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: response => Player.logError('There was an error downloading.', response, '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);
	},
};


/***/ }),

/***/ "./src/config/display.js":
/*!*******************************!*\
  !*** ./src/config/display.js ***!
  \*******************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = [
	{
		property: 'autoshow',
		default: true,
		title: 'Autoshow',
		description: 'Automatically show the player when the thread contains sounds.',
		displayGroup: 'Display'
	},
	{
		property: 'pauseOnHide',
		default: true,
		title: 'Pause On Hide',
		description: 'Pause the player when it\'s hidden.',
		displayGroup: 'Display'
	},
	{
		property: 'showUpdatedNotification',
		default: true,
		title: 'Show Updated Notifications',
		description: 'Show notifications when the player is successfully updated.',
		displayGroup: 'Display'
	},
	{
		title: 'Minimised Display',
		description: 'Optional displays for when the player is minimised.',
		displayGroup: 'Display',
		settings: [
			{
				property: 'pip',
				title: 'Thumbnail',
				description: 'Display a fixed thumbnail of the playing sound in the bottom right of the thread.',
				default: true
			},
			{
				property: 'maxPIPWidth',
				title: 'Max Width',
				description: 'Maximum width for the thumbnail.',
				default: '150px',
				updateStylesheet: true
			},
			{
				property: 'chanXControls',
				title: '4chan X Header Controls',
				description: 'Show playback controls in the 4chan X header. The display can be customised in Templates.',
				displayMethod: isChanX || null,
				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.',
		displayGroup: 'Display',
		settings: [
			{
				property: 'limitPostWidths',
				title: 'Enabled',
				default: true
			},
			{
				property: 'minPostWidth',
				title: 'Minimum Width',
				default: '50%'
			}
		]
	},
	{
		property: 'threadsViewStyle',
		title: 'Threads View',
		description: 'How threads in the threads view are listed.',
		displayGroup: 'Display',
		settings: [ {
			title: 'Display',
			default: 'table',
			options: {
				table: 'Table',
				board: 'Board'
			}
		} ]
	},
	{
		title: 'Colors',
		displayGroup: 'Display',
		property: 'colors',
		updateStylesheet: true,
		actions: [
			{ title: 'Match Theme', handler: 'display.forceBoardTheme' }
		],
		// These colors will be overriden with the theme defaults at initialization.
		settings: [
			{
				property: 'colors.text',
				default: '#000000',
				title: 'Text'
			},
			{
				property: 'colors.background',
				default: '#d6daf0',
				title: 'Background'
			},
			{
				property: 'colors.border',
				default: '#b7c5d9',
				title: 'Border'
			},
			{
				property: 'colors.odd_row',
				default: '#d6daf0',
				title: 'Odd Row',
			},
			{
				property: 'colors.even_row',
				default: '#b7c5d9',
				title: 'Even Row'
			},
			{
				property: 'colors.playing',
				default: '#98bff7',
				title: 'Playing Row'
			},
			{
				property: 'colors.dragging',
				default: '#c396c8',
				title: 'Dragging Row'
			},
			{
				property: 'colors.controls_background',
				default: '#3f3f44',
				title: 'Controls Background',
				description: 'The controls container element background.',
				actions: [ { title: 'Reset', handler: 'settings.reset' } ],
			},
			{
				property: 'colors.controls_inactive',
				default: '#FFFFFF',
				title: 'Control Items',
				description: 'The playback controls and played bar.',
				actions: [ { title: 'Reset', handler: 'settings.reset' } ],
			},
			{
				property: 'colors.controls_active',
				default: '#00b6f0',
				title: 'Focused Control Items',
				description: 'The control items when hovered.',
				actions: [ { title: 'Reset', handler: 'settings.reset' } ],
			},
			{
				property: 'colors.controls_empty_bar',
				default: '#131314',
				title: 'Volume/Seek Bar Background',
				decscription: 'The background of the volume and seek bars.',
				actions: [ { title: 'Reset', handler: 'settings.reset' } ],
			},
			{
				property: 'colors.controls_loaded_bar',
				default: '#5a5a5b',
				title: 'Loaded Bar Background',
				description: 'The loaded bar within the seek bar.',
				actions: [ { title: 'Reset', handler: 'settings.reset' } ],
			}
		]
	}
];


/***/ }),

/***/ "./src/config/filter.js":
/*!******************************!*\
  !*** ./src/config/filter.js ***!
  \******************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = [
	{
		property: 'allow',
		title: 'Allowed Hosts',
		description: 'Which domains sources are allowed to be loaded from.',
		default: [
			'4cdn.org',
			'catbox.moe',
			'dmca.gripe',
			'lewd.se',
			'pomf.cat',
			'zz.ht'
		],
		actions: [ { title: 'Reset', handler: 'settings.reset' } ],
		displayGroup: 'Filter',
		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' } ],
		displayGroup: 'Filter',
		split: '\n'
	}
];


/***/ }),

/***/ "./src/config/hosts.js":
/*!*****************************!*\
  !*** ./src/config/hosts.js ***!
  \*****************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = [
	{
		property: 'defaultUploadHost',
		default: 'catbox',
		parse: 'settings.setDefaultHost'
	},
	{
		property: 'uploadHosts',
		title: 'Hosts',
		actions: [
			{ title: 'Add', handler: 'settings.addUploadHost' },
			{ title: 'Restore Defaults', handler: 'settings.restoreDefaultHosts' },
		],
		displayGroup: 'Hosts',
		displayMethod: 'settings.renderHosts',
		parse: 'settings.parseHosts',
		looseCompare: true,
		dismissTextId: 'uplodHostSettings',
		dismissRestoreText: 'Show Help',
		text: 'Properties'
			+ '<br><strong>Name</strong>: A unique identifier.'
			+ '<br><strong>URL</strong>: The URL to post the file to.'
			+ '<br><strong>Response Path/Match</strong>: A key path or regular expression to locate the uploaded filename in the response.'
			+ '<br><strong>File URL Format</strong>: The URL format for uploaded sounds. %s is replaced with the result of response path/match if given or the full response.'
			+ '<br><strong>Data</strong>: The form data for the upload (as JSON). Specify the file using $file.',
		mix: true,
		default:  {
			catbox: {
				default: true,
				url: 'https://catbox.moe/user/api.php',
				data: { reqtype: 'fileupload', fileToUpload: '$file', userhash: null },
				filenameLength: 29
			},
			pomf: {
				url: 'https://pomf.cat/upload.php',
				data: { 'files[]': '$file' },
				responsePath: 'files.0.url',
				filenameLength: 23
			},
			zz: {
				url: 'https://zz.ht/api/upload',
				responsePath: 'files.0.url',
				data: {
					'files[]': '$file'
				},
				headers: {
					token: null
				},
				filenameLength: 19
			},
			lewd: {
				url: 'https://lewd.se/upload',
				data: { file: '$file' },
				headers: { token: null, shortUrl: true },
				responsePath: 'data.link',
				filenameLength: 30
			}
		}
	}
];


/***/ }),

/***/ "./src/config/index.js":
/*!*****************************!*\
  !*** ./src/config/index.js ***!
  \*****************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

module.exports = [
	// Order the groups appear in.
	...__webpack_require__(/*! ./display */ "./src/config/display.js"),
	...__webpack_require__(/*! ./filter */ "./src/config/filter.js"),
	...__webpack_require__(/*! ./keybinds */ "./src/config/keybinds.js"),
	...__webpack_require__(/*! ./templates */ "./src/config/templates.js"),
	...__webpack_require__(/*! ./hosts */ "./src/config/hosts.js"),

	{
		property: 'shuffle',
		default: false
	},
	{
		property: 'repeat',
		default: 'all'
	},
	{
		property: 'viewStyle',
		default: 'playlist'
	},
	{
		property: 'hoverImages',
		default: false
	},
	{
		property: 'showPlaylistSearch',
		deafult: true
	}
];


/***/ }),

/***/ "./src/config/keybinds.js":
/*!********************************!*\
  !*** ./src/config/keybinds.js ***!
  \********************************/
/*! no static exports found */
/***/ (function(module, exports) {

const hasMediaSession = 'mediaSession' in navigator;

module.exports = [
	{
		property: 'hardwareMediaKeys',
		title: 'Hardware Media Keys',
		displayGroup: 'Keybinds',
		description: 'Enable playback control via hardware media keys.'
			+ (!hasMediaSession ? ' Your browser does not support this feature.' : ''),
		default: hasMediaSession,
		attrs: !hasMediaSession && 'disabled'
	},
	{
		title: 'Keybinds',
		displayGroup: 'Keybinds',
		description: 'Enable keyboard shortcuts.',
		format: 'hotkeys.stringifyKey',
		parse: 'hotkeys.parseKey',
		class: `${ns}-key-input`,
		property: 'hotkey_bindings',
		settings: [
			{
				property: 'hotkeys',
				default: 'open',
				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.previousGroup',
				title: 'Previous Group',
				keyHandler: () => Player.previous({ group: true }),
				ignoreRepeat: true,
				default: { shiftKey: true, key: 'arrowleft' }
			},
			{
				property: 'hotkey_bindings.nextGroup',
				title: 'Next Group',
				keyHandler: () => Player.next({ group: true }),
				ignoreRepeat: true,
				default: { shiftKey: true, 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.toggleSearch',
				title: 'Toggle Playlist Search',
				keyHandler: () => Player.set('showPlaylistSearch', !Player.config.showPlaylistSearch),
				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: '' }
			}
		]
	}
];


/***/ }),

/***/ "./src/config/templates.js":
/*!*********************************!*\
  !*** ./src/config/templates.js ***!
  \*********************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = [
	{
		property: 'headerTemplate',
		title: 'Header',
		actions: [ { title: 'Reset', handler: 'settings.reset' } ],
		default: 'repeat-button shuffle-button hover-images-button playlist-button\nsound-name\nview-menu-button add-button reload-button close-button',
		displayGroup: 'Templates',
		displayMethod: 'textarea'
	},
	{
		property: 'rowTemplate',
		title: 'Row',
		actions: [ { title: 'Reset', handler: 'settings.reset' } ],
		default: 'sound-name h:{menu-button}',
		displayGroup: 'Templates',
		displayMethod: 'textarea'
	},
	{
		property: 'footerTemplate',
		title: 'Footer',
		actions: [ { title: 'Reset', handler: 'settings.reset' } ],
		default: 'playing-button:"sound-index /" sound-count sounds\n'
			+ 'p:{\n'
			+ '	<div style="float: right; margin-right: .5rem">\n'
			+ '		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',
		displayGroup: 'Templates',
		displayMethod: 'textarea',
		attrs: 'style="height:120px;"'
	},
	{
		property: 'chanXTemplate',
		title: '4chan X Header',
		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' } ],
		displayGroup: 'Templates',
		displayMethod: 'textarea'
	}
];


/***/ }),

/***/ "./src/file_parser.js":
/*!****************************!*\
  !*** ./src/file_parser.js ***!
  \****************************/
/*! no static exports found */
/***/ (function(module, exports) {

const protocolRE = /^(https?:)?\/\//;
const filenameRE = /(.*?)[[({](?:audio|sound)[ =:|$](.*?)[\])}]/gi;

let localCounter = 0;

module.exports = {
	parseFiles,
	parsePost,
	parseFileName
};

function parseFiles(target, postRender) {
	let addedSounds = false;
	let posts = target.classList.contains('post')
		? [ target ]
		: target.querySelectorAll(is4chan ? '.post' : 'article');

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

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

function parsePost(post, skipRender) {
	try {
		if (post.classList.contains('style-fetcher')) {
			return;
		}
		const parentParent = post.parentElement.parentElement;
		if (parentParent.id === 'qp' || post.parentElement.classList.contains('noFile')) {
			return;
		}

		// If there's a play button this post has already been parsed. Just wire up the link.
		let playLink = post.querySelector(`.${ns}-play-link`);
		if (playLink) {
			const id = playLink.getAttribute('data-id');
			playLink.onclick = () => Player.play(Player.sounds.find(sound => sound.id === id));
			return;
		}

		let filename = null;
		let filenameLocations;

		// For the archive the OP and replies selector differs
		// For 4chan there's native / 4chan X / 4chan X with file info formatting
		if (!is4chan) {
			filenameLocations = {
				'.thread_image_box .post_file_filename': 'textContent',
				'.post_file_filename': 'title'
			};
		} else {
			filenameLocations = {
				'.fileText .file-info .fnfull': 'textContent',
				'.fileText .file-info > a': 'textContent',
				'.fileText > a': 'title',
				'.fileText': 'textContent'
			};
		}

		Object.keys(filenameLocations).some(function (selector) {
			const node = post.querySelector(selector);
			return node && (filename = node[filenameLocations[selector]]);
		});

		if (!filename) {
			return;
		}

		const postID = post.id.slice(is4chan ? 1 : 0);
		const fileThumb = post.querySelector(is4chan ? '.fileThumb' : '.thread_image_link');
		const imageSrc = fileThumb && fileThumb.href;
		const thumbImg = fileThumb && fileThumb.querySelector('img');
		const thumbSrc = thumbImg && thumbImg.src;
		const imageMD5 = thumbImg && thumbImg.getAttribute('data-md5');

		const sounds = parseFileName(filename, imageSrc, postID, thumbSrc, imageMD5);

		if (!sounds.length) {
			return;
		}

		// Create a play link
		const firstID = sounds[0].id;
		const text = is4chan ? 'play' : 'Play';
		const clss = `${ns}-play-link` + (is4chan ? '' : ' btnr');
		let playLinkParent;
		if (is4chan) {
			playLinkParent = post.querySelector('.fileText');
			playLinkParent.appendChild(document.createTextNode(' '));
		} else {
			playLinkParent = post.querySelector('.post_controls');
		}
		playLink = createElement(`<a href="javascript:;" class="${clss}" data-id="${firstID}">${text}</a>`, playLinkParent);
		playLink.onclick = () => Player.play(Player.sounds.find(sound => sound.id === firstID));

		// Don't add sounds from inline quotes of posts in the thread
		sounds.forEach(sound => Player.add(sound, skipRender));
		return sounds.length > 0;
	} catch (err) {
		Player.logError('There was an issue parsing the files. Please check the console for details.', err);
		console.log('[4chan sounds player]', post);
	}
}

function parseFileName(filename, image, post, thumb, imageMD5, bypassVerification) {
	if (!filename) {
		return [];
	}
	filename = filename.replace(/-/, '/');
	const matches = [];
	let match;
	while ((match = filenameRE.exec(filename)) !== null) {
		matches.push(match);
	}
	const defaultName = matches[0] && matches[0][1] || post || 'Local Sound ' + localCounter;
	matches.length && !post && localCounter++;

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

		try {
			if (src.includes('%')) {
				src = decodeURIComponent(src);
			}

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

		const sound = { src, id, title, post, image, filename, thumb, imageMD5 };
		if (bypassVerification || Player.acceptedSound(sound)) {
			sounds.push(sound);
		}
		return sounds;
	}, []);
}


/***/ }),

/***/ "./src/globals.js":
/*!************************!*\
  !*** ./src/globals.js ***!
  \************************/
/*! no static exports found */
/***/ (function(module, exports) {

/**
 * Global variables and helpers.
 */

window.ns = 'fc-sounds';

window.is4chan = location.hostname.includes('4chan.org') || location.hostname.includes('4channel.org');
window.isChanX = document.documentElement.classList.contains('fourchan-x');
window.Board = location.pathname.split('/')[1];

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) {
	if (typeof path !== 'string') {
		return 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;
};

/**
 * Check two values are equal. Arrays/Objects are deep checked.
 */
window._isEqual = function (a, b, strict = true) {
	if (typeof a !== typeof b) {
		return false;
	}
	if (Array.isArray(a, b)) {
		return a === b || a.length === b.length && a.every((_a, i) => _isEqual(_a, b[i]));
	}
	if (a && b && typeof a === 'object' && a !== b) {
		const allKeys = Object.keys(a);
		allKeys.push(...Object.keys(b).filter(k => !allKeys.includes(k)));
		return allKeys.every(key => _isEqual(a[key], b[key]));
	}
	return strict
		? a === b
		: a == b;
};

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

class PlayerError extends Error {
	constructor(msg, type, err) {
		super(msg);
		this.reason = msg;
		this.type = type;
		this.error = err;
	}
}
window.PlayerError = PlayerError;


/***/ }),

/***/ "./src/main.js":
/*!*********************!*\
  !*** ./src/main.js ***!
  \*********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _globals__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./globals */ "./src/globals.js");
/* harmony import */ var _globals__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_globals__WEBPACK_IMPORTED_MODULE_0__);




async function doInit() {
	// Wait for 4chan X if it's installed and not finished initialising.
	if (!isChanX && (isChanX = document.documentElement.classList.contains('fourchan-x'))) {
		return;
	}

	// Require these here so every other require is sure of the 4chan X state.
	const Player = __webpack_require__(/*! ./player */ "./src/player.js");
	const { parseFiles } = __webpack_require__(/*! ./file_parser */ "./src/file_parser.js");

	// The player tends to be all black without this timeout.
	// Something with the timing of the stylesheet loading and applying the board theme.
	setTimeout(async function () {
		await Player.initialize();

		parseFiles(document.body, true);

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

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

document.addEventListener('4chanXInitFinished', function () {
	const wasChanX = isChanX;
	isChanX = true;
	if (wasChanX) {
		doInit();
	}
	Player.display.initChanX();
});

// If it's already known 4chan X is installed this can be skipped.
if (!isChanX) {
	if (document.readyState !== 'loading') {
		doInit();
	} else {
		document.addEventListener('DOMContentLoaded', doInit);
	}
}



/***/ }),

/***/ "./src/migrations.js":
/*!***************************!*\
  !*** ./src/migrations.js ***!
  \***************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = [
	{
		version: '3.0.0',
		name: 'hosts-filename-length',
		async run() {
			const defaultHosts = Player.settings.findDefault('uploadHosts').default;
			Object.keys(defaultHosts).forEach(host => {
				Player.config.uploadHosts[host].filenameLength = defaultHosts[host].filenameLength;
			});
		}
	}
];


/***/ }),

/***/ "./src/player.js":
/*!***********************!*\
  !*** ./src/player.js ***!
  \***********************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

const components = {
	// Settings must be first.
	settings: __webpack_require__(/*! ./components/settings */ "./src/components/settings.js"),
	controls: __webpack_require__(/*! ./components/controls */ "./src/components/controls.js"),
	display: __webpack_require__(/*! ./components/display */ "./src/components/display.js"),
	events: __webpack_require__(/*! ./components/events */ "./src/components/events.js"),
	footer: __webpack_require__(/*! ./components/footer */ "./src/components/footer.js"),
	header: __webpack_require__(/*! ./components/header */ "./src/components/header.js"),
	hotkeys: __webpack_require__(/*! ./components/hotkeys */ "./src/components/hotkeys.js"),
	minimised: __webpack_require__(/*! ./components/minimised */ "./src/components/minimised.js"),
	playlist: __webpack_require__(/*! ./components/playlist */ "./src/components/playlist.js"),
	position: __webpack_require__(/*! ./components/position */ "./src/components/position.js"),
	threads: __webpack_require__(/*! ./components/threads */ "./src/components/threads.js"),
	tools: __webpack_require__(/*! ./components/tools */ "./src/components/tools.js"),
	userTemplate: __webpack_require__(/*! ./components/user-template */ "./src/components/user-template/index.js")
};

// Create a global ref to the player.
const Player = window.Player = module.exports = {
	ns,

	audio: new Audio(),
	sounds: [],
	isHidden: true,
	container: null,
	ui: {},
	_public: [],

	// 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__(/*! ./templates/body.tpl */ "./src/templates/body.tpl"),
		controls: __webpack_require__(/*! ./templates/controls.tpl */ "./src/templates/controls.tpl"),
		css: __webpack_require__(/*! ./scss/style.scss */ "./src/scss/style.scss"),
		footer: __webpack_require__(/*! ./templates/footer.tpl */ "./src/templates/footer.tpl"),
		header: __webpack_require__(/*! ./templates/header.tpl */ "./src/templates/header.tpl"),
		hostInput: __webpack_require__(/*! ./templates/host_input.tpl */ "./src/templates/host_input.tpl"),
		itemMenu: __webpack_require__(/*! ./templates/item_menu.tpl */ "./src/templates/item_menu.tpl"),
		list: __webpack_require__(/*! ./templates/list.tpl */ "./src/templates/list.tpl"),
		player: __webpack_require__(/*! ./templates/player.tpl */ "./src/templates/player.tpl"),
		settings: __webpack_require__(/*! ./templates/settings.tpl */ "./src/templates/settings.tpl"),
		threads: __webpack_require__(/*! ./templates/threads.tpl */ "./src/templates/threads.tpl"),
		threadBoards: __webpack_require__(/*! ./templates/thread_boards.tpl */ "./src/templates/thread_boards.tpl"),
		threadList: __webpack_require__(/*! ./templates/thread_list.tpl */ "./src/templates/thread_list.tpl"),
		tools: __webpack_require__(/*! ./templates/tools.tpl */ "./src/templates/tools.tpl"),
		viewsMenu: __webpack_require__(/*! ./templates/views_menu.tpl */ "./src/templates/views_menu.tpl")
	},

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

			// Expose some functionality via PlayerEvent custom events.
			document.addEventListener('PlayerEvent', e => {
				if (e.detail.action && ( true || false)) {
					return _get(Player, e.detail.action).apply(window, e.detail.arguments);
				}
			});

			// Render the player, but not neccessarily show it.
			Player.display.render();
		} catch (err) {
			Player.logError('There was an error initialzing the sound player. Please check the console for details.', 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) => typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener(property, (_prop, oldValue, newValue, remote) => {
		remote && callback(newValue, oldValue);
	}),

	/**
	 * Log errors and show an error notification.
	 */
	logError: function (message, error, type) {
		console.error('[4chan sounds player]', message, error);
		if (error instanceof PlayerError) {
			error.error && console.error('[4chan sound player]', error.error);
			message = error.reason;
			type = error.type || type;
		}
		Player.alert(message, type || 'error', 5);
	},

	/**
	 * Show a notification using 4chan X or the native extention.
	 */
	alert: function (content, type = 'info', lifetime = 5) {
		if (isChanX) {
			content = createElement(`<span>${content}</span`);
			document.dispatchEvent(new CustomEvent('CreateNotification', {
				bubbles: true,
				detail: { content, type, lifetime }
			}));
		} else if (typeof Feedback !== 'undefined') {
			Feedback.showMessage(content, type === 'info' ? 'notify' : 'error', lifetime * 1000);
		}
	}
};

// 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]);
	(Player[name].public || []).forEach(k => {
		Player._public.push((Player[name].atRoot || []).includes(k) ? k : `${name}.${k}`);
	});
}


/***/ }),

/***/ "./src/scss/style.scss":
/*!*****************************!*\
  !*** ./src/scss/style.scss ***!
  \*****************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = (data = {}) => `.${ns}-text-muted {
  color: #909090;
}

.${ns}-controls {
  align-items: center;
  padding: 0.5rem;
  background: ${Player.config.colors.controls_background};
}

.${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: 0.8rem;
  background: ${Player.config.colors.controls_inactive};
}
.${ns}-media-control:hover > div {
  background: ${Player.config.colors.controls_active};
}

.${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: ${Player.config.colors.controls_inactive};
}

.${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: 0.3rem;
  width: 100%;
  background: ${Player.config.colors.controls_empty_bar};
  border-radius: 1rem;
  position: relative;
}
.${ns}-progress-bar .${ns}-full-bar > div {
  position: absolute;
  top: 0;
  bottom: 0;
  border-radius: 1rem;
}
.${ns}-progress-bar .${ns}-full-bar .${ns}-loaded-bar {
  background: ${Player.config.colors.controls_loaded_bar};
}
.${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: ${Player.config.colors.controls_inactive};
  height: 0.8rem;
  min-width: 0.8rem;
  border-radius: 1rem;
  box-shadow: rgba(0, 0, 0, 0.76) 0 0 3px 0;
}
.${ns}-progress-bar:hover .${ns}-current-bar:after {
  background: ${Player.config.colors.controls_active};
}

.${ns}-seek-bar .${ns}-current-bar {
  background: ${Player.config.colors.controls_active};
}

.${ns}-volume-bar .${ns}-current-bar {
  background: ${Player.config.colors.controls_inactive};
}

.${ns}-chan-x-controls {
  align-items: inherit;
}
.${ns}-chan-x-controls .${ns}-current-time, .${ns}-chan-x-controls .${ns}-duration {
  margin: 0 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: 0.7rem;
  width: 0.5rem;
}

.${ns}-footer {
  padding: 0.15rem 0.25rem;
  border-top: solid 1px ${Player.config.colors.border};
}
.${ns}-footer .${ns}-expander {
  position: absolute;
  bottom: 0px;
  right: 0px;
  height: 0.75rem;
  width: 0.75rem;
  cursor: se-resize;
  background: linear-gradient(to bottom right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 50%, ${Player.config.colors.border} 55%, ${Player.config.colors.border} 100%);
}
.${ns}-footer:hover .${ns}-hover-display {
  display: inline-block;
}

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

html:not(.fourchan-x) .${ns}-header .${ns}-col-auto {
  margin: 0 0.075rem;
}

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

.${ns}-image-link {
  text-align: center;
  display: flex;
  justify-items: center;
  justify-content: center;
}
.${ns}-image-link.${ns}-pip {
  position: fixed;
  right: 10px;
  height: ${Player.config.maxPIPWidth} !important;
  max-width: ${Player.config.maxPIPWidth};
  align-items: end;
}
.${ns}-image-link.${ns}-pip .${ns}-image, .${ns}-image-link.${ns}-pip .${ns}-video {
  max-height: 100%;
  height: initial;
  width: initial;
  object-fit: contain;
}

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

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

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

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

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

.${ns}-heading {
  font-weight: 600;
  margin: 0.5rem 0;
}

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

.${ns}-heading-action {
  font-weight: normal;
  text-decoration: underline;
  margin-left: 0.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%;
}

.${ns}-align-center {
  align-items: center;
  align-content: center;
  align-self: center;
}

.${ns}-space-between {
  justify-content: space-between;
}

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

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

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

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, 0.15);
  border-radius: 3px;
  padding-top: 1px;
  padding-bottom: 3px;
}
html:not(.fourchan-x) .${ns}-menu .entry {
  position: relative;
  display: block;
  padding: 0.125rem 0.5rem;
  min-width: 70px;
  white-space: nowrap;
}
html:not(.fourchan-x) .${ns}-menu .has-submenu::after {
  content: "";
  border-left: 0.5em solid;
  border-top: 0.3em solid transparent;
  border-bottom: 0.3em solid transparent;
  display: inline-block;
  margin: 0.35em;
  position: absolute;
  right: 3px;
}
html:not(.fourchan-x) .${ns}-menu .submenu {
  position: absolute;
  display: none;
}
html:not(.fourchan-x) .${ns}-menu .focused > .submenu {
  display: block;
}

.${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;
}
.${ns}-list-container .${ns}-list-item {
  list-style-type: none;
  padding: 0.15rem 0.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: 0.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};
}

.${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;
}
.${ns}-settings .${ns}-settings-tabs {
  align-items: center;
  align-content: center;
  justify-content: center;
}
.${ns}-settings .${ns}-settings-tab {
  margin: 0.25rem;
  text-decoration: underline;
  text-align: center;
}
.${ns}-settings .${ns}-settings-tab.active {
  font-weight: bold;
}
.${ns}-settings .${ns}-settings-group {
  display: none;
}
.${ns}-settings .${ns}-settings-group.active {
  display: block;
}
.${ns}-settings .${ns}-host-input {
  margin: 0.5rem 0;
  border-top: solid 1px ${Player.config.colors.border};
}
.${ns}-settings .${ns}-host-input.invalid {
  border: solid 1px red;
}
.${ns}-settings .${ns}-host-input .${ns}-host-controls {
  align-items: center;
  justify-content: space-between;
  margin: 0.125rem 0;
}
.${ns}-settings .${ns}-host-input input[type=text] {
  min-width: 100%;
  box-sizing: border-box;
}

.${ns}-threads .${ns}-thread-board-list label {
  display: inline-block;
  width: 4rem;
}
.${ns}-threads .${ns}-thread-list {
  margin: 1rem -0.25rem 0;
  padding: 0.5rem 1rem;
  border-top: solid 1px ${Player.config.colors.border};
}
.${ns}-threads .${ns}-thread-list .boardBanner {
  margin: 1rem 0;
}
.${ns}-threads table {
  margin-top: 0.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: 0.25rem;
}
.${ns}-threads table tr {
  padding: 0.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}-create-sound-status {
  margin-top: 0.5rem;
  border: solid 1px ${Player.config.colors.border};
  border-radius: 5px;
  padding: 0.25rem;
}

.${ns}-file-input, .${ns}-tools input[type=text] {
  width: 100%;
  color: black;
}

.${ns}-file-overlay, .${ns}-tools input[type=text] {
  box-sizing: border-box;
  height: 1.5rem;
  border: solid 1px ${Player.config.colors.border};
  min-width: 5rem;
  display: flex;
  align-items: center;
  padding: 0 0.25rem;
  margin: 0;
}

.${ns}-file-input.placeholder span, .${ns}-create-sound-form input[type=text]::placeholder {
  color: #AAA;
  opacity: 1;
}

.${ns}-file-input .${ns}-file-overlay {
  position: relative;
  background: white;
}
.${ns}-file-input .placeholder-text {
  display: none;
}
.${ns}-file-input.placeholder .placeholder-text {
  display: inherit;
}
.${ns}-file-input span {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.${ns}-file-input input[type=file] {
  width: 100%;
  box-sizing: border-box;
  height: 100%;
  position: absolute;
  left: 0;
  opacity: 0;
}
.${ns}-file-input .overfile {
  z-index: 9999;
}
.${ns}-file-input .${ns}-file-list {
  padding: 0 0.25rem;
}
.${ns}-file-input .${ns}-file-list:empty {
  display: none;
}

.${ns}-input-append {
  position: absolute;
  display: flex;
  align-items: center;
  background: white;
  padding-left: 0.25rem;
  right: 0.125rem;
}

.${ns}-threads, .${ns}-settings, .${ns}-tools, .${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=tools] .${ns}-tools {
  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, #${ns}-container[data-view-style=image] .${ns}-playlist-search {
  display: none;
}
#${ns}-container[data-view-style=image] .${ns}-image-link {
  height: auto;
}

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

#${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);
  opacity: 0.7;
}
#${ns}-container[data-view-style=fullscreen] .${ns}-controls:hover {
  bottom: 0;
}`

/***/ }),

/***/ "./src/templates/body.tpl":
/*!********************************!*\
  !*** ./src/templates/body.tpl ***!
  \********************************/
/*! no static exports found */
/***/ (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 class="${ns}-tools ${ns}-panel" style="height: 400px">
			${Player.templates.tools(data)}
		</div>
	</div>
	<div class="${ns}-footer">
		${Player.templates.footer(data)}
	</div>
	<input class="${ns}-add-local-file-input" type="file" style="display: none" accept="image/*,.webm" multiple>
</div>`

/***/ }),

/***/ "./src/templates/controls.tpl":
/*!************************************!*\
  !*** ./src/templates/controls.tpl ***!
  \************************************/
/*! no static exports found */
/***/ (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>
		<span class="${ns}-current-time">0:00</span>
		<span class="${ns}-text-muted"> / <span class="${ns}-duration">0:00</span></span>
	</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>`

/***/ }),

/***/ "./src/templates/footer.tpl":
/*!**********************************!*\
  !*** ./src/templates/footer.tpl ***!
  \**********************************/
/*! no static exports found */
/***/ (function(module, exports) {

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

/***/ }),

/***/ "./src/templates/header.tpl":
/*!**********************************!*\
  !*** ./src/templates/header.tpl ***!
  \**********************************/
/*! no static exports found */
/***/ (function(module, exports) {

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


/***/ }),

/***/ "./src/templates/host_input.tpl":
/*!**************************************!*\
  !*** ./src/templates/host_input.tpl ***!
  \**************************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = (data = {}) => {
	// data is the host name
	const host = Player.config.uploadHosts[data];
	if (!host) {
		return '';
	}
	return `<div class="${ns}-row ${ns}-col ${ns}-host-input ${host.invalid ? 'invalid' : ''}" data-host-name="${data}">
		<div class="${ns}-row ${ns}-host-controls">
			<div class="${ns}-col-auto">
				<label>
					<input type="checkbox" data-property="defaultUploadHost" ${Player.config.defaultUploadHost === data ? 'checked': ''}>
					Default
				</label>
			</div>
			<div class="${ns}-col-auto"><a href="#" class="${ns}-heading-action ${ns}-remove-host" data-handler="settings.removeHost" data-property="uploadHosts">Remove</a></div>
		</div>
		<div class="${ns}-row">
			<div class="${ns}-col"><input type="text" data-property="uploadHosts" name="name" value="${data || ''}" placeholder="Name"></div>
			<div class="${ns}-col"><input type="text" data-property="uploadHosts" name="url" value="${host.url || ''}" placeholder="URL"></div>
			<div class="${ns}-col"><input type="text" data-property="uploadHosts" name="responsePath" value="${host.responsePath || ''}" placeholder="Response Path"></div>
			<div class="${ns}-col"><input type="text" data-property="uploadHosts" name="responseMatch" value="${host.responseMatch || ''}" placeholder="Response Match"></div>
			<div class="${ns}-col"><input type="text" data-property="uploadHosts" name="soundUrl" value="${host.soundUrl || ''}" placeholder="File URL Format"></div>
		</div>
		<div class="${ns}-row">
			<div class="${ns}-col"><textarea data-property="uploadHosts" name="data" placeholder="Data (JSON)">${JSON.stringify(host.data, null, 4)}</textarea></div>
		</div>
		<div class="${ns}-row">
			<div class="${ns}-col"><textarea data-property="uploadHosts" name="headers" placeholder="Headers (JSON)">${host.headers ? JSON.stringify(host.headers, null, 4) : ''}</textarea></div>
		</div>
	</div>`;
}

/***/ }),

/***/ "./src/templates/item_menu.tpl":
/*!*************************************!*\
  !*** ./src/templates/item_menu.tpl ***!
  \*************************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = (data = {}) => `<div class="${ns}-menu dialog" id="menu" tabindex="0" data-type="post" style="position: fixed;">
	<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>`


/***/ }),

/***/ "./src/templates/list.tpl":
/*!********************************!*\
  !*** ./src/templates/list.tpl ***!
  \********************************/
/*! no static exports found */
/***/ (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}" ${!Player.playlist.matchesSearch(sound) ? 'style="display: none"' : ''} draggable="true">
		${Player.userTemplate.build({
			template: Player.config.rowTemplate,
			sound,
			outerClass: `${ns}-col-auto`
		})}
	</div>`
).join('')

/***/ }),

/***/ "./src/templates/player.tpl":
/*!**********************************!*\
  !*** ./src/templates/player.tpl ***!
  \**********************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = (data = {}) => `<div class="${ns}-media">
	<a class="${ns}-image-link" target="_blank">
		<img class="${ns}-image" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDUyIiBoZWlnaHQ9IjI1NyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Zz48dGV4dCBmb250LXdlaWdodD0iYm9sZCIgZm9udC1zdHlsZT0iaXRhbGljIiBmb250LWZhbWlseT0iSGVsdmV0aWNhLCBBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNTAiIHk9IjE5NyIgZmlsbC1vcGFjaXR5PSIwLjA1IiBmaWxsPSIjMDAwMDAwIj40c3A8L3RleHQ+PC9nPjwvc3ZnPg=="></img>
		<video class="${ns}-video"></video>
	</a>
	<div class="${ns}-controls ${ns}-row">
		${Player.templates.controls(data)}
	</div>
</div>
<input class="${ns}-playlist-search" type="input" placeholder="Search" style="min-width: 100%; box-sizing: border-box; ${!Player.config.showPlaylistSearch ? 'display: none;' : ''}">
<div class="${ns}-list-container style="height: 100px">
	${Player.templates.list(data)}
</div>
<img class="${ns}-hover-image">`

/***/ }),

/***/ "./src/templates/settings.tpl":
/*!************************************!*\
  !*** ./src/templates/settings.tpl ***!
  \************************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

module.exports = (data = {}) => {
	const settingsConfig = __webpack_require__(/*! config */ "./src/config/index.js");
	const groups = settingsConfig.reduce((groups, setting) => {
		if (setting.displayGroup) {
			groups[setting.displayGroup] || (groups[setting.displayGroup] = []);
			groups[setting.displayGroup].push(setting);
		}
		return groups;
	}, {});

	let tpl = `<div class="${ns}-settings-tabs ${ns}-row">
		${Object.keys(groups).map(name => 
			`<a href="javascript:;" class="${ns}-col-auto ${ns}-settings-tab ${Player.settings.view !== name ? '' : 'active'}" data-group="${name}">${name}</a>`
		).join(' | ')}
		| <a href="${Player.settings.changelog}" class="${ns}-col-auto ${ns}-settings-tab" target="_blank">v${"3.1.2"}</a>
	</div>`;

	Object.keys(groups).forEach(name => {
		tpl += `<div class="${ns}-settings-group ${Player.settings.view !== name ? '' : 'active'}" data-group="${name}">`;

		groups[name].forEach(function addSetting(setting) {
			// Filter settings with a null display method;
			if (setting.displayMethod === null) {
				return;
			}
			const desc = setting.description;

			tpl += `
			<div class="${ns}-row ${ns}-align-center ${setting.isSubSetting ? `${ns}-sub-settings` : ''}">
				<div class="${ns}-col ${!setting.isSubSetting ? `${ns}-heading` : `${ns}-space-between`} ${desc ? `${ns}-has-description` : ''}" ${desc ? `title="${desc.replace(/"/g, '&quot;')}"` : ''}>
					${setting.title}
					<div style="display: inline-block; margin-right: .25rem">
						${(setting.actions || []).map(action => `<a href="#" class="${ns}-heading-action" data-handler="${action.handler}" data-property="${setting.property}">${action.title}</a>`).join(' ')}
					</div>
				</div>`;
				if (setting.text) {
					tpl += setting.dismissTextId
						? `<div class="${ns}-col" style="min-width: 100%">`
								+ Player.display.ifNotDismissed(
									setting.dismissTextId,
									setting.dismissRestoreText,
									`<div data-dismiss-id="${setting.dismissTextId}">`
										+ setting.text
										+ `<a href="javascript:;" class="${ns}-dismiss-link" data-dismiss="${setting.dismissTextId}" style="display:block; margin-top:.25rem">Dismiss</a>`
									+ `</div>`
								)
							+ `</div>`
						: setting.text;
				};

				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}"`,
						displayMethod = setting.displayMethod,
						displayMethodFunction = _get(Player, displayMethod);

					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 += typeof displayMethodFunction === 'function'
							? displayMethodFunction(value, attrs)

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

						: displayMethod === 'textarea' || type === 'object'
							? `<div class="${ns}-row ${ns}-col"><textarea ${attrs}>${value}</textarea></div>`

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

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

		tpl += '</div>';
	});

	return tpl;
}

/***/ }),

/***/ "./src/templates/thread_boards.tpl":
/*!*****************************************!*\
  !*** ./src/templates/thread_boards.tpl ***!
  \*****************************************/
/*! no static exports found */
/***/ (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('')

/***/ }),

/***/ "./src/templates/thread_list.tpl":
/*!***************************************!*\
  !*** ./src/templates/thread_list.tpl ***!
  \***************************************/
/*! no static exports found */
/***/ (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('')


/***/ }),

/***/ "./src/templates/threads.tpl":
/*!***********************************!*\
  !*** ./src/templates/threads.tpl ***!
  \***********************************/
/*! no static exports found */
/***/ (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="#">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="#">${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>`


/***/ }),

/***/ "./src/templates/tools.tpl":
/*!*********************************!*\
  !*** ./src/templates/tools.tpl ***!
  \*********************************/
/*! no static exports found */
/***/ (function(module, exports) {

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

<div class="${ns}-heading">
	Create Sound Image
</div>
<div class="${ns}-create-sound-form">
	<div class="${ns}-row" style="margin-bottom: .5rem">
		${Player.display.ifNotDismissed('createSoundDetails', 'Show Help',
		`<div class="${ns}-col" data-dismiss-id="createSoundDetails">
			Select an image and sound to combine as a sound image.
			The sound will be uploaded to the selected file host and the url will be added to the image filename.
			If you have an account for a host that you would like to use then make the required changes in the <a class="${ns}-host-setting-link" href="#">host config</a>.
			That typically means providing a user token in the data or headers.<br>
			${Player.tools.hasFFmpeg
				? 'Selecting a webm with audio as the image will split it into a video only webm to be posted and ogg audio file to be uploaded.'
				: 'For a webm with audio first split the webm into a separate video and audio file and select them both.'
			}
			<br>
			Multiple sound files, or a comma-separated list of sound URLs, can be given for a single image.
			If you do have multiple sounds the name will also be a considered comma-separated list.<br>
			<a href="javascript:;" class="${ns}-dismiss-link" data-dismiss="createSoundDetails">Dismiss</a>
		</div>`)}
	</div>
	<div class="${ns}-row">
		Host
	</div>
	<div class="${ns}-row">
		<div class="${ns}-col">
			<select class="${ns}-create-sound-host">
				${Object.keys(Player.config.uploadHosts).map((hostId, i) =>
					Player.config.uploadHosts[hostId] && !Player.config.uploadHosts[hostId].invalid
						? `<option value="${hostId}" ${Player.config.defaultUploadHost === hostId ? 'selected' : ''}>${hostId}</option>`
						: ''
				).join('')}
			</select>
		</div>
	</div>
	<div class="${ns}-row" style="margin-top: .25rem">
		Data
	</div>
	<div class="${ns}-row">
		<div class="${ns}-col">
			<input type="text" class="${ns}-create-sound-name" placeholder="Name/s">
		</div>
	</div>
	<div class="${ns}-row">
		<div class="${ns}-col">
			<div class="${ns}-file-input placeholder">
				<div class="${ns}-file-overlay">
				<span class="placeholder-text">Select/Drop Image</span>
				<span class="text"></span>
				<input class="${ns}-create-sound-img" type="file" accept="image/*,.webm">
				</div>
			</div>
		</div>
		<div class="${ns}-col">
			<div class="${ns}-file-input placeholder" ${Player.tools.useSoundURL ? 'display: none;' : ''}>
				<div class="${ns}-file-overlay">
					<span class="placeholder-text">Select/Drop Sound/s</span>
					<span class="text"></span>
					<div class="overfile ${ns}-input-append">
						${Player.tools.hasFFmpeg && `<label class="${ns}-use-video-label" style="display: none;">Use video<input type="checkbox" class="${ns}-use-video"></label>` || ''}
						<a href="#" class="${ns}-toggle-sound-input fa fa-link" data-type="url" title="Enter a URL of a previously uploaded file.">U</a>
					</div>
					<input class="${ns}-create-sound-snd" type="file" accept="audio/*,video/*" multiple>
				</div>
				<div class="${ns}-file-list"></div>
			</div>
			<div class="${ns}-row ${ns}-align-center" style="position: relative; ${Player.tools.useSoundURL ? '' : 'display: none;'}">
				<a href="#" class="${ns}-toggle-sound-input ${ns}-input-append" data-type="file" title="Select a file to upload.">
					<span class="fa fa-file-sound-o" style="margin-right: .125rem">F</span>
				</a>
				<input type="text" class="${ns}-create-sound-snd-url" placeholder="Sound URL/s" style="min-width: 100%;">
			</div>
		</div>
	</div>
	<div class="${ns}-row" style="margin-top: .5rem">
		<div class="${ns}-col-auto">
			<button class="${ns}-create-button">Create</button>
		</div>
	</div>
</div>
<div class="${ns}-create-sound-status" ${Player.tools.createStatusText ? '' : 'style="display: none"'}>
	${Player.tools.createStatusText}
</div>`


/***/ }),

/***/ "./src/templates/views_menu.tpl":
/*!**************************************!*\
  !*** ./src/templates/views_menu.tpl ***!
  \**************************************/
/*! no static exports found */
/***/ (function(module, exports) {

module.exports = (data = {}) => `<div class="${ns}-menu dialog" id="menu" tabindex="0" data-type="post" style="position: fixed;">
	${['playlist', 'image' ].includes(Player.config.viewStyle) ? '' : `<a class="${ns}-player-button entry" href="javascript:;">Player</a>`}
	${Player.config.viewStyle === 'settings' ? '' : `<a class="${ns}-config-button entry" href="javascript:;">Settings</a>`}
	${Player.config.viewStyle === 'threads' ? ''  : `<a class="${ns}-threads-button entry" href="javascript:;">Threads</a>`}
	${Player.config.viewStyle === 'tools' ? ''    : `<a class="${ns}-tools-button entry" href="javascript:;">Tools</a>`}
</div>`


/***/ })

/******/ });