// ==UserScript==
// @name YouTube Volume Curve Designer
// @name:ja YouTube Volume Curve Designer
// @name:zh-CN YouTube Volume Curve Designer
// @namespace knoa.jp
// @description Customize the volume increasing curve for subtle sound tweaks and maximum volume boosts.
// @description:ja 音量の増加曲線をカスタマイズして、繊細な音の微調整や最大音量の引き上げを実現します。
// @description:zh-CN 自定义音量增加曲线,以实现细腻的声音微调和最大音量提升。
// @include https://www.youtube.com/*
// @include https://www.youtube.com/embed/*
// @include https://www.youtube-nocookie.com/embed/*
// @include https://music.youtube.com/*
// @exclude https://www.youtube.com/live_chat*
// @exclude https://www.youtube.com/live_chat_replay*
// @version 2.0.5
// @grant none
// ==/UserScript==
(function(){
const SCRIPTID = 'YouTubeVolumeCurveDesigner';
const SCRIPTNAME = 'YouTube Volume Curve Designer';
const DEBUG = false;/*
[update]
Minor fix.
[bug]
[todo]
[possible]
公式スライダをコピーしてからエミュレートすれば長いスライダに置換できるかな
広告とプレミア公開のカウントダウンにだけ別のゲイン値を設定?
複数タブ間のStorage経由同期?音量の同期は予期されていない
[research]
ABEMA: audio要素(チャンネルによって違いうる?), ビデオはvideoぽい
YouTube, Twitch, Vimeo, Dailymotion, Netflix, Amazon, Hulu
Niconico, ABEMA, OPENREC, SHOWROOM
bilibili, IQiyi, Youku
[memo]
YouTubeはノーマライズ(ゲイン調整)してる。100%の表示でも実際の video.volume は 0.5 だったりする。音楽で特に顕著。
そのため、YouTubeが表示用の音量として用意している ['aria-valuenow'] が必要。
YouTubeは直接音量0にするとミュートがオンになってvideo.volume自体は元の数値を保持したままとなる。
CPU使用率は Mac (Intel Core i7-3740QM) の coreaudiod で +1/800% ちょい。
Thanks to:
https://stackoverflow.com/questions/43794356/html5-volume-increase-past-100
Encouraged by:
https://www.dr-lex.be/info-stuff/volumecontrols.html
*/
if(window === top && console.time) console.time(SCRIPTID);
const MS = 1, SECOND = 1000*MS, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
const VIDEOVIEWS = [
/^https:\/\/www\.youtube\.com\/(channel|c|user)\/[^/]+(\?.+)?$/,
/^https:\/\/www\.youtube\.com\/(channel|c|user)\/[^/]+\/live(\?.+)?$/,
/^https:\/\/www\.youtube\.com\/watch\?/,
/^https:\/\/www\.youtube\.com\/embed\//,
/^https:\/\/www\.youtube-nocookie\.com\/embed\//,
/^https:\/\/music\.youtube\.com\/watch\?/,
];
const CHARTUNIT = 3;/* px per 1/100 of max volume */
const site = {
targets: {
title: () => $('title'),
},
videoTargets: {
video: () => $('video'),
},
controlTargets: {
/* YouTube || YouTube Music */
chromeBottom: () => $('.ytp-chrome-bottom') || $('ytmusic-player-bar'),
muteButton: () => $('button.ytp-mute-button') || $('paper-icon-button.volume'),
volumePanel: () => $('.ytp-volume-panel[aria-valuenow]') || $('#volume-slider'),
volumeSlider: () => $('.ytp-volume-slider') || $('#progressContainer'),
volumeSliderHandle: () => $('.ytp-volume-slider-handle') || $('#sliderKnob .slider-knob-inner'),
},
get: {
volumeNow: (volumePanel) => volumePanel ? parseFloat(volumePanel.attributes['aria-valuenow'].value) : 100,
tooltipText: () => $('.ytp-tooltip-text'),/* black tooltip */
},
showVolumeSlider: (chromeBottom) => chromeBottom.classList.add('ytp-volume-slider-active'),
hideVolumeSlider: (chromeBottom) => chromeBottom.classList.remove('ytp-volume-slider-active'),
};
class Configs{
constructor(configs){
Configs.PROPERTIES = {
enable: {type: 'bool', default: 1 },
gain: {type: 'float', default: 2.0},
exponent: {type: 'float', default: 2.0},
};
this.data = this.read(configs || {});
/* use Proxy for flexibility */
return new Proxy(this, {
get: function(configs, field){
if(field in configs) return configs[field];
}
});
}
read(configs){
let newConfigs = {};
Object.keys(Configs.PROPERTIES).forEach(key => {
if(configs[key] === undefined) return newConfigs[key] = Configs.PROPERTIES[key].default;
switch(Configs.PROPERTIES[key].type){
case('bool'): return newConfigs[key] = (configs[key]) ? 1 : 0;
case('int'): return newConfigs[key] = parseInt(configs[key]);
case('float'): return newConfigs[key] = parseFloat(configs[key]);
default: return newConfigs[key] = configs[key];
}
});
return newConfigs;
}
toJSON(){
let json = {};
Object.keys(this.data).forEach(key => {
json[key] = this.data[key];
});
return json;
}
set enable(enable){this.data.enable = enable;}
set gain(gain){this.data.gain = gain;}
set exponent(exponent){this.data.exponent = exponent;}
get enable(){return this.data.enable;}
get gain(){return this.data.gain;}
get exponent(){return this.data.exponent;}
}
let elements = {}, timers = {}, sizes = {}, panels, configs, gainNode;
const core = {
initialize: function(){
elements.html = document.documentElement;
elements.html.classList.add(SCRIPTID);
text.setup(texts, document.documentElement.lang);
core.ready();
core.addStyle('style');
core.addStyle('panelStyle');
},
ready: function(){
core.getTargets(site.targets).then(() => {
log("I'm ready.");
panels = new Panels(document.body.appendChild(createElement(html.panels())));
core.readyForVideos();
core.observeTitle();
}).catch(e => {
console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
});
},
readyForVideos: function(){
if(VIDEOVIEWS.some(view => view.test(location.href)) === false) return;
/* on ads, the video element is just change its src attribute */
core.getTargets(site.videoTargets).then(() => {
log("I'm ready for the video.", elements.video);
configs = new Configs(Storage.read('configs') || {});
core.createGain();
core.listenVolumeChange();
core.getTargets(site.controlTargets).then(() => {
log("I'm ready for the video control.");
core.configs.createButton();
core.configs.createPanel();
core.updateVolume(configs.gain, configs.exponent);
core.addIndicationOnMuteButton();
core.getVolumeSliderWidth();
}).catch(e => {
console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
});
}).catch(e => {
console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
});
},
observeTitle: function(){
let url = location.href;
observe(elements.title, function(records){
setTimeout(() => {
if(url === location.href) return;
else url = location.href;
panels.hide('configs');
core.readyForVideos();
}, 1000);
}, {childList: true, characterData: true, subtree: true});
},
addIndicationOnMuteButton: function(){
let muteButton = elements.muteButton, tooltipText = site.get.tooltipText();
if(muteButton.listeningMouseOver) return;
if(tooltipText === null) return;
muteButton.listeningMouseOver = true;
muteButton.addEventListener('mouseover', function(e){
animate(function(){
tooltipText.appendChild(document.createElement('br'));
tooltipText.appendChild(document.createTextNode(text('or Right Click...')));
});
});
},
getVolumeSliderWidth: function(){
/* やむなくクラス操作で表示させてから実測する */
let volumeSlider = elements.volumeSlider, chromeBottom = elements.chromeBottom;
let transitionElement = volumeSlider.parentNode;
transitionElement.style.transition = 'none';/*アニメーション途中で計測しない*/
site.showVolumeSlider(chromeBottom);
volumeSlider.style.transform = '';
let timer = setInterval(function(){
let rect = volumeSlider.getBoundingClientRect();
if(rect.x === 0 || rect.width === 0) return setTimeout(core.configs.getVolumeSliderWidth, 1000);
/* 小数点が含まれるとマウスクリックのズレが解消できない */
volumeSlider.style.transform = `translateX(-${rect.x%1}px)`;
sizes.volumeSliderWidth = rect.width;
//log('transform:', volumeSlider.style.transform, 'volumeSliderWidth:', sizes.volumeSliderWidth);
transitionElement.style.transition = '';/*transitionendめんどい*/
site.hideVolumeSlider(chromeBottom);
clearInterval(timer);
}, 1000);
if(window.listeningResize) return;
window.listeningResize = true;
window.addEventListener('resize', function(e){
clearTimeout(timers.getVolumeSliderWidth);
timers.getVolumeSliderWidth = setTimeout(core.configs.getVolumeSliderWidth, 1000);
});
},
createGain: function(){
let video = elements.video;
if(video.gained === true) return;
video.gained = true;
let context = new AudioContext();
let source = context.createMediaElementSource(video);
gainNode = context.createGain();
source.connect(gainNode);
gainNode.connect(context.destination);
/* rest the CPU on video paused */
if(video.paused) context.suspend();
video.addEventListener('play', e => context.resume());
video.addEventListener('pause', e => context.suspend());
},
listenVolumeChange: function(){
let video = elements.video;
if(video.listeningVolumechange) return;
video.listeningVolumechange = true;
video.addEventListener('volumechange', function(e){
core.updateVolume(configs.gain, configs.exponent);
if(panels.hidden('configs')) return;
core.configs.updateVolumeSlider();
core.configs.updateChartHighlight();
});
},
updateVolume: function(gain, exponent){
if(configs.enable === 0) return gainNode.gain.value = 1;
let video = elements.video, volumePanel = elements.volumePanel, volumeNow = site.get.volumeNow(volumePanel);
if(video.volume === 0 || volumeNow === 0) gainNode.gain.value = 0;
else gainNode.gain.value = (gain * ((volumeNow/100)**exponent)) / (volumeNow/100);
log('Volume:', video.volume.toFixed(2), 'Gained volume:', (gainNode.gain.value * video.volume).toFixed(2));
},
configs: {
createButton: function(){
let muteButton = elements.muteButton;
if(muteButton.listeningContextmenu) return;
muteButton.listeningContextmenu = true;
muteButton.addEventListener('contextmenu', function(e){
e.preventDefault();
e.stopPropagation();
if(panels.hidden('configs')){
core.configs.updateVolumeSlider();
core.configs.updateChartHighlight();
}
panels.toggle('configs');
});
},
createPanel: function(){
let panel = elements.configPanel = createElement(html.configPanel()), items = {};
let fieldset = elements.fieldset = panel.querySelector('fieldset.island');
let chart = elements.chart = panel.querySelector('.chart');
Array.from(panel.querySelectorAll('[name]')).forEach(e => items[e.name] = e);
let gain = parseFloat(items.gain.value), exponent = parseFloat(items.exponent.value);
/* on/off by enable checkbox */
let initialEnable = chart.dataset.enable = configs.enable;
items.enable.addEventListener('change', function(e){
configs.enable = fieldset.dataset.enable = chart.dataset.enable = (items.enable.checked) ? 1 : 0;
items.gain.disabled = items.exponent.disabled = !configs.enable;
core.updateVolume(gain, exponent);
core.configs.updateChartHighlight();
});
/* show 0.0 format and draw chart */
items.gain.value = parseFloat(items.gain.value).toFixed(1);
items.exponent.value = parseFloat(items.exponent.value).toFixed(1);
core.configs.drawChart(chart, gain, exponent);
items.gain.addEventListener('input', function(e){
let gain = parseFloat(items.gain.value), exponent = parseFloat(items.exponent.value);
items.gain.value = gain.toFixed(1);
core.configs.drawChart(chart, gain, exponent);
core.updateVolume(gain, exponent);
});
items.exponent.addEventListener('input', function(e){
let gain = parseFloat(items.gain.value), exponent = parseFloat(items.exponent.value);
items.exponent.value = exponent.toFixed(1);
core.configs.drawChart(chart, gain, exponent);
core.updateVolume(gain, exponent);
});
/* syncronize volume sliders */
let volumeSlider = elements.volumeSlider;
let volumeSliderHandle = elements.volumeSliderHandle;
elements.slider = items.slider;/* for synchronizing */
items.slider.addEventListener('input', function(e){
let volume = parseFloat(items.slider.value);
let rect = volumeSlider.getBoundingClientRect(), radius = volumeSliderHandle.getBoundingClientRect().width / 2;
if(location.host === 'music.youtube.com') radius = 0;/* special definition!! different slider style!! */
let options = {
clientX: rect.x + radius + ((sizes.volumeSliderWidth - radius*2) * (volume/100)),
clientY: rect.y + (rect.height/2),
bubbles: true,
};
volumeSlider.dispatchEvent(new MouseEvent('mousedown', options));
volumeSlider.dispatchEvent(new MouseEvent('mouseup', options));
});
/* cancel */
panel.querySelector('button.cancel').addEventListener('click', function(e){
configs.enable = initialEnable;/* restore */
panels.hide('configs');
core.configs.createPanel();/*clear*/
core.updateVolume(configs.gain, configs.exponent);
});
/* save */
panel.querySelector('button.save').addEventListener('click', function(e){
configs = new Configs({
enable: items.enable.checked,
gain: items.gain.value,
exponent: items.exponent.value,
});
Storage.save('configs', configs.toJSON());
panels.hide('configs');
core.configs.createPanel();/*clear*/
core.updateVolume(configs.gain, configs.exponent);
});
panels.add('configs', panel);
draggable(panel);
},
drawChart: function(chart, gain, exponent){
let originalBars = elements.originalBars = chart.querySelectorAll('dl.original dd');
let gainedBars = elements.gainedBars = chart.querySelectorAll('dl.gained dd');
originalBars.forEach(dd => {
let level = parseInt(dd.previousElementSibling.textContent) / 100;
dd.style.width = `calc(((${level} / ${gain})*100)*${CHARTUNIT}px)`;
dd.style.textIndent = `calc(((${level} / ${gain})*100)*${CHARTUNIT}px + 2px)`;
});
gainedBars.forEach(dd => {
let level = parseInt(dd.previousElementSibling.textContent) / 100;
let volume = ((level**exponent) * gain * 100).toFixed(1).split(/(?=\.)/);
dd.textContent = volume[0];
if(0 < level && parseInt(volume[0]) < 10) dd.append(createElement(`<span class="decimal">${volume[1]}</span>`));
dd.style.width = `calc(((${level**exponent})*100)*${CHARTUNIT}px)`;
dd.style.textIndent = `calc(((${level**exponent})*100)*${CHARTUNIT}px + 2px)`;
});
},
updateVolumeSlider: function(){
let slider = elements.slider;/* on the config panel */
if(document.activeElement === slider) return;
let video = elements.video;
let volumePanel = elements.volumePanel, volumeNow = site.get.volumeNow(volumePanel);
if(video.muted) slider.value = 0;
else slider.value = volumeNow;
},
updateChartHighlight: function(){
let video = elements.video;
let volumePanel = elements.volumePanel, volumeNow = site.get.volumeNow(volumePanel);
let chart = elements.chart, terms = chart.querySelectorAll(configs.enable ? 'dl.gained dt' : 'dl.original dt');
for(let i = 0, lastIndex = terms.length - 1, dt, filled = false; dt = terms[i]; i++){
switch(true){
case(filled):
case(video.muted):
if(dt.classList.contains('fill')) dt.classList.remove('fill');
break;
case(parseInt(dt.textContent) <= volumeNow):
case(i === lastIndex && volumeNow === 100):
if(!dt.classList.contains('fill')) dt.classList.add('fill');
break;
case(volumeNow <= parseInt(dt.textContent)):
default:
filled = true;
if(dt.classList.contains('fill')) dt.classList.remove('fill');
break;
}
}
},
},
getTarget: function(selector, retry = 10, interval = 1*SECOND){
const key = selector.name;
const get = function(resolve, reject){
let selected = selector();
if(selected && selected.length > 0) selected.forEach((s) => s.dataset.selector = key);/* elements */
else if(selected instanceof HTMLElement) selected.dataset.selector = key;/* element */
else if(--retry) return log(`Not found: ${key}, retrying... (${retry})`), setTimeout(get, interval, resolve, reject);
else return reject(new Error(`Not found: ${selector.name}, I give up.`));
elements[key] = selected;
resolve(selected);
};
return new Promise(function(resolve, reject){
get(resolve, reject);
});
},
getTargets: function(selectors, retry = 10, interval = 1*SECOND){
return Promise.all(Object.values(selectors).map(selector => core.getTarget(selector, retry, interval)));
},
addStyle: function(name = 'style'){
if(html[name] === undefined) return;
let style = createElement(html[name]());
document.head.appendChild(style);
if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
elements[name] = style;
},
};
const texts = {
'or Right Click...': {
en: () => `or Right Click...`,
ja: () => `または右クリック...`,
zh: () => `或右键单击...`,
},
'${SCRIPTNAME}': {
en: () => `${SCRIPTNAME}`,
ja: () => `${SCRIPTNAME}`,
zh: () => `${SCRIPTNAME}`,
},
'${SCRIPTNAME} preferences': {
en: () => `${SCRIPTNAME} preferences`,
ja: () => `${SCRIPTNAME} 設定`,
zh: () => `${SCRIPTNAME} 设定`,
},
'Volume curve design': {
en: () => `Volume curve design`,
ja: () => `音量カーブ設計`,
zh: () => `音量曲线设计`,
},
'Enable': {
en: () => `Enable`,
ja: () => `有効にする`,
zh: () => `使之有效`,
},
'Gain': {
en: () => `Gain`,
ja: () => `ゲイン`,
zh: () => `增量`,
},
'Exponent': {
en: () => `Exponent`,
ja: () => `冪数`,
zh: () => `幂数`,
},
'volume × G': {
en: () => `volume × G`,
ja: () => `音量 × G`,
zh: () => `音量 × G`,
},
'volume<sup>E</sup>': {
en: () => `volume<sup>E</sup>`,
ja: () => `音量<sup>E</sup>`,
zh: () => `音量<sup>E</sup>`,
},
'Try on this video': {
en: () => `Try on this video`,
ja: () => `この動画の音量で試す`,
zh: () => `尝试这个视频的音量`,
},
'Actual volume(%)': {
en: () => `Actual volume(%)`,
ja: () => `実際の音量(%)`,
zh: () => `实际的音量(%)`,
},
'Cancel': {
en: () => `Cancel`,
ja: () => `キャンセル`,
zh: () => `取消`,
},
'Save': {
en: () => `Save`,
ja: () => `保存`,
zh: () => `保存`,
},
};
const html = {
panels: () => `<div class="panels" id="${SCRIPTID}-panels" data-panels="0"></div>`,
configPanel: () => `
<div class="panel" id="${SCRIPTID}-configPanel" data-order="1">
<h1>${text('${SCRIPTNAME} preferences')}</h1>
<fieldset class="island">
<legend>${text('Volume curve design')}</legend>
<p><label for="config-enable">${text('Enable')}:</label><input type="checkbox" name="enable" id="config-enable" value="1" ${configs.enable ? 'checked' : ''}></p>
<p class="sub"><label for="config-gain" >${text('Gain') }<small>(${text('volume × G') })</small>:</label><input type="number" name="gain" id="config-gain" value="${configs.gain}" min="1.0" max="8.0" step="0.1"></p>
<p class="sub"><label for="config-exponent">${text('Exponent')}<small>(${text('volume<sup>E</sup>')})</small>:</label><input type="number" name="exponent" id="config-exponent" value="${configs.exponent}" min="1.0" max="4.0" step="0.1"></p>
<legend>${text('Try on this video')}</legend>
<p><input type="range" name="slider" id="config-slider" value="100" min="0" max="100" step="1"></p>
</fieldset>
<div class="chart" data-enable="${configs.enable}">
<dl class="original">
<dt> 0</dt><dd> 0</dd>
<dt> 5</dt><dd> 5</dd>
<dt> 10</dt><dd> 10</dd>
<dt> 15</dt><dd> 15</dd>
<dt> 20</dt><dd> 20</dd>
<dt> 25</dt><dd> 25</dd>
<dt> 30</dt><dd> 30</dd>
<dt> 35</dt><dd> 35</dd>
<dt> 40</dt><dd> 40</dd>
<dt> 45</dt><dd> 45</dd>
<dt> 50</dt><dd> 50</dd>
<dt> 55</dt><dd> 55</dd>
<dt> 60</dt><dd> 60</dd>
<dt> 65</dt><dd> 65</dd>
<dt> 70</dt><dd> 70</dd>
<dt> 75</dt><dd> 75</dd>
<dt> 80</dt><dd> 80</dd>
<dt> 85</dt><dd> 85</dd>
<dt> 90</dt><dd> 90</dd>
<dt> 95</dt><dd> 95</dd>
<dt>100</dt><dd>100</dd>
</dl>
<dl class="gained">
<dt> 0</dt><dd> 0</dd>
<dt> 5</dt><dd> 5</dd>
<dt> 10</dt><dd> 10</dd>
<dt> 15</dt><dd> 15</dd>
<dt> 20</dt><dd> 20</dd>
<dt> 25</dt><dd> 25</dd>
<dt> 30</dt><dd> 30</dd>
<dt> 35</dt><dd> 35</dd>
<dt> 40</dt><dd> 40</dd>
<dt> 45</dt><dd> 45</dd>
<dt> 50</dt><dd> 50</dd>
<dt> 55</dt><dd> 55</dd>
<dt> 60</dt><dd> 60</dd>
<dt> 65</dt><dd> 65</dd>
<dt> 70</dt><dd> 70</dd>
<dt> 75</dt><dd> 75</dd>
<dt> 80</dt><dd> 80</dd>
<dt> 85</dt><dd> 85</dd>
<dt> 90</dt><dd> 90</dd>
<dt> 95</dt><dd> 95</dd>
<dt>100</dt><dd>100</dd>
</dl>
</div>
<p class="buttons"><button class="cancel">${text('Cancel')}</button><button class="save primary">${text('Save')}</button></p>
</div>
`,
style: () => `
<style type="text/css" id="${SCRIPTID}-style">
[data-selector="muteButton"]:hover *{
fill: rgb(131,176,234);
}
</style>
`,
panelStyle: () => `
<style type="text/css" id="${SCRIPTID}-panelStyle">
/* panels default */
#${SCRIPTID}-panels *{
font-size: 14px;
line-height: 20px;
padding: 0;
margin: 0;
}
#${SCRIPTID}-panels{
font-family: Arial, sans-serif;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
overflow: hidden;
pointer-events: none;
cursor: default;
z-index: 99999;
}
#${SCRIPTID}-panels div.panel{
position: absolute;
max-height: 100%;
overflow: auto;
left: 50%;
bottom: 50%;
transform: translate(-50%, 50%);
background: rgba(0,0,0,.75);
transition: 250ms ease-out;
padding: 5px 0;
pointer-events: auto;
}
#${SCRIPTID}-panels div.panel.hidden{
transform: translate(-50%, calc(50vh + 100%)) !important;
display: block !important;
}
#${SCRIPTID}-panels div.panel.hidden *{
animation: none !important;
}
#${SCRIPTID}-panels h1,
#${SCRIPTID}-panels h2,
#${SCRIPTID}-panels h3,
#${SCRIPTID}-panels h4,
#${SCRIPTID}-panels legend,
#${SCRIPTID}-panels ul,
#${SCRIPTID}-panels ol,
#${SCRIPTID}-panels dl,
#${SCRIPTID}-panels p{
color: white;
padding: 2px 10px;
vertical-align: baseline;
}
#${SCRIPTID}-panels legend ~ p,
#${SCRIPTID}-panels legend ~ ul,
#${SCRIPTID}-panels legend ~ ol,
#${SCRIPTID}-panels legend ~ dl{
padding-left: calc(10px + 14px);
}
#${SCRIPTID}-panels header{
display: flex;
}
#${SCRIPTID}-panels header h1{
flex: 1;
}
#${SCRIPTID}-panels fieldset{
border: none;
}
#${SCRIPTID}-panels fieldset > p{
display: flex;
align-items: center;
}
#${SCRIPTID}-panels fieldset > p:not([class]):hover,
#${SCRIPTID}-panels fieldset > p.sub:hover{
background: rgba(255,255,255,.125);
}
#${SCRIPTID}-panels fieldset > p > label{
flex: 1;
}
#${SCRIPTID}-panels fieldset > p > input,
#${SCRIPTID}-panels fieldset > p > textarea,
#${SCRIPTID}-panels fieldset > p > select{
color: black;
background: white;
padding: 1px 2px;
}
#${SCRIPTID}-panels fieldset > p > input,
#${SCRIPTID}-panels fieldset > p > button{
box-sizing: border-box;
height: 20px;
}
#${SCRIPTID}-panels fieldset small{
font-size: 85%;
margin: 0 0 0 .25em;
}
#${SCRIPTID}-panels fieldset sup,
#${SCRIPTID}-panels fieldset p.note{
font-size: 10px;
line-height: 14px;
color: rgb(192,192,192);
}
#${SCRIPTID}-panels a{
color: inherit;
font-size: inherit;
line-height: inherit;
}
#${SCRIPTID}-panels a:hover{
color: rgb(255,255,255);
}
#${SCRIPTID}-panels div.panel > p.buttons{
text-align: right;
padding: 5px 10px;
}
#${SCRIPTID}-panels div.panel > p.buttons button{
line-height: 1.4;
width: 120px;
padding: 5px 10px;
margin-left: 10px;
border-radius: 5px;
color: rgba(255,255,255,1);
background: rgba(64,64,64,1);
border: 1px solid rgba(255,255,255,1);
cursor: pointer;
}
#${SCRIPTID}-panels div.panel > p.buttons button.primary{
font-weight: bold;
background: rgba(0,0,0,1);
}
#${SCRIPTID}-panels div.panel > p.buttons button:hover,
#${SCRIPTID}-panels div.panel > p.buttons button:focus{
background: rgba(128,128,128,1);
}
#${SCRIPTID}-panels .template{
display: none !important;
}
/* config panel */
#${SCRIPTID}-configPanel{
/*--${SCRIPTID}-chartSize: max(calc(36px + 100*${CHARTUNIT}px + 16px), calc(32px + 21*16px));*//* max: from Firefox 75*/
--${SCRIPTID}-chartSize: calc(32px + 21*16px);
}
#${SCRIPTID}-configPanel{
width: calc(var(--${SCRIPTID}-chartSize) + 10px);
cursor: grab;
}
#${SCRIPTID}-configPanel.dragging{
transition: none !important;
cursor: grabbing;
}
#${SCRIPTID}-configPanel fieldset.island{
position: absolute;
z-index: 10;
}
#${SCRIPTID}-configPanel fieldset.island[data-enable="0"] .sub{
pointer-events: none;
opacity: .5;
}
#${SCRIPTID}-configPanel fieldset.island input[name="gain"],
#${SCRIPTID}-configPanel fieldset.island input[name="exponent"]{
width: 4em;
margin-left: 5px;
}
#${SCRIPTID}-configPanel fieldset.island input[name="slider"]{
width: 100%;
}
#${SCRIPTID}-configPanel .chart{
width: var(--${SCRIPTID}-chartSize);
height: var(--${SCRIPTID}-chartSize);
padding: 5px;
transform: rotate(-90deg);
}
#${SCRIPTID}-configPanel .chart dl{
width: var(--${SCRIPTID}-chartSize);
height: var(--${SCRIPTID}-chartSize);
padding: 0;
margin: 0;
position: absolute;
}
#${SCRIPTID}-configPanel .chart[data-enable="1"] dl.gained,
#${SCRIPTID}-configPanel .chart[data-enable="0"] dl.original{
z-index: 5;
}
#${SCRIPTID}-configPanel .chart dl dt{
text-align: right;
width: 32px;
padding-right: 4px;
clear: both;
}
#${SCRIPTID}-configPanel .chart dl dt,
#${SCRIPTID}-configPanel .chart dl dd{
float: left;
height: 16px;
font-size: 12px !important;
line-height: 16px;
}
#${SCRIPTID}-configPanel .chart dl dd{
transition: width 125ms, text-indent 125ms;
}
#${SCRIPTID}-configPanel .chart dl.original dt:last-of-type{
position: relative;
}
#${SCRIPTID}-configPanel .chart dl.original dt:last-of-type::after{
content: "${text('Actual volume(%)')}";
position: absolute;
left: 100%;
bottom: -100%;
width: ${100*CHARTUNIT}px;
text-align: left;
}
#${SCRIPTID}-configPanel .chart[data-enable="1"] dl.original dd,
#${SCRIPTID}-configPanel .chart[data-enable="0"] dl.gained dd{
color: transparent;
background: rgba(256,256,256,.125);
}
#${SCRIPTID}-configPanel .chart dl.gained dt{
visibility: hidden;
}
#${SCRIPTID}-configPanel .chart[data-enable="1"] dl.gained dd,
#${SCRIPTID}-configPanel .chart[data-enable="0"] dl.original dd{
background: rgba(192,192,192,.75);
}
#${SCRIPTID}-configPanel .chart[data-enable="1"] dl.gained dt.fill + dd,
#${SCRIPTID}-configPanel .chart[data-enable="0"] dl.original dt.fill + dd{
background: rgba( 0, 0,256,.75);
}
#${SCRIPTID}-configPanel .chart[data-enable="1"] dl.gained dd span.decimal{
font-size: 10px;
color: gray;
}
</style>
`,
};
const setTimeout = window.setTimeout.bind(window), clearTimeout = window.clearTimeout.bind(window), setInterval = window.setInterval.bind(window), clearInterval = window.clearInterval.bind(window), requestAnimationFrame = window.requestAnimationFrame.bind(window);
const alert = window.alert.bind(window), confirm = window.confirm.bind(window), prompt = window.prompt.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window);
if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
class Storage{
static key(key){
return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
}
static save(key, value, expire = null){
key = Storage.key(key);
localStorage[key] = JSON.stringify({
value: value,
saved: Date.now(),
expire: expire,
});
}
static read(key){
key = Storage.key(key);
if(localStorage[key] === undefined) return undefined;
let data = JSON.parse(localStorage[key]);
if(data.value === undefined) return data;
if(data.expire === undefined) return data;
if(data.expire === null) return data.value;
if(data.expire < Date.now()) return localStorage.removeItem(key);/*undefined*/
return data.value;
}
static remove(key){
key = Storage.key(key);
delete localStorage.removeItem(key);
}
static delete(key){
Storage.remove(key);
}
static saved(key){
key = Storage.key(key);
if(localStorage[key] === undefined) return undefined;
let data = JSON.parse(localStorage[key]);
if(data.saved) return data.saved;
else return undefined;
}
}
class Panels{
constructor(container){
this.container = container;
this.panels = {};
this.listen();
}
listen(){
const EXCLUDES = ['input', 'textarea'];
window.addEventListener('keydown', (e) => {
if(e.key !== 'Escape') return;
if(EXCLUDES.includes(document.activeElement.localName)) return;
Object.keys(this.panels).forEach(key => this.hide(key));
}, true);
}
add(name, panel){
this.panels[name] = panel;
}
toggle(name){
if(this.panels[name] === undefined) return;
let panel = this.panels[name];
if(panel.isConnected === false || panel.classList.contains('hidden')) this.show(name);
else this.hide(name);
}
show(name){
if(this.panels[name] === undefined) return;
let panel = this.panels[name];
if(panel.isConnected) return;
panel.classList.add('hidden');
this.container.appendChild(panel);
this.container.dataset.panels = parseInt(this.container.dataset.panels) + 1;
animate(() => panel.classList.remove('hidden'));
}
shown(name){
if(this.panels[name] === undefined) return;
let panel = this.panels[name];
return panel.isConnected;
}
hide(name){
if(this.panels[name] === undefined) return;
let panel = this.panels[name];
if(panel.classList.contains('hidden')) return;
panel.classList.add('hidden');
panel.addEventListener('transitionend', (e) => {
this.container.removeChild(panel);
this.container.dataset.panels = parseInt(this.container.dataset.panels) - 1;
}, {once: true});
}
hidden(name){
if(this.panels[name] === undefined) return;
return !this.shown(name);
}
}
const text = function(key, ...args){
if(text.texts[key] === undefined){
log('Not found text key:', key);
return key;
}else return text.texts[key](args);
};
text.setup = function(texts, language){
let languages = [...window.navigator.languages];
if(language) languages.unshift(...String(language).split('-').map((p,i,a) => a.slice(0,1+i).join('-')).reverse());
if(!languages.includes('en')) languages.push('en');
languages = languages.map(l => l.toLowerCase());
Object.keys(texts).forEach(key => {
Object.keys(texts[key]).forEach(l => texts[key][l.toLowerCase()] = texts[key][l]);
texts[key] = texts[key][languages.find(l => texts[key][l] !== undefined)] || (() => key);
});
text.texts = texts;
};
const $ = function(s, f){
let target = document.querySelector(s);
if(target === null) return null;
return f ? f(target) : target;
};
const $$ = function(s, f){
let targets = document.querySelectorAll(s);
return f ? Array.from(targets).map(t => f(t)) : targets;
};
const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
const createElement = function(html = '<span></span>'){
let outer = document.createElement('div');
outer.innerHTML = html;
return outer.firstElementChild;
};
const observe = function(element, callback, options = {childList: true, characterData: false, subtree: false, attributes: false, attributeFilter: undefined}){
let observer = new MutationObserver(callback.bind(element));
observer.observe(element, options);
return observer;
};
const draggable = function(element, grabbable){
const DELAY = 1000;/* catching up mouse position between each mousemoves while fast dragging (ms) */
const EXCLUDE = ['input', 'textarea', 'button'];
const mousedown = function(e){
if(e.button !== 0) return;
if(EXCLUDE.includes(e.target.localName)) return;
if(grabbable && e.target !== grabbable) return;
element.classList.add('dragging');
[screenX, screenY] = [e.screenX, e.screenY];
[a,b,c,d,tx,ty] = (getComputedStyle(element).transform.match(/[-0-9.]+/g) || [1,0,0,1,0,0]).map((n) => parseFloat(n));
window.addEventListener('mousemove', mousemove);
window.addEventListener('mouseup', mouseup, {once: true});
document.body.addEventListener('mouseleave', mouseup, {once: true});
element.addEventListener('mouseleave', mouseleave, {once: true});
};
const mousemove = function(e){
element.style.transform = `matrix(${a},${b},${c},${d},${tx + (e.screenX - screenX)},${ty + (e.screenY - screenY)})`;
};
const mouseup = function(e){
element.classList.remove('dragging');
window.removeEventListener('mousemove', mousemove);
};
const mouseleave = function(e){
let timer = setTimeout(mouseup, DELAY);
element.addEventListener('mouseenter', clearTimeout.bind(window, timer), {once: true});
};
let screenX, screenY, a, b, c, d, tx, ty;
element.classList.add('draggable');
element.addEventListener('mousedown', mousedown);
};
const log = function(){
if(!DEBUG) return;
let l = log.last = log.now || new Date(), n = log.now = new Date();
let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
//console.log(error.stack);
console.log(
SCRIPTID + ':',
/* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
/* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
/* :00 */ ':' + line,
/* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
/* caller */ (callers[1] || '') + '()',
...arguments
);
};
log.formats = [{
name: 'Firefox Scratchpad',
detector: /MARKER@Scratchpad/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Console',
detector: /MARKER@debugger/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Greasemonkey 3',
detector: /\/gm_scripts\//,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Greasemonkey 4+',
detector: /MARKER@user-script:/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Tampermonkey',
detector: /MARKER@moz-extension:/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Chrome Console',
detector: /at MARKER \(<anonymous>/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
}, {
name: 'Chrome Tampermonkey',
detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?name=/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 1,
getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
}, {
name: 'Chrome Extension',
detector: /at MARKER \(chrome-extension:/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
}, {
name: 'Edge Console',
detector: /at MARKER \(eval/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
}, {
name: 'Edge Tampermonkey',
detector: /at MARKER \(Function/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
}, {
name: 'Safari',
detector: /^MARKER$/m,
getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
getCallers: (e) => e.stack.split('\n'),
}, {
name: 'Default',
detector: /./,
getLine: (e) => 0,
getCallers: (e) => [],
}];
log.format = log.formats.find(function MARKER(f){
if(!f.detector.test(new Error().stack)) return false;
//console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
return true;
});
core.initialize();
if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
})();