AvBase Ultimate Enhancer

首頁/演員頁/作品列表頁(日期・新着・搜尋・標籤)/作品詳情頁/個人動態(首頁feed・ブックマーク) 統一改版:全寬(document-start 搶插版)、自訂欄數、大封面卡片、統一浮層(封面+預覽圖+試看+代碼日期資訊+空白鍵播放+自動跳轉+循環播放全部+VR網址自動偵測+VR頭戴沉浸模式進入鈕+播放器自適應滿屏+找不到試看影片提示+試看網址多重猜測+非DMM來源備援)、sukebei 種子搜尋(含複製磁鏈)、missav 快捷、演員頁側欄精簡(保留原生互動+頭像載入時機修正)、標籤頁分類側欄精簡、個人動態頁 Pinterest 風格(移除左側留白)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         AvBase Ultimate Enhancer
// @namespace    http://tampermonkey.net/
// @version      4.2.0
// @description  首頁/演員頁/作品列表頁(日期・新着・搜尋・標籤)/作品詳情頁/個人動態(首頁feed・ブックマーク) 統一改版:全寬(document-start 搶插版)、自訂欄數、大封面卡片、統一浮層(封面+預覽圖+試看+代碼日期資訊+空白鍵播放+自動跳轉+循環播放全部+VR網址自動偵測+VR頭戴沉浸模式進入鈕+播放器自適應滿屏+找不到試看影片提示+試看網址多重猜測+非DMM來源備援)、sukebei 種子搜尋(含複製磁鏈)、missav 快捷、演員頁側欄精簡(保留原生互動+頭像載入時機修正)、標籤頁分類側欄精簡、個人動態頁 Pinterest 風格(移除左側留白)
// @match        https://www.avbase.net/*
// @match        https://avbase.net/*
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      sukebei.nyaa.si
// @license      MIT
// ==/UserScript==

/*
 * ============================================================================
 * 維護須知(給未來要修這支腳本的人/AI 看)
 * ----------------------------------------------------------------------------
 * AvBase 是 Next.js 網站,class 大多是 Tailwind 自動產生,改版時很容易全部
 * 跑掉。這支腳本盡量避免死綁一長串 class,而是抓「結構特徵」:
 *
 *   - 作品卡片:辨識依據是卡片內部一定有 `.flex.min-w-0.border-y.border-light`
 *     這個「小圖 + 標題」列,以及一個 `a[href^="/works/"]` 標題連結。
 *
 *   - 全寬:目前發現網站有兩種「限制寬度」的容器 class:`.max-w-7xl`(首頁/
 *     演員頁/作品詳情頁/個人動態頁)與 `.cl-container`(/works/date、
 *     /works/recent、/works?q=... 這類列表頁)。兩個都要蓋掉 max-width,
 *     將來如果又出現第三種,同樣加進 GM_addStyle 那段最上面的全寬區塊即可。
 *     這條規則用「重複 class 拉高 specificity」的寫法(例如
 *     `.cl-container.cl-container`),是因為 /works/date、/works/recent、
 *     /works?q=... 這類搜尋結果頁在 SPA 切頁時,Next.js 有時會晚一步才把
 *     該頁專屬的 CSS chunk 插進 <head>,順序排在我們的 GM_addStyle 後面;
 *     雙方都用 !important 時瀏覽器就會比 specificity 而不是比誰後宣告,所以
 *     故意疊 class 把分數拉高,確保不管誰先誰後都是我們贏。
 *
 *   - 封面大圖網址:DMM/FANZA 圖檔命名規則固定「資料夾名 = 檔名去掉 ps/pl/jp」,
 *     所以直接用 cid 組 pl.jpg 網址,不依賴頁面上有沒有現成的大圖連結。
 *     ※ 但要注意 DMM 圖片實際上有兩種路徑家族:
 *       (a) `/digital/video/<cid>/<cid>pl.jpg`(VR、單體配信等「digital」系列)
 *       (b) `/mono/movie/adult/<cid>/<cid>pl.jpg`(單体作品 mono 系列,常見於
 *           ABF、NAAC 這類代碼,小圖網址本身就長在 `/mono/movie/` 底下)
 *       兩種資料夾結構不能互換,猜錯路徑會拿到不存在或錯誤的圖。過去的版本
 *       對 mono 系列一律先猜 (a) 的 digital/video 路徑,猜錯時 DMM 有時不會
 *       回傳明確的 404(可能是預留位置圖或跳轉),導致 <img onerror> 不會
 *       觸發、fallback chain 卡住不會往下一個候選網址走,使用者只會看到
 *       「用小圖硬撐」或整張圖是灰的。修正方式是:只要偵測到 smallSrc 本身
 *       是 `/mono/movie/` 路徑,就直接優先用同一個資料夾把 ps 換成 pl,不要
 *       猜 digital/video 那個路徑;digital/video 只在 smallSrc 本身就是走
 *       digital 家族時才猜。
 *
 *   - 試看影片網址 / 預覽圖 / 是否為 VR:全部從 Next.js 內建的
 *     `<script id="__NEXT_DATA__">` 這個 JSON 標籤直接 JSON.parse 出來,
 *     走 props.pageProps.work.products 陣列(每個 product 是不同的販售通路,
 *     各自可能有自己的 sample_movie_url / sample_image_urls)。這比起用正規
 *     表示式在整段 HTML 裡亂抓穩定非常多,是改版後最不容易失效的地方。
 *     如果 Next.js 資料結構改了(例如 work 欄位改名),要修的地方只有
 *     extractWorkInfoFromNextData() 這個函式。
 *     萬一哪天 __NEXT_DATA__ 整個消失(改用別的資料傳遞方式),才需要回頭用
 *     extractSampleVideoUrlFallback() 這類正規表示式硬找,那只是保險用的備援。
 *     另外 __NEXT_DATA__ 給的 sample_movie_url 常常是 null(尤其 duga 來源、
 *     或還沒上架的預售品),但實測發現只要照 DMM 的檔名規則自己組網址,
 *     很多時候影片其實是存在的,只是官方沒把網址寫進資料裡。
 *     buildTrailerUrlCandidates() 就是把目前已知會中的幾種猜測網址準備好,
 *     播放時依序試(跟封面圖的 fallback chain 是同一個精神),全部試過還是
 *     不行才真的代表沒有試看影片。這幾條規則是實測整理出來的,不保證每支
 *     影片都適用,DMM 那邊換路徑規則的話要重新抓包更新這裡。
 *
 *   - VR 判斷:目前用 product_id / 標題裡有沒有出現「VR」「vrkm」「savr」
 *     「urvrsp」等已知字樣來判斷,寫在 isVRProduct()。這是最土炮但最不依賴
 *     頁面結構的作法;如果之後發現誤判,調整這裡的關鍵字即可。
 *
 *   - sukebei 種子搜尋:讀 sukebei.nyaa.si 的 RSS(?page=rss&q=...),解析
 *     <item> 底下 nyaa 命名空間欄位(seeders/leechers/size/infoHash)。
 *     磁力連結是自己用 infoHash 組出來的(sukebei RSS 沒有直接給 magnet
 *     欄位)。如果 sukebei 改了 RSS 結構,要修的地方是 parseSukebeiRss()。
 *
 *   - 標籤頁 (/tags/xxx) 左側「分類」欄位跟演員頁側欄根本是同一套版面(外層
 *     都是那個 4 欄 grid,左欄是 `.lg:col-span-1`),所以用同樣的手法處理:
 *     外層 grid 疊上 avbase-talent-stack 變成上下堆疊,側欄本身再精簡成一排
 *     靠左橫向排列(分類名稱+子標籤展開鈕+編輯鈕+フォロー鈕),這樣就不
 *     會在作品列表左邊留一大塊空白。寫在 rebuildTagSidebar()。
 *
 *   - 作品詳情頁封面/浮層:舊版只認「頁面上有沒有 `img[src*="pl.jpg"]`」
 *     這一個條件來決定要不要把封面點擊事件掛上去。但很多作品的販售來源根本
 *     不是 DMM(例如 mgstage、duga、自社配信),這類頁面上完全不會出現
 *     `pl.jpg` 字樣的圖,於是整個 rebuildWorkDetailCover() 直接提前 return,
 *     導致封面點了沒反應、浮層完全打不開。修正方式是不要只依賴 DOM 選擇器
 *     找封面圖,而是「先讀 __NEXT_DATA__ 拿到這個作品的權威資料(cid、標題、
 *     封面大圖網址、演員),再回頭找一個『看起來像封面』的 DOM 元素當作點擊
 *     目標」;就算找不到理想的封面 <img>,也會退而求其次把 <h1> 或整個
 *     封面容器當作點擊熱區,確保浮層永遠掛得上去。細節在
 *     rebuildWorkDetailCover() 與新增的 findWorkDetailCoverImgEl()。
 *
 *   - 個人動態頁(首頁 /home?... 的「あなたのタイムライン」/收藏頁
 *     /home/bookmarks 的「あなたの保存」)跟作品列表頁共用同一種卡片格
 *     (`grid.sm:grid-cols-2.gap-4`),但外層多包了一層「4 欄 grid,左欄
 *     放歡迎詞 hero 卡片、右邊 3 欄放篩選列+卡片」的版面,導致跟演員頁一樣
 *     的「左邊留白」問題。這裡不像演員頁那樣把 hero 卡片整塊搬到上方堆疊
 *     (那樣還是會佔一整排的高度),而是把 hero 卡片精簡成一條窄的橫幅塞在
 *     篩選列上方,同時讓卡片區塊佔滿全寬,寫在 rebuildFeedHero()。
 *     這類頁面用「外層 4 欄 grid 裡,其中一欄含有 `<h1>` 且沒有作品卡片」
 *     這個結構特徵去找 hero 卡片,不依賴頁面標題文字(「あなたのタイムライン」
 *     /「あなたの保存」之類的字樣以後可能會改版),比較耐改版。
 * ============================================================================
 */

(function () {
  'use strict';

  if (window.__avbaseReforgeLoaded) return;
  window.__avbaseReforgeLoaded = true;

  /* =========================================================
   * 0. 常數
   * ========================================================= */
  const COLS_KEY = 'avbase_reforge_cols_v1';
  const COLS_MIN = 3;
  const COLS_MAX = 12;
  const COLS_DEFAULT = 6;

  /* =========================================================
   * 0-1. 頁面類型判斷(全部功能的總開關,改版後路徑變了先來這裡改)
   * ========================================================= */
  function isHomePage() {
    return location.pathname === '/' || location.pathname === '';
  }

  // 個人動態首頁(登入後看到「あなたのタイムライン」的那個 /home),
  // 帶不帶 query string 都算(篩選條件、分頁都是 query string)
  function isHomeFeedPage() {
    return location.pathname === '/home';
  }

  // 收藏頁「あなたの保存」,版面跟 isHomeFeedPage() 是同一套,只是路徑不同
  function isBookmarksPage() {
    return location.pathname === '/home/bookmarks';
  }

  // 這兩個頁面(動態首頁/收藏頁)共用「外層 4 欄 grid + hero 卡片」那套版面,
  // 想再加其他子頁面(例如 /home/follows)的話一併加進這裡
  function isFeedStyleHomePage() {
    return isHomeFeedPage() || isBookmarksPage();
  }

  function isTalentPage() {
    if (!/^\/talents\//.test(location.pathname)) return false;
    if (/^\/talents\/(recent|popular|edit)(\/|$)/.test(location.pathname)) return false;
    return true;
  }

  // 作品「列表」頁:日期瀏覽 /works/date(/YYYY-MM-DD)、新着 /works/recent、
  // 搜尋結果 /works(?q=...)
  function isWorksListPage() {
    const p = location.pathname;
    if (p === '/works') return true;
    if (p === '/works/date' || p.startsWith('/works/date/')) return true;
    if (p === '/works/recent') return true;
    return false;
  }

  // 標籤頁 /tags/xxx,觀察到跟其他列表頁共用同一套卡片元件
  function isTagPage() {
    return /^\/tags\//.test(location.pathname);
  }

  // 作品「詳情」頁:/works/<maker:CODE> 或 /works/<CODE>,且不是上面的列表頁
  function isWorkDetailPage() {
    const p = location.pathname;
    if (!p.startsWith('/works/')) return false;
    if (isWorksListPage()) return false;
    const rest = p.slice('/works/'.length);
    return rest.length > 0;
  }

  // 這幾種頁面共用「全寬 + 網格化 + 卡片重建」那一套邏輯
  function isCardGridPage() {
    return isHomePage() || isTalentPage() || isWorksListPage() || isTagPage() || isFeedStyleHomePage();
  }

  /* =========================================================
   * 1. 樣式
   * ========================================================= */

  // ---- 1-0. 全寬修正:必須最早插入,跟 Next.js 自己的 CSS 搶時間 ----
  // 光靠「重複 class 拉高 specificity」不夠:實測發現這幾個列表頁在 SPA
  // 路由切換時,Next.js 有時會晚一步才把該頁專屬的 CSS chunk(同樣用
  // !important)插進 <head>,順序排在我們的樣式後面,結果就變成後者覆蓋
  // 前者,即使我們 specificity 比較高也可能因為某些瀏覽器/情境判斷差異而
  // 翻車。最穩的做法是用 @run-at document-start,在 Next.js 自己的 CSS
  // 都還沒開始載入之前,就把我們的 <style> 塞進 <head> 最前面,這樣不管
  // Next.js 之後插入幾份 CSS,我們永遠是「先來的那個 !important」,
  // 瀏覽器對這種情況一律用「後宣告的贏」,所以只要每次路由切換都重新確認
  // 我們的 <style> 還在文件最前面即可(見下方 ensureFullWidthStyleFirst)。
  // 用原生 document.createElement('style') 而非 GM_addStyle,因為
  // document-start 階段 document.head 可能都還不存在,GM_addStyle 在部分
  // 腳本管理器上會等 head 出現才插入,時機沒有保證比 Next.js 早。
  const FULL_WIDTH_STYLE_ID = 'avbase-reforge-fullwidth';
  const FULL_WIDTH_CSS = `
    /* 首頁/演員頁/作品詳情頁/個人動態頁用 .max-w-7xl,/works/date /works/recent
       /works?q=... 這類列表頁用 .cl-container,兩種都要蓋掉。
       [class*="..."] 版本是保險,避免哪天 class 順序或多加字尾導致
       純 .class 選擇器選不到 */
    .max-w-7xl, .max-w-7xl.max-w-7xl,
    .cl-container, .cl-container.cl-container, .cl-container.cl-container.cl-container,
    [class*="max-w-7xl"], [class*="cl-container"] {
      max-width: 100% !important;
      width: 100% !important;
    }
  `;
  function insertFullWidthStyle() {
    let el = document.getElementById(FULL_WIDTH_STYLE_ID);
    if (!el) {
      el = document.createElement('style');
      el.id = FULL_WIDTH_STYLE_ID;
      el.textContent = FULL_WIDTH_CSS;
    }
    const head = document.head || document.documentElement;
    if (!head) return;
    // 只要不是「已經是 head 的第一個 style/link 子節點」就搬到最前面,
    // 確保永遠比 Next.js 自己插入的任何 CSS 都早(source order 上)
    if (head.firstChild !== el) head.insertBefore(el, head.firstChild);
  }
  insertFullWidthStyle();
  // Next.js client-side 路由切換、或又插入新的 CSS chunk 時,把我們的
  // style 重新搬回最前面,避免被「後宣告覆蓋前宣告」反咬一口
  new MutationObserver(insertFullWidthStyle).observe(
    document.documentElement,
    { childList: true, subtree: true }
  );
  document.addEventListener('DOMContentLoaded', insertFullWidthStyle);

  GM_addStyle(`
    :root { --avbase-cols: ${COLS_DEFAULT}; }

    /* ---- 首頁「本日発売の作品 / 最近登録された作品」左右並排容器改成上下堆疊 ---- */
    .avbase-full-stack { display: block !important; }
    .avbase-full-stack > * { width: 100% !important; }
    .avbase-full-stack > * + * { margin-top: 12px; }

    /* ---- 作品卡片網格(首頁 / 演員頁 / 作品列表頁 / 標籤頁 / 個人動態頁 共用)---- */
    .avbase-grid2 {
      display: grid !important;
      grid-template-columns: repeat(var(--avbase-cols), minmax(0, 1fr)) !important;
      gap: 14px !important;
    }
    .avbase-grid2 > .relative { width: 100%; margin: 0 !important; }

    /* ---- 卡片本體:作品代碼/日期 -> 封面(橫向為主) -> 標題(最多三行) -> 演員 ---- */
    .avbase-card2 { display: flex; flex-direction: column; height: 100%; }
    .avbase-meta2 {
      order: 1; padding: 8px 10px; font-size: 11px; color: #9ca3af;
      border-bottom: 1px dashed rgba(148,163,184,.25);
    }
    .avbase-meta2 a { color: inherit; }
    .avbase-cover2 {
      order: 2;
      position: relative;
      width: 100%;
      aspect-ratio: 16 / 10;
      background: #0b1220;
      overflow: hidden;
      cursor: zoom-in;
      display: flex; align-items: center; justify-content: center;
    }
    .avbase-cover2-img { width: 100%; height: 100%; object-fit: contain; display: block; transition: transform .25s ease; }
    .avbase-cover2:hover .avbase-cover2-img { transform: scale(1.03); }
    /* 註:故意不做放大鏡圖示,滑鼠移上去 cursor 本身就是 zoom-in 了 */
    .avbase-title2 {
      order: 3;
      display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
      overflow: hidden; padding: 10px 10px 4px; font-weight: 600; font-size: 13px; line-height: 1.4;
      color: inherit; text-decoration: none;
    }
    .avbase-title2:hover { text-decoration: underline; }
    .avbase-actors2 { order: 4; margin-top: auto; display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 10px 10px; }

    /* ---- 設定面板 ---- */
    .avbase-settings-btn {
      position: fixed; right: 18px; bottom: 18px; z-index: 99998;
      width: 44px; height: 44px; border-radius: 999px;
      background: #111827; color: #fff; border: 1px solid #374151; font-size: 18px; cursor: pointer;
      box-shadow: 0 4px 14px rgba(0,0,0,.4);
    }
    .avbase-settings-panel {
      position: fixed; right: 18px; bottom: 70px; z-index: 99998;
      background: #111827; border: 1px solid #374151; border-radius: 10px;
      padding: 12px 14px; display: none; min-width: 210px;
      box-shadow: 0 8px 24px rgba(0,0,0,.5);
    }
    .avbase-settings-panel.show { display: block; }
    .avbase-settings-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
    .avbase-settings-row label { color: #cbd5e1; font-size: 13px; }
    .avbase-settings-row input {
      width: 64px; background: #0b1220; color: #e5e7eb; border: 1px solid #374151;
      border-radius: 6px; padding: 4px 8px;
    }
    .avbase-settings-hint { margin-top: 6px; color: #64748b; font-size: 11px; line-height: 1.5; }

    /* =========================================================
     * 浮層(Lightbox / Gallery)
     * 版面:左=目前作品自己的預覽圖(固定5格視窗,淡入淡出)/中=純圖片(或播放器)
     * /右=標題演員按鈕。下方=所有作品的封面列(切換「作品」用)。
     * 縮小視窗時:左側預覽圖搬頂部橫排、右側資訊搬到圖片下方。
     * ========================================================= */
    .avbase-overlay {
      position: fixed; inset: 0; background: rgba(0,0,0,.94); z-index: 100000;
      display: flex; flex-direction: column; opacity: 0; transition: opacity .2s ease;
    }
    .avbase-overlay.show { opacity: 1; }

    .avbase-ov-shell { flex: 1; min-height: 0; display: flex; flex-direction: row; }

    /* 左:目前作品自己的封面+預覽圖,固定顯示 5 格,目前那張置中並放大 */
    .avbase-ov-thumbside { flex: 0 0 100px; border-right: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.02); }
    .avbase-ov-thumbside-inner {
      height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;
      gap: 10px; padding: 12px 8px; overflow: hidden;
    }
    .avbase-ov-thumb {
      flex: 0 0 auto; width: 72px; height: 72px; border-radius: 6px; overflow: hidden; cursor: pointer;
      opacity: .5; border: 2px solid transparent; background: #0b1220;
      display: flex; align-items: center; justify-content: center;
      transition: opacity .3s ease, transform .3s ease;
    }
    .avbase-ov-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
    .avbase-ov-thumb.active { opacity: 1; border-color: #60a5fa; transform: scale(1.15); }
    /* 換圖當下暫時套用這個 class 讓它先「淡出+往下位移」,拿掉後 CSS transition 會自動補完淡入+歸位的動畫 */
    .avbase-ov-thumb.avbase-ov-thumb-anim { opacity: 0 !important; transform: translateY(10px) scale(.85) !important; }

    /* 中:純圖片/播放器,左右鍵切換「作品」 */
    .avbase-ov-main { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; justify-content: center; padding: 16px; }
    /* 預覽圖跟播放器用同一套「填滿容器」邏輯(自適應滿屏),不再用
       max-width/max-height 讓圖縮小置中,改成 width/height:100% 搭配
       object-fit:contain 撐滿 .avbase-ov-main 扣掉 padding 後的可用空間,
       圖片比例仍會完整保留,不會裁切變形,但畫面觀感跟播放器一致 */
    .avbase-ov-img { width: 100%; height: 100%; object-fit: contain; border-radius: 6px; transition: opacity .2s ease; }
    /* 播放器容器:填滿 .avbase-ov-main 扣掉 padding 後的剩餘空間,普通模式/VR 模式都吃這個尺寸,
       不再用 max-width/max-height 卡死,這樣視窗多大播放器就多大 */
    .avbase-ov-player { display: none; width: 100%; height: 100%; }
    .avbase-ov-player video.avbase-trailer-video { width: 100%; height: 100%; border-radius: 6px; background: #000; display: block; object-fit: contain; }
    .avbase-vr-wrap { width: 100%; height: 100%; position: relative; }
    .avbase-vr-wrap a-scene { width: 100%; height: 100%; border-radius: 6px; overflow: hidden; display: block; }
    /* 明顯的「進入 VR 頭戴模式」按鈕,疊在畫面右下角,點下去會呼叫 A-Frame
       scene 的 enterVR(),在支援 WebXR 的頭戴裝置瀏覽器(例如 Quest 內建的
       Quest Browser、或裝置上的 Firefox Reality)上會真的進入沉浸式 VR,
       而不是停留在螢幕裡的平面畫面。瀏覽器規範要求進入 VR 一定要由「使用者
       手動點擊」觸發,不能自動進入,所以這裡刻意做成一個顯眼、隨時可點的
       按鈕,取代 A-Frame 內建那個容易被忽略的小圖示。 */
    .avbase-vr-enter-btn {
      position: absolute; right: 14px; bottom: 14px; z-index: 6;
      display: flex; align-items: center; gap: 6px;
      background: rgba(37,99,235,.92); color: #fff; border: none; border-radius: 999px;
      padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
      box-shadow: 0 4px 16px rgba(0,0,0,.4); transition: background .15s ease;
    }
    .avbase-vr-enter-btn:hover { background: #1d4ed8; }
    .avbase-vr-enter-btn.avbase-vr-unsupported { display: none; }
    /* 來源 video 不能用 display:none,否則部分瀏覽器停止解碼,改用縮到 1px + opacity:0 */
    .avbase-vr-source-video { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }

    /* ---- 找不到試看影片的提示:淡入 -> 停留 -> 淡出,不需要使用者手動關閉 ---- */
    .avbase-ov-toast {
      position: absolute; left: 50%; bottom: 24px; transform: translateX(-50%);
      background: rgba(17,24,39,.92); color: #f3f4f6; font-size: 13px;
      padding: 10px 18px; border-radius: 999px; white-space: nowrap;
      opacity: 0; pointer-events: none; transition: opacity .35s ease; z-index: 5;
      box-shadow: 0 4px 16px rgba(0,0,0,.4);
    }
    .avbase-ov-toast.show { opacity: 1; }

    .avbase-ov-prev, .avbase-ov-next {
      position: absolute; top: 50%; transform: translateY(-50%);
      background: rgba(0,0,0,.5); color: #fff; border: none; border-radius: 999px;
      width: 44px; height: 44px; font-size: 20px; cursor: pointer; z-index: 3;
    }
    .avbase-ov-prev { left: 8px; }
    .avbase-ov-next { right: 8px; }
    .avbase-ov-prev:hover, .avbase-ov-next:hover { background: rgba(0,0,0,.8); }

    /* 右:代碼/日期/標題/演員/操作按鈕 */
    .avbase-ov-side {
      flex: 0 0 320px; max-width: 320px; border-left: 1px solid rgba(255,255,255,.08);
      background: rgba(255,255,255,.02); overflow-y: auto; padding: 16px;
      display: flex; flex-direction: column; gap: 12px;
    }
    /* 標題正上方的代碼+發售日期,比標題小一號、顏色淡一點,做出「meta 資訊」的層次感 */
    .avbase-ov-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; color: #9ca3af; }
    .avbase-ov-meta .avbase-ov-meta-code { font-weight: 700; color: #d1d5db; letter-spacing: .02em; }
    .avbase-ov-title { color: #f3f4f6; font-size: 14px; line-height: 1.5; font-weight: 600; }
    .avbase-ov-actors { display: flex; flex-wrap: wrap; gap: 6px; }
    .avbase-ov-controls { display: flex; flex-direction: column; gap: 8px; margin-top: auto; padding-top: 8px; }
    .avbase-ov-controls button, .avbase-ov-controls a {
      width: 100%; box-sizing: border-box; text-align: center;
      background: #1f2937; color: #e5e7eb; border: 1px solid #374151; border-radius: 8px;
      padding: 10px 12px; font-size: 13px; cursor: pointer; text-decoration: none;
    }
    .avbase-ov-controls button:hover, .avbase-ov-controls a:hover { background: #374151; }
    .avbase-ov-controls button:disabled { opacity: .5; cursor: default; }
    /* 自動跳轉/循環播放這兩個開關按鈕啟用時的醒目樣式,一眼就能看出目前是「開」還是「關」 */
    .avbase-ov-controls button.active { background: #2563eb; border-color: #2563eb; color: #fff; }
    .avbase-ov-controls button.active:hover { background: #1d4ed8; }

    /* 下:所有「作品」的封面列,點了切換目前顯示哪一部作品(跟左側「同一部作品的預覽圖」是兩回事)*/
    .avbase-ov-bottomthumbs {
      flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 10px 16px;
      overflow-x: auto; background: rgba(255,255,255,.03); border-top: 1px solid rgba(255,255,255,.08);
    }
    .avbase-ov-bottomthumb {
      flex: 0 0 auto; width: auto; height: 86px; border-radius: 6px; overflow: hidden; cursor: pointer;
      opacity: .45; border: 2px solid transparent; background: #0b1220;
      display: flex; align-items: center; justify-content: center; transition: opacity .2s, border-color .2s;
    }
    .avbase-ov-bottomthumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
    .avbase-ov-bottomthumb:hover { opacity: .8; }
    .avbase-ov-bottomthumb.active { opacity: 1; border-color: #60a5fa; }

    @media (max-width: 860px) {
      .avbase-ov-shell { flex-direction: column; }
      .avbase-ov-thumbside { order: 1; flex: 0 0 auto; width: 100%; height: 100px; border-right: none; border-bottom: 1px solid rgba(255,255,255,.08); }
      .avbase-ov-thumbside-inner { flex-direction: row; }
      .avbase-ov-main { order: 2; flex: 1 1 auto; min-height: 200px; }
      .avbase-ov-side { order: 3; flex: 0 0 auto; width: 100%; max-width: none; max-height: 40vh; border-left: none; border-top: 1px solid rgba(255,255,255,.08); }
      .avbase-ov-controls { flex-direction: row; flex-wrap: wrap; margin-top: 0; }
      .avbase-ov-controls button, .avbase-ov-controls a { width: auto; flex: 1 1 120px; }
    }

    /* ---- Sukebei 種子搜尋(浮層中再開一層) ---- */
    .avbase-sukebei-overlay {
      position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100010;
      display: flex; align-items: center; justify-content: center; padding: 20px;
      opacity: 0; transition: opacity .2s ease;
    }
    .avbase-sukebei-overlay.show { opacity: 1; }
    .avbase-sukebei-card {
      background: #111827; border: 1px solid #374151; border-radius: 12px;
      width: 100%; max-width: 640px; max-height: 80vh; display: flex; flex-direction: column;
      box-shadow: 0 12px 40px rgba(0,0,0,.5);
    }
    .avbase-sukebei-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #374151; color: #f3f4f6; }
    .avbase-sukebei-close { width: 28px; height: 28px; border-radius: 999px; border: none; background: rgba(255,255,255,.1); color: #fff; cursor: pointer; }
    .avbase-sukebei-search-row { display: flex; gap: 8px; padding: 12px 16px; }
    .avbase-sukebei-input { flex: 1; background: #0b1220; color: #e5e7eb; border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; }
    .avbase-sukebei-go { background: #2563eb; color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
    .avbase-sukebei-go:hover { background: #1d4ed8; }
    .avbase-sukebei-results { overflow-y: auto; padding: 0 16px 16px; display: flex; flex-direction: column; gap: 8px; }
    .avbase-sukebei-hint { color: #9ca3af; font-size: 13px; text-align: center; padding: 20px 0; }
    .avbase-sukebei-item { background: #1f2937; border: 1px solid #374151; border-radius: 8px; padding: 10px 12px; color: #e5e7eb; }
    .avbase-sukebei-item-title { font-size: 13px; line-height: 1.4; word-break: break-all; }
    .avbase-sukebei-item-meta { margin-top: 4px; display: flex; gap: 10px; font-size: 11px; color: #9ca3af; flex-wrap: wrap; }
    .avbase-sukebei-item-actions { margin-top: 8px; display: flex; gap: 8px; }
    .avbase-sukebei-item-actions a, .avbase-sukebei-item-actions button {
      background: #263142; color: #e5e7eb; border: 1px solid #374151; border-radius: 6px;
      padding: 6px 10px; font-size: 12px; cursor: pointer; text-decoration: none;
    }
    .avbase-sukebei-item-actions a:hover, .avbase-sukebei-item-actions button:hover { background: #33415a; }

    /* ---- 演員頁:整塊資訊改成上下堆疊(原本左欄下方留白的問題)
       標籤頁的分類側欄也共用這個 class ---- */
    .avbase-talent-stack { display: flex !important; flex-direction: column !important; gap: 16px !important; }
    .avbase-talent-stack > * { width: 100% !important; }

    /* ---- 演員頁:左側精簡資訊(置中) ---- */
    .avbase-talent-mini { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 16px 8px; }
    .avbase-talent-avatar-btn {
      border: none; background: transparent; padding: 0; cursor: pointer;
      border-radius: 999px; overflow: hidden; width: 128px; height: 128px;
      transition: transform .2s ease, box-shadow .2s ease;
    }
    .avbase-talent-avatar-btn:hover { transform: scale(1.03); box-shadow: 0 4px 16px rgba(0,0,0,.25); }
    .avbase-talent-avatar-btn img { width: 100%; height: 100%; object-fit: cover; display: block; }
    .avbase-talent-name { font-size: 20px; font-weight: 700; }
    .avbase-talent-follow, .avbase-talent-social { display: flex; justify-content: center; }
    .avbase-talent-social > div { display: flex; justify-content: center; gap: 12px; }

    /* ---- 演員頁:頭像點擊後的完整資料浮卡 ---- */
    .avbase-talent-overlay {
      position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100000;
      display: flex; align-items: center; justify-content: center; padding: 24px;
      opacity: 0; transition: opacity .2s ease;
    }
    .avbase-talent-overlay.show { opacity: 1; }
    .avbase-talent-card {
      position: relative; background: var(--background, #111827); color: inherit;
      border-radius: 12px; max-width: 480px; width: 100%; max-height: 85vh; overflow-y: auto;
      padding: 20px; box-shadow: 0 12px 40px rgba(0,0,0,.5);
    }
    .avbase-talent-card-close {
      position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;
      border-radius: 999px; border: none; background: rgba(128,128,128,.25); cursor: pointer; font-size: 14px;
    }
    .avbase-talent-card-body { text-align: center; }
    .avbase-talent-card-body .text-center.grid.grid-cols-2 { grid-template-columns: 1fr; }

    /* ---- 標籤頁:分類側欄精簡(合併到作品列上方,靠左橫向排列,不像演員頁那樣置中)---- */
    .avbase-tag-mini { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 4px 4px; }
    .avbase-tag-mini-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }

    /* ---- 個人動態頁(首頁 /home、收藏頁 /home/bookmarks):
       原本左欄的「あなたのタイムライン/あなたの保存」歡迎詞卡片精簡成一條
       橫幅塞進篩選列上方,不再獨占一整欄留白 ---- */
    .avbase-feedhero-bar {
      display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px;
      padding: 12px 16px; margin-bottom: 4px;
      border: 1px solid var(--light, rgba(148,163,184,.25)); border-radius: 12px;
      background: linear-gradient(90deg, rgba(99,102,241,.08), transparent 60%);
    }
    .avbase-feedhero-title { display: flex; align-items: baseline; gap: 8px; font-weight: 700; font-size: 15px; white-space: nowrap; }
    .avbase-feedhero-title .avbase-feedhero-badge {
      display: inline-flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 600;
      text-transform: uppercase; letter-spacing: .04em; color: #9ca3af;
    }
    .avbase-feedhero-desc { flex: 1 1 260px; min-width: 0; font-size: 12px; color: #9ca3af; line-height: 1.5; }
    .avbase-feedhero-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
    .avbase-feedhero-actions a {
      white-space: nowrap; border-radius: 999px; padding: 6px 14px; font-size: 12px; font-weight: 500;
      border: 1px solid var(--light, rgba(148,163,184,.25)); text-decoration: none; color: inherit;
      transition: border-color .15s ease, color .15s ease;
    }
    .avbase-feedhero-actions a:hover { border-color: #60a5fa; color: #60a5fa; }
    @media (max-width: 640px) {
      .avbase-feedhero-bar { flex-direction: column; align-items: flex-start; }
      .avbase-feedhero-actions { margin-left: 0; }
    }
  `);

  /* =========================================================
   * 2. 共用工具函式
   * ========================================================= */
  function escapeHtml(str) {
    return String(str).replace(/[&<>"']/g, ch => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[ch]));
  }

  function debounce(fn, wait) {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), wait);
    };
  }

  function dedupe(arr) {
    return Array.from(new Set(arr.filter(Boolean)));
  }

  // 幫 <img> 掛上「這張失敗就換下一個網址」的重試鏈
  function withFallbackChain(imgEl, urls) {
    if (!imgEl) return;
    if (!urls || !urls.length) return;
    let i = 0;
    imgEl.onerror = () => {
      i++;
      if (i < urls.length) imgEl.src = urls[i];
      else imgEl.onerror = null;
    };
    imgEl.src = urls[0];
  }

  // AV 圖檔命名規則固定為 <資料夾>/<資料夾><ps|pl|jp|jm>.jpg,取出資料夾名稱 (cid)
  function extractCid(pathname) {
    const decoded = decodeURIComponent(pathname);
    let m = decoded.match(/\/([A-Za-z0-9_]+)\/\1(?:ps|pl|jp|jm)(?:-\d+)?\.jpg$/i);
    if (m) return m[1];
    m = decoded.match(/\/([A-Za-z0-9_]+)\/[^/]+\.jpg$/i);
    if (m) return m[1];
    return null;
  }

  function getDmmCid(url) {
    try {
      const u = new URL(url, location.href);
      if (!/(^|\.)dmm\.co\.jp$/i.test(u.hostname)) return null;
      return extractCid(u.pathname);
    } catch (e) {
      return null;
    }
  }

  // 判斷一張 DMM 小圖網址是不是走「/mono/movie/」這個路徑家族(單体作品常見,
  // 例如 ABF、NAAC 這類代碼)。這個家族的大圖(pl.jpg)就跟小圖住在同一個
  // 資料夾底下,不能套用「/digital/video/<cid>/<cid>pl.jpg」那組猜測規則,
  // 兩邊資料夾結構是分開維護的,猜錯路徑常常拿到不存在或無關的圖,而且 DMM
  // 對猜錯的路徑不一定會回傳乾淨的 404(可能是預留位置圖),<img onerror>
  // 不會觸發,fallback chain 就卡住走不到下一個候選網址。
  function isMonoMoviePath(url) {
    return /\/mono\/movie\//i.test(url || '');
  }

  // 卡片頂部大封面:優先用跟小圖同路徑家族猜出來的 pl.jpg
  function buildCoverCandidates(smallSrc) {
    const list = [];
    const cid = getDmmCid(smallSrc);

    if (cid && isMonoMoviePath(smallSrc)) {
      // mono/movie 系列(ABF、NAAC 等):大圖跟小圖同一個資料夾,直接把
      // ps 換成 pl,這是實測會中的路徑,優先試這個
      if (/ps(?:-\d+)?\.jpg(\?.*)?$/i.test(smallSrc)) {
        list.push(smallSrc.replace(/ps(?:-\d+)?\.jpg(\?.*)?$/i, 'pl.jpg$1'));
      }
      list.push(`https://pics.dmm.co.jp/mono/movie/adult/${cid}/${cid}pl.jpg`);
      // digital/video 那個路徑家族對 mono 系列通常是錯的,放最後面當作
      // 保底猜測,不要優先試(避免猜錯又卡住 fallback chain)
      list.push(`https://pics.dmm.co.jp/digital/video/${cid}/${cid}pl.jpg`);
    } else if (cid) {
      // digital 系列(VR、單体配信等),維持原本的猜測順序
      list.push(`https://pics.dmm.co.jp/digital/video/${cid}/${cid}pl.jpg`);
      list.push(`https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/${cid}/${cid}pl.jpg`);
    } else if (/ps(?:-\d+)?\.jpg(\?.*)?$/i.test(smallSrc)) {
      list.push(smallSrc.replace(/ps(?:-\d+)?\.jpg(\?.*)?$/i, 'pl.jpg$1'));
    }
    list.push(smallSrc);
    return dedupe(list);
  }

  // 浮層大圖:同樣優先用跟小圖同路徑家族猜出來的 pl.jpg
  function buildLightboxCandidates(smallSrc) {
    const list = [];
    const cid = getDmmCid(smallSrc);

    if (cid && isMonoMoviePath(smallSrc)) {
      if (/ps(?:-\d+)?\.jpg(\?.*)?$/i.test(smallSrc)) {
        list.push(smallSrc.replace(/ps(?:-\d+)?\.jpg(\?.*)?$/i, 'pl.jpg$1'));
      }
      list.push(`https://pics.dmm.co.jp/mono/movie/adult/${cid}/${cid}pl.jpg`);
      list.push(`https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/${cid}/${cid}pl.jpg`);
    } else if (cid) {
      list.push(`https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/${cid}/${cid}pl.jpg`);
      list.push(`https://pics.dmm.co.jp/digital/video/${cid}/${cid}pl.jpg`);
    }
    list.push(smallSrc);
    return dedupe(list);
  }

  // 從 /works/attackers:ADN-801 這類網址取出 "ADN-801",給 sukebei / missav 搜尋用
  function extractWorkCode(workHref) {
    try {
      const last = decodeURIComponent((workHref || '').split('/').filter(Boolean).pop() || '');
      const parts = last.split(':');
      return parts[parts.length - 1] || last;
    } catch (e) {
      return workHref || '';
    }
  }

  // 統一組出「一個畫廊項目」的資料結構,卡片格、作品詳情頁封面、關聯作品都共用這個
  function buildGalleryItem({ smallSrc, workHref, title, actorsHtml, el, code, dateText }) {
    return {
      workHref,
      title: title || '',
      actorsHtml: actorsHtml || '',
      code: code || extractWorkCode(workHref), // 沒特別給的話,退而求其次從網址猜代碼
      dateText: dateText || '',
      cid: getDmmCid(smallSrc),
      coverCandidates: buildCoverCandidates(smallSrc),
      lightboxCandidates: buildLightboxCandidates(smallSrc),
      el: el || null,
      isCurrentPage: false,
      _detailsPromise: null // fetchWorkDetails() 用的快取,見該函式
    };
  }

  /* =========================================================
   * 3. 每行顯示數量設定
   * ========================================================= */
  function getStoredCols() {
    const v = parseInt(localStorage.getItem(COLS_KEY), 10);
    return Number.isFinite(v) && v >= COLS_MIN && v <= COLS_MAX ? v : COLS_DEFAULT;
  }
  function setStoredCols(v) {
    const c = Math.max(COLS_MIN, Math.min(COLS_MAX, parseInt(v, 10) || COLS_DEFAULT));
    localStorage.setItem(COLS_KEY, String(c));
    return c;
  }
  function effectiveCols() {
    const stored = getStoredCols();
    const w = window.innerWidth;
    if (w < 640) return Math.min(stored, 2);
    if (w < 1024) return Math.min(stored, 3);
    if (w < 1440) return Math.min(stored, 5);
    return stored;
  }
  function applyColumns() {
    document.documentElement.style.setProperty('--avbase-cols', String(effectiveCols()));
  }

  function buildSettingsPanel() {
    if (document.querySelector('.avbase-settings-btn')) return;

    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'avbase-settings-btn';
    btn.textContent = '⚙';
    btn.title = 'AvBase 版面設定';

    const panel = document.createElement('div');
    panel.className = 'avbase-settings-panel';
    panel.innerHTML = `
      <div class="avbase-settings-row">
        <label>每行顯示數量</label>
        <input type="number" min="${COLS_MIN}" max="${COLS_MAX}" step="1" class="avbase-cols-input">
      </div>
      <div class="avbase-settings-hint">小螢幕會自動再縮減欄數(適用於首頁/演員頁/作品列表頁/標籤頁/個人動態頁)</div>
    `;

    document.body.appendChild(btn);
    document.body.appendChild(panel);

    const input = panel.querySelector('.avbase-cols-input');
    input.value = String(getStoredCols());
    input.addEventListener('change', () => {
      setStoredCols(input.value);
      input.value = String(getStoredCols());
      applyColumns();
    });

    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      panel.classList.toggle('show');
    });
    document.addEventListener('click', (e) => {
      if (!panel.contains(e.target) && e.target !== btn) panel.classList.remove('show');
    });
  }

  /* =========================================================
   * 4. 版面重排:全寬 + 網格化(首頁 / 演員頁 / 作品列表頁 / 標籤頁 / 個人動態頁)
   * ========================================================= */
  function normalizeLayout() {
    if (!isCardGridPage()) return;

    // 觀察到的卡片格 class 組合有 gap-2(首頁)跟 gap-4(其他頁)兩種,都吃
    // ※若某頁版面跟這裡假設的不一樣,卡片就不會被抓到,屬於安全失敗(原生版面照常顯示)
    const grids = document.querySelectorAll('.grid.sm\\:grid-cols-2.gap-2, .grid.sm\\:grid-cols-2.gap-4');
    grids.forEach(grid => {
      if (grid.dataset.avbaseGrid === '1') return;
      const hasCards = grid.querySelector('.bg-background.border.border-light.rounded-lg.overflow-hidden');
      if (!hasCards) return;

      grid.classList.add('avbase-grid2');
      grid.dataset.avbaseGrid = '1';

      // 首頁「本日発売の作品」「最近登録された作品」左右並排的外層改成上下堆疊、各自全寬
      const twoColWrap = grid.closest('.grid.gap-3.lg\\:grid-cols-2');
      if (twoColWrap && twoColWrap.dataset.avbaseStack !== '1') {
        twoColWrap.classList.add('avbase-full-stack');
        twoColWrap.dataset.avbaseStack = '1';
      }
    });
  }

  /* =========================================================
   * 5. 卡片重建:作品代碼/日期 -> 大封面 -> 標題 -> 演員
   * ========================================================= */
  function rebuildCard(cardEl) {
    if (cardEl.dataset.avbaseDone === '1') return;

    const metaHeader = cardEl.querySelector(':scope > .bg-muted.py-2.flex.flex-col.gap-2');
    const mediaRow = cardEl.querySelector(':scope > .flex.min-w-0.border-y.border-light');
    const actorsWrap = cardEl.querySelector(':scope > .relative.min-h-10.bg-background.p-2 .flex.flex-wrap.gap-2');
    if (!mediaRow) return;

    const smallImg = mediaRow.querySelector('img');
    const titleLink = mediaRow.querySelector('a[href^="/works/"]');
    if (!smallImg || !titleLink) return;

    const smallSrc = smallImg.getAttribute('src') || '';
    const altText = smallImg.getAttribute('alt') || titleLink.textContent.trim();
    const workHref = titleLink.getAttribute('href') || '#';
    const titleText = titleLink.textContent.trim();
    const metaHtml = metaHeader ? metaHeader.innerHTML : '';
    const actorsHtml = actorsWrap ? actorsWrap.innerHTML : '';

    // 從 metaHeader 裡把「代碼」跟「發售日期」抓出來,給浮層標題上方的 meta 列用。
    // 代碼是 .font-bold.text-gray-500 這個 span(例如 "MBDD-2195",不含
    // maker 前綴文字),日期是 a.block.font-bold 這個連結的文字(例如 "2026/10/16")
    const codeText = metaHeader ? (metaHeader.querySelector('span.font-bold.text-gray-500')?.textContent || '').trim() : '';
    const dateText = metaHeader ? (metaHeader.querySelector('a.block.font-bold')?.textContent || '').trim() : '';

    const item = buildGalleryItem({ smallSrc, workHref, title: titleText, actorsHtml, el: cardEl, code: codeText, dateText });

    cardEl.innerHTML = `
      ${metaHtml ? `<div class="avbase-meta2">${metaHtml}</div>` : ''}
      <div class="avbase-cover2">
        <img class="avbase-cover2-img" alt="${escapeHtml(altText)}" loading="lazy">
      </div>
      <a class="avbase-title2" href="${escapeHtml(workHref)}">${escapeHtml(titleText)}</a>
      ${actorsHtml ? `<div class="avbase-actors2">${actorsHtml}</div>` : ''}
    `;

    const coverImg = cardEl.querySelector('.avbase-cover2-img');
    withFallbackChain(coverImg, item.coverCandidates);

    cardEl.classList.add('avbase-card2');
    cardEl.dataset.avbaseDone = '1';
    cardEl._avbaseItem = item;

    cardEl.querySelector('.avbase-cover2').addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      openGalleryFromCard(cardEl);
    });
  }

  function rebuildAllCards() {
    if (!isCardGridPage()) return;
    document
      .querySelectorAll('.avbase-grid2 .bg-background.border.border-light.rounded-lg.overflow-hidden')
      .forEach(card => {
        try { rebuildCard(card); } catch (e) { console.warn('[AvBase Reforge] rebuildCard failed', e); }
      });
  }

  /* =========================================================
   * 6. 從 __NEXT_DATA__ 抓試看影片 / VR 試看影片 / 預覽圖
   * ========================================================= */
  // 把整段 HTML 裡的 <script id="__NEXT_DATA__">...</script> 挖出來 JSON.parse
  function extractNextDataFromHtml(html) {
    const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
    if (!m) return null;
    try { return JSON.parse(m[1]); } catch (e) { return null; }
  }

  // 讀取「目前這個頁面」的 __NEXT_DATA__,作品詳情頁的封面重建/浮層都靠這個
  // 拿到權威資料(不依賴頁面上有沒有現成的 pl.jpg 圖)
  function getCurrentPageNextData() {
    const el = document.getElementById('__NEXT_DATA__');
    if (!el) return null;
    try { return JSON.parse(el.textContent); } catch (e) { return null; }
  }

  // 判斷一個 product 是不是 VR 版本:看 product_id / 標題 / 作品標題裡有沒有出現常見 VR 關鍵字
  // ※誤判時(例如新的 VR 廠牌代碼沒列到)調整這裡的關鍵字即可
  function isVRProduct(product, work) {
    const hay = [
      product && product.product_id,
      product && product.title,
      work && work.title
    ].filter(Boolean).join(' ');
    return /【VR】|\bVR\b|vrkm|savr|urvrsp|bibivr|ipvr|atvr|juvr|3dsvr|nkkvr/i.test(hay);
  }

  // 試看影片網址「猜測」規則:__NEXT_DATA__ 給的 sample_movie_url 常常是 null,
  // 但實測發現只要照 DMM 檔名規則自己組網址,很多時候影片其實是存在的,只是
  // 官方沒把網址寫進資料裡。這裡回傳一組「候選網址陣列」,播放時依序試,
  // 全部失敗才代表真的沒有試看影片。
  // ※這是實測整理出來的規則,不保證每支影片都適用;DMM 換路徑規則的話要
  //   重新抓包更新這裡。
  function buildTrailerUrlCandidates(code, isVR) {
    if (!code) return [];
    const prefix = code.substr(0, 3);
    const list = [];

    // 部分較舊番號(1f 開頭但不是 1fsvss、或 1m 開頭)在部分鏡像站上,長影片檔名
    // 把番號中間的補零拿掉了(第 7~8 碼),所以另外組一份「去掉這兩碼」的網址
    if ((code.startsWith('1f') && !code.startsWith('1fsvss')) || code.startsWith('1m')) {
      const shortCode = code.substr(0, 6) + code.substr(8, 3);
      list.push(`https://videos.vpdmm.cc/litevideo/freepv/${code.charAt(0)}/${code.substr(0, 3)}/${shortCode}/${shortCode}4k.mp4`);
    }

    list.push(`https://cc3001.dmm.com/${isVR ? 'vrsample' : 'litevideo/freepv'}/${code.charAt(0)}/${prefix}/${code}/${code}${isVR ? 'vrlite' : 'hhb'}.mp4`);
    list.push(`https://cc3001.dmm.com/litevideo/freepv/${code.charAt(0)}/${prefix}/${code}/${code}_dmb_w.mp4`);

    return list;
  }

  // 從 mgstage / duga 等非 DMM 來源的 product 資料裡整理出試看網址與預覽圖,
  // 這些來源沒有固定的檔名猜測規則可用,只能照 __NEXT_DATA__ 給的原始欄位
  function extractNonDmmProductInfo(product) {
    const sampleImages = Array.isArray(product && product.sample_image_urls)
      ? product.sample_image_urls
          .map(s => ({ s: s && s.s, l: (s && s.l) || (s && s.s) }))
          .filter(s => s.s || s.l)
      : [];
    return {
      sampleMovieUrl: (product && product.sample_movie_url) || null,
      coverUrl: (product && (product.image_url || product.thumbnail_url)) || null,
      sampleImages
    };
  }

  // 從已經 JSON.parse 好的 __NEXT_DATA__ 物件,整理出:一般試看網址/VR試看網址/
  // 各自的候選網址陣列(官方網址 + 猜測網址)/預覽圖列表/非 DMM 來源的封面網址
  function extractWorkInfoFromNextData(nextData) {
    const work = nextData && nextData.props && nextData.props.pageProps && nextData.props.pageProps.work;
    const products = (work && work.products) || [];
    let normalTrailerUrl = null;
    let vrTrailerUrl = null;
    let normalTrailerCode = null;
    let vrTrailerCode = null;
    let sampleImages = [];
    let nonDmmCoverUrl = null;

    products.forEach(p => {
      if (!p) return;
      const isDmm = /dmm\.co\.jp/i.test(p.url || '') || p.source === 'dmm';
      const vr = isVRProduct(p, work);
      if (p.sample_movie_url) {
        if (vr && !vrTrailerUrl) vrTrailerUrl = p.sample_movie_url;
        if (!vr && !normalTrailerUrl) normalTrailerUrl = p.sample_movie_url;
      }
      // 就算這個 product 沒有現成的 sample_movie_url,也先記下它的番號,
      // 待會 buildTrailerUrlCandidates() 要用番號去猜網址(僅對 DMM 來源有意義)
      if (vr && !vrTrailerCode && p.product_id) vrTrailerCode = p.product_id;
      if (!vr && !normalTrailerCode && p.product_id) normalTrailerCode = p.product_id;

      if (!sampleImages.length && Array.isArray(p.sample_image_urls) && p.sample_image_urls.length) {
        sampleImages = p.sample_image_urls
          .map(s => ({ s: s && s.s, l: (s && s.l) || (s && s.s) }))
          .filter(s => s.s || s.l);
      }

      // 非 DMM 來源(mgstage、duga 等):記下它自己的封面圖網址,供頁面上
      // 找不到 pl.jpg 時當作大封面/浮層點擊來源使用
      if (!isDmm && !nonDmmCoverUrl && (p.image_url || p.thumbnail_url)) {
        nonDmmCoverUrl = p.image_url || p.thumbnail_url;
      }
    });

    const normalTrailerCandidates = dedupe([normalTrailerUrl].concat(buildTrailerUrlCandidates(normalTrailerCode, false)));
    const vrTrailerCandidates = dedupe([vrTrailerUrl].concat(buildTrailerUrlCandidates(vrTrailerCode, true)));

    return { normalTrailerUrl, vrTrailerUrl, normalTrailerCandidates, vrTrailerCandidates, sampleImages, nonDmmCoverUrl };
  }

  // 備援:__NEXT_DATA__ 抓不到時,用正規表示式硬找 sample_movie_url / sample_image_urls
  // ※只在 JSON.parse 失敗時才會用到,正常情況不會走這條路
  function extractSampleVideoUrlFallback(html) {
    const patterns = [
      /"sample_movie_url"\s*:\s*"([^"]+)"/i,
      /<video[^>]+src="([^"]+?\.mp4[^"]*)"/i,
      /(https?:\\\/\\\/[^"'<>\s]+?\.mp4)/i,
      /(https?:\/\/[^"'<>\s]+?\.mp4)/i
    ];
    for (const re of patterns) {
      const m = html.match(re);
      if (m && m[1] && m[1] !== 'null') return m[1].replace(/\\\//g, '/');
    }
    return null;
  }
  function extractSampleImageUrlsFallback(html) {
    const m = html.match(/"sample_image_urls"\s*:\s*(\[[\s\S]*?\])/);
    if (!m) return [];
    try {
      const arr = JSON.parse(m[1].replace(/\\\//g, '/'));
      return arr.map(o => ({ s: o && o.s, l: (o && o.l) || (o && o.s) })).filter(o => o.s || o.l);
    } catch (e) {
      return [];
    }
  }

  // 作品詳情頁畫面上實際的縮圖列,當 __NEXT_DATA__ 抓不到預覽圖時的最後備援
  // 命名規則跟封面一樣:<cid>-<N>.jpg 或 <cid>jp-<N>.jpg
  function findWorkDetailSampleImagesFromDom(cid) {
    if (!cid) return [];
    const re = new RegExp(cid + '(?:jp)?-(\\d+)\\.jpg', 'i');
    const imgs = Array.from(document.querySelectorAll(`img[src*="${cid}"]`));
    const seen = new Map();
    imgs.forEach(img => {
      const src = img.getAttribute('src') || '';
      const m = src.match(re);
      if (m) {
        const n = parseInt(m[1], 10);
        if (!seen.has(n)) seen.set(n, src);
      }
    });
    return Array.from(seen.entries()).sort((a, b) => a[0] - b[0]).map(([, src]) => src);
  }

  // 抓單一作品的「一般試看候選網址/VR試看候選網址/預覽圖列表/非DMM封面」,
  // 同一個作品只查一次(快取在 item 上)
  function fetchWorkDetails(it) {
    if (it._detailsPromise) return it._detailsPromise;

    it._detailsPromise = (async () => {
      let nextData = null;
      let html = null;

      if (it.isCurrentPage) {
        nextData = getCurrentPageNextData();
      } else {
        try {
          const res = await fetch(it.workHref, { credentials: 'same-origin' });
          if (res.ok) {
            html = await res.text();
            nextData = extractNextDataFromHtml(html);
          }
        } catch (e) {
          console.warn('[AvBase Reforge] 抓取作品頁失敗:', it.workHref, e);
        }
      }

      let details = {
        normalTrailerUrl: null,
        vrTrailerUrl: null,
        normalTrailerCandidates: [],
        vrTrailerCandidates: [],
        sampleImages: [],
        nonDmmCoverUrl: null
      };
      if (nextData) {
        details = extractWorkInfoFromNextData(nextData);
      } else if (html) {
        // __NEXT_DATA__ 解析失敗時的備援路徑,沒有番號可以猜網址,只能用正規表示式硬找的那一條
        const fallbackUrl = extractSampleVideoUrlFallback(html);
        details.normalTrailerUrl = fallbackUrl;
        details.normalTrailerCandidates = dedupe([fallbackUrl]);
        details.vrTrailerCandidates = [];
        details.sampleImages = extractSampleImageUrlsFallback(html);
      }

      if (it.isCurrentPage && !details.sampleImages.length && it.cid) {
        details.sampleImages = findWorkDetailSampleImagesFromDom(it.cid).map(src => ({ s: src, l: src }));
      }

      return details;
    })();

    return it._detailsPromise;
  }

  /* =========================================================
   * 7. 浮層(封面/預覽圖 + 試看播放 + VR 自動偵測 + sukebei/missav 快捷)
   * ========================================================= */
  function openGalleryFromCard(cardEl) {
    // 收集同一個 avbase-grid2 網格內所有已重建的卡片,一起放進同一個浮層瀏覽
    const grid = cardEl.closest('.avbase-grid2');
    const scope = grid ? grid.querySelectorAll('.avbase-card2') : document.querySelectorAll('.avbase-grid2 .avbase-card2');
    const items = Array.from(scope).map(c => c._avbaseItem).filter(Boolean);
    const idx = items.findIndex(it => it.el === cardEl);
    openGallery(items, idx >= 0 ? idx : 0);
  }

  // items:這次浮層可以左右切換的「作品」清單
  // startIndex:一開始顯示 items 裡的第幾個作品
  // initialPreviewIndex:只有第一次顯示那個作品時,要直接跳到它自己第幾張預覽圖
  //   (目前只有「作品詳情頁點下面縮圖列」會用到這個參數,其餘情況都是 0=封面)
  function openGallery(items, startIndex, initialPreviewIndex) {
    if (!items.length) return;

    let index = startIndex;
    let vrMode = false;
    let previewList = [];   // 目前這部作品自己的圖(封面 + 預覽圖),給左側視窗 + 上下鍵切換
    let previewIndex = 0;
    let requestToken = 0;   // 防止「切作品切太快,舊的 fetch 結果蓋掉新的畫面」

    const overlay = document.createElement('div');
    overlay.className = 'avbase-overlay';
    overlay.innerHTML = `
      <div class="avbase-ov-shell">
        <div class="avbase-ov-thumbside"><div class="avbase-ov-thumbside-inner"></div></div>
        <div class="avbase-ov-main">
          <button type="button" class="avbase-ov-prev" aria-label="上一個作品">&#10094;</button>
          <img class="avbase-ov-img" alt="">
          <div class="avbase-ov-player"></div>
          <div class="avbase-ov-toast">找不到試看影片</div>
          <button type="button" class="avbase-ov-next" aria-label="下一個作品">&#10095;</button>
        </div>
        <div class="avbase-ov-side">
          <div class="avbase-ov-meta"></div>
          <div class="avbase-ov-title"></div>
          <div class="avbase-ov-actors"></div>
          <div class="avbase-ov-controls">
            <button type="button" class="avbase-ov-play">▶ 播放預告 (space)</button>
            <button type="button" class="avbase-ov-autoplay">🔁 循環播放:關</button>
            <button type="button" class="avbase-ov-sukebei">🔍 搜尋種子 (sukebei)</button>
            <a class="avbase-ov-missav" target="_blank" rel="noopener">missav.ai ↗</a>
            <a class="avbase-ov-open" target="_blank" rel="noopener">前往作品頁 ↗</a>
          </div>
        </div>
      </div>
      <div class="avbase-ov-bottomthumbs"></div>
    `;
    document.body.appendChild(overlay);
    document.body.style.overflow = 'hidden';

    const shellEl = overlay.querySelector('.avbase-ov-shell');
    const thumbsideEl = overlay.querySelector('.avbase-ov-thumbside');
    const thumbsideInner = overlay.querySelector('.avbase-ov-thumbside-inner');
    const mainEl = overlay.querySelector('.avbase-ov-main');
    const sideEl = overlay.querySelector('.avbase-ov-side');
    const imgEl = overlay.querySelector('.avbase-ov-img');
    const playerEl = overlay.querySelector('.avbase-ov-player');
    const toastEl = overlay.querySelector('.avbase-ov-toast');
    const playBtn = overlay.querySelector('.avbase-ov-play');
    const autoBtn = overlay.querySelector('.avbase-ov-autoplay');
    const sukebeiBtn = overlay.querySelector('.avbase-ov-sukebei');
    const missavLink = overlay.querySelector('.avbase-ov-missav');
    const openLink = overlay.querySelector('.avbase-ov-open');
    const metaEl = overlay.querySelector('.avbase-ov-meta');
    const titleEl = overlay.querySelector('.avbase-ov-title');
    const actorsEl = overlay.querySelector('.avbase-ov-actors');
    const prevBtn = overlay.querySelector('.avbase-ov-prev');
    const nextBtn = overlay.querySelector('.avbase-ov-next');
    const bottomthumbsEl = overlay.querySelector('.avbase-ov-bottomthumbs');

    // ---- 左側「這部作品自己的預覽圖」:固定 5 個格子,永遠只更新內容,不整批重建 DOM ----
    const THUMBSIDE_SLOTS = 5;
    const slotEls = [];
    for (let s = 0; s < THUMBSIDE_SLOTS; s++) {
      const el = document.createElement('div');
      el.className = 'avbase-ov-thumb';
      el.innerHTML = '<img loading="lazy">';
      thumbsideInner.appendChild(el);
      slotEls.push(el);
    }

    function renderThumbside() {
      const total = previewList.length;
      if (!total) { slotEls.forEach(el => { el.style.visibility = 'hidden'; }); return; }

      const windowSize = Math.min(THUMBSIDE_SLOTS, total);
      const start = total <= THUMBSIDE_SLOTS ? 0 : Math.min(Math.max(previewIndex - 2, 0), total - THUMBSIDE_SLOTS);

      slotEls.forEach((el, slotPos) => {
        const dataIndex = start + slotPos;
        if (slotPos >= windowSize) { el.style.visibility = 'hidden'; return; }
        el.style.visibility = '';

        const entry = previewList[dataIndex];
        const img = el.querySelector('img');
        const candidates = [entry.thumbSrc].filter(Boolean).concat(entry.candidates || []);

        // 觸發「淡出+下移」再拿掉,靠 CSS transition 自動補完「淡入+歸位」的動畫
        el.classList.add('avbase-ov-thumb-anim');
        withFallbackChain(img, candidates);
        el.classList.toggle('active', dataIndex === previewIndex);
        el.onclick = () => showImage(dataIndex);
        requestAnimationFrame(() => {
          requestAnimationFrame(() => el.classList.remove('avbase-ov-thumb-anim'));
        });
      });
    }

    // ---- 找不到試看影片:淡入提示 -> 停留 1 秒 -> 淡出,同時把畫面切回封面圖 ----
    let toastTimer1 = null;
    let toastTimer2 = null;
    function showNoTrailerToast() {
      playerEl.style.display = 'none';
      playerEl.innerHTML = '';
      imgEl.style.display = '';
      showImage(previewIndex); // 回到封面/目前預覽圖

      clearTimeout(toastTimer1);
      clearTimeout(toastTimer2);
      toastEl.classList.remove('show'); // 若上一次的還沒淡出完就重觸發,先重置一次確保動畫能重跑
      // 強制 reflow,讓 transition 在下一輪 class toggle 時能正確觸發
      void toastEl.offsetWidth;
      toastEl.classList.add('show');
      toastTimer1 = setTimeout(() => {
        toastEl.classList.remove('show');
      }, 1000);
    }

    // ---- 中間主圖:切換「目前這張是第幾張預覽圖」,上/下鍵、點縮圖都走這裡 ----
    function showImage(i) {
      if (!previewList.length) return;
      previewIndex = (i + previewList.length) % previewList.length;
      playerEl.style.display = 'none';
      playerEl.innerHTML = '';
      imgEl.style.display = '';
      imgEl.style.opacity = '0.3';
      withFallbackChain(imgEl, previewList[previewIndex].candidates);
      imgEl.onload = () => { imgEl.style.opacity = '1'; };
      renderThumbside();
    }

    // ---- 下方「所有作品」封面列:畫一次,之後只切換 active 狀態 ----
    bottomthumbsEl.style.display = items.length > 1 ? '' : 'none';
    items.forEach((it, i) => {
      const t = document.createElement('div');
      t.className = 'avbase-ov-bottomthumb';
      t.innerHTML = '<img loading="lazy">';
      withFallbackChain(t.querySelector('img'), it.lightboxCandidates);
      t.addEventListener('click', () => render(i));
      bottomthumbsEl.appendChild(t);
    });
    const bottomThumbEls = Array.from(bottomthumbsEl.children);

    // ---- 切換「作品」:左右鍵、上/下一個作品按鈕、下方封面列都走這裡 ----
    async function render(i, forcedPreviewIndex) {
      index = (i + items.length) % items.length;
      const it = items[index];
      const myToken = ++requestToken;

      // 先用已經有的封面資料把畫面畫出來,預覽圖之後非同步補上
      previewList = [{
        thumbSrc: (it.coverCandidates && it.coverCandidates[it.coverCandidates.length - 1]) || it.lightboxCandidates[0],
        candidates: it.lightboxCandidates
      }];
      showImage(0);

      titleEl.textContent = it.title || '(無標題)';
      // 標題正上方的代碼/發售日期 meta 列,兩者都沒有的話整列不顯示
      const metaParts = [];
      if (it.code) metaParts.push(`<span class="avbase-ov-meta-code">${escapeHtml(it.code)}</span>`);
      if (it.dateText) metaParts.push(`<span>${escapeHtml(it.dateText)}</span>`);
      metaEl.innerHTML = metaParts.join('<span>·</span>');
      metaEl.style.display = metaParts.length ? '' : 'none';
      actorsEl.innerHTML = it.actorsHtml || '';
      if (it.isCurrentPage) { openLink.style.display = 'none'; }
      else { openLink.style.display = ''; openLink.href = it.workHref; }
      missavLink.href = `https://missav.ai/${encodeURIComponent(extractWorkCode(it.workHref))}`;
      playBtn.textContent = '▶ 播放預告 (space)';
      playBtn.disabled = false;
      vrMode = false;
      prevBtn.style.display = items.length > 1 ? '' : 'none';
      nextBtn.style.display = items.length > 1 ? '' : 'none';
      bottomThumbEls.forEach((t, ti) => t.classList.toggle('active', ti === index));
      if (bottomThumbEls[index]) bottomThumbEls[index].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });

      const details = await fetchWorkDetails(it);
      if (myToken !== requestToken) return; // 使用者已經切走了,這次結果作廢
      it._details = details;

      // 非 DMM 來源(mgstage/duga 等)且畫廊項目本身沒有可用的候選圖時,
      // 用 __NEXT_DATA__ 給的 nonDmmCoverUrl 補上封面(例如 892OERO-011 這類作品)
      if (details.nonDmmCoverUrl && (!previewList[0].candidates || !previewList[0].candidates.length || previewList[0].candidates[0] === undefined)) {
        previewList[0] = { thumbSrc: details.nonDmmCoverUrl, candidates: [details.nonDmmCoverUrl] };
      }

      if (details.sampleImages && details.sampleImages.length) {
        const extra = details.sampleImages.map(s => ({ thumbSrc: s.s || s.l, candidates: [s.l, s.s].filter(Boolean) }));
        previewList = [previewList[0]].concat(extra);
      }
      if (typeof forcedPreviewIndex === 'number' && forcedPreviewIndex >= 0 && forcedPreviewIndex < previewList.length) {
        showImage(forcedPreviewIndex);
      } else {
        renderThumbside();
      }
    }

    // autoSkip:播放預告失敗(load 不到)時自動跳到下一個封面重試,是「單次」
    //   性質的開關,不會主動幫你連續播完全部作品,只是失敗時不留在原地。
    // loopPlay:從目前這個作品開始,把所有封面的預告依序循環播放下去(成功
    //   播完一支或失敗都會自動接下一支),直到使用者關閉這個開關或關閉浮層。
    // 兩者是各自獨立的開關,狀態存在外層變數上,這樣即使 render() 重新執行
    // (切換作品)也不會被重置掉。
    // loopOn: 循環播放全部封面的預告,播完/失敗都自動接下一個
    let loopOn = false;
    let loopPlayToken = 0; // 防止「使用者手動切走 + 循環播放的自動接續」互相打架

    // ---- 試看播放:先試一般試看候選網址(依序 fallback),全部失敗(或本來就沒有)才試 VR 候選網址;
    //      成功用 VR 網址播出來的話,自動把 VR 模式切成「開」。若一路試到最後兩邊
    //      候選網址都放不出來,回到封面圖並淡入淡出提示「找不到試看影片」,並視
    //      autoSkip/loopPlay 的開關狀態決定要不要自動接下一個作品。
    function playSelected() {
      const it = items[index];
      const details = it._details;
      if (!details) { onPlayUnavailable(); return; }

      const normalList = details.normalTrailerCandidates || [];
      const vrList = details.vrTrailerCandidates || [];
      const primaryList = vrMode ? (vrList.length ? vrList : normalList) : (normalList.length ? normalList : vrList);
      const secondaryList = vrMode ? normalList : vrList;
      if (!primaryList.length) { onPlayUnavailable(); return; }
      const primaryIsVR = vrMode ? !!vrList.length : false;

      imgEl.style.display = 'none';
      playerEl.style.display = 'block';
      toastEl.classList.remove('show');

      // 循環播放模式下不能讓影片自己無限重播(loop),不然永遠不會觸發
      // ended、也就永遠接不到下一個作品;一般手動觀看(loopOn=false)
      // 時維持原本「自己重播」的行為
      const playerOpts = { loop: !loopOn, onEnded: loopOn ? onTrailerEnded : null };

      if (primaryIsVR) {
        mountVRPlayer(playerEl, primaryList, () => {
          // VR 候選網址全部失敗,還有一般候選可以退回去試
          if (secondaryList && secondaryList.length) {
            vrMode = false;
            mountFlatPlayer(playerEl, secondaryList, onPlayUnavailable, null, playerOpts);
          } else {
            onPlayUnavailable();
          }
        }, playerOpts);
      } else {
        mountFlatPlayer(playerEl, primaryList, () => {
          if (secondaryList && secondaryList.length) {
            vrMode = true;
            mountVRPlayer(playerEl, secondaryList, onPlayUnavailable, playerOpts);
          } else {
            onPlayUnavailable();
          }
        }, () => {
          // 候選網址其實是 VR 路徑(vrsample/vrlite),自動切成 VR 模式播放
          vrMode = true;
        }, playerOpts);
      }
    }

    // 播放失敗(找不到試看影片)的統一處理:一定先跳提示,再視開關狀態決定
    // 要不要自動接下一個作品
    function onPlayUnavailable() {
      showNoTrailerToast();
      if (loopOn) advanceAndPlay();
    }

    // 影片自然播放完畢(循環播放模式專用,一般手動觀看時 loop:true 不會觸發這個)
    function onTrailerEnded() {
      if (loopOn) advanceAndPlay();
    }

    // 切到下一個作品並嘗試播放它的預告,循環播放/自動跳轉共用這個推進邏輯。
    // 用 loopPlayToken 確保如果使用者在自動推進的過程中手動點了上一頁/下一頁/
    // 關閉浮層,這次排定好的自動播放就直接作廢,不會冒出來蓋掉使用者的操作。
    // 只有一個作品時(items.length === 1)不能自動推進,不然會一直重試同一個
    // 失敗的作品變成無限迴圈,這時安靜停下來就好。
    async function advanceAndPlay() {
      if (items.length <= 1) return;
      const myToken = ++loopPlayToken;
      await render(index + 1);
      if (myToken !== loopPlayToken) return; // 使用者這段時間手動切走了,作廢
      await handlePlay();
    }

    async function handlePlay() {
      const it = items[index];
      playBtn.disabled = true;
      playBtn.textContent = '載入中…';
      if (!it._details) it._details = await fetchWorkDetails(it);
      playBtn.disabled = false;
      playBtn.textContent = '▶ 播放預告 (space)';
      const hasNormal = it._details.normalTrailerCandidates && it._details.normalTrailerCandidates.length;
      const hasVr = it._details.vrTrailerCandidates && it._details.vrTrailerCandidates.length;
      if (!hasNormal && !hasVr) { onPlayUnavailable(); return; }
      playSelected();
    }

    playBtn.addEventListener('click', handlePlay);
    autoBtn.addEventListener('click', () => {
      loopOn = !loopOn;
      autoBtn.textContent = `🔁 循環播放:${loopOn ? '開' : '關'}`;
      autoBtn.classList.toggle('active', loopOn);
      if (loopOn) handlePlay(); // 立刻從目前作品開始播,不用再按一次播放預告
    });
    sukebeiBtn.addEventListener('click', () => {
      openSukebeiSearch(extractWorkCode(items[index].workHref));
    });

    function close() {
      loopPlayToken++; // 作廢任何排定中的自動推進
      overlay.remove();
      document.body.style.overflow = '';
      document.removeEventListener('keydown', onKey);
    }
    function onKey(e) {
      if (e.key === 'Escape') close();
      else if (e.key === 'ArrowLeft') { loopPlayToken++; render(index - 1); }
      else if (e.key === 'ArrowRight') { loopPlayToken++; render(index + 1); }
      else if (e.key === 'ArrowUp') { e.preventDefault(); showImage(previewIndex - 1); }
      else if (e.key === 'ArrowDown') { e.preventDefault(); showImage(previewIndex + 1); }
      else if (e.key === ' ' || e.code === 'Space') {
        // 空白鍵播放預告;避免使用者正在搜尋種子輸入框或其他輸入元件裡打字
        // 時被空白鍵誤觸發,只有焦點不在任何輸入元件上才生效
        const tag = (document.activeElement && document.activeElement.tagName) || '';
        if (tag === 'INPUT' || tag === 'TEXTAREA') return;
        e.preventDefault();
        if (!playBtn.disabled) handlePlay();
      }
    }

    // 點空白處關閉:外層背景、shell、主圖區留白、左側縮圖列背景、右側面板背景、底部封面列背景都算「空白」
    const blankEls = new Set([overlay, shellEl, mainEl, thumbsideEl, sideEl, bottomthumbsEl]);
    overlay.addEventListener('click', (e) => { if (blankEls.has(e.target)) close(); });
    prevBtn.addEventListener('click', () => { loopPlayToken++; render(index - 1); });
    nextBtn.addEventListener('click', () => { loopPlayToken++; render(index + 1); });
    document.addEventListener('keydown', onKey);

    render(index, initialPreviewIndex);
    requestAnimationFrame(() => overlay.classList.add('show'));
  }

  // 網址本身看得出是 VR 專用路徑的話(例如 .../vrsample/... 或檔名結尾 vrlite.mp4),
  // 即使是從「一般試看」的候選清單裡試出來的,也直接當作 VR 內容處理,自動切成 VR 模式
  // 播放,而不是硬用平面播放器播一支其實是 VR 格式(魚眼/雙眼)的影片。
  function isVRSampleUrl(url) {
    return /\/vrsample\//i.test(url) || /vrlite\.mp4(\?.*)?$/i.test(url);
  }

  /* ---- 一般播放器:接受一組候選網址,依序試(跟封面圖 fallback chain 同精神)。
     途中如果某個候選網址其實是 VR 路徑(vrsample/vrlite),改交給 mountVRPlayer
     用 VR 模式播放,並回報 becameVR 讓呼叫端把 UI 上的 VR 模式開關同步打開。
     全部候選都試過還是失敗才呼叫 onError。
     opts.onEnded:影片自然播放完畢(非使用者手動切走)時呼叫,循環播放全部
       封面功能靠這個知道「這支撥完了,換下一個」。
     opts.loop:是否要讓影片自己無限重播(預設 true,維持原本手動觀看時的
       行為);循環播放全部封面模式下呼叫端會傳 false,這樣 ended 事件才會
       確實觸發,才能正確接到下一支。---- */
  function mountFlatPlayer(container, urls, onError, becameVR, opts) {
    opts = opts || {};
    container.innerHTML = '';
    const video = document.createElement('video');
    video.className = 'avbase-trailer-video';
    video.controls = true;
    video.playsInline = true;
    video.autoplay = true;
    video.loop = opts.loop !== false;
    container.appendChild(video);

    const list = dedupe(Array.isArray(urls) ? urls : [urls]);
    let idx = 0;

    function tryPlay() {
      video.play().catch(() => { video.muted = true; video.play().catch(() => {}); });
    }
    function tryNext() {
      if (idx >= list.length) {
        video.removeEventListener('error', tryNext);
        if (onError) onError();
        return;
      }
      const url = list[idx];
      idx++;
      if (isVRSampleUrl(url)) {
        // 剩下沒試過的候選(包含這個 VR 網址本身)一起交給 VR 播放器接手,
        // 讓它從這個 VR 網址開始試,而不是硬塞回平面 <video>
        video.removeEventListener('error', tryNext);
        if (becameVR) becameVR();
        mountVRPlayer(container, list.slice(idx - 1), onError, opts);
        return;
      }
      video.src = url;
      tryPlay();
    }

    if (opts.onEnded) video.addEventListener('ended', opts.onEnded);
    video.addEventListener('error', tryNext);
    tryNext();
  }

  /* ---- VR 模式:只顯示 A-Frame videosphere 本身,同樣接受一組候選網址依序試,
     全部候選都失敗才呼叫 onError。opts.onEnded / opts.loop 意義同上。---- */
  let aframeLoadPromise = null;
  function ensureAFrame() {
    if (window.AFRAME) return Promise.resolve();
    if (aframeLoadPromise) return aframeLoadPromise;
    aframeLoadPromise = new Promise((resolve, reject) => {
      const s = document.createElement('script');
      s.src = 'https://aframe.io/releases/1.5.0/aframe.min.js';
      s.onload = () => resolve();
      s.onerror = () => reject(new Error('aframe load failed'));
      document.head.appendChild(s);
      setTimeout(() => reject(new Error('aframe load timeout')), 8000);
    });
    return aframeLoadPromise;
  }

  async function mountVRPlayer(container, urls, onError, opts) {
    opts = opts || {};
    const list = dedupe(Array.isArray(urls) ? urls : [urls]);
    if (!list.length) { if (onError) onError(); return; }

    try {
      await ensureAFrame();
    } catch (e) {
      // A-Frame 載入失敗,退回平面播放器硬播,至少畫面有東西
      mountFlatPlayer(container, list, onError, null, opts);
      return;
    }

    // 這支影片是 SBS(左右並排)雙眼影像整張直接貼到球體上,沒有另外做 stereo 分割,
    // 所以整張「左眼+右眼」畫面會佔滿 360 度球面。camera 預設朝 -Z(yaw 0)看,
    // 如果 videosphere 沒轉夠,鏡頭一開始會剛好卡在畫面正中央(左右眼交界的黑邊)。
    // 把 videosphere 轉到讓鏡頭一開始對準「左眼畫面」的正中間(也就是左半邊圖的中心,
    // 换算成球面角度是整張圖的 1/4 處),使用者一進來看到的就是完整的左眼畫面,不是黑邊。
    //
    // 進入 VR 頭戴模式:疊一顆顯眼的按鈕在畫面右下角,點下去呼叫 A-Frame scene
    // 的 enterVR(),在 Quest 內建的 Quest Browser/裝置上的 Firefox Reality
    // 這類支援 WebXR 的頭戴瀏覽器上,會真的進入沉浸式頭戴顯示模式(而不是
    // 停留在螢幕裡的一塊平面畫面)。瀏覽器規範要求進入 VR 必須由使用者手動
    // 點擊觸發(不能自動進入),所以無法在載入完成後自動幫使用者戴上頭盔,
    // 只能把入口做得夠明顯。如果目前瀏覽器/裝置根本不支援 WebXR(例如一般
    // 電腦瀏覽器沒接頭戴裝置),會隱藏這顆按鈕,避免點了沒反應。
    const loopAttr = opts.loop !== false ? 'loop' : '';
    container.innerHTML = `
      <div class="avbase-vr-wrap">
        <video id="avbase-vr-video" class="avbase-vr-source-video" crossorigin="anonymous" playsinline ${loopAttr} autoplay muted></video>
        <a-scene embedded vr-mode-ui="enabled: true">
          <a-videosphere src="#avbase-vr-video" rotation="0 -180 0"></a-videosphere>
          <a-camera fov="105"></a-camera>
        </a-scene>
        <button type="button" class="avbase-vr-enter-btn avbase-vr-unsupported">🥽 進入 VR 頭戴模式</button>
      </div>
    `;

    const sceneEl = container.querySelector('a-scene');
    const enterVRBtn = container.querySelector('.avbase-vr-enter-btn');
    // 偵測目前瀏覽器/裝置是否真的支援 WebXR 沉浸式 VR,支援才顯示按鈕;
    // isMobileVR 是 A-Frame 內建的裝置偵測,涵蓋沒有 navigator.xr 的舊版
    // Quest Browser,兩者任一為真就顯示
    function checkVRSupport() {
      const hasWebXR = !!(navigator.xr);
      const aframeSaysVR = !!(window.AFRAME && AFRAME.utils && AFRAME.utils.device && AFRAME.utils.device.checkHeadsetConnected && AFRAME.utils.device.checkHeadsetConnected());
      if (hasWebXR || aframeSaysVR) {
        enterVRBtn.classList.remove('avbase-vr-unsupported');
      }
    }
    // navigator.xr.isSessionSupported 若存在就再精確確認一次 immersive-vr
    // 是否真的可用,避免有 navigator.xr 但裝置其實不支援的情況顯示了按鈕卻按不動
    if (navigator.xr && navigator.xr.isSessionSupported) {
      navigator.xr.isSessionSupported('immersive-vr').then(supported => {
        if (supported) enterVRBtn.classList.remove('avbase-vr-unsupported');
      }).catch(() => { checkVRSupport(); });
    } else {
      checkVRSupport();
    }
    enterVRBtn.addEventListener('click', () => {
      if (sceneEl && sceneEl.enterVR) sceneEl.enterVR();
    });

    const vid = container.querySelector('#avbase-vr-video');
    let idx = 0;
    function tryNext() {
      if (idx >= list.length) {
        vid.removeEventListener('error', tryNext);
        if (onError) onError();
        return;
      }
      vid.src = list[idx];
      idx++;
      // 先靜音才能保證 autoplay 一定會成功(瀏覽器的自動播放限制),播放一開始
      // 就立刻取消靜音;如果瀏覽器擋下「有聲音自動播放」,退回靜音播放總比完全播不出來好。
      vid.muted = true;
      vid.play()
        .then(() => { vid.muted = false; })
        .catch(() => { vid.muted = true; vid.play().catch(() => {}); });
    }
    if (opts.onEnded) vid.addEventListener('ended', opts.onEnded);
    vid.addEventListener('error', tryNext);
    tryNext();
  }

  /* =========================================================
   * 8. Sukebei (sukebei.nyaa.si) 種子搜尋
   * 用 GM_xmlhttpRequest 是因為 sukebei 不會回應一般網頁的 CORS,
   * 直接用 fetch() 會被瀏覽器擋,要靠 Tampermonkey 的跨網域權限繞過。
   * ========================================================= */
  function searchSukebei(query) {
    return new Promise((resolve, reject) => {
      if (typeof GM_xmlhttpRequest !== 'function') {
        reject(new Error('此瀏覽器/腳本管理器不支援 GM_xmlhttpRequest'));
        return;
      }
      const url = `https://sukebei.nyaa.si/?page=rss&q=${encodeURIComponent(query)}&sort=seeders&order=desc`;
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout: 15000,
        onload: (res) => {
          try { resolve(parseSukebeiRss(res.responseText)); }
          catch (e) { reject(e); }
        },
        onerror: () => reject(new Error('network error')),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // 解析 sukebei 的 RSS。磁力連結 sukebei 沒有直接給,靠 nyaa:infoHash 自己組
  // magnet:?xt=urn:btih:<infoHash>&dn=<標題>。若 sukebei 改了 RSS 結構,要修的地方就是這裡。
  function parseSukebeiRss(xmlText) {
    const doc = new DOMParser().parseFromString(xmlText, 'text/xml');
    const items = Array.from(doc.querySelectorAll('item'));
    return items.map(it => {
      const title = it.querySelector('title')?.textContent || '';
      const link = it.querySelector('link')?.textContent || '';
      const pubDate = it.querySelector('pubDate')?.textContent || '';
      const size = it.getElementsByTagNameNS('*', 'size')[0]?.textContent || '';
      const seeders = it.getElementsByTagNameNS('*', 'seeders')[0]?.textContent || '';
      const leechers = it.getElementsByTagNameNS('*', 'leechers')[0]?.textContent || '';
      const infoHash = it.getElementsByTagNameNS('*', 'infoHash')[0]?.textContent || '';
      const magnet = infoHash ? `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(title)}` : '';
      return { title, link, size, seeders, leechers, pubDate, magnet };
    });
  }

  function copyTextToClipboard(text) {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      return navigator.clipboard.writeText(text);
    }
    // 舊瀏覽器 / clipboard API 被擋掉時的備援
    return new Promise((resolve, reject) => {
      const ta = document.createElement('textarea');
      ta.value = text;
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      try {
        document.execCommand('copy');
        resolve();
      } catch (e) {
        reject(e);
      } finally {
        ta.remove();
      }
    });
  }

  function openSukebeiSearch(defaultQuery) {
    const overlay = document.createElement('div');
    overlay.className = 'avbase-sukebei-overlay';
    overlay.innerHTML = `
      <div class="avbase-sukebei-card">
        <div class="avbase-sukebei-head">
          <strong>Sukebei 種子搜尋</strong>
          <button type="button" class="avbase-sukebei-close" aria-label="關閉">✕</button>
        </div>
        <div class="avbase-sukebei-search-row">
          <input type="text" class="avbase-sukebei-input" value="${escapeHtml(defaultQuery)}">
          <button type="button" class="avbase-sukebei-go">搜尋</button>
        </div>
        <div class="avbase-sukebei-results"><p class="avbase-sukebei-hint">搜尋中…</p></div>
      </div>
    `;
    document.body.appendChild(overlay);

    const input = overlay.querySelector('.avbase-sukebei-input');
    const resultsEl = overlay.querySelector('.avbase-sukebei-results');
    const closeBtn = overlay.querySelector('.avbase-sukebei-close');
    const goBtn = overlay.querySelector('.avbase-sukebei-go');

    function close() {
      overlay.remove();
      document.removeEventListener('keydown', onKey);
    }
    function onKey(e) { if (e.key === 'Escape') close(); }
    closeBtn.addEventListener('click', close);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
    document.addEventListener('keydown', onKey);

    async function runSearch() {
      const q = input.value.trim();
      if (!q) return;
      resultsEl.innerHTML = '<p class="avbase-sukebei-hint">搜尋中…</p>';
      try {
        const items = await searchSukebei(q);
        if (!items.length) {
          resultsEl.innerHTML = '<p class="avbase-sukebei-hint">找不到結果。</p>';
          return;
        }
        resultsEl.innerHTML = '';
        items.forEach(it => {
          const row = document.createElement('div');
          row.className = 'avbase-sukebei-item';
          row.innerHTML = `
            <div class="avbase-sukebei-item-title">${escapeHtml(it.title)}</div>
            <div class="avbase-sukebei-item-meta">
              <span>📦 ${escapeHtml(it.size || '-')}</span>
              <span>🌱 ${escapeHtml(it.seeders || '0')}</span>
              <span>🌊 ${escapeHtml(it.leechers || '0')}</span>
              <span>${escapeHtml(it.pubDate || '')}</span>
            </div>
            <div class="avbase-sukebei-item-actions">
              <a href="${escapeHtml(it.link || '#')}" target="_blank" rel="noopener">開啟頁面 ↗</a>
              ${it.magnet ? '<button type="button" class="avbase-sukebei-copy">複製磁鏈接</button>' : ''}
            </div>
          `;
          const copyBtn = row.querySelector('.avbase-sukebei-copy');
          if (copyBtn) {
            copyBtn.addEventListener('click', () => {
              copyTextToClipboard(it.magnet)
                .then(() => { copyBtn.textContent = '已複製 ✓'; setTimeout(() => { copyBtn.textContent = '複製磁鏈接'; }, 1500); })
                .catch(() => {});
            });
          }
          resultsEl.appendChild(row);
        });
      } catch (e) {
        resultsEl.innerHTML = '<p class="avbase-sukebei-hint">搜尋失敗,可能是網路問題或來源網站改版。</p>';
        console.warn('[AvBase Reforge] sukebei 搜尋失敗', e);
      }
    }

    goBtn.addEventListener('click', runSearch);
    input.addEventListener('keydown', (e) => { if (e.key === 'Enter') runSearch(); });

    requestAnimationFrame(() => overlay.classList.add('show'));
    runSearch();
  }

  /* =========================================================
   * 9. 演員頁:整塊資訊搬到作品列表上方 + 側欄精簡(頭像/名字/フォロー/社交)
   * ========================================================= */
  function rebuildTalentLayout() {
    if (!isTalentPage()) return;
    // 外層 4 欄 grid(左:演員資訊欄,右:作品列表欄)改成單欄上下堆疊,
    // 解決「演員資訊欄比較短、右邊作品列表比較長,左邊下方留白」的問題
    const outerGrid = document.querySelector('.grid.grid-cols-1.lg\\:grid-cols-4.lg\\:gap-8');
    if (outerGrid && outerGrid.dataset.avbaseStack !== '1') {
      outerGrid.classList.add('avbase-talent-stack');
      outerGrid.dataset.avbaseStack = '1';
    }
  }

  // 演員頁側欄精簡「不能」透過 outerHTML / innerHTML 字串化再塞回去——那樣做
  // 等於把整批節點丟掉重新用字串解析出新的節點,原本掛在 React 元件上的事件
  // 監聽器(フォロー按鈕、社交連結、編輯鈕…)全部一起消失,畫面看起來一樣但
  // 按了完全沒反應。正確做法是「搬移既有的真實 DOM 節點」,而不是複製它們的
  // HTML 字串:用 appendChild 把原本的節點原封不動搬到新的容器裡,事件監聽器
  // 是綁在節點物件上,搬移節點本身不會讓監聽器消失。
  //
  // 另外頭像圖片本身也有時機問題:Next.js SPA 導頁時,側欄的 <img> 節點雖然
  // 已經在 DOM 上,但 React 可能還沒把真正的 src 屬性寫上去(先是空字串或
  // data URL 佔位),如果我們在這個時間點就把畫面重建完畢,等於用一張「還沒
  // 設定好網址」的狀態卡住,之後 React 才把 src 補上時已經跟我們的重建動作
  // 無關了(我們沒有再重新讀一次),使用者只能整頁重新整理讓 SSR 直接生成
  // 正確結果。修法是:如果偵測到頭像 <img> 目前沒有可用的 src,先不要重建,
  // 直接 return(讓外層的 MutationObserver debounce 之後再次呼叫這個函式),
  // 一直等到 src 真的出現才進行重建;重建時也是直接搬移這個真實的 <img>
  // 節點(而不是讀 src 字串重新建立一個新的 <img>),確保拿到的一定是當下
  // React 已經寫好的最新網址。
  function rebuildTalentSidebar() {
    if (!isTalentPage()) return;

    const avatarEl = document.querySelector('.lg\\:col-span-1 span[data-slot="avatar"]');
    if (!avatarEl) return;

    const sideCol = avatarEl.closest('.lg\\:col-span-1');
    if (!sideCol || sideCol.dataset.avbaseDone === '1') return;

    const nameEl = sideCol.querySelector('h1');
    if (!nameEl) return; // 版面跟預期不符就不動它,避免破壞頁面

    const avatarImgEl = avatarEl.querySelector('img');
    // 頭像圖片還沒被 React 寫上真正的網址:先不重建,等下一輪 MutationObserver
    // 再重試,避免把「還沒 ready」的頭像狀態直接烤進畫面裡
    const avatarSrc = avatarImgEl ? avatarImgEl.getAttribute('src') : '';
    if (!avatarImgEl || !avatarSrc) return;

    const followBtn = sideCol.querySelector('button[aria-label="フォローする"]');
    const socialWrap = sideCol.querySelector('.group\\/social');

    // 建立新的精簡版容器,但裡面的「內容節點」全部是搬移(appendChild)既有的
    // 真實節點,不是重新生成的字串 HTML,藉此保留原本綁在這些節點上的事件監聽器
    const mini = document.createElement('div');
    mini.className = 'avbase-talent-mini';

    const avatarBtn = document.createElement('button');
    avatarBtn.type = 'button';
    avatarBtn.className = 'avbase-talent-avatar-btn';
    avatarBtn.setAttribute('aria-label', '查看完整資料');
    avatarBtn.appendChild(avatarImgEl); // 搬移真實的 <img> 節點本身

    const nameWrap = document.createElement('div');
    nameWrap.className = 'avbase-talent-name';
    // h1 本身不能整個搬進去(它是頁面語意上的主標題,搬走可能影響其他邏輯/
    // SEO/可及性樹),改成搬移它「裡面的節點」,h1 這個空殼留在原地清掉即可
    while (nameEl.firstChild) nameWrap.appendChild(nameEl.firstChild);

    const followWrap = document.createElement('div');
    followWrap.className = 'avbase-talent-follow';
    if (followBtn) followWrap.appendChild(followBtn); // 搬移真實的 <button> 節點,フォロー功能維持可點擊

    const socialWrapMini = document.createElement('div');
    socialWrapMini.className = 'avbase-talent-social';
    if (socialWrap) socialWrapMini.appendChild(socialWrap); // 搬移真實的社交連結節點

    mini.appendChild(avatarBtn);
    mini.appendChild(nameWrap);
    mini.appendChild(followWrap);
    mini.appendChild(socialWrapMini);

    // 剩下留在 sideCol 裡的所有節點(暱稱標籤/基本情報/プロフィール/編集履歴…),
    // 全部搬進一個 fragment 保留起來,收進點擊頭像才打開的浮卡;一樣是搬移
    // 真實節點,不走 outerHTML/innerHTML 字串化,事件監聽器才不會不見
    const detailFragment = document.createDocumentFragment();
    while (sideCol.firstChild) detailFragment.appendChild(sideCol.firstChild);

    sideCol.appendChild(mini);
    sideCol.dataset.avbaseDone = '1';

    avatarBtn.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      openTalentDetailCard(detailFragment);
    });
  }

  function openTalentDetailCard(detailFragment) {
    const overlay = document.createElement('div');
    overlay.className = 'avbase-talent-overlay';
    overlay.innerHTML = `
      <div class="avbase-talent-card">
        <button type="button" class="avbase-talent-card-close" aria-label="關閉">✕</button>
        <div class="avbase-talent-card-body"></div>
      </div>
    `;
    const bodyEl = overlay.querySelector('.avbase-talent-card-body');
    // 直接把先前搬移保留下來的真實節點 fragment 塞進浮卡,不經過任何字串化
    // 步驟,裡面每個按鈕/連結原本綁的事件監聽器都還在,點了才會有反應。
    // 注意:fragment 裡的節點只能被「移動」進 DOM 一次,這也是為什麼浮卡
    // 每次打開都要用同一份 detailFragment(節點在裡面搬來搬去,關閉浮卡時
    // 不會被銷毀,只是暫時脫離文件),下面 close() 時會把節點搬回 fragment
    // 保管,讓浮卡可以重複開關而不會把內容用光。
    bodyEl.appendChild(detailFragment);
    document.body.appendChild(overlay);
    document.body.style.overflow = 'hidden';

    function close() {
      // 把節點搬回 fragment 保管(而不是隨著 overlay.remove() 一起被丟棄),
      // 下次再點頭像打開同一個浮卡時內容才還在
      while (bodyEl.firstChild) detailFragment.appendChild(bodyEl.firstChild);
      overlay.remove();
      document.body.style.overflow = '';
      document.removeEventListener('keydown', onKey);
    }
    function onKey(e) { if (e.key === 'Escape') close(); }

    overlay.querySelector('.avbase-talent-card-close').addEventListener('click', close);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
    document.addEventListener('keydown', onKey);

    requestAnimationFrame(() => overlay.classList.add('show'));
  }

  /* =========================================================
   * 9-1. 標籤頁:分類側欄精簡(跟演員頁側欄同一套版面,同樣手法處理)
   * ========================================================= */
  function rebuildTagSidebar() {
    if (!isTagPage()) return;

    // 標籤頁左欄跟演員頁左欄是同一種 `.lg:col-span-1` 版位,用「裡面有 /tags/ 連結」
    // 這個結構特徵去找,順便排除已經處理過的
    const sideCol = Array.from(document.querySelectorAll('.lg\\:col-span-1'))
      .find(el => el.dataset.avbaseDone !== '1' && el.querySelector('a[href^="/tags/"]'));
    if (!sideCol) return;

    // 跟演員頁一樣:外層 4 欄 grid 改成上下堆疊,側欄精簡後才不會被拉開留白
    const outerGrid = sideCol.closest('.grid');
    if (outerGrid && outerGrid.dataset.avbaseStack !== '1') {
      outerGrid.classList.add('avbase-talent-stack');
      outerGrid.dataset.avbaseStack = '1';
    }

    // 結構:sideCol > 外層 wrap div > box(border 卡片) > [row1(標籤名稱+子標籤鈕+編輯鈕), followBtn]
    const box = sideCol.querySelector(':scope > div > div');
    if (!box) return;
    const row1 = box.querySelector(':scope > div');
    if (!row1) return;

    const tagInfoWrap = row1.querySelector(':scope > div'); // 圖示 + 分類連結 + 子標籤展開鈕
    const editBtn = row1.querySelector(':scope > button');   // 編輯(鉛筆)鈕
    const followBtn = box.querySelector(':scope > button');  // フォロー鈕

    const tagLink = sideCol.querySelector('a[href^="/tags/"]');
    if (!tagLink) return;

    sideCol.innerHTML = `
      <div class="avbase-tag-mini">
        <div class="avbase-tag-mini-row">
          ${tagInfoWrap ? tagInfoWrap.outerHTML : tagLink.outerHTML}
          ${editBtn ? editBtn.outerHTML : ''}
        </div>
        ${followBtn ? followBtn.outerHTML : ''}
      </div>
    `;
    sideCol.dataset.avbaseDone = '1';
  }

  /* =========================================================
   * 9-2. 個人動態頁(首頁 /home、收藏頁 /home/bookmarks):
   * 把左欄的歡迎詞 hero 卡片精簡成一條橫幅塞進篩選列上方,右側卡片區塊全寬
   * ========================================================= */
  function rebuildFeedHero() {
    if (!isFeedStyleHomePage()) return;

    // 外層 4 欄 grid:跟演員頁/標籤頁同一種版位 `.grid.grid-cols-1.lg:grid-cols-4...`
    const outerGrid = document.querySelector('.grid.grid-cols-1.lg\\:grid-cols-4');
    if (!outerGrid || outerGrid.dataset.avbaseFeedDone === '1') return;

    const cols = Array.from(outerGrid.children);
    // hero 欄:4 欄 grid 裡「沒有作品卡片、但有 <h1>」的那一欄(不依賴文字內容,
    // 避免「あなたのタイムライン」「あなたの保存」這類字樣以後改版就抓不到)
    const heroCol = cols.find(col => col.querySelector('h1') && !col.querySelector('.bg-background.border.border-light.rounded-lg.overflow-hidden'));
    // 內容欄:剩下那個包了篩選列/卡片格的欄(用 lg:col-span-3 這個已知 class 找,
    // 找不到就退而求其次抓「不是 heroCol 的另一欄」)
    const contentCol = outerGrid.querySelector('.lg\\:col-span-3') ||
      cols.find(col => col !== heroCol);
    if (!heroCol || !contentCol) return;

    const titleEl = heroCol.querySelector('h1');
    const descEl = heroCol.querySelector('p');
    // hero 卡片裡的操作連結(セール中/フォローを管理/ブックマークを見る 之類),
    // 全部原樣保留,塞進橫幅右側
    const actionLinks = Array.from(heroCol.querySelectorAll('a[href]'));

    const bar = document.createElement('div');
    bar.className = 'avbase-feedhero-bar';
    bar.innerHTML = `
      <div class="avbase-feedhero-title">
        <span class="avbase-feedhero-badge">✨ feed</span>
        <span>${titleEl ? escapeHtml(titleEl.textContent.trim()) : ''}</span>
      </div>
      ${descEl ? `<div class="avbase-feedhero-desc">${escapeHtml(descEl.textContent.trim())}</div>` : ''}
      <div class="avbase-feedhero-actions"></div>
    `;
    const actionsWrap = bar.querySelector('.avbase-feedhero-actions');
    actionLinks.forEach(a => actionsWrap.appendChild(a.cloneNode(true)));

    // 找篩選列(含搜尋框那個 sm:border 卡片)當作橫幅的插入點,插在它前面;
    // 找不到的話退而求其次插在內容欄最前面
    const filterCard = contentCol.querySelector('.sm\\:border.border-light.sm\\:rounded-lg.overflow-hidden');
    if (filterCard && filterCard.parentElement) {
      filterCard.parentElement.insertBefore(bar, filterCard);
    } else {
      contentCol.insertBefore(bar, contentCol.firstChild);
    }

    // 原本的 4 欄 grid 改成單欄:hero 欄整個移除(內容已經搬進橫幅),
    // 內容欄變成佔滿全寬
    heroCol.remove();
    outerGrid.classList.remove('grid-cols-1', 'lg:grid-cols-4', 'gap-4', 'lg:gap-8');
    outerGrid.style.display = 'block';
    contentCol.classList.remove('lg:col-span-3');
    outerGrid.dataset.avbaseFeedDone = '1';
  }

  /* =========================================================
   * 10. 作品詳情頁:大封面 + 下方縮圖列點擊都開浮層,關聯作品也套同一套浮層
   * ========================================================= */
  let currentPageItem = null; // 作品詳情頁自己的畫廊項目,封面跟下方縮圖列共用同一份

  // 從「出演者・メモ」區塊撈演員名單的 HTML(找標題是「出演者」的 h2,抓它同區塊底下的內容)
  function extractWorkDetailActorsHtml() {
    const headings = Array.from(document.querySelectorAll('h2'));
    const h = headings.find(el => el.textContent.trim().startsWith('出演者'));
    if (!h) return '';
    const section = h.closest('section');
    const wrap = section ? section.querySelector('.flex.flex-wrap.gap-2') : null;
    return wrap ? wrap.innerHTML : '';
  }

  // 找作品詳情頁上「看起來像封面」的 <img>,不再只認 DMM 的 pl.jpg。
  // 依序嘗試:
  //   1. DMM 的 pl.jpg(原本就支援的家族)
  //   2. __NEXT_DATA__ 給的 image_url/thumbnail_url,直接用這個網址在頁面上找對應的 <img>
  //   3. 上述都找不到的話,退而求其次抓頁面上第一張「夠大」的圖(寬或高有明確
   //      設定、不是 icon/avatar 的那種),當作封面點擊熱區
  // 回傳 { imgEl, coverUrl },imgEl 可能是 null(找不到任何合適的 <img>,
  // 這時呼叫端會改用其他元素當點擊熱區)
  function findWorkDetailCoverInfo(nextData) {
    // 1) DMM pl.jpg:原本就支援的最穩定路徑
    let imgEl = document.querySelector('img[src*="pl.jpg"]');
    if (imgEl) return { imgEl, coverUrl: imgEl.getAttribute('src') || '' };

    // 2) 從 __NEXT_DATA__ 拿非 DMM 來源(mgstage/duga...)的封面網址,
    // 回頭在頁面上找 src 完全相同、或至少同網域+檔名相同的 <img>
    const work = nextData && nextData.props && nextData.props.pageProps && nextData.props.pageProps.work;
    const products = (work && work.products) || [];
    let coverUrl = null;
    for (const p of products) {
      if (p && (p.image_url || p.thumbnail_url)) { coverUrl = p.image_url || p.thumbnail_url; break; }
    }

    if (coverUrl) {
      // 先找完全相同網址的 <img>
      imgEl = document.querySelector(`img[src="${cssEscapeAttr(coverUrl)}"]`);
      if (!imgEl) {
        // 找不到就用檔名(不含 query string)比對,避免 CDN 網址參數不同導致比不到
        try {
          const fileName = new URL(coverUrl, location.href).pathname.split('/').pop();
          if (fileName) {
            imgEl = Array.from(document.querySelectorAll('img')).find(img => (img.getAttribute('src') || '').includes(fileName)) || null;
          }
        } catch (e) { /* ignore */ }
      }
      return { imgEl, coverUrl };
    }

    // 3) 完全沒有 __NEXT_DATA__ 封面資訊:退而求其次,抓頁面上第一張看起來
    // 不是頭像/圖示的大圖(寬高其中一項 > 150px,且不是演員頭像 avatar)
    const candidateImgs = Array.from(document.querySelectorAll('main img'));
    imgEl = candidateImgs.find(img => {
      if (img.closest('[data-slot="avatar"]')) return false;
      const w = img.naturalWidth || parseInt(img.getAttribute('width') || '0', 10);
      const h = img.naturalHeight || parseInt(img.getAttribute('height') || '0', 10);
      return w > 150 || h > 150 || (!w && !h); // 抓不到尺寸資訊時也先當作候選
    }) || candidateImgs[0] || null;

    return { imgEl, coverUrl: (imgEl && imgEl.getAttribute('src')) || '' };
  }

  // 極簡版 CSS.escape polyfill(只處理屬性選擇器裡常見的引號/反斜線問題),
  // 避免某些沒有 CSS.escape 的環境(理論上現代瀏覽器都有,這裡純保險)
  function cssEscapeAttr(str) {
    if (window.CSS && typeof CSS.escape === 'function') return CSS.escape(str);
    return String(str).replace(/["\\]/g, '\\$&');
  }

  function rebuildWorkDetailCover() {
    if (!isWorkDetailPage()) return;
    if (document.body.dataset.avbaseCoverDone === '1') return;

    const nextData = getCurrentPageNextData();
    const { imgEl: coverImg, coverUrl } = findWorkDetailCoverInfo(nextData);

    // 就算完全找不到可用的 <img>,只要有 coverUrl(來自 __NEXT_DATA__)就還是
    // 值得把畫廊項目建起來、掛在標題 <h1> 上當點擊熱區,讓浮層至少打得開
    const work = nextData && nextData.props && nextData.props.pageProps && nextData.props.pageProps.work;
    const title = (work && work.title) || (document.querySelector('h1')?.textContent || '').trim();
    const actorsHtml = extractWorkDetailActorsHtml();

    // 代碼/日期:__NEXT_DATA__ 的 work.work_id 就是完整代碼(例如
    // "892OERO-011"),work.min_date 是 JS Date 可解析的字串,格式化成
    // YYYY/MM/DD 跟卡片格上的日期風格一致
    const codeText = (work && work.work_id) || '';
    let dateText = '';
    if (work && work.min_date) {
      const d = new Date(work.min_date);
      if (!isNaN(d.getTime())) {
        dateText = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
      }
    }

    const smallSrc = coverUrl || (coverImg && coverImg.getAttribute('src')) || '';
    if (!smallSrc) return; // 真的什麼都拿不到,安全放棄,不動頁面

    const item = buildGalleryItem({ smallSrc, workHref: location.pathname, title, actorsHtml, code: codeText, dateText });
    item.isCurrentPage = true;
    currentPageItem = item;

    // 點擊熱區:優先用找到的封面 <img>(或其 <a> 外框),完全找不到的話退而
    // 求其次用 <h1> 當熱區,確保浮層一定掛得上去
    let clickTarget = null;
    if (coverImg) {
      clickTarget = coverImg.closest('a') || coverImg;
      coverImg.dataset.avbaseDone = '1';
    } else {
      clickTarget = document.querySelector('h1');
    }
    if (!clickTarget || clickTarget.dataset.avbaseDone === '1') {
      if (!clickTarget) return;
    }
    clickTarget.dataset.avbaseDone = '1';

    clickTarget.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      openGallery([item], 0);
    });
    clickTarget.style.cursor = 'zoom-in';

    document.body.dataset.avbaseCoverDone = '1';
  }

  // 封面下方那排可以左右滑動的預覽圖縮圖,點了也開同一個浮層,直接跳到那張圖
  function rebuildWorkDetailSampleStrip() {
    if (!isWorkDetailPage() || !currentPageItem || !currentPageItem.cid) return;

    const cid = currentPageItem.cid;
    const re = new RegExp(cid + '(?:jp)?-(\\d+)\\.jpg', 'i');
    const links = Array.from(document.querySelectorAll('a[target="_blank"]')).filter(a => {
      const img = a.querySelector('img');
      return img && re.test(img.getAttribute('src') || '');
    });

    links.forEach(a => {
      if (a.dataset.avbaseDone === '1') return;
      a.dataset.avbaseDone = '1';
      const img = a.querySelector('img');
      const m = (img.getAttribute('src') || '').match(re);
      const n = m ? parseInt(m[1], 10) : 1; // 第幾張預覽圖(1-based,跟 previewList 裡「封面之後」的順序對得上)
      a.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        openGallery([currentPageItem], 0, n);
      });
      a.style.cursor = 'zoom-in';
    });
  }

  // 「同じ女優の作品」「同じシリーズなどの作品」等側欄推薦卡片,點了也開同一套浮層
  function rebuildRelatedArticles() {
    if (!isWorkDetailPage()) return;

    const asideSections = document.querySelectorAll('aside[aria-label="関連作品"] section');
    asideSections.forEach(section => {
      if (section.dataset.avbaseDone === '1') return;
      const articles = Array.from(section.querySelectorAll('article'));
      if (!articles.length) return;

      const items = [];
      articles.forEach(article => {
        const link = article.querySelector('a[href^="/works/"]');
        const img = article.querySelector('img');
        const span = article.querySelector('span.line-clamp-3, span');
        if (!link || !img) return;
        const smallSrc = img.getAttribute('src') || '';
        if (!smallSrc) return;
        const title = (span ? span.textContent : link.textContent || '').trim();
        const item = buildGalleryItem({ smallSrc, workHref: link.getAttribute('href') || '#', title, actorsHtml: '' });
        items.push(item);

        link.addEventListener('click', (e) => {
          e.preventDefault();
          e.stopPropagation();
          const idx = items.indexOf(item);
          openGallery(items, idx >= 0 ? idx : 0);
        });
      });

      section.dataset.avbaseDone = '1';
    });
  }

  /* =========================================================
   * 11. 初始化 / 監聽 SPA 變化
   * ========================================================= */
  function runPageEnhancements() {
    if (isCardGridPage()) {
      normalizeLayout();
      applyColumns();
      rebuildAllCards();
    }
    if (isTalentPage()) {
      rebuildTalentLayout();
      rebuildTalentSidebar();
    }
    if (isTagPage()) {
      rebuildTagSidebar();
    }
    if (isFeedStyleHomePage()) {
      rebuildFeedHero();
    }
    if (isWorkDetailPage()) {
      rebuildWorkDetailCover();
      rebuildWorkDetailSampleStrip();
      rebuildRelatedArticles();
    }
  }

  // 每次路徑切換(SPA 導頁)時,跟頁面類型綁定的一次性旗標要重置,
  // 不然從作品詳情頁切走再切回來(或切到另一個作品)時,rebuildWorkDetailCover()
  // 會因為 document.body.dataset.avbaseCoverDone 還是 '1' 而被誤判成「已經處理過」
  let lastPathname = location.pathname;
  function resetPerPageFlagsIfNavigated() {
    if (location.pathname !== lastPathname) {
      lastPathname = location.pathname;
      delete document.body.dataset.avbaseCoverDone;
      currentPageItem = null;
    }
  }

  function init() {
    buildSettingsPanel();
    applyColumns();
    runPageEnhancements();

    const mo = new MutationObserver(debounce(() => {
      resetPerPageFlagsIfNavigated();
      runPageEnhancements();
    }, 150));
    mo.observe(document.body, { childList: true, subtree: true });

    window.addEventListener('resize', debounce(applyColumns, 200));
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();