// ==UserScript==
// @name Anime1.me Plus
// @namespace https://github.com/bakabaka0613/anime1-plus
// @version 0.6.75
// @description Anime1.me 增強:自動封面圖、觀看記錄、續播、自動下一集、網頁全螢幕、快捷鍵
// @author bakabaka0613
// @license MIT
// @match https://anime1.me/*
// @icon https://anime1.me/favicon.ico
// @homepageURL https://github.com/bakabaka0613/anime1-plus
// @supportURL https://github.com/bakabaka0613/anime1-plus/issues
// @updateURL https://raw.githubusercontent.com/bakabaka0613/anime1-plus/main/dist/anime1-plus.user.js
// @downloadURL https://raw.githubusercontent.com/bakabaka0613/anime1-plus/main/dist/anime1-plus.user.js
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @connect api.bgm.tv
// @connect lain.bgm.tv
// @connect api.github.com
// @require https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/dist/umd/full.js
// @run-at document-idle
// @noframes
// ==/UserScript==
(() => {
// src/dom.js
var SEL = {
entryTitle: ".entry-title",
entryContent: ".entry-content",
// 單集頁的分類連結(WordPress 標準 rel,外加 fallback)
categoryLink: 'a[rel~="category"], .cat-links a, .entry-meta a[href*="/category/"], .entry-footer a[href*="/category/"], footer a[href*="/category/"]',
// 分類頁每集連結:指向 /{postId} 且內含標題
episodeLink: 'a[href*="anime1.me/"] h3, a[href*="anime1.me/"] h2'
};
function getPageType(loc = location) {
const p = loc.pathname;
if (/^\/category\//.test(p)) return "category";
if (/^\/\d+\/?$/.test(p)) return "episode";
if (p === "/" || p === "") return "list";
return "other";
}
function postIdFromPath(loc = location) {
const m = loc.pathname.match(/^\/(\d+)\/?$/);
return m ? m[1] : null;
}
function postIdFromUrl(url) {
const m = String(url || "").match(/anime1\.me\/(\d+)/);
return m ? m[1] : null;
}
function postUrl(postId) {
return postId ? `https://anime1.me/${postId}` : null;
}
function animeKeyFromCategoryPath(path) {
let p = path;
try {
p = decodeURIComponent(path);
} catch {
}
const m = p.match(/\/category\/.+$/);
p = m ? m[0] : p;
return p.replace(/\/page\/\d+\/?$/, "").replace(/\/+$/, "");
}
function yearFromText(text) {
const m = String(text || "").match(/(\d{4})\s*年/);
return m ? parseInt(m[1], 10) : null;
}
function waitForVideo(timeout = 2e4) {
return new Promise((resolve) => {
const existing = document.querySelector("video");
if (existing) return resolve(existing);
const obs = new MutationObserver(() => {
const v = document.querySelector("video");
if (v) {
obs.disconnect();
resolve(v);
}
});
obs.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
obs.disconnect();
resolve(document.querySelector("video"));
}, timeout);
});
}
function parseApiReq(el) {
if (!el) return null;
const raw = el.getAttribute && el.getAttribute("data-apireq");
if (!raw) return null;
try {
return JSON.parse(decodeURIComponent(raw));
} catch {
return null;
}
}
function getCategoryId() {
const req = parseApiReq(document.querySelector("[data-apireq]"));
if (req && req.c) return String(req.c);
const cls = `${document.body.className} ${(document.querySelector('article[class*="category-"]') || {}).className || ""}`;
const m = cls.match(/category-(\d+)/);
if (m) return m[1];
const a = document.querySelector('a[href*="cat="]');
if (a) {
const mm = (a.getAttribute("href") || "").match(/[?&]cat=(\d+)/);
if (mm) return mm[1];
}
for (const s of document.querySelectorAll("script:not([src])")) {
const mm = (s.textContent || "").match(/categoryID['"]?\s*[:=]\s*['"]?(\d+)/);
if (mm) return mm[1];
}
return null;
}
function getAnimeTitle() {
const og = document.querySelector('meta[property="og:title"]');
if (og && og.content) return og.content.replace(/\s*全集\s*$/, "").trim();
const h1 = getContentH1();
if (h1) return h1.textContent.trim();
return (document.title || "").replace(/\s*[–\-|].*$/, "").trim();
}
function getContentH1() {
const pageTitle = document.querySelector(".page-title");
if (pageTitle && pageTitle.textContent.trim()) return pageTitle;
return Array.from(document.querySelectorAll("h1")).find(
(h) => !h.closest("#masthead, .site-header, nav, footer, aside") && h.textContent.trim()
) || null;
}
function getCategoryInfo() {
const a = document.querySelector(SEL.categoryLink);
if (!a) return null;
const href = a.getAttribute("href") || "";
const name = (a.textContent || "").trim();
const animeKey = animeKeyFromCategoryPath(href);
return { href, name, animeKey, year: yearFromText(animeKey) };
}
// src/parse.js
var CN_DIGIT = { 一: 1, 二: 2, 兩: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
function cnToNum(s) {
if (/^\d+$/.test(s)) return parseInt(s, 10);
if (s === "十") return 10;
let m;
if (m = s.match(/^十([一二三四五六七八九])$/)) return 10 + CN_DIGIT[m[1]];
if (m = s.match(/^([一二三四五六七八九])十([一二三四五六七八九])?$/)) {
return CN_DIGIT[m[1]] * 10 + (m[2] ? CN_DIGIT[m[2]] : 0);
}
return CN_DIGIT[s] || null;
}
var ROMAN = { "Ⅱ": 2, "Ⅲ": 3, "Ⅳ": 4, "Ⅴ": 5, "Ⅵ": 6 };
var ROMAN_ASCII = { i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6 };
var ORDINAL_WORD = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6 };
function extractEpisode(title) {
const m = title.match(/\[([^\]]*)\]\s*$/);
if (!m) return { ep: null, epRaw: null, rest: title.trim() };
const epRaw = m[1].trim();
const n = epRaw.match(/^(\d+(?:\.\d+)?)(?:v\d+)?$/i);
return {
ep: n ? parseFloat(n[1]) : null,
epRaw,
rest: title.slice(0, m.index).trim()
};
}
function extractType(rest, epRaw) {
const hay = `${rest} ${epRaw || ""}`;
let type = "TV";
if (/劇場版|剧场版|電影版|电影版|\bmovie\b/i.test(hay)) type = "MOVIE";
else if (/OVA|OAD/i.test(hay)) type = "OVA";
else if (/特別篇|特别篇|總集篇|总集篇|\bSP\b|\bspecial\b/i.test(hay)) type = "SP";
const cleaned = rest.replace(
/劇場版|剧场版|電影版|电影版|\bmovie\b|OVA|OAD|特別篇|特别篇|總集篇|总集篇|\bSP\b|\bspecial\b/gi,
""
);
return { type, rest: cleaned };
}
function extractSeason(rest) {
const tries = [
{ re: /第\s*([一二三四五六七八九十\d]+)\s*[季期部]/, num: (m) => cnToNum(m[1]) },
{ re: /\b(\d+)\s*(?:st|nd|rd|th)\s+season\b/i, num: (m) => parseInt(m[1], 10) },
{ re: /\bseason\s*(\d+)\b/i, num: (m) => parseInt(m[1], 10) },
{ re: /\bseason\s+(iii|ii|iv|vi|v|i)\b/i, num: (m) => ROMAN_ASCII[m[1].toLowerCase()] },
{ re: /\b(first|second|third|fourth|fifth|sixth)\s+season\b/i, num: (m) => ORDINAL_WORD[m[1].toLowerCase()] },
{ re: /\bpart\s*(\d+)\b/i, num: (m) => parseInt(m[1], 10) },
{ re: /\b(?:the\s+)?final\s+season\b/i, num: () => 2 },
{ re: /[ⅡⅢⅣⅤⅥ]/, num: (m) => ROMAN[m[0]] }
];
let seasonNum = 1;
let out = rest;
for (const t of tries) {
const m = out.match(t.re);
if (!m) continue;
const n = t.num(m);
out = out.slice(0, m.index) + out.slice(m.index + m[0].length);
if (seasonNum === 1 && n) seasonNum = n;
}
return { seasonNum, rest: out };
}
function normalizeSpace(s) {
return s.replace(/[((]\s*[))]/g, "").replace(/\s[–\-—]+(?=\s|$)/g, "").replace(/\s+/g, " ").trim();
}
function parseTitle(raw) {
const title = String(raw || "").trim();
const { ep, epRaw, rest: r1 } = extractEpisode(title);
const { type, rest: r2 } = extractType(r1, epRaw);
const { seasonNum, rest: r3 } = extractSeason(r2);
return { raw: title, ep, epRaw, seasonNum, type, baseName: normalizeSpace(r3) };
}
// src/util.js
var _ccCache = {};
function ccConverter(from, to) {
const key = `${from}2${to}`;
if (key in _ccCache) return _ccCache[key];
_ccCache[key] = null;
try {
const g = typeof unsafeWindow !== "undefined" ? unsafeWindow : typeof window !== "undefined" ? window : {};
const OC = typeof OpenCC !== "undefined" && OpenCC || g.OpenCC;
if (OC && OC.Converter) _ccCache[key] = OC.Converter({ from, to });
} catch {
}
return _ccCache[key];
}
function toSimplified(s) {
const str = String(s || "");
const conv = ccConverter("tw", "cn");
return conv ? conv(str) : str;
}
function toTraditional(s) {
const str = String(s || "");
const conv = ccConverter("cn", "tw");
return conv ? conv(str) : str;
}
function isAdultLink(href) {
return /anime1\.pw/i.test(String(href || ""));
}
function titleSearchSegments(baseName) {
const k = String(baseName || "").trim();
const clean = (s) => s.replace(/^[-–—\s]+|[-–—\s]+$/g, "").trim();
const out = [];
const add = (s) => {
const c = clean(s);
if (c && c.length >= 2 && c !== k && !out.includes(c)) out.push(c);
};
const paren = k.match(/^(.*?)[((]([^(())]+)[))](.*)$/);
if (paren) {
add(`${paren[1]} ${paren[3]}`);
add(paren[2]);
return out;
}
const bi = k.match(/^([A-Za-z][A-Za-z0-9 .,&':!?]*?)[\s—–-]+([-ヿ㐀-䶿一-鿿豈-].*)$/);
if (bi) {
add(bi[1]);
add(bi[2]);
return out;
}
const m = k.match(/\s*[—–]+\s*|\s+-+\s*|\s+/);
if (m) {
add(k.slice(0, m.index));
add(k.slice(m.index + m[0].length));
}
return out;
}
function splitAliasNames(value) {
return String(value || "").split(/[、,,;;//]/).map((s) => s.trim()).filter(Boolean);
}
function toHalfWidth(s) {
return s.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 65248)).replace(/ /g, " ");
}
function normalizeName(s) {
return toSimplified(toHalfWidth(String(s || ""))).toLowerCase().replace(/[\s]/g, "").replace(/[!?。.・:~\-—_、,「」『』()\[\]{}"'’“”…★☆※//]/g, "");
}
function levenshtein(a, b) {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const cur = [i];
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
}
prev = cur;
}
return prev[b.length];
}
function lcsLength(a, b) {
const m = a.length;
const n = b.length;
if (!m || !n) return 0;
let prev = new Array(n + 1).fill(0);
for (let i = 1; i <= m; i++) {
const cur = new Array(n + 1).fill(0);
for (let j = 1; j <= n; j++) {
cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
}
prev = cur;
}
return prev[n];
}
function similarity(a, b) {
const na = normalizeName(a);
const nb = normalizeName(b);
if (!na || !nb) return 0;
if (na === nb) return 1;
let score;
if (na.includes(nb) || nb.includes(na)) {
const ratio = Math.min(na.length, nb.length) / Math.max(na.length, nb.length);
score = 0.3 + 0.5 * ratio;
} else {
const dist = levenshtein(na, nb);
score = 1 - dist / Math.max(na.length, nb.length);
}
const lcsRatio = lcsLength(na, nb) / Math.max(na.length, nb.length);
return Math.max(score, lcsRatio);
}
function parseLatestEp(text) {
const t = String(text || "").trim();
const airing = t.match(/連載中\s*\(([^)]*)\)/);
const head = airing ? airing[1] : t.split("+")[0];
const nums = head.match(/\d+(?:\.\d+)?/g);
return nums ? Math.max(...nums.map(Number)) : null;
}
function isAiring(text) {
return /連載中/.test(String(text || ""));
}
function pendingNewEpisodes(latestEp, watch) {
if (latestEp == null) return null;
let maxDone = null;
for (const ep of Object.keys(watch || {})) {
if (!watch[ep] || !watch[ep].done) continue;
const n = Number(ep);
if (!Number.isNaN(n) && (maxDone === null || n > maxDone)) maxDone = n;
}
if (maxDone === null || latestEp <= maxDone) return null;
return latestEp - maxDone;
}
function caughtUpNewEpisodes(latestEp, watch, maxEpSeen) {
if (latestEp == null || maxEpSeen == null) return null;
let maxDone = null;
for (const ep of Object.keys(watch || {})) {
if (!watch[ep] || !watch[ep].done) continue;
const n = Number(ep);
if (!Number.isNaN(n) && (maxDone === null || n > maxDone)) maxDone = n;
}
if (maxDone === null || maxDone < maxEpSeen || latestEp <= maxDone) return null;
return latestEp - maxDone;
}
function resumeTarget(episodes) {
let lastEp = null;
let lastAt = -1;
for (const e of Object.keys(episodes || {})) {
const at = episodes[e] && episodes[e].watchedAt || 0;
if (at > lastAt) {
lastAt = at;
lastEp = e;
}
}
if (lastEp == null) return { mode: "none" };
if (!episodes[lastEp].done) return { mode: "resume", ep: lastEp };
return { mode: "next", ep: Number(lastEp) + 1 };
}
function isCaughtUp(episodes, metaEpisodes, newEps) {
const target = resumeTarget(episodes);
if (target.mode === "resume") return false;
const hasNextItem = Array.isArray(metaEpisodes) && metaEpisodes.some((it) => String(it.ep) === String(target.ep));
if (hasNextItem) return false;
if (newEps) return false;
return true;
}
function pickByHint(jobs, hint) {
if (!jobs || !jobs.length || !hint || !hint.length) return 0;
const rank = /* @__PURE__ */ new Map();
hint.forEach((k, i) => {
if (!rank.has(k)) rank.set(k, i);
});
let best = 0;
let bestRank = Infinity;
for (let i = 0; i < jobs.length; i++) {
const j = jobs[i];
const r = j && j.key != null && rank.has(j.key) ? rank.get(j.key) : Infinity;
if (r < bestRank) {
bestRank = r;
best = i;
}
}
return best;
}
function evaluateRecheckLease(stored, tabId, now, ttl) {
const fresh = stored && typeof stored.expires === "number" && stored.expires > now;
if (fresh && stored.owner !== tabId) return { owns: false, lease: stored };
return { owns: true, lease: { owner: tabId, expires: now + ttl } };
}
function shouldRecheck(cover, now, retryMs = 7 * 24 * 60 * 60 * 1e3) {
if (!cover || !cover.tentative) return false;
if (cover.deepTried && now - cover.deepTried < retryMs) return false;
return true;
}
function markEpisodesDone(animeWatch, metaEpisodes, now) {
const eps = new Set(Object.keys(animeWatch || {}));
if (Array.isArray(metaEpisodes)) {
for (const it of metaEpisodes) if (it && it.ep != null) eps.add(String(it.ep));
}
const sorted = [...eps].sort((a, b) => Number(a) - Number(b));
const out = {};
sorted.forEach((ep, i) => {
out[ep] = { ...animeWatch && animeWatch[ep], done: true, watchedAt: now + i };
});
return out;
}
function maxWatchedAt(animeWatch) {
let mx = 0;
for (const k of Object.keys(animeWatch || {})) {
const w = animeWatch[k] && animeWatch[k].watchedAt || 0;
if (w > mx) mx = w;
}
return mx;
}
function isDeleted(animeWatch, animeMeta) {
const d = animeMeta && animeMeta.deletedAt || 0;
if (!d) return false;
return d >= maxWatchedAt(animeWatch);
}
function unionEpisodes(a, b) {
const byPost = /* @__PURE__ */ new Map();
for (const e of Array.isArray(a) ? a : []) if (e && e.postId != null) byPost.set(String(e.postId), e);
for (const e of Array.isArray(b) ? b : []) if (e && e.postId != null) byPost.set(String(e.postId), e);
return [...byPost.values()];
}
function mergeSync(local, remote) {
const lw = local && local.watch || {};
const rw = remote && remote.watch || {};
const lm = local && local.meta || {};
const rm = remote && remote.meta || {};
const watch = {};
for (const catId of /* @__PURE__ */ new Set([...Object.keys(lw), ...Object.keys(rw)])) {
const le = lw[catId] || {};
const re = rw[catId] || {};
const eps = {};
for (const ep of /* @__PURE__ */ new Set([...Object.keys(le), ...Object.keys(re)])) {
const a = le[ep];
const b = re[ep];
if (!a) eps[ep] = b;
else if (!b) eps[ep] = a;
else eps[ep] = (b.watchedAt || 0) >= (a.watchedAt || 0) ? b : a;
}
watch[catId] = eps;
}
const meta = {};
for (const catId of /* @__PURE__ */ new Set([...Object.keys(lm), ...Object.keys(rm)])) {
const a = lm[catId];
const b = rm[catId];
let m;
if (!a) m = { ...b };
else if (!b) m = { ...a };
else {
const am = typeof a.maxEpSeen === "number" ? a.maxEpSeen : -Infinity;
const bm = typeof b.maxEpSeen === "number" ? b.maxEpSeen : -Infinity;
m = { ...bm >= am ? b : a, maxEpSeen: Math.max(am, bm), episodes: unionEpisodes(a.episodes, b.episodes) };
}
const dz = Math.max(a && a.deletedAt || 0, b && b.deletedAt || 0);
if (dz && dz >= maxWatchedAt(watch[catId])) m.deletedAt = dz;
else delete m.deletedAt;
meta[catId] = m;
}
return { watch, meta };
}
function throttle(fn, wait) {
let last = 0;
let timer = null;
let lastArgs = null;
return function throttled(...args) {
lastArgs = args;
const now = Date.now();
const remaining = wait - (now - last);
if (remaining <= 0) {
if (timer) {
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(this, lastArgs);
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, lastArgs);
}, remaining);
}
};
}
function cleanTitle(s) {
return String(s || "").replace(/\s*[–\-|]\s*Anime1.*$/i, "").trim();
}
function normalizeWatchMeta(data) {
const src = data || {};
const before = JSON.stringify({ watch: src.watch || {}, meta: src.meta || {} });
const POST_ID = /anime1\.me\/(\d+)/;
const slimEp = (rec) => {
const r = { ...rec };
if (r.url) {
if (!r.postId) {
const m = String(r.url).match(POST_ID);
if (m) r.postId = m[1];
}
if (r.postId) delete r.url;
}
return r;
};
const watch = {};
for (const cat of Object.keys(src.watch || {})) {
const eps = src.watch[cat] || {};
watch[cat] = {};
for (const ep of Object.keys(eps)) watch[cat][ep] = slimEp(eps[ep]);
}
const meta = {};
for (const cat of Object.keys(src.meta || {})) {
const m = { ...src.meta[cat] || {} };
if (typeof m.title === "string") m.title = cleanTitle(m.title);
if (Array.isArray(m.episodes)) m.episodes = m.episodes.map(slimEp);
meta[cat] = m;
}
const after = JSON.stringify({ watch, meta });
return { watch, meta, changed: after !== before };
}
function formatTime(sec) {
if (!Number.isFinite(sec) || sec < 0) sec = 0;
const s = Math.floor(sec % 60);
const m = Math.floor(sec / 60 % 60);
const h = Math.floor(sec / 3600);
const pad = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
function seasonBuckets(r3, r4) {
const s = String(r4 || "");
const prefixed = [...s.matchAll(/(\d{4})\s*([春夏秋冬])/g)];
if (prefixed.length) return prefixed.map((m) => m[1] + m[2]);
const seasons = s.match(/[春夏秋冬]/g) || [];
const years = [];
for (const y of String(r3 || "").match(/\d{4}/g) || []) if (!years.includes(y)) years.push(y);
if (!seasons.length || !years.length) return [];
if (years.length === seasons.length) return seasons.map((se, i) => years[i] + se);
if (years.length === 1) return seasons.map((se) => years[0] + se);
return seasons.map((se) => years[0] + se);
}
function dateToBucket(dateStr) {
const m = String(dateStr || "").match(/(\d{4})-(\d{1,2})/);
if (!m) return null;
let year = parseInt(m[1], 10);
const mon = parseInt(m[2], 10);
if (!mon || mon < 1 || mon > 12) return null;
let season;
if (mon === 12 || mon <= 2) {
season = "冬";
if (mon === 12) year += 1;
} else if (mon <= 5) season = "春";
else if (mon <= 8) season = "夏";
else season = "秋";
return `${year}${season}`;
}
function tagNamesFrom(raw) {
const arr = Array.isArray(raw) ? raw : [];
if (arr.length && typeof arr[0] === "object") {
return arr.filter((t) => t && typeof t.name === "string" && t.name.trim()).slice().sort((a, b) => (b.count || 0) - (a.count || 0)).map((t) => t.name.trim());
}
return arr.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim());
}
var META_TAG_DROP = /* @__PURE__ */ new Set(["TV", "日本"]);
function isTimeTag(s) {
return /^\d{4}$/.test(s) || /^\d{4}年(\d{1,2}月)?$/.test(s) || /^\d{1,2}月$/.test(s) || /^\d{4}\s*[春夏秋冬]$/.test(s);
}
function buildCoverTags(rawTags, rawMetaTags, n = 10) {
const metaTrad = (Array.isArray(rawMetaTags) ? rawMetaTags : []).map((t) => toTraditional(String(t == null ? "" : t).trim())).filter(Boolean);
const metaAll = new Set(metaTrad);
const metaTags = [];
const metaSeen = /* @__PURE__ */ new Set();
for (const name of metaTrad) {
if (META_TAG_DROP.has(name) || metaSeen.has(name)) continue;
metaSeen.add(name);
metaTags.push(name);
}
const tags = [];
const tagSeen = /* @__PURE__ */ new Set();
for (const raw of tagNamesFrom(rawTags)) {
const name = toTraditional(raw);
if (!name || name === "TV" || isTimeTag(name) || metaAll.has(name) || tagSeen.has(name)) continue;
tagSeen.add(name);
tags.push(name);
if (tags.length >= n) break;
}
return { tags, metaTags };
}
function needsCoverMeta(cover, now, retryMs = 7 * 24 * 60 * 60 * 1e3) {
if (!cover || !cover.subjectId || cover.date) return false;
if (cover.metaTriedAt && now - cover.metaTriedAt < retryMs) return false;
return true;
}
// src/store.js
var ROOT_KEY = "a1p:data";
var SYNC_KEY = "a1p:sync";
var RECHECK_LEASE_KEY = "a1p:recheck_lease";
var COVER_EVT_KEY = "a1p:cover_evt";
var RECHECK_HINT_KEY = "a1p:recheck_hint";
var LAST_WRITE_KEY = "a1p:last_write";
var DEFAULT_SETTINGS = {
autoNext: true,
// 看完自動下一集
autoNextThreshold: 0.9,
// 看完判定比例
resume: true,
// 續播
shortcuts: true,
// 鍵盤快捷鍵
seekSeconds: 5,
// 方向鍵快進/後退秒數
rememberRate: true,
// 記憶播放速度
listThumbs: true,
// 列表頁增強(封面/卡片)
gridView: true,
// 列表頁卡片檢視(false = 原始列表)
cardWidth: 250
// 卡片最小寬度 px
};
function loadRoot() {
try {
const raw = GM_getValue(ROOT_KEY, "");
const obj = raw ? JSON.parse(raw) : {};
return {
covers: obj.covers || {},
// { [catId]: { subjectId, cover, name, name_cn, score, manual, ts } }
watch: obj.watch || {},
// { [catId]: { [ep]: { currentTime, duration, done, watchedAt, postId } } }(postId 重建單集頁網址)
meta: obj.meta || {},
// { [catId]: { title(乾淨無站名後綴), maxEpSeen, episodes:[{ep,postId}] } }
settings: { ...DEFAULT_SETTINGS, ...obj.settings || {} }
};
} catch {
return { covers: {}, watch: {}, meta: {}, settings: { ...DEFAULT_SETTINGS } };
}
}
var changeListener = null;
function onDataChange(fn) {
changeListener = fn;
}
function saveRoot(root) {
GM_setValue(ROOT_KEY, JSON.stringify(root));
if (changeListener) {
try {
changeListener();
} catch {
}
}
}
function getCover(catId) {
return loadRoot().covers[catId] || null;
}
function setCover(catId, data) {
const root = loadRoot();
root.covers[catId] = { ...data, ts: Date.now() };
saveRoot(root);
}
function getRecheckLease() {
try {
return JSON.parse(GM_getValue(RECHECK_LEASE_KEY, "") || "null");
} catch {
return null;
}
}
function setRecheckLease(lease) {
GM_setValue(RECHECK_LEASE_KEY, JSON.stringify(lease));
}
function notifyCoverUpgrade(catId) {
GM_setValue(COVER_EVT_KEY, JSON.stringify({ catId, ts: Date.now() }));
}
function setRecheckHint(order) {
GM_setValue(RECHECK_HINT_KEY, JSON.stringify({ ts: Date.now(), order: order || [] }));
}
function getRecheckHint(maxAgeMs = 12e4) {
try {
const h = JSON.parse(GM_getValue(RECHECK_HINT_KEY, "") || "null");
if (h && Array.isArray(h.order) && typeof h.ts === "number" && Date.now() - h.ts <= maxAgeMs) return h.order;
} catch {
}
return null;
}
function onCoverUpgradeEvent(fn) {
if (typeof GM_addValueChangeListener !== "function") return;
GM_addValueChangeListener(COVER_EVT_KEY, (_name, _old, newV, remote) => {
if (!remote) return;
try {
const e = JSON.parse(newV);
if (e && e.catId) fn(e.catId);
} catch {
}
});
}
function getTentativeCovers() {
const { covers } = loadRoot();
return Object.entries(covers).filter(([, c]) => c && c.tentative).map(([catId, c]) => ({ catId, ...c }));
}
function getAnimeWatch(catId) {
const root = loadRoot();
if (isDeleted(root.watch[catId], root.meta[catId])) return {};
return root.watch[catId] || {};
}
function getEpisode(catId, ep) {
const root = loadRoot();
if (isDeleted(root.watch[catId], root.meta[catId])) return null;
return (root.watch[catId] || {})[ep] || null;
}
function setEpisodeProgress(catId, ep, data) {
const root = loadRoot();
root.watch[catId] = root.watch[catId] || {};
const prev = root.watch[catId][ep] || {};
const rec = { ...prev, ...data, watchedAt: Date.now() };
if (rec.postId && rec.url) delete rec.url;
root.watch[catId][ep] = rec;
if (root.meta[catId] && root.meta[catId].deletedAt) delete root.meta[catId].deletedAt;
saveRoot(root);
markLocalWrite();
}
function markLocalWrite() {
GM_setValue(LAST_WRITE_KEY, Date.now());
}
function getLastWriteAt() {
const v = GM_getValue(LAST_WRITE_KEY, 0);
return typeof v === "number" ? v : Number(v) || 0;
}
function setMeta(catId, data) {
const root = loadRoot();
root.meta[catId] = { ...root.meta[catId] || {}, ...data };
saveRoot(root);
}
function getMeta(catId) {
return loadRoot().meta[catId] || null;
}
function getInProgressList() {
const root = loadRoot();
const out = [];
for (const catId of Object.keys(root.watch)) {
if (isDeleted(root.watch[catId], root.meta[catId])) continue;
const eps = root.watch[catId];
const epNums = Object.keys(eps);
const anyUnfinished = epNums.some((e) => !eps[e].done);
const lastWatched = Math.max(...epNums.map((e) => eps[e].watchedAt || 0));
out.push({
catId,
cover: root.covers[catId] || null,
meta: root.meta[catId] || null,
episodes: eps,
anyUnfinished,
lastWatched
});
}
return out.sort((a, b) => b.lastWatched - a.lastWatched);
}
function getSettings() {
return loadRoot().settings;
}
function setSettings(patch) {
const root = loadRoot();
root.settings = { ...root.settings, ...patch };
saveRoot(root);
}
function clearAnime(catId) {
const root = loadRoot();
delete root.covers[catId];
delete root.watch[catId];
delete root.meta[catId];
saveRoot(root);
}
function markAnimeWatched(catId) {
const root = loadRoot();
const metaEps = root.meta[catId] && root.meta[catId].episodes;
root.watch[catId] = markEpisodesDone(root.watch[catId] || {}, metaEps, Date.now());
saveRoot(root);
}
function deleteAnimeSynced(catId) {
const root = loadRoot();
const eps = root.watch[catId];
if (eps) for (const ep of Object.keys(eps)) eps[ep] = { ...eps[ep], currentTime: 0 };
root.meta[catId] = { ...root.meta[catId] || {}, deletedAt: Date.now() };
saveRoot(root);
}
function clearCover(catId) {
const root = loadRoot();
delete root.covers[catId];
saveRoot(root);
}
function clearCovers() {
const root = loadRoot();
root.covers = {};
saveRoot(root);
}
function clearWatch() {
const root = loadRoot();
root.watch = {};
root.meta = {};
saveRoot(root);
}
function clearSettings() {
const root = loadRoot();
root.settings = { ...DEFAULT_SETTINGS };
saveRoot(root);
}
function clearAll() {
saveRoot({ covers: {}, watch: {}, meta: {}, settings: { ...DEFAULT_SETTINGS } });
}
function exportAll() {
return JSON.stringify(loadRoot(), null, 2);
}
function importAll(jsonText, { merge = true } = {}) {
const incoming = JSON.parse(jsonText);
if (!merge) {
saveRoot({
covers: incoming.covers || {},
watch: incoming.watch || {},
meta: incoming.meta || {},
settings: { ...DEFAULT_SETTINGS, ...incoming.settings || {} }
});
return;
}
const root = loadRoot();
saveRoot({
covers: { ...root.covers, ...incoming.covers || {} },
watch: { ...root.watch, ...incoming.watch || {} },
meta: { ...root.meta, ...incoming.meta || {} },
settings: { ...root.settings, ...incoming.settings || {} }
});
}
var DEFAULT_SYNC = { token: "", gistId: "", enabled: false, lastSyncAt: 0, lastError: "", rateLimitUntil: 0, lastAttemptAt: 0 };
function getSyncConfig() {
try {
const raw = GM_getValue(SYNC_KEY, "");
return { ...DEFAULT_SYNC, ...raw ? JSON.parse(raw) : {} };
} catch {
return { ...DEFAULT_SYNC };
}
}
function setSyncConfig(patch) {
const next = { ...getSyncConfig(), ...patch };
GM_setValue(SYNC_KEY, JSON.stringify(next));
return next;
}
function getSyncSubset() {
const root = loadRoot();
return { watch: root.watch, meta: root.meta };
}
function applySyncedData(incoming) {
const root = loadRoot();
const before = JSON.stringify({ watch: root.watch, meta: root.meta });
const merged = mergeSync({ watch: root.watch, meta: root.meta }, incoming || {});
const norm = normalizeWatchMeta(merged);
const after = JSON.stringify({ watch: norm.watch, meta: norm.meta });
if (after === before) return { changed: false };
root.watch = norm.watch;
root.meta = norm.meta;
saveRoot(root);
return { changed: true };
}
function migrateStored() {
const root = loadRoot();
const norm = normalizeWatchMeta({ watch: root.watch, meta: root.meta });
if (!norm.changed) return false;
root.watch = norm.watch;
root.meta = norm.meta;
saveRoot(root);
return true;
}
// src/animelist.js
var URL2 = "https://anime1.me/animelist.json";
var TTL = 5 * 60 * 1e3;
var cache = null;
var cacheAt = 0;
async function fetchLatestEpMap() {
const now = Date.now();
if (cache && now - cacheAt < TTL) return cache;
try {
const res = await fetch(URL2, { credentials: "omit" });
if (!res.ok) return cache || {};
const rows = await res.json();
const map = {};
for (const r of rows) {
if (!Array.isArray(r) || r[0] == null) continue;
const epText = String(r[2]);
map[`cat:${r[0]}`] = {
ep: parseLatestEp(epText),
// 無一般集數 → null(renderPanel 視同無更新)
airing: isAiring(epText),
name: r[1] != null ? String(r[1]).trim() : "",
year: r[3] != null ? String(r[3]) : null,
season: r[4] != null ? String(r[4]) : null
// 原始季欄(可能跨季/跨年斜線多值)→ seasonBuckets 解析
};
}
cache = map;
cacheAt = now;
return map;
} catch {
return cache || {};
}
}
// src/ui.js
var BGM = (id) => `https://bgm.tv/subject/${id}`;
var stylesInjected = false;
function injectStyles() {
if (stylesInjected) return;
stylesInjected = true;
const css = `
.a1p-card{display:flex;gap:12px;align-items:flex-start;margin:0;padding:12px;
background:#1b1b1f;border:1px solid #33343a;border-radius:10px;color:#e8e8ea;font-size:14px}
.a1p-card img{width:96px;height:136px;object-fit:cover;border-radius:6px;flex:none;background:#2a2a30}
/* 電腦端(站方斷點 769px)封面放大 1.5 倍 */
@media (min-width:769px){.a1p-card img{width:144px;height:204px}}
.a1p-card .a1p-meta{flex:1;min-width:0}
.a1p-card .a1p-name{font-weight:700;font-size:16px;margin:0 0 4px}
.a1p-card .a1p-sub{color:#9aa0a6;margin:0 0 6px;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.a1p-badge{display:inline-block;padding:1px 7px;border-radius:99px;font-size:12px;margin-right:6px}
.a1p-badge.ok{background:#1e3a24;color:#7ee29a}
.a1p-badge.warn{background:#3a2f1e;color:#e2c47e}
.a1p-badge.season{background:#23252b;color:#b9c0c9}
.a1p-btn{cursor:pointer;border:1px solid #45464c;background:#26272c;color:#e8e8ea;
border-radius:6px;padding:4px 10px;font-size:13px;margin-right:6px}
.a1p-btn:hover{background:#303138}
/* 播放器下方原生「全集連結/下一集/上一集」連結:單集頁→水平按鈕列;分類頁→隱藏 */
.a1p-navrow{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0}
.a1p-navrow .a1p-btn{margin-right:0;text-decoration:none;display:inline-block}
.a1p-btn-disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.a1p-nav-hidden{display:none!important}
.a1p-pick{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
.a1p-pick .a1p-opt{width:84px;cursor:pointer;text-align:center}
.a1p-pick .a1p-opt img{width:84px;height:118px;object-fit:cover;border-radius:6px;background:#2a2a30}
.a1p-pick .a1p-opt span{display:block;font-size:11px;color:#cfd2d6;margin-top:3px;line-height:1.2}
.a1p-ep-done{opacity:.55}
.a1p-ep-done::after{content:" ✓";color:#7ee29a}
.a1p-ep-bar{height:3px;background:#7aa2f7;border-radius:2px;margin-top:3px}
.a1p-toast-wrap{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483600;
display:flex;flex-direction:column;gap:8px;align-items:center}
.a1p-toast{background:#26272cdd;color:#fff;border:1px solid #45464c;border-radius:8px;
padding:8px 14px;font-size:14px;display:flex;align-items:center;gap:10px;backdrop-filter:blur(4px)}
.a1p-toast .a1p-btn{padding:2px 8px}
/* 貼上 JSON 匯入對話框(不依賴檔案選擇器,油猴環境較可靠)*/
.a1p-modal-overlay{position:fixed;inset:0;z-index:2147483640;background:#000a;
display:flex;align-items:center;justify-content:center}
.a1p-modal{background:#1b1b1f;border:1px solid #33343a;border-radius:10px;padding:16px;
width:min(560px,90vw);color:#e8e8ea}
.a1p-modal h4{margin:0 0 10px;font-size:15px}
.a1p-modal-ta{width:100%;height:200px;box-sizing:border-box;background:#0d0d10;border:1px solid #45464c;
border-radius:6px;color:#e8e8ea;padding:8px;font-size:12px;font-family:monospace;resize:vertical}
.a1p-modal-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:10px}
.a1p-fab{position:fixed;right:18px;bottom:18px;z-index:2147483600;width:46px;height:46px;border-radius:50%;
background:#7aa2f7;color:#0b1020;font-size:22px;border:none;cursor:pointer;box-shadow:0 3px 10px #0006;
user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;touch-action:manipulation}
.a1p-panel{position:fixed;right:18px;bottom:74px;z-index:2147483600;width:370px;max-height:60vh;overflow:auto;
background:#1b1b1f;border:1px solid #33343a;border-radius:10px;color:#e8e8ea;font-size:13px;padding:10px}
.a1p-panel h4{margin:2px 0 8px;font-size:14px}
.a1p-row{display:flex;gap:8px;padding:6px 0;border-top:1px solid #2a2a30;align-items:center}
.a1p-row img{width:40px;height:56px;object-fit:cover;border-radius:4px;flex:none;background:#2a2a30;cursor:zoom-in}
/* 追番列封面 hover 放大預覽:浮在面板外(面板 overflow:auto 會裁切,故獨立貼 body 並 fixed 定位) */
.a1p-cover-preview{position:fixed;z-index:2147483601;display:none;width:240px;height:338px;padding:4px;
background:#0b0b0d;border:1px solid #45464c;border-radius:8px;box-shadow:0 8px 28px #000a;
object-fit:contain;pointer-events:none}
.a1p-row a{color:#9ec1ff;text-decoration:none}
.a1p-row a.a1p-row-term{color:#9aa0a6} /* 已看完/已到最新進度:低調灰,仍可點回最後看的一集 */
.a1p-row .a1p-rname{font-weight:600}
.a1p-row.a1p-row-new{background:#2a1820;border-left:3px solid #e0466e;padding-left:6px;margin-left:-3px}
.a1p-row-badge{display:inline-block;margin-left:6px;background:#e0466e;color:#fff;font-size:11px;
font-weight:700;line-height:1;padding:2px 6px;border-radius:99px;vertical-align:middle}
.a1p-row-actions{margin-left:auto;flex:none;display:flex;flex-direction:column;gap:6px}
.a1p-row-del{flex:none;border:1px solid #e0466e;background:transparent;color:#e0466e;
cursor:pointer;border-radius:6px;width:28px;height:28px;font-size:15px;line-height:1;
display:flex;align-items:center;justify-content:center}
.a1p-row-del:hover{background:#e0466e;color:#fff}
.a1p-row-done{flex:none;border:1px solid #7ee29a;background:transparent;color:#7ee29a;
cursor:pointer;border-radius:6px;width:28px;height:28px;font-size:15px;line-height:1;
display:flex;align-items:center;justify-content:center}
.a1p-row-done:hover{background:#1e3a24;color:#7ee29a}
.a1p-panel-hint{margin:-4px 0 8px;font-size:11px;color:#e0466e}
.a1p-hide{display:none!important}
.a1p-list-thumb{width:34px;height:48px;object-fit:cover;border-radius:4px;vertical-align:middle;
margin-right:8px;background:#2a2a30;display:inline-block}
.a1p-thumb-unknown{border:1px dashed #6a6a72}
/* 海報容器:角標(待確認/評分)的定位基準。原始列表模式同封面一起隱藏 */
.a1p-poster-wrap{display:none}
body.a1p-grid-on .a1p-poster-wrap{display:block;position:relative;
-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
/* 封面待確認角標:低信心仍放圖,左上角標提示,誘導點進分類頁重新比對/手選 */
.a1p-cover-uncertain{display:none}
body.a1p-grid-on .a1p-cover-uncertain{display:flex;align-items:center;gap:3px;position:absolute;
top:6px;left:6px;z-index:2;pointer-events:none;background:#3a2f1ee6;color:#e2c47e;font-size:11px;
font-weight:600;line-height:1;padding:3px 7px;border-radius:99px;border:1px solid #6b5a2e;backdrop-filter:blur(2px)}
/* Bangumi 評分:海報右下角「★ 8.5」 */
.a1p-rating-badge{display:none}
body.a1p-grid-on .a1p-rating-badge{display:block;position:absolute;right:6px;bottom:6px;z-index:2;
pointer-events:none;background:#000a;color:#ffd24a;font-size:12px;font-weight:700;line-height:1;
padding:3px 7px;border-radius:99px;backdrop-filter:blur(2px)}
/* 右鍵封面 → TAG 疊層(metaTags 藍底在前、tags 灰底在後);滑鼠移開即移除。覆滿封面、超出可捲動。
淡入漸暗(animation),底色不過暗(半透明+模糊);tag 置中、平均分散好看 */
.a1p-cover-tags{position:absolute;inset:0;z-index:6;background:rgba(10,10,14,.62);
overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;
padding:12px 10px;box-sizing:border-box;backdrop-filter:blur(3px);animation:a1p-tags-fade .22s ease both;
-webkit-user-select:none;user-select:none;
scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.32) transparent}
/* 美化垂直捲動條(WebKit):細、半透明、圓角,hover 加亮 */
.a1p-cover-tags::-webkit-scrollbar{width:6px}
.a1p-cover-tags::-webkit-scrollbar-track{background:transparent;margin:6px 0}
.a1p-cover-tags::-webkit-scrollbar-thumb{background:rgba(255,255,255,.3);border-radius:99px}
.a1p-cover-tags::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.5)}
@keyframes a1p-tags-fade{from{opacity:0}to{opacity:1}}
.a1p-cover-tags.a1p-cover-tags-out{animation:a1p-tags-fadeout .18s ease both;pointer-events:none}
@keyframes a1p-tags-fadeout{from{opacity:1}to{opacity:0}}
.a1p-cover-tags-inner{min-height:100%;display:flex;flex-wrap:wrap;gap:8px;
justify-content:center;align-content:center;align-items:center}
/* 長 tag 在卡片過窄時於 pill 內換行(不撐出水平捲動);max-width 限制不超出容器 */
.a1p-cover-tag{font-size:12.5px;line-height:1.4;padding:4px 11px;border-radius:12px;text-align:center;
white-space:normal;word-break:break-word;max-width:100%;box-sizing:border-box;
background:rgba(28,30,40,.92);color:#f1f3f9;border:1px solid rgba(255,255,255,.42);
box-shadow:0 1px 3px rgba(0,0,0,.36)}
.a1p-cover-tag.meta{font-weight:600;color:#fff;border-color:rgba(196,214,255,.85);
background:linear-gradient(135deg,#4f6ee0,#8a4fd6)}
/* 疊層的狀態訊息(載入中/查無標籤):低調置中文字,非 chip 樣式 */
.a1p-cover-tags-msg{font-size:13px;color:#cdd2da;padding:4px 10px;text-align:center}
/* 動畫資訊卡內平鋪的 TAG 列(chip 樣式重用 .a1p-cover-tag,但較小)。
截斷成最多兩排:chip 高 ~21.4px(font 11×1.4 + padding 4 + border 2),兩排+1 gap(5px)≈48px;
第三排起頭已超過 max-height → overflow:hidden 整排裁掉,不留半截。 */
.a1p-card-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;max-height:48px;overflow:hidden}
.a1p-card-tags .a1p-cover-tag{font-size:11px;line-height:1.4;padding:2px 8px;border-radius:10px;
white-space:nowrap}
/* 更新提醒徽章:卡片右上角,僅卡片檢視模式定位(原始列表模式隱藏)*/
.a1p-update-badge{display:none}
body.a1p-grid-on .a1p-card-row{position:relative}
body.a1p-grid-on .a1p-update-badge{display:block;position:absolute;top:6px;right:6px;z-index:3;
background:#e0466e;color:#fff;font-size:12px;font-weight:700;line-height:1;padding:3px 7px;
border-radius:99px;box-shadow:0 1px 5px #0008;pointer-events:none}
/* PLEX 風格海報卡片網格(僅在 body.a1p-grid-on 時生效,可切換回原始列表)*/
.a1p-poster{display:none} /* 原始列表模式:封面隱藏 */
/* 懸浮工具列:搜尋 + 卡片/列表切換 + 大小調整 */
.a1p-toolbar{display:flex;gap:10px;align-items:center;
flex-wrap:wrap;padding:8px 12px;margin:0 auto 14px;max-width:1152px;background:#0d0d10ee;backdrop-filter:blur(6px);
border:1px solid #2a2a30;border-radius:8px}
/* 吸頂時保留原本尺寸與留白:頂部留間距、沿用圓角/邊框(不貼滿)。
left/width 由 setupStickyToolbar 量測 spacer 後以 inline style 設定,確保與靜止狀態完全對齊。*/
.a1p-toolbar.a1p-toolbar-fixed{position:fixed;top:12px;right:auto;margin:0;z-index:2147483600;
box-shadow:0 6px 24px #0009}
/* 吸頂時的頂部漸層遮罩:實心蓋頂端間距+工具列後方,下緣淡出顯露內容(高度/漸層由 JS 設定)*/
.a1p-toolbar-mask{position:fixed;top:0;left:0;right:0;z-index:2147483599;pointer-events:none;display:none}
.a1p-toolbar-mask.on{display:block}
.a1p-toolbar>*{align-self:center}
/* flex-basis:0 是關鍵:flexbox 換行判斷用的是 basis 而非收縮後寬度,basis 設 0 才能讓
滑條與圖標鈕一律算進同一行、search 再以 flex-grow 吃掉剩餘空間,不被擠到下一行 */
.a1p-tb-search{flex:1 1 0;min-width:0;display:flex;align-items:center}
.a1p-tb-input{width:100%;height:32px;box-sizing:border-box;background:#1b1b1f;border:1px solid #45464c;
border-radius:6px;color:#e8e8ea;padding:0 10px;font-size:13px}
.dataTables_filter{display:none!important} /* 原生搜尋隱藏,由工具列的輸入框代理 */
.a1p-tb-btn{cursor:pointer;border:1px solid #45464c;background:#26272c;color:#e8e8ea;
border-radius:6px;height:32px;padding:0 12px;font-size:13px;white-space:nowrap}
.a1p-tb-btn:hover{background:#303138}
.a1p-tb-size{display:flex;align-items:center;gap:6px;height:32px;font-size:12px;color:#9aa0a6;white-space:nowrap}
/* 滑條縮短成約一半長、固定寬不被 flex 擠壓。完全自訂外觀:原生 WebKit 的 accent 填色在
最大值時 thumb 中心只到「軌道寬 − 半 thumb」,右側恆留空白=看起來拉不到底;改用 JS 依值
設 --a1p-range-fill 的漸層把填色畫到底(Firefox 用原生 ::-moz-range-progress)。*/
.a1p-tb-size input[type=range]{-webkit-appearance:none;appearance:none;width:84px;flex:0 0 auto;
margin:0;height:18px;background:transparent;cursor:pointer}
.a1p-tb-size input[type=range]::-webkit-slider-runnable-track{height:8px;border-radius:4px;
background:linear-gradient(to right,#2f6fed var(--a1p-range-fill,50%),#45464c var(--a1p-range-fill,50%))}
.a1p-tb-size input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:16px;height:16px;border-radius:50%;background:#2f6fed;border:2px solid #e8e8ea;
box-sizing:border-box;margin-top:-4px}
.a1p-tb-size input[type=range]::-moz-range-track{height:8px;border-radius:4px;background:#45464c}
.a1p-tb-size input[type=range]::-moz-range-progress{height:8px;border-radius:4px;background:#2f6fed}
.a1p-tb-size input[type=range]::-moz-range-thumb{width:16px;height:16px;border:2px solid #e8e8ea;
border-radius:50%;background:#2f6fed;box-sizing:border-box}
body:not(.a1p-grid-on) .a1p-tb-size{display:none} /* 原始列表模式不需大小調整 */
/* 年+季桶篩選列:|(✕)‹ 桶 ›|。✕ 在捲動區外最左,頭尾 ‹› 為邊緣淡出指示(不可按)。 */
.a1p-tb-bucketwrap{flex:1 1 100%;display:flex;align-items:center;gap:6px;min-width:0}
.a1p-tb-scroll{position:relative;flex:1 1 auto;min-width:0;display:flex}
.a1p-tb-buckets{flex:1 1 auto;min-width:0;display:flex;gap:6px;align-items:center;overflow-x:auto;
scrollbar-width:none} /* 隱藏滑條,改用頭尾淡出提示 */
.a1p-tb-buckets::-webkit-scrollbar{display:none}
/* 頭尾淡出:絕對覆蓋邊緣,漸層讓 chip 淡入背景 + 小而淡的 ‹›;不可按、點擊穿透到下方 chip。
只在該方向還能捲時 .show 淡入。背景色對齊工具列底色 #0d0d10。 */
.a1p-tb-arrow{position:absolute;top:0;bottom:0;width:28px;pointer-events:none;opacity:0;
display:flex;align-items:center;color:#c2c7cf;font-size:15px;line-height:1;transition:opacity .15s}
.a1p-tb-arrow.show{opacity:1}
.a1p-tb-arrow.l{left:0;justify-content:flex-start;padding-left:1px;
background:linear-gradient(to right,#0d0d10 35%,transparent)}
.a1p-tb-arrow.r{right:0;justify-content:flex-end;padding-right:1px;
background:linear-gradient(to left,#0d0d10 35%,transparent)}
.a1p-bucket-chip{flex:0 0 auto;cursor:pointer;border:1px solid #45464c;background:#26272c;color:#cfd2d6;
border-radius:14px;height:26px;padding:0 12px;font-size:12px;white-space:nowrap}
.a1p-bucket-chip:hover{background:#303138}
.a1p-bucket-chip[aria-pressed="true"]{background:#2f6fed;border-color:#2f6fed;color:#fff}
/* 清除鈕:純 ✕ 緊湊方鈕,在捲動區外最左(✕ 隱藏時 flex gap 不佔位) */
.a1p-bucket-clear{flex:0 0 auto;cursor:pointer;
border:1px solid #5a3a3a;background:#2c2326;color:#e0a3a3;border-radius:13px;
width:26px;height:26px;padding:0;font-size:13px;line-height:1;display:flex;align-items:center;justify-content:center}
.a1p-bucket-clear:hover{background:#3a2c2f}
.a1p-bucket-clear[hidden]{display:none}
body.a1p-grid-on .a1p-grid-table thead{display:none}
body.a1p-grid-on .a1p-grid-table{margin-top:8px!important}
body.a1p-grid-on .dataTables_paginate,body.a1p-grid-on .dataTables_info,
body.a1p-grid-on .dataTables_length{display:none!important}
body.a1p-grid-on .a1p-grid-table,body.a1p-grid-on .a1p-grid-table tbody{display:block;border:none!important;width:100%!important}
body.a1p-grid-on .a1p-grid-table tbody{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--a1p-card-w,250px),1fr));gap:16px}
body.a1p-grid-on .a1p-grid-table tbody tr{display:flex;flex-direction:column;background:#1b1b1f;
border:1px solid #2a2a30!important;border-radius:8px;overflow:hidden;transition:transform .1s}
body.a1p-grid-on .a1p-grid-table tbody tr:hover{transform:translateY(-2px);border-color:#7aa2f7!important}
body.a1p-grid-on .a1p-grid-table tbody td{display:block;border:none!important;padding:3px 8px;
font-size:12px;color:#9aa0a6;background:transparent!important;text-align:left}
body.a1p-grid-on .a1p-grid-table tbody td:first-child{padding:0}
body.a1p-grid-on .a1p-grid-table tbody td:nth-child(n+3){display:none}
body.a1p-grid-on .a1p-grid-table .a1p-poster{width:100%;aspect-ratio:2/3;object-fit:cover;display:block;background:#2a2a30;
-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
body.a1p-grid-on .a1p-grid-table tbody td:first-child a{display:block;padding:6px 8px 2px;color:#e8e8ea;
font-weight:600;font-size:13px;line-height:1.3;text-decoration:none}
body.a1p-grid-on .a1p-grid-table tbody td:nth-child(2){padding:0 8px 8px;color:#7aa2f7}
/* 右側欄折疊:跟著檢視模式走——卡片檢視(body.a1p-grid-on)隱藏側欄讓海報網格更寬,
切回原始列表則顯示。不再有獨立的折疊按鈕。*/
body.a1p-grid-on #secondary,body.a1p-grid-on .widget-area{display:none!important}
body.a1p-grid-on #primary,body.a1p-grid-on .content-area{
width:100%!important;max-width:100%!important;flex:1 1 100%!important;float:none!important}
/* footer 置底(內容頁 首頁/分類/單集 皆套):內容不足一屏時把 #colophon 推到視窗底,消除底端白邊。
只改 #page 直接子層的排版,內部 float 兩欄佈局不受影響。*/
body.a1p-stick-footer #page.site{display:flex;flex-direction:column;min-height:100vh}
/* width:100% 保住原本的滿版置中:site-content 帶 margin:auto,成為 flex 子項後
auto margin 會讓它收縮到內容寬度(版型變窄)→ 用明確寬度抵銷,仍受 max-width 限制。*/
body.a1p-stick-footer #page.site>#content{flex:1 0 auto;width:100%}
body.a1p-stick-footer #page.site>#colophon{flex-shrink:0;margin-top:auto}
.a1p-last{display:flex;align-items:center;gap:10px;margin:8px 0;padding:8px 12px;
background:#15233a;border:1px solid #2c4a6e;border-radius:8px;color:#d6e4ff;font-size:14px}
.a1p-last b{color:#fff}
/* 網頁全螢幕:把播放器容器放大填滿視窗(非系統全螢幕)*/
.a1p-webfull{position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;
max-width:none!important;margin:0!important;padding:0!important;border-radius:0!important;
background:#000!important;z-index:2147483600!important}
.a1p-webfull video,.a1p-webfull .vjs-tech{width:100%!important;height:100%!important;object-fit:contain!important}
body.a1p-webfull-lock{overflow:hidden!important}
body.a1p-webfull-lock .a1p-fab,
body.a1p-webfull-lock .a1p-panel{display:none!important}
.a1p-webfull-btn{position:absolute!important;top:10px!important;right:10px!important;z-index:2147483000!important;
width:44px!important;height:44px!important;min-width:44px!important;min-height:44px!important;
border:2px solid #fff!important;border-radius:8px!important;background:#000c!important;color:#fff!important;
font-size:24px!important;cursor:pointer!important;line-height:1!important;opacity:1!important;
display:flex!important;align-items:center!important;justify-content:center!important;
box-shadow:0 2px 8px #000a!important;text-shadow:none!important;outline:none!important;
transition:background .15s,transform .15s,opacity .25s,visibility .25s!important}
.a1p-webfull-btn:focus,.a1p-webfull-btn:focus-visible{outline:none!important;box-shadow:0 2px 8px #000a!important}
.a1p-webfull-btn:hover{background:#fff!important;border-color:#fff!important;color:#000!important;transform:scale(1.08)!important}
.a1p-webfull .a1p-webfull-btn{top:16px!important;right:16px!important}
/* 播放中且使用者閒置時跟 video.js 控制列一起淡出(一般與網頁全螢幕皆適用);滑鼠移入播放器才顯示 */
.video-js.vjs-has-started.vjs-playing.vjs-user-inactive .a1p-webfull-btn{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
/* 分類頁:上方選集、下方單一播放器(隱藏其餘集的 article)*/
.a1p-ep-selector{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0 0 10px;padding:10px 12px;
background:#1b1b1f;border:1px solid #2a2a30;border-radius:8px}
.a1p-ep-label{color:#9aa0a6;font-size:12px}
.a1p-ep-btn{cursor:pointer;border:1px solid #45464c;background:#26272c;color:#e8e8ea;
border-radius:6px;padding:5px 11px;font-size:13px;min-width:38px;text-align:center}
.a1p-ep-btn:hover{background:#303138}
.a1p-ep-btn.a1p-ep-active{background:#7aa2f7;color:#0b1020;border-color:#7aa2f7;font-weight:700}
.a1p-ep-btn.a1p-ep-done-btn{opacity:.6}
.a1p-ep-btn.a1p-ep-done-btn::after{content:" ✓";color:#7ee29a}
.a1p-ep-btn.a1p-ep-done-btn.a1p-ep-active::after{color:#0b1020}
.a1p-ep-page{color:#9ec1ff;text-decoration:none;padding:5px 9px;border:1px solid #45464c;
border-radius:6px;font-size:13px}
.a1p-ep-page:hover{background:#303138}
.a1p-ep-hidden{display:none!important}
.pagination,.wp-pagenavi{display:none!important} /* 原生上一頁/下一頁,已併入選集列 */
/* 站方導覽列/底部注入的「插件原始碼」連結(沿用站方選單樣式,僅補圖示與間距)*/
#primary-menu li.a1p-nav-link>a::before{content:"🧩"}
/* 桌機(站方選單斷點 769px):橫向選單只留 emoji,與原生連結同列平行 */
@media screen and (min-width:769px){#primary-menu li.a1p-nav-link .a1p-nav-text{display:none}}
/* 手機(≤768px,站方收合成「選單」):emoji+字 */
@media screen and (max-width:768px){#primary-menu li.a1p-nav-link .a1p-nav-text{margin-left:6px}}
.a1p-foot-link{display:inline-block;margin-top:4px}
.a1p-foot-link a::before{content:"🧩 "}
`;
const el = document.createElement("style");
el.textContent = css;
document.head.appendChild(el);
}
var PROJECT_URL = "https://github.com/bakabaka0613/anime1-plus";
var PROJECT_LABEL = "插件原始碼";
function injectProjectLinks() {
injectStyles();
const mkLink = (textClass) => {
const a = document.createElement("a");
a.href = PROJECT_URL;
a.target = "_blank";
a.rel = "noopener";
const txt = document.createElement("span");
if (textClass) txt.className = textClass;
txt.textContent = PROJECT_LABEL;
a.appendChild(txt);
return a;
};
const menu = document.getElementById("primary-menu");
if (menu && !menu.querySelector(".a1p-nav-link")) {
const li = document.createElement("li");
li.className = "menu-item a1p-nav-link";
li.appendChild(mkLink("a1p-nav-text"));
menu.appendChild(li);
}
const info = document.querySelector("#colophon .site-info");
if (info && !info.querySelector(".a1p-foot-link")) {
const span = document.createElement("span");
span.className = "a1p-foot-link";
span.appendChild(document.createElement("br"));
span.appendChild(mkLink());
info.appendChild(span);
}
}
function toastWrap() {
let w = document.querySelector(".a1p-toast-wrap");
if (!w) {
w = document.createElement("div");
w.className = "a1p-toast-wrap";
document.body.appendChild(w);
}
return w;
}
function toast(msg, { actionLabel, onAction, actions, duration = 4e3 } = {}) {
injectStyles();
const el = document.createElement("div");
el.className = "a1p-toast";
const span = document.createElement("span");
span.textContent = msg;
el.appendChild(span);
const list = actions && actions.length ? actions : actionLabel ? [{ label: actionLabel, onAction }] : [];
for (const a of list) {
const btn = document.createElement("button");
btn.className = "a1p-btn";
btn.textContent = a.label;
btn.onclick = () => {
try {
a.onAction && a.onAction();
} finally {
el.remove();
}
};
el.appendChild(btn);
}
toastWrap().appendChild(el);
if (duration > 0) setTimeout(() => el.remove(), duration);
return el;
}
function alignFullWidthCard(card, alignRefSel) {
const ref = alignRefSel && document.querySelector(alignRefSel);
if (!card || !ref) return;
const off = ref.getBoundingClientRect().left - card.getBoundingClientRect().left;
if (off > 1) {
card.style.margin = `${off}px`;
}
}
function renderCoverCard(mountEl, data, { onChange, align } = {}) {
injectStyles();
if (!mountEl) return;
const old = document.querySelector(".a1p-card");
if (old) old.remove();
const card = document.createElement("div");
card.className = "a1p-card";
const badge = data.manual ? '已手動確認' : `信心 ${Math.round((data.score || 0) * 100)}%`;
const mainTitle = data.local || data.name_cn || data.name || "";
const subName = data.name && data.name !== mainTitle ? data.name : "";
const seasonBadge = data.bucket ? `📅 ${escapeHtml(data.bucket)}` : "";
const tagChips = [
...(data.metaTags || []).map((t) => `${escapeHtml(t)}`),
...(data.tags || []).map((t) => `${escapeHtml(t)}`)
].join("");
const tagsRow = tagChips ? `
${tagChips}
` : "";
card.innerHTML = `
`;
mountEl.parentNode.insertBefore(card, mountEl);
alignFullWidthCard(card, align);
card.querySelector(".a1p-change").onclick = () => onChange && onChange();
}
var activeTagOverlayClose = null;
function attachCoverTagsOverlay(parentEl, getData, onFetchTags) {
if (!parentEl || parentEl._a1pTagsBound) return;
parentEl._a1pTagsBound = true;
let overlay = null;
let pressTimer = null;
let docCloser = null;
let sx = 0;
let sy = 0;
let lastType = "mouse";
const tagsOf = () => {
const d = typeof getData === "function" ? getData() : getData;
return { meta: d && d.metaTags || [], tags: d && d.tags || [] };
};
const disarm = () => {
if (docCloser) {
document.removeEventListener("click", docCloser, true);
docCloser = null;
}
};
const removeNow = () => {
parentEl.querySelectorAll(".a1p-cover-tags").forEach((n) => n.remove());
overlay = null;
disarm();
if (activeTagOverlayClose === removeNow) activeTagOverlayClose = null;
};
const hide = () => {
disarm();
if (!overlay) return;
const el = overlay;
overlay = null;
el.classList.add("a1p-cover-tags-out");
el.addEventListener("animationend", () => el.remove(), { once: true });
setTimeout(() => el.remove(), 260);
if (activeTagOverlayClose === removeNow) activeTagOverlayClose = null;
};
const armTapAnywhere = () => {
if (docCloser) return;
docCloser = (e) => {
e.preventDefault();
e.stopPropagation();
hide();
};
document.addEventListener("click", docCloser, true);
};
const hasFetch = typeof onFetchTags === "function";
const chipsHtml = (meta, tags) => [
...meta.map((t) => `${escapeHtml(t)}`),
...tags.map((t) => `${escapeHtml(t)}`)
].join("");
const msgHtml = (text) => `${escapeHtml(text)}`;
const renderOverlay = (innerHtml) => {
if (activeTagOverlayClose && activeTagOverlayClose !== removeNow) activeTagOverlayClose();
removeNow();
const sel = window.getSelection && window.getSelection();
if (sel && sel.removeAllRanges) sel.removeAllRanges();
overlay = document.createElement("div");
overlay.className = "a1p-cover-tags";
overlay.innerHTML = `${innerHtml}
`;
parentEl.appendChild(overlay);
activeTagOverlayClose = removeNow;
};
const show = async () => {
const { meta, tags } = tagsOf();
if (meta.length || tags.length) {
renderOverlay(chipsHtml(meta, tags));
return true;
}
if (!hasFetch) return false;
renderOverlay(msgHtml("載入標籤中…"));
try {
await onFetchTags();
} catch (e) {
}
if (!overlay) return true;
const after = tagsOf();
renderOverlay(after.meta.length || after.tags.length ? chipsHtml(after.meta, after.tags) : msgHtml("查無標籤"));
if (lastType === "touch") armTapAnywhere();
return true;
};
parentEl.addEventListener("selectstart", (e) => e.preventDefault());
parentEl.addEventListener("mouseleave", hide);
parentEl.addEventListener("contextmenu", (e) => {
const { meta, tags } = tagsOf();
if (!meta.length && !tags.length && !hasFetch) return;
e.preventDefault();
if (lastType !== "touch") show();
});
const cancelTimer = () => {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
};
parentEl.addEventListener("pointerdown", (e) => {
lastType = e.pointerType;
if (e.pointerType !== "touch") return;
sx = e.clientX;
sy = e.clientY;
cancelTimer();
pressTimer = setTimeout(() => {
pressTimer = null;
show();
}, 480);
});
parentEl.addEventListener("pointermove", (e) => {
if (pressTimer && (Math.abs(e.clientX - sx) > 10 || Math.abs(e.clientY - sy) > 10)) cancelTimer();
});
const onUp = () => {
cancelTimer();
if (overlay && lastType === "touch") setTimeout(armTapAnywhere, 0);
};
parentEl.addEventListener("pointerup", onUp);
parentEl.addEventListener("pointercancel", onUp);
}
function renderCoverPicker(mountEl, ranked, parsed, onPick, { align } = {}) {
injectStyles();
if (!mountEl) return;
const old = document.querySelector(".a1p-card");
if (old) old.remove();
const card = document.createElement("div");
card.className = "a1p-card";
const opts = ranked.map((r, i) => {
const s = r.subject;
const cover = s.images && (s.images.medium || s.images.common || s.images.grid) || "";
return `
${escapeHtml(s.name_cn || s.name || "")}
`;
}).join("");
card.innerHTML = `
`;
mountEl.parentNode.insertBefore(card, mountEl);
alignFullWidthCard(card, align);
card.querySelectorAll(".a1p-opt").forEach((opt) => {
opt.onclick = () => onPick(ranked[Number(opt.dataset.i)]);
});
}
function hideEmptyEntryImages() {
document.querySelectorAll(".entry-image").forEach((el) => {
if (!el.querySelector("img")) el.style.display = "none";
});
}
function markCategoryEpisodes(animeKey) {
injectStyles();
const titles = document.querySelectorAll(".entry-title");
const episodes = [];
let maxEp = 0;
let firstAnchor = null;
titles.forEach((h) => {
const a = h.querySelector("a[href]");
if (!a) return;
const m = (a.href || a.getAttribute("href") || "").match(/anime1\.me\/(\d+)/);
if (!m) return;
const postId = m[1];
if (!firstAnchor) firstAnchor = h;
const parsed = parseTitle(h.textContent || "");
if (parsed.ep != null) {
episodes.push({ ep: parsed.ep, postId });
maxEp = Math.max(maxEp, parsed.ep);
} else if (parsed.epRaw || parsed.type !== "TV") {
episodes.push({ ep: null, epRaw: parsed.epRaw || parsed.type, postId });
}
const rec = parsed.ep != null ? getEpisode(animeKey, parsed.ep) : null;
if (rec && rec.done) {
h.classList.add("a1p-ep-done");
} else if (rec && rec.currentTime > 5 && rec.duration > 0) {
const bar = document.createElement("div");
bar.className = "a1p-ep-bar";
bar.style.width = `${Math.min(100, rec.currentTime / rec.duration * 100)}%`;
h.parentNode.appendChild(bar);
}
});
if (episodes.length) {
const prev = getMeta(animeKey);
const byPost = /* @__PURE__ */ new Map();
if (prev && Array.isArray(prev.episodes)) for (const e of prev.episodes) byPost.set(String(e.postId), e);
for (const e of episodes) byPost.set(String(e.postId), e);
const merged = [...byPost.values()];
const maxEpAll = merged.reduce((mx, e) => typeof e.ep === "number" ? Math.max(mx, e.ep) : mx, 0);
setMeta(animeKey, {
episodes: merged,
maxEpSeen: Math.max(maxEp, maxEpAll, prev && prev.maxEpSeen || 0),
// 單調不退
title: cleanTitle(document.title)
});
}
return firstAnchor;
}
function appendPagination(bar) {
const links = document.querySelectorAll(PAGINATION_SEL);
if (!links.length) return;
const sep = document.createElement("span");
sep.className = "a1p-ep-label";
sep.textContent = "|其他頁:";
bar.appendChild(sep);
const seen = /* @__PURE__ */ new Set();
links.forEach((a) => {
const href = a.getAttribute("href");
if (!href || seen.has(href)) return;
seen.add(href);
const link = document.createElement("a");
link.className = "a1p-ep-page";
link.href = href;
link.textContent = (a.textContent || "").trim() || "頁";
bar.appendChild(link);
});
}
var PAGINATION_SEL = ".pagination a, .nav-links a, a.page-numbers, .wp-pagenavi a, .page-nav a";
function collapseToSinglePlayer(animeKey) {
injectStyles();
if (document.querySelector(".a1p-ep-selector")) return;
const articles = Array.from(document.querySelectorAll("article")).filter(
(a) => a.querySelector(".entry-content") && a.querySelector(".entry-title")
);
if (!articles.length) return;
if (articles.length < 2 && !document.querySelector(PAGINATION_SEL)) return;
const eps = articles.map((a) => {
const p = parseTitle(a.querySelector(".entry-title").textContent || "");
return { article: a, ep: p.ep, epRaw: p.epRaw, type: p.type };
});
const specialBase = (e) => e.epRaw || (e.type && e.type !== "TV" ? e.type : "") || "特";
eps.sort((a, b) => {
const na = a.ep ?? Infinity;
const nb = b.ep ?? Infinity;
if (na !== nb) return na - nb;
return specialBase(a).localeCompare(specialBase(b), void 0, { numeric: true });
});
const baseCount = {};
for (const e of eps) if (e.ep == null) baseCount[specialBase(e)] = (baseCount[specialBase(e)] || 0) + 1;
const baseUsed = {};
for (const e of eps) {
if (e.ep != null) {
e.label = String(e.ep);
continue;
}
const base = specialBase(e);
e.label = baseCount[base] > 1 ? `${base}${baseUsed[base] = (baseUsed[base] || 0) + 1}` : base;
}
const watch = getAnimeWatch(animeKey);
const bar = document.createElement("div");
bar.className = "a1p-ep-selector";
const label = document.createElement("span");
label.className = "a1p-ep-label";
label.textContent = "選集:";
bar.appendChild(label);
const select = (i) => {
eps.forEach((e, j) => {
const hide = j !== i;
e.article.classList.toggle("a1p-ep-hidden", hide);
e.btn.classList.toggle("a1p-ep-active", j === i);
if (hide) {
const v = e.article.querySelector("video");
if (v && !v.paused) {
try {
v.pause();
} catch {
}
}
}
});
window.dispatchEvent(new Event("resize"));
};
eps.forEach((e, i) => {
const btn = document.createElement("button");
btn.className = "a1p-ep-btn";
btn.type = "button";
btn.textContent = e.label;
const rec = e.ep != null ? watch[e.ep] : null;
if (rec && rec.done) btn.classList.add("a1p-ep-done-btn");
btn.addEventListener("click", () => select(i));
e.btn = btn;
bar.appendChild(btn);
});
appendPagination(bar);
articles[0].parentNode.insertBefore(bar, articles[0]);
let defaultIdx = eps.length - 1;
const target = resumeTarget(watch);
if (target.mode === "resume" || target.mode === "next") {
const idx = eps.findIndex((x) => String(x.ep) === String(target.ep));
if (idx >= 0) defaultIdx = idx;
}
select(defaultIdx);
}
var NAV_LINK_TEXTS = ["全集連結", "上一集", "下一集", "上一話", "下一話"];
function orderedMetaEpisodes(list) {
return [...list].sort((a, b) => {
const na = typeof a.ep === "number" ? a.ep : Infinity;
const nb = typeof b.ep === "number" ? b.ep : Infinity;
if (na !== nb) return na - nb;
return String(a.epRaw || "").localeCompare(String(b.epRaw || ""), void 0, { numeric: true });
});
}
function episodeNeighbors(animeKey, ep, epRaw, postId) {
const meta = getMeta(animeKey);
const eps = meta && Array.isArray(meta.episodes) ? meta.episodes : [];
if (!eps.length) return { found: false, prev: null, next: null };
const list = orderedMetaEpisodes(eps);
let idx = ep != null ? list.findIndex((e) => e.ep === ep) : -1;
if (idx < 0 && epRaw) idx = list.findIndex((e) => e.ep == null && String(e.epRaw) === String(epRaw));
if (idx < 0 && postId) idx = list.findIndex((e) => String(e.postId) === String(postId));
if (idx < 0) return { found: false, prev: null, next: null };
const urlAt = (i) => {
const t = list[i];
return t ? t.url || postUrl(t.postId) : null;
};
return { found: true, prev: urlAt(idx - 1), next: urlAt(idx + 1) };
}
function navButton(text, href, cls) {
const a = document.createElement("a");
a.className = href ? `a1p-btn ${cls}` : `a1p-btn ${cls} a1p-btn-disabled`;
a.textContent = text;
if (href) a.href = href;
return a;
}
function enhanceEpisodeNav({ hide = false, animeKey = null, ep = null, epRaw = null, postId = null } = {}) {
injectStyles();
const rows = /* @__PURE__ */ new Set();
for (const a of document.querySelectorAll("a")) {
if (a.closest(".a1p-navrow, .a1p-nav-hidden")) continue;
if (!NAV_LINK_TEXTS.includes((a.textContent || "").trim())) continue;
if (!hide) a.classList.add("a1p-btn");
if (a.parentNode) rows.add(a.parentNode);
}
for (const p of rows) {
if (hide) {
p.classList.add("a1p-nav-hidden");
} else {
p.classList.add("a1p-navrow");
p.querySelectorAll("br").forEach((br) => br.remove());
}
}
if (hide || !animeKey) return;
const { found, prev, next } = episodeNeighbors(animeKey, ep, epRaw, postId);
for (const p of rows) {
if (found) {
[...p.querySelectorAll("a")].forEach((a) => {
if (a.classList.contains("a1p-prev-ep") || a.classList.contains("a1p-next-ep")) return;
if ((a.textContent || "").trim() === "全集連結") return;
a.remove();
});
if (!p.querySelector(".a1p-next-ep")) p.appendChild(navButton("下一集", next, "a1p-next-ep"));
}
const hasPrev = p.querySelector(".a1p-prev-ep") || [...p.querySelectorAll("a")].some((a) => (a.textContent || "").trim() === "上一集");
if (!hasPrev) p.insertBefore(navButton("上一集", prev, "a1p-prev-ep"), p.firstChild);
}
}
function renderLastWatched(animeKey, mountEl) {
injectStyles();
if (!mountEl) return;
const watch = getAnimeWatch(animeKey);
const eps = Object.keys(watch);
if (!eps.length) return;
let lastEp = eps[0];
for (const e of eps) {
if ((watch[e].watchedAt || 0) > (watch[lastEp].watchedAt || 0)) lastEp = e;
}
const rec = watch[lastEp];
const meta = getMeta(animeKey);
const num = String(animeKey).replace(/^cat:/, "");
const catUrl = /^\d+$/.test(num) ? `https://anime1.me/?cat=${num}` : null;
const findUrl = (ep) => {
const r = watch[ep];
if (r && (r.url || r.postId)) return r.url || postUrl(r.postId);
const it = meta && Array.isArray(meta.episodes) ? meta.episodes.find((m) => String(m.ep) === String(ep)) : null;
return it ? it.url || postUrl(it.postId) : null;
};
const target = resumeTarget(watch);
let text;
let link = "";
if (target.mode === "resume") {
text = `上次看到 第 ${escapeHtml(String(lastEp))} 話(看到 ${formatTime(rec.currentTime || 0)})`;
const u = findUrl(target.ep) || catUrl;
if (u) link = `▶ 繼續看`;
} else {
text = `上次看完 第 ${escapeHtml(String(lastEp))} 話`;
const u = findUrl(target.ep);
if (u) link = `▶ 看下一集 第 ${escapeHtml(String(target.ep))} 話`;
}
const old = document.querySelector(".a1p-last");
if (old) old.remove();
const bar = document.createElement("div");
bar.className = "a1p-last";
bar.innerHTML = `${text}${link}`;
mountEl.parentNode.insertBefore(bar, mountEl);
}
function mountTrackingPanel() {
injectStyles();
if (document.querySelector(".a1p-fab")) return;
const fab = document.createElement("button");
fab.className = "a1p-fab";
fab.textContent = "📺";
fab.title = "追番清單(Shift+點擊 或 長按 1.5 秒 → 管理模式)";
document.body.appendChild(fab);
const panel = document.createElement("div");
panel.className = "a1p-panel a1p-hide";
document.body.appendChild(panel);
let pressTimer = null;
let longPressed = false;
fab.onclick = (e) => {
if (longPressed) {
longPressed = false;
return;
}
const willOpen = panel.classList.contains("a1p-hide");
panel.classList.toggle("a1p-hide");
if (willOpen) {
panel.classList.toggle("a1p-del-mode", e.shiftKey);
renderPanel(panel);
} else {
preview.style.display = "none";
}
};
const cancelPress = () => {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
};
fab.addEventListener("pointerdown", () => {
longPressed = false;
cancelPress();
pressTimer = setTimeout(() => {
pressTimer = null;
longPressed = true;
panel.classList.remove("a1p-hide");
panel.classList.add("a1p-del-mode");
preview.style.display = "none";
renderPanel(panel);
}, 1500);
});
fab.addEventListener("pointerup", cancelPress);
fab.addEventListener("pointerleave", cancelPress);
fab.addEventListener("pointercancel", cancelPress);
fab.addEventListener("contextmenu", (e) => e.preventDefault());
const preview = document.createElement("img");
preview.className = "a1p-cover-preview";
preview.referrerPolicy = "no-referrer";
document.body.appendChild(preview);
const isRowThumb = (el) => el && el.tagName === "IMG" && el.closest(".a1p-row") && !!el.getAttribute("src");
panel.addEventListener("mouseover", (e) => {
if (!isRowThumb(e.target)) return;
preview.src = e.target.src;
preview.style.display = "block";
const pr = panel.getBoundingClientRect();
const ir = e.target.getBoundingClientRect();
const pw = preview.offsetWidth;
const ph = preview.offsetHeight;
let left = pr.left - pw - 10;
if (left < 8) left = Math.min(pr.right + 10, window.innerWidth - pw - 8);
let top = ir.top + ir.height / 2 - ph / 2;
top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
preview.style.left = `${left}px`;
preview.style.top = `${top}px`;
});
panel.addEventListener("mouseout", (e) => {
if (isRowThumb(e.target)) preview.style.display = "none";
});
panel.addEventListener("click", (e) => {
const done = e.target.closest(".a1p-row-done");
if (done) {
e.preventDefault();
e.stopPropagation();
const name2 = done.dataset.name || "這部動畫";
if (!confirm(`把「${name2}」標記為已看完?會把已知的每一集都設為看完。`)) return;
markAnimeWatched(done.dataset.cat);
renderPanel(panel);
return;
}
const del = e.target.closest(".a1p-row-del");
if (!del) return;
e.preventDefault();
e.stopPropagation();
const name = del.dataset.name || "這部動畫";
if (!confirm(`確定刪除「${name}」的觀看進度?
此刪除會同步到其他裝置並隱藏;再次觀看此動畫即可復原。`)) return;
deleteAnimeSynced(del.dataset.cat);
renderPanel(panel);
});
}
async function renderPanel(panel) {
const delMode = panel.classList.contains("a1p-del-mode");
const head = `追番清單
${delMode ? '管理模式:✓ 標記已看完、🗑 刪除該動畫進度
' : ""}`;
const list = getInProgressList();
if (!list.length) {
panel.innerHTML = `${head}還沒有觀看記錄
`;
return;
}
sortByGroup(list);
panel.innerHTML = `${head}${panelRowsHtml(list, delMode)}`;
const latestMap = await fetchLatestEpMap();
for (const x of list) {
const info = latestMap[x.catId];
x.newEps = caughtUpNewEpisodes(info ? info.ep : null, x.episodes, x.meta && x.meta.maxEpSeen);
x.airing = !!(info && info.airing);
}
sortByGroup(list);
panel.innerHTML = `${head}${panelRowsHtml(list, delMode)}`;
}
function sortByGroup(list) {
list.sort(
(a, b) => (isCaughtUp(a.episodes, a.meta && a.meta.episodes, a.newEps) ? 1 : 0) - (isCaughtUp(b.episodes, b.meta && b.meta.episodes, b.newEps) ? 1 : 0)
);
}
function panelRowsHtml(list, delMode) {
return list.map((x) => {
const cover = x.cover && x.cover.cover ? x.cover.cover : "";
const name = x.cover && (x.cover.local || x.cover.name_cn && toTraditional(x.cover.name_cn) || x.cover.name) || cleanTitle(x.meta && x.meta.title) || x.catId;
const eps = x.episodes;
const num = String(x.catId).replace(/^cat:/, "");
const catUrl = /^\d+$/.test(num) ? `https://anime1.me/?cat=${num}` : "#";
const epUrl = (ep) => {
const r = eps[ep];
if (r && (r.url || r.postId)) return r.url || postUrl(r.postId);
const item = x.meta && Array.isArray(x.meta.episodes) ? x.meta.episodes.find((it) => String(it.ep) === String(ep)) : null;
return item ? item.url || postUrl(item.postId) : catUrl;
};
const target = resumeTarget(eps);
let lastWatchedEp = null;
let lastAt = -1;
for (const k of Object.keys(eps)) {
const at = eps[k] && eps[k].watchedAt || 0;
if (at > lastAt) {
lastAt = at;
lastWatchedEp = k;
}
}
let link;
if (target.mode === "resume") {
const t = formatTime((eps[target.ep] || {}).currentTime || 0);
link = `繼續看 第${target.ep}集 (${t})`;
} else {
const nextEp = target.ep;
const nextItem = x.meta && Array.isArray(x.meta.episodes) ? x.meta.episodes.find((it) => String(it.ep) === String(nextEp)) : null;
if (nextItem) {
link = `看下一集 第${nextEp}集`;
} else if (x.newEps) {
link = `看新集 第${nextEp}集`;
} else {
const label = x.airing ? "已到最新進度" : "已看完";
const u = lastWatchedEp != null ? epUrl(lastWatchedEp) : null;
const epTxt = /^\d+$/.test(String(lastWatchedEp)) ? `第${lastWatchedEp}集` : String(lastWatchedEp || "");
link = u ? `${label}${epTxt ? `(回看${epTxt})` : ""}` : `${label}`;
}
}
const badge = x.newEps ? `+${x.newEps} 新集` : "";
const caughtUp = isCaughtUp(eps, x.meta && x.meta.episodes, x.newEps);
const actions = delMode ? `${caughtUp ? "" : ``}
` : "";
return `
${escapeHtml(name)}${badge}
${link}
${actions}
`;
}).join("");
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
}
// src/progress.js
var DONE_RATIO = 0.9;
var MIN_DONE_SEC = 30;
function computeDone(cur, dur, threshold) {
return dur > 60 && cur >= MIN_DONE_SEC && cur / dur >= (threshold || DONE_RATIO);
}
function activeVideo() {
const vids = Array.from(document.querySelectorAll("video"));
const visible = vids.filter((v) => v.getClientRects().length > 0);
const pool = visible.length ? visible : vids;
return pool.find((v) => !v.paused) || pool.find((v) => v.currentTime > 0) || pool[0] || null;
}
var seekHotkeyBound = false;
function setupSeekHotkey() {
if (seekHotkeyBound) return;
seekHotkeyBound = true;
window.addEventListener(
"keydown",
(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
if (!getSettings().shortcuts) return;
const tag = e.target && e.target.tagName || "";
if (/INPUT|TEXTAREA|SELECT/.test(tag) || e.isComposing) return;
const v = activeVideo();
if (!v) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const sec = e.shiftKey ? 10 : Number(getSettings().seekSeconds) || 5;
const d = e.key === "ArrowLeft" ? -sec : sec;
try {
v.currentTime = Math.max(0, Math.min(v.duration || 0, v.currentTime + d));
} catch {
}
},
true
);
}
var rateHotkeyBound = false;
function setupRateHotkey() {
if (rateHotkeyBound) return;
rateHotkeyBound = true;
window.addEventListener(
"keydown",
(e) => {
if (!getSettings().shortcuts) return;
let delta = 0;
if (e.key === "+" || e.key === "=") delta = 0.25;
else if (e.key === "-" || e.key === "_") delta = -0.25;
else return;
const tag = e.target && e.target.tagName || "";
if (/INPUT|TEXTAREA|SELECT/.test(tag) || e.isComposing) return;
const v = activeVideo();
if (!v) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const r = Math.max(0.25, Math.min(4, (v.playbackRate || 1) + delta));
try {
v.playbackRate = r;
} catch {
}
toast(`速度 ${r}x`, { duration: 1200 });
},
true
);
}
var playPauseHotkeyBound = false;
function setupPlayPauseHotkey() {
if (playPauseHotkeyBound) return;
playPauseHotkeyBound = true;
window.addEventListener(
"keydown",
(e) => {
if (e.key !== " " && e.code !== "Space") return;
if (!getSettings().shortcuts) return;
if (e.repeat) return;
const tag = e.target && e.target.tagName || "";
if (/INPUT|TEXTAREA|SELECT/.test(tag) || e.isComposing) return;
const v = activeVideo();
if (!v) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
try {
v.paused ? v.play() : v.pause();
} catch {
}
},
true
);
}
var webFullHotkeyBound = false;
function webFullBox(video) {
return video.closest(".video-js") || video.parentElement;
}
function exitWebFull(box) {
box.classList.remove("a1p-webfull");
document.body.classList.remove("a1p-webfull-lock");
}
function enterWebFull(box) {
document.querySelectorAll(".a1p-webfull").forEach(exitWebFull);
box.classList.add("a1p-webfull");
document.body.classList.add("a1p-webfull-lock");
}
function toggleWebFull(box) {
if (box.classList.contains("a1p-webfull")) exitWebFull(box);
else enterWebFull(box);
}
function toggleWebFullCurrent() {
const cur = document.querySelector(".a1p-webfull");
if (cur) {
exitWebFull(cur);
return;
}
const vids = Array.from(document.querySelectorAll("video"));
const target = vids.find((v) => !v.paused) || vids[0];
if (target) enterWebFull(webFullBox(target));
}
function addWebFullButton(video) {
const box = webFullBox(video);
if (!box || box.querySelector(".a1p-webfull-btn")) return;
if (getComputedStyle(box).position === "static") box.style.position = "relative";
const btn = document.createElement("button");
btn.className = "a1p-webfull-btn";
btn.type = "button";
btn.title = "網頁全螢幕 (W)";
btn.textContent = "⛶";
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
toggleWebFull(box);
});
box.appendChild(btn);
}
function setupWebFullHotkey() {
if (webFullHotkeyBound) return;
webFullHotkeyBound = true;
window.addEventListener("keydown", (e) => {
const tag = e.target && e.target.tagName || "";
if (/INPUT|TEXTAREA|SELECT/.test(tag) || e.isComposing) return;
if (e.key === "w" || e.key === "W") {
toggleWebFullCurrent();
} else if (e.key === "Escape") {
const cur = document.querySelector(".a1p-webfull");
if (cur) exitWebFull(cur);
}
});
}
function epForVideo(video) {
const req = parseApiReq(video) || parseApiReq(video.closest("[data-apireq]"));
if (req && req.e != null) {
const n = parseFloat(String(req.e));
if (!Number.isNaN(n)) return n;
}
const article = video.closest("article");
const title = article && article.querySelector(".entry-title");
return title ? parseTitle(title.textContent || "").ep : null;
}
function nextEpisodeUrl(animeKey, ep) {
const meta = getMeta(animeKey);
if (!meta || !Array.isArray(meta.episodes) || ep == null) return null;
const later = meta.episodes.filter((e) => typeof e.ep === "number" && e.ep > ep).sort((a, b) => a.ep - b.ep);
return later.length ? later[0].url || postUrl(later[0].postId) : null;
}
async function initEpisodePage(ctx) {
const video = await waitForVideo();
if (!video) return;
const settings = getSettings();
const { animeKey, ep } = ctx;
addWebFullButton(video);
setupWebFullHotkey();
setupSeekHotkey();
setupRateHotkey();
setupPlayPauseHotkey();
if (settings.resume && ep != null) {
const rec = getEpisode(animeKey, ep);
if (rec && !rec.done && rec.currentTime > 5) {
const seekTo = rec.currentTime;
const doSeek = () => {
try {
if (video.currentTime < 2) video.currentTime = seekTo;
} catch {
}
};
if (video.readyState >= 1) doSeek();
else video.addEventListener("loadedmetadata", doSeek, { once: true });
toast(`已續播到 ${formatTime(seekTo)}`, {
actionLabel: "從頭播放",
onAction: () => {
try {
video.currentTime = 0;
} catch {
}
}
});
}
}
const persist = (done) => {
if (ep == null) return;
const dur = video.duration || 0;
const cur = video.currentTime || 0;
if (cur < 1 && !done) return;
setEpisodeProgress(animeKey, ep, {
currentTime: cur,
duration: dur,
done: done ?? computeDone(cur, dur, settings.autoNextThreshold),
postId: postIdFromPath()
// 跨分頁「繼續看」用;網址由 postId 重建
});
};
const persistThrottled = throttle(() => persist(), 5e3);
video.addEventListener("timeupdate", persistThrottled);
video.addEventListener("pause", () => persist());
window.addEventListener("pagehide", () => persist());
if (settings.rememberRate) {
if (settings.playbackRate && settings.playbackRate !== 1) {
try {
video.playbackRate = settings.playbackRate;
} catch {
}
}
video.addEventListener("ratechange", () => setSettings({ playbackRate: video.playbackRate }));
}
video.addEventListener("ended", () => {
persist(true);
if (!settings.autoNext) return;
const url = nextEpisodeUrl(animeKey, ep);
if (!url) return;
let cancelled = false;
toast("即將播放下一集…", {
duration: 5e3,
actions: [
{
label: "立即播放",
onAction: () => {
cancelled = true;
location.href = url;
}
},
{ label: "取消", onAction: () => {
cancelled = true;
} }
]
});
setTimeout(() => {
if (!cancelled) location.href = url;
}, 5e3);
});
if (settings.shortcuts) bindShortcuts(video, ctx);
}
function initCategoryPlayback(animeKey) {
const settings = getSettings();
const bound = /* @__PURE__ */ new WeakSet();
const refreshUI = () => {
const h1 = getContentH1();
if (h1) renderLastWatched(animeKey, h1);
};
const playNextVideo = (video) => {
const bar = document.querySelector(".a1p-ep-selector");
if (bar) {
const btns = Array.from(bar.querySelectorAll(".a1p-ep-btn"));
const idx = btns.findIndex((b) => b.classList.contains("a1p-ep-active"));
const next2 = btns[idx + 1];
if (!next2) return;
next2.click();
setTimeout(() => {
const v = document.querySelector("article:not(.a1p-ep-hidden) video");
if (v) {
v.scrollIntoView({ behavior: "smooth", block: "center" });
try {
v.play();
} catch {
}
}
}, 150);
return;
}
const vids = Array.from(document.querySelectorAll("video"));
const next = vids[vids.indexOf(video) + 1];
if (!next) return;
next.scrollIntoView({ behavior: "smooth", block: "center" });
try {
next.play();
} catch {
}
};
const bind = (video) => {
if (bound.has(video)) return;
const ep = epForVideo(video);
if (ep == null) return;
bound.add(video);
addWebFullButton(video);
const a = video.closest("article");
const epHref = (a && a.querySelector('.entry-title a, a[rel="bookmark"]') || {}).href || "";
const epPostId = postIdFromUrl(epHref);
if (settings.resume) {
const rec = getEpisode(animeKey, ep);
if (rec && !rec.done && rec.currentTime > 5) {
const seekTo = rec.currentTime;
const doSeek = () => {
try {
if (video.currentTime < 2) video.currentTime = seekTo;
} catch {
}
};
if (video.readyState >= 1) doSeek();
else video.addEventListener("loadedmetadata", doSeek, { once: true });
}
}
const persist = (done) => {
const dur = video.duration || 0;
const cur = video.currentTime || 0;
if (cur < 1 && !done) return;
setEpisodeProgress(animeKey, ep, {
currentTime: cur,
duration: dur,
done: done ?? computeDone(cur, dur, settings.autoNextThreshold),
postId: epPostId
// 網址由 postId 重建(分類頁就地播放時取自集連結)
});
};
const persistThrottled = throttle(() => persist(), 5e3);
video.addEventListener("timeupdate", persistThrottled);
video.addEventListener("play", () => {
persist();
refreshUI();
});
video.addEventListener("pause", () => {
persist();
refreshUI();
});
video.addEventListener("ended", () => {
persist(true);
refreshUI();
if (settings.autoNext) playNextVideo(video);
});
if (settings.rememberRate) {
if (settings.playbackRate && settings.playbackRate !== 1) {
try {
video.playbackRate = settings.playbackRate;
} catch {
}
}
video.addEventListener("ratechange", () => setSettings({ playbackRate: video.playbackRate }));
}
};
const scan = () => document.querySelectorAll("video").forEach(bind);
scan();
setupWebFullHotkey();
setupSeekHotkey();
setupRateHotkey();
setupPlayPauseHotkey();
new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener("pagehide", () => document.querySelectorAll("video").forEach((v) => {
const ep = epForVideo(v);
if (ep != null) {
const dur = v.duration || 0;
const cur = v.currentTime || 0;
if (cur > 0) setEpisodeProgress(animeKey, ep, { currentTime: cur, duration: dur, done: computeDone(cur, dur, settings.autoNextThreshold) });
}
}));
}
function bindShortcuts(video, ctx) {
window.addEventListener("keydown", (e) => {
const tag = e.target && e.target.tagName || "";
if (/INPUT|TEXTAREA|SELECT/.test(tag) || e.isComposing) return;
switch (e.key) {
// ←/→ 由全域 setupSeekHotkey 處理(秒數可調)
// 空白鍵由全域 setupPlayPauseHotkey 處理(capture,分類頁/單集頁共用)
case "f":
case "F":
if (document.fullscreenElement) document.exitFullscreen();
else (video.requestFullscreen ? video : video.parentElement).requestFullscreen?.();
break;
case "n":
case "N": {
const url = nextEpisodeUrl(ctx.animeKey, ctx.ep);
if (url) location.href = url;
break;
}
// +/- 調速由全域 setupRateHotkey 處理
default:
break;
}
});
}
// src/bangumi.js
var UA = "anime1-plus/0.1 (https://github.com/bakabaka0613/anime1-plus)";
function gmFetch({ method, url, headers, data }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
data,
timeout: 15e3,
onload: (res) => resolve(res),
onerror: () => reject(new Error("network error")),
ontimeout: () => reject(new Error("timeout"))
});
});
}
async function searchV0(keyword, limit) {
const res = await gmFetch({
method: "POST",
url: `https://api.bgm.tv/v0/search/subjects?limit=${limit}`,
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": UA },
data: JSON.stringify({ keyword, filter: { type: [2] } })
});
if (res.status < 200 || res.status >= 300) throw new Error(`v0 status ${res.status}`);
const json = JSON.parse(res.responseText);
return Array.isArray(json.data) ? json.data : [];
}
async function searchLegacy(keyword, limit) {
const url = `https://api.bgm.tv/search/subject/${encodeURIComponent(keyword)}?type=2&responseGroup=large&max_results=${limit}`;
const res = await gmFetch({ method: "GET", url, headers: { Accept: "application/json", "User-Agent": UA } });
if (res.status < 200 || res.status >= 300) throw new Error(`legacy status ${res.status}`);
const json = JSON.parse(res.responseText);
const list = Array.isArray(json.list) ? json.list : [];
return list.map((s) => ({
id: s.id,
name: s.name,
name_cn: s.name_cn,
date: s.air_date || s.date,
images: s.images,
rating: s.rating,
tags: Array.isArray(s.tags) ? s.tags : [],
meta_tags: Array.isArray(s.meta_tags) ? s.meta_tags : []
}));
}
async function searchOnce(keyword, limit) {
try {
return await searchV0(keyword, limit);
} catch (e) {
try {
return await searchLegacy(keyword, limit);
} catch (e2) {
console.warn("[anime1-plus] Bangumi 搜尋失敗", e, e2);
return [];
}
}
}
async function searchAnime(keyword, limit = 10) {
if (!keyword || !keyword.trim()) return [];
const simp = toSimplified(keyword);
const variants = simp !== keyword ? [simp, keyword] : [keyword];
const seen = /* @__PURE__ */ new Set();
const merged = [];
for (const kw of variants) {
const res = await searchOnce(kw, limit);
for (const s of res) {
if (!seen.has(s.id)) {
seen.add(s.id);
merged.push(s);
}
}
}
return merged;
}
async function getSubjectAliases(id) {
try {
const res = await gmFetch({
method: "GET",
url: `https://api.bgm.tv/v0/subjects/${id}`,
headers: { Accept: "application/json", "User-Agent": UA }
});
if (res.status < 200 || res.status >= 300) return [];
const json = JSON.parse(res.responseText);
const out = [];
if (json.name) out.push(json.name);
if (json.name_cn) out.push(json.name_cn);
if (Array.isArray(json.infobox)) {
for (const f of json.infobox) {
if (!/别名|別名|中文名|英文名|英文|日文|罗马|羅馬/.test(f.key || "")) continue;
const v = f.value;
if (typeof v === "string") out.push(v);
else if (Array.isArray(v)) v.forEach((it) => out.push(it && (it.v || it.value) || it));
}
}
return out.filter((s) => typeof s === "string" && s.trim());
} catch {
return [];
}
}
async function getSubjectMeta(id) {
try {
const res = await gmFetch({
method: "GET",
url: `https://api.bgm.tv/v0/subjects/${id}`,
headers: { Accept: "application/json", "User-Agent": UA }
});
if (res.status < 200 || res.status >= 300) return null;
const json = JSON.parse(res.responseText);
return {
date: json.date || null,
tags: Array.isArray(json.tags) ? json.tags : [],
meta_tags: Array.isArray(json.meta_tags) ? json.meta_tags : []
};
} catch {
return null;
}
}
function coverUrl(subject) {
const img = subject && subject.images;
if (!img) return null;
return img.large || img.common || img.medium || img.grid || img.small || null;
}
// src/match.js
var W_NAME = 0.7;
var W_YEAR = 0.2;
var W_SEASON = 0.1;
var BUCKET_BONUS = 0.08;
var CONFIDENT_SCORE = 0.6;
var CONFIDENT_MARGIN = 0.1;
var CONFIDENT_NAME = 0.5;
function subjectYear(subject) {
const m = String(subject.date || subject.air_date || "").match(/(\d{4})/);
return m ? parseInt(m[1], 10) : null;
}
function leadTitleSegment(s) {
const seg = String(s || "").split(/[\s ::]/)[0].trim();
return seg;
}
function nameScore(parsed, subject) {
const scores = [];
for (const raw of [subject.name_cn, subject.name]) {
if (!raw) continue;
const candBase = parseTitle(raw).baseName || raw;
scores.push(similarity(parsed.baseName, candBase));
const lead = leadTitleSegment(raw);
if (lead && lead !== raw) scores.push(similarity(parsed.baseName, parseTitle(lead).baseName || lead));
}
return scores.length ? Math.max(...scores) : 0;
}
function seasonScore(parsed, subject) {
const candName = subject.name_cn || subject.name || "";
const candSeason = parseTitle(candName).seasonNum;
return candSeason === parsed.seasonNum ? 1 : 0;
}
function yearScore(parsed, subject, anime1Year) {
if (!anime1Year) return 0.5;
const sy = subjectYear(subject);
if (!sy) return 0.5;
const diff = Math.abs(sy - anime1Year);
if (diff === 0) return 1;
if (diff === 1) return 0.5;
return 0;
}
function bucketBonus(subject, anime1Buckets) {
if (!Array.isArray(anime1Buckets) || !anime1Buckets.length) return 0;
const candBucket = dateToBucket(subject.date || subject.air_date);
return candBucket && anime1Buckets.includes(candBucket) ? BUCKET_BONUS : 0;
}
function scoreCandidate(parsed, anime1Year, subject, anime1Buckets) {
const name = nameScore(parsed, subject);
const year = yearScore(parsed, subject, anime1Year);
const season = seasonScore(parsed, subject);
const bucket = bucketBonus(subject, anime1Buckets);
const score = name * W_NAME + year * W_YEAR + season * W_SEASON + bucket;
return { subject, score, breakdown: { name, year, season, bucket } };
}
function rankCandidates(parsed, anime1Year, subjects, anime1Buckets) {
const ranked = (subjects || []).map((s) => scoreCandidate(parsed, anime1Year, s, anime1Buckets)).sort((a, b) => b.score - a.score);
if (!ranked.length) {
return { ranked, best: null, confident: false, needConfirm: true };
}
const best = ranked[0];
const second = ranked[1];
const margin = second ? best.score - second.score : Infinity;
const confident = best.score >= CONFIDENT_SCORE && best.breakdown.name >= CONFIDENT_NAME && margin >= CONFIDENT_MARGIN;
return { ranked, best, confident, needConfirm: !confident };
}
// src/coverQueue.js
var TIERS = ["visible", "tracking", "meta", "recheck"];
var GAP = { visible: 500, tracking: 500, meta: 1200, recheck: 5e3 };
var MAX_RETRIES = 2;
var q = { visible: [], tracking: [], meta: [], recheck: [] };
var selectors = {};
var pumping = false;
var lastRunAt = 0;
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function enqueue(tier, run, key) {
q[tier].push({ run, retries: 0, key });
pump();
}
function setSelector(tier, fn) {
selectors[tier] = fn;
}
async function pump() {
if (pumping) return;
pumping = true;
while (TIERS.some((t) => q[t].length)) {
const tier = TIERS.find((t) => q[t].length);
const wait = Math.max(0, GAP[tier] - (Date.now() - lastRunAt));
if (wait) {
await sleep(Math.min(wait, 250));
continue;
}
const jobs = q[tier];
let idx = 0;
if (selectors[tier]) {
const i = selectors[tier](jobs);
if (Number.isInteger(i) && i >= 0 && i < jobs.length) idx = i;
}
const job = jobs.splice(idx, 1)[0];
lastRunAt = Date.now();
let ok = false;
try {
ok = await job.run();
} catch {
ok = false;
}
if (!ok && job.retries < MAX_RETRIES) {
job.retries++;
q[tier].push(job);
}
}
pumping = false;
}
// src/cover.js
var ALIAS_CHECK_LIMIT = 10;
var STRONG_SCORE = 0.8;
async function matchByAlias(parsed, subjects) {
const targets = [toSimplified(parsed.baseName), ...titleSearchSegments(parsed.baseName).map((s) => toSimplified(s))];
for (const subject of subjects.slice(0, ALIAS_CHECK_LIMIT)) {
const aliases = await getSubjectAliases(subject.id);
for (const al of aliases) {
for (const piece of [al, ...splitAliasNames(al)]) {
const cand = toSimplified(parseTitle(piece).baseName || piece);
if (targets.some((t) => similarity(t, cand) >= 0.9)) {
return { subject, score: 1, breakdown: { name: 1, year: 0.5, season: 1 } };
}
}
}
}
return null;
}
function toCoverData(scored, manual = false) {
const s = scored.subject;
return {
subjectId: s.id,
cover: coverUrl(s),
name: s.name,
name_cn: s.name_cn,
rating: s.rating && s.rating.score || null,
// Bangumi 用戶評分(0–10),0/無 → null
score: scored.score,
// 注意:這是我們的比對信心分數,非 Bangumi 評分
// 放送日/放送季桶/標籤:v0 搜尋結果本就帶 date/tags/meta_tags,順手存進快取,零額外請求。
// tags/metaTags 經 buildCoverTags 清洗(轉繁+去重+過濾時間/泛用/重疊)。
date: s.date || s.air_date || null,
bucket: dateToBucket(s.date || s.air_date),
...buildCoverTags(s.tags, s.meta_tags),
manual
};
}
async function lookupCover({ animeKey, title, year, deep = false, buckets }) {
const parsed = parseTitle(title);
const cached = getCover(animeKey);
if (cached && !cached.tentative) return { cached: true, parsed, data: cached, ranked: [], confident: true };
const subjects = await searchAnime(parsed.baseName);
let { ranked, best, confident } = rankCandidates(parsed, year, subjects, buckets);
if (deep) {
let pool = subjects;
if (!confident) {
const segs = titleSearchSegments(parsed.baseName);
if (segs.length) {
const seen = new Set(subjects.map((s) => s.id));
const merged = [...subjects];
for (const seg of segs) {
for (const s of await searchAnime(seg)) {
if (!seen.has(s.id)) {
seen.add(s.id);
merged.push(s);
}
}
}
if (merged.length > subjects.length) {
pool = merged;
({ ranked, best, confident } = rankCandidates(parsed, year, merged, buckets));
}
}
}
if ((!confident || best && best.score < STRONG_SCORE) && pool.length) {
const aliasHit = await matchByAlias(parsed, pool);
if (aliasHit && (!confident || aliasHit.subject.id === best.subject.id)) {
best = aliasHit;
confident = true;
ranked = [aliasHit, ...ranked.filter((r) => r.subject.id !== aliasHit.subject.id)];
}
}
}
return { cached: false, parsed, data: confident && best ? toCoverData(best) : null, ranked, confident };
}
async function resolveCover({ animeKey, title, year, mountEl, align }) {
if (!mountEl) return;
const listMeta = (await fetchLatestEpMap())[animeKey];
const buckets = listMeta ? seasonBuckets(listMeta.year, listMeta.season) : void 0;
const res = await lookupCover({ animeKey, title, year, deep: true, buckets });
const { parsed } = res;
const local = title;
const showPicker = (ranked) => {
renderCoverPicker(
mountEl,
ranked.slice(0, 6),
parsed,
(chosen) => {
const data = { ...toCoverData(chosen, true), local };
setCover(animeKey, data);
renderCoverCard(mountEl, data, { onChange: () => showPicker(ranked), align });
},
{ align }
);
};
const refetchAndPick = async () => {
const subjects = await searchAnime(parsed.baseName);
showPicker(rankCandidates(parsed, year, subjects, buckets).ranked);
};
if (res.cached) {
if (needsCoverMeta(res.data, Date.now())) enqueueMetaBackfill(animeKey);
renderCoverCard(mountEl, { ...res.data, local: res.data.local || local }, { onChange: refetchAndPick, align });
} else if (res.data) {
const data = { ...res.data, local };
setCover(animeKey, data);
renderCoverCard(mountEl, data, { onChange: () => showPicker(res.ranked), align });
} else {
showPicker(res.ranked);
}
}
var recheckQueued = /* @__PURE__ */ new Set();
var onCoverUpgrade = null;
setSelector("recheck", (jobs) => pickByHint(jobs, getRecheckHint()));
var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
var RECHECK_LEASE_TTL = 12e3;
function claimRecheckLease() {
const { owns, lease } = evaluateRecheckLease(getRecheckLease(), TAB_ID, Date.now(), RECHECK_LEASE_TTL);
if (owns) setRecheckLease(lease);
return owns;
}
function setCoverUpgradeHook(fn) {
onCoverUpgrade = fn;
}
function enqueueRecheck(catId, { background = false } = {}) {
if (recheckQueued.has(catId)) return;
const cover = getCover(catId);
if (!shouldRecheck(cover, Date.now())) return;
recheckQueued.add(catId);
enqueue("recheck", async () => {
if (background && !claimRecheckLease()) return true;
const fresh = getCover(catId);
if (!shouldRecheck(fresh, Date.now())) return true;
const meta = (await fetchLatestEpMap())[catId];
const title = meta && meta.name || fresh.local || fresh.name;
if (!title) return true;
const buckets = meta ? seasonBuckets(meta.year, meta.season) : void 0;
const res = await lookupCover({ animeKey: catId, title, year: meta ? meta.year : null, deep: true, buckets });
if (res.data) {
const data = { ...res.data, local: title };
setCover(catId, data);
if (onCoverUpgrade) onCoverUpgrade(catId, data);
notifyCoverUpgrade(catId);
console.info("[anime1-plus] 封面複查轉正:", title);
} else {
setCover(catId, { ...fresh, deepTried: Date.now() });
}
return true;
}, catId);
}
var metaBackfillQueued = /* @__PURE__ */ new Set();
function enqueueMetaBackfill(catId) {
if (metaBackfillQueued.has(catId)) return;
if (!needsCoverMeta(getCover(catId), Date.now())) return;
metaBackfillQueued.add(catId);
enqueue("meta", async () => {
const fresh = getCover(catId);
if (!needsCoverMeta(fresh, Date.now())) return true;
const m = await getSubjectMeta(fresh.subjectId);
if (m && m.date) {
setCover(catId, {
...fresh,
// 保留既有 subjectId/cover/name/score…,僅補充下列欄位
date: m.date,
bucket: dateToBucket(m.date),
...buildCoverTags(m.tags, m.meta_tags)
});
} else {
setCover(catId, { ...fresh, metaTriedAt: Date.now() });
}
return true;
}, catId);
}
async function ensureCoverMeta(catId) {
const fresh = getCover(catId);
if (!fresh) return null;
if (!needsCoverMeta(fresh, Date.now())) return fresh;
metaBackfillQueued.add(catId);
const m = await getSubjectMeta(fresh.subjectId);
const latest = getCover(catId) || fresh;
const updated = m && m.date ? { ...latest, date: m.date, bucket: dateToBucket(m.date), ...buildCoverTags(m.tags, m.meta_tags) } : { ...latest, metaTriedAt: Date.now() };
setCover(catId, updated);
return updated;
}
var resweepTimer = null;
var RESWEEP_MS = 15e3;
async function recheckTentativeCovers({ orderHint } = {}) {
const now = Date.now();
let targets = getTentativeCovers().filter((c) => shouldRecheck(c, now) && !recheckQueued.has(c.catId));
if (!targets.length) return;
if (!claimRecheckLease()) {
if (!resweepTimer) {
resweepTimer = setTimeout(() => {
resweepTimer = null;
recheckTentativeCovers({ orderHint });
}, RESWEEP_MS);
}
return;
}
const order = [...getRecheckHint() || [], ...Array.isArray(orderHint) ? orderHint : []];
if (order.length) {
const rank = /* @__PURE__ */ new Map();
order.forEach((k, i) => {
if (!rank.has(k)) rank.set(k, i);
});
const near = [];
const rest = [];
for (const c of targets) (rank.has(c.catId) ? near : rest).push(c);
near.sort((a, b) => rank.get(a.catId) - rank.get(b.catId));
targets = [...near, ...rest];
}
for (const c of targets) enqueueRecheck(c.catId, { background: true });
}
// src/list.js
var ADULT_COVER = "data:image/svg+xml," + encodeURIComponent(
``
);
var currentDt = null;
var initialLen = null;
var activeBucket = null;
var bucketMap = null;
var nodeCatId = /* @__PURE__ */ new WeakMap();
var filterTable = null;
var predicatePushed = false;
var updateBucketEdges = () => {
};
function animeRef(a) {
const href = a.getAttribute("href") || "";
if (isAdultLink(href)) return null;
let dec = href;
try {
dec = decodeURIComponent(href);
} catch {
}
if (/\/category\/[^/]+\/[^/?#]+/.test(dec)) {
return { key: animeKeyFromCategoryPath(href), year: yearFromText(dec) };
}
const m = href.match(/[?&]cat=(\d+)/);
if (m) return { key: `cat:${m[1]}`, year: null };
return null;
}
function markCover(img, data) {
const box = img && img.parentNode;
if (!box) return;
const uncertain = !!(data && data.tentative);
let tag = box.querySelector(".a1p-cover-uncertain");
if (uncertain && !tag) {
tag = document.createElement("span");
tag.className = "a1p-cover-uncertain";
tag.textContent = "? 待確認";
tag.title = "封面比對信心較低,點擊進入該動畫可重新比對或手動選擇";
box.appendChild(tag);
} else if (!uncertain && tag) {
tag.remove();
}
}
function markRating(img, data) {
const box = img && img.parentNode;
if (!box) return;
const score = data && data.rating;
let tag = box.querySelector(".a1p-rating-badge");
if (score) {
if (!tag) {
tag = document.createElement("span");
tag.className = "a1p-rating-badge";
box.appendChild(tag);
}
tag.textContent = `★ ${Number(score).toFixed(1)}`;
} else if (tag) {
tag.remove();
}
}
function initListPage() {
injectStyles();
const seen = /* @__PURE__ */ new WeakSet();
let trackingPrefetched = false;
onCoverUpgradeEvent((catId) => {
const cover = getCover(catId);
if (cover && cover.cover) repaintCard(catId, cover);
});
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
io.unobserve(e.target);
if (e.target._a1pJob) enqueue("visible", () => resolve(e.target._a1pJob));
}
},
{ rootMargin: "400px" }
);
async function resolve({ img, key, name, year }) {
const paint = (data) => {
if (!img) return;
img.src = data.cover || "";
img.classList.remove("a1p-thumb-unknown");
markCover(img, data);
markRating(img, data);
};
const buckets = bucketMap ? bucketMap[key] : void 0;
const res = await lookupCover({ animeKey: key, title: name, year, buckets });
if (res.cached) {
paint(res.data);
if (needsCoverMeta(res.data, Date.now())) enqueueMetaBackfill(key);
return true;
}
if (res.data) {
const data = { ...res.data, local: name };
setCover(key, data);
paint(data);
return true;
}
const top = res.ranked && res.ranked[0];
if (top && top.subject) {
const data = toCoverData(top);
if (data.cover) {
data.tentative = true;
data.local = name;
setCover(key, data);
paint(data);
enqueueRecheck(key);
}
return true;
}
if (img) img.classList.add("a1p-thumb-unknown");
return false;
}
async function prefetchTrackingCovers() {
if (trackingPrefetched) return;
trackingPrefetched = true;
const inProgress = getInProgressList();
for (const x of inProgress) {
if (x.cover && x.cover.cover && needsCoverMeta(x.cover, Date.now())) enqueueMetaBackfill(x.catId);
}
const need = inProgress.filter((x) => !(x.cover && x.cover.cover));
if (!need.length) return;
const infoMap = await fetchLatestEpMap();
for (const x of need) {
const info = infoMap[x.catId];
const name = info && info.name || cleanTitle(x.meta && x.meta.title) || null;
if (!name) continue;
const job = { key: x.catId, name, year: info ? info.year : null };
enqueue("tracking", () => resolve(job));
}
}
function enhanceRow(tr) {
if (seen.has(tr)) return;
const nameTd = tr.querySelector("td");
if (!nameTd) return;
const a = nameTd.querySelector("a[href]");
if (!a) return;
const name = (a.textContent || "").trim();
if (!name) return;
if (isAdultLink(a.href)) {
seen.add(tr);
tr.classList.add("a1p-card-row");
const img2 = document.createElement("img");
img2.className = "a1p-poster";
img2.referrerPolicy = "no-referrer";
img2.alt = name;
img2.src = ADULT_COVER;
img2.style.cursor = "pointer";
img2.addEventListener("click", () => {
window.location.href = a.href;
});
const wrap2 = document.createElement("div");
wrap2.className = "a1p-poster-wrap";
wrap2.appendChild(img2);
nameTd.insertBefore(wrap2, nameTd.firstChild);
return;
}
const ref = animeRef(a);
if (!ref) return;
seen.add(tr);
tr.classList.add("a1p-card-row");
const epTd = nameTd.nextElementSibling;
const latestEp = epTd ? parseLatestEp(epTd.textContent) : null;
const newCount = pendingNewEpisodes(latestEp, getAnimeWatch(ref.key));
if (newCount) {
const badge = document.createElement("span");
badge.className = "a1p-update-badge";
badge.textContent = `+${newCount}`;
badge.title = `已更新至第 ${latestEp} 話,有 ${newCount} 集未看`;
tr.appendChild(badge);
}
const img = document.createElement("img");
img.className = "a1p-poster";
img.referrerPolicy = "no-referrer";
img.alt = name;
img.style.cursor = "pointer";
img.addEventListener("click", () => {
window.location.href = a.href;
});
const wrap = document.createElement("div");
wrap.className = "a1p-poster-wrap";
wrap.appendChild(img);
nameTd.insertBefore(wrap, nameTd.firstChild);
attachCoverTagsOverlay(wrap, () => getCover(ref.key), () => ensureCoverMeta(ref.key));
const cached = getCover(ref.key);
if (cached && cached.cover) {
img.src = cached.cover;
markCover(img, cached);
markRating(img, cached);
if (cached.tentative) enqueueRecheck(ref.key);
if (needsCoverMeta(cached, Date.now())) enqueueMetaBackfill(ref.key);
return;
}
img._a1pJob = { img, key: ref.key, name, year: ref.year };
io.observe(img);
}
function scanTable() {
const table = document.querySelector("table.tablepress") || document.querySelector("table");
if (!table) return;
table.classList.add("a1p-grid-table");
table.querySelectorAll("tbody tr").forEach(enhanceRow);
}
document.body.classList.toggle("a1p-grid-on", getSettings().gridView !== false);
scanTable();
new MutationObserver(scanTable).observe(document.body, { childList: true, subtree: true });
mountToolbar();
setupInfiniteScroll();
initBucketFilter();
prefetchTrackingCovers();
const publishHint = throttle(() => setRecheckHint(viewportCatOrder().slice(0, 30)), 1e3);
publishHint();
window.addEventListener("scroll", publishHint, { passive: true });
}
function viewportCatOrder() {
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
const center = vh / 2;
const rows = [];
for (const row of document.querySelectorAll(".a1p-card-row")) {
const a = row.querySelector("a[href]");
if (!a) continue;
const ref = animeRef(a);
if (!ref) continue;
const r = row.getBoundingClientRect();
rows.push({ key: ref.key, dist: Math.abs((r.top + r.bottom) / 2 - center) });
}
rows.sort((p, q2) => p.dist - q2.dist);
return rows.map((x) => x.key);
}
function repaintCard(catId, data) {
for (const row of document.querySelectorAll(".a1p-card-row")) {
const a = row.querySelector("a[href]");
if (!a) continue;
const ref = animeRef(a);
if (!ref || ref.key !== catId) continue;
const img = row.querySelector("img.a1p-poster");
if (!img) return;
if (data.cover) img.src = data.cover;
img.classList.remove("a1p-thumb-unknown");
markCover(img, data);
markRating(img, data);
return;
}
}
var SEASON_ORD = { 冬: 0, 春: 1, 夏: 2, 秋: 3 };
function compareBuckets(a, b) {
const ya = +a.slice(0, 4);
const yb = +b.slice(0, 4);
if (ya !== yb) return yb - ya;
return (SEASON_ORD[b[4]] ?? -1) - (SEASON_ORD[a[4]] ?? -1);
}
function bucketPredicate(settings, _searchData, dataIndex) {
if (filterTable && settings.nTable !== filterTable) return true;
if (!activeBucket) return true;
const node = settings.aoData[dataIndex] && settings.aoData[dataIndex].nTr;
if (!node) return true;
let catId = nodeCatId.get(node);
if (catId === void 0) {
const a = node.querySelector("a[href]");
const ref = a ? animeRef(a) : null;
catId = ref ? ref.key : null;
nodeCatId.set(node, catId);
}
if (!catId) return false;
const bs = bucketMap && bucketMap[catId] || [];
return bs.includes(activeBucket);
}
function ensureBucketPredicate(tries = 0) {
if (predicatePushed) return;
const w = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const $ = w.jQuery || w.$;
if ($ && $.fn && $.fn.dataTable && $.fn.dataTable.ext) {
$.fn.dataTable.ext.search.push(bucketPredicate);
predicatePushed = true;
return;
}
if (tries < 48) setTimeout(() => ensureBucketPredicate(tries + 1), 250);
}
function redrawFilter() {
const dt = currentDt || getDataTable();
if (dt) {
try {
dt.draw(false);
} catch {
}
}
}
function selectBucket(bucket, wrap) {
activeBucket = bucket;
for (const chip of wrap.querySelectorAll(".a1p-bucket-chip")) {
chip.setAttribute("aria-pressed", String(chip.dataset.bucket === bucket));
}
const clear = document.querySelector(".a1p-bucket-clear");
if (clear) clear.hidden = !bucket;
redrawFilter();
}
async function initBucketFilter() {
const wrap = document.querySelector(".a1p-tb-buckets");
if (!wrap || wrap.dataset.filled) return;
const map = await fetchLatestEpMap();
bucketMap = {};
const all = /* @__PURE__ */ new Set();
for (const [catId, info] of Object.entries(map)) {
const bs = seasonBuckets(info.year, info.season);
if (!bs.length) continue;
bucketMap[catId] = bs;
for (const b of bs) all.add(b);
}
filterTable = document.querySelector("table.tablepress") || document.querySelector("table");
ensureBucketPredicate();
const buckets = [...all].sort(compareBuckets);
const frag = document.createDocumentFragment();
for (const b of buckets) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "a1p-bucket-chip";
chip.textContent = b;
chip.dataset.bucket = b;
chip.setAttribute("aria-pressed", "false");
chip.onclick = () => selectBucket(b === activeBucket ? null : b, wrap);
frag.appendChild(chip);
}
wrap.appendChild(frag);
wrap.dataset.filled = "1";
updateBucketEdges();
}
function mountToolbar() {
if (document.querySelector(".a1p-toolbar")) return;
injectStyles();
const bar = document.createElement("div");
bar.className = "a1p-toolbar";
const search = document.createElement("div");
search.className = "a1p-tb-search";
const input = document.createElement("input");
input.type = "search";
input.placeholder = "搜尋動畫…";
input.className = "a1p-tb-input";
input.oninput = () => {
const native = document.querySelector(
'.dataTables_filter input, .dataTables_wrapper input[type="search"], .dataTables_wrapper input[type="text"]'
);
if (native) {
native.value = input.value;
native.dispatchEvent(new Event("input", { bubbles: true }));
native.dispatchEvent(new Event("keyup", { bubbles: true }));
} else if (currentDt) {
try {
currentDt.search(input.value).draw();
} catch {
}
}
};
search.appendChild(input);
const viewBtn = document.createElement("button");
viewBtn.className = "a1p-tb-btn";
const refresh = () => {
const on = document.body.classList.contains("a1p-grid-on");
viewBtn.textContent = on ? "▦" : "☰";
viewBtn.title = on ? "切換為原始列表" : "切換為卡片檢視";
};
viewBtn.onclick = () => {
const on = !document.body.classList.contains("a1p-grid-on");
document.body.classList.toggle("a1p-grid-on", on);
setSettings({ gridView: on });
refresh();
if (on) window.dispatchEvent(new Event("scroll"));
else if (currentDt && initialLen != null) {
try {
currentDt.page.len(initialLen).draw(false);
} catch {
}
}
};
refresh();
const sizeWrap = document.createElement("label");
sizeWrap.className = "a1p-tb-size";
const range = document.createElement("input");
range.type = "range";
range.min = "140";
range.max = "360";
range.step = "10";
range.value = String(getSettings().cardWidth || 250);
const applyWidth = (w) => {
document.documentElement.style.setProperty("--a1p-card-w", `${w}px`);
const pct = (Number(w) - Number(range.min)) / (Number(range.max) - Number(range.min)) * 100;
range.style.setProperty("--a1p-range-fill", `${pct}%`);
};
applyWidth(range.value);
range.oninput = () => {
applyWidth(range.value);
setSettings({ cardWidth: Number(range.value) });
};
sizeWrap.append(range);
const buckets = document.createElement("div");
buckets.className = "a1p-tb-buckets";
buckets.addEventListener(
"wheel",
(e) => {
if (!e.deltaY) return;
e.preventDefault();
buckets.scrollLeft += e.deltaY;
},
{ passive: false }
);
const clearBtn = document.createElement("button");
clearBtn.type = "button";
clearBtn.className = "a1p-bucket-clear";
clearBtn.textContent = "✕";
clearBtn.title = "清除";
clearBtn.hidden = true;
clearBtn.onclick = () => selectBucket(null, buckets);
const scroll = document.createElement("div");
scroll.className = "a1p-tb-scroll";
const arrowL = document.createElement("span");
arrowL.className = "a1p-tb-arrow l";
arrowL.textContent = "‹";
const arrowR = document.createElement("span");
arrowR.className = "a1p-tb-arrow r";
arrowR.textContent = "›";
updateBucketEdges = () => {
const max = buckets.scrollWidth - buckets.clientWidth;
arrowL.classList.toggle("show", buckets.scrollLeft > 2);
arrowR.classList.toggle("show", buckets.scrollLeft < max - 2);
};
buckets.addEventListener("scroll", updateBucketEdges, { passive: true });
window.addEventListener("resize", updateBucketEdges, { passive: true });
scroll.append(buckets, arrowL, arrowR);
const bucketWrap = document.createElement("div");
bucketWrap.className = "a1p-tb-bucketwrap";
bucketWrap.append(clearBtn, scroll);
bar.append(search, sizeWrap, viewBtn, bucketWrap);
const anchor = document.querySelector("#primary, .content-area, #main, #content") || document.body;
anchor.insertBefore(bar, anchor.firstChild);
setupStickyToolbar(bar);
}
function setupStickyToolbar(bar) {
const MAX_W = 1152;
const FADE = 26;
const spacer = document.createElement("div");
bar.parentNode.insertBefore(spacer, bar);
const mask = document.createElement("div");
mask.className = "a1p-toolbar-mask";
document.body.appendChild(mask);
let fixed = false;
const applyGeom = () => {
const r = spacer.getBoundingClientRect();
const w = Math.min(r.width, MAX_W);
bar.style.width = `${w}px`;
bar.style.left = `${r.left + (r.width - w) / 2}px`;
const solid = Math.ceil(bar.getBoundingClientRect().bottom);
mask.style.height = `${solid + FADE}px`;
mask.style.background = `linear-gradient(to bottom,#0d0d10 0,#0d0d10 ${solid}px,transparent ${solid + FADE}px)`;
};
const update = () => {
const top = spacer.getBoundingClientRect().top;
if (!fixed && top < 0) {
spacer.style.height = `${bar.offsetHeight}px`;
bar.classList.add("a1p-toolbar-fixed");
mask.classList.add("on");
applyGeom();
fixed = true;
} else if (fixed && top >= 0) {
spacer.style.height = "0";
bar.classList.remove("a1p-toolbar-fixed");
mask.classList.remove("on");
bar.style.left = bar.style.width = "";
fixed = false;
} else if (fixed) {
applyGeom();
}
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
}
function getDataTable() {
const w = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const $ = w.jQuery || w.$;
const table = document.querySelector("table.tablepress") || document.querySelector("table");
if (!$ || !$.fn || !$.fn.DataTable || !table) return null;
if (!$.fn.DataTable.isDataTable(table)) return null;
try {
return $(table).DataTable();
} catch {
return null;
}
}
function setupInfiniteScroll() {
const STEP = 60;
let tries = 0;
const timer = setInterval(() => {
const dt = getDataTable();
if (dt) {
clearInterval(timer);
attach(dt);
} else if (++tries > 48) {
clearInterval(timer);
}
}, 250);
function attach(dt) {
currentDt = dt;
try {
initialLen = dt.page.info().length;
dt.page(0);
} catch {
}
let loading = false;
const onScroll = () => {
if (loading) return;
if (!document.body.classList.contains("a1p-grid-on")) return;
let info;
try {
info = dt.page.info();
} catch {
return;
}
if (!info || info.length < 0) return;
if (info.length >= info.recordsDisplay) return;
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 800;
if (!nearBottom) return;
loading = true;
try {
dt.page.len(info.length + STEP).draw(false);
} catch {
}
setTimeout(() => {
loading = false;
onScroll();
}, 250);
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
}
}
// src/sync.js
var GIST_FILENAME = "anime1-plus-sync.json";
var GIST_DESC = "anime1-plus 追番進度同步(請勿手動編輯)";
var PUSH_DEBOUNCE = 4e3;
var GAP_IDLE = 1e4;
var GAP_WATCHING = 3e4;
var WATCH_HEARTBEAT = 7e3;
function syncGap(lastWriteAt, now, { idle = GAP_IDLE, watching = GAP_WATCHING, hb = WATCH_HEARTBEAT } = {}) {
return now - (lastWriteAt || 0) < hb ? watching : idle;
}
function currentGap(now = Date.now()) {
return syncGap(getLastWriteAt(), now);
}
function inSyncCooldown(lastAttemptAt, now, force = false, gap = GAP_IDLE) {
if (force) return false;
return now - (lastAttemptAt || 0) < gap;
}
function gmGist({ method, path, token, body }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url: `https://api.github.com${path}`,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json"
},
data: body ? JSON.stringify(body) : void 0,
timeout: 2e4,
onload: (res) => resolve(res),
onerror: () => reject(new Error("網路錯誤")),
ontimeout: () => reject(new Error("逾時"))
});
});
}
function parseRateLimitReset(responseHeaders, nowMs) {
const h = String(responseHeaders || "").toLowerCase();
let until = 0;
const retry = /^[ \t]*retry-after:[ \t]*(\d+)/m.exec(h);
if (retry) {
until = nowMs + Number(retry[1]) * 1e3;
} else {
const remaining = /^[ \t]*x-ratelimit-remaining:[ \t]*(\d+)/m.exec(h);
const reset = /^[ \t]*x-ratelimit-reset:[ \t]*(\d+)/m.exec(h);
if (reset && remaining && Number(remaining[1]) === 0) until = Number(reset[1]) * 1e3;
}
if (until <= nowMs) return 0;
return Math.min(until, nowMs + 60 * 60 * 1e3);
}
function parseOrThrow(res, ctx) {
if (res.status < 200 || res.status >= 300) {
if (res.status === 403 || res.status === 429) {
const until = parseRateLimitReset(res.responseHeaders, Date.now());
if (until) setSyncConfig({ rateLimitUntil: until });
}
let msg = `${ctx} HTTP ${res.status}`;
try {
const j = JSON.parse(res.responseText);
if (j && j.message) msg += `(${j.message})`;
} catch {
}
throw new Error(msg);
}
return JSON.parse(res.responseText);
}
async function createGist(token) {
const res = await gmGist({
method: "POST",
path: "/gists",
token,
body: {
description: GIST_DESC,
public: false,
files: { [GIST_FILENAME]: { content: '{"_v":1,"watch":{},"meta":{}}' } }
}
});
return parseOrThrow(res, "建立 gist").id;
}
async function findExistingGist(token) {
const res = await gmGist({ method: "GET", path: "/gists?per_page=100", token });
const list = parseOrThrow(res, "列出 gist");
const hit = Array.isArray(list) ? list.find((g) => g.files && g.files[GIST_FILENAME]) : null;
return hit ? hit.id : null;
}
async function gistReachable(token, gistId) {
const res = await gmGist({ method: "GET", path: `/gists/${gistId}`, token });
if (res.status >= 200 && res.status < 300) return true;
if (res.status === 404) return false;
parseOrThrow(res, "讀取 gist");
}
async function resolveGistId(token, existingId, deps = {}) {
const { reachable = gistReachable, find = findExistingGist, create = createGist } = deps;
if (existingId && await reachable(token, existingId)) return existingId;
return await find(token) || await create(token);
}
async function pullGist(token, gistId) {
const res = await gmGist({ method: "GET", path: `/gists/${gistId}`, token });
const gist = parseOrThrow(res, "讀取 gist");
const file = gist.files && gist.files[GIST_FILENAME];
if (!file || !file.content) return { watch: {}, meta: {} };
if (file.truncated) throw new Error("同步資料過大(>1MB),暫不支援");
const obj = JSON.parse(file.content);
return { watch: obj.watch || {}, meta: obj.meta || {} };
}
async function pushGist(token, gistId, subset) {
const content = JSON.stringify({ _v: 1, watch: subset.watch || {}, meta: subset.meta || {} });
const res = await gmGist({
method: "PATCH",
path: `/gists/${gistId}`,
token,
body: { files: { [GIST_FILENAME]: { content } } }
});
parseOrThrow(res, "寫入 gist");
}
var syncing = false;
var pushTimer = null;
var heartbeatTimer = null;
var lastSubsetSig = null;
function subsetSig() {
return JSON.stringify(getSyncSubset());
}
async function syncNow({ silent = false, force = false } = {}) {
const cfg = getSyncConfig();
if (!cfg.enabled || !cfg.token || !cfg.gistId) return { ok: false, reason: "not-configured" };
if (syncing) return { ok: false, reason: "busy" };
const now = Date.now();
if (cfg.rateLimitUntil && now < cfg.rateLimitUntil) {
if (!silent) {
const mins = Math.max(1, Math.ceil((cfg.rateLimitUntil - now) / 6e4));
toast(`已達 GitHub API 速率上限,約 ${mins} 分鐘後自動重試`, { duration: 5e3 });
}
return { ok: false, reason: "rate-limited" };
}
if (inSyncCooldown(cfg.lastAttemptAt, now, force, currentGap(now))) return { ok: false, reason: "cooldown" };
syncing = true;
setSyncConfig({ lastAttemptAt: now });
try {
const remote = await pullGist(cfg.token, cfg.gistId);
const { changed } = applySyncedData(remote);
const subset = getSyncSubset();
const remoteStr = JSON.stringify({ watch: remote.watch || {}, meta: remote.meta || {} });
const localStr = JSON.stringify({ watch: subset.watch || {}, meta: subset.meta || {} });
if (localStr !== remoteStr) await pushGist(cfg.token, cfg.gistId, subset);
lastSubsetSig = localStr;
setSyncConfig({ lastSyncAt: Date.now(), lastError: "", rateLimitUntil: 0 });
if (changed && !silent) toast("已同步追番進度,部分頁面重新整理後更新", { duration: 3500 });
return { ok: true, changed };
} catch (e) {
setSyncConfig({ lastError: e.message });
if (!silent) toast(`同步失敗:${e.message}`, { duration: 5e3 });
return { ok: false, reason: e.message };
} finally {
syncing = false;
}
}
function armPush() {
const cfg = getSyncConfig();
if (!cfg.enabled || !cfg.token || !cfg.gistId) return;
if (pushTimer) clearTimeout(pushTimer);
const now = Date.now();
let delay = PUSH_DEBOUNCE;
if (cfg.rateLimitUntil && cfg.rateLimitUntil > now) delay = Math.max(delay, cfg.rateLimitUntil - now + 1e3);
const gapLeft = currentGap(now) - (now - (cfg.lastAttemptAt || 0));
if (gapLeft > 0) delay = Math.max(delay, gapLeft + 250);
pushTimer = setTimeout(async () => {
pushTimer = null;
const r = await syncNow({ silent: true });
if (r && !r.ok && (r.reason === "cooldown" || r.reason === "rate-limited" || r.reason === "busy")) {
lastSubsetSig = null;
schedulePush();
}
}, delay);
}
function armHeartbeat() {
if (heartbeatTimer) clearTimeout(heartbeatTimer);
heartbeatTimer = setTimeout(() => {
heartbeatTimer = null;
if (pushTimer) armPush();
}, WATCH_HEARTBEAT);
}
function schedulePush() {
if (syncing) return;
const cfg = getSyncConfig();
if (!cfg.enabled || !cfg.token || !cfg.gistId) return;
const sig = subsetSig();
if (sig === lastSubsetSig) return;
lastSubsetSig = sig;
armHeartbeat();
armPush();
}
function initSync() {
lastSubsetSig = subsetSig();
onDataChange(schedulePush);
const cfg = getSyncConfig();
if (cfg.enabled && cfg.token && cfg.gistId) syncNow({ silent: true });
}
async function configureSync() {
const cfg = getSyncConfig();
const input = prompt(
"貼上 GitHub Personal Access Token\n(fine-grained,只需勾選 Gist 讀寫權限)。\n留空並確定=刪除 token 並停用同步:",
cfg.token || ""
);
if (input === null) return;
const token = input.trim();
if (!token) {
setSyncConfig({ token: "", enabled: false });
toast("已刪除 token 並停用多端同步", { duration: 3e3 });
return;
}
setSyncConfig({ token });
toast("正在設定同步…", { duration: 2e3 });
try {
const gistId = await resolveGistId(token, cfg.gistId);
setSyncConfig({ gistId, enabled: true, lastError: "" });
const r = await syncNow({ silent: true, force: true });
if (r.ok) toast("同步已啟用並完成首次同步 ✓", { duration: 3500 });
else toast(`同步已設定,但首次同步失敗:${r.reason}`, { duration: 5e3 });
} catch (e) {
setSyncConfig({ lastError: e.message });
toast(`設定同步失敗:${e.message}`, { duration: 5e3 });
}
}
// src/main.js
var currentAnimeKey = null;
function initCategoryPage() {
const path = decodeURIComponent(location.pathname);
const catId = getCategoryId();
const animeKey = catId ? `cat:${catId}` : animeKeyFromCategoryPath(location.pathname);
currentAnimeKey = animeKey;
const year = yearFromText(path);
const firstAnchor = markCategoryEpisodes(animeKey);
const h1 = getContentH1();
const mountEl = h1 || firstAnchor;
if (!mountEl) return;
const animeName = getAnimeTitle();
renderLastWatched(animeKey, mountEl);
resolveCover({ animeKey, title: animeName, year, mountEl });
collapseToSinglePlayer(animeKey);
enhanceEpisodeNav({ hide: true });
initCategoryPlayback(animeKey);
const pageTitle = document.querySelector(".page-title");
if (pageTitle) pageTitle.style.display = "none";
}
function initEpisodePageRoute() {
const cat = getCategoryInfo();
const titleEl = document.querySelector(SEL.entryTitle);
const parsed = parseTitle(titleEl ? titleEl.textContent : document.title);
const catId = getCategoryId();
const animeKey = catId ? `cat:${catId}` : cat ? cat.animeKey : `post:${postIdFromPath()}`;
currentAnimeKey = animeKey;
const year = cat ? cat.year : null;
initEpisodePage({ animeKey, ep: parsed.ep, title: parsed.raw });
hideEmptyEntryImages();
if (cat && titleEl) {
const colArea = document.querySelector("#primary, .content-area");
resolveCover({
animeKey,
title: cat.name,
year,
mountEl: colArea || titleEl,
align: colArea ? ".entry-title" : void 0
});
}
enhanceEpisodeNav({ animeKey, ep: parsed.ep, epRaw: parsed.epRaw, postId: postIdFromPath() });
}
function downloadJson(text, filename) {
const blob = new Blob([text], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1e3);
}
function importViaPaste() {
injectStyles();
const overlay = document.createElement("div");
overlay.className = "a1p-modal-overlay";
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
const ta = overlay.querySelector(".a1p-modal-ta");
overlay.querySelector(".a1p-modal-cancel").onclick = close;
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
overlay.querySelector(".a1p-modal-ok").onclick = () => {
const text = (ta.value || "").trim();
if (!text) return;
try {
importAll(text, { merge: true });
close();
toast("匯入完成,重新整理後生效", { duration: 4e3 });
} catch (e) {
toast(`匯入失敗:${e.message}`, { duration: 5e3 });
}
};
ta.focus();
}
function registerMenu() {
if (typeof GM_registerMenuCommand !== "function") return;
GM_registerMenuCommand(
"匯出資料 (JSON)",
() => downloadJson(exportAll(), `anime1-plus-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`)
);
GM_registerMenuCommand("匯入資料 (JSON)", importViaPaste);
const sync = getSyncConfig();
if (sync.enabled && sync.gistId) {
GM_registerMenuCommand("☁️ 立即同步", async () => {
toast("同步中…", { duration: 1500 });
const r = await syncNow({ silent: true, force: true });
if (r.ok) toast(r.changed ? "同步完成,部分頁面重新整理後更新" : "已是最新進度 ✓", { duration: 3e3 });
else toast(`同步失敗:${r.reason}`, { duration: 5e3 });
});
GM_registerMenuCommand("☁️ 同步設定(變更 token)…", configureSync);
GM_registerMenuCommand("✓ 多端同步(點此停用)", () => {
setSyncConfig({ enabled: false });
toast("已停用多端同步(選單下次開啟更新)", { duration: 2500 });
});
} else {
GM_registerMenuCommand("☁️ 設定多端同步(GitHub Gist)…", configureSync);
}
GM_registerMenuCommand(`⏩ 方向鍵快進秒數(目前 ${getSettings().seekSeconds || 5}s)`, () => {
const cur = getSettings().seekSeconds || 5;
const v = prompt("方向鍵快進/後退秒數(1–120):", String(cur));
if (v == null) return;
const n = parseInt(v, 10);
if (n >= 1 && n <= 120) {
setSettings({ seekSeconds: n });
toast(`快進秒數已設為 ${n} 秒`, { duration: 2500 });
} else {
toast("請輸入 1–120 的數字", { duration: 2500 });
}
});
const toggles = [
["autoNext", "看完自動下一集"],
["resume", "自動續播"],
["shortcuts", "鍵盤快捷鍵"],
["rememberRate", "記憶播放速度"]
];
for (const [key, label] of toggles) {
const on = getSettings()[key];
GM_registerMenuCommand(`${on ? "✓" : "✗"} ${label}`, () => {
setSettings({ [key]: !getSettings()[key] });
toast(`${label}:${!on ? "開啟" : "關閉"}(選單下次開啟更新)`, { duration: 2500 });
});
}
GM_registerMenuCommand("🧹 清除資料…", openClearMenu);
}
function openClearMenu() {
const opts = [];
if (currentAnimeKey) {
opts.push(["清除此動畫的觀看記錄", () => clearAnime(currentAnimeKey)]);
opts.push(["清除此動畫封面快取", () => clearCover(currentAnimeKey)]);
} else {
opts.push(["清除所有封面快取", clearCovers]);
}
opts.push(["清除追番記錄(所有觀看進度)", clearWatch]);
opts.push(["還原所有設定為預設", clearSettings]);
opts.push(["清除所有資料(完全重置)", clearAll]);
const menu = opts.map(([label2], i) => `${i + 1}. ${label2}`).join("\n");
const v = prompt(`輸入數字選擇要清除的資料:
${menu}`, "");
if (v == null) return;
const n = parseInt(String(v).trim(), 10);
if (!(n >= 1 && n <= opts.length)) {
toast("未選擇有效項目", { duration: 2500 });
return;
}
const [label, action] = opts[n - 1];
if (!confirm(`確定要「${label}」?此動作無法復原。`)) return;
action();
toast(`已${label},重新整理生效`, { duration: 3e3 });
}
function main() {
migrateStored();
injectStyles();
injectProjectLinks();
mountTrackingPanel();
const type = getPageType();
if (type === "list" || type === "category" || type === "episode") {
document.body.classList.add("a1p-stick-footer");
}
if (type === "category") initCategoryPage();
else if (type === "episode") initEpisodePageRoute();
else if (type === "list") {
if (getSettings().listThumbs) initListPage();
}
registerMenu();
initSync();
if (type === "list") setCoverUpgradeHook(repaintCard);
recheckTentativeCovers(type === "list" ? { orderHint: viewportCatOrder() } : {});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}
})();