☰

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.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==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,
  });
})();