Greasy Fork

Greasy Fork is available in English.

4chan sounds player

Play that faggy music weeb boi

当前为 2020-05-10 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         4chan sounds player
// @version      1.1.0
// @namespace    rccom
// @description  Play that faggy music weeb boi
// @author       RCC
// @match        *://boards.4chan.org/*
// @match        *://boards.4channel.org/*
// @grant        GM.getValue
// @grant        GM.setValue
// @run-at       document-start
// ==/UserScript==

(function() {
	'use strict';

	let isChanX;

	const ns = 'fc-sounds';

	function _logError(message, type = 'error') {
		console.error(message);
		document.dispatchEvent(new CustomEvent("CreateNotification", {
			bubbles: true,
			detail: {
				type: type,
				content: message,
				lifetime: 5
			}
		}));
	}

	function _set(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;
	}

	function _get(object, path, dflt) {
		const props = path.split('.');
		return props.reduce((obj, k) => obj && obj[k], object) || dflt;
	}

	function toDuration(number) {
		number = Math.floor(number || 0);
		let seconds = number % 60;
		const minutes = Math.floor(number / 60) % 60;
		const hours = Math.floor(number / 60 / 60);
		seconds < 10 && (seconds = '0' + seconds);
		return (hours ? hours + ':' : '') + minutes + ':' + seconds;
	}

	function _mix (to, from) {
		for (let key in from || {}) {
			if (from[key] && typeof from[key] === 'object' && !Array.isArray(from[key])) {
				to[key] || (to[key] = {});
				_mix(to[key], from[key]);
			} else {
				to[key] = from[key];
			}
		}
	}

	const settingsConfig = [
	{
		property: 'shuffle',
		default: false
	},
	{
		property: 'repeat',
		default: 'all'
	},
	{
		property: 'viewStyle',
		default: 'playlist'
	},
	{
		property: 'autoshow',
		default: true,
		title: 'Autoshow',
		description: 'Automatically show the player when the thread contains sounds.',
		showInSettings: true
	},
	{
		property: 'pauseOnHide',
		default: true,
		title: 'Pause on hide',
		description: 'Pause the player when it\'s hidden.',
		showInSettings: true
	},
	{
		property: 'hotkeys',
		default: 'open',
		title: 'Hotkeys',
		description: 'Enable hot keys for controlling the player playback.',
		showInSettings: true,
		handler: 'hotkeys.apply',
		options: [
			[ 'always', 'Always' ],
			[ 'open', 'Only with the player open' ],
			[ 'never', 'Never' ]
		]
	},
	{
		title: 'Hotkey Bindings',
		showInSettings: true,
		format: 'hotkeys.stringifyKey',
		parse: 'hotkeys.parseKey',
		class: `${ns}-key-input`,
		property: 'hotkey_bindings',
		settings: [
			{
				property: 'hotkey_bindings.playPause',
				title: 'Play/Pause',
				keyHandler: 'togglePlay',
				ignoreRepeat: true,
				default: { key: ' ' }
			},
			{
				property: 'hotkey_bindings.previous',
				title: 'Previous',
				keyHandler: 'previous',
				ignoreRepeat: true,
				default: { key: 'arrowleft' }
			},
			{
				property: 'hotkey_bindings.next',
				title: 'Next',
				keyHandler: 'next',
				ignoreRepeat: true,
				default: { key: 'arrowright' }
			},
			{
				property: 'hotkey_bindings.volumeUp',
				title: 'Volume Up',
				keyHandler: 'hotkeys.volumeUp',
				default: { shiftKey: true, key: 'arrowup' }
			},
			{
				property: 'hotkey_bindings.volumeDown',
				title: 'Volume Down',
				keyHandler: 'hotkeys.volumeDown',
				default: { shiftKey: true, key: 'arrowdown' }
			}
		]
	},
	{
		property: 'allow',
		default: [
			'4cdn.org',
			'catbox.moe',
			'dmca.gripe',
			'lewd.se',
			'pomf.cat',
			'zz.ht'
		],
		title: 'Allow',
		description: 'Which domains sources are allowed to be loaded from.',
		showInSettings: true,
		split: '\n'
	},
	{
		title: 'Colors',
		showInSettings: true,
		property: 'colors',
		settings: [
			{
				property: 'colors.background',
				default: '#d6daf0',
				title: 'Background Color'
			},
			{
				property: 'colors.border',
				default: '#b7c5d9',
				title: 'Border Color'
			},
			{
				property: 'colors.odd_row',
				default: '#d6daf0',
				title: 'Odd Row Color',
			},
			{
				property: 'colors.even_row',
				default: '#b7c5d9',
				title: 'Even Row Color'
			},
			{
				property: 'colors.playing',
				default: '#98bff7',
				title: 'Playing Row Color'
			},
			{
				property: 'colors.expander',
				default: '#808bbf',
				title: 'Expander Color'
			}
		]
	}
]

	const Player = {};

const components = {
	controls: {
	atRoot: [ 'togglePlay', 'play', 'pause', 'next', 'previous' ],

	delegatedEvents: {
		click: {
			[`.${ns}-previous-button`]: 'previous',
			[`.${ns}-play-button`]: 'togglePlay',
			[`.${ns}-next-button`]: 'next',
			[`.${ns}-seek-bar`]: 'controls.handleSeek',
			[`.${ns}-volume-bar`]: 'controls.handleVolume',
		},
		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' },
		playing: { [`.${ns}-video`]: 'controls.syncVideo' },
		pause: { [`.${ns}-video`]: 'controls.syncVideo' },
		loadeddata: { [`.${ns}-video`]: 'controls.syncVideo' }
	},

	audioEvents: {
		ended: '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'
	},

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

	/**
	 * Start playback.
	 */
	play: function (sound) {
		if (!Player.audio) {
			return;
		}

		try {
			// If nothing is currently selected to play start playing the first sound.
			if (!sound && !Player.playing && Player.playOrder.length) {
				sound = Player.playOrder[0];
			}
			// 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.header.render();
				Player.audio.src = sound.src;
				if (sound.image.endsWith('.webm')) {
					Player.playlist.playVideo(sound);
				} else {
					Player.playlist.showImage(sound);
				}
				Player.playlist.render();
			}
			Player.audio.play();
		} catch (err) {
			_logError('There was an error playing the sound. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

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

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

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

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

	/**
	 * Handle audio events. Sync the video up, and update the controls.
	 */
	handleAudioEvent: function () {
		Player.controls.syncVideo();
		Player.controls.updateDuration();
		Player.$(`.${ns}-play-button .${ns}-play-button-display`).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 = Player.$(`.${ns}-video`);
			if (video) {
				video.currentTime = Player.audio.currentTime;
				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;
		}
		Player.ui.currentTime.innerHTML = toDuration(Player.audio.currentTime);
		Player.ui.duration.innerHTML = ' / ' + toDuration(Player.audio.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 (Player._progressBarStyleSheets[id]) {
			Player._progressBarStyleSheets[id].innerHTML = `${id} .${ns}-current-bar:after {
				margin-right: ${-.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();
	}
},
	display: {
	atRoot: [ 'show', 'hide' ],

	delegatedEvents: {
		click: {
			[`.${ns}-close-button`]: 'hide'
		}
	},

	/**
	 * Create the player show/hide button in to the 4chan X header.
	 */
	initChanX: function () {
		if (Player.display._initedChanX) {
			return;
		}
		Player.display._initedChanX = true;
		const shortcuts = document.getElementById('shortcuts');
		const showIcon = document.createElement('span');
		shortcuts.insertBefore(showIcon, document.getElementById('shortcut-settings'));

		const attrs = { id: 'shortcut-sounds', class: 'shortcut brackets-wrap', 'data-index': 0 };
		for (let attr in attrs) {
			showIcon.setAttribute(attr, attrs[attr]);
		}
		showIcon.innerHTML = '<a href="javascript:;" title="Sounds" class="fa fa-play-circle">Sounds</a>';
		showIcon.querySelector('a').addEventListener('click', Player.display.toggle);
	},

	/**
	 * Generate the data passed to the templates.
	 */
	_tplOptions: function () {
		return { data: Player.config };
	},

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

			// Insert the stylesheet.
			Player.stylesheet = document.createElement('style');
			Player.stylesheet.innerHTML = Player.templates.css(Player.display._tplOptions());
			document.head.appendChild(Player.stylesheet);

			// Create the main player.
			const el = document.createElement('div');
			el.innerHTML = Player.templates.body(Player.display._tplOptions());
			Player.container = el.querySelector(`#${ns}-container`);
			document.body.appendChild(Player.container);

			Player.trigger('rendered');

			// Keep track of heavily updated elements.
			Player.ui.currentTime = Player.$(`.${ns}-current-time`);
			Player.ui.duration = Player.$(`.${ns}-duration`);
			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(Player._progressBarStyleSheets[`.${ns}-seek-bar`] = document.createElement('style'));
			document.head.appendChild(Player._progressBarStyleSheets[`.${ns}-volume-bar`] = document.createElement('style'));
			Player.controls.updateDuration();
			Player.controls.updateVolume();
		} catch (err) {
			_logError('There was an error rendering the sound player. Please check the console for details.');
			console.error('[4chan sounds player]', err);
			// Can't recover, throw.
			throw err;
		}
	},

	/**
	 * Change what view is being shown
	 * @param {Chagn} e 
	 */
	setViewStyle: function (style) {
		Player.config.viewStyle = style;
		Player.container.setAttribute('data-view-style', style);
	},

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

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

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

			if (Player.config.pauseOnHide) {
				Player.pause();
			}
		} catch (err) {
			_logError('There was an error hiding the sound player. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

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

			Player.isHidden = false;
			Player.trigger('show');

			// Apply the last position/size
			const [ top, left ] = (await GM.getValue(ns + '.position') || '').split(':');
			const [ width, height ] = (await GM.getValue(ns + '.size') || '').split(':');
			+width && +height && Player.position.resize(width, height);
			+top && +left && Player.position.move(top, left);

			Player.container.focus();
		} catch (err) {
			_logError('There was an error showing the sound player. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	}
},
	events: {
	atRoot: [ 'on', 'off', 'trigger' ],

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

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

		for (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));
		}
	
		this.on('rendered', function () {
			// Wire up delegated events on the container.
			for (let evt in delegated) {
				Player.container.addEventListener(evt, function (e) {
					for (let eventList of delegated[evt]) {
						for (let selector in eventList) {
							const eventTarget = e.target.closest(selector);
							if (eventTarget) {
								e.eventTarget = eventTarget;
								let handler = Player.events.getHandler(eventList[selector]);
								if (handler) {
									return handler(e);
								}
							}
						}
					}
				});
			}

			// Wire up undelegated events.
			Player.events.wireUpUndelegated();

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

	/**
	 * Set, or reset, directly bound events.
	 */
	wireUpUndelegated: function () {
		const undelegated = Player.events._undelegatedEvents;
		for (let evt in undelegated) {
			for (let eventList of undelegated[evt]) {
				for (let selector in eventList) {
					document.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) {
			if (await handler(...data) === false) {
				return;
			}
		}
	},

	/**
	 * 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;
	}
},
	header: {
	options: {
		repeat: {
			all: { title: 'Repeat All', text: '[RA]', class: 'fa-repeat' },
			one: { title: 'Repeat One', text: '[R1]', class: 'fa-repeat fa-repeat-one' },
			none: { title: 'No Repeat', text: '[R0]', class: 'fa-repeat disabled' }
		},
		shuffle: {
			true: { title: 'Shuffled', text: '[S]', class: 'fa-random' },
			false: { title: 'Ordered', text: '[O]', class: 'fa-random disabled' },
		},
		viewStyle: {
			playlist: { title: 'Hide Playlist', text: '[+]', class: 'fa-compress' },
			image: { title: 'Show Playlist', text: '[-]', class: 'fa-expand' }
		}
	},

	delegatedEvents: {
		click: {
			[`.${ns}-shuffle-button`]: 'header.toggleShuffle',
			[`.${ns}-repeat-button`]: 'header.toggleRepeat',
		}
	},

	/**
	 * Render the player header.
	 */
	render: function () {
		if (!Player.container) {
			return;
		}
		Player.$(`.${ns}-title`).innerHTML = Player.templates.header(Player.display._tplOptions());
	},

	/**
	 * Toggle the repeat style.
	 */
	toggleRepeat: function (e) {
		try {
			e.preventDefault();
			const options = Object.keys(Player.header.options.repeat);
			const current = options.indexOf(Player.config.repeat);
			Player.config.repeat = options[(current + 4) % 3];
			Player.header.render();
			Player.settings.save();
		} catch (err) {
			_logError('There was an error changing the repeat setting. Please check the console for details.', 'warning');
			console.error('[4chan sounds player]', err);
		}
	},

	/**
	 * Toggle the shuffle style.
	 */
	toggleShuffle: function (e) {
		try {
			e.preventDefault();
			Player.config.shuffle = !Player.config.shuffle;
			Player.header.render();

			// Update the play order.
			if (!Player.config.shuffle) {
				Player.playOrder = [ ...Player.sounds ];
			} else {
				const playOrder = Player.playOrder;
				for (let i = playOrder.length - 1; i > 0; i--) {
					const j = Math.floor(Math.random() * (i + 1));
					[playOrder[i], playOrder[j]] = [playOrder[j], playOrder[i]];
				}
			}
			Player.settings.save();
		} catch (err) {
			_logError('There was an error changing the shuffle setting. Please check the console for details.', 'warning');
			console.error('[4chan sounds player]', err);
		}
	}
},
	hotkeys: {
	initialize: function () {
		Player.on('rendered', Player.hotkeys.apply);
	},

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

	addHandler: () => 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('hide', Player.hotkeys.addHandler);
		Player.off('show', 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.config.hotkeys === 'open' && Player.isHidden) {
			return;
		}
		const k = e.key.toLowerCase();
		const bindings = Player.config.hotkey_bindings || {};

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

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


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

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

	volumeDown: function () {
		Player.audio.volume = Math.max(Player.audio.volume - .05, 0);
	}
}
,
	playlist: {
	atRoot: [ 'add' ],

	delegatedEvents: {
		click: {
			[`.${ns}-viewStyle-button`]: 'playlist.toggleView',
			[`.${ns}-list`]: 'playlist.handleSelect'
		},
	},

	/**
	 * Render the playlist.
	 */
	render: function () {
		if (!Player.container) {
			return;
		}
		if (Player.$(`.${ns}-list`)) {
			Player.$(`.${ns}-list`).innerHTML = Player.templates.list(Player.display._tplOptions());
		}
	},

	/**
	 * Update the image displayed in the player.
	 */
	showImage: function (sound, thumb) {
		if (!Player.container) {
			return;
		}
		Player.playlist.isVideo = false;
		try {
			Player.$(`.${ns}-image`).src = thumb ? sound.thumb : sound.image;
			Player.$(`.${ns}-image-link`).href = sound.image;
			Player.$(`.${ns}-image-link`).classList.remove(ns + '-show-video');
		} catch (err) {
			_logError('There was an error display the sound player image. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

	/**
	 * Play the video for a sound in place of an image.
	 */
	playVideo: function (sound) {
		if (!Player.container) {
			return;
		}
		Player.playlist.isVideo = true;
		try {
			Player.$(`.${ns}-video`).src = sound.image;
			Player.$(`.${ns}-image-link`).href = sound.image;
			Player.$(`.${ns}-image-link`).classList.add(ns + '-show-video');
		} catch (err) {
			_logError('There was an error display the sound player image. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

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

	/**
	 * Add a new sound from the thread to the player.
	 */
	add: function (title, id, src, thumb, image) {
		try {
			// Avoid duplicate additions.
			if (Player.sounds.find(sound => sound.id === id)) {
				return;
			}
			const sound = { title, src, id, thumb, image };
			Player.sounds.push(sound);

			// Add the sound to the play order at the end, or someone random for shuffled.
			const index = Player.config.shuffle
				? Math.floor(Math.random() * Player.sounds.length - 1)
				: Player.sounds.length;
			Player.playOrder.splice(index, 0, sound);

			if (Player.container) {
				// Re-render the list.
				Player.playlist.render();
				Player.$(`.${ns}-count`).innerHTML = Player.sounds.length;

				// If nothing else has been added yet show the image for this sound.
				if (Player.playOrder.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);
				}
			}
		} catch (err) {
			_logError('There was an error adding to the sound player. Please check the console for details.');
			console.log('[4chan sounds player]', title, id, src, thumb, image);
			console.error('[4chan sounds player]', err);
		}
	},

	handleSelect: function (e) {
		const id = e.target.getAttribute('data-id');
		const sound = id && Player.sounds.find(function (sound) {
			return sound.id === '' + id;
		});
		sound && Player.play(sound);
	}
},
	position: {
	delegatedEvents: {
		mousedown: {
			[`.${ns}-title`]: 'position.initMove',
			[`.${ns}-expander`]: 'position.initResize'
		}
	},

	/**
	 * Handle the user grabbing the expander.
	 */
	initResize: function initDrag(e) {
		e.preventDefault();
		Player._startX = e.clientX;
		Player._startY = e.clientY;
		Player._startWidth = parseInt(document.defaultView.getComputedStyle(Player.container).width, 10);
		Player._startHeight = parseInt(document.defaultView.getComputedStyle(Player.container).height, 10);
		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 style = document.defaultView.getComputedStyle(Player.container);
		document.documentElement.removeEventListener('mousemove', Player.position.doResize, false);
		document.documentElement.removeEventListener('mouseup', Player.position.stopResize, false);
		GM.setValue(ns + '.size', parseInt(style.width, 10) + ':' + parseInt(style.height, 10));
	},

	/**
	 * Resize the player.
	 */
	resize: function (width, height) {
		if (!Player.container) {
			return;
		}
		// Make sure the player isn't going off screen. 40 to give a bit of spacing for the 4chanX header.
		height = Math.min(height, document.documentElement.clientHeight - 40);

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

		// Change the height of the playlist or image.
		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`) : null;

		const containerHeight = parseInt(document.defaultView.getComputedStyle(Player.container).height, 10);
		const offset = containerHeight - (parseInt(heightElement.style.height, 10) || 0);
		heightElement.style.height = Math.max(10, height - offset) + 'px';
	},

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

		// Try to reapply the current sizing to fix oversized winows.
		const style = document.defaultView.getComputedStyle(Player.container);
		Player.position.resize(parseInt(style.width, 10), parseInt(style.height, 10));

		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 (e) {
		document.documentElement.removeEventListener('mousemove', Player.position.doMove, false);
		document.documentElement.removeEventListener('mouseup', Player.position.stopMove, false);
		Player.$(`.${ns}-title`).style.cursor = null;
		GM.setValue(ns + '.position', parseInt(Player.container.style.left, 10) + ':' + parseInt(Player.container.style.top, 10));
	},

	/**
	 * Move the player.
	 */
	move: function (x, y) {
		if (!Player.container) {
			return;
		}
		const style = document.defaultView.getComputedStyle(Player.container);
		const maxX = document.documentElement.clientWidth; - parseInt(style.width, 10);
		const maxY = document.documentElement.clientHeight; - parseInt(style.height, 10);
		Player.container.style.left = Math.max(0, Math.min(x, maxX)) + 'px';
		Player.container.style.top = Math.max(0, Math.min(y, maxY)) + 'px';
	}
},
	settings: {
	delegatedEvents: {
		click: {
			[`.${ns}-config-button`]: 'settings.toggle'
		},
		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'
		}
	},

	/**
	 * Persist the player settings.
	 */
	save: function () {
		try {
			return GM.setValue(ns + '.settings', JSON.stringify(Player.config));
		} catch (err) {
			_logError('There was an error saving the sound player settings. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

	/**
	 * Restore the saved player settings.
	 */
	load: async function () {
		try {
			let settings = await GM.getValue(ns + '.settings');
			if (!settings) {
				return;
			}
			try {
				settings = JSON.parse(settings);
			} catch(e) {
				return;
			}
			_mix(Player.config, settings);
		} catch (err) {
			_logError('There was an error loading the sound player settings. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

	/**
	 * Toggle whether the player or settings are displayed.
	 */
	toggle: function (e) {
		try {
			e.preventDefault();
			if (Player.config.viewStyle === 'settings') {
				Player.display.setViewStyle(Player._preSettingsView || 'playlist');
			} else {
				Player._preSettingsView = Player.config.viewStyle;
				Player.display.setViewStyle('settings');
			}
		} catch (err) {
			_logError('There was an error rendering the sound player settings. Please check the console for details.');
			console.error('[4chan sounds player]', err);
			// Can't recover, throw.
			throw err;
		}
	},

	/**
	 * Handle the user making a change in the settings view.
	 */
	handleChange: function (e) {
		try {
			const input = e.eventTarget;
			const property = input.getAttribute('data-property');
			let settingConfig;
			settingsConfig.find(function searchConfig(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;
			});

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

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

			// Not the most stringent check but enough to avoid some spamming.
			if (currentValue !== newValue) {
				// Update the setting.
				_set(Player.config, property, newValue);

				// Update the stylesheet reflect any changes.
				Player.stylesheet.innerHTML = Player.templates.css(Player.display._tplOptions());

				// Save the new settings.
				Player.settings.save();
			}

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

	handleKeyChange: function (e) {
		e.preventDefault();
		if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Meta') {
			return;
		}
		e.eventTarget.value = Player.hotkeys.stringifyKey(e);
	}
}
,
};

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

Object.assign(Player, {
	ns,

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

	config: settingsConfig.reduce(function reduceSettings(config, settingConfig) {
		if (settingConfig.settings) {
			return settingConfig.settings.reduce(reduceSettings, config);
		}
		return _set(config, settingConfig.property, settingConfig.default);
	}, {}),

	$: (...args) => Player.container && Player.container.querySelector(...args),

	templates: {
		css: ({ data }) => `audio {
  width: 100%;
}

.${ns}-controls {
  align-items: center;
  padding: .5rem;
  border-bottom: solid 1px ${data.colors.border};
  background: #3f3f44;
}

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

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

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

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

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

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

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

.${ns}-current-time {
  color: white;
}

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

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

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

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

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

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

.${ns}-progress-bar .${ns}-full-bar .${ns}-current-bar:after {
  content: '';
  background: white;
  height: .8rem;
  min-width: .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: #00b6f0;
}

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

.${ns}-volume-bar .${ns}-current-bar {
  background: white;
}

.${ns}-volume-bar {
  width: 3.5rem;
}

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

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

.${ns}-title {
  cursor: grab;
  text-align: center;
  border-bottom: solid 1px ${data.colors.border};
  padding: .25rem 0;
}

html.fourchan-x .${ns}-title a {
  font-size: 0;
  visibility: hidden;
  margin: 0 0.15rem;
}

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

.${ns}-image-link {
  text-align: center;
  display: flex;
  justify-items: center;
  justify-content: center;
  border-bottom: solid 1px ${data.colors.border};
}

.${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: ${data.colors.background};
  border: 1px solid ${data.colors.border};
  min-height: 200px;
  max-height: calc(100% - 40px);
  min-width: 100px;
}

.${ns}-row {
  display: flex;
  flex-wrap: wrap;
}

.${ns}-col-auto {
  flex: 0 0 auto;
  width: auto;
  max-width: 100%;
  margin-left: 0.25rem;
}

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

.${ns}-list-container {
  overflow: auto;
}

.${ns}-list {
  display: grid;
  list-style-type: none;
  padding: 0;
  margin: 0;
}

.${ns}-list-item {
  list-style-type: none;
  padding: 0.15rem 0.25rem;
  white-space: nowrap;
  cursor: pointer;
  background: ${data.colors.odd_row};
}

.${ns}-list-item.playing {
  background: ${data.colors.playing} !important;
}

.${ns}-list-item:nth-child(2n) {
  background: ${data.colors.even_row};
}

.${ns}-settings {
  display: none;
  padding: 0 .25rem;
  height: 100%;
  overflow: auto;
}

.${ns}-settings .${ns}-setting-header {
  font-weight: 600;
  margin: .5rem 0;
}

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

#${ns}-container[data-view-style="settings"] .${ns}-player {
  display: none;
}

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

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

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

#${ns}-container[data-view-style="image"] .${ns}-image-link {
  height: auto;
  min-height: 125px;
}
`,
		body: ({ data }) => `<div id="${ns}-container" data-view-style="${data.viewStyle}" style="top: 100px; left: 100px; width: 350px; display: none;">
	<div class="${ns}-title ${ns}-row" style="justify-content: between;">
		${Player.templates.header({ data })}
	</div>
	<div class="${ns}-view-container">
		<div class="${ns}-player">
			${Player.templates.player({ data })}
		</div>
		<div class="${ns}-settings" style="height: 400px">
			${Player.templates.settings({ data })}
		</div>
	</div>
	<div class="${ns}-footer">
		<span class="${ns}-count">0</span> sounds
		<div class="${ns}-expander"></div>
	</div>
</div>`,
		header: ({ data }) => `<div class="${ns}-col-auto" style="margin-left: 0.25rem;">`
	+ Object.keys(Player.header.options).map(key => {
		let option = Player.header.options[key][data[key]] || Player.header.options[key][Object.keys(Player.header.options[key])[0]];
		return `<a class="${ns}-${key}-button fa ${option.class}" title="${option.title}" href="javascript;">
			${option.text}
		</a>`
	}).join('') + `
</div><div class="${ns}-col" style="white-space: nowrap; text-overflow: ellipsis; overflow: hidden;">
	${Player.playing ? Player.playing.title : '4chan Sounds'}
</div>
<div class=".${ns}-col-auto" style="margin-right: 0.25rem;">
	<a class="${ns}-config-button fa fa-wrench" title="Settings" href="javascript;">Settings</a>
	<a class="${ns}-close-button fa fa-times" href="javascript;">X</a>
</div>`,
		player: ({ data }) => `<a class="${ns}-image-link" style="height: 128px" target="_blank">
	<img class="${ns}-image"></img>
	<video class="${ns}-video"></video>
</a>
<div class="${ns}-controls ${ns}-row">
	${Player.templates.controls({ data })}
</div>
<div class="${ns}-list-container" style="height: 100px">
	<ul class="${ns}-list">
		${Player.templates.list({ data })}
	</ul>
</div>`,
		controls: ({ data }) => `<div class="${ns}-col-auto ${ns}-row" href="javascript;">
	<div class="${ns}-media-control ${ns}-previous-button">
		<div class="${ns}-previous-button-display"></div>
	</div>
	<div class="${ns}-media-control ${ns}-play-button">
		<div class="${ns}-play-button-display ${!Player.audio || Player.audio.paused ? `${ns}-play` : ''}"></div>
	</div>
	<div class="${ns}-media-control ${ns}-next-button">
		<div class="${ns}-next-button-display"></div>
	</div>
</div>
<div class="${ns}-col">
	<div class="${ns}-seek-bar ${ns}-progress-bar">
		<div class="${ns}-full-bar">
			<div class="${ns}-loaded-bar"></div>
			<div class="${ns}-current-bar"></div>
		</div>
	</div>
</div>
<div class="${ns}-col-auto">
	<span class="${ns}-current-time">0:00</span><span class="${ns}-duration"> / 0:00</span>
</div>
<div class="${ns}-col-auto">
	<div class="${ns}-volume-bar ${ns}-progress-bar">
		<div class="${ns}-full-bar">
			<div class="${ns}-current-bar" style="width: ${Player.audio.volume * 100}%"></div>
		</div>
	</div>
</div>`,
		list: ({ data }) => Player.sounds.map(sound =>
	`<li class="${ns}-list-item ${sound.playing ? 'playing' : ''}" data-id="${sound.id}">
		${sound.title}
	</li>`
).join(''),
		settings: ({ data }) => 
settingsConfig.filter(setting => setting.showInSettings).map(function addSetting(setting) {
	let out = `<div class="${setting.isSubSetting ? `${ns}-col` : `${ns}-setting-header`}" ${setting.description ? `title="${setting.description}"` : ''}>
		${setting.title}
	</div>`;

	if (setting.settings) {
		out += `<div class="${ns}-row ${ns}-sub-settings">`
			+ setting.settings.map(subSetting => {
				return addSetting({ ...setting, settings: null, ...subSetting, isSubSetting: true })
			}).join('')
		+ `</div>`;

		return out;
	}

	let value = _get(data, setting.property, setting.default);
	let clss = setting.class ? `class="${setting.class}"` : '';

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

	let type = typeof value;

	setting.isSubSetting && (out += `<div class="${ns}-col">`);

	if (type === 'boolean') {
		out += `<input type="checkbox" ${clss} data-property="${setting.property}" ${value ? 'checked' : ''}></input>`;
	} else if (setting.showInSettings === 'textarea' || type === 'object') {
		if (setting.split) {
			value = value.join(setting.split);
		} else if (type === 'object') {
			value = JSON.stringify(value, null, 4);
		}
		out += `<textarea ${clss} data-property="${setting.property}">${value}</textarea>`;
	} else if (setting.options) {
		out += `<select ${clss} data-property="${setting.property}">`
			+ setting.options.map(option => `<option value="${option[0]}" ${value === option[0] ? 'selected' : ''}>${option[1]}</option>`)
		+ '</select>';
	} else {
		out += `<input type="text" ${clss} data-property="${setting.property}" value="${value}"></input>`;
	}

	setting.isSubSetting && (out += `</div><div class="${ns}-col" style="min-width: 100%"></div>`);
	return out;
}).join('')
	},

	/**
	 * Set up the player.
	 */
	initialize: async function () {
		try {
			Player.sounds = [ ];
			Player.playOrder = [ ];

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

			// Run the initialisation for each component.
			for (let name in components) {
				components[name].initialize && components[name].initialize();
			}

			// If it's already known that 4chan X is running then setup the button for it.
			// If not add the the [Sounds] link in the top and bottom nav.
			if (isChanX) {
				Player.display.initChanX()
			} else {
				document.querySelectorAll('#settingsWindowLink, #settingsWindowLinkBot').forEach(function (link) {
					const bracket = document.createTextNode('] [');
					const showLink = document.createElement('a');
					showLink.innerHTML = 'Sounds';
					showLink.href = 'javascript;';
					link.parentNode.insertBefore(showLink, link);
					link.parentNode.insertBefore(bracket, link);
					showLink.addEventListener('click', Player.display.toggle);
				});
			}

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


	document.addEventListener('DOMContentLoaded', async function() {
		await Player.initialize();

		parseFiles(document.body);

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

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

	function parseFiles (target) {
		target.querySelectorAll('.post').forEach(function (post) {
			if (post.parentElement.parentElement.id === 'qp' || post.parentElement.classList.contains('noFile')) {
				return;
			}
			post.querySelectorAll('.file').forEach(function (file) {
				parseFile(file, post);
			});
		});
	};

	function parseFile(file, post) {
		try {
			if (!file.classList.contains('file')) {
				return;
			}

			const fileLink = isChanX
				? file.querySelector('.fileText .file-info > a')
				: file.querySelector('.fileText > a');

			if (!fileLink) {
				return;
			}

			if (!fileLink.href) {
				return;
			}

			let fileName = null;

			if (isChanX) {
				[
					file.querySelector('.fileText .file-info .fnfull'),
					file.querySelector('.fileText .file-info > a')
				].some(function (node) {
					return node && (fileName = node.textContent);
				});
			} else {
				[
					file.querySelector('.fileText'),
					file.querySelector('.fileText > a')
				].some(function (node) {
					return node && (fileName = node.title || node.tagName === 'A' && node.textContent);
				});
			}

			if (!fileName) {
				return;
			}

			fileName = fileName.replace(/\-/, '/');

			const match = fileName.match(/^(.*)[\[\(\{](?:audio|sound)[ \=\:\|\$](.*?)[\]\)\}]/i);

			if (!match) {
				return;
			}

			const id = post.id.slice(1);
			const name = match[1] || id;
			const fileThumb = post.querySelector('.fileThumb');
			const fullSrc = fileThumb && fileThumb.href;
			const thumbSrc = fileThumb && fileThumb.querySelector('img').src;
			let link = match[2];

			if (link.includes('%')) {
				try {
					link = decodeURIComponent(link);
				} catch (error) {
					return;
				}
			}

			if (link.match(/^(https?\:)?\/\//) === null) {
				link = (location.protocol + '//' + link);
			}

			try {
				link = new URL(link);
			} catch (error) {
				return;
			}

			for (let item of Player.config.allow) {
				if (link.hostname.toLowerCase() === item || link.hostname.toLowerCase().endsWith('.' + item)) {
					return Player.add(name, id, link.href, thumbSrc, fullSrc);
				}
			}
		} catch (err) {
			_logError('There was an issue parsing the files. Please check the console for details.');
			console.log('[4chan sounds player]', post)
			console.error(err);
		}
	};
})();