// ==UserScript==
// @name ABEMA Auto Adjust Playback Position
// @namespace https://gf.qytechs.cn/scripts/451815
// @version 3
// @description ABEMAで放送中の番組の遅延をなるべく改善します。
// @match https://abema.tv/*
// @grant none
// @license MIT License
// ==/UserScript==
// @ts-check
(() => {
'use strict';
/* ---------- Settings ---------- */
// 変更した値はブラウザのローカルストレージに保存するので
// スクリプトをバージョンアップするたびに書き換える必要はありません。
// (値が0のときは以前に変更した値もしくは初期値を使用します)
// 倍速再生時の速度倍率
// 初期値:1.5
// 有効値:1.1 ~ 2.0
let playbackRate = 0;
// 生放送でのバッファの下限(秒数)
// 初期値:3
// 有効値:1 ~ 10
let liveBuffer = 0;
// 遅延を積極的に減らす(1:有効 / 2:無効)
// 初期値:1
// 有効値:1 ~ 2
let activelyAdjust = 0;
/* ------------------------------ */
const sid = 'AutoAdjustPlaybackPosition',
ls = JSON.parse(localStorage.getItem(sid) || '{}') || {},
buffer = {
archive: 15,
changeableRate: true,
count: 0,
currentMax: 0,
currentMin: 0,
/** @type {number[]} */
max: [],
/** @type {number[]} */
min: [],
originalArchive: 0,
originalLive: 0,
prev: 0,
},
interval = { buffer: 0, init: 0, speed: 0, video: 0 },
moConfig = { childList: true, subtree: true },
selector = {
footerText: '.com-tv-LinearFooter__feed-super-text',
inner: '.c-application-DesktopAppContainer__content',
liveIcon: '.com-a-LegacyIcon__red-icon-path[aria-label="生放送"]',
main: 'main',
splash: '.com-a-Video__video',
video: 'video[src]:not([style*="display: none;"])',
};
/**
* ページにイベントリスナーを追加
*/
const addEventPage = () => {
const id = document.querySelector(`.${sid}_Event`);
if (!id) {
log('addEventPage');
const inner = document.querySelector(selector.inner);
if (inner) {
inner.classList.add(`${sid}_Event`);
}
}
};
/**
* 動画の再生速度を変更する
* @param {number} t 変更する時間(秒)
* @param {number} r 速度の倍率
*/
const changePlaybackSpeed = (t, r) => {
clearInterval(interval.speed);
const vi = returnVideo();
if (t && r) {
t = (t / r) * 2;
log('Start change playback speed', t.toFixed(2), r);
vi.playbackRate = r;
interval.speed = setInterval(() => {
clearInterval(interval.speed);
log('Stop change playback speed', t.toFixed(2), r);
vi.playbackRate = 1;
resetBufferObj();
}, t * 1000);
} else if (vi.playbackRate !== 1) {
log('Reset playback speed');
vi.playbackRate = 1;
resetBufferObj();
}
};
/**
* 動画のバッファを調べる
*/
const checkVideoBuffer = () => {
clearInterval(interval.buffer);
interval.buffer = setInterval(() => {
const vi = returnVideo();
if (
/^https:\/\/abema\.tv\/now-on-air\/[\w-]+\/?$/.test(location.href) &&
vi?.buffered?.length
) {
const b = Math.floor((vi.buffered.end(0) - vi.currentTime) * 10) / 10,
live = cheeckExistsFooterLiveIcon(),
after = live ? ' [LIVE]' : '',
slow = 0.8;
if (buffer.currentMax < b) buffer.currentMax = b;
if (buffer.currentMin > b || buffer.currentMin === 0) {
buffer.currentMin = b;
}
if (vi.buffered.length > 1) {
log('*** vi.buffered.length ***', vi.buffered.length);
for (let i = 0, l = vi.buffered.length; i < l; i++) {
log(i, vi.currentTime, vi.buffered.start(i), vi.buffered.end(i));
}
}
if (
b > 0 &&
buffer.changeableRate &&
vi.duration > 20000000000 &&
checkExistsFooterText()
) {
if (vi.playbackRate >= 1 && b < 1) {
//現在のバッファが1秒未満になったときスロー再生する
if (live) liveBuffer += 0.5;
else buffer.archive += 0.5;
const buff = live ? liveBuffer : buffer.archive;
log('## A', vi.playbackRate, b, live, buff);
changePlaybackSpeed(1.2 - b, slow);
} else if (vi.playbackRate >= 1 && b < 2 && !live) {
//生放送以外で現在のバッファが2秒未満になったときスロー再生する
buffer.archive += 0.5;
log('## B', vi.playbackRate, b, live, buffer.archive);
changePlaybackSpeed(3 - b, slow);
} else if (vi.playbackRate > 1 && b < 8 && !live) {
//生放送以外で倍速再生中に現在のバッファが8秒未満になったとき等速再生に戻す
log('## C', vi.playbackRate, b, live);
changePlaybackSpeed(0, 0);
} else if (
buffer.prev < b &&
buffer.currentMax - buffer.currentMin > 1
) {
buffer.max.push(buffer.currentMax);
buffer.min.push(buffer.currentMin);
buffer.currentMax = 0;
buffer.currentMin = 0;
buffer.count += 1;
let time = 0;
const maxLast = [...buffer.max].slice(-10),
minLast = [...buffer.min].slice(-10),
maxBottom = maxLast.reduce((x, y) => Math.min(x, y)),
minBottom = minLast.reduce((x, y) => Math.min(x, y)),
maxDiff =
Math.round(
(maxLast.reduce((x, y) => Math.max(x, y)) - maxBottom) * 100
) / 100,
minDiff =
Math.round(
(minLast.reduce((x, y) => Math.max(x, y)) - minBottom) * 100
) / 100,
lb1 = liveBuffer <= 3.5 ? 2 : liveBuffer <= 6.5 ? 4 : 6,
lb2 = liveBuffer > 3 ? liveBuffer : 3;
if (vi.playbackRate === 1) {
if ((maxDiff >= 1 || minDiff >= 1) && live) {
//生放送時に最大/最小バッファのどちらかの差分が1秒以上のとき
//最低バッファが4秒になるようスロー再生する
time = Math.round((4 - minBottom) * 100) / 100;
log('## D', time, b, maxDiff, minDiff, live);
changePlaybackSpeed(time, slow);
} else if (
//最大バッファがbuffer.archiveより多いとき
//最大バッファがbuffer.archiveに近づくよう倍速再生する
maxLast.length >= 3 &&
maxBottom > buffer.archive + 0.5
) {
time = Math.round((maxBottom - buffer.archive) * 100) / 100;
log('## E', time, b, maxDiff, minDiff, live);
changePlaybackSpeed(time, playbackRate);
} else if (
//生放送&最小バッファがliveBufferより多い&バッファが安定し続けているとき
//最小バッファがliveBufferに近づくよう倍速再生する
live &&
minLast.length >= 5 &&
minBottom > liveBuffer + 0.5 &&
maxDiff < 0.5 &&
minDiff < 0.5
) {
time = Math.round((minBottom - liveBuffer) * 100) / 100;
log('## F', time, b, maxDiff, minDiff);
changePlaybackSpeed(time, playbackRate);
} else if (
//生放送でバッファが安定しつづけているとき最小バッファを
//liveBufferよりも減らすよう(下限は2秒)倍速再生する
activelyAdjust === 1 &&
live &&
minLast.length >= 10 &&
minBottom > lb1 + 0.5 &&
maxDiff < 0.5 &&
minDiff < 0.5
) {
time = Math.round((minBottom - lb1) * 100) / 100;
log('## G', time, b, maxDiff, minDiff);
changePlaybackSpeed(time, playbackRate);
} else if (
//生放送以外で最小バッファが9秒に近づくよう倍速再生する
activelyAdjust === 1 &&
!live &&
minLast.length >= 10 &&
minBottom > 9.5 &&
maxDiff < 0.5
) {
time = Math.round((minBottom - 9) * 100) / 100;
log('## H', time, b, maxDiff, minDiff);
changePlaybackSpeed(time, playbackRate);
} else if (
//生放送以外でバッファが生放送のように安定し続けているとき
//最小バッファがliveBuffer(下限は3秒)に近づくよう倍速再生する
activelyAdjust === 1 &&
!live &&
minLast.length >= 10 &&
minBottom > lb2 + 0.5 &&
maxDiff < 0.5 &&
minDiff < 0.5
) {
time = Math.round((minBottom - lb2) * 100) / 100;
log('## I', time, b, maxDiff, minDiff);
changePlaybackSpeed(time, playbackRate);
}
}
log(
buffer.count,
'max:[',
buffer.max.slice(-5).join(' '),
']',
maxBottom,
maxDiff,
'min:[',
buffer.min.slice(-5).join(' '),
']',
minBottom,
minDiff,
live,
vi.buffered.length
);
}
} else if (b <= 0 && checkExistsFooterText()) {
log(
'** -b',
vi.playbackRate,
b,
buffer.currentMax,
buffer.currentMin,
live,
vi.buffered.length
);
} else {
buffer.archive = buffer.originalArchive;
buffer.count = 0;
buffer.currentMax = 0;
buffer.currentMin = 0;
buffer.max = [];
buffer.min = [];
liveBuffer = buffer.originalLive;
if (vi.playbackRate !== 1) {
changePlaybackSpeed(0, 0);
}
}
if (!buffer.changeableRate && !checkExistsFooterText()) {
buffer.changeableRate = true;
log('changeableRate', buffer.changeableRate);
}
buffer.prev = b;
if (vi.playbackRate > 1) {
showInfo(`▶▶ ×${vi.playbackRate}${after}`);
} else if (vi.playbackRate < 1) {
showInfo(`▶ ×${vi.playbackRate}${after}`);
}
} else {
clearInterval(interval.buffer);
resetBufferObj();
}
}, 100);
};
/**
* 動画を構成している要素に変更があったとき
*/
const checkChangeElements = () => {
log('checkChangeElements');
const inner = document.querySelector(selector.inner);
if (inner) {
setTimeout(() => {
addEventPage();
checkVideoBuffer();
}, 50);
}
};
/**
* フッターに番組プログラムのテキストがあるか調べる
* @returns {boolean}
*/
const checkExistsFooterText = () => {
const span = document.querySelector(selector.footerText);
return span ? true : false;
};
/**
* フッターに生放送アイコンがあるか調べる
* @returns {boolean}
*/
const cheeckExistsFooterLiveIcon = () => {
const svg = document.querySelector(selector.liveIcon);
return svg ? true : false;
};
/**
* 情報を表示する要素をクリックしたとき
*/
const clickInfo = () => {
log('clickInfo');
if (buffer.changeableRate) {
changePlaybackSpeed(0, 0);
buffer.changeableRate = false;
log('changeableRate', buffer.changeableRate);
}
};
/**
* 情報を表示する要素を作成
*/
const createInfo = () => {
const css = `
#${sid}_Info {
align-items: center;
background-color: rgba(0, 0, 0, 0.4);
border-radius: 4px;
bottom: 105px;
color: #fff;
display: flex;
font-family: sans-serif;
justify-content: center;
left: 90px;
min-height: 30px;
min-width: 3em;
opacity: 0;
padding: 0.5ex 1ex;
position: fixed;
user-select: none;
visibility: hidden;
z-index: 2270;
}
#${sid}_Info.aapp_show {
opacity: 0.8;
visibility: visible;
}
#${sid}_Info:hover.aapp_show {
background-color: rgba(0, 0, 0, 1);
cursor: pointer;
opacity: 1;
}
#${sid}_Info:hover.aapp_show:after {
color: #cc9;
content: "クリックで等速再生";
padding-left: 1em;
}
#${sid}_Info.aapp_hidden {
opacity: 0;
transition: opacity 0.5s ease-out, visibility 0.5s ease-out;
visibility: hidden;
}
`,
div = document.createElement('div'),
style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
div.id = `${sid}_Info`;
div.innerHTML = '';
div.addEventListener('click', clickInfo);
document.body.appendChild(div);
};
/**
* ページを開いたときに1度だけ実行
*/
const init = () => {
log('init');
setupSettings();
waitShowVideo();
createInfo();
};
/**
* デバッグ用ログ
* @param {...any} a
*/
const log = (...a) => {
if (ls.debug) {
try {
if (/^debug$|^error$|^info$|^warn$/.test(a[a.length - 1])) {
const b = a.pop();
console[b](sid, a.join(' '));
showInfo(a[0]);
} else console.log(sid, a.join(' '));
} catch (e) {
if (e instanceof Error) console.error(e.message, ...a);
else if (typeof e === 'string') console.error(e, ...a);
else console.error('log error', ...a);
}
}
};
/**
* bufferオブジェクトをリセット
*/
const resetBufferObj = () => {
log('resetBufferObj');
buffer.count = 0;
buffer.currentMax = 0;
buffer.currentMin = 0;
buffer.max = [];
buffer.min = [];
buffer.prev = 0;
};
/**
* video要素を返す
* @returns {*}
*/
const returnVideo = () => {
const vi = document.querySelector(selector.video);
return vi ? vi : null;
};
/**
* ローカルストレージに設定を保存する
*/
const saveLocalStorage = () => localStorage.setItem(sid, JSON.stringify(ls));
/**
* 設定の値を用意する
*/
const setupSettings = () => {
let rate = Number.isFinite(Number(playbackRate)) ? Number(playbackRate) : 0,
buff = Number.isFinite(Number(liveBuffer)) ? Number(liveBuffer) : 0,
act = Number.isFinite(Number(activelyAdjust))
? Number(activelyAdjust)
: 0;
rate = rate > 2 ? 2 : rate < 1.1 && rate !== 0 ? 1.1 : rate;
buff = buff > 10 ? 10 : buff < 1 && buff !== 0 ? 1 : buff;
act = act > 2 ? 2 : act < 1 && act !== 0 ? 1 : act;
playbackRate = ls.playbackRate ? ls.playbackRate : rate ? rate : 1.5;
liveBuffer = ls.liveBuffer ? ls.liveBuffer : buff ? buff : 3;
activelyAdjust = ls.activelyAdjust ? ls.activelyAdjust : act ? act : 1;
if (rate && ls.playbackRate !== rate) {
playbackRate = rate;
ls.playbackRate = rate;
saveLocalStorage();
}
if (buff && ls.liveBuffer !== buff) {
liveBuffer = buff;
ls.liveBuffer = buff;
saveLocalStorage();
}
if (act && ls.activelyAdjust !== act) {
activelyAdjust = act;
ls.activelyAdjust = act;
saveLocalStorage();
}
buffer.originalArchive = buffer.archive;
buffer.originalLive = liveBuffer;
};
/**
* 情報を表示
* @param {string} s 表示する文字列
*/
const showInfo = (s) => {
const eInfo = document.getElementById(`${sid}_Info`);
if (eInfo) {
eInfo.textContent = s ? s : '';
eInfo.classList.remove('aapp_hidden');
eInfo.classList.add('aapp_show');
clearTimeout(interval.info);
interval.info = setTimeout(() => {
eInfo.classList.remove('aapp_show');
eInfo.classList.add('aapp_hidden');
}, 1000);
}
};
/**
* 指定時間だけ待つ
* @param {number} msec
*/
const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));
/**
* ページを開いて動画が表示されたら1度だけ実行
*/
const startFirstObserve = () => {
log('startFirstObserve');
addEventPage();
const main = document.querySelector(selector.main);
if (main) observerC.observe(main, moConfig);
else log('startFirstObserve: Not found element.', 'error');
};
/**
* 動画が表示されるのを待つ
*/
const waitShowVideo = async () => {
log('waitShowVideo');
const splash = () => {
const sp = document.querySelector(selector.splash);
if (!sp) {
log('waitShowVideo: Not found element.', 'error');
return true;
}
const cs = getComputedStyle(sp);
if (cs?.visibility === 'visible') return true;
return false;
};
await sleep(400);
clearInterval(interval.video);
interval.video = setInterval(() => {
if (returnVideo() && !isNaN(returnVideo().duration) && splash()) {
clearInterval(interval.video);
startFirstObserve();
}
}, 250);
};
const observerC = new MutationObserver(checkChangeElements);
clearInterval(interval.init);
interval.init = setInterval(() => {
if (/^https:\/\/abema\.tv\/now-on-air\/[\w-]+\/?$/.test(location.href)) {
clearInterval(interval.init);
init();
}
}, 1000);
})();