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