// ==UserScript==
// @name ABEMA Auto Adjust Playback Position
// @namespace https://gf.qytechs.cn/scripts/451815
// @version 2
// @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;
/* ------------------------------ */
const sid = 'AutoAdjustPlaybackPosition',
ls = JSON.parse(localStorage.getItem(sid) || '{}') || {},
buffer = {
archive: 15,
changeRate: true,
count: 0,
currentMax: 0,
currentMin: 0,
/** @type {number[]} */
max: [],
/** @type {number[]} */
min: [],
prev: 0,
},
interval = { buffer: 0, changeRate: 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 (
buffer.changeRate &&
vi.duration > 20000000000 &&
checkExistsFooterText()
) {
if (vi.playbackRate >= 1 && b < 1) {
//現在のバッファが1秒未満になったときスロー再生する
log(vi.playbackRate, b, buffer.currentMax, buffer.currentMin, live);
changePlaybackSpeed(1.2 - b, slow);
} else if (vi.playbackRate >= 1 && b < 2 && !live) {
//生放送以外で現在のバッファが2秒未満になったときスロー再生する
log(vi.playbackRate, b, buffer.currentMax, buffer.currentMin, live);
changePlaybackSpeed(3 - b, slow);
} else if (vi.playbackRate > 1 && b < 8 && !live) {
//生放送以外で倍速再生中に現在のバッファが8秒未満になったとき等速再生に戻す
log(vi.playbackRate, b, buffer.currentMax, buffer.currentMin, 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;
const maxLast5 = [...buffer.max].slice(-5),
minLast5 = [...buffer.min].slice(-5),
maxBottom = maxLast5.reduce((x, y) => Math.min(x, y)),
minBottom = minLast5.reduce((x, y) => Math.min(x, y)),
maxDiff =
Math.round(
(maxLast5.reduce((x, y) => Math.max(x, y)) - maxBottom) * 100
) / 100,
minDiff =
Math.round(
(minLast5.reduce((x, y) => Math.max(x, y)) - minBottom) * 100
) / 100;
if (vi.playbackRate === 1) {
if (maxLast5.length >= 3 && maxBottom > buffer.archive + 1) {
//最大バッファがbuffer.archiveより多いとき
//最大バッファがbuffer.archiveに近づくよう倍速再生する
log(
'--- changePlaybackSpeed ---',
Math.round((maxBottom - buffer.archive) * 100) / 100,
b,
maxDiff,
minDiff,
live
);
changePlaybackSpeed(maxBottom - buffer.archive, playbackRate);
} else if (
live &&
minLast5.length === 5 &&
minBottom > liveBuffer + 1 &&
maxDiff < 0.5 &&
minDiff < 0.5
) {
//生放送&最小バッファがliveBufferより多い&バッファが安定し続けているとき
//最小バッファがliveBufferに近づくよう倍速再生する
log(
'--- changePlaybackSpeed LIVE ---',
Math.round((minBottom - liveBuffer) * 100) / 100,
b,
maxDiff,
minDiff
);
changePlaybackSpeed(minBottom - liveBuffer, playbackRate);
}
}
log(
buffer.count,
' max: [',
buffer.max.slice(-5).join(' '),
'] ',
maxDiff,
' min: [',
buffer.min.slice(-5).join(' '),
'] ',
minDiff,
live,
vi.buffered.length
);
}
} else {
buffer.count = 0;
buffer.currentMax = 0;
buffer.currentMin = 0;
buffer.max = [];
buffer.min = [];
changePlaybackSpeed(0, 0);
}
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 start');
changePlaybackSpeed(0, 0);
buffer.changeRate = false;
clearInterval(interval.changeRate);
interval.changeRate = setTimeout(() => {
log('clickInfo end');
buffer.changeRate = true;
}, 90000);
};
/**
* 情報を表示する要素を作成
*/
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.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) {
if (/^debug$|^error$|^info$|^warn$/.test(a[a.length - 1])) {
const b = a.pop();
console[b](sid, a.toString());
showInfo(a[0]);
} else console.log(sid, a.toString());
}
};
/**
* 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;
rate = rate > 2 ? 2 : rate < 1.1 && rate !== 0 ? 1.1 : rate;
buff = buff > 10 ? 10 : buff < 1 && buff !== 0 ? 1 : buff;
playbackRate = ls.playbackRate ? ls.playbackRate : rate ? rate : 1.5;
liveBuffer = ls.liveBuffer ? ls.liveBuffer : buff ? buff : 3;
if (rate && ls.playbackRate !== rate) {
playbackRate = rate;
ls.playbackRate = rate;
saveLocalStorage();
}
if (buff && ls.liveBuffer !== buff) {
liveBuffer = buff;
ls.liveBuffer = buff;
saveLocalStorage();
}
};
/**
* 情報を表示
* @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);
})();