Some user experience enhancement and small features for Animate-Gamer.
// ==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(); })();