// ==UserScript==
// @name 4chan sounds
// @version 0.1.3
// @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 repeatOptions = {
all: 'Repeat All',
one: 'Repeat One',
none: 'No Repeat'
};
const Player = {
ns: 'fc-sounds',
sounds: [],
container: null,
ui: {},
settings: {
shuffle: false,
repeat: Object.keys(repeatOptions)[0],
autoshow: true,
colors: {
background: '#d6daf0',
border: '#b7c5d9',
odd_row: '#d6daf0',
even_row: '#b7c5d9',
expander: '#808bbf',
expander_hover: '#9aa6e1',
playing: '#98bff7'
},
allow: [
"4cdn.org",
"catbox.moe",
"dmca.gripe",
"lewd.se",
"pomf.cat",
"zz.ht"
]
},
_templates: {
css: ({ ns, colors }) =>
`#${ns}-container {
position: fixed;
background: ${colors.background};
border: 1px solid ${colors.border};
display: relative;
min-height: 200px;
min-width: 100px;
}
.${ns}-show-settings .${ns}-player {
display: none;
}
.${ns}-setting {
display: none;
}
.${ns}-settings {
display: none;
padding: .25rem;
}
.${ns}-show-settings .${ns}-settings {
display: block;
}
.${ns}-settings .${ns}-setting-header {
font-weight: 600;
margin-top: 0.25rem;
}
.${ns}-settings textarea {
border: solid 1px ${colors.border};
min-width: 100%;
min-height: 4rem;
box-sizing: border-box;
}
.${ns}-title {
cursor: grab;
text-align: center;
border-bottom: solid 1px ${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 {
height: 128px;
text-align: center;
display: flex;
justify-items: center;
justify-content: center;
border-bottom: solid 1px ${colors.border};
}
.${ns}-image-link .${ns}-video {
display: none;
}
.${ns}-image-link.${ns}-show-video .${ns}-video {
display: block;
}
.${ns}-image-link.${ns}-show-video .${ns}-image {
display: none;
}
.${ns}-image, .${ns}-video {
max-height: 125px;
}
.${ns}-audio {
width: 100%;
}
.${ns}-list-container {
overflow: scroll;
}
.${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;
}
.${ns}-list-item.playing {
background: ${colors.playing} !important;
}
.${ns}-list-item:nth-child(n) {
background: ${colors.odd_row};
}
.${ns}-list-item:nth-child(2n) {
background: ${colors.even_row};
}
.${ns}-expander {
position: absolute;
bottom: 0px;
right: 0px;
height: 12px;
width: 12px;
cursor: se-resize;
background: linear-gradient(to bottom right, rgba(0,0,0,0), rgba(0,0,0,0) 50%, ${colors.expander} 55%, ${colors.expander} 100%)
}
.${ns}-expander:hover {
background: linear-gradient(to bottom right, rgba(0,0,0,0), rgba(0,0,0,0) 50%, ${colors.expander_hover} 55%, ${colors.expander_hover} 100%)
}`,
body: ({ ns }) =>
`<div id="${ns}-container" style="top: 100px; left: 100px; width: 350px; display: none;">
<div class="${ns}-title">
<span style="float: left; margin-left: 0.25rem;">
<a class="${ns}-repeat-button fa fa-repeat" href="javascript;">Repeat</a>
<a class="${ns}-shuffle-button fa fa-random" href="javascript;">Ordered</a>
</span>
4chan Sounds
<span style="float: right; 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>
</span>
</div>
<div class="${ns}-player">
<a class="${ns}-image-link" target="_blank">
<img class="${ns}-image"></img>
<video class="${ns}-video"></video>
</a>
<audio class="${ns}-audio" controls="true"></audio>
<div class="${ns}-list-container" style="height: 100px">
<ul class="${ns}-list">
</ul>
</div>
<div class="${ns}-expander"></div>
</div>
<div class="${ns}-settings">
</div>
</div>`,
list: ({ ns }) =>
Player.sounds.map(sound => `<li class="${ns}-list-item" data-id="${sound.id}">${sound.title}</li>`).join(''),
settings: ({ ns, colors, allow, autoshow }) =>
`<div class="${ns}-setting-header" title="Automatically show the player when the thread contains sounds.">Autoshow</div>
<input type="checkbox" data-property="autoshow" ${autoshow ? 'checked' : ''}></input>
<div class="${ns}-setting-header" title="Which domains sources are allowed to be loaded from.">Allow</div>
<textarea data-property="allow" data-split="linebreak">${allow.join('\n')}</textarea>
<div class="${ns}-setting-header">Background Color</div>
<input type="text" data-property="colors.background" value="${colors.background}"></input>
<div class="${ns}-setting-header">Border Color</div>
<input type="text" data-property="colors.border" value="${colors.border}"></input>
<div class="${ns}-setting-header">Odd Row Color</div>
<input type="text" data-property="colors.odd_row" value="${colors.odd_row}"></input>
<div class="${ns}-setting-header">Even Row Color</div>
<input type="text" data-property="colors.even_row" value="${colors.even_row}"></input>
<div class="${ns}-setting-header">Playing Row Color</div>
<input type="text" data-property="colors.playing" value="${colors.playing}"></input>
<div class="${ns}-setting-header">Expand Color</div>
<input type="text" data-property="colors.expander" value="${colors.expander}"></input>
<div class="${ns}-setting-header">Expand Hover Color</div>
<input type="text" data-property="colors.expander_hover" value="${colors.expander_hover}"></input>`
},
initialize: async function () {
await Player.loadSettings();
Player.sounds = [ ];
Player.playOrder = [ ];
if (isChanX) {
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);
} 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);
});
}
Player.render();
},
_tplOptions: function () {
return { ns: Player.ns, ...Player.settings };
},
render: async function () {
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(`#${Player.ns}-container`);
document.body.appendChild(Player.container);
// Keep track of various elements
Player.ui.title = Player.container.querySelector(`.${Player.ns}-title`);
Player.ui.closeButton = Player.container.querySelector(`.${Player.ns}-close-button`);
Player.ui.repeatButton = Player.container.querySelector(`.${Player.ns}-repeat-button`);
Player.ui.shuffleButton = Player.container.querySelector(`.${Player.ns}-shuffle-button`);
Player.ui.configButton = Player.container.querySelector(`.${Player.ns}-config-button`)
Player.ui.imageLink = Player.container.querySelector(`.${Player.ns}-image-link`);
Player.ui.image = Player.container.querySelector(`.${Player.ns}-image`);
Player.ui.video = Player.container.querySelector(`.${Player.ns}-video`);
Player.ui.listContainer = Player.container.querySelector(`.${Player.ns}-list-container`);
Player.ui.list = Player.container.querySelector(`.${Player.ns}-list`);
Player.ui.settingsContainer = Player.container.querySelector(`.${Player.ns}-settings`);
Player.ui.expander = Player.container.querySelector(`.${Player.ns}-expander`);
Player.audio = Player.container.querySelector(`.${Player.ns}-audio`);
// Render the other bits and make sure the buttons states are correct
Player.renderList();
Player.renderSettings();
Player.updateRepeatButton();
Player.updateShuffleButton();
// Add the event listeners for selecting a song
Player.ui.list.addEventListener('click', 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);
});
// Add event listeners for the title buttons
Player.ui.closeButton.addEventListener('click', Player.hide);
Player.ui.configButton.addEventListener('click', Player.toggleSettings);
Player.ui.shuffleButton.addEventListener('click', Player.toggleShuffle);
Player.ui.repeatButton.addEventListener('click', Player.toggleRepeat);
// Add event listeners for moving/resizing
Player.ui.expander.addEventListener('mousedown', Player.initResize, false);
Player.ui.title.addEventListener('mousedown', Player.initMove, false);
// Add audio event listeners
Player.audio.addEventListener('ended', Player.next);
Player.audio.addEventListener('pause', () => Player.ui.video.pause());
Player.audio.addEventListener('play', () => {
Player.ui.video.currentTime = Player.audio.currentTime;
Player.ui.video.play();
});
Player.audio.addEventListener('seeked', () => Player.ui.video.currentTime = Player.audio.currentTime);
},
renderList: function () {
if (Player.ui.list) {
Player.ui.list.innerHTML = Player._templates.list(Player._tplOptions());
}
},
renderSettings: function () {
if (Player.ui.settingsContainer) {
Player.ui.settingsContainer.innerHTML = Player._templates.settings(Player._tplOptions());
Player.ui.settingsContainer.querySelectorAll('input, textarea').forEach(function (input) {
input.addEventListener('blur', Player.handleSettingChange);
});
Player.ui.settingsContainer.querySelectorAll('input[type=checkbox]').forEach(function (input) {
input.addEventListener('change', Player.handleSettingChange);
});
}
},
hide: function (e) {
e && e.preventDefault();
Player.container.style.display = 'none';
},
show: async function (e) {
e && e.preventDefault();
if (!Player.container.style.display) {
return;
}
Player.container.style.display = null;
// Apply the last position/size
const [ top, left ] = (await GM.getValue(Player.ns + '.position') || '').split(':');
const [ width, height ] = (await GM.getValue(Player.ns + '.size') || '').split(':');
+width && +height && Player.resizeTo(width, height);
+top && +left && Player.moveTo(top, left);
},
toggleDisplay: function (e) {
e && e.preventDefault();
if (Player.container.style.display === 'none') {
Player.show();
} else {
Player.hide();
}
},
saveSettings: function () {
return GM.setValue(Player.ns + '.settings', JSON.stringify(Player.settings));
},
loadSettings: async function () {
let settings = await GM.getValue(Player.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);
},
handleSettingChange: function (e) {
const input = e.currentTarget;
const property = input.getAttribute('data-property').split('.');
const split = input.getAttribute('data-split');
const currentValue = property.reduce((v, k) => v && v[k], Player.settings);
let newValue = input.getAttribute('type') === 'checkbox'
? input.checked
: input.value;
if (split) {
newValue = newValue.split(split === 'linebreak' ? '\n' : split);
}
// Not the most stringent check but enough to avoid some spamming.
if (currentValue !== newValue) {
// Update the setting.
const lastProp = property.pop();
const setOn = property.reduce((obj, k) => obj && obj[k], Player.settings);
setOn && (setOn[lastProp] = newValue);
// Update the stylesheet reflect any changes.
Player.stylesheet.innerHTML = Player._templates.css(Player._tplOptions());
// Save the new settings.
Player.saveSettings();
}
},
toggleSettings: function (e) {
e.preventDefault();
if (Player.container.classList.contains(Player.ns + '-show-settings')) {
Player.container.classList.remove(Player.ns + '-show-settings');
} else {
Player.container.classList.add(Player.ns + '-show-settings');
}
},
toggleShuffle: function (e) {
e.preventDefault();
Player.settings.shuffle = !Player.settings.shuffle;
Player.updateShuffleButton();
// 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();
},
updateShuffleButton: function () {
const action = Player.settings.shuffle ? 'remove' : 'add';
Player.ui.shuffleButton.classList[action]('disabled');
Player.ui.shuffleButton.innerHTML = Player.settings.shuffle ? 'Shuffle' : 'Ordered';
Player.ui.shuffleButton.title = isChanX && Player.ui.shuffleButton.innerHTML;
},
toggleRepeat: function (e) {
e.preventDefault();
const options = Object.keys(repeatOptions);
const current = options.indexOf(Player.settings.repeat);
Player.settings.repeat = options[(current + 4) % 3];
Player.updateRepeatButton();
Player.saveSettings();
},
updateRepeatButton: function () {
Player.ui.repeatButton.innerHTML = repeatOptions[Player.settings.repeat];
Player.ui.repeatButton.title = isChanX && Player.ui.repeatButton.innerHTML;
const disabled = Player.settings.repeat === 'none';
const addOne = Player.settings.repeat === 'one';
Player.ui.repeatButton.classList[disabled ? 'add' : 'remove']('disabled');
Player.ui.repeatButton.classList[addOne ? 'add' : 'remove']('fa-repeat-one');
},
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);
},
doResize: function(e) {
Player.resizeTo(Player._startWidth + e.clientX - Player._startX, Player._startHeight + e.clientY - Player._startY);
},
resizeTo: function (width, height) {
Player.container.style.width = width + 'px';
Player.ui.listContainer.style.height = Math.max(10, height - 194) + 'px';
},
stopResize: function(e) {
const style = document.defaultView.getComputedStyle(Player.container);
document.documentElement.removeEventListener('mousemove', Player.doResize, false);
document.documentElement.removeEventListener('mouseup', Player.stopResize, false);
enableUserSelect();
GM.setValue(Player.ns + '.size', parseInt(style.width, 10) + ':' + parseInt(style.height, 10));
},
initMove: function (e) {
disableUserSelect();
Player.ui.title.style.cursor = 'grabbing';
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);
},
doMove: function (e) {
Player.moveTo(e.clientX - Player._offsetX, e.clientY - Player._offsetY);
},
moveTo: function (x, y) {
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';
},
stopMove: function (e) {
document.documentElement.removeEventListener('mousemove', Player.doMove, false);
document.documentElement.removeEventListener('mouseup', Player.stopMove, false);
Player.ui.title.style.cursor = null;
enableUserSelect();
GM.setValue(Player.ns + '.position', parseInt(Player.container.style.left, 10) + ':' + parseInt(Player.container.style.top, 10));
},
showThumb: function (sound) {
Player.ui.imageLink.classList.remove(Player.ns + '-show-video');
Player.ui.image.src = sound.thumb;
Player.ui.imageLink.href = sound.image;
},
showImage: function (sound) {
Player.ui.imageLink.classList.remove(Player.ns + '-show-video');
Player.ui.image.src = sound.image;
Player.ui.imageLink.href = sound.image;
},
playVideo: function (sound) {
Player.ui.imageLink.classList.add(Player.ns + '-show-video');
Player.ui.video.src = sound.image;
Player.ui.video.play();
},
add: function (title, id, src, thumb, image) {
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);
// Re-render the list
Player.renderList();
// 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.showThumb(sound);
}
},
play: function (sound) {
if (sound) {
if (Player.playing) {
const currentItem = Player.ui.list.querySelector('.playing');
currentItem && currentItem.classList.remove('playing');
}
const item = Player.ui.list.querySelector(`li[data-id="${sound.id}"]`);
item && item.classList.add('playing');
Player.playing = sound;
Player.audio.src = sound.src;
if (sound.image.endsWith('.webm')) {
Player.playVideo(sound);
} else {
Player.showImage(sound);
}
}
Player.audio.play();
},
pause: function () {
Player.audio.pause();
},
next: function () {
Player._movePlaying(1);
},
previous: function () {
Player._movePlaying(-1);
},
_movePlaying: function (direction) {
// 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);
}
};
async function doInit() {
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("DOMContentLoaded", function (event) {
setTimeout(function () {
if (!isChanX) {
doInit();
}
}, 1);
});
document.addEventListener( "4chanXInitFinished", function (event) {
if (document.documentElement.classList.contains("fourchan-x") && document.documentElement.classList.contains("sw-yotsuba")) {
isChanX = true;
doInit();
}
});
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) {
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);
}
}
};
function disableUserSelect () {
document.body.style.userSelect = 'none';
document.body.style.MozUserSelect = 'none';
}
function enableUserSelect () {
document.body.style.userSelect = null;
document.body.style.MozUserSelect = null;
}
})();