// ==UserScript==
// @name 巴哈姆特動畫瘋 威力加強版
// @name:en Animate-Gamer Enhancement
// @name:zh-TW 巴哈姆特動畫瘋 威力加強版
// @namespace https://github.com/rod24574575
// @description 一些巴哈姆特動畫瘋的 UX 改善和小功能
// @description:en Some user experience enhancement and small features for Animate-Gamer.
// @description:zh-TW 一些巴哈姆特動畫瘋的 UX 改善和小功能
// @version 1.1.2
// @license MIT
// @author rod24574575
// @homepage https://github.com/rod24574575/monorepo
// @homepageURL https://github.com/rod24574575/monorepo
// @supportURL https://github.com/rod24574575/monorepo/issues
// @match *://ani.gamer.com.tw/animeVideo.php*
// @run-at document-idle
// @resource css https://github.com/rod24574575/monorepo/raw/animate-gamer-enhancement-v1.1.2/packages/animate-gamer-enhancement/animate-gamer-enhancement.css
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.getResourceUrl
// ==/UserScript==
// TODO: 支援區間重複播放
// TODO: 功能/快捷鍵說明
// TODO: 提供只在此分頁有效的設定
// @ts-check
'use strict';
(function() {
/**
* I18n
*/
const i18n = {
settings_tab_name: '動畫瘋加強版',
play_settings: '播放設定',
auto_agree_content_rating: '自動同意分級確認',
auto_play_next_episode: '自動播放下一集',
auto_play_next_episode_tip: '此功能和動畫瘋內建提供的自動播放功能衝突,如果沒有自訂延遲時間的需求,可以直接使用內建的自動播放功能即可',
auto_play_next_episode_delay: '自動播放延遲時間',
auto_play_countdown: '倒數{0}秒繼續播放',
interrupt_play: '中斷播放',
second: '秒',
timeline_automation_rule: '時間軸自動化規則',
timeline_automation_rule_tip: '影片播放至規則所設定的時間時,會觸發該規則的指定操作。\n快捷鍵:\n[\\]帶入目前影片時間',
add: '新增',
advance_5s: '快轉5秒',
advance_60s: '快轉60秒',
rewind_5s: '倒轉5秒',
rewind_60s: '倒轉60秒',
switch_next_episode: '切換到下一集',
switch_previous_episode: '切換到上一集',
};
/**
* @param {keyof typeof i18n} key
* @returns {string}
*/
function getI18n(key) {
return i18n[key] ?? key;
}
/**
* @param {string} str
* @param {unknown[]} args
* @returns {string}
*/
function formatString(str, ...args) {
return str.replace(/\{(\d+)\}/g, (_, index) => {
return String(args[+index]);
});
}
/**
* Settings
*/
/**
* @typedef {| never
* | 'advance_5s'
* | 'advance_60s'
* | 'rewind_5s'
* | 'rewind_60s'
* | 'switch_next_episode'
* | 'switch_previous_episode'
* } Command
*/
/**
* @typedef ShortcutAction
* @property {string} name
* @property {Command} cmd
*/
/**
* @typedef TimelineAction
* @property {number} time
* @property {Command} cmd
*/
/**
* @typedef Settings
* @property {boolean} autoAgreeContentRating
* @property {boolean} autoPlayNextEpisode
* @property {number} autoPlayNextEpisodeDelay
* @property {ShortcutAction[]} shortcutActions
* @property {TimelineAction[]} timelineActions
*/
/**
* @returns {Promise<Settings>}
*/
async function loadSettings() {
/** @type {Settings} */
const settings = {
autoAgreeContentRating: false,
autoPlayNextEpisode: false,
autoPlayNextEpisodeDelay: 5,
shortcutActions: [
{
name: 'PageUp',
cmd: 'switch_previous_episode',
},
{
name: 'PageDown',
cmd: 'switch_next_episode',
},
],
timelineActions: [],
};
const entries = await Promise.all(
Object.entries(settings).map(async ([key, value]) => {
try {
value = await GM.getValue(key, value);
} catch (e) {
console.warn(e);
}
return /** @type {[string, any]} */ ([key, value]);
}),
);
return /** @type {Settings} */ (Object.fromEntries(entries));
}
/**
* @param {Partial<Settings>} settings
*/
async function saveSettings(settings) {
await Promise.allSettled(
Object.entries(settings).map(async ([name, value]) => {
return GM.setValue(name, value);
}),
);
}
/**
* Store
*/
/**
* @typedef {HTMLElement} VjsPlayerElement
*/
/**
* @param {VjsPlayerElement} vjsPlayer
*/
function useCommand(vjsPlayer) {
const videoEl = vjsPlayer.querySelector('video');
/**
* @param {number} second
*/
function advance(second) {
if (videoEl) {
videoEl.currentTime += second;
}
}
/**
* @param {number} second
*/
function rewind(second) {
if (videoEl) {
videoEl.currentTime -= second;
}
}
function switchPreviousEpisode() {
/** @type {HTMLButtonElement | null} */
const button = vjsPlayer.querySelector('button.vjs-pre-button');
button?.click();
}
function switchNextEpisode() {
/** @type {HTMLButtonElement | null} */
const button = vjsPlayer.querySelector('button.vjs-next-button');
button?.click();
}
/**
* @param {Command} cmd
* @returns {boolean}
*/
function execCommand(cmd) {
switch (cmd) {
case 'advance_5s':
advance(5);
break;
case 'advance_60s':
advance(60);
break;
case 'rewind_5s':
rewind(5);
break;
case 'rewind_60s':
rewind(60);
break;
case 'switch_next_episode':
switchNextEpisode();
break;
case 'switch_previous_episode':
switchPreviousEpisode();
break;
default:
return false;
}
return true;
}
return {
execCommand,
};
}
/**
* @param {VjsPlayerElement} vjsPlayer
*/
function useContentRating(vjsPlayer) {
let enabled = false;
function agreeContentRating() {
/** @type {HTMLButtonElement | null} */
const button = vjsPlayer.querySelector('button.choose-btn-agree');
button?.click();
}
/** @type {MutationObserver | undefined} */
let contentRatingMutationObserver;
function onAutoAgreeContentRatingChange() {
contentRatingMutationObserver?.disconnect();
if (enabled) {
agreeContentRating();
contentRatingMutationObserver = new MutationObserver(() => {
agreeContentRating();
});
contentRatingMutationObserver.observe(vjsPlayer, {
childList: true,
});
}
}
/**
* @param {boolean} value
*/
function enableAutoAgreeContentRating(value) {
if (enabled === value) {
return;
}
enabled = value;
onAutoAgreeContentRatingChange();
}
return {
enableAutoAgreeContentRating,
};
}
/**
* @param {VjsPlayerElement} vjsPlayer
*/
function useNextEpisode(vjsPlayer) {
/**
* @typedef {'due' | 'clear' | 'cancel'} StopCountdownReason
*/
let enabled = false;
let delayTime = 0;
/** @type {{ countdownTimer: number, finishTimer: number, resolve: (reason: StopCountdownReason) => void } | null} */
let countdownData = null;
const videoEl = /** @type {HTMLVideoElement | null} */ (vjsPlayer.querySelector('video'));
const stopEl = /** @type {HTMLElement | null} */ (vjsPlayer.querySelector('.stop'));
const titleEl = /** @type {HTMLElement | null | undefined} */ (stopEl?.querySelector('#countDownTitle'));
const nextEpisodeEl = /** @type {HTMLAnchorElement | null | undefined} */ (stopEl?.querySelector('a#nextEpisode'));
const stopAutoPlayEl = /** @type {HTMLAnchorElement | null | undefined} */ (stopEl?.querySelector('a#stopAutoPlay'));
const nextEpisodeSvgEl = /** @type {SVGElement | null | undefined} */ (nextEpisodeEl?.querySelector('svg'));
const nextEpisodeCountdownEl = /** @type {SVGElement | null | undefined} */ (nextEpisodeEl?.querySelector('#countDownCircle'));
if (!videoEl || !stopEl || !titleEl || !nextEpisodeEl || !stopAutoPlayEl || !nextEpisodeSvgEl || !nextEpisodeCountdownEl) {
console.warn('Missing elements for next episode auto play.');
}
/**
* @returns {boolean}
*/
function isStopElShown() {
return !!stopEl && !stopEl.classList.contains('vjs-hidden');
}
/**
* @param {boolean} display
*/
function setCountdownUiDisplay(display) {
if (nextEpisodeEl) {
if (display) {
nextEpisodeEl.classList.add('center-btn');
} else {
nextEpisodeEl.classList.remove('center-btn');
}
}
if (nextEpisodeSvgEl) {
if (display) {
nextEpisodeSvgEl.classList.remove('is-hide');
} else {
nextEpisodeSvgEl.classList.add('is-hide');
}
}
if (nextEpisodeCountdownEl) {
if (display) {
nextEpisodeCountdownEl.classList.add('is-countdown');
nextEpisodeCountdownEl.style.animation = `circle-offset ${delayTime}s linear 1 forwards`;
} else {
nextEpisodeCountdownEl.classList.remove('is-countdown');
nextEpisodeCountdownEl.style.animation = '';
}
}
if (stopAutoPlayEl) {
if (display) {
stopAutoPlayEl.classList.remove('vjs-hidden', 'is-disabled');
const stopAutoPlayTextEl = stopAutoPlayEl.querySelector('p');
if (stopAutoPlayTextEl) {
stopAutoPlayTextEl.textContent = getI18n('interrupt_play');
}
} else {
stopAutoPlayEl.classList.add('vjs-hidden');
}
}
updateCountdownUi(display ? delayTime : 0);
}
/**
* @param {number} countdownValue
*/
function updateCountdownUi(countdownValue) {
if (titleEl) {
titleEl.textContent = countdownValue ? formatString(getI18n('auto_play_countdown'), countdownValue) : '';
}
}
/**
* @returns {Promise<StopCountdownReason>}
*/
async function countdown() {
clearCountdown();
setCountdownUiDisplay(true);
let countdownValue = delayTime;
const countdownTimer = window.setInterval(() => {
--countdownValue;
updateCountdownUi(countdownValue);
}, 1000);
/** @type {ReturnType<typeof Promise.withResolvers<StopCountdownReason>>} */
const { promise, resolve } = Promise.withResolvers();
const finishTimer = window.setTimeout(() => stopCountdown('due'), delayTime * 1000);
countdownData = {
countdownTimer,
finishTimer,
resolve,
};
const reason = await promise;
if (reason !== 'clear') {
setCountdownUiDisplay(false);
}
return reason;
}
/**
* @param {StopCountdownReason} reason
*/
function stopCountdown(reason) {
if (!countdownData) {
return;
}
const { countdownTimer, finishTimer, resolve } = countdownData;
window.clearInterval(countdownTimer);
window.clearTimeout(finishTimer);
resolve(reason);
countdownData = null;
}
function clearCountdown() {
return stopCountdown('clear');
}
function cancelCountdown() {
return stopCountdown('cancel');
}
/**
* @returns {Promise<void>}
*/
async function maybePlayNextEpisode() {
if (!isStopElShown()) {
return;
}
if (delayTime) {
const reason = await countdown();
// Check again whether the stop element is still shown after the countdown.
if (reason !== 'due' || !isStopElShown()) {
return;
}
}
nextEpisodeEl?.click();
}
/** @type {MutationObserver | undefined} */
let nextEpisodeMutationObserver;
function onAutoPlayNextEpisodeChange() {
if (!stopEl) {
return;
}
nextEpisodeMutationObserver?.disconnect();
if (enabled) {
nextEpisodeMutationObserver = new MutationObserver((records) => {
for (const { type, target, oldValue } of records) {
// Only handle the class attribute change of the stop element when
// it becomes visible.
if (type !== 'attributes' || target !== stopEl || oldValue === null || !oldValue.split(' ').includes('vjs-hidden')) {
continue;
}
maybePlayNextEpisode();
}
});
nextEpisodeMutationObserver.observe(stopEl, {
attributes: true,
attributeFilter: ['class'],
attributeOldValue: true,
});
maybePlayNextEpisode();
} else {
cancelCountdown();
}
if (videoEl) {
if (enabled) {
videoEl.addEventListener('emptied', clearCountdown);
} else {
videoEl.removeEventListener('emptied', clearCountdown);
}
}
if (stopAutoPlayEl) {
if (enabled) {
stopAutoPlayEl.addEventListener('click', cancelCountdown);
} else {
stopAutoPlayEl.removeEventListener('emptied', cancelCountdown);
}
}
}
/**
* @param {boolean} value
*/
function enableAutoPlayNextEpisode(value) {
if (enabled === value) {
return;
}
enabled = value;
onAutoPlayNextEpisodeChange();
}
/**
* @param {number} value
*/
function setAutoPlayNextEpisodeDelay(value) {
if (!isFinite(value)) {
return;
}
value = Math.round(value);
if (delayTime === value) {
return;
}
delayTime = value;
}
return {
enableAutoPlayNextEpisode,
setAutoPlayNextEpisodeDelay,
};
}
/**
* @param {VjsPlayerElement} vjsPlayer
*/
function useShortcuts(vjsPlayer) {
/** @type {Map<string, Command>} */
const customShortcutMap = new Map();
/** @type {Map<string, Array<() => void>>} */
const localShortcutMap = new Map();
const commandStore = useCommand(vjsPlayer);
/**
* @param {KeyboardEvent} e
* @returns {string}
*/
function getKeyName(e) {
return e.key;
}
/**
* @param {KeyboardEvent} e
*/
function getKeyModifier(e) {
/** @type {string} */
let str = '';
if (e.shiftKey) {
str = 'Shift-' + str;
}
if (e.ctrlKey) {
str = 'Ctrl-' + str;
}
if (e.metaKey) {
str = 'Meta-' + str;
}
if (e.altKey) {
str = 'Alt-' + str;
}
return str;
}
/**
* @param {KeyboardEvent} e
* @returns {string}
*/
function getKeyFullName(e) {
return getKeyModifier(e) + getKeyName(e);
}
/**
* @param {KeyboardEvent} e
*/
function onKeyDown(e) {
if (e.defaultPrevented) {
return;
}
const name = getKeyFullName(e);
const cmd = customShortcutMap.get(name);
if (cmd) {
commandStore.execCommand(cmd);
e.preventDefault();
return;
}
const handlers = localShortcutMap.get(name);
if (handlers && handlers.length > 0) {
for (const handler of handlers) {
handler();
}
e.preventDefault();
return;
}
}
/**
* @param {readonly ShortcutAction[]} value
*/
function setCustomShortcuts(value) {
customShortcutMap.clear();
for (const { name, cmd } of value) {
customShortcutMap.set(name, cmd);
}
}
/**
* @param {Record<string, Array<() => void>>} value
*/
function addLocalShortcuts(value) {
for (const [name, newHandlers] of Object.entries(value)) {
let handlers = localShortcutMap.get(name);
if (!handlers) {
handlers = [];
localShortcutMap.set(name, handlers);
}
handlers.push(...newHandlers);
}
}
vjsPlayer.addEventListener('keydown', onKeyDown);
return {
setCustomShortcuts,
addLocalShortcuts,
};
}
/**
* @param {VjsPlayerElement} vjsPlayer
*/
function useTimelineActions(vjsPlayer) {
/** @type {TimelineAction[]} */
const timelineActions = [];
const videoEl = /** @type {HTMLVideoElement | null} */ (vjsPlayer.querySelector('video'));
if (videoEl) {
videoEl.addEventListener('seeking', onVideoTimeSet);
videoEl.addEventListener('emptied', onVideoTimeSet);
videoEl.addEventListener('timeupdate', onVideoTimeUpdate);
}
let currentTime = -1;
const commandStore = useCommand(vjsPlayer);
function onVideoTimeSet() {
currentTime = -1;
}
function onVideoTimeUpdate() {
const oldCurrentTime = currentTime;
const newCurrentTime = Math.floor(videoEl?.currentTime ?? 0);
currentTime = newCurrentTime;
if (oldCurrentTime < 0 || newCurrentTime <= oldCurrentTime) {
return;
}
let fromIndex = timelineActions.findIndex((action) => (oldCurrentTime < action.time));
if (fromIndex < 0) {
return;
}
// eslint-disable-next-line no-constant-condition
while (1) {
const { time, cmd } = timelineActions[fromIndex];
if (newCurrentTime < time) {
break;
}
commandStore.execCommand(cmd);
++fromIndex;
}
}
/**
* @param {TimelineAction[]} actions
*/
function setTimelineActions(actions) {
timelineActions.length = 0;
timelineActions.push(...actions);
timelineActions.sort((a, b) => (a.time - b.time));
}
/**
* @param {number} time
* @param {Command} command
*/
function addTimelineAction(time, command) {
let insertIndex = timelineActions.findIndex((action) => (time < action.time));
if (insertIndex < 0) {
insertIndex = timelineActions.length;
}
timelineActions.splice(insertIndex, 0, { time, cmd: command });
}
/**
* @param {number} index
*/
function removeTimelineAction(index) {
timelineActions.splice(index, 1);
}
return {
setTimelineActions,
addTimelineAction,
removeTimelineAction,
};
}
/**
* @param {VjsPlayerElement} vjsPlayer
* @param {(settings: Partial<Settings>) => void} callback
*/
function useSettingUi(vjsPlayer, callback) {
/**
* @typedef SettingComponent
* @property {Element} el
* @property {() => void} [onMounted]
* @property {(settings: Partial<Settings>) => void} [onSettings]
* @property {Record<string, Array<() => void>>} [shortcuts]
*/
const videoEl = vjsPlayer.querySelector('video');
/** @type {readonly TimelineAction[]} */
let timelineActions = [];
/** @type {SettingComponent | null} */
let tabContentComponent = null;
const subtitleFrame = vjsPlayer.closest('.player')?.querySelector('.subtitle');
const tabContentId = 'ani-tab-content-enhancement';
async function attachCss() {
const url = await GM.getResourceUrl('css');
const linkEl = document.createElement('link');
linkEl.rel = 'stylesheet';
linkEl.type = 'text/css';
linkEl.href = url;
document.head.appendChild(linkEl);
}
function attachTabUi() {
if (!subtitleFrame) {
return;
}
const tabsEl = subtitleFrame.querySelector('.ani-tabs');
if (!tabsEl) {
return;
}
const tabItemEl = document.createElement('div');
tabItemEl.classList.add('ani-tabs__item');
const tabLinkEl = document.createElement('a');
tabLinkEl.href = '#' + tabContentId;
tabLinkEl.classList.add('ani-tabs-link');
tabLinkEl.textContent = getI18n('settings_tab_name');
tabLinkEl.addEventListener('click', function(e) {
e.preventDefault();
// The pure-js implementation of the same logic from the original site.
// HACK: workaround for Plus-Ani.
for (const el of subtitleFrame.querySelectorAll('.ani-tabs-link.is-active, .plus_ani-tabs-link.is-active')) {
el.classList.remove('is-active');
}
this.classList.add('is-active');
for (const el of /** @type {NodeListOf<HTMLElement>} */ (subtitleFrame.querySelectorAll('.ani-tab-content__item'))) {
el.style.display = 'none';
}
// Must use `getAttribute` to only get the id rather than the full url.
const targetContentEl = document.getElementById((this.getAttribute('href') ?? '').slice(1));
if (targetContentEl) {
targetContentEl.style.display = targetContentEl.classList.contains('setting-program') ? 'flex' : 'block';
}
});
tabItemEl.appendChild(tabLinkEl);
tabsEl.appendChild(tabItemEl);
}
function attachTabContentUi() {
if (!subtitleFrame) {
return;
}
const tabContentEl = subtitleFrame.querySelector('.ani-tab-content');
if (!tabContentEl) {
return;
}
/**
* @param {number} time
* @returns {{ hour: number, minute: number, second: number }}
*/
function parseTime(time) {
return {
hour: Math.floor(time / 3600),
minute: Math.floor(time / 60) % 60,
second: Math.floor(time % 60),
};
}
/**
* @param {number} hour
* @param {number} minute
* @param {number} second
* @returns {number}
*/
function serializeTime(hour, minute, second) {
return hour * 3600 + minute * 60 + second;
}
tabContentComponent = createSettingTabComp({
id: tabContentId,
sections: [
{
title: getI18n('play_settings'),
items: [
{
type: 'checkbox',
label: getI18n('auto_agree_content_rating'),
value: false,
onMounted: (el) => {
el.addEventListener('change', (e) => {
callback({ autoAgreeContentRating: el.checked });
});
},
onSettings: (el, { autoAgreeContentRating }) => {
if (autoAgreeContentRating !== undefined) {
el.checked = autoAgreeContentRating;
}
},
},
{
type: 'checkbox',
label: getI18n('auto_play_next_episode'),
labelTip: getI18n('auto_play_next_episode_tip'),
value: false,
onMounted: (el) => {
el.addEventListener('change', (e) => {
callback({ autoPlayNextEpisode: el.checked });
});
},
onSettings: (el, { autoPlayNextEpisode }) => {
if (autoPlayNextEpisode !== undefined) {
el.checked = autoPlayNextEpisode;
}
},
},
{
type: 'number',
label: getI18n('auto_play_next_episode_delay'),
value: 5,
max: 10,
min: 0,
placeholder: getI18n('second'),
onMounted: (el) => {
el.addEventListener('change', (e) => {
callback({ autoPlayNextEpisodeDelay: +el.value });
});
},
onSettings: (el, { autoPlayNextEpisodeDelay }) => {
if (autoPlayNextEpisodeDelay !== undefined) {
el.value = String(autoPlayNextEpisodeDelay);
}
},
},
],
},
{
title: getI18n('timeline_automation_rule'),
id: 'enh-ani-timeline-automation-rule',
tip: getI18n('timeline_automation_rule_tip'),
items: [
{
type: 'html',
html: `
<div class="ani-setting-item">
<div class="enh-ani-timeline-header">
<div class="enh-ani-timeline-time">
<input type="number" id="enh-ani-timeline-time-hour" class="ani-input" placeholder="0" min="0" max="9">
<span class="enh-ani-time-colon">:</span>
<input type="number" id="enh-ani-timeline-time-minute" class="ani-input" placeholder="00" min="0" max="59">
<span class="enh-ani-time-colon">:</span>
<input type="number" id="enh-ani-timeline-time-second" class="ani-input" placeholder="00" min="0" max="59">
</div>
<div class="enh-ani-timeline-cmd btn-newanime-filter">
<input type="text" id="enh-ani-timeline-cmd-input" class="ani-input" readonly>
<ul class="filter-items"></ul>
</div>
<a href="#" role="button" class="bluebtn">${getI18n('add')}</a>
</div>
<div class="enh-ani-timeline-body">
<ul class="sub_list"></ul>
</div>
</div>
`,
onMounted: (el) => {
/**
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
const hourInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-hour'));
const minuteInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-minute'));
const secondInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-second'));
const cmdInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-cmd-input'));
const cmdEl = el.querySelector('.enh-ani-timeline-cmd');
const cmdOptionsEl = el.querySelector('.filter-items');
const addBtnEl = el.querySelector('.bluebtn');
if (cmdOptionsEl) {
/** @type {Command[]} */
const cmdOptions = [
'advance_5s',
'advance_60s',
'rewind_5s',
'rewind_60s',
'switch_next_episode',
'switch_previous_episode',
];
for (const cmd of cmdOptions) {
const optionEl = document.createElement('li');
optionEl.setAttribute('data-cmd', cmd);
optionEl.textContent = getI18n(cmd);
cmdOptionsEl.appendChild(optionEl);
}
}
cmdEl?.addEventListener('click', function(e) {
cmdOptionsEl?.classList.toggle('is-active');
const target = e.target;
if (target && target instanceof HTMLElement && cmdInputEl) {
const optionEl = target.closest('li');
if (optionEl) {
const parent = optionEl.parentElement;
if (parent) {
for (const child of parent.children) {
child.classList.remove('is-active');
}
}
optionEl.classList.add('is-active');
const cmd = /** @type {Command | undefined | null} */ (optionEl.getAttribute('data-cmd'));
if (cmd) {
cmdInputEl.setAttribute('data-cmd', cmd);
cmdInputEl.value = getI18n(cmd);
}
}
}
});
addBtnEl?.addEventListener('click', function(e) {
e.preventDefault();
const hour = hourInputEl ? clamp(Math.floor(+hourInputEl.value), 0, 9) : 0;
const minute = minuteInputEl ? clamp(Math.floor(+minuteInputEl.value), 0, 59) : 0;
const second = secondInputEl ? clamp(Math.floor(+secondInputEl.value), 0, 59) : 0;
if (!isFinite(hour) || !isFinite(minute) || !isFinite(second)) {
return;
}
const cmd = /** @type {Command | undefined | null} */ (cmdInputEl?.getAttribute('data-cmd'));
if (!cmd) {
return;
}
callback({
timelineActions: [
...timelineActions,
{ time: serializeTime(hour, minute, second), cmd },
].sort((a, b) => (a.time - b.time)),
});
});
},
onSettings: (el, settings) => {
if (settings.timelineActions === undefined) {
return;
}
const ulEl = el.querySelector('.enh-ani-timeline-body')?.firstElementChild;
if (!ulEl) {
return;
}
ulEl.innerHTML = '<li class="sub-list-li">';
for (const [index, { time, cmd }] of timelineActions.entries()) {
const { hour, minute, second } = parseTime(time);
const timeStr = formatString(
'{0}:{1}:{2}',
String(hour),
String(minute).padStart(2, '0'),
String(second).padStart(2, '0'),
);
const dummyEl = document.createElement('div');
dummyEl.innerHTML = `
<li class="sub-list-li">
<b>${timeStr}</b>
<div class="sub_content"><span>${getI18n(cmd)}</span></div>
<a href="#" role="button" class="ani-keyword-close">
<i class="material-icons">close</i>
</a>
</li>
`;
const itemEl = /** @type {Element} */ (dummyEl.firstElementChild);
itemEl.querySelector('.ani-keyword-close')?.addEventListener('click', function(e) {
e.preventDefault();
callback({
timelineActions: timelineActions.toSpliced(index, 1),
});
});
ulEl.appendChild(itemEl);
}
},
shortcuts: {
'\\': (el) => {
const hourInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-hour'));
const minuteInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-minute'));
const secondInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-second'));
const { hour, minute, second } = parseTime(videoEl?.currentTime ?? 0);
if (hourInputEl) {
hourInputEl.value = String(hour);
}
if (minuteInputEl) {
minuteInputEl.value = String(minute);
}
if (secondInputEl) {
secondInputEl.value = String(second);
}
},
},
},
],
},
],
});
tabContentEl.appendChild(tabContentComponent.el);
tabContentComponent.onMounted?.();
}
/**
* @template {Element} [T=Element]
* @typedef SettingBaseConfig
* @property {(el: T) => void} [onMounted]
* @property {(el: T, settings: Partial<Settings>) => void} [onSettings]
* @property {Record<string, (el: T) => void>} [shortcuts]
*/
/**
* @typedef _SettingCheckboxConfig
* @property {'checkbox'} type
* @property {string} [id]
* @property {string} [label]
* @property {string} [labelTip]
* @property {boolean} [value]
*
* @typedef {SettingBaseConfig<HTMLInputElement> & _SettingCheckboxConfig} SettingCheckboxConfig
*/
/**
* @typedef _SettingNumberConfig
* @property {'number'} type
* @property {string} [id]
* @property {string} [label]
* @property {string} [labelTip]
* @property {number} [value]
* @property {number} [max]
* @property {number} [min]
* @property {string} [placeholder]
*
* @typedef {SettingBaseConfig<HTMLInputElement> & _SettingNumberConfig} SettingNumberConfig
*/
/**
* @typedef _SettingHtmlConfig
* @property {'html'} type
* @property {string} html
*
* @typedef {SettingBaseConfig & _SettingHtmlConfig} SettingHtmlConfig
*/
/**
* @typedef {SettingCheckboxConfig | SettingNumberConfig | SettingHtmlConfig} SettingItemConfig
*/
/**
* @typedef SettingSectionConfig
* @property {string} title
* @property {string} [id]
* @property {string} [tip]
* @property {SettingItemConfig[]} items
*/
/**
* @typedef SettingTabConfig
* @property {string} [id]
* @property {SettingSectionConfig[]} sections
*/
/**
* @param {string} tip
* @returns {Element}
*/
function createSettingTipEl(tip) {
const dummyEl = document.createElement('div');
dummyEl.innerHTML = `
<div class="qa-icon" style="display:inline-block;top:1px;">
<img src="https://i2.bahamut.com.tw/anime/smallQAicon.svg">
</div>
`;
const tipEl = /** @type {Element} */ (dummyEl.firstElementChild);
tipEl.setAttribute('tip-content', tip);
return tipEl;
}
/**
* @param {SettingItemConfig} config
* @returns {DocumentFragment}
*/
function createSettingItemLabelEl(config) {
const fragment = document.createDocumentFragment();
if (('label' in config) && config.label) {
const dummyEl = document.createElement('div');
dummyEl.innerHTML = `
<div class="ani-setting-label">
<span class="ani-setting-label__mian"></span>
</div>
`;
const labelEl = dummyEl.querySelector('.ani-setting-label');
if (labelEl) {
labelEl.textContent = config.label;
}
fragment.append(...dummyEl.childNodes);
if (config.labelTip) {
fragment.append(createSettingTipEl(config.labelTip));
}
}
return fragment;
}
/**
* @param {SettingItemConfig} config
* @returns {SettingComponent}
*/
function createSettingItemComp(config) {
if (config.type === 'checkbox') {
const dummyEl = document.createElement('div');
dummyEl.innerHTML = `
<div class="ani-setting-item ani-flex">
<div class="ani-setting-value ani-set-flex-right">
<div class="ani-checkbox">
<label class="ani-checkbox__label">
<input type="checkbox" name="ani-checkbox">
<div class="ani-checkbox__button"></div>
</label>
</div>
</div>
</div>
`;
const itemEl = /** @type {HTMLDivElement} */ (dummyEl.firstElementChild);
itemEl.prepend(createSettingItemLabelEl(config));
const inputEl = itemEl.querySelector('input');
if (inputEl) {
if (config.id) {
inputEl.id = config.id;
}
inputEl.checked = config.value ?? false;
}
return {
...createSettingComponentAttrs(config, inputEl),
el: itemEl,
};
} else if (config.type === 'number') {
const dummyEl = document.createElement('div');
dummyEl.innerHTML = `
<div class="ani-setting-item ani-flex">
<div class="ani-setting-value ani-set-flex-right">
<input type="number" class="ani-input" style="margin:0">
</div>
</div>
`;
const itemEl = /** @type {HTMLDivElement} */ (dummyEl.firstElementChild);
itemEl.prepend(createSettingItemLabelEl(config));
const inputEl = dummyEl.querySelector('input');
if (inputEl) {
if (config.id) {
inputEl.id = config.id;
}
inputEl.value = config.value !== undefined ? String(config.value) : '';
if (config.max !== undefined) {
inputEl.max = String(config.max);
}
if (config.min !== undefined) {
inputEl.min = String(config.min);
}
if (config.placeholder !== undefined) {
inputEl.placeholder = config.placeholder;
}
}
return {
...createSettingComponentAttrs(config, inputEl),
el: itemEl,
};
} else if (config.type === 'html') {
const dummyEl = document.createElement('div');
dummyEl.innerHTML = config.html;
const itemEl = dummyEl.firstElementChild ?? dummyEl;
return {
...createSettingComponentAttrs(config, itemEl),
el: itemEl,
};
} else {
throw new Error(`Unknown setting item: ${config}`);
}
}
/**
* @param {SettingSectionConfig} config
* @returns {SettingComponent}
*/
function createSettingSectionComp(config) {
const { title, id, tip, items } = config;
const sectionEl = document.createElement('div');
sectionEl.classList.add('ani-setting-section');
const titleEl = document.createElement('h4');
titleEl.classList.add('ani-setting-title');
titleEl.textContent = title;
if (id) {
sectionEl.id = id;
}
if (tip) {
const tipEl = createSettingTipEl(tip);
tipEl.style.marginLeft = '8px';
titleEl.appendChild(tipEl);
}
sectionEl.appendChild(titleEl);
const itemComponents = items.map((item) => createSettingItemComp(item));
for (const { el } of itemComponents) {
sectionEl.append(el);
}
return {
...mergeSettingComponentAttrs(itemComponents),
el: sectionEl,
};
}
/**
* @param {SettingTabConfig} config
* @returns {SettingComponent}
*/
function createSettingTabComp(config) {
const tabEl = document.createElement('div');
if (config.id) {
tabEl.id = config.id;
}
tabEl.classList.add('ani-tab-content__item');
const sectionComponents = config.sections.map((section) => createSettingSectionComp(section));
for (const { el } of sectionComponents) {
tabEl.append(el);
}
return {
...mergeSettingComponentAttrs(sectionComponents),
el: tabEl,
};
}
/**
* @template {Element} T
* @param {SettingBaseConfig<T>} config
* @param {T | null} el
* @returns {Omit<SettingComponent, 'el'>}
*/
function createSettingComponentAttrs(config, el) {
return {
onMounted: (config.onMounted && el) ? config.onMounted.bind(null, el) : undefined,
onSettings: (config.onSettings && el) ? config.onSettings.bind(null, el) : undefined,
shortcuts: (config.shortcuts && el) ?
Object.fromEntries(
Object.entries(config.shortcuts).map(([key, handler]) => {
return [key, [handler.bind(null, el)]];
}),
) :
undefined,
};
}
/**
* @param {SettingComponent[]} components
* @returns {Omit<SettingComponent, 'el'>}
*/
function mergeSettingComponentAttrs(components) {
return {
onMounted() {
for (const { onMounted } of components) {
onMounted?.();
}
},
onSettings(settings) {
for (const { onSettings } of components) {
onSettings?.(settings);
}
},
shortcuts: components
.flatMap(({ shortcuts }) => Object.entries(shortcuts ?? {}))
.reduce((acc, [key, handlers]) => {
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(...handlers);
return acc;
}, /** @type {NonNullable<SettingComponent['shortcuts']>} */ ({})),
};
}
/**
* @returns {Record<string, Array<() => void>>}
*/
function getLocalShortcuts() {
return tabContentComponent?.shortcuts ?? {};
}
/**
* @param {Partial<Settings>} settings
*/
function applySettings(settings) {
if (settings.timelineActions) {
timelineActions = settings.timelineActions;
}
tabContentComponent?.onSettings?.(settings);
}
attachCss();
attachTabUi();
attachTabContentUi();
return {
applySettings,
getLocalShortcuts,
};
}
/**
* @returns {Promise<VjsPlayerElement>}
*/
async function waitVjsPlayerElementInit() {
/**
* @returns {VjsPlayerElement | null}
*/
function queryVjsPlayerElement() {
return document.querySelector('.video-js');
}
/**
* @param {VjsPlayerElement} vjsPlyer
* @returns {boolean}
*/
function checkVjsPlayerElementReady(vjsPlyer) {
return !!vjsPlyer.querySelector('.stop');
}
let vjsPlyer = queryVjsPlayerElement();
if (vjsPlyer && checkVjsPlayerElementReady(vjsPlyer)) {
return vjsPlyer;
}
/** @type {MutationObserver | undefined} */
let mutationObserver;
return new Promise((resolve) => {
mutationObserver = new MutationObserver(async () => {
if (!vjsPlyer) {
vjsPlyer = queryVjsPlayerElement();
}
if (vjsPlyer && checkVjsPlayerElementReady(vjsPlyer)) {
resolve(vjsPlyer);
}
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
}).finally(() => {
mutationObserver?.disconnect();
});
}
async function main() {
const settings = await loadSettings();
const vjsPlayerElement = await waitVjsPlayerElementInit();
const contentRatingStore = useContentRating(vjsPlayerElement);
const nextEpisodeStore = useNextEpisode(vjsPlayerElement);
const shortcutsStore = useShortcuts(vjsPlayerElement);
const timelineActionsStore = useTimelineActions(vjsPlayerElement);
const settingUiStore = useSettingUi(vjsPlayerElement, (settings) => {
saveSettings(settings);
applySettings(settings);
});
shortcutsStore.addLocalShortcuts(settingUiStore.getLocalShortcuts());
/**
* @param {Partial<Settings>} settings
*/
function applySettings(settings) {
if (settings.autoAgreeContentRating !== undefined) {
contentRatingStore.enableAutoAgreeContentRating(settings.autoAgreeContentRating);
}
if (settings.autoPlayNextEpisode !== undefined) {
nextEpisodeStore.enableAutoPlayNextEpisode(settings.autoPlayNextEpisode);
}
if (settings.autoPlayNextEpisodeDelay !== undefined) {
nextEpisodeStore.setAutoPlayNextEpisodeDelay(settings.autoPlayNextEpisodeDelay);
}
if (settings.shortcutActions !== undefined) {
shortcutsStore.setCustomShortcuts(settings.shortcutActions);
}
if (settings.timelineActions !== undefined) {
timelineActionsStore.setTimelineActions(settings.timelineActions);
}
settingUiStore.applySettings(settings);
}
applySettings(settings);
}
main();
})();