专为 Jable 和 MissAV 网站制作:添加视频快捷跳转、播放速度、滤镜、进度记忆、视频书签、键盘快捷键和日文标题翻译。Made specifically for the Jable and MissAV websites: adds video seek controls, playback speed, filter presets, progress memory, bookmarks, keyboard shortcuts, and Japanese title translation.
// ==UserScript==
// @name Jable & MissAV Time Jump Buttons
// @namespace https://jable.tv/
// @version 1.9.13
// @description 专为 Jable 和 MissAV 网站制作:添加视频快捷跳转、播放速度、滤镜、进度记忆、视频书签、键盘快捷键和日文标题翻译。Made specifically for the Jable and MissAV websites: adds video seek controls, playback speed, filter presets, progress memory, bookmarks, keyboard shortcuts, and Japanese title translation.
// @author Pda
// @license MIT
// @match https://jable.tv/videos/*
// @match https://missav.ai/*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @connect translate.googleapis.com
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'jable-seek-panel-v130';
const TITLE_TRANSLATION_ID = 'jable-title-translation-v1';
const TITLE_CACHE_KEY = 'jable-title-translation-cache-v1';
const LEGACY_PANEL_ID = 'jable-seek-panel';
const OLD_PANEL_IDS = [
'jable-seek-panel-v102',
'jable-seek-panel-v103',
'jable-seek-panel-v104',
'jable-seek-panel-v105',
'jable-seek-panel-v106',
'jable-seek-panel-v107',
'jable-seek-panel-v108',
'jable-seek-panel-v109',
'jable-seek-panel-v110',
'jable-seek-panel-v111',
'jable-seek-panel-v112',
'jable-seek-panel-v113',
'jable-seek-panel-v114',
'jable-seek-panel-v115',
'jable-seek-panel-v116',
'jable-seek-panel-v117',
'jable-seek-panel-v118',
'jable-seek-panel-v119',
'jable-seek-panel-v120',
'jable-seek-panel-v121',
'jable-seek-panel-v122',
'jable-seek-panel-v123',
'jable-seek-panel-v124',
'jable-seek-panel-v125',
'jable-seek-panel-v126',
'jable-seek-panel-v127',
'jable-seek-panel-v128',
'jable-seek-panel-v129',
];
const OLD_STYLE_IDS = [
'jable-seek-panel-style-v102',
'jable-seek-panel-style-v103',
'jable-seek-panel-style-v104',
'jable-seek-panel-style-v105',
'jable-seek-panel-style-v106',
'jable-seek-panel-style-v107',
'jable-seek-panel-style-v108',
'jable-seek-panel-style-v109',
'jable-seek-panel-style-v110',
'jable-seek-panel-style-v111',
'jable-seek-panel-style-v112',
'jable-seek-panel-style-v113',
'jable-seek-panel-style-v114',
'jable-seek-panel-style-v115',
'jable-seek-panel-style-v116',
'jable-seek-panel-style-v117',
'jable-seek-panel-style-v118',
'jable-seek-panel-style-v119',
'jable-seek-panel-style-v120',
'jable-seek-panel-style-v121',
'jable-seek-panel-style-v122',
'jable-seek-panel-style-v123',
'jable-seek-panel-style-v124',
'jable-seek-panel-style-v125',
'jable-seek-panel-style-v126',
'jable-seek-panel-style-v127',
'jable-seek-panel-style-v128',
'jable-seek-panel-style-v129',
];
const STYLE_ID = 'jable-seek-panel-style-v130';
const PROGRESS_KEY = 'jable-video-progress-v1';
const BOOKMARK_KEY = 'jable-video-bookmarks-v1';
const JUMPS = [
{ seconds: -600, label: '-10m', title: 'Back 10 minutes' },
{ seconds: -300, label: '-5m', title: 'Back 5 minutes' },
{ seconds: -60, label: '-1m', title: 'Back 1 minute' },
{ seconds: -5, label: '-5s', title: 'Back 5 seconds' },
{ seconds: 5, label: '+5s', title: 'Forward 5 seconds' },
{ seconds: 60, label: '+1m', title: 'Forward 1 minute' },
{ seconds: 300, label: '+5m', title: 'Forward 5 minutes' },
{ seconds: 600, label: '+10m', title: 'Forward 10 minutes' },
];
const PLAYBACK_RATES = [
0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5, 6, 8,
];
const FILTERS = [
{ key: 'none', label: '原图', value: '' },
{ key: 'vivid', label: '鲜艳', value: 'saturate(1.35) contrast(1.08)' },
{ key: 'bright', label: '提亮', value: 'brightness(1.18) contrast(1.04)' },
{ key: 'warm', label: '暖色', value: 'sepia(0.18) saturate(1.16) hue-rotate(-8deg)' },
{ key: 'cool', label: '冷色', value: 'saturate(0.92) hue-rotate(12deg) brightness(1.04)' },
{ key: 'bw', label: '黑白', value: 'grayscale(1) contrast(1.08)' },
{ key: 'vintage', label: '复古', value: 'sepia(0.34) contrast(1.08) saturate(0.88)' },
{ key: 'night', label: '夜视', value: 'brightness(0.78) contrast(1.2) saturate(0.82)' },
{ key: 'eye', label: '护眼', value: 'sepia(0.12) saturate(0.85) brightness(0.94) contrast(0.96)' },
{ key: 'cinema', label: '影院', value: 'brightness(0.92) contrast(1.16) saturate(1.08)' },
{ key: 'soft', label: '柔和', value: 'brightness(1.06) contrast(0.92) saturate(0.9)' },
{ key: 'muted', label: '低饱和', value: 'saturate(0.62) contrast(1.06)' },
{ key: 'skin', label: '肤色增强', value: 'sepia(0.08) saturate(1.16) hue-rotate(-5deg) contrast(1.04)' },
];
const FILTER_COMBOS = [
{ key: 'cinema-eye', label: '影院护眼', value: 'brightness(0.9) contrast(1.12) saturate(0.96) sepia(0.08)' },
{ key: 'soft-warm', label: '柔和暖色', value: 'brightness(1.04) contrast(0.94) saturate(0.98) sepia(0.12) hue-rotate(-5deg)' },
{ key: 'cool-cinema', label: '冷调影院', value: 'brightness(0.94) contrast(1.14) saturate(0.94) hue-rotate(10deg)' },
{ key: 'muted-vintage', label: '低饱和复古', value: 'sepia(0.22) contrast(1.08) saturate(0.62) brightness(0.98)' },
{ key: 'skin-soft', label: '柔和肤色', value: 'brightness(1.04) contrast(0.96) saturate(1.08) sepia(0.08) hue-rotate(-4deg)' },
];
const FILTER_PRESETS = [...FILTERS, ...FILTER_COMBOS];
let activeVideo = null;
let activePlayer = null;
let refreshTimer = 0;
let touchHideTimer = 0;
let titleTranslationTimer = 0;
let titleTranslationRequestKey = '';
let titleTranslationFailedKey = '';
let activeFilterKey = 'none';
let filterAdjustments = { brightness: 100, contrast: 100, saturation: 100 };
let progressSaveTimer = 0;
let resumeOffered = false;
let resumeTime = 0;
let bookmarkMenuOpen = false;
function getMediaKey() {
return `${window.location.origin}${window.location.pathname}`;
}
function readStorage(key, fallback) {
try {
const value = JSON.parse(localStorage.getItem(key) || 'null');
return value && typeof value === 'object' ? value : fallback;
} catch {
return fallback;
}
}
function writeStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage can be unavailable in private browsing.
}
}
function getProgress() {
return readStorage(PROGRESS_KEY, {});
}
function getBookmarks() {
const store = readStorage(BOOKMARK_KEY, {});
return Array.isArray(store[getMediaKey()]) ? store[getMediaKey()] : [];
}
function saveBookmarks(bookmarks) {
const store = readStorage(BOOKMARK_KEY, {});
store[getMediaKey()] = bookmarks;
writeStorage(BOOKMARK_KEY, store);
}
function getVideo() {
if (isMissavPage()) {
const video = document.querySelector('video.player');
return video instanceof HTMLVideoElement ? video : null;
}
if (!isJableDetailPage()) return null;
const video = document.querySelector('video#player');
return video instanceof HTMLVideoElement ? video : null;
}
function hasJapanese(text) {
return /[\u3040-\u30ff]/.test(text || '');
}
function getDirectText(element) {
return [...element.childNodes]
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent.trim())
.filter(Boolean)
.join(' ')
.trim();
}
function getTitleElement(title) {
const headings = [...document.querySelectorAll('h1, h2, h3, h4, [role="heading"]')];
return headings.find((heading) => {
const directText = getDirectText(heading);
return directText && (directText === title || directText.includes(title));
}) || headings.find((heading) => hasJapanese(getDirectText(heading)));
}
function getOriginalTitle() {
const metaTitle = document.querySelector('meta[property="og:title"]')?.content?.trim();
if (metaTitle) return metaTitle;
const heading = [...document.querySelectorAll('h1, h2, h3, h4')]
.find((element) => hasJapanese(getDirectText(element)));
return heading ? getDirectText(heading) : '';
}
function getTitleTranslationCache() {
try {
const value = JSON.parse(localStorage.getItem(TITLE_CACHE_KEY) || '{}');
return value && typeof value === 'object' ? value : {};
} catch {
return {};
}
}
function setTitleTranslationCache(title, translation) {
try {
const cache = getTitleTranslationCache();
cache[title] = translation;
localStorage.setItem(TITLE_CACHE_KEY, JSON.stringify(cache));
} catch {
// Storage can be unavailable in private browsing.
}
}
function renderTitleTranslation(titleElement, text, loading = false) {
if (!titleElement) return;
let translation = titleElement.querySelector(`#${TITLE_TRANSLATION_ID}`);
if (!text) {
translation?.remove();
return;
}
if (!translation) {
translation = document.createElement('div');
translation.id = TITLE_TRANSLATION_ID;
translation.className = 'jable-title-translation';
titleElement.insertBefore(translation, titleElement.firstElementChild);
}
translation.dataset.sourceTitle = titleElement.dataset.jableTitleSource || '';
translation.classList.toggle('is-loading', loading);
translation.textContent = loading ? '中文翻译:翻译中…' : `中文翻译:${text}`;
}
function requestTitleTranslation(title) {
const url = 'https://translate.googleapis.com/translate_a/single'
+ `?client=gtx&sl=auto&tl=zh-CN&dt=t&q=${encodeURIComponent(title)}`;
return new Promise((resolve, reject) => {
const parseResponse = (responseText) => {
try {
const data = JSON.parse(responseText);
const translation = Array.isArray(data?.[0])
? data[0].map((part) => part?.[0] || '').join('').trim()
: '';
if (translation) resolve(translation);
else reject(new Error('Translation response is empty'));
} catch (error) {
reject(error);
}
};
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: 10000,
onload: (response) => {
if (response.status >= 200 && response.status < 300) parseResponse(response.responseText);
else reject(new Error(`Translation request failed: ${response.status}`));
},
onerror: reject,
ontimeout: reject,
});
return;
}
fetch(url).then((response) => {
if (!response.ok) throw new Error(`Translation request failed: ${response.status}`);
return response.text();
}).then(parseResponse).catch(reject);
});
}
async function updateTitleTranslation() {
const title = getOriginalTitle();
const oldTranslation = document.getElementById(TITLE_TRANSLATION_ID);
if (!title || !hasJapanese(title)) {
oldTranslation?.remove();
titleTranslationRequestKey = '';
titleTranslationFailedKey = '';
return;
}
const titleElement = getTitleElement(title);
if (!titleElement) return;
titleElement.dataset.jableTitleSource = title;
const oldParent = oldTranslation?.parentElement;
if (oldParent && oldParent !== titleElement) oldTranslation.remove();
const currentTranslation = titleElement.querySelector(`#${TITLE_TRANSLATION_ID}`);
if (currentTranslation && currentTranslation.dataset.sourceTitle === title
&& !currentTranslation.classList.contains('is-loading')) return;
if (titleTranslationRequestKey === title) return;
if (titleTranslationFailedKey === title) return;
const cachedTranslation = getTitleTranslationCache()[title];
if (cachedTranslation) {
renderTitleTranslation(titleElement, cachedTranslation);
return;
}
titleTranslationRequestKey = title;
renderTitleTranslation(titleElement, title, true);
try {
const translation = await requestTitleTranslation(title);
if (getOriginalTitle() === title) {
setTitleTranslationCache(title, translation);
titleTranslationFailedKey = '';
renderTitleTranslation(titleElement, translation);
}
} catch {
if (getOriginalTitle() === title) {
titleTranslationFailedKey = title;
renderTitleTranslation(titleElement, '');
}
} finally {
titleTranslationRequestKey = '';
}
}
function scheduleTitleTranslation() {
if (titleTranslationTimer) return;
titleTranslationTimer = window.setTimeout(() => {
titleTranslationTimer = 0;
updateTitleTranslation();
}, 250);
}
function formatTime(seconds) {
const safeSeconds = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const rest = safeSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
: `${minutes}:${String(rest).padStart(2, '0')}`;
}
function getSavedResume(video) {
const saved = getProgress()[getMediaKey()];
if (!saved || !Number.isFinite(saved.time) || saved.time < 5) return null;
if (isSeekable(video) && saved.time >= video.duration - 5) return null;
return saved;
}
function updateResumeControls(video = activeVideo) {
const resumeButton = document.querySelector(`#${PANEL_ID} .jable-resume-button`);
const resetButton = document.querySelector(`#${PANEL_ID} .jable-reset-progress-button`);
if (!resumeButton || !resetButton) return;
const saved = getSavedResume(video);
resumeTime = saved?.time || 0;
const hasResume = Boolean(saved);
resumeButton.classList.toggle('is-empty', !hasResume);
resetButton.classList.toggle('is-empty', !hasResume);
resumeButton.textContent = hasResume ? `续播 ${formatTime(resumeTime)}` : '续播';
resumeButton.title = hasResume
? `从 ${formatTime(resumeTime)} 继续播放`
: '暂无已保存的播放进度';
resumeButton.disabled = !hasResume;
resetButton.disabled = !hasResume;
}
function saveProgress(video = activeVideo) {
if (!isSeekable(video)) return;
const progress = getProgress();
if (video.currentTime < 5 || video.currentTime >= video.duration - 5) {
delete progress[getMediaKey()];
} else {
progress[getMediaKey()] = {
time: video.currentTime,
duration: video.duration,
updatedAt: Date.now(),
};
}
writeStorage(PROGRESS_KEY, progress);
updateResumeControls(video);
}
function scheduleProgressSave() {
window.clearTimeout(progressSaveTimer);
progressSaveTimer = window.setTimeout(() => {
progressSaveTimer = 0;
saveProgress();
}, 1200);
}
function handleVideoTimeUpdate() {
scheduleProgressSave();
}
function handleVideoPause() {
window.clearTimeout(progressSaveTimer);
progressSaveTimer = 0;
saveProgress();
}
function handleVideoMetadata() {
updateResumeControls();
}
function resumeVideo() {
const video = activeVideo || getVideo();
const saved = getSavedResume(video);
if (!saved || !isSeekable(video)) return;
video.currentTime = Math.min(saved.time, video.duration - 0.5);
video.play().catch(() => {});
showToast(`已跳转到 ${formatTime(video.currentTime)}`);
updateResumeControls(video);
}
function resetProgress() {
const progress = getProgress();
delete progress[getMediaKey()];
writeStorage(PROGRESS_KEY, progress);
if (activeVideo) activeVideo.currentTime = 0;
updateResumeControls();
showToast('已清除播放进度');
}
function applyFilter() {
const video = activeVideo || getVideo();
if (!video) return;
const filter = getActiveFilter();
const adjustments = [];
if (filterAdjustments.brightness !== 100) {
adjustments.push(`brightness(${filterAdjustments.brightness / 100})`);
}
if (filterAdjustments.contrast !== 100) {
adjustments.push(`contrast(${filterAdjustments.contrast / 100})`);
}
if (filterAdjustments.saturation !== 100) {
adjustments.push(`saturate(${filterAdjustments.saturation / 100})`);
}
video.style.filter = [filter.value, ...adjustments].filter(Boolean).join(' ');
}
function isSeekable(video) {
return video && Number.isFinite(video.duration) && video.duration > 0;
}
function showToast(message) {
const toast = document.querySelector(`#${PANEL_ID} .jable-seek-toast`);
if (!toast) return;
toast.textContent = message;
toast.classList.add('is-visible');
window.clearTimeout(toast.hideTimer);
toast.hideTimer = window.setTimeout(() => toast.classList.remove('is-visible'), 1000);
}
function seekBy(seconds) {
const video = activeVideo || getVideo();
if (!isSeekable(video)) {
showToast('Video is not ready');
return;
}
const nextTime = Math.min(video.duration, Math.max(0, video.currentTime + seconds));
video.currentTime = nextTime;
scheduleProgressSave();
showToast(`${seconds > 0 ? '+' : ''}${seconds}s ${formatTime(nextTime)}`);
}
function formatRate(rate) {
return `${Number.isInteger(rate) ? rate : rate.toFixed(2).replace(/0+$/, '')}x`;
}
function updateSpeedButton() {
const button = document.querySelector(`#${PANEL_ID} .jable-speed-button`);
if (!button) return;
const video = activeVideo || getVideo();
const rate = video && Number.isFinite(video.playbackRate) && video.playbackRate > 0
? video.playbackRate
: 1;
const label = formatRate(rate);
button.textContent = label;
button.title = `Playback speed: ${label} (click to change)`;
button.setAttribute('aria-label', `Playback speed ${label}. Click to change.`);
button.parentElement?.querySelectorAll('.jable-speed-menu button').forEach((option) => {
const selected = Math.abs(Number(option.dataset.rate) - rate) < 0.001;
option.classList.toggle('is-selected', selected);
option.setAttribute('aria-selected', String(selected));
});
}
function setPlaybackRate(rate) {
const video = activeVideo || getVideo();
if (!video) {
showToast('Video is not ready');
return;
}
video.playbackRate = rate;
updateSpeedButton();
showToast(`Speed ${formatRate(rate)}`);
}
function getActiveFilter() {
return FILTER_PRESETS.find((filter) => filter.key === activeFilterKey) || FILTER_PRESETS[0];
}
function updateFilterButton() {
const button = document.querySelector(`#${PANEL_ID} .jable-filter-button`);
if (!button) return;
const filter = getActiveFilter();
button.textContent = filter.key === 'none' ? '滤镜' : filter.label;
button.title = `Video filter: ${filter.label} (click to change)`;
button.setAttribute('aria-label', `Video filter ${filter.label}. Click to change.`);
button.parentElement?.querySelectorAll('.jable-filter-menu button').forEach((option) => {
const selected = option.dataset.filter === filter.key;
option.classList.toggle('is-selected', selected);
option.setAttribute('aria-selected', String(selected));
});
}
function setVideoFilter(filterKey, announce = true) {
const filter = FILTER_PRESETS.find((item) => item.key === filterKey) || FILTER_PRESETS[0];
activeFilterKey = filter.key;
applyFilter();
updateFilterButton();
if (announce) showToast(`Filter: ${filter.label}`);
}
function updateFilterAdjustmentInputs() {
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
for (const input of panel.querySelectorAll('.jable-filter-adjustments input')) {
const key = input.dataset.adjustment;
input.value = String(filterAdjustments[key]);
const value = panel.querySelector(`[data-adjustment-value="${key}"]`);
if (value) value.textContent = `${filterAdjustments[key]}%`;
}
}
function resetFilterAdjustments() {
filterAdjustments = { brightness: 100, contrast: 100, saturation: 100 };
updateFilterAdjustmentInputs();
applyFilter();
showToast('已重置滤镜参数');
}
function handleFilterAdjustment(event) {
const key = event.target.dataset.adjustment;
if (!key) return;
filterAdjustments[key] = Number(event.target.value);
updateFilterAdjustmentInputs();
applyFilter();
}
function setBookmarkMenuOpen(open) {
const menu = document.querySelector(`#${PANEL_ID} .jable-bookmark-menu`);
const button = document.querySelector(`#${PANEL_ID} .jable-bookmark-button`);
if (!menu || !button) return;
bookmarkMenuOpen = open;
menu.hidden = !open;
button.setAttribute('aria-expanded', String(open));
if (open) renderBookmarks();
}
function toggleBookmarkMenu(event) {
event.stopPropagation();
setSpeedMenuOpen(false);
setFilterMenuOpen(false);
setBookmarkMenuOpen(!bookmarkMenuOpen);
}
function addBookmark() {
const video = activeVideo || getVideo();
if (!isSeekable(video)) {
showToast('Video is not ready');
return;
}
const note = window.prompt('请输入书签备注(可留空):', '')
?.trim() || '';
const bookmarks = getBookmarks();
bookmarks.push({ time: video.currentTime, note, createdAt: Date.now() });
bookmarks.sort((a, b) => a.time - b.time);
saveBookmarks(bookmarks);
renderBookmarks();
showToast(`已添加书签 ${formatTime(video.currentTime)}`);
}
function removeBookmark(index) {
const bookmarks = getBookmarks();
bookmarks.splice(index, 1);
saveBookmarks(bookmarks);
renderBookmarks();
showToast('已删除书签');
}
function jumpToBookmark(time) {
const video = activeVideo || getVideo();
if (!isSeekable(video)) return;
video.currentTime = Math.min(time, video.duration);
scheduleProgressSave();
showToast(`已跳转到 ${formatTime(video.currentTime)}`);
}
function renderBookmarks() {
const menu = document.querySelector(`#${PANEL_ID} .jable-bookmark-menu`);
if (!menu) return;
const list = menu.querySelector('.jable-bookmark-list');
if (!list) return;
list.replaceChildren();
const bookmarks = getBookmarks();
if (!bookmarks.length) {
const empty = document.createElement('span');
empty.className = 'jable-bookmark-empty';
empty.textContent = '暂无书签';
list.appendChild(empty);
return;
}
bookmarks.forEach((bookmark, index) => {
const row = document.createElement('div');
row.className = 'jable-bookmark-row';
const jump = document.createElement('button');
jump.type = 'button';
jump.className = 'jable-bookmark-jump';
jump.textContent = `${formatTime(bookmark.time)}${bookmark.note ? ` ${bookmark.note}` : ''}`;
jump.title = '跳转到此书签';
jump.addEventListener('click', () => jumpToBookmark(bookmark.time));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'jable-bookmark-remove';
remove.textContent = '×';
remove.title = '删除书签';
remove.setAttribute('aria-label', `删除书签 ${formatTime(bookmark.time)}`);
remove.addEventListener('click', () => removeBookmark(index));
row.append(jump, remove);
list.appendChild(row);
});
}
function setFilterMenuOpen(open) {
const menu = document.querySelector(`#${PANEL_ID} .jable-filter-menu`);
const button = document.querySelector(`#${PANEL_ID} .jable-filter-button`);
if (!menu || !button) return;
menu.hidden = !open;
button.setAttribute('aria-expanded', String(open));
if (open) {
setBookmarkMenuOpen(false);
updateFilterAdjustmentInputs();
}
}
function toggleFilterMenu(event) {
event.stopPropagation();
const menu = document.querySelector(`#${PANEL_ID} .jable-filter-menu`);
if (menu) {
if (menu.hidden) setSpeedMenuOpen(false);
setFilterMenuOpen(menu.hidden);
}
}
function setSpeedMenuOpen(open) {
const menu = document.querySelector(`#${PANEL_ID} .jable-speed-menu`);
const button = document.querySelector(`#${PANEL_ID} .jable-speed-button`);
if (!menu || !button) return;
menu.hidden = !open;
button.setAttribute('aria-expanded', String(open));
if (open) {
setBookmarkMenuOpen(false);
setFilterMenuOpen(false);
}
}
function toggleSpeedMenu(event) {
event.stopPropagation();
const menu = document.querySelector(`#${PANEL_ID} .jable-speed-menu`);
if (menu) {
if (menu.hidden) setFilterMenuOpen(false);
setSpeedMenuOpen(menu.hidden);
}
}
function handleDocumentPointerDown(event) {
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.contains(event.target)) {
setSpeedMenuOpen(false);
setFilterMenuOpen(false);
setBookmarkMenuOpen(false);
}
}
function handleDocumentKeydown(event) {
if (event.key === 'Escape') {
setSpeedMenuOpen(false);
setFilterMenuOpen(false);
setBookmarkMenuOpen(false);
}
}
function isInsideRect(x, y, rect, padding = 0) {
return x >= rect.left - padding
&& x <= rect.right + padding
&& y >= rect.top - padding
&& y <= rect.bottom + padding;
}
function isTouchDevice() {
return window.matchMedia('(hover: none)').matches
|| (navigator.maxTouchPoints > 0
&& window.matchMedia('(any-hover: none)').matches);
}
function isMissavPage() {
return window.location.hostname === 'missav.ai';
}
function isJableDetailPage() {
return window.location.hostname === 'jable.tv'
&& /^\/videos\//.test(window.location.pathname);
}
function handleFullscreenPointerMove(event) {
const panel = document.getElementById(PANEL_ID);
if (!panel?.classList.contains('jable-seek-fullscreen-host')) return;
if (panel.classList.contains('is-touch-fullscreen')) {
panel.classList.remove('is-hover-visible');
return;
}
const panelRect = panel.getBoundingClientRect();
const menu = panel.querySelector(
'.jable-speed-menu:not([hidden]), .jable-filter-menu:not([hidden])',
);
const menuRect = menu?.getBoundingClientRect();
const overPanel = panel.contains(event.target)
|| isInsideRect(event.clientX, event.clientY, panelRect, 16);
const overMenu = menuRect && isInsideRect(event.clientX, event.clientY, menuRect, 8);
panel.classList.toggle('is-hover-visible', Boolean(overPanel || overMenu));
}
function scheduleTouchHide() {
window.clearTimeout(touchHideTimer);
const panel = document.getElementById(PANEL_ID);
if (!panel?.classList.contains('jable-seek-fullscreen-host')
|| !panel.classList.contains('is-touch-fullscreen')) return;
touchHideTimer = window.setTimeout(() => {
const currentPanel = document.getElementById(PANEL_ID);
if (!currentPanel?.classList.contains('jable-seek-fullscreen-host')) return;
currentPanel.classList.remove('is-touch-visible');
setSpeedMenuOpen(false);
setFilterMenuOpen(false);
}, 3000);
}
function handleFullscreenPointerDown() {
const panel = document.getElementById(PANEL_ID);
if (!panel?.classList.contains('jable-seek-fullscreen-host')
|| !panel.classList.contains('is-touch-fullscreen')) return;
panel.classList.add('is-touch-visible');
scheduleTouchHide();
}
function handleFullscreenPointerLeave() {
const panel = document.getElementById(PANEL_ID);
panel?.classList.remove('is-hover-visible');
}
function addStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${PANEL_ID} {
position: relative;
/* 最低层:不抢其他控件(字幕搜索等油猴按钮)的绘制与点击 */
z-index: 0;
isolation: auto;
left: auto;
bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
width: fit-content;
max-width: calc(100% - 24px);
margin: 8px auto 0;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 14px;
background: rgba(16, 18, 22, 0.58);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-4px);
font: 600 13px/1.1 system-ui, sans-serif;
transition: opacity 160ms ease, transform 160ms ease, visibility 160ms ease;
}
#${PANEL_ID} .jable-seek-actions,
#${PANEL_ID} .jable-seek-jumps {
display: contents;
}
.jable-seek-panel-host:hover > #${PANEL_ID},
#${PANEL_ID}:hover,
#${PANEL_ID}:focus-within {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* 容器自身永远不拦截事件,只有真正可交互的子控件拦截 */
#${PANEL_ID} button,
#${PANEL_ID} input,
#${PANEL_ID} .jable-speed-menu,
#${PANEL_ID} .jable-filter-menu,
#${PANEL_ID} .jable-bookmark-menu {
pointer-events: auto;
}
#${LEGACY_PANEL_ID} {
display: none !important;
}
#jable-seek-panel-v102,
#jable-seek-panel-v103,
#jable-seek-panel-v104,
#jable-seek-panel-v105,
#jable-seek-panel-v106,
#jable-seek-panel-v107,
#jable-seek-panel-v108,
#jable-seek-panel-v109,
#jable-seek-panel-v110,
#jable-seek-panel-v111,
#jable-seek-panel-v112,
#jable-seek-panel-v113,
#jable-seek-panel-v114,
#jable-seek-panel-v115,
#jable-seek-panel-v116,
#jable-seek-panel-v117,
#jable-seek-panel-v118,
#jable-seek-panel-v119,
#jable-seek-panel-v120,
#jable-seek-panel-v121,
#jable-seek-panel-v122,
#jable-seek-panel-v123,
#jable-seek-panel-v124,
#jable-seek-panel-v125,
#jable-seek-panel-v126 {
display: none !important;
}
#${PANEL_ID}.is-hidden {
display: none !important;
}
#${PANEL_ID} button {
min-width: 48px;
height: 34px;
margin: 0;
padding: 0 9px;
border: 0;
border-radius: 9px;
color: #f7f7f7;
background: rgba(255, 255, 255, 0.09);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
cursor: pointer;
font: inherit;
transition: background 120ms ease, transform 120ms ease;
}
#${PANEL_ID} button:hover,
#${PANEL_ID} button:focus-visible {
outline: none;
background: rgba(232, 55, 88, 0.78);
}
#${PANEL_ID} button:active { transform: scale(0.94); }
#${PANEL_ID} button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
#${PANEL_ID} .jable-speed-control {
position: relative;
display: flex;
}
#${PANEL_ID} .jable-speed-menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(58px, 1fr));
gap: 4px;
min-width: 132px;
padding: 6px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 10px;
background: rgba(16, 18, 22, 0.66);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.4);
}
#${PANEL_ID} .jable-speed-menu[hidden] { display: none; }
#${PANEL_ID} .jable-speed-menu button {
min-width: 0;
height: 30px;
padding: 0 8px;
font-size: 12px;
}
#${PANEL_ID} .jable-speed-menu button.is-selected {
background: rgba(232, 55, 88, 0.78);
}
#${PANEL_ID} .jable-filter-control {
position: relative;
display: flex;
}
#${PANEL_ID} .jable-filter-menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(70px, 1fr));
gap: 4px;
min-width: 160px;
padding: 6px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 10px;
background: rgba(16, 18, 22, 0.66);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.4);
}
#${PANEL_ID} .jable-filter-menu[hidden] { display: none; }
#${PANEL_ID} .jable-filter-menu button {
min-width: 0;
height: 30px;
padding: 0 8px;
font-size: 12px;
}
#${PANEL_ID} .jable-filter-menu button.is-selected {
background: rgba(232, 55, 88, 0.78);
}
#${PANEL_ID} .jable-filter-adjustments {
display: grid;
grid-column: 1 / -1;
gap: 5px;
margin-top: 3px;
padding-top: 7px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
}
#${PANEL_ID} .jable-filter-adjustment-row {
display: grid;
grid-template-columns: 44px minmax(90px, 1fr) 38px;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
}
#${PANEL_ID} .jable-filter-adjustment-row input {
width: 100%;
accent-color: #e83758;
}
#${PANEL_ID} .jable-filter-adjustment-row output {
text-align: right;
color: rgba(255, 255, 255, 0.62);
}
#${PANEL_ID} .jable-filter-reset {
grid-column: 1 / -1;
min-width: 0;
height: 27px;
margin-top: 2px;
font-size: 11px;
}
#${PANEL_ID} .jable-bookmark-control {
position: relative;
display: flex;
}
#${PANEL_ID} .jable-bookmark-menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 2;
display: block;
width: min(280px, calc(100vw - 28px));
padding: 7px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 10px;
background: rgba(16, 18, 22, 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.4);
}
#${PANEL_ID} .jable-bookmark-menu[hidden] { display: none; }
#${PANEL_ID} .jable-bookmark-add {
width: 100%;
margin-bottom: 6px;
}
#${PANEL_ID} .jable-bookmark-list {
display: grid;
gap: 4px;
max-height: 180px;
overflow-y: auto;
}
#${PANEL_ID} .jable-bookmark-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
gap: 4px;
}
#${PANEL_ID} .jable-bookmark-jump {
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
#${PANEL_ID} .jable-bookmark-remove {
min-width: 0;
padding: 0;
}
#${PANEL_ID} .jable-bookmark-empty {
padding: 4px 2px;
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
text-align: center;
}
#${PANEL_ID}.jable-seek-fullscreen-host {
position: absolute !important;
/* 高于播放器自带的控制条(video.js 控制在 3~5 层),
但仍远低于文档级浮层(其它油猴控件用 fixed + 2147483646) */
z-index: 10 !important;
left: 50% !important;
bottom: 86px !important;
width: fit-content !important;
max-width: calc(100% - 24px) !important;
margin: 0 !important;
transform: translate(-50%, 6px) !important;
}
#${PANEL_ID}.jable-seek-fullscreen-host.is-hover-visible,
#${PANEL_ID}.jable-seek-fullscreen-host:hover,
#${PANEL_ID}.jable-seek-fullscreen-host:focus-within {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0) !important;
}
#${PANEL_ID}.jable-seek-fullscreen-host.is-touch-fullscreen:not(.is-touch-visible) {
opacity: 0 !important;
visibility: hidden !important;
transform: translate(-50%, 6px) !important;
}
#${PANEL_ID}.jable-seek-fullscreen-host.is-touch-fullscreen.is-touch-visible {
opacity: 1 !important;
visibility: visible !important;
transform: translate(-50%, 0) !important;
transform: translate(-50%, 0) !important;
}
#${PANEL_ID}.jable-seek-fullscreen-host .jable-speed-menu {
top: auto;
bottom: calc(100% + 6px);
}
#${PANEL_ID}.jable-seek-fullscreen-host .jable-filter-menu {
top: auto;
bottom: calc(100% + 6px);
}
#${PANEL_ID}.jable-seek-fullscreen-host,
#${PANEL_ID}.jable-seek-fullscreen-host button,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-speed-menu,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-filter-menu,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-bookmark-menu {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
#${PANEL_ID} .jable-seek-toast {
position: absolute;
left: 50%;
top: calc(100% + 10px);
padding: 7px 10px;
border-radius: 8px;
color: #fff;
background: rgba(0, 0, 0, 0.8);
opacity: 0;
pointer-events: none;
transform: translate(-50%, -5px);
transition: opacity 120ms ease, transform 120ms ease;
white-space: nowrap;
}
#${PANEL_ID} .jable-seek-toast.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
#${PANEL_ID}.jable-seek-fullscreen-host .jable-seek-toast {
top: auto;
bottom: calc(100% + 10px);
}
.jable-title-translation {
margin-top: 8px;
color: #b4234d;
font-size: 0.92em;
font-weight: 600;
line-height: 1.5;
}
.jable-title-translation.is-loading {
color: #6b7280;
font-weight: 500;
}
@media (max-width: 560px) {
#${PANEL_ID} {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
width: min(360px, calc(100% - 20px));
max-width: calc(100% - 20px);
gap: 4px;
padding: 6px;
border-radius: 12px;
}
#${PANEL_ID} .jable-seek-actions,
#${PANEL_ID} .jable-seek-jumps {
display: grid;
grid-column: 1 / -1;
gap: 4px;
}
#${PANEL_ID} .jable-seek-actions {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
#${PANEL_ID} .jable-seek-jumps {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#${PANEL_ID} .jable-seek-actions > *,
#${PANEL_ID} .jable-seek-jumps > * {
min-width: 0;
width: 100%;
}
#${PANEL_ID} .jable-speed-button,
#${PANEL_ID} .jable-filter-button,
#${PANEL_ID} .jable-bookmark-button,
#${PANEL_ID} .jable-resume-button,
#${PANEL_ID} .jable-reset-progress-button {
min-width: 0;
width: 100%;
}
#${PANEL_ID} button,
#${PANEL_ID} .jable-speed-control,
#${PANEL_ID} .jable-filter-control,
#${PANEL_ID} .jable-bookmark-control {
min-width: 0;
width: 100%;
}
#${PANEL_ID} button {
height: 34px;
padding: 0 2px;
font-size: 11px;
white-space: nowrap;
}
#${PANEL_ID} .jable-speed-control,
#${PANEL_ID} .jable-filter-control,
#${PANEL_ID} .jable-bookmark-control {
display: block;
}
#${PANEL_ID} .jable-speed-menu,
#${PANEL_ID} .jable-filter-menu,
#${PANEL_ID} .jable-bookmark-menu {
left: 0;
width: min(190px, calc(100vw - 28px));
min-width: 0;
transform: none;
}
#${PANEL_ID}.jable-seek-fullscreen-host {
/* 与 1.9.8 保持一致的高度 */
bottom: 44px !important;
width: min(360px, calc(100% - 20px)) !important;
max-width: calc(100% - 20px) !important;
}
#${PANEL_ID}.jable-seek-fullscreen-host .jable-speed-menu,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-filter-menu,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-bookmark-menu {
left: 0;
right: auto;
transform: none;
}
}
@media (min-width: 561px) and (max-width: 1366px) and (max-height: 1024px) {
#${PANEL_ID} {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(620px, calc(100% - 28px));
max-width: calc(100% - 28px);
gap: 6px;
padding: 8px;
}
#${PANEL_ID} .jable-seek-actions {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
#${PANEL_ID} .jable-seek-jumps {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
#${PANEL_ID} .jable-seek-actions > *,
#${PANEL_ID} .jable-seek-jumps > * {
min-width: 0;
width: 100%;
}
#${PANEL_ID} .jable-speed-button,
#${PANEL_ID} .jable-filter-button,
#${PANEL_ID} .jable-bookmark-button,
#${PANEL_ID} .jable-resume-button,
#${PANEL_ID} .jable-reset-progress-button {
min-width: 0;
width: 100%;
}
#${PANEL_ID} button {
height: 36px;
padding: 0 6px;
font-size: 13px;
}
#${PANEL_ID}.jable-seek-fullscreen-host {
bottom: 52px !important;
width: min(620px, calc(100% - 28px)) !important;
max-width: calc(100% - 28px) !important;
}
}
@media (max-width: 340px) {
#${PANEL_ID} {
width: calc(100% - 16px);
max-width: calc(100% - 16px);
gap: 3px;
padding: 5px;
}
#${PANEL_ID} .jable-seek-actions,
#${PANEL_ID} .jable-seek-jumps {
gap: 3px;
}
#${PANEL_ID} button {
height: 32px;
padding: 0 1px;
font-size: 10px;
}
}
@media (max-width: 560px) and (orientation: landscape) {
#${PANEL_ID},
#${PANEL_ID}.jable-seek-fullscreen-host {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(620px, calc(100% - 20px)) !important;
max-width: calc(100% - 20px) !important;
gap: 4px;
padding: 6px;
}
#${PANEL_ID} .jable-seek-actions,
#${PANEL_ID} .jable-seek-jumps {
display: grid;
grid-column: 1 / -1;
gap: 4px;
}
#${PANEL_ID} .jable-seek-actions {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
#${PANEL_ID} .jable-seek-jumps {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#${PANEL_ID} .jable-seek-actions > *,
#${PANEL_ID} .jable-seek-jumps > * {
min-width: 0;
width: 100%;
}
#${PANEL_ID}.jable-seek-fullscreen-host {
bottom: 42px !important;
}
#${PANEL_ID} button,
#${PANEL_ID} .jable-speed-control,
#${PANEL_ID} .jable-filter-control {
min-width: 0;
width: 100%;
}
#${PANEL_ID} button {
height: 30px;
padding: 0 3px;
font-size: 11px;
white-space: nowrap;
}
#${PANEL_ID} .jable-speed-control,
#${PANEL_ID} .jable-filter-control {
display: block;
}
#${PANEL_ID} .jable-speed-menu,
#${PANEL_ID} .jable-filter-menu,
#${PANEL_ID} .jable-bookmark-menu {
grid-template-columns: repeat(4, minmax(0, 1fr));
left: 0;
width: min(220px, calc(100vw - 28px));
min-width: 0;
transform: none;
}
#${PANEL_ID}.jable-seek-fullscreen-host .jable-speed-menu,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-filter-menu,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-bookmark-menu {
left: 0;
right: auto;
transform: none;
}
}
@media (min-width: 561px) and (max-width: 900px)
and (orientation: landscape) and (max-height: 560px) {
#${PANEL_ID},
#${PANEL_ID}.jable-seek-fullscreen-host {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(760px, calc(100% - 16px)) !important;
max-width: calc(100% - 16px) !important;
gap: 4px;
padding: 5px;
border-radius: 10px;
}
#${PANEL_ID} .jable-seek-actions,
#${PANEL_ID} .jable-seek-jumps {
display: grid;
grid-column: 1 / -1;
gap: 4px;
}
#${PANEL_ID} .jable-seek-actions {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
#${PANEL_ID} .jable-seek-jumps {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#${PANEL_ID} .jable-seek-actions > *,
#${PANEL_ID} .jable-seek-jumps > * {
min-width: 0;
width: 100%;
}
#${PANEL_ID} .jable-speed-button,
#${PANEL_ID} .jable-filter-button,
#${PANEL_ID} .jable-bookmark-button,
#${PANEL_ID} .jable-resume-button,
#${PANEL_ID} .jable-reset-progress-button {
min-width: 0;
width: 100%;
}
#${PANEL_ID} button {
height: 30px;
padding: 0 3px;
font-size: 11px;
white-space: nowrap;
}
#${PANEL_ID}.jable-seek-fullscreen-host {
/* 与 1.9.8 一致的高度 */
bottom: 38px !important;
grid-template-columns: repeat(13, minmax(0, 1fr));
}
#${PANEL_ID}.jable-seek-fullscreen-host .jable-seek-actions,
#${PANEL_ID}.jable-seek-fullscreen-host .jable-seek-jumps {
display: contents;
grid-column: auto;
}
}
@media (hover: none), (pointer: coarse) {
#${PANEL_ID}:not(.jable-seek-fullscreen-host) {
opacity: 1;
visibility: visible;
transform: none !important;
}
}
`;
document.head.appendChild(style);
}
function createPanel() {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement('div');
panel.id = PANEL_ID;
panel.setAttribute('role', 'toolbar');
panel.setAttribute('aria-label', 'Video time jump controls');
panel.addEventListener('dblclick', (event) => {
if (!panel.classList.contains('jable-seek-fullscreen-host')) return;
event.preventDefault();
event.stopPropagation();
}, true);
const actions = document.createElement('span');
actions.className = 'jable-seek-actions';
const speedControl = document.createElement('span');
speedControl.className = 'jable-speed-control';
const speedButton = document.createElement('button');
speedButton.className = 'jable-speed-button';
speedButton.type = 'button';
speedButton.textContent = '1x';
speedButton.title = 'Playback speed: 1x (click to change)';
speedButton.setAttribute('aria-label', 'Playback speed 1x. Click to change.');
speedButton.setAttribute('aria-haspopup', 'listbox');
speedButton.setAttribute('aria-expanded', 'false');
speedButton.addEventListener('click', toggleSpeedMenu);
speedControl.appendChild(speedButton);
const speedMenu = document.createElement('div');
speedMenu.className = 'jable-speed-menu';
speedMenu.hidden = true;
speedMenu.setAttribute('role', 'listbox');
speedMenu.setAttribute('aria-label', 'Playback speed');
for (const rate of PLAYBACK_RATES) {
const option = document.createElement('button');
option.type = 'button';
option.textContent = formatRate(rate);
option.dataset.rate = String(rate);
option.setAttribute('role', 'option');
option.addEventListener('click', (event) => {
event.stopPropagation();
setPlaybackRate(rate);
setSpeedMenuOpen(false);
});
speedMenu.appendChild(option);
}
speedControl.appendChild(speedMenu);
actions.appendChild(speedControl);
const filterControl = document.createElement('span');
filterControl.className = 'jable-filter-control';
const filterButton = document.createElement('button');
filterButton.className = 'jable-filter-button';
filterButton.type = 'button';
filterButton.textContent = '滤镜';
filterButton.title = '视频滤镜:原图(点击选择)';
filterButton.setAttribute('aria-label', '视频滤镜 原图。点击选择。');
filterButton.setAttribute('aria-haspopup', 'listbox');
filterButton.setAttribute('aria-expanded', 'false');
filterButton.addEventListener('click', toggleFilterMenu);
filterControl.appendChild(filterButton);
const filterMenu = document.createElement('div');
filterMenu.className = 'jable-filter-menu';
filterMenu.hidden = true;
filterMenu.setAttribute('role', 'listbox');
filterMenu.setAttribute('aria-label', '视频滤镜');
for (const filter of FILTER_PRESETS) {
const option = document.createElement('button');
option.type = 'button';
option.textContent = filter.label;
option.dataset.filter = filter.key;
option.setAttribute('role', 'option');
option.addEventListener('click', (event) => {
event.stopPropagation();
setVideoFilter(filter.key);
setFilterMenuOpen(false);
});
filterMenu.appendChild(option);
}
filterControl.appendChild(filterMenu);
actions.appendChild(filterControl);
const filterAdjustmentsContainer = document.createElement('div');
filterAdjustmentsContainer.className = 'jable-filter-adjustments';
for (const adjustment of [
{ key: 'brightness', label: '亮度' },
{ key: 'contrast', label: '对比度' },
{ key: 'saturation', label: '饱和度' },
]) {
const row = document.createElement('label');
row.className = 'jable-filter-adjustment-row';
row.textContent = adjustment.label;
const input = document.createElement('input');
input.type = 'range';
input.min = '50';
input.max = '150';
input.value = String(filterAdjustments[adjustment.key]);
input.dataset.adjustment = adjustment.key;
input.addEventListener('input', handleFilterAdjustment);
const output = document.createElement('output');
output.dataset.adjustmentValue = adjustment.key;
output.textContent = `${filterAdjustments[adjustment.key]}%`;
row.append(input, output);
filterAdjustmentsContainer.appendChild(row);
}
const filterReset = document.createElement('button');
filterReset.type = 'button';
filterReset.className = 'jable-filter-reset';
filterReset.textContent = '重置参数';
filterReset.addEventListener('click', resetFilterAdjustments);
filterAdjustmentsContainer.appendChild(filterReset);
filterMenu.appendChild(filterAdjustmentsContainer);
const bookmarkControl = document.createElement('span');
bookmarkControl.className = 'jable-bookmark-control';
const bookmarkButton = document.createElement('button');
bookmarkButton.className = 'jable-bookmark-button';
bookmarkButton.type = 'button';
bookmarkButton.textContent = '书签';
bookmarkButton.title = '视频书签';
bookmarkButton.setAttribute('aria-label', '视频书签');
bookmarkButton.setAttribute('aria-haspopup', 'menu');
bookmarkButton.setAttribute('aria-expanded', 'false');
bookmarkButton.addEventListener('click', toggleBookmarkMenu);
bookmarkControl.appendChild(bookmarkButton);
const bookmarkMenu = document.createElement('div');
bookmarkMenu.className = 'jable-bookmark-menu';
bookmarkMenu.hidden = true;
const bookmarkAdd = document.createElement('button');
bookmarkAdd.type = 'button';
bookmarkAdd.className = 'jable-bookmark-add';
bookmarkAdd.textContent = '在当前时间添加书签';
bookmarkAdd.addEventListener('click', addBookmark);
bookmarkMenu.appendChild(bookmarkAdd);
const bookmarkList = document.createElement('div');
bookmarkList.className = 'jable-bookmark-list';
bookmarkMenu.appendChild(bookmarkList);
bookmarkControl.appendChild(bookmarkMenu);
actions.appendChild(bookmarkControl);
const resumeButton = document.createElement('button');
resumeButton.className = 'jable-resume-button';
resumeButton.type = 'button';
resumeButton.textContent = '续播';
resumeButton.addEventListener('click', resumeVideo);
actions.appendChild(resumeButton);
const resetProgressButton = document.createElement('button');
resetProgressButton.className = 'jable-reset-progress-button';
resetProgressButton.type = 'button';
resetProgressButton.textContent = '清除进度';
resetProgressButton.addEventListener('click', resetProgress);
actions.appendChild(resetProgressButton);
panel.appendChild(actions);
const jumps = document.createElement('span');
jumps.className = 'jable-seek-jumps';
for (const jump of JUMPS) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = jump.label;
button.title = jump.title;
button.setAttribute('aria-label', jump.title);
button.addEventListener('click', () => seekBy(jump.seconds));
jumps.appendChild(button);
}
panel.appendChild(jumps);
const toast = document.createElement('span');
toast.className = 'jable-seek-toast';
toast.setAttribute('aria-live', 'polite');
panel.appendChild(toast);
return panel;
}
function removeLegacyPanel() {
document.getElementById(LEGACY_PANEL_ID)?.remove();
for (const panelId of OLD_PANEL_IDS) {
document.getElementById(panelId)?.remove();
}
for (const styleId of OLD_STYLE_IDS) {
document.getElementById(styleId)?.remove();
}
}
function getPanelPlacement(video) {
const player = video.closest('.plyr, .jwplayer, .video-js') || video;
if (isMissavPage()) {
const aspectContainer = player.parentElement;
const videoSection = aspectContainer?.parentElement;
if (videoSection?.id === 'video' && aspectContainer) {
return { host: videoSection, anchor: aspectContainer };
}
}
const host = player.parentElement || document.body;
return { host, anchor: player };
}
function syncPanel() {
const video = getVideo();
const panel = createPanel();
if (activeVideo !== video) {
activeVideo?.removeEventListener('ratechange', updateSpeedButton);
activeVideo?.removeEventListener('timeupdate', handleVideoTimeUpdate);
activeVideo?.removeEventListener('pause', handleVideoPause);
activeVideo?.removeEventListener('loadedmetadata', handleVideoMetadata);
activeVideo = video;
activeVideo?.addEventListener('ratechange', updateSpeedButton);
activeVideo?.addEventListener('timeupdate', handleVideoTimeUpdate);
activeVideo?.addEventListener('pause', handleVideoPause);
activeVideo?.addEventListener('loadedmetadata', handleVideoMetadata);
if (activeVideo) applyFilter();
}
panel.classList.toggle('is-hidden', !video);
if (!video) {
panel.remove();
return;
}
const player = video.closest('.plyr, .jwplayer, .video-js') || video;
if (activePlayer !== player) {
activePlayer?.removeEventListener('pointermove', handleFullscreenPointerMove);
activePlayer?.removeEventListener('pointerleave', handleFullscreenPointerLeave);
activePlayer = player;
activePlayer.addEventListener('pointermove', handleFullscreenPointerMove);
activePlayer.addEventListener('pointerleave', handleFullscreenPointerLeave);
}
player.classList.remove('jable-seek-player-host');
const isFullscreen = document.fullscreenElement
&& (document.fullscreenElement === player || player.contains(document.fullscreenElement));
player.classList.toggle('jable-seek-fullscreen-player', Boolean(isFullscreen));
if (isFullscreen) {
panel.classList.remove('is-hidden');
panel.classList.add('jable-seek-fullscreen-host');
panel.classList.remove('is-hover-visible');
const touchFullscreen = isTouchDevice();
const wasTouchFullscreen = panel.classList.contains('is-touch-fullscreen');
panel.classList.toggle('is-touch-fullscreen', touchFullscreen);
if (touchFullscreen && !wasTouchFullscreen) {
panel.classList.add('is-touch-visible');
scheduleTouchHide();
}
if (panel.parentElement !== player) player.appendChild(panel);
} else {
const { host, anchor } = getPanelPlacement(video);
if (!host) return;
for (const previousHost of document.querySelectorAll('.jable-seek-panel-host')) {
if (previousHost !== host) previousHost.classList.remove('jable-seek-panel-host');
}
host.classList.add('jable-seek-panel-host');
panel.classList.remove('jable-seek-fullscreen-host');
panel.classList.remove('is-hover-visible');
panel.classList.remove('is-touch-fullscreen');
panel.classList.remove('is-touch-visible');
window.clearTimeout(touchHideTimer);
panel.classList.toggle('is-hidden', false);
if (panel.parentElement !== host || panel.previousElementSibling !== anchor) {
if (anchor.parentElement !== host) return;
host.insertBefore(panel, anchor.nextSibling);
}
}
updateSpeedButton();
updateFilterButton();
updateResumeControls(video);
if (bookmarkMenuOpen) renderBookmarks();
}
function isEditableTarget(target) {
return target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable;
}
function handleKeyboard(event) {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || isEditableTarget(event.target)) return;
let seconds = 0;
if (event.altKey && event.key === 'ArrowLeft') seconds = -60;
if (event.altKey && event.key === 'ArrowRight') seconds = 60;
if (event.shiftKey && event.key === 'ArrowLeft') seconds = -600;
if (event.shiftKey && event.key === 'ArrowRight') seconds = 600;
if (!event.altKey && !event.shiftKey && event.key === 'ArrowLeft') seconds = -5;
if (!event.altKey && !event.shiftKey && event.key === 'ArrowRight') seconds = 5;
if (!seconds) return;
event.preventDefault();
seekBy(seconds);
}
function scheduleSync() {
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(syncPanel, 100);
}
function handleFullscreenChange() {
syncPanel();
scheduleSync();
}
function handleVisibilityChange() {
if (document.visibilityState !== 'hidden') return;
window.clearTimeout(progressSaveTimer);
progressSaveTimer = 0;
saveProgress();
}
function handleDomMutation() {
scheduleSync();
scheduleTitleTranslation();
}
addStyles();
removeLegacyPanel();
syncPanel();
scheduleTitleTranslation();
document.addEventListener('keydown', handleKeyboard, true);
document.addEventListener('pointerdown', handleDocumentPointerDown, true);
document.addEventListener('pointerdown', handleFullscreenPointerDown, true);
document.addEventListener('keydown', handleDocumentKeydown, true);
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('resize', scheduleSync);
window.addEventListener('beforeunload', () => saveProgress());
new MutationObserver(handleDomMutation).observe(document.documentElement, {
childList: true,
subtree: true,
});
})();