Greasy Fork

4chan sounds player

Play that faggy music weeb boi

目前为 2020-05-09 提交的版本。查看 最新版本

// ==UserScript==
// @name         4chan sounds player
// @version      1.0.1
// @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;
	}

	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: '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'
	},
	{
		property: 'colors.background',
		default: '#d6daf0',
		title: 'Background Color',
		showInSettings: true
	},
	{
		property: 'colors.border',
		default: '#b7c5d9',
		title: 'Border Color',
		showInSettings: true
	},
	{
		property: 'colors.odd_row',
		default: '#d6daf0',
		title: 'Odd Row Color',
		showInSettings: true
	},
	{
		property: 'colors.even_row',
		default: '#b7c5d9',
		title: 'Even Row Color',
		showInSettings: true
	},
	{
		property: 'colors.playing',
		default: '#98bff7',
		title: 'Playing Row Color',
		showInSettings: true
	},
	{
		property: 'colors.expander',
		default: '#808bbf',
		title: 'Expander Color',
		showInSettings: true
	}
]

	const headerOptions = {
	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' },
	},
	playlist: {
		true: { title: 'Hide Playlist', text: '[+]', class: 'fa-expand' },
		false: { title: 'Show Playlist', text: '[-]', class: 'fa-compress' }
	}
}

const Player = {
	ns,

	audio: new Audio(),
	sounds: [],
	container: null,
	ui: {},
	_progressBarStyleSheets: {},
	settings: settingsConfig.reduce((settings, settingConfig) => {
		return _set(settings, settingConfig.property, settingConfig.default);
	}, {}),

	$: (...args) => 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: 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};
  display: relative;
  min-height: 200px;
  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: .25rem;
}

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

.${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}-player">
		${Player.templates.player({ data })}
	</div>
	<div class="${ns}-settings">
		${Player.templates.settings({ data })}
	</div>
</div>`,
		header: ({ data }) => `<div class="${ns}-col-auto" style="margin-left: 0.25rem;">`
	+ Object.keys(headerOptions).map(key => {
		let option = headerOptions[key][data[key]] || headerOptions[key][Object.keys(headerOptions[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>
<div class="${ns}-footer">
	<span class="${ns}-count">0</span> sounds
	<div class="${ns}-expander"></div>
</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(setting => {
	let out = `<div class="${ns}-setting-header" ${setting.title ? `title="${setting.desc}"` : ''}>${setting.title}</div>`;
	if (typeof setting.default === 'boolean') {
		out += `<input type="checkbox" data-property="${setting.property}" ${_get(data, setting.property, false) ? 'checked' : ''}></input>`;
	} else if (Array.isArray(setting.default)) {
		out += `<textarea data-property="${setting.property}">${_get(data, setting.property, '').join(setting.split)}</textarea>`;
	} else {
		out += `<input type="text" data-property="${setting.property}" value="${_get(data, setting.property, '')}"></input>`;
	}
	return out;
}).join('')
	},

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

			// Playback settings
			[`.${ns}-shuffle-button`]: 'toggleShuffle',
			[`.${ns}-repeat-button`]: 'toggleRepeat',

			// Media controls
			[`.${ns}-previous-button`]: 'previous',
			[`.${ns}-play-button`]: 'togglePlay',
			[`.${ns}-next-button`]: 'next',
			[`.${ns}-seek-bar`]: 'handleSeek',
			[`.${ns}-volume-bar`]: 'handleVolume',

			// View settings
			[`.${ns}-playlist-button`]: 'togglePlayerView',
			[`.${ns}-config-button`]: 'toggleSettings',

			// Playlist controls
			[`.${ns}-list`]: 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);
			}
		},
		mousedown: {
			[`.${ns}-title`]: 'initMove',
			[`.${ns}-expander`]: 'initResize',
			[`.${ns}-seek-bar`]: () => Player._seekBarDown = true,
			[`.${ns}-volume-bar`]: () => Player._volumeBarDown = true
		},
		mousemove: {
			[`.${ns}-seek-bar`]: e => Player._seekBarDown && Player.handleSeek(e),
			[`.${ns}-volume-bar`]: e => Player._volumeBarDown && Player.handleVolume(e)
		},
		focusout: {
			[`.${ns}-settings input, .${ns}-settings textarea`]: 'handleSettingChange'
		},
		change: {
			[`.${ns}-settings input[type=checkbox]`]: 'handleSettingChange'
		}
	},

	undelegatedEvents: {
		mouseleave: {
			[`.${ns}-seek-bar`]: e => Player._seekBarDown && Player.handleSeek(e),
			[`.${ns}-volume-bar`]: e => Player._volumeBarDown && Player.handleVolume(e)
		},
		mouseup: {
			body: () => {
				Player._seekBarDown = false;
				Player._volumeBarDown = false;
			}
		},
		play: { [`.${ns}-video`]: 'syncVideo' },
		playing: { [`.${ns}-video`]: 'syncVideo' },
		pause: { [`.${ns}-video`]: 'syncVideo' },
		seeked: { [`.${ns}-video`]: 'syncVideo' },
		loadeddata: { [`.${ns}-video`]: 'syncVideo' }
	},

	audioEvents: {
		ended: 'next',
		pause:'handleAudioEvent',
		play: 'handleAudioEvent',
		seeked: 'handleAudioEvent',
		waiting: 'handleAudioEvent',
		timeupdate: 'updateDuration',
		loadedmetadata: 'updateDuration',
		durationchange: 'updateDuration',
		volumechange: 'updateVolume',
		loadstart: 'pollForLoading'
	},

	/**
	 * 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.
	 */
	getEventHandler: function (handler) {
		return typeof handler === 'string' ? Player[handler] : handler;
	},

	/**
	 * Set up the player.
	 */
	initialize: async function () {
		try {
			await Player.loadSettings();
			Player.sounds = [ ];
			Player.playOrder = [ ];
 
			// 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.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.toggleDisplay);
				});
			}

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

	/**
	 * Create the player show/hide button in to the 4chan X header.
	 */
	initChanX: function () {
		if (Player._initedChanX) {
			return;
		}
		Player._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.toggleDisplay);
	},

	/**
	 * Persist the player settings.
	 */
	saveSettings: function () {
		try {
			return GM.setValue(ns + '.settings', JSON.stringify(Player.settings));
		} 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.
	 */
	loadSettings: async function () {
		try {
			let settings = await GM.getValue(ns + '.settings');
			if (!settings) {
				return;
			}
			try {
				settings = JSON.parse(settings);
			} catch(e) {
				return;
			}
			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];
					}
				}
			}
			_mix(Player.settings, 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);
		}
	},

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

	/**
	 * 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._tplOptions());
			document.head.appendChild(Player.stylesheet);

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

			// 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.updateDuration();
			Player.updateVolume();

			// Wire up delegated events on the container.
			for (let evt in Player.delegatedEvents) {
				Player.container.addEventListener(evt, function (e) {
					for (let selector in Player.delegatedEvents[evt]) {
						const eventTarget = e.target.closest(selector);
						if (eventTarget) {
							e.eventTarget = eventTarget;
							return Player.getEventHandler(Player.delegatedEvents[evt][selector])(e);
						}
					}
				});
			}

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

			// Wite up audio events.
			for (let evt in Player.audioEvents) {
				Player.audio.addEventListener(evt, Player.getEventHandler(Player.audioEvents[evt]));
			}
		} 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;
		}
	},

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

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

	/**
	 * Set, or reset, directly bound events.
	 */
	wireUpUndelegatedEvents: function () {
		for (let evt in Player.undelegatedEvents) {
			for (let selector in Player.undelegatedEvents[evt]) {
				document.querySelectorAll(selector).forEach(element => {
					const handler = Player.getEventHandler(Player.undelegatedEvents[evt][selector]);
					element.removeEventListener(evt, handler);
					element.addEventListener(evt, handler);
				});
			}
		}
	},

	/**
	 * Handle audio events. Sync the video up, and update the controls.
	 */
	handleAudioEvent: function () {
		Player.syncVideo();
		Player.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 () {
		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.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.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.updateProgressBarPosition(`.${ns}-seek-bar`, Player.ui.currentTimeBar, Player.audio.currentTime, Player.audio.duration);
	},

	/**
	 * Update the volume bar.
	 */
	updateVolume: function () {
		Player.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.updateVolume();
	},

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

	/**
	 * Togle the display status of the player.
	 */
	toggleDisplay: 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.stopPollingForLoading();
			Player.container.style.display = 'none';
			if (Player.settings.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.pollForLoading();
			Player.container.style.display = null;
			// 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.resizeTo(width, height);
			+top && +left && Player.moveTo(top, left);
		} catch (err) {
			_logError('There was an error showing the sound player. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},
	
	/**
	 * Toggle the repeat style.
	 */
	toggleRepeat: function (e) {
		try {
			e.preventDefault();
			const options = Object.keys(headerOptions.repeat);
			const current = options.indexOf(Player.settings.repeat);
			Player.settings.repeat = options[(current + 4) % 3];
			Player.renderHeader();
			Player.saveSettings();
		} catch (err) {
			_logError('There was an error changing the repeat setting. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},
	
	
	/**
	 * Toggle the shuffle style.
	 */
	toggleShuffle: function (e) {
		try {
			e.preventDefault();
			Player.settings.shuffle = !Player.settings.shuffle;
			Player.renderHeader();

			// Update the play order.
			if (!Player.settings.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.saveSettings();
		} catch (err) {
			_logError('There was an error changing the shuffle setting. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

	
	/**
	 * Toggle whether the player or settings are displayed.
	 */
	toggleSettings: function (e) {
		try {
			e.preventDefault();
			if (Player.settings.viewStyle === 'settings') {
				Player.setViewStyle(Player._preSettingsView);
			} else {
				Player._preSettingsView = Player.settings.viewStyle;
				Player.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.
	 */
	handleSettingChange: function (e) {
		try {
			const input = e.eventTarget;
			const property = input.getAttribute('data-property');
			const settingConfig = settingsConfig.find(settingConfig => settingConfig.property === property);
			const currentValue = _get(Player.settings, property);
			let newValue = input[input.getAttribute('type') === 'checkbox' ? 'checked' : 'value'];
			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.settings, property, newValue);

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

				// Save the new settings.
				Player.saveSettings();
			}
		} catch (err) {
			_logError('There was an updating the setting. Please check the console for details.');
			console.error('[4chan sounds player]', err);
		}
	},

	/**
	 * Handle the user grabbing the expander.
	 */
	initResize: function initDrag(e) {
		disableUserSelect();
		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.doResize, false);
		document.documentElement.addEventListener('mouseup', Player.stopResize, false);
	},

	/**
	 * Handle the user dragging the expander.
	 */
	doResize: function(e) {
		e.preventDefault();
		Player.resizeTo(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.doResize, false);
		document.documentElement.removeEventListener('mouseup', Player.stopResize, false);
		enableUserSelect();
		GM.setValue(ns + '.size', parseInt(style.width, 10) + ':' + parseInt(style.height, 10));
	},

	/**
	 * Resize the player.
	 */
	resizeTo: 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.settings.viewStyle === 'playlist'
			? Player.$(`.${ns}-list-container`)
			: Player.$(`.${ns}-image-link`);

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

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

		// Try to reapply the current sizing to fix oversized winows.
		const style = document.defaultView.getComputedStyle(Player.container);
		Player.resizeTo(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.doMove, false);
		document.documentElement.addEventListener('mouseup', Player.stopMove, false);
	},

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

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

	/**
	 * Move the player.
	 */
	moveTo: 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';
	},

	/**
	 * Update the image displayed in the player.
	 */
	showImage: function (sound, thumb) {
		if (!Player.container) {
			return;
		}
		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;
		}
		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.
	 */
	togglePlayerView: function (e) {
		e.preventDefault()
		if (!Player.container) {
			return;
		}
		e && e.preventDefault();
		let style = Player.settings.viewStyle === 'playlist' ? 'image' : 'playlist';
		try {
			Player.setViewStyle(style);
			Player.renderHeader();
			Player.saveSettings();
		} 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.settings.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.renderList();
				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.settings.autoshow) {
						Player.show();
					}
					Player.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);
		}
	},

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

		try {
			// 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.renderHeader();
				Player.audio.src = sound.src;
				if (sound.image.endsWith('.webm')) {
					Player.playVideo(sound);
				} else {
					Player.showImage(sound);
				}
				Player.renderList();
			}
			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();
	},

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

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

	/**
	 * Play the previous sound.
	 */
	previous: function () {
		Player._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.playSound(Player.playOrder[0]);
			}
			// Get the next index, either repeating the same, wrapping round to repeat all or just moving the index.
			const nextIndex = Player.settings.repeat === 'one'
				? currentIndex
				: Player.settings.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);
		}
	}
};


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

	function disableUserSelect () {
		document.body.style.userSelect = 'none';
		document.body.style.MozUserSelect = 'none';
	}

	function enableUserSelect () {
		document.body.style.userSelect = null;
		document.body.style.MozUserSelect = null;
	}
})();