☰

JAV Helper

Native subtitle controls and preferred-language SRT selection for MissAV, Jable and 123AV, with JavDB watched, want-to-watch and rating synchronization.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         JAV Helper
// @name:zh-TW   JAV 助手
// @name:zh-CN   JAV 助手
// @namespace    Eddie Userscripts
// @version      1.2.10
// @description  Native subtitle controls and preferred-language SRT selection for MissAV, Jable and 123AV, with JavDB watched, want-to-watch and rating synchronization.
// @description:zh-TW 為 MissAV、Jable、123AV 整合原生風格字幕控制、偏好語言 SRT 自動選取,以及 JavDB 已觀看、想看與評分同步。
// @description:zh-CN 为 MissAV、Jable、123AV 整合原生风格字幕控制、偏好语言 SRT 自动选择,以及 JavDB 已观看、想看与评分同步。
// @author       Eddie Lin
// @license      MIT AND Apache-2.0
// @match        https://missav.ws/*
// @match        https://jable.tv/*
// @match        https://123av.com/*
// @match        https://sadie-shop.site/e/*
// @match        https://javdb.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_log
// @connect      www.subtitlecat.com
// @connect      subtitlecat.com
// @connect      javdb.com
// @run-at       document-idle
// ==/UserScript==

/*
jav-helper third-party notices

Main program
The jav-helper program is licensed under the MIT License by Eddie Lin.
The repository's MIT license is reproduced in the generated userscript and
included as LICENSE-MIT.txt in release packages.

Closed-caption icon
Adapted from Google Material Design Icons, closed_caption, 24px.
Source: https://github.com/google/material-design-icons/blob/master/src/av/closed_caption/materialicons/24px.svg
Upstream project: https://github.com/google/material-design-icons
License: Apache License, Version 2.0.
The complete license is provided in licenses/Apache-2.0.txt, reproduced in the
generated userscript, and included as LICENSE-Apache-2.0.txt in releases.
Modifications: SVG wrapper/accessibility attributes were changed for native
player controls; the unused background path was removed and a few path
coordinates were rounded. These modifications do not imply Google endorsement.

JavDB favicon
The JavDB favicon is loaded from https://javdb.com/favicon-32x32.png for direct
record navigation. The image is not bundled or relicensed as MIT/Apache artwork.
JavDB's name and branding remain their owners' property. jav-helper is not
an official JavDB product and is not affiliated with the supported sites.

Reference userscripts
JAV2DB and Missav字幕小姐 inspired the feature design. Their userscript source
is not bundled in jav-helper; these credits are not a relicensing claim.
https://sleazyfork.org/zh-TW/scripts/535367
https://sleazyfork.org/zh-TW/scripts/549578

MIT LICENSE — jav-helper program

MIT License

Copyright (c) 2025 Eddie Lin

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

APACHE LICENSE 2.0 — adapted closed-caption icon


                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
*/

// Generated by node jav-helper/build.mjs. Edit source modules, not this file.
(async () => {
'use strict';
/* Shared, side-effect-free settings schema. */
const JHSettings = (() => {
  'use strict';
  const defaults = Object.freeze({
    theme: 'dark', fontSize: 26, bottom: 18, subtitleBackground: false,
    subtitleColor: '#ffffff', encoding: 'utf-8', missav: true, jable: true, av123: true,
    skipSeconds: 5, boostRate: 3, forwardKey: 'x', backwardKey: 'c', boostKey: 'z',
    subtitleLanguages: Object.freeze(['zh-TW', 'zh-CN', 'en']), autoSubtitles: true, forceSubtitles: false, javdbSync: true
  });
  function language(value) {
    if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(value)) throw new Error('Use language tags such as zh-TW, zh-CN, en or ja.');
    try { return Intl.getCanonicalLocales(value)[0]; }
    catch { throw new Error(`Invalid language tag: ${value}`); }
  }
  function validate(envelope) {
    if (!envelope || envelope.version !== 2 || !envelope.settings || typeof envelope.settings !== 'object' || Array.isArray(envelope.settings)) throw new Error('Expected a version 2 jav-helper settings file.');
    const value = envelope.settings;
    const keys = Object.keys(defaults);
    if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key))) throw new Error('Settings fields do not match the jav-helper schema.');
    for (const key of keys) {
      if (key !== 'subtitleLanguages' && typeof value[key] !== typeof defaults[key]) throw new Error(`Invalid type for ${key}.`);
    }
    if (!['dark', 'light'].includes(value.theme) || !['utf-8', 'big5', 'gbk'].includes(value.encoding)) throw new Error('Invalid theme or subtitle encoding.');
    for (const [key, min, max, integer] of [['fontSize', 16, 42, true], ['bottom', 10, 35, true], ['skipSeconds', 1, 120, true], ['boostRate', 1, 8, false]]) {
      if (!Number.isFinite(value[key]) || value[key] < min || value[key] > max || (integer && !Number.isInteger(value[key]))) throw new Error(`${key} must be ${integer ? 'an integer' : 'a number'} from ${min} to ${max}.`);
    }
    if (!/^#[0-9a-f]{6}$/i.test(value.subtitleColor)) throw new Error('Subtitle color must be a six-digit hex color.');
    const shortcuts = ['forwardKey', 'backwardKey', 'boostKey'].map(key => value[key]);
    if (shortcuts.some(key => !/^[a-z0-9]$/.test(key)) || new Set(shortcuts).size !== shortcuts.length) throw new Error('Playback keys must be distinct lowercase letters or digits.');
    if (!Array.isArray(value.subtitleLanguages) || value.subtitleLanguages.length < 1 || value.subtitleLanguages.length > 10) throw new Error('Choose between one and ten languages in preference order.');
    const languages = value.subtitleLanguages.map(language);
    if (new Set(languages.map(tag => tag.toLowerCase())).size !== languages.length) throw new Error('Language preferences must not contain duplicates.');
    return { ...Object.fromEntries(keys.map(key => [key, value[key]])), subtitleLanguages: languages };
  }
  function importDocument(envelope) {
    if (envelope?.version !== 1) return validate(envelope);
    const raw = envelope.settings;
    const legacy = ['theme', 'toolbarPosition', 'compact', 'focus', 'fontSize', 'bottom', 'subtitleBackground', 'language', 'encoding', 'missav', 'jable', 'av123', 'subtitleColor', 'skipSeconds', 'boostRate', 'forwardKey', 'backwardKey', 'boostKey', 'customSearch'];
    const required = legacy.slice(0, 12);
    if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !required.every(key => Object.hasOwn(raw, key)) || Object.keys(raw).some(key => !legacy.includes(key))) throw new Error('Invalid legacy settings file.');
    if (!['zh-TW', 'zh-CN', 'en', 'ja'].includes(raw.language)) throw new Error('Invalid legacy subtitle language.');
    const next = { ...defaults };
    for (const key of Object.keys(next)) if (Object.hasOwn(raw, key)) next[key] = raw[key];
    next.subtitleLanguages = [...new Set([raw.language, ...defaults.subtitleLanguages])];
    return validate({ version: 2, settings: next });
  }
  function document(settings) { return { version: 2, settings: validate({ version: 2, settings }) }; }
  return Object.freeze({ defaults, validate, importDocument, document });
})();

/* Local subtitle decoding and a single, video-scoped renderer. */
const JHSubtitles = (() => {
  'use strict';

  function timestamp(value) {
    const match = /^(?:(\d+):)?(\d{2}):(\d{2})[.,](\d{1,3})$/.exec(value);
    if (!match || Number(match[2]) > 59 || Number(match[3]) > 59) return NaN;
    const seconds = Number(match[1] || 0) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4].padEnd(3, '0')) / 1000;
    return Number.isFinite(seconds) ? seconds : NaN;
  }

  function plainText(value) {
    const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', lrm: '\u200e', rlm: '\u200f' };
    return value.replace(/<br\s*\/?\s*>/gi, '\n').replace(/<[^>]*>/g, '')
      .replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (original, entity) => {
        if (entity[0] !== '#') return entities[entity.toLowerCase()] ?? original;
        const number = entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
        return number > 0 && number <= 0x10ffff && !(number >= 0xd800 && number <= 0xdfff) ? String.fromCodePoint(number) : '\ufffd';
      }).replace(/\u0000/g, '').trim();
  }

  function parse(text) {
    if (typeof text !== 'string') throw new TypeError('Subtitle text must be a string.');
    const lines = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n');
    const cues = [];
    for (let index = 0; index < lines.length; index += 1) {
      const line = lines[index].trim();
      if (!line || /^WEBVTT(?:\s|$)/i.test(line)) continue;
      if (/^(NOTE|STYLE|REGION)(?:\s|$)/.test(line)) {
        while (index + 1 < lines.length && lines[index + 1].trim()) index += 1;
        continue;
      }
      const timing = /^(\S+)\s+-->\s+(\S+)(?:\s+.*)?$/.exec(line);
      if (!timing) continue;
      const start = timestamp(timing[1]);
      const end = timestamp(timing[2]);
      const body = [];
      while (index + 1 < lines.length && lines[index + 1].trim()) body.push(lines[++index]);
      if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue;
      const content = plainText(body.join('\n'));
      if (content) cues.push({ start, end, text: content });
    }
    if (!cues.length) throw new Error('No valid SRT or WebVTT subtitle cues found.');
    return cues.sort((a, b) => a.start - b.start);
  }

  function create({ getSettings, getContext, readOffset, writeOffset, notify = () => {} }) {
    let video = null;
    let videoDocument = null;
    let videoWindow = null;
    let overlay = null;
    let nativeTrack = null;
    let nativeLanguage = '';
    const nativeTracks = new WeakMap();
    const ownedTracks = new WeakSet();
    let nativeUnavailable = false;
    let nativeMode = false;
    let webkitFullscreen = false;
    let changingModes = false;
    let destroyed = false;
    let retainedBuffer = null;
    let encoding = '';
    let forcedEncoding = '';
    let subtitleLanguage = '';
    let label = '';
    let source = '';
    let context = '';
    let offsetKey = '';
    let offset = 0;
    let cues = [];
    let prefixEnds = [];
    let text = '';
    let frame = null;
    let timer = null;
    let generation = 0;
    let resizeObserver = null;
    let pendingWrites = Promise.resolve();
    const originalModes = new Map();
    const listeners = [];

    function currentContext() {
      const value = getContext();
      return JSON.stringify([String(value.title || ''), String(value.edition || '')]);
    }

    function decode(buffer, selectedEncoding) {
      return parse(new TextDecoder(selectedEncoding, { fatal: true }).decode(buffer));
    }

    function indexCues(value) {
      cues = value;
      let end = 0;
      prefixEnds = cues.map(cue => (end = Math.max(end, cue.end)));
    }

    function listen(target, name, handler, options) {
      target.addEventListener(name, handler, options);
      listeners.push(() => target.removeEventListener(name, handler, options));
    }

    function stopClock() {
      generation += 1;
      if (frame !== null && video && typeof video.cancelVideoFrameCallback === 'function') video.cancelVideoFrameCallback(frame);
      clearTimeout(timer);
      frame = null;
      timer = null;
    }

    function startClock() {
      if (!video || !cues.length || video.paused || video.ended || destroyed || frame !== null || timer !== null) return;
      const token = generation;
      const next = () => {
        if (token !== generation) return;
        frame = null;
        timer = null;
        render();
        startClock();
      };
      if (typeof video.requestVideoFrameCallback === 'function') frame = video.requestVideoFrameCallback(next);
      else timer = setTimeout(next, 100);
    }

    function suppressNativeTracks() {
      if (!video || !cues.length || changingModes) return;
      changingModes = true;
      try {
        for (const track of Array.from(video.textTracks || [])) {
          if (track === nativeTrack || !['subtitles', 'captions'].includes(track.kind)) continue;
          if (!ownedTracks.has(track) && !originalModes.has(track)) originalModes.set(track, track.mode);
          if (track.mode !== 'disabled') track.mode = 'disabled';
        }
        if (nativeTrack) {
          const desired = nativeMode ? 'showing' : 'disabled';
          if (nativeTrack.mode !== desired) nativeTrack.mode = desired;
        }
      } finally {
        changingModes = false;
      }
    }

    function removeNativeTrack() {
      if (nativeTrack) {
        nativeTrack.mode = 'hidden';
        for (const cue of Array.from(nativeTrack.cues || [])) nativeTrack.removeCue(cue);
        nativeTrack.mode = 'disabled';
      }
      nativeLanguage = '';
      nativeTrack = null;
      nativeUnavailable = false;
      nativeMode = false;
    }

    function restoreNativeTracks() {
      changingModes = true;
      try {
        removeNativeTrack();
        for (const [track, mode] of originalModes) {
          try { track.mode = mode; } catch { /* A removed site track no longer needs restoration. */ }
        }
        originalModes.clear();
      } finally {
        changingModes = false;
      }
    }

    function rebuildNativeCues() {
      if (!nativeTrack) return;
      // Disabled TextTracks expose null cues even when they still own cue objects.
      nativeTrack.mode = 'hidden';
      for (const cue of Array.from(nativeTrack.cues || [])) nativeTrack.removeCue(cue);
      const Cue = videoWindow.VTTCue;
      for (const cue of cues) {
        const end = cue.end + offset;
        const start = Math.max(0, cue.start + offset);
        if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue;
        const safeText = cue.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        nativeTrack.addCue(new Cue(start, end, safeText));
      }
    }

    function ensureNativeTrack() {
      if (nativeTrack) return true;
      if (nativeUnavailable) return false;
      try {
        if (!videoWindow.VTTCue) throw new Error('Native subtitle cues are unavailable.');
        nativeLanguage = subtitleLanguage || getSettings().subtitleLanguages[0];
        let tracks = nativeTracks.get(video);
        if (!tracks) { tracks = new Map(); nativeTracks.set(video, tracks); }
        nativeTrack = tracks.get(nativeLanguage);
        if (!nativeTrack) {
          // An empty <track src> asynchronously fails loading and discards programmatic cues.
          nativeTrack = video.addTextTrack('subtitles', 'jav-helper', nativeLanguage);
          tracks.set(nativeLanguage, nativeTrack);
          ownedTracks.add(nativeTrack);
        }
        nativeTrack.mode = 'hidden';
        rebuildNativeCues();
        return true;
      } catch {
        removeNativeTrack();
        nativeUnavailable = true;
        notify('This player cannot display local subtitles in video-only fullscreen or picture-in-picture.');
        return false;
      }
    }

    function ensureOverlay() {
      if (overlay || !videoDocument) return;
      overlay = videoDocument.createElement('div');
      overlay.setAttribute('aria-hidden', 'true');
      Object.assign(overlay.style, {
        position: 'fixed', zIndex: '2147483646', pointerEvents: 'none',
        display: 'none', boxSizing: 'border-box', textAlign: 'center',
        whiteSpace: 'pre-line', overflowWrap: 'anywhere', lineHeight: '1.35',
        fontFamily: 'sans-serif', fontWeight: '600', padding: '2px 10px',
        textShadow: '0 1px 3px #000, 0 0 5px #000', transform: 'translateX(-50%)'
      });
      applyOverlayStyle();
    }

    function applyOverlayStyle() {
      if (!overlay) return;
      const settings = getSettings();
      overlay.style.fontSize = `${settings.fontSize}px`;
      overlay.style.color = settings.subtitleColor;
      overlay.style.background = settings.subtitleBackground ? 'rgba(0,0,0,0.72)' : 'transparent';
      overlay.style.borderRadius = settings.subtitleBackground ? '4px' : '0';
    }

    function activeText(time) {
      let left = 0;
      let right = cues.length;
      while (left < right) {
        const middle = (left + right) >>> 1;
        if (cues[middle].start <= time) left = middle + 1;
        else right = middle;
      }
      const active = [];
      for (let index = left - 1; index >= 0 && prefixEnds[index] > time; index -= 1) {
        if (cues[index].end > time) active.push(cues[index].text);
      }
      return active.reverse().join('\n');
    }

    function render() {
      if (!video || !cues.length || destroyed) {
        text = '';
        if (overlay) overlay.style.display = 'none';
        return;
      }
      const hasFrame = video.readyState > 0 && !video.ended;
      text = hasFrame && Number.isFinite(video.currentTime) ? activeText(video.currentTime - offset) : '';
      const fullscreen = videoDocument.fullscreenElement || videoDocument.webkitFullscreenElement;
      const videoOnly = fullscreen === video || videoDocument.pictureInPictureElement === video || webkitFullscreen || ['fullscreen', 'picture-in-picture'].includes(video.webkitPresentationMode);
      nativeMode = videoOnly && hasFrame && ensureNativeTrack();
      suppressNativeTracks();
      ensureOverlay();
      const host = fullscreen && fullscreen !== video && fullscreen.contains(video) ? fullscreen : (videoDocument.body || videoDocument.documentElement);
      if (overlay.parentNode !== host) host.appendChild(overlay);
      if (overlay.textContent !== text) overlay.textContent = text;
      const rect = video.getBoundingClientRect();
      const visible = text && !videoOnly && video.isConnected && rect.width > 0 && rect.height > 0;
      overlay.style.display = visible ? 'block' : 'none';
      if (visible) {
        overlay.style.left = `${rect.left + rect.width / 2}px`;
        overlay.style.top = `${rect.top + rect.height * (1 - getSettings().bottom / 100)}px`;
        overlay.style.maxWidth = `${rect.width * 0.94}px`;
        overlay.style.width = 'max-content';
        overlay.style.transform = 'translate(-50%, -100%)';
      }
    }

    function onVideoEvent() {
      render();
      if (!video || video.paused || video.ended) stopClock();
      else startClock();
    }

    function detachVideo() {
      stopClock();
      for (const remove of listeners.splice(0)) remove();
      if (resizeObserver) resizeObserver.disconnect();
      resizeObserver = null;
      restoreNativeTracks();
      if (overlay) overlay.remove();
      overlay = null;
      video = null;
      videoDocument = null;
      videoWindow = null;
      webkitFullscreen = false;
      text = '';
    }

    function setVideo(next) {
      if (destroyed) return;
      if (cues.length && context !== currentContext()) clear();
      if (next === video) return;
      detachVideo();
      if (!next) return;
      video = next;
      videoDocument = video.ownerDocument;
      videoWindow = videoDocument.defaultView || globalThis;
      for (const name of ['timeupdate', 'seeking', 'seeked', 'play', 'playing', 'pause', 'ended', 'loadedmetadata', 'emptied', 'ratechange', 'enterpictureinpicture', 'leavepictureinpicture', 'webkitpresentationmodechanged']) listen(video, name, onVideoEvent);
      listen(video, 'webkitbeginfullscreen', () => { webkitFullscreen = true; onVideoEvent(); });
      listen(video, 'webkitendfullscreen', () => { webkitFullscreen = false; onVideoEvent(); });
      for (const name of ['fullscreenchange', 'webkitfullscreenchange']) listen(videoDocument, name, onVideoEvent);
      listen(videoDocument, 'scroll', render, true);
      listen(videoWindow, 'resize', render);
      if (video.textTracks) {
        listen(video.textTracks, 'addtrack', suppressNativeTracks);
        listen(video.textTracks, 'change', suppressNativeTracks);
      }
      if (videoWindow.ResizeObserver) {
        resizeObserver = new videoWindow.ResizeObserver(render);
        resizeObserver.observe(video);
      }
      onVideoEvent();
    }

    async function load(buffer, nextLabel, nextSource, options = {}) {
      if (destroyed) throw new Error('Subtitle controller has been destroyed.');
      if (Object.prototype.toString.call(buffer) !== '[object ArrayBuffer]') throw new TypeError('Subtitle data must be an ArrayBuffer.');
      const selectedEncoding = options.encoding || getSettings().encoding;
      const parsed = decode(buffer, selectedEncoding);
      const nextContext = currentContext();
      const key = JSON.stringify([nextContext, String(nextSource)]);
      const storedOffset = readOffset(key);
      clear();
      retainedBuffer = buffer;
      encoding = selectedEncoding;
      forcedEncoding = options.encoding || '';
      subtitleLanguage = options.language || '';
      label = String(nextLabel);
      source = String(nextSource);
      context = nextContext;
      offsetKey = key;
      offset = Number.isFinite(storedOffset) ? storedOffset : 0;
      indexCues(parsed);
      onVideoEvent();
      return state();
    }

    function clear() {
      stopClock();
      restoreNativeTracks();
      retainedBuffer = null;
      encoding = '';
      forcedEncoding = '';
      subtitleLanguage = '';
      label = '';
      source = '';
      context = '';
      offsetKey = '';
      offset = 0;
      cues = [];
      prefixEnds = [];
      text = '';
      if (overlay) {
        overlay.textContent = '';
        overlay.style.display = 'none';
      }
    }

    async function setOffset(value) {
      if (destroyed) throw new Error('Subtitle controller has been destroyed.');
      if (!Number.isFinite(value)) throw new TypeError('Subtitle offset must be a finite number.');
      if (!cues.length) throw new Error('Load a subtitle before adjusting its offset.');
      offset = value;
      rebuildNativeCues();
      render();
      const key = offsetKey;
      const write = pendingWrites.catch(() => {}).then(() => writeOffset(key, value));
      pendingWrites = write;
      await write;
      return offset;
    }

    function adjustOffset(delta) {
      if (!Number.isFinite(delta)) return Promise.reject(new TypeError('Subtitle offset adjustment must be finite.'));
      return setOffset(offset + delta);
    }

    function updateStyle() {
      if (destroyed) return false;
      const selectedEncoding = forcedEncoding || getSettings().encoding;
      if (retainedBuffer && selectedEncoding !== encoding) {
        try {
          const parsed = decode(retainedBuffer, selectedEncoding);
          indexCues(parsed);
          encoding = selectedEncoding;
          rebuildNativeCues();
        } catch (error) {
          notify(`Could not decode subtitle as ${selectedEncoding}: ${error.message}`);
          applyOverlayStyle();
          render();
          return false;
        }
      }
      if (nativeTrack && nativeLanguage !== (subtitleLanguage || getSettings().subtitleLanguages[0])) removeNativeTrack();
      applyOverlayStyle();
      render();
      startClock();
      return true;
    }

    function state() { return { label, source, language: subtitleLanguage || getSettings().subtitleLanguages[0], offset, cueCount: cues.length, text }; }

    function destroy() {
      if (destroyed) return;
      clear();
      detachVideo();
      destroyed = true;
    }

    return { setVideo, load, clear, setOffset, adjustOffset, updateStyle, state, destroy };
  }

  return { parse, create };
})();

/* Playback controls for the document that owns the actual video element. */
const JHPlayer = (() => {
  'use strict';

  function create({ getSettings, notify = () => {}, onChange = () => {} }) {
    let video = null;
    let boost = null;
    let heldKey = null;
    let destroyed = false;
    let eventDocument = null;
    let eventWindow = null;

    function fail(message) {
      notify(message);
      return false;
    }

    function reportRate(target = video) {
      if (target && Number.isFinite(target.playbackRate)) onChange({ rate: target.playbackRate });
    }

    function seek(delta) {
      if (destroyed) return false;
      if (!video) return fail('No active video is available.');
      if (!Number.isFinite(delta)) return fail('Seek distance must be a finite number.');
      if (!video.readyState || !Number.isFinite(video.currentTime)) return fail('Video metadata is not available yet.');
      const ranges = [];
      try {
        for (let index = 0; index < video.seekable.length; index += 1) {
          const start = video.seekable.start(index);
          const end = video.seekable.end(index);
          if (Number.isFinite(start) && Number.isFinite(end) && end > start) ranges.push([start, end]);
        }
      } catch {
        return fail('This video does not expose a seekable range.');
      }
      if (!ranges.length && Number.isFinite(video.duration) && video.duration > 0) ranges.push([0, video.duration]);
      if (!ranges.length) return fail('This video is not seekable yet.');
      const requested = video.currentTime + delta;
      let target = ranges[0][0];
      let distance = Infinity;
      for (const [start, end] of ranges) {
        const candidate = Math.max(start, Math.min(end, requested));
        const difference = Math.abs(candidate - requested);
        if (difference < distance || (difference === distance && delta > 0)) {
          target = candidate;
          distance = difference;
        }
      }
      try {
        video.currentTime = target;
        return true;
      } catch (error) {
        return fail(`Could not seek this video: ${error.message}`);
      }
    }

    function assignRate(target, rate) {
      try {
        target.playbackRate = rate;
        if (Math.abs(target.playbackRate - rate) > 0.001) return fail('This player did not accept the requested playback speed.');
        reportRate(target);
        return true;
      } catch (error) {
        return fail(`Could not change playback speed: ${error.message}`);
      }
    }

    function setRate(rate) {
      if (destroyed) return false;
      if (!video) return fail('No active video is available.');
      if (!Number.isFinite(rate) || rate <= 0) return fail('Playback speed must be a positive finite number.');
      if (boost && !boostEnd()) return false;
      return assignRate(video, rate);
    }

    function boostStart() {
      if (destroyed) return false;
      if (boost) return true;
      if (!video) return fail('No active video is available.');
      const rate = getSettings().boostRate;
      if (!Number.isFinite(rate) || rate <= 0) return fail('Boost speed must be a positive finite number.');
      const target = video;
      const originalRate = target.playbackRate;
      if (!Number.isFinite(originalRate) || originalRate <= 0) return fail('The original playback speed is unavailable.');
      if (!assignRate(target, rate)) {
        // Some media engines clamp an unsupported rate instead of throwing.
        try { target.playbackRate = originalRate; } catch { /* The failure has already been reported. */ }
        return false;
      }
      boost = { video: target, rate: originalRate };
      return true;
    }

    function boostEnd() {
      heldKey = null;
      if (!boost) return false;
      const original = boost;
      boost = null;
      return assignRate(original.video, original.rate);
    }

    function editable(event) {
      const path = typeof event.composedPath === 'function' ? event.composedPath() : [event.target];
      return path.some(node => {
        if (!node || node.nodeType !== 1) return false;
        return node.isContentEditable || ['input', 'textarea', 'select', 'button', 'option'].includes(node.localName) ||
          ['textbox', 'combobox', 'spinbutton', 'slider', 'button'].includes(node.getAttribute('role'));
      }) || Boolean(eventDocument && eventDocument.designMode === 'on');
    }

    function keydown(event) {
      if (destroyed || !video || event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || editable(event)) return;
      const key = event.key.toLowerCase();
      const settings = getSettings();
      let handled = false;
      if (key === settings.boostKey) {
        if (event.repeat) handled = Boolean(boost);
        else if (boostStart()) {
          heldKey = { key, code: event.code };
          handled = true;
        }
      } else if (key === settings.forwardKey) handled = seek(settings.skipSeconds);
      else if (key === settings.backwardKey) handled = seek(-settings.skipSeconds);
      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    }

    function keyup(event) {
      if (!heldKey) return;
      if (event.key.toLowerCase() !== heldKey.key && (!heldKey.code || event.code !== heldKey.code)) return;
      boostEnd();
      event.preventDefault();
      event.stopPropagation();
    }

    function release() { boostEnd(); }
    function visibilitychange() {
      if (eventDocument.hidden) release();
    }

    function detachEvents() {
      if (!eventDocument) return;
      eventDocument.removeEventListener('keydown', keydown, true);
      eventDocument.removeEventListener('keyup', keyup, true);
      eventDocument.removeEventListener('visibilitychange', visibilitychange);
      eventWindow.removeEventListener('blur', release);
      eventWindow.removeEventListener('pagehide', release);
      eventDocument = null;
      eventWindow = null;
    }

    function attachEvents(nextDocument) {
      if (nextDocument === eventDocument) return;
      release();
      detachEvents();
      eventDocument = nextDocument;
      eventWindow = nextDocument.defaultView || globalThis;
      eventDocument.addEventListener('keydown', keydown, true);
      eventDocument.addEventListener('keyup', keyup, true);
      eventDocument.addEventListener('visibilitychange', visibilitychange);
      eventWindow.addEventListener('blur', release);
      eventWindow.addEventListener('pagehide', release);
    }

    function ratechange() { reportRate(); }

    function setVideo(next) {
      if (destroyed || video === next) return;
      release();
      if (video) video.removeEventListener('ratechange', ratechange);
      video = next;
      if (video) {
        attachEvents(video.ownerDocument);
        video.addEventListener('ratechange', ratechange);
        reportRate();
      }
    }

    function destroy() {
      if (destroyed) return;
      release();
      if (video) video.removeEventListener('ratechange', ratechange);
      video = null;
      detachEvents();
      destroyed = true;
    }

    attachEvents(document);
    return { setVideo, seek, setRate, boostStart, boostEnd, destroy };
  }

  return { create };
})();

const JHCodes = (() => {
  const separator = '(?:[-_]|\\s+)';
  // Inner separators and release suffixes are identity, not formatting to erase.
  const suffix = '[A-Z]?(?:[-_]\\d{1,8}[A-Z]?)*(?:[-_][A-Z](?![A-Z0-9]))?';
  const payload = `\\d{2,8}${suffix}`;
  const fc2 = new RegExp(`^FC2(?:${separator}?PPV)?${separator}?(\\d{5,9}${suffix})$`);
  const tokyoHot = new RegExp(`^TOKYO${separator}HOT${separator}([NK]\\d{4,8}${suffix})$`);
  const separated = new RegExp(`^([A-Z0-9]{1,18})${separator}(${payload})$`);
  const compact = new RegExp(`^([A-Z]{2,12}|[NK])(\\d{2,8}${suffix})$`);
  const numeric = new RegExp(`^\\d{6,8}[-_]\\d{3,4}${suffix}$`);
  const candidates = new RegExp([
    `FC2(?:${separator}?PPV)?${separator}?\\d{5,9}${suffix}`,
    `TOKYO${separator}HOT${separator}[NK]\\d{4,8}${suffix}`,
    `[A-Z0-9]{1,18}${separator}${payload}`,
    `(?:[A-Z]{2,12}|[NK])\\d{2,8}${suffix}`,
  ].join('|'), 'g');
  const metadataPrefix = /^(?:HEVC|AVC|XVID|DIVX|MPEG|UTF|SHA|MD5|H26[45]|X26[45])$/;
  const month = /^(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)$/;
  const edition = /^(?:UNCENSORED|CENSORED|LEAK|LEAKED|SUB|SUBS|SUBBED|SUBTITLE|SUBTITLES|CHINESE|ENGLISH|JAPANESE|ENG|CHS|CHT|ZH|EN|JA|KO|HD|FHD|UHD)(?:[-_.\s]|$)/;

  function text(value) {
    return String(value ?? '').normalize('NFKC').toUpperCase().replace(/[‐‑‒–—−]/g, '-');
  }

  function normalize(value) {
    const token = text(value).trim();
    if (!token) return '';
    const special = fc2.exec(token);
    if (special) return `FC2-PPV-${special[1]}`;
    // Never reinterpret malformed FC2 identifiers through the generic grammar.
    if (token.startsWith('FC2')) return '';
    const provider = tokyoHot.exec(token);
    if (provider) return `TOKYO-HOT-${provider[1]}`;
    if (numeric.test(token)) return token;
    const match = separated.exec(token) || compact.exec(token);
    if (!match) return '';
    const [, prefix, serial] = match;
    if (!/[A-Z]/.test(prefix) || (/^[A-Z]+$/.test(prefix) && prefix.length > 12)) return '';
    if (prefix.length === 1 && !/^\d{3,8}(?:\D|$)/.test(serial)) return '';
    // Compact one-letter families are explicit: T28617 must not become T28-617.
    if (!separated.test(token) && prefix.length === 1 && !/^[NK]\d{4,8}(?:\D|$)/.test(token)) return '';
    if (metadataPrefix.test(prefix) || (/^[HX]$/.test(prefix) && /^26[45](?:\D|$)/.test(serial))) return '';
    if (/^(?:HD|FHD|UHD|VIDEO)$/.test(prefix) && /^(?:720|1080|1440|2160|4320)P$/.test(serial)) return '';
    if (month.test(prefix) && /^(?:19|20)\d{2}$/.test(serial)) return '';
    if (/^(?:19|20)\d{2}[-_]\d{2}[-_]\d{2}$/.test(serial)) return '';
    return `${prefix}-${serial}`;
  }

  function searchable(value) {
    // A URL contributes only its path. Hosts, tracking parameters and fragments
    // must not supply a different work's identifier, even inside ordinary text.
    const source = text(value).replace(/(?:\b[A-Z][A-Z0-9+.-]*:\/\/|\/\/|\bWWW\.)[^\s<>"']+|\b(?:[A-Z0-9-]+\.)+(?:COM|NET|ORG|TV|WS|TO|IO|ME|JP|CC|XYZ)(?=[/?#\s<>"']|$)[^\s<>"']*/g, raw => {
      try {
        const url = new URL(raw.startsWith('//') ? `HTTPS:${raw}` : raw.includes('://') ? raw : `HTTPS://${raw}`);
        return ` ${url.pathname} `;
      } catch { return ' '; }
    });
    const decoded = source.replace(/(?:%[0-9A-F]{2})+/g, encoded => {
      try { return decodeURIComponent(encoded); } catch { return ' '; }
    });
    return text(decoded).replace(/[?#][^\s<>"']*/g, ' ');
  }

  function scan(value, visit) {
    const source = searchable(value);
    // Each scan gets its own cursor, including when a caller scans recursively.
    const pattern = new RegExp(candidates.source, 'g');
    for (const match of source.matchAll(pattern)) {
      const start = match.index;
      const end = start + match[0].length;
      if (start && /[A-Z0-9_\-=@%]/.test(source[start - 1])) continue;
      if (/[A-Z0-9_=@]/.test(source[end] || '')) continue;
      if (source[end] === '-' && !edition.test(source.slice(end + 1))) continue;
      // A space before a display resolution is prose, not a new series code.
      if (/\s(?:720|1080|1440|2160|4320)P$/.test(match[0])) continue;
      const code = normalize(match[0]);
      if (code && visit(code, match[0], start)) return code;
    }
    return '';
  }

  function extract(value) {
    let best = '';
    let score = -1;
    scan(value, (code, raw) => {
      const rank = code.startsWith('FC2-PPV-') ? 100 : /^\d/.test(code) && !/[A-Z]/.test(code) ? 40 : /[-_\s]/.test(raw) ? 60 : 50;
      if (rank > score) { best = code; score = rank; }
      return false;
    });
    return best;
  }

  function matches(left, right) {
    const expected = normalize(left);
    return Boolean(expected && expected === normalize(right));
  }

  function contains(value, code) {
    const expected = normalize(code);
    return Boolean(expected && scan(value, found => found === expected));
  }

  return { normalize, extract, matches, contains };
})();

/* JavDB synchronization: live forms are the authority; no credentials or tokens are retained. */
const JHJavDB = (() => {
  'use strict';

  const ORIGIN = 'https://javdb.com';
  const SCORE = 'video_review[score]';
  const STATUS = 'video_review[status]';

  function failure(code, message, outcomeUnknown = false) {
    const error = new Error(message);
    error.name = 'JavDBError';
    error.code = code;
    if (outcomeUnknown) error.outcomeUnknown = true;
    return error;
  }


  function requireCode(value) {
    const code = JHCodes.normalize(value);
    if (!code) throw failure('invalid-code', 'A complete movie code is required.');
    return code;
  }

  function safeURL(value, base = ORIGIN) {
    let url;
    try { url = new URL(value, base); } catch { throw failure('invalid-url', 'Invalid JavDB URL.'); }
    if (url.origin !== ORIGIN || url.username || url.password || url.hash) {
      throw failure('invalid-url', 'Only HTTPS javdb.com URLs without credentials are accepted.');
    }
    return url;
  }

  function detailURL(value) {
    const url = safeURL(value);
    if (!/^\/v\/[A-Za-z0-9]+$/.test(url.pathname) || url.search) {
      throw failure('invalid-url', 'A canonical JavDB movie URL is required.');
    }
    return url;
  }

  function baseState(code, url, status) {
    return { code, url, title: '', status, score: 0, retainedScore: 0, publicScore: null, hasContent: false,
      canSave: false, canWant: false, canWatch: false, canDelete: false, supportedScores: [] };
  }

  function responseKind(response, doc, writing = false) {
    const url = safeURL(response.finalUrl);
    const title = doc.title || '';
    if (/^\/(?:login|sign_in)(?:\/|$)/.test(url.pathname)) return 'login';
    if (/^\/plans(?:\/|$)/.test(url.pathname)) return 'vip';
    if (writing && (response.status === 419 || response.status === 422 ||
      /invalid authenticity token|can't verify csrf|csrf token (?:is )?invalid/i.test(doc.body?.textContent || ''))) {
      throw failure('stale-session', 'JavDB rejected the session or form token. Reload before saving again.');
    }
    if (response.status === 401) return 'login';
    if (response.status === 403 || response.status === 429 ||
      /just a moment|attention required|security verification|access denied/i.test(title) ||
      doc.querySelector('#challenge-form, #cf-challenge-running, .cf-challenge, form[action*="/challenge"]')) {
      throw failure('challenge', 'JavDB requires a browser security check or is rate limiting requests. Open JavDB and try again manually.');
    }
    if (response.status === 404 || response.status === 410) return 'not-found';
    if (response.status < 200 || response.status >= 300) {
      throw failure('http', writing ?
        `JavDB save returned HTTP ${response.status}. The account may have changed; use Retry to refresh. No automatic retry was made.` :
        `JavDB read returned HTTP ${response.status}. No automatic retry was made.`, writing);
    }
    return null;
  }

  function formFields(form) {
    const fields = [];
    let submitted = false;
    for (const input of form.querySelectorAll('input[name], textarea[name], select[name], button[name]')) {
      if (input.disabled || input.closest('fieldset[disabled]')) continue;
      const type = (input.getAttribute('type') || '').toLowerCase();
      if (['file', 'reset', 'button', 'image'].includes(type)) continue;
      if ((type === 'radio' || type === 'checkbox') && !input.checked) continue;
      if (type === 'submit' || input.tagName === 'BUTTON') {
        if (submitted) continue;
        submitted = true;
      }
      if (input.tagName === 'SELECT') {
        for (const option of input.selectedOptions) if (!option.disabled) fields.push([input.name, option.value]);
      } else fields.push([input.name, input.value]);
    }
    return fields;
  }

  function observedForm(form, pageURL) {
    if (!form || (form.getAttribute('method') || 'get').toLowerCase() !== 'post') return null;
    let action;
    try { action = safeURL(form.getAttribute('action'), pageURL); } catch { return null; }
    if (action.search) return null;
    const fields = formFields(form);
    const token = fields.find(([name, value]) => name === 'authenticity_token' && value);
    if (!token) return null;
    const methods = fields.filter(([name]) => name === '_method');
    if (methods.length > 1) return null;
    return { url: action.href, path: action.pathname, fields, method: (methods[0]?.[1] || 'post').toLowerCase(), form };
  }

  function scoreChoices(form, currentScore, creating) {
    const scores = new Set();
    for (const input of form.querySelectorAll('input[name="video_review[score]"], select[name="video_review[score]"] option')) {
      if (input.disabled) continue;
      if (input.value === '' || input.value === '0') scores.add(0);
      else if (/^[1-5]$/.test(input.value)) scores.add(Number(input.value));
    }
    // An unchecked new review naturally omits its optional rating; omission cannot clear an existing rating.
    if (creating || currentScore === 0) scores.add(0);
    return Array.from(scores).sort((a, b) => a - b);
  }

  function reviewId(path, pageURL) {
    const prefix = `${new URL(pageURL).pathname}/reviews/`;
    if (!path.startsWith(prefix)) return null;
    const id = path.slice(prefix.length);
    return /^\d+$/.test(id) ? id : null;
  }

  function inspect(doc, pageURL, code) {
    const detail = doc.querySelector('.video-detail');
    const heading = detail?.querySelector('h2.title');
    const foundCode = JHCodes.normalize(heading?.querySelector('strong')?.textContent);
    if (!detail || !foundCode) throw failure('markup', 'JavDB movie details could not be read safely.');
    if (foundCode !== code) throw failure('code-mismatch', 'JavDB returned a different movie code. Nothing was saved.');
    const state = baseState(code, pageURL, 'unmarked');
    state.title = (heading.querySelector('.current-title')?.textContent || heading.textContent).trim();
    const panel = detail.querySelector('.video-meta-panel');
    for (const block of panel?.querySelectorAll('.panel-block') || []) {
      if (!/評分|评分|Rating/i.test(block.querySelector('strong')?.textContent || '')) continue;
      const match = /\d+(?:\.\d+)?/.exec(block.querySelector('.value')?.textContent || '');
      const rating = match ? Number(match[0]) : NaN;
      if (rating >= 0 && rating <= 5) state.publicScore = rating;
      break;
    }
    const loggedIn = Array.from(doc.querySelectorAll('a[href]')).some(link => {
      try { return safeURL(link.getAttribute('href'), pageURL).pathname === '/logout'; } catch { return false; }
    });
    if (!loggedIn) return { state: { ...state, status: 'login' }, operations: {} };
    const controls = panel?.querySelector('nav.movie-panel-info > .review-buttons');
    if (!controls) throw failure('markup', 'JavDB personal controls are unavailable.');

    // Only the movie's personal controls qualify. Public review-list links never establish ownership.
    const titles = Array.from(controls.querySelectorAll('.review-title'));
    const watched = titles.some(title => title.querySelector('.tag.is-success.is-light'));
    const wanted = titles.some(title => title.querySelector('.tag.is-info.is-light'));
    if (watched && wanted) throw failure('markup', 'JavDB returned conflicting personal watch states.');
    if (watched) state.status = 'watched';
    else if (wanted) state.status = 'wanted';

    const forms = Array.from(controls.querySelectorAll('form')).map(form => observedForm(form, pageURL)).filter(Boolean);
    const root = new URL(pageURL).pathname;
    const createForms = forms.filter(form => form.path === `${root}/reviews` && form.method === 'post' &&
      form.form.id === 'new_review' && form.form.closest('#modal-review-watched'));
    const patchForms = forms.filter(form => reviewId(form.path, pageURL) && ['patch', 'put'].includes(form.method) &&
      form.form.id === 'edit_review' && form.form.closest('#modal-review-edit'));
    const ownLinks = (titles.length ? Array.from(controls.querySelectorAll('a[data-method="delete"][href]')) : []).filter(link => {
      try {
        const target = safeURL(link.getAttribute('href'), pageURL);
        return !target.search && reviewId(target.pathname, pageURL);
      } catch { return false; }
    });
    const ownPaths = new Set(ownLinks.map(link => safeURL(link.getAttribute('href'), pageURL).pathname));
    const patches = patchForms.filter(form => ownPaths.has(form.path));
    const review = patches.length === 1 ? patches[0] : null;
    const personalStatus = review?.fields.find(([name]) => name === STATUS)?.[1];
    if (personalStatus === 'watched') state.status = 'watched';
    else if (personalStatus === 'want_watch') state.status = 'wanted';
    if (patchForms.length && state.status === 'unmarked') {
      throw failure('markup', 'JavDB existing personal review state could not be established.');
    }
    const contentInput = review?.form.querySelector('textarea[name="video_review[content]"]');
    const checked = review?.form.querySelector('input[name="video_review[score]"]:checked, select[name="video_review[score]"]');
    let storedScore = 0;
    if (checked) {
      if (!/^[0-5]$/.test(checked.value) && checked.value !== '') throw failure('markup', 'JavDB personal rating is invalid.');
      storedScore = Number(checked.value);
      state.retainedScore = storedScore;
      if (state.status === 'watched') state.score = storedScore;
    }
    // Unknown review content is treated as content, not as permission to erase it.
    state.hasContent = state.status !== 'unmarked' && (!contentInput || Boolean(contentInput.value.trim()));
    const operations = {};
    const csrfName = doc.querySelector('meta[name="csrf-param"]')?.getAttribute('content');
    const csrfToken = doc.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
    if (state.status !== 'unmarked' && ownPaths.size === 1 && csrfName === 'authenticity_token' && csrfToken) {
      const path = Array.from(ownPaths)[0];
      operations.remove = { url: `${ORIGIN}${path}`, fields: [['_method', 'delete'], [csrfName, csrfToken]] };
    }
    const wants = forms.filter(form => form.path === `${root}/reviews/want_to_watch` && form.method === 'post' &&
      form.form.classList.contains('button_to'));
    // Avoid duplicate review creation or clobbering comments through the quick-want endpoint.
    if (wants.length === 1 && state.status === 'unmarked') operations.want = wants[0];
    const watch = state.status === 'unmarked' && createForms.length === 1 ? createForms[0] : review;
    if (watch && watch.form.querySelector('textarea[name="video_review[content]"]')) {
      const statuses = Array.from(watch.form.querySelectorAll('input[name="video_review[status]"], select[name="video_review[status]"] option'))
        .filter(input => !input.disabled).map(input => input.value);
      if (statuses.includes('watched')) {
        operations.watch = { ...watch, wireStatus: 'watched' };
        state.supportedScores = scoreChoices(watch.form, storedScore, state.status === 'unmarked');
      }
      if (review && statuses.includes('want_watch')) operations.want = { ...review, wireStatus: 'want_watch' };
    }
    state.canWant = Boolean(operations.want) || state.status === 'wanted';
    state.canWatch = Boolean(operations.watch);
    state.canDelete = Boolean(operations.remove);
    state.canSave = state.canWant || state.canWatch || state.canDelete;
    return { state, operations };
  }

  function planSave(state, operations, desired) {
    if (!desired || !['unmarked', 'wanted', 'watched'].includes(desired.status) ||
      !Number.isInteger(desired.score) || desired.score < 0 || desired.score > 5 ||
      (desired.status !== 'watched' && desired.score !== 0)) {
      throw failure('invalid-state', 'Choose a valid watch state and an integer rating from 0 to 5.');
    }
    if (!['unmarked', 'wanted', 'watched'].includes(state.status)) {
      throw failure(state.status, state.status === 'login' ? 'Sign in to JavDB before saving.' : 'This JavDB record is unavailable for saving.');
    }
    if (state.status === desired.status && state.score === desired.score) return null;
    if (desired.status === 'unmarked') {
      if (!operations.remove) throw failure('unsupported', 'JavDB did not expose an owned delete operation. Nothing was saved.');
      if (state.hasContent && desired.confirmDelete !== true) {
        throw failure('confirmation-required', 'Clearing this mark also deletes your existing written review. Confirm deletion first.');
      }
      return { url: operations.remove.url, fields: operations.remove.fields.map(pair => pair.slice()) };
    }
    const operation = desired.status === 'wanted' ? operations.want : operations.watch;
    if (!operation) throw failure('unsupported', 'JavDB did not expose this transition. Clear the existing mark explicitly before adding another.');
    const fields = operation.fields.map(pair => pair.slice());
    if (operation.wireStatus) {
      for (let index = fields.length - 1; index >= 0; index -= 1) if (fields[index][0] === STATUS) fields.splice(index, 1);
      fields.push([STATUS, operation.wireStatus]);
    }
    if (desired.status === 'watched') {
      if (!state.supportedScores.includes(desired.score)) {
        throw failure('unsupported', 'JavDB did not expose this rating value. Clear the mark explicitly to remove an existing rating.');
      }
      for (let index = fields.length - 1; index >= 0; index -= 1) if (fields[index][0] === SCORE) fields.splice(index, 1);
      if (desired.score > 0) fields.push([SCORE, String(desired.score)]);
      else {
        const blank = operation.form.querySelector('input[name="video_review[score]"][value=""], select[name="video_review[score]"] option[value=""]');
        const zero = operation.form.querySelector('input[name="video_review[score]"][value="0"], select[name="video_review[score]"] option[value="0"]');
        if (blank || zero) fields.push([SCORE, (blank || zero).value]);
      }
      // CONTENT remains exactly as serialized from the freshly loaded form.
    }
    return { url: operation.url, fields };
  }

  function planNativeAction(state, action, confirmDelete = false) {
    if (!['play', 'favorite-on', 'favorite-off'].includes(action)) {
      throw failure('invalid-state', 'Unknown native synchronization action.');
    }
    if (!['unmarked', 'wanted', 'watched'].includes(state.status)) {
      throw failure(state.status, state.status === 'login' ? 'Sign in to JavDB before synchronizing.' : 'Choose an available JavDB record before synchronizing.');
    }
    if (state.status === 'watched') return null;
    if (action === 'play') return { status: 'watched', score: state.retainedScore || state.score || 0 };
    if (action === 'favorite-on') return state.status === 'wanted' ? null : { status: 'wanted', score: 0 };
    if (state.status !== 'wanted') return null;
    if (state.retainedScore || state.score) {
      throw failure('protected-rating', 'JavDB Want was kept because removing it would delete a saved rating. Rate or play it to mark Watched, or manage the record on JavDB.');
    }
    return { status: 'unmarked', score: 0, confirmDelete };
  }

  function create({ request }) {
    async function fetchPage(url, writing = false, fields) {
      let response;
      try {
        response = await request({ url, method: writing ? 'POST' : 'GET', responseType: 'text',
          headers: writing ? { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
            Accept: 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest' } :
            { Accept: 'text/html', 'Cache-Control': 'no-cache' },
          ...(writing ? { data: new URLSearchParams(fields).toString() } : {}) });
      } catch {
        throw failure('network', writing ? 'The JavDB save response was lost. The account may have changed; reload before saving again.' :
          'JavDB could not be reached. Check the connection and try again manually.', writing);
      }
      if (typeof response.text !== 'string' || !response.finalUrl) throw failure('response', 'JavDB returned an unreadable response.', writing);
      const doc = new DOMParser().parseFromString(response.text, 'text/html');
      return { response, doc, kind: responseKind(response, doc, writing) };
    }

    async function read(url, code) {
      const expected = detailURL(url);
      const page = await fetchPage(expected.href);
      if (page.kind) return { state: baseState(code, expected.href, page.kind), operations: {} };
      const final = safeURL(page.response.finalUrl);
      if (final.pathname !== expected.pathname) throw failure('redirect', 'JavDB redirected away from the selected movie. Nothing was saved.');
      return inspect(page.doc, expected.href, code);
    }

    async function load(url, code) { return (await read(url, requireCode(code))).state; }

    async function lookup(value) {
      const code = requireCode(value);
      const matches = new Map();
      let url = `${ORIGIN}/search?q=${encodeURIComponent(code)}&f=all`;
      const visited = new Set();
      for (let pageNumber = 0; pageNumber < 5; pageNumber += 1) {
        if (visited.has(url)) throw failure('markup', 'JavDB search pagination is invalid.');
        visited.add(url);
        const page = await fetchPage(url);
        if (page.kind) return baseState(code, '', page.kind);
        const final = safeURL(page.response.finalUrl);
        if (final.pathname !== '/search' || JHCodes.normalize(final.searchParams.get('q')) !== code ||
          (final.searchParams.get('f') || 'all') !== 'all') {
          throw failure('redirect', 'JavDB redirected away from the requested search.');
        }
        const list = page.doc.querySelector('.movie-list');
        const empty = page.doc.querySelector('.empty-message');
        if (!list && !empty) throw failure('markup', 'JavDB search results are unavailable or blocked.');
        if (list && !empty && !list.querySelector('.item a.box[href] .video-title strong')) {
          throw failure('markup', 'JavDB returned an incomplete search list, not an explicit empty result.');
        }
        for (const card of list?.querySelectorAll('.item a.box[href]') || []) {
          if (JHCodes.normalize(card.querySelector('.video-title strong')?.textContent) !== code) continue;
          const target = detailURL(new URL(card.getAttribute('href'), final.href).href);
          matches.set(target.href, { url: target.href, code, title: (card.getAttribute('title') || card.querySelector('.video-title')?.textContent || code).trim() });
        }
        if (matches.size > 1) return { status: 'ambiguous', code, matches: Array.from(matches.values()) };
        const next = page.doc.querySelector('a.pagination-next[href]:not([disabled]):not(.is-disabled), .pagination a[rel="next"][href]');
        if (!next) {
          if (matches.size === 1) return load(Array.from(matches.keys())[0], code);
          return baseState(code, '', 'not-found');
        }
        const target = safeURL(next.getAttribute('href'), final.href);
        if (target.pathname !== '/search' || JHCodes.normalize(target.searchParams.get('q')) !== code ||
          (target.searchParams.get('f') || 'all') !== 'all') throw failure('markup', 'JavDB search pagination changed the requested query.');
        url = target.href;
      }
      throw failure('incomplete-search', 'JavDB search exceeded five pages; an unambiguous exact match could not be established.');
    }

    async function commit(url, code, current, desired, options) {
      let operation;
      try { operation = planSave(current.state, current.operations, desired); }
      catch (error) {
        if (error.code === 'confirmation-required') error.state = current.state;
        throw error;
      }
      if (!operation || (options.isCurrent && !options.isCurrent())) return current.state;
      const result = await fetchPage(operation.url, true, operation.fields);
      if (result.kind) throw failure(result.kind === 'login' ? 'stale-session' : result.kind,
        result.kind === 'login' ? 'The JavDB session expired. Sign in and reload before saving again.' : 'JavDB did not accept the save. Reload the record before continuing.');
      const final = safeURL(result.response.finalUrl);
      const record = detailURL(url);
      if (final.pathname !== record.pathname && final.pathname !== new URL(operation.url).pathname) {
        throw failure('redirect', 'JavDB returned an unexpected save destination. Reload to check the account state.', true);
      }
      const verified = await read(url, code);
      if (verified.state.status !== desired.status || verified.state.score !== desired.score) {
        throw failure('verification-failed', 'JavDB did not confirm the requested watch state and rating. Reload before saving again.', true);
      }
      return verified.state;
    }

    async function save(url, value, desired, options = {}) {
      const code = requireCode(value);
      return commit(url, code, await read(url, code), desired, options);
    }

    async function synchronize(url, value, action, options = {}) {
      const code = requireCode(value);
      const current = await read(url, code);
      if (options.isCurrent && !options.isCurrent()) return current.state;
      const desired = planNativeAction(current.state, action, options.confirmDelete === true);
      return desired ? commit(url, code, current, desired, options) : current.state;
    }

    return { lookup, load, save, synchronize };
  }

  return { create, planSave, planNativeAction };
})();

/* Public Subtitle Cat catalogue discovery; never invokes translation or download actions. */
const JHSubtitleCat = (() => {
  'use strict';

  const ORIGIN = 'https://www.subtitlecat.com';
  const MAX_CATALOGUES = 10;
  const POSITIVE_TTL = 6 * 60 * 60 * 1000;
  const NEGATIVE_TTL = 15 * 60 * 1000;
  const LANGUAGE_NAMES = {
    'chinese (traditional)': 'zh-TW', 'traditional chinese': 'zh-TW',
    'chinese (simplified)': 'zh-CN', 'simplified chinese': 'zh-CN',
    english: 'en', japanese: 'ja', korean: 'ko', chinese: 'zh',
    hebrew: 'he', indonesian: 'id', javanese: 'jv', filipino: 'tl'
  };

  function normalizeLanguage(value) {
    if (typeof value !== 'string') return '';
    const text = value.trim().replace(/_/g, '-').toLowerCase();
    if (Object.hasOwn(LANGUAGE_NAMES, text)) return LANGUAGE_NAMES[text];
    if (!/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/.test(text)) return '';
    const parts = text.split('-');
    parts[0] = ({ iw: 'he', in: 'id', ji: 'yi', jw: 'jv', fil: 'tl' })[parts[0]] || parts[0];
    if (parts[0] === 'zh') {
      // Script is more specific than a conflicting region; generic Chinese stays ambiguous.
      if (parts.includes('hant')) return 'zh-TW';
      if (parts.includes('hans')) return 'zh-CN';
      if (parts.some(part => part === 'tw' || part === 'hk' || part === 'mo')) return 'zh-TW';
      if (parts.some(part => part === 'cn' || part === 'sg')) return 'zh-CN';
    }
    return parts.map((part, index) => index === 0 ? part : /^[a-z]{4}$/.test(part)
      ? part[0].toUpperCase() + part.slice(1) : /^[a-z]{2}$/.test(part) ? part.toUpperCase() : part).join('-');
  }

  function languageRank(language, languages) {
    const candidate = normalizeLanguage(language);
    if (!candidate) return -1;
    return languages.findIndex(value => {
      const preferred = normalizeLanguage(value);
      return preferred === candidate || (preferred.split('-')[0] !== 'zh' && preferred.includes('-') && candidate === preferred.split('-')[0]);
    });
  }


  function decode(value) {
    try { return decodeURIComponent(value); } catch { return value; }
  }

  function editionScore(label, edition, code = '') {
    const canonical = JHCodes.normalize(code).toLowerCase();
    const codeWords = new Set([...canonical.split(/[-_]/), canonical.replace(/[-_]/g, '')]);
    const ignored = new Set(['videos', 'video', 'html', 'https', 'www', 'com', 'en', 'zh', 'tw', 'cn', 'subs']);
    const tokens = value => new Set((decode(String(value || '')).toLowerCase().match(/[a-z0-9]+|[\u3400-\u9fff]+/g) || [])
      .map(word => ({ leaked: 'leak', subbed: 'subtitles', subtitle: 'subtitles' })[word] || word)
      .filter(word => word.length > 1 && !/^\d+$/.test(word) && !codeWords.has(word) && !ignored.has(word)));
    const wanted = tokens(edition);
    let score = 0;
    for (const word of tokens(label)) if (wanted.has(word)) score += 1;
    return score;
  }

  function chooseDownload(candidates, languages, { edition = '', code = '' } = {}) {
    let best = null;
    let bestRank = Infinity;
    let bestEdition = -1;
    let bestExact = false;
    for (const candidate of candidates) {
      const rank = languageRank(candidate.language, languages);
      if (rank < 0) continue;
      const score = editionScore(candidate.catalogue || candidate.label, edition, code);
      const exact = normalizeLanguage(candidate.language) === normalizeLanguage(languages[rank]);
      if (rank < bestRank || (rank === bestRank && (score > bestEdition || (score === bestEdition && exact && !bestExact)))) {
        best = candidate;
        bestRank = rank;
        bestEdition = score;
        bestExact = exact;
      }
    }
    return best;
  }

  function failure(message, code = 'SUBTITLECAT_MARKUP') {
    const error = new Error(`Subtitle Cat: ${message}`);
    error.code = code;
    return error;
  }

  function publicUrl(value, base, kind) {
    try {
      const url = new URL(value, base);
      if (url.origin !== ORIGIN || url.username || url.password || url.hash) return null;
      if (kind === 'search') {
        if (!['/', '/index.php'].includes(url.pathname) || [...url.searchParams.keys()].some(key => !['search', 'show'].includes(key))) return null;
      } else if (url.search || !new RegExp(`^/subs/\\d+/[^/]+\\.${kind === 'detail' ? 'html' : 'srt'}$`, 'i').test(url.pathname)) return null;
      return url;
    } catch { return null; }
  }

  function filename(url) {
    return decode(url.pathname.slice(url.pathname.lastIndexOf('/') + 1));
  }

  function matchesDownload(name, language, code) {
    const expected = normalizeLanguage(language);
    if (!expected || !/\.srt$/i.test(name)) return false;
    const parts = name.slice(0, -4).split('-');
    for (let index = parts.length - 1; index > 0; index -= 1) {
      if (normalizeLanguage(parts.slice(index).join('-')) === expected &&
          JHCodes.contains(parts.slice(0, index).join('-'), code)) return true;
    }
    return false;
  }

  function documentFrom(text) {
    if (typeof text !== 'string' || !text.trim()) throw failure('empty response.');
    const doc = new DOMParser().parseFromString(text, 'text/html');
    if (doc.querySelector('#challenge-form, #cf-challenge-running, .g-recaptcha, iframe[src*="captcha"]') || /just a moment|attention required|verify (?:you are|that you are) human|access denied/i.test(doc.title)) {
      throw failure('access challenge; open the public site and try later.', 'SUBTITLECAT_CHALLENGE');
    }
    return doc;
  }

  function parseSearch(doc, url, code) {
    const table = doc.querySelector('.subtitles table.sub-table');
    const heading = table?.querySelector('.sec-title');
    const countMatch = /(?:^|\s)([\d,]+)\s+subtitles?\s+found\b/i.exec(heading?.textContent || '');
    const field = doc.querySelector('.top-search input[name="search"]');
    if (JHCodes.normalize(field?.value) !== code) throw failure('search query does not match the requested code.');
    if (!table && /^Nothing found$/i.test(doc.querySelector('.subtitles #nores .nores__title')?.textContent.trim() || '')) {
      return { catalogues: [], complete: true, more: null };
    }
    if (!table || !countMatch) throw failure('unrecognized search results.');
    const total = Number(countMatch[1].replace(/,/g, ''));
    const catalogues = [];
    const seen = new Set();
    for (const row of table.querySelectorAll('tbody tr')) {
      const anchor = row.querySelector('td:first-child a[href]');
      const detail = anchor && publicUrl(anchor.getAttribute('href'), url, 'detail');
      if (!detail) throw failure('invalid catalogue search row.');
      if (seen.has(detail.href)) continue;
      seen.add(detail.href);
      const title = anchor.textContent.trim();
      if (JHCodes.contains(title, code) && JHCodes.contains(filename(detail), code)) catalogues.push({ url: detail.href, title });
    }
    if (!Number.isSafeInteger(total) || total < seen.size || (total > 0 && !seen.size)) throw failure('inconsistent search result count.');
    let more = null;
    const link = doc.querySelector('.subtitles .load-more-wrap a[href]');
    if (link) {
      const next = publicUrl(link.getAttribute('href'), url, 'search');
      if (next && JHCodes.normalize(next.searchParams.get('search')) === code && /^\d+$/.test(next.searchParams.get('show') || '')) more = next.href;
    }
    return { catalogues, complete: seen.size === total, more };
  }

  function parseDetail(doc, detailUrl, code) {
    const root = doc.querySelector('.all-sub');
    const titleMatch = /^All language subtitles for\s+(.+)$/i.exec(root?.querySelector('h2.sec-title')?.textContent.trim() || '');
    if (!titleMatch || !JHCodes.contains(titleMatch[1], code)) throw failure('catalogue identity does not match the requested code.');
    const rows = root.querySelectorAll('.sub-single');
    if (!rows.length) throw failure('missing catalogue language rows.');
    const downloads = [];
    for (const row of rows) {
      const spans = [...row.children].filter(child => child.tagName === 'SPAN');
      const label = spans[1]?.textContent.trim() || '';
      const flagLanguage = normalizeLanguage(row.querySelector('img.flag[alt]')?.getAttribute('alt'));
      const language = flagLanguage || normalizeLanguage(label);
      if (!language) throw failure('unrecognized language row.');
      let available = false;
      for (const anchor of row.querySelectorAll('a[href]')) {
        if (!/^download$/i.test(anchor.textContent.trim())) continue;
        const download = publicUrl(anchor.getAttribute('href'), detailUrl, 'srt');
        if (!download) throw failure('invalid subtitle download URL.');
        // Validate the row language against the actual filename, never synthesize a suffix.
        if (!matchesDownload(filename(download), language, code)) throw failure('download code or language does not match its row.');
        downloads.push({ url: download.href, language, label: label || language, detailUrl, catalogue: titleMatch[1] });
        available = true;
      }
      if (!available && ![...row.querySelectorAll('button, a')].some(control => /^translate$/i.test(control.textContent.trim()))) {
        throw failure('language row has neither an existing download nor a translation action.');
      }
    }
    return downloads;
  }

  function resultOnly(candidate) {
    return candidate && { url: candidate.url, language: candidate.language, label: candidate.label, detailUrl: candidate.detailUrl };
  }

  function resolveDiscovery(candidates, languages, { edition = '', code = '', complete = false } = {}) {
    const best = chooseDownload(candidates, languages, { edition, code });
    if (!complete && (!best || languageRank(best.language, languages) !== 0)) {
      throw failure('discovery is incomplete (at most 10 exact-code catalogues are scanned); no primary-language download was confirmed.', 'SUBTITLECAT_INCOMPLETE');
    }
    return resultOnly(best);
  }

  function create({ request, readCache, writeCache, wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)) }) {
    if (typeof request !== 'function') throw new TypeError('Subtitle Cat requires a request transport.');
    const memory = new Map();
    let queue = Promise.resolve();
    let requested = false;

    async function load(url, kind, code) {
      if (requested) await wait(1200);
      requested = true;
      const response = await request({ url, method: 'GET', responseType: 'text', anonymous: true });
      if (response.status !== 200) throw failure(`HTTP ${response.status}.`, 'SUBTITLECAT_HTTP');
      const final = publicUrl(response.finalUrl || url, url, kind);
      if (!final || (kind === 'detail' ? final.href !== url : JHCodes.normalize(final.searchParams.get('search')) !== code)) {
        throw failure('unexpected public-page redirect.');
      }
      return { doc: documentFrom(response.text), url: final.href };
    }

    async function discover(inputCode, inputLanguages, edition) {
      const code = JHCodes.normalize(inputCode);
      if (!code) throw new TypeError('Subtitle Cat requires an exact video code.');
      if (!Array.isArray(inputLanguages) || !inputLanguages.length) throw new TypeError('Subtitle Cat requires ordered language preferences.');
      const languages = [...new Set(inputLanguages.map(normalizeLanguage))];
      if (languages.includes('')) throw new TypeError('Invalid subtitle language preference.');
      if (typeof edition !== 'string') throw new TypeError('Subtitle edition must be a string.');
      const key = `subtitlecat:v1:${JSON.stringify([code, languages, edition])}`;
      const cached = readCache ? await readCache(key) : memory.get(key);
      const age = Date.now() - cached?.at;
      if (cached?.version === 1 && age >= 0 && age < (cached.result === null ? NEGATIVE_TTL : POSITIVE_TTL)) {
        if (cached.result === null) return null;
        const result = cached.result;
        const detail = publicUrl(result?.detailUrl, ORIGIN, 'detail');
        const download = publicUrl(result?.url, ORIGIN, 'srt');
        if (detail && download && JHCodes.contains(filename(detail), code) && matchesDownload(filename(download), result.language, code) && languageRank(result.language, languages) >= 0 && typeof result.label === 'string') {
          return resultOnly(result);
        }
      }

      const first = await load(`${ORIGIN}/index.php?search=${encodeURIComponent(code)}`, 'search', code);
      let search = parseSearch(first.doc, first.url, code);
      if (!search.complete && search.more) {
        const expanded = await load(search.more, 'search', code);
        search = parseSearch(expanded.doc, expanded.url, code);
      }
      const catalogues = search.catalogues.sort((a, b) => editionScore(b.title, edition, code) - editionScore(a.title, edition, code));
      const candidates = [];
      let best = null;
      for (const catalogue of catalogues.slice(0, MAX_CATALOGUES)) {
        const page = await load(catalogue.url, 'detail', code);
        candidates.push(...parseDetail(page.doc, page.url, code));
        best = chooseDownload(candidates, languages, { edition, code });
        // Higher edition matches were visited first; no later catalogue can improve this language.
        if (best && languageRank(best.language, languages) === 0 && normalizeLanguage(best.language) === languages[0]) break;
      }
      const complete = search.complete && catalogues.length <= MAX_CATALOGUES;
      const result = resolveDiscovery(candidates, languages, { edition, code, complete });
      const entry = { version: 1, at: Date.now(), result };
      if (writeCache) await writeCache(key, entry);
      else memory.set(key, entry);
      return result;
    }

    function find(code, languages, { edition = '' } = {}) {
      const pending = queue.then(() => discover(code, languages, edition));
      queue = pending.catch(() => {});
      return pending;
    }

    return { find };
  }

  return { create, normalizeLanguage, languageRank, editionScore, chooseDownload, matchesDownload, resolveDiscovery };
})();

/* Native actions are observations only: this adapter never clicks or sends site requests. */
const JHNativeSite = (() => {
  'use strict';

  const ACTION_TIMEOUT = 15000;
  const SETTLE_TIME = 150;

  function create({ hostName, getVideo, onPlay = () => {}, onFavorite = () => {} }) {
    const supported = ['missav.ws', 'jable.tv', '123av.com', 'sadie-shop.site'].includes(hostName);
    let destroyed = false;
    let favorite = null;
    let video = null;
    let playIntent = null;
    let favoriteIntent = null;
    let playTimer = null;
    let favoriteTimer = null;

    const pageKey = () => location.pathname + location.search;
    const elementTarget = event => event.target?.nodeType === 1 ? event.target : event.target?.parentElement;

    function findFavorite() {
      let candidates = [];
      if (hostName === 'missav.ws') {
        candidates = [...document.querySelectorAll('button')].filter(button =>
          button.textContent.trim() === '收藏' &&
          button.getAttribute('@click.prevent') === 'toggleSave' &&
          /\bsaved\b/.test(button.getAttribute(':class') || '') &&
          button.parentElement?.matches('.flex.flex-wrap.justify-center')
        );
      } else if (hostName === 'jable.tv') {
        candidates = [...document.querySelectorAll('.video-info button.fav[data-fav-video-id][data-fav-type="0"]')]
          .filter(button => /^\d+$/.test(button.getAttribute('data-fav-video-id')));
      } else if (hostName === '123av.com') {
        candidates = [...document.querySelectorAll('.watch__head .watch__meta--stats button.watch__save')]
          .filter(button => /\bsave\s*\(/.test(button.getAttribute('x-data') || '') &&
            /\btype\s*:\s*['"]title['"]/.test(button.getAttribute('x-data') || ''));
      }
      return candidates.length === 1 ? candidates[0] : null;
    }

    function missavOwner(element) {
      for (let owner = element?.parentElement; owner; owner = owner.parentElement) {
        const expression = owner.getAttribute('x-data') || '';
        if (!expression.includes('toggleSave') || !expression.includes('dvdId')) continue;
        // Alpine's existing reactive state is read, never modified or evaluated.
        const state = owner._x_dataStack?.find(value =>
          typeof value.saved === 'boolean' && typeof value.loading === 'boolean');
        return state ? { owner, state } : null;
      }
      return null;
    }

    function favoriteKey(element) {
      if (hostName === 'jable.tv') return element.getAttribute('data-fav-video-id');
      if (hostName === '123av.com') return element.getAttribute('x-data');
      return missavOwner(element)?.owner.getAttribute('x-data') || null;
    }

    function savedState(element) {
      if (hostName === 'jable.tv') return element.classList.contains('active');
      if (hostName === '123av.com') {
        const saved = element.classList.contains('is-on');
        const label = element.getAttribute('aria-label');
        return label === (saved ? '已儲存' : '儲存') ? saved : null;
      }
      if (hostName === 'missav.ws') {
        const saved = element.classList.contains('text-primary');
        const unsaved = element.classList.contains('text-nord4');
        return saved !== unsaved ? saved : null;
      }
      return null;
    }

    function favoriteCount(element) {
      if (hostName === '123av.com') {
        const state = element._x_dataStack?.find(value => Number.isFinite(value.followCount));
        if (state) return state.followCount;
      }
      const text = element.querySelector(hostName === 'jable.tv' ? '.count' : '[x-text="followLabel"]')?.textContent.trim();
      if (hostName === '123av.com' && text === '儲存') return 0;
      return text && /^\d+$/.test(text) ? Number(text) : null;
    }

    function clearPlay() {
      playIntent = null;
      clearTimeout(playTimer);
      playTimer = null;
    }

    function clearFavorite() {
      clearTimeout(favoriteIntent?.startTimer);
      favoriteIntent = null;
      clearTimeout(favoriteTimer);
      favoriteTimer = null;
      favoriteObserver.takeRecords();
    }

    function reset() {
      clearPlay();
      clearFavorite();
    }

    function scan() {
      if (destroyed || !supported) return null;
      const nextVideo = getVideo();
      if (nextVideo !== video) {
        reset();
        video = nextVideo?.isConnected ? nextVideo : null;
      }
      const nextFavorite = findFavorite();
      if (nextFavorite !== favorite) {
        clearFavorite();
        favoriteObserver.disconnect();
        favorite = nextFavorite;
        if (favorite) favoriteObserver.observe(favorite, {
          attributes: true, attributeOldValue: true, childList: true, characterData: true, subtree: true,
        });
      }
      return favorite;
    }

    function checkFavorite(records = []) {
      const intent = favoriteIntent;
      if (!intent) return;
      clearTimeout(favoriteTimer);
      favoriteTimer = null;
      if (destroyed || Date.now() > intent.deadline || intent.page !== pageKey() ||
          !favorite?.isConnected || favorite !== intent.element || findFavorite() !== favorite ||
          intent.key !== favoriteKey(favorite)) {
        clearFavorite();
        return;
      }
      let busy;
      if (hostName === 'missav.ws') {
        const current = missavOwner(favorite);
        if (!current || current.state !== intent.state) { clearFavorite(); return; }
        busy = current.state.loading;
        // saved changes optimistically, but loading becomes false ONLY in axios.then.
        // A rejected request leaves loading true: it must never reach JavDB.
        if (busy && current.state.saved === !intent.before) intent.sawBusy = true;
      } else {
        busy = favorite.disabled;
        if (busy || records.some(record => record.target === favorite && record.attributeName === 'disabled' && record.oldValue !== null)) {
          intent.sawBusy = true;
        }
      }
      const saved = savedState(favorite);
      const changed = saved !== null && saved === !intent.before;
      if (!busy && saved === intent.before && (intent.dispatched || intent.sawBusy)) {
        // Login prompts and failed requests did not save anything. A later
        // deliberate retry must start a new intent instead of being swallowed.
        clearFavorite();
        return;
      }
      // Hydration changes saved but never the count. Use Alpine's exact followCount
      // when 123AV renders an abbreviated label, so hydration cannot mimic a save.
      const count = favoriteCount(favorite);
      const countMatches = hostName === 'missav.ws' ||
        (intent.count !== null && count === Math.max(0, intent.count + (saved ? 1 : -1)));
      const committed = intent.sawBusy && !busy && changed && countMatches &&
        (hostName !== 'missav.ws' || intent.state.saved === saved);
      if (committed) {
        if (intent.settledAt === null) intent.settledAt = Date.now();
        if (Date.now() - intent.settledAt >= SETTLE_TIME) {
          clearFavorite();
          onFavorite(saved);
          return;
        }
      } else intent.settledAt = null;
      favoriteTimer = setTimeout(checkFavorite, 50);
    }

    const favoriteObserver = new MutationObserver(checkFavorite);

    function armFavorite() {
      if (favorite.disabled || favorite.getAttribute('aria-disabled') === 'true') return;
      if (favoriteIntent) {
        const busy = hostName === 'missav.ws' ? missavOwner(favorite)?.state.loading : favorite.disabled;
        if (busy) return;
        clearFavorite();
      }
      favoriteObserver.takeRecords();
      const before = savedState(favorite);
      const key = favoriteKey(favorite);
      if (before === null || key === null) return;
      const current = hostName === 'missav.ws' ? missavOwner(favorite) : null;
      if (hostName === 'missav.ws' && (!current || current.state.loading || current.state.saved !== before)) return;
      const intent = favoriteIntent = {
        element: favorite, key, before, count: favoriteCount(favorite), state: current?.state,
        page: pageKey(), deadline: Date.now() + ACTION_TIMEOUT, sawBusy: false, settledAt: null, dispatched: false,
      };
      // Capture listeners can finish a microtask checkpoint before the native
      // target handler runs. Wait for the next task before treating no change
      // as a login prompt; attribute observers still capture fast busy cycles.
      intent.startTimer = setTimeout(() => {
        if (favoriteIntent !== intent) return;
        intent.dispatched = true;
        checkFavorite();
      }, 0);
    }

    function nativePlayTarget(target, actualVideo) {
      if (target === actualVideo) return true;
      const player = actualVideo.closest('.plyr');
      if (!player || !player.contains(target)) return false;
      const playButton = target.closest('[data-plyr="play"]');
      if (playButton) return playButton.closest('.plyr') === player;
      // Plyr's poster/video surface is a native click-to-play target, not its menus,
      // sliders, subtitle controls, advertising, or a different player's preview.
      return target.matches('.plyr__poster,.plyr__video-wrapper') &&
        target.closest('.plyr') === player;
    }

    function click(event) {
      if (destroyed || !supported || !event.isTrusted || (event.button !== undefined && event.button !== 0)) return;
      scan();
      const target = elementTarget(event);
      if (!target) return;
      if (favorite?.contains(target)) { armFavorite(); return; }
      const actualVideo = video;
      if (!actualVideo?.isConnected || actualVideo !== getVideo() || !nativePlayTarget(target, actualVideo)) return;
      clearPlay();
      if (!actualVideo.paused) return;
      playIntent = {
        video: actualVideo, source: actualVideo.currentSrc || actualVideo.src,
        page: pageKey(), deadline: Date.now() + ACTION_TIMEOUT,
      };
      playTimer = setTimeout(clearPlay, ACTION_TIMEOUT);
    }

    function playing(event) {
      const intent = playIntent;
      if (!intent || !event.isTrusted || event.target !== intent.video) return;
      const actualVideo = getVideo();
      const valid = !destroyed && Date.now() <= intent.deadline && intent.page === pageKey() &&
        actualVideo === video && actualVideo === intent.video && actualVideo.isConnected &&
        !actualVideo.paused && intent.source === (actualVideo.currentSrc || actualVideo.src);
      clearPlay();
      if (valid) onPlay();
    }

    function playbackReset(event) {
      if (event.target === playIntent?.video) clearPlay();
    }

    function destroy() {
      if (destroyed) return;
      destroyed = true;
      reset();
      favoriteObserver.disconnect();
      document.removeEventListener('click', click, true);
      document.removeEventListener('playing', playing, true);
      for (const type of ['pause', 'emptied', 'loadstart', 'error', 'ended']) document.removeEventListener(type, playbackReset, true);
      favorite = null;
      video = null;
    }

    if (supported) {
      document.addEventListener('click', click, true);
      document.addEventListener('playing', playing, true);
      for (const type of ['pause', 'emptied', 'loadstart', 'error', 'ended']) document.addEventListener(type, playbackReset, true);
    }
    return { scan, getFavorite: () => destroyed ? null : scan(), reset, destroy };
  }

  return { create };
})();

const JHLibraryUI = (() => {
  const starIcon = '<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="m12 3 2.78 5.63 6.22.91-4.5 4.39 1.06 6.2L12 17.2l-5.56 2.93 1.06-6.2L3 9.54l6.22-.91L12 3z"/></svg>';
  const spinnerIcon = '<svg data-jav-helper-spinner aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="16" height="16" style="display:none;transform-origin:center"><circle cx="12" cy="12" r="9" style="fill:none;stroke:currentColor;stroke-width:2;opacity:.25"/><path d="M12 3a9 9 0 0 1 9 9" style="fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round"/></svg>';

  function create({ onRefresh, onSelectMatch, onRate }) {
    const host = document.createElement('span');
    host.id = 'jav-helper-library';
    const ratingButton = document.createElement('button');
    ratingButton.id = 'jav-helper-rating';
    ratingButton.type = 'button';
    ratingButton.setAttribute('aria-haspopup', 'dialog');
    ratingButton.setAttribute('aria-expanded', 'false');
    ratingButton.style.alignItems = 'center';
    // Only the original 123AV heart should consume the row's free space.
    if (location.hostname === '123av.com') ratingButton.style.marginLeft = '0';
    ratingButton.innerHTML = `${starIcon}${spinnerIcon}<span>—</span>`;
    const ratingIcon = ratingButton.querySelector('svg');
    const loadingIcon = ratingButton.querySelector('[data-jav-helper-spinner]');
    const publicValue = ratingButton.querySelector('span');
    const recordLink = document.createElement('a');
    recordLink.id = 'jav-helper-javdb-link';
    recordLink.target = '_blank';
    recordLink.rel = 'noopener noreferrer';
    recordLink.setAttribute('role', 'link');
    recordLink.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;text-decoration:none;white-space:nowrap';
    if (location.hostname === '123av.com') recordLink.style.marginLeft = '0';
    const recordIcon = document.createElement('img');
    recordIcon.alt = '';
    recordIcon.width = 16;
    recordIcon.height = 16;
    recordIcon.referrerPolicy = 'no-referrer';
    recordIcon.style.cssText = 'display:block;flex-shrink:0;object-fit:contain';
    recordLink.append(recordIcon);
    if (location.hostname === 'missav.ws') recordLink.append(document.createTextNode('JavDB'));
    recordLink.addEventListener('click', event => {
      event.stopPropagation();
      if (recordLink.getAttribute('aria-disabled') === 'true') event.preventDefault();
    });
    const whiteStar = location.hostname === 'jable.tv';
    const hoverStyle = whiteStar ? document.createElement('style') : null;
    if (hoverStyle) {
      hoverStyle.id = 'jav-helper-rating-style';
      hoverStyle.textContent = '@media(hover:hover){#jav-helper-rating:not(:disabled):hover{--jh-rating-star-stroke:#f5c542}}';
      ratingIcon.querySelector('path').style.cssText = 'fill:none;stroke:var(--jh-rating-star-stroke,#fff);stroke-width:1.8;stroke-linejoin:round';
    }
    const root = host.attachShadow({ mode: 'open' });
    root.innerHTML = `<style>
      :host{display:block;position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;font:inherit;color:inherit;text-align:start;color-scheme:inherit}
      *,*::before,*::after{box-sizing:border-box}[hidden]{display:none!important}
      .popup{position:fixed;width:max-content;max-width:calc(100vw - 16px);max-height:calc(100dvh - 16px);overflow:auto;overscroll-behavior:contain;padding:8px;border:1px solid color-mix(in srgb,currentColor 22%,transparent);border-radius:6px;background:var(--jh-surface,Canvas);color:inherit;box-shadow:0 3px 16px #0004;font-size:.9em;line-height:1.45}
      .popup[data-notice]{width:280px}.ratings{display:flex;align-items:center;gap:0}
      button,select{font:inherit;color:inherit}button{cursor:pointer}button:disabled,select:disabled{opacity:.45;cursor:default}
      button:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid currentColor;outline-offset:2px}
      .ratings button{display:inline-flex;align-items:center;justify-content:center;flex:none;width:24px;height:24px;border:0;background:transparent;padding:0}
      .ratings svg{display:block;width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linejoin:round}
      .ratings button[data-filled="true"] svg{fill:#f5c542;stroke:#f5c542}
      p{margin:0 0 8px;overflow-wrap:anywhere}a{color:inherit;text-underline-offset:2px}select{width:100%;min-width:0;background:Canvas;color:CanvasText;padding:5px;border:1px solid currentColor;border-radius:4px}
      .actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.actions button{padding:4px 8px;border:1px solid color-mix(in srgb,currentColor 30%,transparent);border-radius:4px;background:transparent}.editions{display:grid;gap:5px;margin-bottom:8px}
    </style>
    <section class="popup" role="dialog" aria-label="Rate video on JavDB" tabindex="-1" hidden>
      <div class="ratings" data-ratings role="group" aria-label="Choose a rating from 1 to 5"></div>
      <div data-notice hidden>
        <p data-error role="alert"></p>
        <p data-login hidden><a href="https://javdb.com/login" target="_blank" rel="noopener noreferrer">Sign in to JavDB</a>, then retry.</p>
        <label class="editions" data-editions hidden>Choose the matching edition<select data-match aria-label="Exact JavDB edition"></select></label>
        <div class="actions"><button data-refresh type="button">Retry</button><a data-record target="_blank" rel="noopener noreferrer" hidden>Open on JavDB</a></div>
      </div>
      <div data-confirmation hidden>
        <p>Removing Want also deletes your written JavDB review. Delete both?</p>
        <div class="actions"><button data-delete type="button">Delete mark and review</button><button data-cancel type="button">Keep JavDB record</button></div>
      </div>
    </section>`;
    const nodes = Object.fromEntries(['ratings', 'notice', 'error', 'login', 'editions', 'match', 'refresh', 'record', 'confirmation', 'delete', 'cancel'].map(name => [name, root.querySelector(`[data-${name}]`)]));
    const popup = root.querySelector('.popup');
    const ratingButtons = [];
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    let view = { state: null, busy: false, error: '', enabled: true };
    let open = false;
    let destroyed = false;
    let pending = false;
    let generation = 0;
    let preview = null;
    let confirmation = null;
    let anchorElement = null;
    let loadingAnimation;
    let localError = '';
    let matchSignature = '';

    function safeURL(value) {
      if (typeof value !== 'string' || !value.trim()) return null;
      try {
        const url = new URL(value, 'https://javdb.com/');
        return url.protocol === 'https:' && /^(?:www\.)?javdb\.com$/.test(url.hostname) && !url.username && !url.password ? url.href : null;
      } catch { return null; }
    }
    function personalScore() {
      const score = Number(view.state?.score) || Number(view.state?.retainedScore) || 0;
      return Number.isInteger(score) && score >= 1 && score <= 5 ? score : 0;
    }
    function blocked() { return destroyed || !view.enabled || view.busy || pending; }
    function paintStars() {
      const score = personalScore();
      const shown = preview ?? score;
      for (const button of ratingButtons) {
        const value = Number(button.value);
        button.dataset.filled = String(value <= shown);
        button.setAttribute('aria-pressed', String(value === score));
      }
    }
    function position() {
      if (!open || !ratingButton.isConnected) return;
      const rect = ratingButton.getBoundingClientRect();
      const width = document.documentElement.clientWidth || window.innerWidth;
      popup.style.left = `${Math.max(8, Math.min(rect.left, width - popup.offsetWidth - 8))}px`;
      popup.style.top = `${Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - popup.offsetHeight - 8))}px`;
    }
    function settleConfirmation(answer) {
      if (!confirmation) return;
      const request = confirmation;
      confirmation = null;
      request.resolve(answer);
      update();
    }
    function close(restoreFocus = false) {
      const focusedInside = popup.contains(root.activeElement);
      open = false;
      popup.hidden = true;
      preview = null;
      ratingButton.setAttribute('aria-expanded', 'false');
      settleConfirmation(false);
      paintStars();
      if (restoreFocus && focusedInside && ratingButton.isConnected) ratingButton.focus();
    }
    function show() {
      if (destroyed || !ratingButton.isConnected) return;
      open = true;
      popup.hidden = false;
      ratingButton.setAttribute('aria-expanded', 'true');
      position();
    }
    function update() {
      if (destroyed) return;
      const state = view.state;
      const score = personalScore();
      const notFound = state?.status === 'not-found';
      ratingButton.disabled = notFound;
      ratingButton.setAttribute('aria-disabled', String(notFound));
      ratingButton.style.opacity = notFound ? '.45' : '';
      ratingButton.style.filter = notFound ? 'grayscale(1)' : '';
      if (notFound && open) close(false);
      const recordURL = !notFound && state?.status !== 'ambiguous' ? safeURL(state?.url) : null;
      const linked = Boolean(view.enabled && recordURL);
      if (linked) recordLink.href = recordURL;
      else recordLink.removeAttribute('href');
      recordLink.setAttribute('aria-disabled', String(!linked));
      recordLink.setAttribute('aria-label', notFound ? 'Not found on JavDB' : linked ? 'Open on JavDB' : 'JavDB record unavailable');
      recordLink.tabIndex = linked ? 0 : -1;
      recordLink.style.opacity = linked ? '' : '.45';
      recordLink.style.filter = linked ? '' : 'grayscale(1)';
      recordLink.style.pointerEvents = linked ? '' : 'none';
      const publicScore = state?.publicScore;
      const hasScore = publicScore !== null && publicScore !== undefined && Number.isFinite(Number(publicScore)) && Number(publicScore) >= 0 && Number(publicScore) <= 5;
      const value = hasScore ? Number(publicScore).toFixed(2) : '—';
      if (publicValue.textContent !== value) publicValue.textContent = value;
      ratingButton.setAttribute('aria-busy', String(view.busy));
      ratingButton.setAttribute('aria-label', notFound ? 'Not found on JavDB' : `${view.busy ? 'Loading JavDB rating. ' : ''}${hasScore ? `Community rating ${value}. ` : ''}${score ? `My rating ${score} out of 5. ` : 'Not personally rated. '}Open rating picker`);
      ratingIcon.style.display = view.busy ? 'none' : '';
      loadingIcon.style.display = view.busy ? '' : 'none';
      const animateLoading = view.enabled && view.busy && !motion.matches;
      if (animateLoading && !loadingAnimation) loadingAnimation = loadingIcon.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity });
      else if (!animateLoading && loadingAnimation) { loadingAnimation.cancel(); loadingAnimation = null; }
      const error = view.error || localError;
      const unavailable = !view.busy && state && (!['unmarked', 'wanted', 'watched'].includes(state.status) || !state.canWatch);
      const help = Boolean(error || unavailable);
      nodes.error.textContent = error || ({ ambiguous: 'Choose the exact matching edition.', vip: 'JavDB access requires VIP.', 'not-found': 'Not found on JavDB.' }[state?.status] || (unavailable && state.status !== 'login' ? 'Rating is unavailable for this record.' : ''));
      nodes.error.hidden = !nodes.error.textContent;
      nodes.login.hidden = state?.status !== 'login';
      nodes.editions.hidden = state?.status !== 'ambiguous';
      const matches = state?.status === 'ambiguous' && Array.isArray(state.matches) ? state.matches : [];
      const signature = JSON.stringify(matches.map(match => [match.title, match.code, match.url]));
      if (signature !== matchSignature) {
        const options = [new Option('Choose edition…', '')];
        for (const match of matches) { const url = safeURL(match.url); if (url) options.push(new Option(String(match.title || match.code || 'JavDB edition'), url)); }
        nodes.match.replaceChildren(...options);
        matchSignature = signature;
      }
      const url = safeURL(state?.url);
      nodes.record.hidden = !url || state.status === 'login' || state.status === 'ambiguous';
      if (url) nodes.record.href = url;
      nodes.notice.hidden = !help || Boolean(confirmation);
      nodes.ratings.hidden = help || Boolean(confirmation);
      nodes.confirmation.hidden = !confirmation;
      popup.toggleAttribute('data-notice', help || Boolean(confirmation));
      nodes.refresh.disabled = blocked() || Boolean(confirmation);
      nodes.match.disabled = blocked() || Boolean(confirmation);
      nodes.delete.disabled = destroyed || !view.enabled || view.busy;
      for (const button of ratingButtons) button.disabled = blocked() || Boolean(confirmation) || Boolean(error) || !state?.canWatch || !state.supportedScores?.includes(Number(button.value));
      if (blocked() || error) preview = null;
      paintStars();
      position();
    }
    async function act(callback, ...args) {
      if (blocked() || confirmation) return;
      const token = generation;
      pending = true;
      preview = null;
      localError = '';
      update();
      try { await callback(...args); }
      catch (error) { if (token === generation) localError = error?.message || String(error); }
      finally { if (token === generation) { pending = false; update(); } }
    }
    function requestDeleteConfirmation() {
      if (confirmation) return confirmation.promise;
      if (destroyed || !view.enabled || view.busy || !ratingButton.isConnected) return Promise.resolve(false);
      let resolve;
      const promise = new Promise(done => { resolve = done; });
      confirmation = { promise, resolve };
      show();
      update();
      nodes.cancel.focus();
      return promise;
    }
    for (let value = 1; value <= 5; value += 1) {
      const button = document.createElement('button');
      button.type = 'button';
      button.value = String(value);
      button.innerHTML = starIcon;
      button.setAttribute('aria-label', `Rate ${value} out of 5 and mark watched`);
      button.addEventListener('pointerenter', event => { if (event.pointerType !== 'touch' && !button.disabled && !confirmation) { preview = value; paintStars(); } });
      button.addEventListener('click', event => { if (event.isTrusted && !button.disabled) void act(onRate, value); });
      ratingButtons.push(button);
      nodes.ratings.append(button);
    }
    nodes.ratings.addEventListener('pointerleave', () => { preview = null; paintStars(); });
    nodes.ratings.addEventListener('keydown', event => {
      if (['Enter', ' '].includes(event.key)) { event.stopPropagation(); return; }
      if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
      const buttons = ratingButtons.filter(button => !button.disabled);
      if (!buttons.length) return;
      const index = buttons.indexOf(root.activeElement);
      const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowLeft' ? -1 : 1) + buttons.length) % buttons.length;
      event.preventDefault();
      event.stopPropagation();
      buttons[next].focus();
    });
    ratingButton.addEventListener('click', () => {
      if (ratingButton.disabled) return;
      if (open) return close(true);
      show();
      (ratingButtons.find(button => Number(button.value) === personalScore() && !button.disabled) || ratingButtons.find(button => !button.disabled) || (!nodes.editions.hidden ? nodes.match : !nodes.notice.hidden ? nodes.refresh : popup)).focus();
    });
    nodes.cancel.addEventListener('click', () => { settleConfirmation(false); close(true); });
    nodes.delete.addEventListener('click', event => { if (event.isTrusted && !destroyed && view.enabled && !view.busy) { settleConfirmation(true); close(true); } });
    nodes.refresh.addEventListener('click', () => { void act(onRefresh); });
    nodes.match.addEventListener('change', event => { const url = safeURL(nodes.match.value); if (event.isTrusted && url) void act(onSelectMatch, url); });
    function outside(event) { const path = event.composedPath(); if (open && !path.includes(host) && !path.includes(ratingButton)) close(false); }
    function keyboard(event) { if (open && event.key === 'Escape') { event.preventDefault(); close(true); } }
    function resize() { if (anchorElement) mount(anchorElement); }
    document.addEventListener('pointerdown', outside, true);
    document.addEventListener('keydown', keyboard, true);
    window.addEventListener('resize', resize);
    document.addEventListener('scroll', position, true);
    motion.addEventListener('change', update);

    function render(next) {
      if (destroyed) return;
      const previous = view.state;
      view = { state: null, busy: false, error: '', enabled: true, ...next };
      const state = view.state;
      if (!view.enabled || view.busy || previous && state && (previous.code !== state.code || previous.url !== state.url || previous.status !== state.status || previous.score !== state.score || previous.retainedScore !== state.retainedScore)) settleConfirmation(false);
      update();
    }
    function copyNativeClasses(control, native) {
      control.className = [...native.classList].filter(name => !['active', 'is-on', 'text-primary'].includes(name)).join(' ');
      if (native.classList.contains('text-primary')) control.classList.add('text-nord4', 'hover:text-nord6');
    }
    function mountRecordLink(anchor) {
      let target;
      if (location.hostname === '123av.com') target = ratingButton;
      else if (location.hostname === 'jable.tv') {
        const id = anchor.getAttribute('data-fav-video-id');
        target = [...(anchor.closest('.video-info')?.querySelectorAll('button[data-fav-type="1"][data-fav-video-id]') || [])]
          .find(button => button.getAttribute('data-fav-video-id') === id);
      } else if (location.hostname === 'missav.ws') {
        target = [...anchor.parentElement.querySelectorAll('button[type="button"]')]
          .find(button => button.textContent.trim() === '分享' && button.getAttribute('@click.prevent') === "togglePanel('share')");
      }
      if (!target) { recordLink.remove(); return; }
      copyNativeClasses(recordLink, target);
      const style = getComputedStyle(target);
      if (location.hostname === 'jable.tv') recordLink.style.marginLeft = getComputedStyle(anchor).marginRight;
      const icon = [...target.querySelectorAll('svg')].find(svg => getComputedStyle(svg).display !== 'none');
      const iconStyle = icon && getComputedStyle(icon);
      const height = target.getBoundingClientRect().height;
      recordLink.style.height = height > 0 ? `${height}px` : '';
      recordIcon.style.width = iconStyle?.width || '16px';
      recordIcon.style.height = iconStyle?.height || '16px';
      recordLink.style.gap = location.hostname === 'missav.ws'
        ? (parseFloat(iconStyle?.marginRight) > 0 ? iconStyle.marginRight : style.columnGap === 'normal' ? '8px' : style.columnGap)
        : '0';
      if (!recordIcon.hasAttribute('src')) recordIcon.src = 'https://javdb.com/favicon-32x32.png';
      if (target.nextSibling !== recordLink) target.after(recordLink);
    }
    function mount(anchor) {
      if (destroyed) return;
      if (!anchor?.parentNode || anchor === host || host.contains(anchor)) { reset(); return; }
      anchorElement = anchor;
      // Light-DOM siblings retain the site's real responsive and hover styling.
      copyNativeClasses(ratingButton, anchor);
      if (anchor.nextSibling !== ratingButton) anchor.after(ratingButton);
      mountRecordLink(anchor);
      if (host.parentNode !== document.body) document.body.append(host);
      if (hoverStyle && !hoverStyle.isConnected) document.head.append(hoverStyle);
      host.style.font = getComputedStyle(ratingButton).font;
      host.style.color = getComputedStyle(anchor.parentElement).color;
      const icon = [...anchor.querySelectorAll('svg')].find(svg => getComputedStyle(svg).display !== 'none');
      if (icon) {
        for (const controlIcon of [ratingIcon, loadingIcon]) {
          controlIcon.setAttribute('class', icon.getAttribute('class') || '');
          for (const name of ['width', 'height']) controlIcon.setAttribute(name, icon.getAttribute(name) || '16');
        }
        if (!whiteStar) {
          const paint = getComputedStyle(icon.querySelector('path,use') || icon);
          const outline = paint.fill === 'none' || paint.stroke !== 'none';
          ratingIcon.style.fill = outline ? 'none' : 'currentColor';
          ratingIcon.style.stroke = outline ? 'currentColor' : 'none';
          ratingIcon.style.strokeWidth = outline ? paint.strokeWidth : '0';
          ratingIcon.style.strokeLinejoin = 'round';
        }
      }
      publicValue.className = anchor.querySelector('.count,span[x-text="followLabel"]')?.className || '';
      host.style.removeProperty('--jh-surface');
      for (let parent = anchor.parentElement; parent; parent = parent.parentElement) {
        const background = getComputedStyle(parent).backgroundColor;
        const alpha = background.startsWith('rgba(') ? Number(background.slice(5, -1).split(',')[3]) : 1;
        if (background !== 'transparent' && alpha === 1) { host.style.setProperty('--jh-surface', background); break; }
      }
      position();
    }
    function reset() {
      if (destroyed) return;
      generation += 1;
      close(false);
      host.remove();
      ratingButton.remove();
      recordLink.remove();
      hoverStyle?.remove();
      anchorElement = null;
      pending = false;
      localError = '';
      view = { state: null, busy: false, error: '', enabled: true };
      update();
    }
    function destroy() {
      if (destroyed) return;
      reset();
      destroyed = true;
      document.removeEventListener('pointerdown', outside, true);
      document.removeEventListener('keydown', keyboard, true);
      window.removeEventListener('resize', resize);
      document.removeEventListener('scroll', position, true);
      motion.removeEventListener('change', update);
    }
    update();
    return { mount, render, requestDeleteConfirmation, reset, destroy };
  }
  return { create };
})();

/* Only the requested MissAV promotions, recording warning, and loop section. */
const JHMissavUI = (() => {
  'use strict';

  const MARKER = 'data-jav-helper-missav-hidden';
  const WARNING = '在 MissAV 上觀看影片不會被偷錄,在彷冒 MissAV 的手機 app 上才會';
  const PROMOTIONS = new Map([
    ['色色主播', 'myavlive.com'],
    ['無廣告免費漫畫', 'mycomic.com'],
  ]);
  let nextInstance = 0;

  const text = element => element.textContent.replace(/\s+/g, ' ').trim();
  const hasNativeContent = element => !!element.querySelector('video, audio, iframe, button, input, select');

  function promotionTarget(link) {
    const domain = PROMOTIONS.get(text(link));
    if (!domain || hasNativeContent(link)) return null;
    let hostname;
    try {
      hostname = new URL(link.getAttribute('href'), document.baseURI).hostname;
    } catch {
      return null;
    }
    if (hostname !== domain && !hostname.endsWith(`.${domain}`)) return null;

    // Both footer placements use a one-link LI; hide its spacing as well.
    const item = link.parentElement;
    return item?.tagName === 'LI' && item.children.length === 1 && text(item) === text(link)
      ? item
      : link;
  }

  function loopSection(button) {
    if (text(button) !== '循環播放' ||
        !/^isLooping\s*=\s*!\s*isLooping$/.test((button.getAttribute('@click.prevent') || '').trim())) return null;

    // Confirmed native section: black rounded-bottom wrapper -> flex row ->
    // grow (time controls + hidden label) and sm:ml-6 (toggle button).
    const controls = button.parentElement;
    const row = controls?.parentElement;
    const section = row?.parentElement;
    if (!controls?.classList.contains('sm:ml-6') || controls.children.length !== 1 ||
        !row?.matches('div.flex.flex-nowrap') || row.children.length !== 2 ||
        !section?.matches('div.bg-black.rounded-b-0') || section.children.length !== 1 ||
        row.firstElementChild === controls || !row.firstElementChild.classList.contains('grow') ||
        ![...row.firstElementChild.querySelectorAll('label')].some(label => text(label) === '循環播放至') ||
        section.querySelector('video, audio, iframe') || section.querySelectorAll('button').length !== 1) return null;
    return section;
  }

  function create() {
    const token = `missav-${++nextInstance}`;
    const owned = new Map();
    const style = document.createElement('style');
    style.textContent = `[${MARKER}="${token}"] { display: none !important; }`;
    let destroyed = false;
    let queued = false;

    function restore(element, previous) {
      // Never overwrite a marker subsequently changed by another owner.
      if (element.getAttribute(MARKER) !== token) return;
      if (previous === null) element.removeAttribute(MARKER);
      else element.setAttribute(MARKER, previous);
    }

    function refresh() {
      if (destroyed) return;
      if (!style.isConnected) (document.head || document.documentElement)?.append(style);
      const targets = new Set();
      for (const link of document.querySelectorAll('a[href]')) {
        const target = promotionTarget(link);
        if (target) targets.add(target);
      }
      for (const banner of document.querySelectorAll('div.bg-primary.text-center.text-white')) {
        if (text(banner) === WARNING && !hasNativeContent(banner)) targets.add(banner);
      }
      for (const button of document.querySelectorAll('button')) {
        const section = loopSection(button);
        if (section) targets.add(section);
      }

      for (const [element, previous] of owned) {
        if (!targets.has(element)) {
          restore(element, previous);
          owned.delete(element);
        }
      }
      for (const element of targets) {
        if (!owned.has(element)) owned.set(element, element.getAttribute(MARKER));
        if (element.getAttribute(MARKER) !== token) element.setAttribute(MARKER, token);
      }
    }

    const observer = new MutationObserver(() => {
      if (destroyed || queued) return;
      queued = true;
      queueMicrotask(() => {
        queued = false;
        refresh();
      });
    });

    function destroy() {
      if (destroyed) return;
      destroyed = true;
      observer.disconnect();
      style.remove();
      for (const [element, previous] of owned) restore(element, previous);
      owned.clear();
    }

    refresh();
    observer.observe(document, {
      subtree: true,
      childList: true,
      characterData: true,
      attributes: true,
      attributeFilter: ['href', '@click.prevent'],
    });
    return { refresh, destroy };
  }

  return { create };
})();

const JH_SETTINGS_PAGE = `<!doctype html>
<html lang="en" data-theme="dark">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="color-scheme" content="dark light">
  <title>jav-helper · Settings</title>
  <style>:root{color-scheme:dark;--bg:#101413;--surface:#181e1c;--fg:#e5ede8;--muted:#94a49b;--border:#2b3730;--accent:#a5e4bd;--error:#f3a89e;--shadow:0 18px 50px #0002;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px}
:root[data-theme=light]{color-scheme:light;--bg:#f4f6f2;--surface:#fff;--fg:#1c3024;--muted:#5f7064;--border:#d6e0d5;--accent:#236b43;--error:#a92f26;--shadow:0 18px 50px #19302209}
*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}h1,h2{margin:0;font-weight:600;letter-spacing:-.03em}h1{font-size:26px;line-height:1.2}h2{font-size:18px}small{display:block;font-size:11px;color:var(--muted);margin-top:4px}
button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button,select{cursor:pointer}button{border:1px solid var(--border);background:var(--surface);color:var(--fg);border-radius:8px;padding:10px 13px;min-height:42px}button:hover{border-color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:disabled,input:disabled,select:disabled{opacity:.5;cursor:not-allowed}
.settings-page{max-width:1120px;margin:auto;padding:0 28px 28px}.page-header{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:24px 0;background:var(--bg);border-bottom:1px solid var(--border);margin-bottom:24px}.brand{display:flex;align-items:center;gap:12px;min-width:0}.brand-icon{font:700 20px ui-monospace,monospace;letter-spacing:-.06em;background:var(--accent);color:var(--bg);border-radius:10px;padding:9px}.save-area{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:42%}.mode-tag{font-size:9px;letter-spacing:.1em;color:var(--muted)}.save-state{font-size:11px;color:var(--muted);overflow-wrap:anywhere;text-align:right}.save-state[data-state=error]{color:var(--error)}.save-state[data-state=pending]{color:var(--accent)}
.settings-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:start}.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;min-width:0;scroll-margin-top:110px}.field{display:block;margin:20px 0;font-size:12px}.field:last-child{margin-bottom:0}.field>span{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.field output{font:12px ui-monospace,monospace;color:var(--accent)}.field input:not([type=range]),.field select{width:100%;min-width:0;border:1px solid var(--border);background:var(--bg);color:var(--fg);border-radius:7px;padding:11px;min-height:42px}.field input[type=color]{height:42px;padding:4px;cursor:pointer}.field input[type=range]{width:100%;min-width:0;min-height:30px;accent-color:var(--accent)}.field input::placeholder{color:var(--muted);opacity:1}
.switch-row{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:17px 0;border-top:1px solid var(--border);font-size:12px;cursor:pointer}.switch-row:first-of-type{border-top:0}.switch-row:last-child{padding-bottom:0}input[role=switch]{appearance:none;width:34px;height:20px;min-width:34px;border-radius:12px;background:var(--border);border:1px solid var(--muted);position:relative;cursor:pointer;margin:0}input[role=switch]:before{content:"";position:absolute;left:3px;top:3px;width:12px;height:12px;background:var(--muted);border-radius:50%}input[role=switch]:checked{background:var(--accent);border-color:var(--accent)}input[role=switch]:checked:before{left:17px;background:var(--bg)}
.player{aspect-ratio:16/9;margin-top:20px;background:#0b100e;border:1px solid var(--border);border-radius:10px;position:relative;overflow:hidden;color:#dbece0;isolation:isolate}.scene-grid{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 65%,#29463680,transparent 65%);z-index:-1}.scene-grid:before,.scene-grid:after{content:"";position:absolute;border:1px solid #72998120;border-radius:50%;width:65%;height:110%;left:18%;top:15%;transform:rotate(-35deg)}.scene-grid:after{transform:rotate(35deg)}.scene-mark{position:absolute;top:18%;left:0;right:0;text-align:center;font-size:36px;color:#a5e4bd}.sample-subtitle{position:absolute;left:5%;right:5%;bottom:18%;text-align:center;font-size:26px;line-height:1.35;font-weight:600;text-shadow:0 2px 5px #000,0 0 3px #000;white-space:pre-wrap;overflow-wrap:anywhere}.sample-subtitle.with-background{background:#0009;border-radius:5px;width:fit-content;max-width:90%;left:50%;right:auto;transform:translateX(-50%);padding:5px 10px}.preview-offset{position:absolute;bottom:8px;right:12px;font:10px ui-monospace,monospace;color:#a3b8aa}.slider-control{display:grid;grid-template-columns:42px minmax(0,1fr) 42px;gap:8px;align-items:center}.slider-control button{padding:0}
.preferences{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:center}.button-row{display:flex;gap:8px;flex-wrap:wrap}.button-row button{font-size:12px}.primary{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:600}#import-result{margin-top:12px;font-size:12px;overflow-wrap:anywhere}#import-result:empty{display:none}#import-result.error{color:var(--error)}#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);max-width:90vw;background:var(--accent);color:var(--bg);padding:13px 20px;border-radius:9px;font-size:12px;box-shadow:var(--shadow);z-index:3}
@media(max-width:700px){.settings-page{padding:0 16px 24px}.page-header{align-items:stretch;flex-direction:column;gap:12px;padding:18px 0}.brand-icon{font-size:18px}h1{font-size:22px}.save-area{max-width:none;flex-direction:row;align-items:center;justify-content:space-between}.settings-grid,.preferences{grid-template-columns:minmax(0,1fr);gap:18px}.card{padding:20px}.save-state{max-width:65%}.sample-subtitle{font-size:22px}}
@media(prefers-reduced-motion:no-preference){button,a,input[role=switch]{transition:background .15s,color .15s,border-color .15s}}
</style>
  <script>globalThis.JH_EMBED = __JH_EMBED_PAYLOAD__;</script><script>/* Shared, side-effect-free settings schema. */
const JHSettings = (() => {
  'use strict';
  const defaults = Object.freeze({
    theme: 'dark', fontSize: 26, bottom: 18, subtitleBackground: false,
    subtitleColor: '#ffffff', encoding: 'utf-8', missav: true, jable: true, av123: true,
    skipSeconds: 5, boostRate: 3, forwardKey: 'x', backwardKey: 'c', boostKey: 'z',
    subtitleLanguages: Object.freeze(['zh-TW', 'zh-CN', 'en']), autoSubtitles: true, forceSubtitles: false, javdbSync: true
  });
  function language(value) {
    if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(value)) throw new Error('Use language tags such as zh-TW, zh-CN, en or ja.');
    try { return Intl.getCanonicalLocales(value)[0]; }
    catch { throw new Error(\`Invalid language tag: \${value}\`); }
  }
  function validate(envelope) {
    if (!envelope || envelope.version !== 2 || !envelope.settings || typeof envelope.settings !== 'object' || Array.isArray(envelope.settings)) throw new Error('Expected a version 2 jav-helper settings file.');
    const value = envelope.settings;
    const keys = Object.keys(defaults);
    if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key))) throw new Error('Settings fields do not match the jav-helper schema.');
    for (const key of keys) {
      if (key !== 'subtitleLanguages' && typeof value[key] !== typeof defaults[key]) throw new Error(\`Invalid type for \${key}.\`);
    }
    if (!['dark', 'light'].includes(value.theme) || !['utf-8', 'big5', 'gbk'].includes(value.encoding)) throw new Error('Invalid theme or subtitle encoding.');
    for (const [key, min, max, integer] of [['fontSize', 16, 42, true], ['bottom', 10, 35, true], ['skipSeconds', 1, 120, true], ['boostRate', 1, 8, false]]) {
      if (!Number.isFinite(value[key]) || value[key] < min || value[key] > max || (integer && !Number.isInteger(value[key]))) throw new Error(\`\${key} must be \${integer ? 'an integer' : 'a number'} from \${min} to \${max}.\`);
    }
    if (!/^#[0-9a-f]{6}$/i.test(value.subtitleColor)) throw new Error('Subtitle color must be a six-digit hex color.');
    const shortcuts = ['forwardKey', 'backwardKey', 'boostKey'].map(key => value[key]);
    if (shortcuts.some(key => !/^[a-z0-9]$/.test(key)) || new Set(shortcuts).size !== shortcuts.length) throw new Error('Playback keys must be distinct lowercase letters or digits.');
    if (!Array.isArray(value.subtitleLanguages) || value.subtitleLanguages.length < 1 || value.subtitleLanguages.length > 10) throw new Error('Choose between one and ten languages in preference order.');
    const languages = value.subtitleLanguages.map(language);
    if (new Set(languages.map(tag => tag.toLowerCase())).size !== languages.length) throw new Error('Language preferences must not contain duplicates.');
    return { ...Object.fromEntries(keys.map(key => [key, value[key]])), subtitleLanguages: languages };
  }
  function importDocument(envelope) {
    if (envelope?.version !== 1) return validate(envelope);
    const raw = envelope.settings;
    const legacy = ['theme', 'toolbarPosition', 'compact', 'focus', 'fontSize', 'bottom', 'subtitleBackground', 'language', 'encoding', 'missav', 'jable', 'av123', 'subtitleColor', 'skipSeconds', 'boostRate', 'forwardKey', 'backwardKey', 'boostKey', 'customSearch'];
    const required = legacy.slice(0, 12);
    if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !required.every(key => Object.hasOwn(raw, key)) || Object.keys(raw).some(key => !legacy.includes(key))) throw new Error('Invalid legacy settings file.');
    if (!['zh-TW', 'zh-CN', 'en', 'ja'].includes(raw.language)) throw new Error('Invalid legacy subtitle language.');
    const next = { ...defaults };
    for (const key of Object.keys(next)) if (Object.hasOwn(raw, key)) next[key] = raw[key];
    next.subtitleLanguages = [...new Set([raw.language, ...defaults.subtitleLanguages])];
    return validate({ version: 2, settings: next });
  }
  function document(settings) { return { version: 2, settings: validate({ version: 2, settings }) }; }
  return Object.freeze({ defaults, validate, importDocument, document });
})();
</script>
  
</head>
<body>
<main class="settings-page">
  <header class="page-header">
    <div class="brand"><span class="brand-icon" aria-hidden="true">jh</span><h1>jav-helper settings</h1></div>
    <div class="save-area"><span class="mode-tag" id="mode-tag">LOCAL SETTINGS</span><span class="save-state" id="save-state" role="status" aria-live="polite">Defaults</span></div>
  </header>
  <div class="settings-grid">
    <section class="card" aria-labelledby="subtitle-heading">
      <h2 id="subtitle-heading">Subtitles</h2>
      <label class="field" for="subtitle-languages"><span>Languages</span><input id="subtitle-languages" type="text" data-setting="subtitleLanguages" placeholder="zh-TW, zh-CN, en"></label>
      <label class="switch-row" for="force-subtitles"><span>Force external subtitles</span><input id="force-subtitles" type="checkbox" role="switch" data-setting="forceSubtitles"></label>
      <label class="field" for="font-size"><span>Font size <output id="font-size-output" for="font-size">26 px</output></span><input id="font-size" type="range" min="16" max="42" step="1" data-setting="fontSize"></label>
      <label class="field" for="bottom"><span>Bottom inset <output id="bottom-output" for="bottom">18%</output></span><input id="bottom" type="range" min="10" max="35" step="1" data-setting="bottom"></label>
      <label class="field" for="subtitle-color"><span>Subtitle color</span><input id="subtitle-color" type="color" data-setting="subtitleColor"></label>
      <label class="switch-row" for="subtitle-background"><span>Subtitle background</span><input id="subtitle-background" type="checkbox" role="switch" data-setting="subtitleBackground"></label>
      <label class="field" for="encoding"><span>Local-file encoding</span><select id="encoding" data-setting="encoding"><option value="utf-8">UTF-8</option><option value="big5">Big5</option><option value="gbk">GBK</option></select></label>
    </section>
    <section class="card" aria-labelledby="preview-heading">
      <h2 id="preview-heading">Preview</h2>
      <div class="player" id="player-preview">
        <div class="scene-grid" aria-hidden="true"></div>
        <span class="scene-mark" aria-hidden="true">▷</span>
        <div class="sample-subtitle" id="sample-subtitle">字幕預覽 · Subtitle preview</div>
        <output class="preview-offset" id="offset-readout">+0.0 s</output>
      </div>
      <div class="field"><span><label for="offset">Offset</label><output id="offset-value" for="offset">+0.0 s</output></span><div class="slider-control"><button id="earlier" aria-label="Earlier subtitles">−</button><input id="offset" type="range" min="-30" max="30" step="0.5" value="0"><button id="later" aria-label="Later subtitles">+</button></div></div>
      <button id="reset-offset">Reset</button>
    </section>
    <section class="card" aria-labelledby="playback-heading">
      <h2 id="playback-heading">Playback</h2>
      <label class="field" for="skip-seconds"><span>Seek step (seconds)</span><input id="skip-seconds" type="number" min="1" max="120" step="1" data-setting="skipSeconds"></label>
      <label class="field" for="boost-rate"><span>Hold-to-boost rate</span><input id="boost-rate" type="number" min="1" max="8" step="0.1" data-setting="boostRate"></label>
      <label class="field" for="forward-key"><span>Seek forward key</span><input id="forward-key" type="text" maxlength="1" data-setting="forwardKey"></label>
      <label class="field" for="backward-key"><span>Seek backward key</span><input id="backward-key" type="text" maxlength="1" data-setting="backwardKey"></label>
      <label class="field" for="boost-key"><span>Hold-to-boost key</span><input id="boost-key" type="text" maxlength="1" data-setting="boostKey"></label>
    </section>
    <section class="card" aria-labelledby="sites-heading">
      <h2 id="sites-heading">Sites & automations</h2>
      <label class="switch-row" for="auto-subtitles"><span>Automatic subtitles</span><input id="auto-subtitles" type="checkbox" role="switch" data-setting="autoSubtitles"></label>
      <label class="switch-row" for="javdb-sync"><span>JavDB sync</span><input id="javdb-sync" type="checkbox" role="switch" data-setting="javdbSync"></label>
      <label class="switch-row" for="missav"><span>MissAV<small>missav.ws</small></span><input id="missav" type="checkbox" role="switch" data-setting="missav"></label>
      <label class="switch-row" for="jable"><span>Jable<small>jable.tv</small></span><input id="jable" type="checkbox" role="switch" data-setting="jable"></label>
      <label class="switch-row" for="av123"><span>123AV<small>123av.com</small></span><input id="av123" type="checkbox" role="switch" data-setting="av123"></label>
    </section>
    <section class="card preferences" aria-labelledby="preferences-heading">
      <div><h2 id="preferences-heading">Preferences</h2><label class="field" for="theme"><span>Theme</span><select id="theme" data-setting="theme"><option value="dark">Dark</option><option value="light">Light</option></select></label></div>
      <div><div class="button-row"><button id="export" class="primary">Export JSON ↓</button><button id="import">Import JSON ↑</button><input id="import-file" type="file" accept=".json,application/json" hidden aria-label="Choose settings JSON file"></div><div id="import-result" role="status" aria-live="polite"></div></div>
    </section>
  </div>
</main>
<div id="toast" role="status" aria-live="polite" hidden></div>
<script>(() => {
  'use strict';

  const STORAGE_KEY = 'jav-helper:settings:v2';
  const embedded = globalThis.JH_EMBED !== undefined;
  const embed = globalThis.JH_EMBED;
  const $ = selector => document.querySelector(selector);
  const controls = Array.from(document.querySelectorAll('[data-setting]'));
  let settings = { ...JHSettings.defaults };
  let bridgeReady = false;
  let bridgePort = null;
  let inFlight = false;
  let queuedDocument = null;
  let offset = 0;
  let toastTimer;

  function status(message, state = 'saved') {
    const node = $('#save-state');
    node.textContent = message;
    node.dataset.state = state;
    node.setAttribute('aria-busy', String(state === 'pending'));
  }

  function notify(message) {
    clearTimeout(toastTimer);
    $('#toast').textContent = message;
    $('#toast').hidden = false;
    toastTimer = setTimeout(() => { $('#toast').hidden = true; }, 4000);
  }

  function post(type, document) {
    bridgePort.postMessage({ source: 'jav-helper', type, token: embed.token, ...(document ? { document } : {}) });
  }

  function send(document) {
    inFlight = true;
    status('Saving…', 'pending');
    try {
      post('save', document);
    } catch (error) {
      inFlight = false;
      queuedDocument = null;
      status(\`Not saved · \${error.message}\`, 'error');
    }
  }

  function persist() {
    const document = JHSettings.document(settings);
    if (embedded) {
      if (!bridgeReady) {
        status('Not saved · settings bridge unavailable', 'error');
        return;
      }
      if (inFlight) {
        queuedDocument = document;
        status('Saving…', 'pending');
      } else send(document);
      return;
    }
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(document));
      status('Saved locally');
    } catch (error) {
      status(\`Not saved · browser storage unavailable: \${error.message}\`, 'error');
    }
  }

  function render() {
    document.documentElement.dataset.theme = settings.theme;
    for (const control of controls) {
      const value = settings[control.dataset.setting];
      if (control.type === 'checkbox') control.checked = value;
      else control.value = Array.isArray(value) ? value.join(', ') : value;
    }
    $('#font-size-output').textContent = \`\${settings.fontSize} px\`;
    $('#bottom-output').textContent = \`\${settings.bottom}%\`;
    const subtitle = $('#sample-subtitle');
    subtitle.style.fontSize = \`\${settings.fontSize}px\`;
    subtitle.style.bottom = \`\${settings.bottom}%\`;
    subtitle.style.color = settings.subtitleColor;
    subtitle.classList.toggle('with-background', settings.subtitleBackground);
  }


  if (embedded) {
    $('#mode-tag').textContent = 'USERSCRIPT SETTINGS';
    try {
      if (!embed || typeof embed.token !== 'string' || !embed.token || typeof embed.origin !== 'string' || window.parent === window) throw new Error('Invalid embedded settings configuration.');
      const origin = new URL(embed.origin);
      if (!['https:', 'http:'].includes(origin.protocol) || origin.origin !== embed.origin) throw new Error('Invalid parent origin.');
      settings = JHSettings.validate(embed.document);
      status('Connecting…', 'pending');
      for (const control of controls) control.disabled = true;
      $('#import').disabled = true;
    } catch (error) {
      status(\`Settings unavailable · \${error.message}\`, 'error');
      for (const control of controls) control.disabled = true;
      $('#import').disabled = true;
      $('#export').disabled = true;
    }

    window.addEventListener('message', event => {
      const message = event.data;
      if (!event.isTrusted || bridgePort || event.origin !== embed.origin || message?.source !== 'jav-helper' || message.type !== 'connect' || message.token !== embed.token || event.ports.length !== 1) return;
      bridgePort = event.ports[0];
      bridgeReady = true;
      for (const control of controls) control.disabled = false;
      $('#import').disabled = false;
      bridgePort.onmessage = receiveSettings;
      bridgePort.start();
      status('Loaded');
    });
    function receiveSettings(event) {
      const message = event.data;
      if (!event.isTrusted || !bridgeReady || message?.source !== 'jav-helper' || message.token !== embed.token || !['saved', 'error', 'settings'].includes(message.type)) return;
      try {
        if (message.type === 'settings') {
          settings = JHSettings.validate(message.document);
          render();
          if (!inFlight) status('Updated');
          return;
        }
        if (!inFlight) return;
        if (message.type === 'error') {
          inFlight = false;
          queuedDocument = null;
          status(\`Not saved · \${typeof message.message === 'string' ? message.message : 'Userscript manager rejected the change.'}\`, 'error');
          return;
        }
        if (message.document !== undefined) JHSettings.validate(message.document);
        inFlight = false;
        if (queuedDocument) {
          const next = queuedDocument;
          queuedDocument = null;
          send(next);
        } else status('Saved');
      } catch (error) {
        inFlight = false;
        queuedDocument = null;
        status(\`Settings response rejected · \${error.message}\`, 'error');
      }
    }
    window.addEventListener('keydown', event => {
      if (event.key === 'Escape' && bridgeReady) {
        event.preventDefault();
        post('close');
      }
    });
  } else {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      const legacy = stored === null ? localStorage.getItem('jav-helper:settings:v1') || localStorage.getItem('jav-helper:design-settings:v1') : null;
      if (stored !== null || legacy !== null) {
        settings = JHSettings.importDocument(JSON.parse(stored || legacy));
        if (legacy !== null) {
          localStorage.setItem(STORAGE_KEY, JSON.stringify(JHSettings.document(settings)));
          localStorage.removeItem('jav-helper:settings:v1');
          localStorage.removeItem('jav-helper:design-settings:v1');
        }
        status('Loaded locally');
      } else status('Defaults');
    } catch (error) {
      status(\`Stored settings unavailable · using defaults: \${error.message}\`, 'error');
    }
  }

  for (const control of controls) {
    control.addEventListener(['range', 'color'].includes(control.type) ? 'input' : 'change', () => {
      const key = control.dataset.setting;
      const value = key === 'subtitleLanguages' ? control.value.split(/[,\\s]+/).filter(Boolean) : control.type === 'checkbox' ? control.checked : ['range', 'number'].includes(control.type) ? control.valueAsNumber : control.value;
      try {
        settings = JHSettings.validate({ version: 2, settings: { ...settings, [key]: value } });
        render();
        persist();
      } catch (error) {
        render();
        notify(\`Change rejected: \${error.message} Preferences unchanged.\`);
      }
    });
  }

  function updateOffset(value) {
    if (!Number.isFinite(value)) return;
    offset = Math.max(-600, Math.min(600, Math.round(value * 2) / 2));
    const control = $('#offset');
    control.min = Math.min(-30, offset);
    control.max = Math.max(30, offset);
    control.value = offset;
    const text = \`\${offset >= 0 ? '+' : ''}\${offset.toFixed(1)} s\`;
    control.setAttribute('aria-valuetext', text);
    $('#offset-value').textContent = text;
    $('#offset-readout').textContent = text;
  }
  $('#offset').addEventListener('input', event => updateOffset(Number(event.target.value)));
  $('#earlier').addEventListener('click', () => updateOffset(offset - 0.5));
  $('#later').addEventListener('click', () => updateOffset(offset + 0.5));
  $('#reset-offset').addEventListener('click', () => updateOffset(0));


  $('#export').addEventListener('click', () => {
    const blob = new Blob([JSON.stringify(JHSettings.document(settings), null, 2) + '\\n'], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = 'jav-helper-settings.json';
    document.body.append(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
    notify('Settings export requested');
  });
  $('#import').addEventListener('click', () => $('#import-file').click());
  $('#import-file').addEventListener('change', async event => {
    const file = event.target.files[0];
    if (!file || (embedded && !bridgeReady)) return;
    const result = $('#import-result');
    try {
      if (file.size > 65536) throw new Error('Settings file exceeds 64 KB.');
      settings = JHSettings.importDocument(JSON.parse(await file.text()));
      render();
      persist();
      result.classList.remove('error');
      result.textContent = 'Imported';
    } catch (error) {
      result.classList.add('error');
      result.textContent = \`Import rejected. \${error.message}\`;
    } finally {
      event.target.value = '';
    }
  });

  render();
})();
</script></body>
</html>
`;
const SETTINGS_KEY = 'jav-helper:settings:v2';
const LEGACY_SETTINGS_KEY = 'jav-helper:settings:v1';
const REVISION_KEY = 'jav-helper:javdb-revision';
const FRAME_PLAY_KEY = 'jav-helper:frame-play';
const FRAME_ORIGIN = 'https://sadie-shop.site';
const TOP_ORIGIN = 'https://123av.com';
const FRAME_SOURCE = 'jav-helper-frame';
const isFrame = window !== window.top;
const hostName = location.hostname;
const isJavDB = hostName === 'javdb.com' && !isFrame;
const siteKey = { 'missav.ws': 'missav', 'jable.tv': 'jable', '123av.com': 'av123' }[hostName];
if (isFrame && hostName !== 'sadie-shop.site') return;
if (!siteKey && hostName !== 'sadie-shop.site' && !isJavDB) return;
if (hostName === 'sadie-shop.site') {
  if (!isFrame || !/^\/e\//.test(location.pathname)) return;
  try { if (new URL(document.referrer).origin !== TOP_ORIGIN) return; } catch { return; }
}
const log = data => GM_log(JSON.stringify({ kind: 'jav-helper', ...data }));
let settings;
try {
  const stored = GM_getValue(SETTINGS_KEY, null);
  const legacy = stored ? null : GM_getValue(LEGACY_SETTINGS_KEY, null);
  settings = stored ? JHSettings.validate(stored) : legacy ? JHSettings.importDocument(legacy) : JHSettings.validate(JHSettings.document({ ...JHSettings.defaults }));
  if (legacy) {
    await GM_setValue(SETTINGS_KEY, JHSettings.document(settings));
    GM_deleteValue(LEGACY_SETTINGS_KEY);
  }
} catch (error) {
  settings = JHSettings.validate(JHSettings.document({ ...JHSettings.defaults }));
  log({ event: 'settings-error', message: error.message });
}
let context = { title: '', edition: location.pathname };
let currentVideo = null;
let selectedFrame = null;
let frameToken = '';
let frameReady = false;
let pageURL = location.href;
let destroyed = false;
let refreshTimer;
let panelHost;
let panelRoot;
let nativeButton;
let missavUI;
let settingsHost;
let settingsFrame;
let settingsPort;
let settingsToken;
let settingsTrigger;
let saveChain = Promise.resolve();
let serviceContext = '';
let serviceGeneration = 0;
let autoGeneration = 0;
let subtitleMode = 'auto';
let forceForPage = false;
let subtitleMessage = '';
let automationSignature = '';
let dbState = null;
let dbError = '';
let dbBusy = false;
let dbSaving = false;
let dbWriteUncertain = false;
let dbReadPromise;
let dbOperationChain = Promise.resolve();
let dbGeneration = 0;
let lastDBRefresh = 0;
let lastJavDBSignature = '';
const listeners = [];

function request({ url, method = 'GET', headers = {}, data, responseType = 'text', anonymous }) {
  const target = new URL(url);
  const allowed = ['javdb.com', 'www.subtitlecat.com', 'subtitlecat.com'];
  if (target.protocol !== 'https:' || target.username || target.password || !allowed.includes(target.hostname)) return Promise.reject(new Error('Service URL is not permitted.'));
  if (!['GET', 'POST'].includes(method)) return Promise.reject(new Error('Unsupported service operation.'));
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      url: target.href, method, data, responseType,
      headers: { ...(target.hostname === 'javdb.com' ? { Referer: 'https://javdb.com/', ...(method === 'POST' ? { Origin: 'https://javdb.com' } : {}) } : {}), ...headers },
      anonymous: anonymous ?? target.hostname !== 'javdb.com', timeout: 20000,
      onload(response) {
        try {
          const finalUrl = new URL(response.finalUrl || target.href);
          if (finalUrl.protocol !== 'https:' || !allowed.includes(finalUrl.hostname)) throw new Error('Service redirected outside the permitted hosts.');
          if (responseType === 'arraybuffer') {
            const buffer = response.response;
            if (Object.prototype.toString.call(buffer) !== '[object ArrayBuffer]' || buffer.byteLength > 5 * 1024 * 1024) throw new Error('Invalid subtitle response or file exceeds 5 MiB.');
            resolve({ status: response.status, finalUrl: finalUrl.href, buffer });
          } else {
            const text = response.responseText || '';
            if (text.length > 5 * 1024 * 1024) throw new Error('Service response exceeds 5 MiB.');
            resolve({ status: response.status, finalUrl: finalUrl.href, text });
          }
        } catch (error) { reject(error); }
      },
      onerror: () => reject(new Error(`Could not reach ${target.hostname}. Check manager permissions and your connection.`)),
      ontimeout: () => reject(new Error(`${target.hostname} request timed out.`))
    });
  });
}
const javdb = JHJavDB.create({ request });
const subtitlecat = JHSubtitleCat.create({
  request,
  readCache: key => GM_getValue(`jav-helper:subtitlecat:${key}`, undefined),
  writeCache: (key, value) => GM_setValue(`jav-helper:subtitlecat:${key}`, value)
});
function notify(message) {
  if (panelRoot) panelRoot.querySelector('[data-message]').textContent = message;
  log({ event: 'notice', message });
}
const subtitles = JHSubtitles.create({
  getSettings: () => settings, getContext: () => context,
  readOffset: key => { const value = GM_getValue(`jav-helper:offset:${key}`, 0); return Number.isFinite(value) ? value : 0; },
  writeOffset: (key, value) => GM_setValue(`jav-helper:offset:${key}`, value), notify
});
const player = JHPlayer.create({ getSettings: () => settings, notify, onChange: () => updatePanel() });
const libraryUI = !isFrame && !isJavDB ? JHLibraryUI.create({
  onRefresh: () => refreshJavDB(undefined, true),
  onSelectMatch: async url => {
    const selectedCode = pageCode();
    await refreshJavDB(url, true);
    if (dbState?.url === url && !dbError) GM_setValue(`jav-helper:javdb-choice:${selectedCode}`, url);
  },
  onRate: score => queueJavDBAction('rate', score)
}) : null;
const nativeSite = !isJavDB ? JHNativeSite.create({
  hostName, getVideo: () => currentVideo, onPlay: handleNativePlay,
  onFavorite: saved => queueJavDBAction(saved ? 'favorite-on' : 'favorite-off')
}) : null;
function pathCode() {
  return JHCodes.extract(location.pathname.match(/[^/]+\/?$/)?.[0] || '');
}
function pageCode() {
  if (isFrame) return JHCodes.extract(context.title);
  const heading = isJavDB ? document.querySelector('h2.title') : hostName === 'jable.tv' ? document.querySelector('h4.h6, .video-info h4, h1') : document.querySelector('h1');
  return JHCodes.extract(heading?.textContent || document.title) || pathCode();
}
function isDetail() {
  if (hostName === 'jable.tv') return /^\/(?:s\d+\/)?videos\/[^/]+\/?$/.test(location.pathname);
  if (hostName === '123av.com') return /^\/(?:[a-z]{2}\/)?v\/[^/]+\/?$/.test(location.pathname);
  return hostName === 'missav.ws' && Boolean(pathCode());
}
function visibleVideo() {
  const selector = hostName === 'jable.tv' ? 'video#player' : hostName === 'missav.ws' ? 'video.player,.plyr video' : '#player video,.plyr video,video';
  let best = null;
  let area = 0;
  for (const video of document.querySelectorAll(selector)) {
    const rect = video.getBoundingClientRect();
    if (rect.width < 150 || rect.height < 80) continue;
    const style = getComputedStyle(video);
    const size = rect.width * rect.height;
    if (style.display !== 'none' && style.visibility !== 'hidden' && size > area) { best = video; area = size; }
  }
  return best;
}
function frameElement() {
  if (hostName !== '123av.com') return null;
  return [...document.querySelectorAll('iframe[src]')].find(frame => {
    try { const url = new URL(frame.src); return url.origin === FRAME_ORIGIN && /^\/e\//.test(url.pathname); } catch { return false; }
  }) || null;
}
function helloFrame() {
  if (!selectedFrame?.contentWindow || !settings.av123) return;
  frameReady = false;
  selectedFrame.contentWindow.postMessage({ source: FRAME_SOURCE, type: 'hello', token: frameToken, context }, FRAME_ORIGIN);
}
function setFrame(frame) {
  if (frame === selectedFrame) return;
  selectedFrame?.removeEventListener('load', helloFrame);
  selectedFrame = frame;
  frameReady = false;
  frameToken = crypto.randomUUID();
  selectedFrame?.addEventListener('load', helloFrame);
  helloFrame();
}
// Adapted Google Material closed_caption icon; see the bundled Apache-2.0 license and third-party notices.
const captionIcon = '<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M19 4H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z"/></svg>';
const panelCSS = `
:host{display:block;position:absolute;right:10px;bottom:56px;width:min(360px,calc(100% - 20px));z-index:2147483647;font:12px/1.5 var(--jh-font,system-ui);color:var(--jh-fg,white)}
:host([hidden]),[hidden]{display:none!important}*{box-sizing:border-box}.panel{background:var(--jh-bg,#161616);border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--jh-radius,4px);padding:12px;max-height:min(70vh,430px);overflow:auto;box-shadow:0 5px 18px #0003}
button,input,select{font:inherit;color:inherit;min-height:34px;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:var(--jh-radius,4px);background:transparent;padding:5px 8px}button,select{cursor:pointer}button:hover{background:color-mix(in srgb,currentColor 9%,transparent)}button:disabled,input:disabled,select:disabled{opacity:.45;cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid currentColor;outline-offset:2px}select option{color:var(--jh-fg);background:var(--jh-bg)}.source{display:grid;grid-template-columns:50px minmax(0,1fr);gap:8px;align-items:center}.source select,.url input{width:100%;min-width:0}.heading{display:flex;gap:8px;align-items:center;margin:10px 0 4px}.heading output{margin-left:auto;font-variant-numeric:tabular-nums}.heading button{min-height:26px;padding:2px 6px}.slider{display:grid;grid-template-columns:34px minmax(0,1fr) 34px;gap:8px;align-items:center}.slider button{padding:0}.slider input{width:100%;min-width:0;border:0;padding:0;accent-color:var(--plyr-color-main,currentColor)}.row,.url{display:flex;gap:8px;align-items:center;margin:8px 0}.url input{flex:1}.row span{margin-right:auto}.message{margin-top:8px;overflow-wrap:anywhere}.message:empty{display:none}@media(max-width:500px){:host{right:6px;bottom:48px;width:calc(100% - 12px)}.panel{padding:10px;max-height:62vh}}`;
function createPanel() {
  panelHost = document.createElement('div');
  panelHost.id = 'jav-helper-panel';
  panelHost.hidden = true;
  panelRoot = panelHost.attachShadow({ mode: 'open' });
  panelRoot.innerHTML = `<style>${panelCSS}</style>
    <section class="panel" aria-label="Subtitles">
      <label class="source"><span>Source</span><select data-source><option value="current" hidden>None</option><option value="none">None</option><option value="auto">Subtitle Cat</option><option value="file">From file…</option><option value="url">From URL…</option></select></label>
      <input data-file-input type="file" accept=".srt,.vtt,text/vtt,application/x-subrip" aria-label="Subtitle file" hidden>
      <div class="url" data-url-row hidden><input data-url type="url" aria-label="Subtitle URL" placeholder="Subtitle Cat URL"><button data-load-url>Load</button></div>
      <div class="heading"><span>Offset</span><output data-offset>+0.0 s</output><button data-reset aria-label="Reset subtitle offset">Reset</button></div>
      <div class="slider"><button data-earlier aria-label="Earlier subtitles">−</button><input data-offset-range type="range" aria-label="Subtitle offset" min="-30" max="30" step="0.1" value="0"><button data-later aria-label="Later subtitles">+</button></div>
      <div class="heading"><span>Speed</span><output data-rate-output>1×</output></div>
      <div class="slider"><button data-slower aria-label="Decrease playback speed">−</button><input data-rate type="range" aria-label="Playback speed" min="0.5" max="4" step="0.05" value="1"><button data-faster aria-label="Increase playback speed">+</button></div>
      <div class="row"><span>Seek</span><button data-back aria-label="Seek back">−10 s</button><button data-forward aria-label="Seek forward">+10 s</button></div>
      <div class="message" data-auto-state role="status" aria-live="polite"></div>
      <div class="message" data-message role="status" aria-live="polite"></div>
    </section>`;
  const on = (selector, event, fn) => panelRoot.querySelector(selector).addEventListener(event, fn);
  const run = fn => Promise.resolve().then(fn).then(() => updatePanel()).catch(error => notify(error.message));
  const setRate = value => { if (!player.setRate(value)) updatePanel(); };
  on('[data-source]', 'change', event => {
    const source = event.target.value;
    updatePanel();
    panelRoot.querySelector('[data-message]').textContent = '';
    panelRoot.querySelector('[data-url-row]').hidden = source !== 'url';
    if (source === 'none') {
      ++autoGeneration;
      subtitleMode = 'cleared';
      subtitles.clear();
      subtitleMessage = '';
      updatePanel();
    } else if (source === 'auto') {
      subtitleMode = 'auto';
      forceForPage = hasBuiltInSubtitles();
      void autoSelect(true);
    } else if (source === 'file') panelRoot.querySelector('[data-file-input]').click();
    else if (source === 'url') panelRoot.querySelector('[data-url]').focus();
  });
  on('[data-file-input]', 'change', event => {
    const file = event.target.files[0];
    event.target.value = '';
    if (!file) return;
    subtitleMode = 'manual';
    const token = ++autoGeneration;
    run(async () => {
      if (!/\.(srt|vtt)$/i.test(file.name) || file.size > 5 * 1024 * 1024) throw new Error('Choose an SRT or VTT file smaller than 5 MiB.');
      await loadBuffer(await file.arrayBuffer(), file.name, {}, token);
    });
  });
  on('[data-earlier]', 'click', () => run(() => subtitles.adjustOffset(-0.5)));
  on('[data-later]', 'click', () => run(() => subtitles.adjustOffset(0.5)));
  on('[data-reset]', 'click', () => run(() => subtitles.setOffset(0)));
  on('[data-offset-range]', 'input', event => {
    const value = Number(event.target.value);
    void run(() => subtitles.setOffset(value));
  });
  on('[data-back]', 'click', () => player.seek(-settings.skipSeconds));
  on('[data-forward]', 'click', () => player.seek(settings.skipSeconds));
  on('[data-rate]', 'input', event => setRate(Number(event.target.value)));
  on('[data-slower]', 'click', () => setRate(Math.max(Number(panelRoot.querySelector('[data-rate]').min), Math.round(((currentVideo?.playbackRate || 1) - 0.25) * 100) / 100)));
  on('[data-faster]', 'click', () => setRate(Math.min(Number(panelRoot.querySelector('[data-rate]').max), Math.round(((currentVideo?.playbackRate || 1) + 0.25) * 100) / 100)));
  on('[data-load-url]', 'click', () => {
    subtitleMode = 'manual';
    const token = ++autoGeneration;
    run(async () => {
      const url = subtitleURL(panelRoot.querySelector('[data-url]').value.trim());
      const response = await request({ url: url.href, responseType: 'arraybuffer', anonymous: true });
      if (response.status !== 200) throw new Error(`Subtitle download failed: HTTP ${response.status}.`);
      subtitleURL(response.finalUrl);
      await loadBuffer(response.buffer, decodeURIComponent(url.pathname.split('/').pop()), { encoding: 'utf-8' }, token);
      if (token === autoGeneration && panelRoot) panelRoot.querySelector('[data-url-row]').hidden = true;
    });
  });
  panelRoot.addEventListener('keydown', event => { event.stopPropagation(); if (event.key === 'Escape') { event.preventDefault(); togglePanel(false); } });
}
function attachNativeControl() {
  const controls = currentVideo?.closest('.plyr')?.querySelector('.plyr__controls');
  if (nativeButton?.parentElement === controls && controls) return;
  nativeButton?.remove();
  nativeButton = null;
  if (!controls) return;
  const button = document.createElement('button');
  button.type = 'button';
  button.className = 'plyr__controls__item plyr__control';
  button.dataset.javHelperControl = 'subtitles';
  button.setAttribute('aria-label', 'Subtitles');
  button.setAttribute('aria-expanded', String(Boolean(panelHost && !panelHost.hidden)));
  button.innerHTML = captionIcon;
  button.addEventListener('click', event => { event.stopPropagation(); togglePanel(); });
  let anchor = controls.querySelector('[data-plyr="settings"],[data-plyr="fullscreen"]');
  while (anchor && anchor.parentElement !== controls) anchor = anchor.parentElement;
  if (anchor) anchor.before(button); else controls.append(button);
  nativeButton = button;
}
function togglePanel(open = panelHost?.hidden) {
  if (!panelHost) createPanel();
  panelHost.hidden = open === undefined ? false : !open;
  nativeButton?.setAttribute('aria-expanded', String(!panelHost.hidden));
  positionPanel();
  updatePanel();
  if (panelHost.hidden) nativeButton?.focus(); else panelRoot.querySelector('[data-source]').focus();
}
function positionPanel() {
  if (!panelHost || !currentVideo) return;
  const fullscreen = document.fullscreenElement;
  const plyr = currentVideo.closest('.plyr');
  const host = fullscreen && fullscreen.tagName !== 'VIDEO' && fullscreen.contains(currentVideo) ? fullscreen : plyr || currentVideo.parentElement;
  if (panelHost.parentElement !== host) host.append(panelHost);
  const sample = plyr?.querySelector('.plyr__menu__container') || plyr?.querySelector('.plyr__controls') || host;
  const style = getComputedStyle(sample);
  const rgb = style.color.match(/\d+/g)?.slice(0, 3).map(Number) || [255, 255, 255];
  const lightText = rgb.reduce((sum, value) => sum + value, 0) > 400;
  const background = style.backgroundColor;
  panelHost.style.setProperty('--jh-fg', style.color);
  panelHost.style.setProperty('--jh-bg', background === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(background) ? (lightText ? '#161616' : '#ffffff') : background);
  panelHost.style.setProperty('--jh-font', style.fontFamily);
  panelHost.style.setProperty('--jh-radius', style.borderRadius === '0px' ? '4px' : style.borderRadius);
}
function updatePanel() {
  if (!panelRoot) return;
  const state = subtitles.state();
  const source = panelRoot.querySelector('[data-source]');
  source.querySelector('[value="current"]').textContent = state.label || 'None';
  source.value = 'current';
  const offset = panelRoot.querySelector('[data-offset-range]');
  offset.min = Math.min(-30, state.offset);
  offset.max = Math.max(30, state.offset);
  offset.value = state.offset;
  const offsetText = `${state.offset >= 0 ? '+' : ''}${state.offset.toFixed(1)} s`;
  offset.setAttribute('aria-valuetext', offsetText);
  panelRoot.querySelector('[data-offset]').textContent = offsetText;
  for (const selector of ['[data-earlier]', '[data-later]', '[data-reset]', '[data-offset-range]']) panelRoot.querySelector(selector).disabled = !state.cueCount;
  const rate = panelRoot.querySelector('[data-rate]');
  const speed = currentVideo?.playbackRate || 1;
  rate.min = Math.min(0.5, speed);
  rate.max = Math.max(4, speed);
  rate.value = speed;
  const speedText = `${Number(speed.toFixed(2))}×`;
  rate.setAttribute('aria-valuetext', speedText);
  panelRoot.querySelector('[data-rate-output]').textContent = speedText;
  panelRoot.querySelector('[data-slower]').disabled = !currentVideo || speed <= Number(rate.min);
  panelRoot.querySelector('[data-faster]').disabled = !currentVideo || speed >= Number(rate.max);
  panelRoot.querySelector('[data-back]').textContent = `−${settings.skipSeconds} s`;
  panelRoot.querySelector('[data-forward]').textContent = `+${settings.skipSeconds} s`;
  panelRoot.querySelector('[data-auto-state]').textContent = subtitleMessage;
}
function subtitleURL(value) {
  let url;
  try { url = new URL(value); } catch { throw new Error('Enter a direct HTTPS Subtitle Cat SRT or VTT URL.'); }
  if (url.protocol !== 'https:' || !['www.subtitlecat.com', 'subtitlecat.com'].includes(url.hostname) || url.username || url.password || !/^\/subs\/.+\.(?:srt|vtt)$/i.test(url.pathname)) throw new Error('Only direct HTTPS Subtitle Cat /subs/…srt or …vtt URLs are permitted.');
  return url;
}
async function loadBuffer(buffer, label, options, token) {
  const owner = serviceContext;
  const hash = await crypto.subtle.digest('SHA-256', buffer);
  const source = [...new Uint8Array(hash)].map(value => value.toString(16).padStart(2, '0')).join('');
  if (owner !== serviceContext || token !== autoGeneration) return;
  if (subtitleMode === 'auto' && skipAutomaticSubtitles()) return;
  await subtitles.load(buffer, label, source, options);
  subtitleMessage = '';
  updatePanel();
}
function hasBuiltInSubtitles() {
  if (isFrame && context.builtIn) return true;
  if ([...currentVideo?.textTracks || []].some(track => ['subtitles', 'captions'].includes(track.kind) && track.label !== 'jav-helper')) return true;
  if (hostName === 'jable.tv') {
    for (const anchor of document.querySelectorAll('.video-info .tags a[href]')) {
      if (anchor.textContent.trim() !== '中文字幕') continue;
      try {
        const url = new URL(anchor.href);
        if (url.hostname === 'jable.tv' && url.pathname === '/categories/chinese-subtitle/') return true;
      } catch { /* A malformed site tag cannot establish caption availability. */ }
    }
  }
  const heading = document.querySelector(hostName === 'jable.tv' ? '.video-info h4' : 'h1');
  return /中文字幕|Chinese subtitles/i.test(heading?.textContent || '');
}
function skipAutomaticSubtitles() {
  return hasBuiltInSubtitles() && !settings.forceSubtitles && !forceForPage;
}
async function autoSelect(explicit = false) {
  const selectedCode = pageCode();
  if (!selectedCode || !currentVideo || subtitleMode !== 'auto' || (!settings.autoSubtitles && !explicit)) return;
  if (skipAutomaticSubtitles()) {
    subtitleMessage = '';
    updatePanel();
    return;
  }
  const token = ++autoGeneration;
  const owner = serviceContext;
  subtitleMessage = 'Loading…';
  updatePanel();
  try {
    const selection = await subtitlecat.find(selectedCode, settings.subtitleLanguages, { edition: context.edition });
    if (token !== autoGeneration || owner !== serviceContext) return;
    if (skipAutomaticSubtitles()) return;
    if (!selection) { subtitleMessage = 'No matching subtitles'; updatePanel(); return; }
    const url = subtitleURL(selection.url);
    const response = await request({ url: url.href, responseType: 'arraybuffer', anonymous: true });
    if (response.status !== 200) throw new Error(`Subtitle download failed: HTTP ${response.status}.`);
    subtitleURL(response.finalUrl);
    if (token !== autoGeneration || owner !== serviceContext || subtitleMode !== 'auto') return;
    if (skipAutomaticSubtitles()) return;
    await loadBuffer(response.buffer, selection.label, { language: selection.language, encoding: 'utf-8' }, token);
  } catch (error) {
    if (token !== autoGeneration || owner !== serviceContext) return;
    subtitleMessage = error.message;
    updatePanel();
  }
}
function renderJavDB() {
  libraryUI?.render({ state: dbState, busy: dbBusy, error: dbError, enabled: settings.javdbSync });
}
async function refreshJavDB(chosenUrl, manual = false) {
  if (isFrame || !settings.javdbSync || !isDetail() || !pageCode() || dbSaving) return;
  const token = ++dbGeneration;
  const owner = serviceContext;
  const selectedCode = pageCode();
  dbBusy = true;
  dbError = '';
  renderJavDB();
  const pending = (async () => {
    try {
      const choice = chosenUrl || GM_getValue(`jav-helper:javdb-choice:${selectedCode}`, '');
      const state = choice ? await javdb.load(choice, selectedCode) : await javdb.lookup(selectedCode);
      if (token !== dbGeneration || owner !== serviceContext) return;
      dbState = state;
      lastDBRefresh = Date.now();
      if (manual) dbWriteUncertain = false;
    } catch (error) {
      if (token !== dbGeneration || owner !== serviceContext) return;
      dbError = error.message;
    } finally {
      if (token === dbGeneration && owner === serviceContext) { dbBusy = false; renderJavDB(); }
    }
  })();
  dbReadPromise = pending;
  await pending;
  if (dbReadPromise === pending) dbReadPromise = null;
}
function handleNativePlay() {
  if (!settings.javdbSync) return;
  if (!isFrame) return queueJavDBAction('play');
  if (!frameToken || !pageCode()) return;
  // Manager storage, not a page-visible postMessage, carries account-write intent.
  Promise.resolve(GM_setValue(FRAME_PLAY_KEY, {
    token: frameToken, code: pageCode(), edition: context.edition, nonce: crypto.randomUUID()
  })).then(() => GM_deleteValue(FRAME_PLAY_KEY)).catch(error => log({ event: 'frame-play-error', message: error.message }));
}
function queueJavDBAction(action, score) {
  const owner = serviceContext;
  const generation = serviceGeneration;
  const isCurrent = () => !destroyed && !isFrame && owner && owner === serviceContext &&
    generation === serviceGeneration && settings.javdbSync && settings[siteKey] && isDetail();
  dbOperationChain = dbOperationChain.catch(() => {}).then(async () => {
    if (!isCurrent()) return;
    if (dbReadPromise) await dbReadPromise;
    if (!isCurrent()) return;
    if (dbWriteUncertain) {
      dbError = 'The previous save could not be confirmed. Use Retry before changing this record.';
      renderJavDB();
      return;
    }
    if (!dbState?.url || !['unmarked', 'wanted', 'watched'].includes(dbState.status)) {
      renderJavDB();
      return;
    }
    const url = dbState.url;
    const selectedCode = pageCode();
    const token = ++dbGeneration;
    dbBusy = true;
    dbSaving = true;
    dbError = '';
    renderJavDB();
    try {
      const perform = confirmDelete => action === 'rate' ?
        javdb.save(url, selectedCode, { status: 'watched', score }, { isCurrent }) :
        javdb.synchronize(url, selectedCode, action, { confirmDelete, isCurrent });
      let saved;
      try { saved = await perform(false); }
      catch (error) {
        if (error.code !== 'confirmation-required' || !isCurrent()) throw error;
        dbState = error.state || dbState;
        dbBusy = false;
        renderJavDB();
        const confirmed = await libraryUI.requestDeleteConfirmation();
        if (!confirmed || !isCurrent()) return;
        dbBusy = true;
        renderJavDB();
        saved = await perform(true);
      }
      if (!isCurrent() || token !== dbGeneration) return;
      dbState = saved;
      lastDBRefresh = Date.now();
      await GM_setValue(REVISION_KEY, { code: selectedCode, time: Date.now(), nonce: crypto.randomUUID() });
    } catch (error) {
      if (!isCurrent() || token !== dbGeneration) return;
      dbError = error.message;
      dbWriteUncertain = error.outcomeUnknown === true;
    } finally {
      dbSaving = false;
      if (isCurrent() && token === dbGeneration) { dbBusy = false; renderJavDB(); }
    }
  });
  return dbOperationChain;
}
function startServices() {
  const selectedCode = pageCode();
  if (!selectedCode || (!isFrame && !isDetail())) return;
  const owner = JSON.stringify([selectedCode, context.edition]);
  if (owner !== serviceContext) {
    serviceContext = owner;
    ++serviceGeneration;
    nativeSite?.reset();
    libraryUI?.reset();
    subtitles.clear();
    ++autoGeneration;
    ++dbGeneration;
    subtitleMode = 'auto';
    forceForPage = false;
    dbState = null;
    dbError = '';
    dbBusy = false;
    dbSaving = false;
    dbWriteUncertain = false;
    lastDBRefresh = 0;
    automationSignature = '';
  }
  const signature = JSON.stringify([owner, Boolean(currentVideo), settings.subtitleLanguages, settings.autoSubtitles, settings.forceSubtitles, hasBuiltInSubtitles(), settings.javdbSync]);
  if (signature === automationSignature) return;
  automationSignature = signature;
  ++autoGeneration;
  if (currentVideo) {
    if (subtitleMode === 'auto' && skipAutomaticSubtitles()) {
      subtitles.clear();
      subtitleMessage = '';
    } else if (settings.autoSubtitles && subtitleMode === 'auto') autoSelect();
    else if (!settings.autoSubtitles) subtitleMessage = '';
  }
  if (!isFrame && settings.javdbSync) refreshJavDB();
  else { ++dbGeneration; dbBusy = false; }
  updatePanel();
  renderJavDB();
}
function closeSettings() {
  settingsPort?.close();
  settingsPort = null;
  settingsHost?.remove();
  settingsHost = null;
  settingsFrame = null;
  settingsToken = null;
  settingsTrigger?.focus();
}
function openSettings() {
  if (settingsHost) return;
  settingsTrigger = document.activeElement;
  settingsToken = crypto.randomUUID();
  settingsHost = document.createElement('div');
  settingsHost.id = 'jav-helper-settings';
  const root = settingsHost.attachShadow({ mode: 'open' });
  root.innerHTML = `<style>:host{position:fixed;inset:0;z-index:2147483647;background:#000a;display:flex;align-items:center;justify-content:center;padding:20px;box-sizing:border-box}.wrap{width:min(1440px,100%);height:94vh;position:relative;background:#101413;border-radius:10px;overflow:hidden}iframe{width:100%;height:100%;border:0}button{position:absolute;right:10px;top:8px;z-index:2;border:1px solid #a5e4bd;background:#182a1f;color:#e5ede8;border-radius:6px;padding:8px 12px;cursor:pointer}@media(max-width:600px){:host{padding:0}.wrap{height:100dvh;border-radius:0}}</style><div class="wrap" role="dialog" aria-modal="true" aria-label="jav-helper settings"><button aria-label="Close jav-helper settings">Close ×</button><iframe title="jav-helper control page" sandbox="allow-scripts allow-downloads allow-popups allow-popups-to-escape-sandbox"></iframe></div>`;
  settingsFrame = root.querySelector('iframe');
  const frame = settingsFrame;
  const token = settingsToken;
  frame.addEventListener('load', () => {
    if (settingsFrame !== frame || settingsToken !== token) return;
    settingsPort?.close();
    const channel = new MessageChannel();
    const port = channel.port1;
    settingsPort = port;
    port.onmessage = event => {
      const data = event.data;
      if (!event.isTrusted || settingsPort !== port || settingsToken !== token || data?.source !== 'jav-helper' || data.token !== token) return;
      if (data.type === 'close') return closeSettings();
      if (data.type !== 'save') return;
      saveChain = saveChain.then(async () => {
        const next = JHSettings.validate(data.document);
        await GM_setValue(SETTINGS_KEY, JHSettings.document(next));
        settings = next;
        await applySettings();
        port.postMessage({ source: 'jav-helper', type: 'saved', token, document: JHSettings.document(settings) });
      }).catch(error => port.postMessage({ source: 'jav-helper', type: 'error', token, message: error.message }));
    };
    port.start();
    // The private port is delivered directly to this exact opaque-origin iframe.
    frame.contentWindow.postMessage({ source: 'jav-helper', type: 'connect', token }, '*', [channel.port2]);
  }, { once: true });
  const payload = JSON.stringify({ token: settingsToken, origin: location.origin, document: JHSettings.document(settings) }).replace(/</g, '\\u003c');
  settingsFrame.srcdoc = JH_SETTINGS_PAGE.replace('__JH_EMBED_PAYLOAD__', payload);
  root.querySelector('button').addEventListener('click', closeSettings);
  settingsHost.addEventListener('click', event => { if (event.target === settingsHost && event.composedPath()[0] === settingsHost) closeSettings(); });
  const fullscreen = document.fullscreenElement;
  (fullscreen && fullscreen.tagName !== 'VIDEO' ? fullscreen : document.body).append(settingsHost);
  root.querySelector('button').focus();
}
function sendSettings(type, extra = {}) {
  settingsPort?.postMessage({ source: 'jav-helper', type, token: settingsToken, ...extra });
}
async function applySettings() {
  const enabled = isFrame ? settings.av123 : isJavDB || settings[siteKey];
  if (!enabled) {
    missavUI?.destroy();
    missavUI = undefined;
    resetPage();
  }
  subtitles.updateStyle();
  refresh();
}
window.addEventListener('message', event => {
  if (!event.isTrusted || !event.data || typeof event.data !== 'object') return;
  const data = event.data;
  if (data.source !== FRAME_SOURCE) return;
  if (isFrame) {
    if (event.origin !== TOP_ORIGIN || event.source !== window.parent || !settings.av123 || data.type !== 'hello' || typeof data.token !== 'string' || typeof data.context?.title !== 'string' || typeof data.context?.edition !== 'string') return;
    if (context.title !== data.context.title || context.edition !== data.context.edition) { subtitles.clear(); serviceContext = ''; ++autoGeneration; }
    frameToken = data.token;
    context = { title: data.context.title.slice(0, 128), edition: data.context.edition.slice(0, 512), builtIn: data.context.builtIn === true };
    refresh();
    window.parent.postMessage({ source: FRAME_SOURCE, type: 'ready', token: frameToken }, TOP_ORIGIN);
  } else if (event.origin === FRAME_ORIGIN && event.source === selectedFrame?.contentWindow) {
    if (data.type === 'available') helloFrame();
    else if (data.type === 'ready' && data.token === frameToken) frameReady = true;
  }
});
function publishJavDBRevision() {
  if (!isJavDB) return;
  const selectedCode = pageCode();
  if (!selectedCode || !/^\/v\/[A-Za-z0-9]+\/?$/.test(location.pathname)) return;
  const title = [...document.querySelectorAll('.review-title .tag')].map(node => node.textContent.trim());
  const score = document.querySelector('input[name="video_review[score]"]:checked')?.value || '';
  const actions = [...document.querySelectorAll('form[action*="/reviews"],a[data-method="delete"][href*="/reviews/"]')].map(node => node.getAttribute('action') || node.getAttribute('href'));
  const signature = JSON.stringify([selectedCode, title, score, actions]);
  if (signature === lastJavDBSignature) return;
  lastJavDBSignature = signature;
  GM_setValue(REVISION_KEY, { code: selectedCode, time: Date.now(), nonce: crypto.randomUUID() });
}
function resetPage() {
  ++autoGeneration;
  ++dbGeneration;
  ++serviceGeneration;
  nativeSite?.reset();
  libraryUI?.reset();
  dbState = null;
  dbError = '';
  dbBusy = false;
  dbSaving = false;
  dbWriteUncertain = false;
  serviceContext = '';
  automationSignature = '';
  currentVideo?.textTracks?.removeEventListener('addtrack', scheduleRefresh);
  subtitles.clear();
  player.setVideo(null);
  subtitles.setVideo(null);
  currentVideo = null;
  nativeButton?.remove();
  nativeButton = null;
  panelHost?.remove();
  panelHost = null;
  panelRoot = null;
  setFrame(null);
}
function refresh() {
  if (destroyed) return;
  if (isJavDB) return publishJavDBRevision();
  if (!(isFrame ? settings.av123 : settings[siteKey])) return;
  if (hostName === 'missav.ws') {
    missavUI ||= JHMissavUI.create();
    missavUI.refresh();
  }
  if (!isFrame && pageURL !== location.href) { pageURL = location.href; resetPage(); }
  if (!isFrame) {
    context = { title: pageCode(), edition: location.pathname, builtIn: hasBuiltInSubtitles() };
    if (!isDetail()) return;
    setFrame(frameElement());
    if (selectedFrame && !frameReady) helloFrame();
  }
  const video = visibleVideo();
  if (video !== currentVideo) {
    currentVideo?.textTracks?.removeEventListener('addtrack', scheduleRefresh);
    currentVideo = video;
    currentVideo?.textTracks?.addEventListener('addtrack', scheduleRefresh);
    player.setVideo(video);
    subtitles.setVideo(video);
    log({ event: 'player', host: hostName, frame: isFrame, found: Boolean(video) });
  }
  startServices();
  const favorite = nativeSite.scan();
  if (settings.javdbSync && favorite) libraryUI?.mount(favorite);
  else libraryUI?.reset();
  renderJavDB();
  if (currentVideo) {
    if (!panelHost) createPanel();
    attachNativeControl();
    positionPanel();
    updatePanel();
  }
}
function scheduleRefresh() {
  if (refreshTimer || destroyed) return;
  refreshTimer = setTimeout(() => { refreshTimer = null; refresh(); }, 150);
}
listeners.push(GM_addValueChangeListener(SETTINGS_KEY, (_key, _old, value, remote) => {
  if (!remote) return;
  try {
    settings = JHSettings.validate(value);
    applySettings().catch(error => notify(error.message));
    sendSettings('settings', { document: JHSettings.document(settings) });
  } catch (error) { notify(error.message); }
}));
listeners.push(GM_addValueChangeListener(REVISION_KEY, (_key, _old, value, remote) => {
  if (remote && value?.code === pageCode() && !isJavDB && !isFrame && settings.javdbSync && isDetail()) refreshJavDB();
}));
listeners.push(GM_addValueChangeListener(FRAME_PLAY_KEY, (_key, _old, value, remote) => {
  if (!remote || isFrame || hostName !== '123av.com' || !selectedFrame || value?.token !== frameToken ||
    value.code !== pageCode() || value.edition !== context.edition || typeof value.nonce !== 'string') return;
  queueJavDBAction('play');
}));
const observer = new MutationObserver(records => {
  if (isJavDB || records.some(record => record.target.nodeType === 1 && record.target.closest('.video-info .tags') || [...record.addedNodes, ...record.removedNodes].some(node => node.nodeType === 1 && (node.matches('video,iframe,button,.plyr,.plyr__controls,.video-info,.tags,h1,h4') || node.querySelector('video,iframe,button'))))) scheduleRefresh();
});
observer.observe(document.body, { childList: true, subtree: true });
const themeObserver = new MutationObserver(() => {
  positionPanel();
  const favorite = nativeSite?.getFavorite();
  if (favorite && settings.javdbSync) libraryUI?.mount(favorite);
});
for (const target of [document.body, document.documentElement]) themeObserver.observe(target, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
document.addEventListener('loadedmetadata', scheduleRefresh, true);
document.addEventListener('fullscreenchange', positionPanel);
document.addEventListener('keydown', event => { if (event.key === 'Escape' && settingsHost) closeSettings(); }, true);
function refreshOnFocus() {
  if (!document.hidden && !isFrame && !isJavDB && isDetail() && settings.javdbSync && !dbBusy && Date.now() - lastDBRefresh > 30000) refreshJavDB();
}
window.addEventListener('focus', refreshOnFocus);
document.addEventListener('visibilitychange', refreshOnFocus);
const navigationTimer = setInterval(() => { if (pageURL !== location.href || (currentVideo && !currentVideo.isConnected)) scheduleRefresh(); }, 1000);
window.addEventListener('pagehide', event => {
  player.boostEnd();
  if (event.persisted) return;
  destroyed = true;
  ++autoGeneration;
  ++dbGeneration;
  clearTimeout(refreshTimer);
  clearInterval(navigationTimer);
  currentVideo?.textTracks?.removeEventListener('addtrack', scheduleRefresh);
  observer.disconnect();
  themeObserver.disconnect();
  subtitles.destroy();
  player.destroy();
  nativeSite?.destroy();
  libraryUI?.destroy();
  missavUI?.destroy();
  for (const listener of listeners) GM_removeValueChangeListener(listener);
});
if (!isFrame) GM_registerMenuCommand('jav-helper Settings', openSettings);
if (isFrame) window.parent.postMessage({ source: FRAME_SOURCE, type: 'available' }, TOP_ORIGIN);
refresh();
log({ event: 'ready', host: hostName, frame: isFrame, code: pageCode(), player: Boolean(currentVideo) });

})().catch(error => { console.error('[jav-helper]', error); if (typeof GM_log === 'function') GM_log(JSON.stringify({kind:'error',message:String(error.message)})); });