☰

Jable & MissAV Time Jump Buttons

专为 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.

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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,
  });
})();