为 asmr.one 提供播放进度保存、播放历史、本地关注和数据管理,并修正界面命名、补充播放器按钮提示。
// ==UserScript==
// @name asmr.one plus
// @namespace asmr-one-plus
// @version 1.0.0
// @description 为 asmr.one 提供播放进度保存、播放历史、本地关注和数据管理,并修正界面命名、补充播放器按钮提示。
// @match https://asmr.one/*
// @match https://www.asmr.one/*
// @match https://asmr-100.com/*
// @match https://www.asmr-100.com/*
// @match https://asmr-200.com/*
// @match https://www.asmr-200.com/*
// @match https://asmr-300.com/*
// @match https://www.asmr-300.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @run-at document-idle
// ==/UserScript==
/*! 自动构建文件。维护入口:src/main.js;修改 src/ 后运行 npm run build,请勿直接修改本文件。 */
'use strict';
(() => {
/*! 版本号由构建脚本从 package.json 注入;不要在源码中另写一份版本号。 */
var SCRIPT_VERSION = "1.0.0";
/*! 网站接入与通用 DOM 工具。当前依赖 Vue 2 的 #q-app.__vue__,网站升级框架时需先检查此处。 */
var LOG_PREFIX = "[asmr.one plus]";
var log = (...args) => console.log(LOG_PREFIX, ...args);
var pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
var SITE_API_KEYS = Object.freeze({
"asmr.one": "original",
"asmr-100.com": "mirror-100",
"asmr-200.com": "mirror-200",
"asmr-300.com": "mirror-300"
});
var siteHost = pageWindow.location.hostname.toLowerCase().replace(/^www\./, "");
function waitForApp(timeoutMs = 15e3) {
return new Promise((resolve, reject) => {
const started = Date.now();
(function poll() {
const el2 = pageWindow.document.querySelector("#q-app");
const vm = el2 && el2.__vue__;
if (vm && vm.$store) return resolve(vm);
if (Date.now() - started > timeoutMs) return reject(new Error("找不到 #q-app 的 Vue 实例"));
setTimeout(poll, 200);
})();
});
}
function formatTime(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
const h = Math.floor(s / 3600);
const m = Math.floor(s % 3600 / 60);
const pad = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`;
}
function el(tag, attrs = {}, children = []) {
const node = pageWindow.document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
else if (key === "class") node.className = value;
else node.setAttribute(key, value);
}
for (const child of [].concat(children)) {
if (child != null) node.append(child);
}
return node;
}
function notify(vm, message) {
if (vm.$q && typeof vm.$q.notify === "function") {
vm.$q.notify({ message, position: "bottom-right", timeout: 2500 });
} else {
log(message);
}
}
/*! 脚本界面文案与语言通知。文案值可为字符串或格式化函数,新增键时同步维护三种语言。 */
var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
var MESSAGES = {
"zh-CN": {
justNow: "刚刚",
minutesAgo: (n) => `${n} 分钟前`,
hoursAgo: (n) => `${n} 小时前`,
daysAgo: (n) => `${n} 天前`,
subtitleMatchFailed: "字幕匹配失败,请在作品页重试",
resumeFailed: "无法直接续播,已打开作品页",
subtitleLoadFailed: "字幕加载失败,可重新选择字幕重试;旧编码字幕也可打开原生预览加载",
listenedTo: (time) => `上次 ${time}`,
history: "播放历史",
trackLine: (playing, title) => `${playing ? "正在播放" : "上次听到"}:${title}`,
nowPlaying: "正在播放",
finished: "已听完",
replay: "重新播放",
resume: "继续播放",
today: "今天",
yesterday: "昨天",
earlier: "更早",
removeFromHistory: "从历史中删除",
confirmRemoveHistory: "从播放历史中删除?续播进度会保留。",
remove: "删除",
workCount: (n) => `${n} 部作品`,
confirmClearHistory: "清空全部播放历史?关注和播放进度会保留。",
clearHistory: "清空历史",
confirmClearProgress: "清除全部播放进度?关注和历史会保留。",
clearProgress: "清除全部播放进度",
clearTrackProgress: "清除这条音轨的播放进度",
confirmClearTrackProgress: "清除这条音频的播放进度?同一音轨的其他格式会一起清除,关注和历史会保留。",
resetCurrentNotice: "当前音轨将暂停并回到开头。",
clearTrackProgressButton: "清除进度",
dataManagement: "数据管理",
clearProgressHint: "清除所有音频的播放进度,保留关注和历史。",
clearAllHint: "清空关注、历史和播放进度。",
close: "关闭",
playerQueue: "查看播放队列",
playerModeNames: { order: "顺序播放", "all repeat": "列表循环", "repeat once": "单曲循环", shuffle: "随机播放" },
playerModeHint: (mode) => `播放模式:${mode}(点击切换)`,
playerDesktopSubtitles: "切换为桌面字幕(画中画)",
playerInlineSubtitles: "切换为页面内字幕",
playerSelectSubtitles: "查看或选择字幕",
playerLoadSubtitles: "加载本地字幕",
playerMoreActions: "更多操作",
historyEmpty: "还没有播放记录,播放任意作品后会出现在这里",
following: "我的关注",
typeLabel: { va: "声优", tag: "标签", circle: "社团" },
inbox: "默认分组",
tab: (label, n) => `${label}(${n})`,
followHint: (type) => `悬停${type},点右上角 ☆ 关注,或点“+ 添加”`,
loadingCounts: "正在更新作品数量…",
refreshCounts: (time) => `刷新作品数量(每 5 分钟自动更新${time ? `,最近更新 ${time}` : ""})。各分组按作品数量从多到少排列`,
countError: "更新失败,保留上次数量;未匹配项显示未知,可重试",
newGroupPrompt: "新分组的名字",
groupNameInvalid: "分组名为空或已存在",
newGroup: "+ 新建分组",
addTitle: "多个名字用分号分隔。",
addPrompt: (group, type) => `添加到“${group}”:多个${type}用分号分隔。`,
add: "+ 添加",
renamePrompt: "分组的新名字",
rename: "改名",
confirmDeleteGroup: (group, n, inbox) => `删除分组“${group}”?里面的${n}项会移到“${inbox}”。`,
deleteGroup: "删除分组",
clearGroup: "清空",
confirmClearGroup: (group, n, type) => `取消关注“${group}”里的全部 ${n} 个${type}?此操作不能撤销,建议先导出备份。`,
moveToGroup: "移到分组",
unknown: "未知",
works: (n) => `${n} 部`,
follow: "关注",
unfollow: "取消关注",
followed: (on, name) => `${on ? "已关注" : "已取消关注"}:${name}`,
starLabel: (on, name) => `${on ? "取消关注" : "关注"}:${name}`,
followMenu: (on, type) => `${on ? "取消关注" : "关注"}${type}`,
backupBadList: "备份列表格式或数量不正确",
backupBadText: "备份文本字段不正确",
backupBadNumber: "备份数值字段不正确",
backupBadTrack: "音轨标识不正确",
backupBadStructure: "备份数据结构不正确",
backupBadWork: "历史作品标识不正确",
backupBadSource: "作品来源标识不正确",
backupTrackMismatch: "历史作品与音轨不匹配",
backupBadProgress: "播放进度格式不正确",
backupTooManyProgress: "进度记录过多",
backupBadFollow: "关注项格式不正确",
backupInvalidFile: "备份文件过大或格式不正确",
backupNotJson: "文件不是有效的 JSON 备份",
backupUnsupported: "不是受支持的脚本数据备份",
storageFailed: "存储写入失败;请保留导出的备份并检查脚本管理器存储",
importRolledBack: "导入未完成,已恢复原数据",
exportSkipped: (n) => `已导出备份;有 ${n} 条无法识别的本地记录未包含在内`,
exportFailed: "备份导出失败,请检查本地数据和下载权限",
backupTooLarge: "备份文件不能超过 10 MB",
importConfirm: (follows, works, tracks, skipped) => `备份包含 ${follows} 个关注、${works} 部历史作品、${tracks} 条音轨进度。
导入会合并数据,不删除现有关注;相同关注保留本地分组,播放记录取更新时间较新的。建议先导出当前数据。${skipped ? `
注意:本地有 ${skipped} 条无法识别的记录,合并后不会保留。` : ""}
确定导入?`,
importDone: "备份已导入并合并",
importFailed: "备份导入失败",
backup: "备份与导入",
exportBackup: "导出全部数据",
importBackup: "导入全部数据",
backupHint: "导出或导入关注、历史和播放进度。",
playlistRemoveHint: "移出这个收藏夹(作品本身不受影响)",
playlistRemoveTitle: "移出收藏夹",
playlistRemoveConfirm: (code) => code ? `把 ${code} 移出这个收藏夹?` : "把这部作品移出这个收藏夹?",
playlistRemoveOk: "移出",
cancel: "取消",
clearAllMenu: "清除全部数据",
clearAllConfirm: "清空关注、历史和播放进度?此操作不能撤销。"
},
ja: {
justNow: "たった今",
minutesAgo: (n) => `${n} 分前`,
hoursAgo: (n) => `${n} 時間前`,
daysAgo: (n) => `${n} 日前`,
subtitleMatchFailed: "字幕を割り当てられませんでした。作品ページで再試行してください",
resumeFailed: "ここから再開できないため、作品ページを開きました",
subtitleLoadFailed: "字幕を読み込めませんでした。字幕を選び直すと再試行します。古い文字コードの字幕はプレビューからも読み込めます",
listenedTo: (time) => `前回 ${time}`,
history: "再生履歴",
trackLine: (playing, title) => `${playing ? "再生中" : "前回の位置"}:${title}`,
nowPlaying: "再生中",
finished: "再生済み",
replay: "最初から再生",
resume: "続きから再生",
today: "今日",
yesterday: "昨日",
earlier: "それ以前",
removeFromHistory: "履歴から削除",
confirmRemoveHistory: "再生履歴から削除しますか?再開位置は残ります。",
remove: "削除",
workCount: (n) => `${n} 作品`,
confirmClearHistory: "すべての再生履歴を消去しますか?フォローと再開位置は残ります。",
clearHistory: "履歴を消去",
confirmClearProgress: "すべての再開位置を消去しますか?フォローと履歴は残ります。",
clearProgress: "すべての再開位置を消去",
clearTrackProgress: "このトラックの再開位置を消去",
confirmClearTrackProgress: "このトラックの再開位置を消去しますか?同じトラックの別形式も対象です。フォローと履歴は残ります。",
resetCurrentNotice: "現在のトラックを一時停止し、先頭に戻します。",
clearTrackProgressButton: "再開位置を消去",
dataManagement: "データ管理",
clearProgressHint: "全トラックの再開位置を消去。フォローと履歴は残ります。",
clearAllHint: "フォロー、履歴、再開位置をすべて削除。",
close: "閉じる",
playerQueue: "再生キューを表示",
playerModeNames: { order: "順番再生", "all repeat": "全曲リピート", "repeat once": "1曲リピート", shuffle: "シャッフル" },
playerModeHint: (mode) => `再生モード:${mode}(クリックで切替)`,
playerDesktopSubtitles: "デスクトップ字幕に切替(ピクチャーインピクチャー)",
playerInlineSubtitles: "ページ内字幕に切替",
playerSelectSubtitles: "字幕を表示・選択",
playerLoadSubtitles: "ローカル字幕を読み込む",
playerMoreActions: "その他の操作",
historyEmpty: "まだ再生履歴がありません。作品を再生するとここに表示されます",
following: "フォロー中",
typeLabel: { va: "声優", tag: "タグ", circle: "サークル" },
inbox: "デフォルト",
tab: (label, n) => `${label}(${n})`,
followHint: (type) => `${type}にカーソルを合わせて右上の ☆、または「+ 追加」でフォロー`,
loadingCounts: "作品数を更新中…",
refreshCounts: (time) => `作品数を更新(5 分ごとに自動更新${time ? `、最終更新 ${time}` : ""})。各グループは作品数の多い順に並びます`,
countError: "更新できませんでした。前回の作品数を表示しています(不明の項目は再試行できます)",
newGroupPrompt: "新しいグループの名前",
groupNameInvalid: "グループ名が空か、すでに存在します",
newGroup: "+ 新しいグループ",
addTitle: "セミコロンで区切ってください",
addPrompt: (group, type) => `「${group}」に追加:${type}の名前を入力(セミコロンで区切る)`,
add: "+ 追加",
renamePrompt: "グループの新しい名前",
rename: "名前を変更",
confirmDeleteGroup: (group, n, inbox) => `グループ「${group}」を削除しますか?中の ${n} 件は「${inbox}」に移ります。`,
deleteGroup: "グループを削除",
clearGroup: "空にする",
confirmClearGroup: (group, n, type) => `「${group}」の${type} ${n} 件をすべてフォロー解除しますか?元に戻せないため、先にバックアップを書き出すことをおすすめします。`,
moveToGroup: "グループを移動",
unknown: "不明",
works: (n) => `${n} 作品`,
follow: "フォロー",
unfollow: "フォロー解除",
followed: (on, name) => `${on ? "フォローしました" : "フォローを解除しました"}:${name}`,
starLabel: (on, name) => `${on ? "フォロー解除" : "フォロー"}:${name}`,
followMenu: (on, type) => `${type}を${on ? "フォロー解除" : "フォロー"}`,
backupBadList: "バックアップのリストの形式か件数が正しくありません",
backupBadText: "バックアップの文字列の項目が正しくありません",
backupBadNumber: "バックアップの数値の項目が正しくありません",
backupBadTrack: "トラック ID が正しくありません",
backupBadStructure: "バックアップのデータ構造が正しくありません",
backupBadWork: "履歴の作品 ID が正しくありません",
backupBadSource: "作品番号が正しくありません",
backupTrackMismatch: "履歴の作品とトラックが一致しません",
backupBadProgress: "再生位置の形式が正しくありません",
backupTooManyProgress: "再生位置の記録が多すぎます",
backupBadFollow: "フォロー項目の形式が正しくありません",
backupInvalidFile: "バックアップファイルが大きすぎるか、形式が正しくありません",
backupNotJson: "有効な JSON バックアップではありません",
backupUnsupported: "このスクリプトのバックアップではありません",
storageFailed: "保存に失敗しました。書き出したバックアップを残し、スクリプトマネージャーの保存領域を確認してください",
importRolledBack: "読み込みを完了できなかったため、元のデータに戻しました",
exportSkipped: (n) => `バックアップを書き出しました。認識できないローカル記録 ${n} 件は含まれていません`,
exportFailed: "バックアップを書き出せませんでした。ローカルデータとダウンロードの許可を確認してください",
backupTooLarge: "バックアップファイルは 10 MB 以下にしてください",
importConfirm: (follows, works, tracks, skipped) => `バックアップにはフォロー ${follows} 件、履歴 ${works} 作品、トラックの再生位置 ${tracks} 件が含まれます。
読み込むとデータを統合します。今のフォローは削除されず、同じフォローはローカルのグループのまま、再生記録は新しいほうを残します。先に今のデータを書き出しておくことをおすすめします。${skipped ? `
注意:認識できないローカル記録が ${skipped} 件あり、統合後は残りません。` : ""}
読み込みますか?`,
importDone: "バックアップを読み込んで統合しました",
importFailed: "バックアップを読み込めませんでした",
backup: "バックアップと復元",
exportBackup: "すべてのデータを書き出す",
importBackup: "すべてのデータを読み込む",
backupHint: "フォロー、履歴、再開位置の書き出し・読み込み。",
playlistRemoveHint: "このコレクションから外す(作品自体は削除されません)",
playlistRemoveTitle: "コレクションから外す",
playlistRemoveConfirm: (code) => code ? `${code} をこのコレクションから外しますか?` : "この作品をこのコレクションから外しますか?",
playlistRemoveOk: "外す",
cancel: "キャンセル",
clearAllMenu: "全データを消去",
clearAllConfirm: "フォロー、履歴、再開位置をすべて削除しますか?元に戻せません。"
},
en: {
justNow: "just now",
minutesAgo: (n) => `${plural(n, "minute", "minutes")} ago`,
hoursAgo: (n) => `${plural(n, "hour", "hours")} ago`,
daysAgo: (n) => `${plural(n, "day", "days")} ago`,
subtitleMatchFailed: "Couldn’t match subtitles. Try again on the work page.",
resumeFailed: "Couldn’t resume here, so the work page was opened.",
subtitleLoadFailed: "Couldn’t load the subtitles. Pick them again to retry; subtitles in older encodings can also be loaded from the preview.",
listenedTo: (time) => `Last at ${time}`,
history: "History",
trackLine: (playing, title) => `${playing ? "Now playing" : "Last played"}: ${title}`,
nowPlaying: "Now playing",
finished: "Finished",
replay: "Play again",
resume: "Continue",
today: "Today",
yesterday: "Yesterday",
earlier: "Earlier",
removeFromHistory: "Remove from history",
confirmRemoveHistory: "Remove from history? The resume position will be kept.",
remove: "Remove",
workCount: (n) => plural(n, "work", "works"),
confirmClearHistory: "Clear all play history? Follows and playback progress will be kept.",
clearHistory: "Clear history",
confirmClearProgress: "Clear all saved playback positions? Follows and history will be kept.",
clearProgress: "Clear playback progress",
clearTrackProgress: "Clear this track’s playback position",
confirmClearTrackProgress: "Clear this track’s playback position, including other formats of the same track? Follows and history will be kept.",
resetCurrentNotice: "The current track will be paused and returned to the beginning.",
clearTrackProgressButton: "Clear progress",
dataManagement: "Data management",
clearProgressHint: "Clear all resume positions. Keep follows and history.",
clearAllHint: "Delete all follows, history and playback progress.",
close: "Close",
playerQueue: "Show playback queue",
playerModeNames: { order: "In order", "all repeat": "Repeat all", "repeat once": "Repeat one", shuffle: "Shuffle" },
playerModeHint: (mode) => `Playback mode: ${mode} (click to change)`,
playerDesktopSubtitles: "Switch to desktop subtitles (picture-in-picture)",
playerInlineSubtitles: "Switch to in-page subtitles",
playerSelectSubtitles: "View or select subtitles",
playerLoadSubtitles: "Load local subtitles",
playerMoreActions: "More actions",
historyEmpty: "Nothing here yet. Works you play will show up here.",
following: "Following",
typeLabel: { va: "Voice actors", tag: "Tags", circle: "Circles" },
typeNoun: { va: "voice actor", tag: "tag", circle: "circle" },
inbox: "Default",
tab: (label, n) => `${label} (${n})`,
followHint: (type) => `Hover over a ${type} and click the ☆ in its corner, or use “+ Add”`,
loadingCounts: "Updating counts…",
refreshCounts: (time) => `Refresh work counts (updates every 5 minutes${time ? `; last updated ${time}` : ""}). Each group is sorted by number of works.`,
countError: "Update failed. Showing the last counts; unmatched items show as unknown. You can retry.",
newGroupPrompt: "Name of the new group",
groupNameInvalid: "The group name is empty or already exists",
newGroup: "+ New group",
addTitle: "Separate names with semicolons only",
addPrompt: (group, type) => `Add to “${group}”: enter ${type} names, separated only by semicolons`,
add: "+ Add",
renamePrompt: "New name for the group",
rename: "Rename",
confirmDeleteGroup: (group, n, inbox) => `Delete the group “${group}”? ${n === 1 ? "Its 1 item" : `Its ${n} items`} will move to “${inbox}”.`,
deleteGroup: "Delete group",
clearGroup: "Clear",
confirmClearGroup: (group, n, type) => `Unfollow all ${n} ${type}${n === 1 ? "" : "s"} in “${group}”? This cannot be undone, so consider exporting a backup first.`,
moveToGroup: "Move to group",
unknown: "Unknown",
works: (n) => plural(n, "work", "works"),
follow: "Follow",
unfollow: "Unfollow",
followed: (on, name) => `${on ? "Followed" : "Unfollowed"}: ${name}`,
starLabel: (on, name) => `${on ? "Unfollow" : "Follow"} ${name}`,
followMenu: (on, type) => `${on ? "Unfollow" : "Follow"} ${type}`,
backupBadList: "A list in the backup has the wrong format or size",
backupBadText: "A text field in the backup is invalid",
backupBadNumber: "A number field in the backup is invalid",
backupBadTrack: "A track ID is invalid",
backupBadStructure: "The backup’s data structure is invalid",
backupBadWork: "A work ID in the history is invalid",
backupBadSource: "A work code is invalid",
backupTrackMismatch: "A history entry doesn’t match its track",
backupBadProgress: "A resume position is invalid",
backupTooManyProgress: "Too many resume positions",
backupBadFollow: "A followed item is invalid",
backupInvalidFile: "The backup file is too large or invalid",
backupNotJson: "This file is not a valid JSON backup",
backupUnsupported: "This is not a supported backup of this script",
storageFailed: "Couldn’t write to storage. Keep your exported backup and check the script manager’s storage.",
importRolledBack: "The import didn’t finish; your original data was restored",
exportSkipped: (n) => `Backup exported. ${plural(n, "unreadable local record was", "unreadable local records were")} left out.`,
exportFailed: "Couldn’t export the backup. Check your local data and download permissions.",
backupTooLarge: "The backup file must be 10 MB or smaller",
importConfirm: (follows, works, tracks, skipped) => `The backup has ${plural(follows, "follow", "follows")}, ${plural(works, "work", "works")} in history and ${plural(tracks, "track position", "track positions")}.
Importing merges the data and doesn’t remove any current follows. Follows you already have keep their local group, and the newer play record wins. Consider exporting your current data first.${skipped ? `
Note: ${plural(skipped, "unreadable local record", "unreadable local records")} won’t be kept after merging.` : ""}
Import now?`,
importDone: "Backup imported and merged",
importFailed: "Couldn’t import the backup",
backup: "Backup & import",
exportBackup: "Export all data",
importBackup: "Import all data",
backupHint: "Export or import follows, history and playback progress.",
playlistRemoveHint: "Remove from this collection (the work itself is not deleted)",
playlistRemoveTitle: "Remove from collection",
playlistRemoveConfirm: (code) => code ? `Remove ${code} from this collection?` : "Remove this work from this collection?",
playlistRemoveOk: "Remove",
cancel: "Cancel",
clearAllMenu: "Clear all data",
clearAllConfirm: "Delete all follows, history and playback progress? This can’t be undone."
}
};
var LOCALE_INFO = {
"zh-CN": { tag: "zh-CN", api: ["zh-cn", "zh-CN", "zh"] },
ja: { tag: "ja-JP", api: ["ja-jp", "ja"] },
en: { tag: "en-US", api: ["en-us", "en"] }
};
var localeVm = null;
var localeListeners = new Set();
function currentLocale() {
const locale = String(localeVm && localeVm.$i18n && localeVm.$i18n.locale || "").toLowerCase();
if (!locale || locale.startsWith("zh")) return "zh-CN";
return locale.startsWith("ja") ? "ja" : "en";
}
function tr(key, ...args) {
const value = MESSAGES[currentLocale()][key] ?? MESSAGES["zh-CN"][key];
return typeof value === "function" ? value(...args) : value;
}
var typeLabel = (type) => tr("typeLabel")[type];
var typeNoun = (type) => (tr("typeNoun") || tr("typeLabel"))[type];
function watchLocale(vm) {
localeVm = vm;
if (!vm.$i18n || typeof vm.$watch !== "function") return;
vm.$watch(() => vm.$i18n.locale, () => {
for (const fn of localeListeners) {
try {
fn();
} catch (err) {
console.error(LOG_PREFIX, err);
}
}
});
}
function onLocaleChange(fn) {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
}
function localizedEntityName(entry, fallback) {
const i18n = entry && entry.i18n;
for (const key of LOCALE_INFO[currentLocale()].api) {
const name = i18n && i18n[key] && i18n[key].name;
if (name) return name;
}
return entry && entry.name || fallback;
}
function formatAgo(timestamp, now = Date.now()) {
const diff = Math.max(0, now - timestamp) / 1e3;
if (diff < 60) return tr("justNow");
if (diff < 3600) return tr("minutesAgo", Math.floor(diff / 60));
if (diff < 86400) return tr("hoursAgo", Math.floor(diff / 3600));
if (diff < 86400 * 30) return tr("daysAgo", Math.floor(diff / 86400));
return new Date(timestamp).toLocaleDateString(LOCALE_INFO[currentLocale()].tag);
}
/*! 持久化统一入口:aop: 前缀下的 GM 存储独立于网站账号,同一已安装脚本可跨匹配域名读取。 */
var storage = {
get(key, fallback) {
return GM_getValue(`aop:${key}`, fallback);
},
set(key, value) {
GM_setValue(`aop:${key}`, value);
},
remove(key) {
GM_deleteValue(`aop:${key}`);
}
};
/*! 本地关注与分组仓库。关注标识优先用网站 ID,别名用于识别手动输入或不同语言下的同一实体。 */
var FAVORITES_KEY = "favorites";
var FAVORITE_TYPES = {
va: { keyword: "va", groups: ["默认分组"], inbox: "默认分组" },
tag: { keyword: "tag", groups: ["默认分组"], inbox: "默认分组" },
circle: { keyword: "circle", groups: ["默认分组"], inbox: "默认分组" }
};
var INBOX_NAMES = new Set(Object.values(MESSAGES).map((messages) => messages.inbox));
function groupName(type, group) {
return group === FAVORITE_TYPES[type].inbox ? tr("inbox") : group;
}
function favoriteNames(item) {
return [...new Set([item.name, ...item.aliases || []].filter(Boolean))];
}
function sameFavorite(item, target) {
if (typeof target !== "object" || target == null) return String(item.id) === String(target) || favoriteNames(item).includes(String(target));
if (item.siteId != null && target.siteId != null) return String(item.siteId) === String(target.siteId);
return String(item.id) === String(target.id) || favoriteNames(item).some((name) => favoriteNames(target).includes(name));
}
var favoritesStore = {
listeners: new Set(),
version: 0,
all() {
const data = storage.get(FAVORITES_KEY, {});
let migrated = false;
data.groups = data.groups || {};
for (const [type, { groups, inbox }] of Object.entries(FAVORITE_TYPES)) {
data[type] = data[type] || [];
data.groups[type] = data.groups[type] || [...groups];
if (data.groups[type].includes("未分组")) {
data.groups[type] = [...new Set(data.groups[type].map((name) => name === "未分组" ? inbox : name))];
for (const item of data[type]) if (item.group === "未分组") item.group = inbox;
migrated = true;
}
if (!data.groups[type].includes(inbox)) data.groups[type].push(inbox);
for (const item of data[type]) if (!data.groups[type].includes(item.group)) item.group = inbox;
}
if (migrated) storage.set(FAVORITES_KEY, data);
return data;
},
groups(type) {
return this.all().groups[type];
},
list(type) {
return this.all()[type];
},
find(type, target) {
return this.list(type).find((item) => sameFavorite(item, target));
},
has(type, target) {
return !!this.find(type, target);
},
// 将旧的纯名称关注补全为网站实体,同时保留原分组和所有已知别名。
reconcile(type, item) {
const data = this.all();
const matches = data[type].filter((entry) => sameFavorite(entry, item));
if (!matches.length) return null;
const first = matches[0];
const siteId = item.siteId != null ? item.siteId : first.siteId;
const merged = { ...first, ...siteId != null ? { siteId } : {}, aliases: [...new Set([...matches, item].flatMap(favoriteNames))].sort() };
const next = data[type].filter((entry) => !matches.includes(entry) || entry === first).map((entry) => entry === first ? merged : entry);
if (JSON.stringify(next) !== JSON.stringify(data[type])) {
data[type] = next;
this.save(data);
}
return merged;
},
add(type, item, { append = false } = {}) {
if (this.has(type, item)) {
this.reconcile(type, item);
return;
}
const data = this.all();
const group = data.groups[type].includes(item.group) ? item.group : FAVORITE_TYPES[type].inbox;
data[type][append ? "push" : "unshift"]({ ...item, group, addedAt: Date.now() });
this.save(data);
},
move(type, id, group, beforeId) {
const data = this.all();
const list = data[type];
const index = list.findIndex((item2) => sameFavorite(item2, id));
if (index < 0 || !data.groups[type].includes(group)) return;
const [item] = list.splice(index, 1);
item.group = group;
const before = beforeId == null ? -1 : list.findIndex((x) => sameFavorite(x, beforeId));
if (before >= 0) list.splice(before, 0, item);
else list.push(item);
this.save(data);
},
addGroup(type, name) {
const data = this.all();
name = name.trim();
if (!name || data.groups[type].includes(name) || INBOX_NAMES.has(name)) return false;
const groups = data.groups[type];
const inboxAt = groups.indexOf(FAVORITE_TYPES[type].inbox);
groups.splice(inboxAt < 0 ? groups.length : inboxAt, 0, name);
this.save(data);
return true;
},
renameGroup(type, from, to) {
const data = this.all();
to = to.trim();
const groups = data.groups[type];
if (!to || groups.includes(to) || INBOX_NAMES.has(to) || !groups.includes(from) || from === FAVORITE_TYPES[type].inbox) return false;
groups[groups.indexOf(from)] = to;
for (const item of data[type]) if (item.group === from) item.group = to;
this.save(data);
return true;
},
removeGroup(type, name) {
const data = this.all();
if (name === FAVORITE_TYPES[type].inbox) return;
data.groups[type] = data.groups[type].filter((g) => g !== name);
for (const item of data[type]) if (item.group === name) item.group = FAVORITE_TYPES[type].inbox;
this.save(data);
},
clearGroup(type, group) {
const data = this.all();
data[type] = data[type].filter((item) => item.group !== group);
this.save(data);
},
remove(type, id) {
const data = this.all();
data[type] = data[type].filter((item) => !sameFavorite(item, id));
this.save(data);
},
toggle(type, item) {
const existing = this.find(type, item);
if (existing) this.remove(type, existing.id);
else this.add(type, item);
return this.has(type, item);
},
clear() {
storage.remove(FAVORITES_KEY);
storage.remove("favorites-tab");
storage.remove("favorites-sort");
storage.remove("directory-sort");
this.version++;
for (const fn of this.listeners) fn();
},
save(data) {
storage.set(FAVORITES_KEY, data);
this.version++;
for (const fn of this.listeners) fn();
},
onChange(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
};
function keywordRoute(type, name) {
return { path: "/works", query: { keyword: `$${FAVORITE_TYPES[type].keyword}:${name}$` } };
}
/*! 历史按作品 workId 去重,保存该作品最后播放音轨的快照,最多保留 200 部作品;清除历史不删除进度。 */
var HISTORY_KEY = "history";
var HISTORY_LIMIT = 200;
var historyStore = {
deleteListeners: new Set(),
all() {
return storage.get(HISTORY_KEY, []);
},
touch(entry) {
const list = this.all().filter((item) => item.workId !== entry.workId);
list.unshift({ ...entry, updatedAt: Date.now() });
storage.set(HISTORY_KEY, list.slice(0, HISTORY_LIMIT));
},
remove(workId) {
storage.set(HISTORY_KEY, this.all().filter((item) => item.workId !== workId));
for (const fn of this.deleteListeners) fn(workId);
},
clear() {
storage.remove(HISTORY_KEY);
for (const fn of this.deleteListeners) fn(null);
},
onDelete(fn) {
this.deleteListeners.add(fn);
return () => this.deleteListeners.delete(fn);
}
};
/*! API 接入:优先采用网站配置,回退到当前域名的接口;目录、封面和历史续播共用此规则。 */
var API_URLS = {
latest: "https://api.asmr-200.com",
original: "https://api.asmr.one",
"mirror-100": "https://api.asmr-100.com",
"mirror-200": "https://api.asmr-200.com",
"mirror-300": "https://api.asmr-300.com"
};
var DEFAULT_API_BASE = API_URLS[SITE_API_KEYS[siteHost]];
/*! 此处的已知地址校验同时限制 JWT 的发送目标,不能直接信任任意 baseURL。 */
function getApiBase(vm) {
const baseURL = vm.$axios && vm.$axios.defaults && vm.$axios.defaults.baseURL;
if (typeof baseURL === "string" && Object.values(API_URLS).includes(baseURL.replace(/\/+$/, ""))) return baseURL.replace(/\/+$/, "");
const ls = vm.$q && vm.$q.localStorage;
const key = ls && ls.getItem("apiUrlKey");
return Object.hasOwn(API_URLS, key) ? API_URLS[key] : DEFAULT_API_BASE;
}
async function apiGet(vm, path, { fresh = false } = {}) {
const ls = vm.$q && vm.$q.localStorage;
const token = ls && ls.getItem("jwt-token");
const res = await fetch(`${getApiBase(vm)}/api/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
redirect: "error",
referrerPolicy: "no-referrer",
...fresh ? { cache: "no-store" } : {}
});
if (!res.ok) throw new Error(`API ${path} 返回 ${res.status}`);
return res.json();
}
/*! 播放进度仓库:按音轨 hash 保存 {t: 秒, d: 总时长秒, title, updatedAt: 毫秒},不负责删除历史。 */
var PROGRESS_KEY = "progress";
var SAVE_INTERVAL_MS = 5e3;
var END_MARGIN_SECONDS = 10;
var titleKey = (title) => String(title || "").replace(/\.[^./\\]+$/, "").normalize("NFKC").trim().toLowerCase();
var closeDuration = (a, b) => !a || !b || Math.abs(a - b) <= Math.max(1, Math.min(a, b) * 0.01);
/*! 不同音频格式可能有不同 hash。同一作品内用去扩展名的标题和接近的时长识别同一音轨。
* 旧记录没有标题时,仅接受唯一的时长匹配,避免把其他音轨的进度接过来。
*/
function progressSiblings(all, track) {
const workId = String(track.hash).split("/")[0];
const base = titleKey(track.title);
const candidates = Object.entries(all).filter(([hash]) => hash !== track.hash && hash.split("/")[0] === workId);
const byRecent = (entries) => entries.sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0)).map(([hash]) => hash);
const named = candidates.filter(([, entry]) => entry.title && base && titleKey(entry.title) === base && closeDuration(entry.d, track.duration));
if (named.length) return byRecent(named);
const legacy = candidates.filter(([, entry]) => !entry.title && track.duration > 0 && entry.d > 0 && Math.abs(entry.d - track.duration) <= 1);
return legacy.length === 1 ? byRecent(legacy) : [];
}
var progressStore = {
listeners: new Set(),
clearListeners: new Set(),
all() {
return storage.get(PROGRESS_KEY, {});
},
get(hash) {
return this.all()[hash];
},
find(track) {
const all = this.all();
if (all[track.hash]) return all[track.hash];
const [sibling] = progressSiblings(all, track);
return sibling ? all[sibling] : void 0;
},
save(hash, t, d, title) {
const all = this.all();
for (const sibling of progressSiblings(all, { hash, title, duration: d })) delete all[sibling];
all[hash] = { t, d, ...title ? { title } : {}, updatedAt: Date.now() };
storage.set(PROGRESS_KEY, all);
this.changed();
},
clear(hash, title, d) {
const all = this.all();
const keys = [hash, ...progressSiblings(all, { hash, title, duration: d })].filter((key) => key in all);
if (!keys.length) return false;
for (const key of keys) delete all[key];
storage.set(PROGRESS_KEY, all);
this.changed();
return true;
},
clearAll() {
storage.remove(PROGRESS_KEY);
for (const fn of this.clearListeners) fn();
this.changed();
},
onClear(fn) {
this.clearListeners.add(fn);
return () => this.clearListeners.delete(fn);
},
version: 0,
changed() {
this.version++;
for (const fn of this.listeners) fn();
},
onChange(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
};
function isFinished(t, d) {
return d > 0 && t >= d - Math.min(END_MARGIN_SECONDS, d * 0.05);
}
/*! 网站播放器状态适配:队列读取、作品标识和目录展开集中在这里。 */
function currentTrack(state) {
const player = state.AudioPlayer;
if (!player || !Array.isArray(player.queue)) return null;
const track = player.queue[player.queueIndex];
return track && track.hash ? track : null;
}
function trackWorkInfo(track) {
const work = track.work || {};
const workId = Number(work.id || String(track.hash).split("/")[0]);
const sourceId = work.source_id || `RJ${String(workId).padStart(workId >= 1e6 ? 8 : 6, "0")}`;
return { workId, sourceId, workTitle: track.workTitle || sourceId };
}
function playbackFiles(tree, audio = [], lyrics = []) {
for (const node of tree || []) {
if (node.type === "folder") playbackFiles(node.children, audio, lyrics);
else if (node.type === "audio") audio.push({ ...node, availableLyrics: [], selectedLyricIndex: -1 });
else if (node.type === "text" && /\.(lrc|vtt|srt|ass)$/i.test(node.title || "")) {
lyrics.push({ ...node, content: typeof node.content === "string" ? node.content : "" });
}
}
return { audio, lyrics };
}
/*! 异步续播请求的失效标记:切歌、清除或新的续播请求会递增,旧请求返回后不得覆盖新队列。 */
var resumeState = { requestId: 0 };
/*! 从历史恢复作品队列;真正的进度跳转由 progress 模块在收到时长后执行。 */
async function resumeFromHistory(vm, entry) {
const requestId = ++resumeState.requestId;
try {
const { audio, lyrics } = playbackFiles(await apiGet(vm, `tracks/${entry.workId}?v=2`));
if (requestId !== resumeState.requestId) return;
const index = audio.findIndex((track) => track.hash === entry.trackHash);
if (index < 0) throw new Error("作品里找不到这条音轨");
if (lyrics.length && !vm.$store._actions?.["AudioPlayer/pushMultiLyricToQueue"]) {
throw new Error("网站的字幕匹配接口不可用");
}
if (vm.$store._mutations && vm.$store._mutations["AudioPlayer/LOAD_PLAYER"]) vm.$store.commit("AudioPlayer/LOAD_PLAYER");
vm.$store.commit("AudioPlayer/SET_QUEUE", { queue: audio, index, resetPlaying: true });
if (lyrics.length) {
try {
await vm.$store.dispatch("AudioPlayer/pushMultiLyricToQueue", { lyrics });
} catch (err) {
console.error(LOG_PREFIX, err);
if (vm.$store.state.AudioPlayer.queue === audio) notify(vm, tr("subtitleMatchFailed"));
}
}
} catch (err) {
if (requestId !== resumeState.requestId) return;
console.error(LOG_PREFIX, err);
notify(vm, tr("resumeFailed"));
vm.$router.push(`/work/${entry.sourceId}`).catch(() => {
});
}
}
/*! 网站导航适配:复用原站侧栏样式,在现有 layout 下注册脚本页面,避免丢失播放器和导航框架。 */
function addNavItem({ id, label, iconSvg, href = null, onClick, isActive = () => false, onInsert = () => {
}, after = "/playlists" }) {
const doc = pageWindow.document;
const selectors = [].concat(after).map((a) => a.startsWith("/") ? `a[href$="${a}"]` : a);
const build = (template) => {
const item = doc.createElement(href ? "a" : "button");
for (const { name, value } of template.attributes) {
if (!["href", "role", "tabindex", "type", "id", "aria-current", "aria-disabled"].includes(name)) item.setAttribute(name, value);
}
for (const child of template.childNodes) item.append(child.cloneNode(true));
item.setAttribute("data-aop-nav", id);
if (href) item.setAttribute("href", href);
else {
item.setAttribute("type", "button");
Object.assign(item.style, { border: "0", width: "100%", background: "transparent", color: "inherit", textAlign: "inherit" });
}
for (const child of item.querySelectorAll("[tabindex]")) child.removeAttribute("tabindex");
item.style.cursor = "pointer";
for (const node of [item, ...item.querySelectorAll("*")]) {
for (const cls of [...node.classList]) if (/active/.test(cls)) node.classList.remove(cls);
}
for (const cls of activeClasses) item.classList.remove(cls);
const icon = item.querySelector(".q-icon");
if (icon) {
icon.removeAttribute("data-aop-native-icon");
icon.innerHTML = iconSvg;
}
setLabel(item);
item.addEventListener("click", (event) => {
if (href && (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)) return;
event.preventDefault();
event.stopPropagation();
onClick();
}, true);
item.addEventListener("keydown", (event) => {
if (["Enter", " "].includes(event.key)) event.stopPropagation();
}, true);
return item;
};
const setLabel = (item) => {
const icon = item.querySelector(".q-icon");
const name = label();
if (item.title !== name) item.title = name;
if (item.getAttribute("aria-label") !== name) item.setAttribute("aria-label", name);
const walker = doc.createTreeWalker(item, 4);
let replaced = false;
for (let text = walker.nextNode(); text; text = walker.nextNode()) {
if (!text.nodeValue.trim() || icon && icon.contains(text)) continue;
const value = replaced ? "" : name;
if (text.nodeValue !== value) text.nodeValue = value;
replaced = true;
}
};
const FALLBACK_ACTIVE = ["q-router-link--active", "q-router-link--exact-active", "text-deep-purple", "text-weight-medium"];
const isLinkActive = (a) => [...a.classList].some((cls) => /^q-router-link--(exact-)?active$/.test(cls));
let activeClasses = new Set(FALLBACK_ACTIVE);
let learned = false;
const learnActiveClasses = () => {
if (learned) return;
const links = [...doc.querySelectorAll("a.q-item")].filter((a) => !a.hasAttribute("data-aop-nav"));
const active = links.find(isLinkActive);
const inactive = links.find((a) => !isLinkActive(a));
if (!active || !inactive) return;
activeClasses = new Set([...active.classList].filter((cls) => !inactive.classList.contains(cls)));
learned = true;
};
const syncActive = (item) => {
learnActiveClasses();
const active = isActive();
if (active) item.setAttribute("aria-current", "page");
else item.removeAttribute("aria-current");
for (const cls of FALLBACK_ACTIVE) if (!activeClasses.has(cls)) item.classList.remove(cls);
for (const cls of activeClasses) item.classList.toggle(cls, active);
};
const ensure = () => {
const existing = doc.querySelector(`[data-aop-nav="${id}"]`);
if (existing) {
syncActive(existing);
setLabel(existing);
return true;
}
const anchor = selectors.map((sel) => doc.querySelector(sel)).find(Boolean);
if (!anchor) return false;
const template = anchor.closest(".q-item") || anchor;
const item = build(template);
syncActive(item);
template.after(item);
onInsert(item);
return true;
};
const found = ensure();
onLocaleChange(ensure);
let scheduled = false;
new MutationObserver(() => {
if (scheduled) return;
scheduled = true;
pageWindow.requestAnimationFrame(() => {
scheduled = false;
ensure();
});
}).observe(doc.body, { childList: true, subtree: true });
return found;
}
var ICONS = {
data: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 3C7.58 3 4 4.79 4 7s3.58 4 8 4 8-1.79 8-4-3.58-4-8-4zM4 10v4c0 2.21 3.58 4 8 4s8-1.79 8-4v-4c-1.72 1.4-4.65 2-8 2s-6.28-.6-8-2zm0 7v2c0 2.21 3.58 4 8 4s8-1.79 8-4v-2c-1.72 1.4-4.65 2-8 2s-6.28-.6-8-2z"/></svg>',
star: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>',
history: '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>'
};
function addPage(vm, { path, title, renderInto }) {
const router = vm.$router;
if (!router || typeof router.addRoute !== "function" || typeof router.getRoutes !== "function") return false;
const sibling = router.getRoutes().find((record) => record.path === "/works");
const layout = sibling && sibling.parent;
if (!layout || !layout.components || !layout.components.default) return false;
const page = {
name: `Aop${path.replace(/\W/g, "")}Page`,
render(h) {
return h("div", { class: "aop-page" });
},
mounted() {
this.previousTitle = pageWindow.document.title;
pageWindow.document.title = title();
this.stopLocale = onLocaleChange(() => {
pageWindow.document.title = title();
});
this.cleanup = renderInto(this.$el);
},
beforeDestroy() {
this.stopLocale();
pageWindow.document.title = this.previousTitle;
if (typeof this.cleanup === "function") this.cleanup();
}
};
router.addRoute({
path: layout.path || "/",
component: layout.components.default,
meta: layout.meta,
children: [{ path: path.replace(/^\//, ""), component: page, meta: layout.meta }]
});
const rematch = () => {
const loc = pageWindow.location;
const current = router.currentRoute;
if (loc.pathname !== path || !current.matched.some((record) => record.path === "*")) return;
router.replace({ path, query: current.query, hash: current.hash }).catch(() => {
});
};
if (typeof router.onReady === "function") router.onReady(rematch);
else rematch();
return true;
}
/*! 历史页面与实时进度展示。页面销毁时解除 Vuex/语言订阅,避免反复进入后重复监听。 */
var HISTORY_PATH = "/history";
var LIVE_MUTATIONS = [
"AudioPlayer/SET_CURRENT_TIME",
"AudioPlayer/SET_DURATION",
"AudioPlayer/ON_PLAY",
"AudioPlayer/ON_PAUSE",
"AudioPlayer/SET_TRACK",
"AudioPlayer/SET_QUEUE"
];
var historyView = {
mount(vm) {
GM_addStyle(`
.aop-fab { position: fixed; top: 9px; left: 50%; transform: translateX(-50%); z-index: 9999;
border: 1px solid rgba(255,255,255,.7); border-radius: 16px; padding: 4px 14px;
background: rgba(255,255,255,.15); color: #fff; font-size: 14px; cursor: pointer; }
.aop-panel { position: fixed; top: 56px; left: 50%; transform: translateX(-50%); z-index: 9999; width: 480px; max-width: calc(100vw - 32px);
max-height: 70vh; overflow-y: auto; background: #fff; color: #222; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.35); }
`);
this.vm = vm;
this.panel = null;
const hasPage = addPage(vm, { path: HISTORY_PATH, title: () => `${tr("history")} - ASMR Online`, renderInto: (container) => this.mountLive(container) });
const open = hasPage ? () => vm.$router.push(HISTORY_PATH).catch(() => {
}) : () => this.togglePanel();
const inNav = addNavItem({
id: "history",
label: () => tr("history"),
iconSvg: ICONS.history,
href: hasPage ? HISTORY_PATH : null,
onClick: open,
isActive: () => vm.$route && vm.$route.path === HISTORY_PATH,
after: ['[data-aop-nav="favorites"]', "/playlists"],
onInsert: () => {
const fab = pageWindow.document.querySelector(".aop-fab");
if (fab) fab.remove();
}
});
if (!inNav) {
setTimeout(() => {
if (!pageWindow.document.querySelector('[data-aop-nav="history"]')) {
const fab = el("button", { class: "aop-fab", onclick: open }, tr("history"));
onLocaleChange(() => {
fab.textContent = tr("history");
});
pageWindow.document.body.append(fab);
}
}, 5e3);
}
if (typeof GM_registerMenuCommand === "function") GM_registerMenuCommand(tr("history"), open);
},
togglePanel() {
if (this.panel) {
this.panelCleanup();
this.panel.remove();
this.panel = null;
} else {
this.panel = el("div", { class: "aop-panel aop-page" });
this.panelCleanup = this.mountLive(this.panel);
pageWindow.document.body.append(this.panel);
}
},
mountLive(container) {
this.container = container;
this.renderInto(container);
const stopLocale = onLocaleChange(() => this.renderInto(container));
let lastRenderAt = 0;
const stop = this.vm.$store.subscribe((mutation, state) => {
if (!LIVE_MUTATIONS.includes(mutation.type)) return;
const track = currentTrack(state);
if (!track) return;
const player = state.AudioPlayer;
const { workId } = trackWorkInfo(track);
const first = container.querySelector(".aop-row");
const row = container.querySelector(`.aop-row[data-work-id="${workId}"]`);
if (!row || row !== first) {
const saved = historyStore.all()[0];
if (saved && saved.workId === workId && (mutation.type !== "AudioPlayer/SET_CURRENT_TIME" || Date.now() - lastRenderAt > 1e3)) {
lastRenderAt = Date.now();
this.renderInto(container);
}
return;
}
this.patchRow(row, { trackTitle: track.title || "", t: player.currentTime, d: player.duration, playing: player.playing });
});
return () => {
stop();
stopLocale();
if (this.container === container) this.container = null;
};
},
patchRow(row, { trackTitle, t, d, playing }) {
const finished = isFinished(t, d);
row.querySelector(".aop-track").textContent = tr("trackLine", playing, trackTitle);
row.querySelector(".aop-track").title = trackTitle;
row.querySelector(".aop-fill").style.width = `${finished ? 100 : d ? Math.min(100, t / d * 100) : 0}%`;
row.querySelector(".aop-time").textContent = finished ? tr("finished") : d ? `${formatTime(t)} / ${formatTime(d)}` : formatTime(t);
row.querySelector(".aop-when").textContent = `${row.dataset.sourceId} · ${playing ? tr("nowPlaying") : formatAgo(Date.now())}`;
row.querySelector(".aop-resume").textContent = finished ? tr("replay") : tr("resume");
},
groupLabel(timestamp) {
const day = (t) => new Date(t).toDateString();
const now = Date.now();
if (day(timestamp) === day(now)) return tr("today");
if (day(timestamp) === day(now - 864e5)) return tr("yesterday");
return tr("earlier");
},
renderInto(container) {
const vm = this.vm;
const rerender = () => this.renderInto(container);
const openWork = (entry) => vm.$router.push(`/work/${entry.sourceId}`);
const entries = historyStore.all();
const row = (entry) => {
const finished = isFinished(entry.t, entry.d);
const percent = entry.d ? Math.min(100, entry.t / entry.d * 100) : 0;
return el("div", { class: "aop-row", "data-work-id": String(entry.workId), "data-source-id": entry.sourceId }, [
el("img", { class: "aop-cover", src: `${getApiBase(vm)}/api/cover/${entry.workId}.jpg?type=240x240`, loading: "lazy", onclick: () => openWork(entry) }),
el("div", { class: "aop-info" }, [
el("div", { class: "aop-title", title: entry.workTitle, onclick: () => openWork(entry) }, entry.workTitle),
el("div", { class: "aop-sub aop-when" }, `${entry.sourceId} · ${formatAgo(entry.updatedAt)}`),
el("div", { class: "aop-sub aop-track", title: entry.trackTitle }, tr("trackLine", false, entry.trackTitle)),
el("div", { class: "aop-progress" }, [
el("div", { class: "aop-bar" }, el("div", { class: "aop-fill", style: `width: ${finished ? 100 : percent}%` })),
el("span", { class: "aop-time" }, finished ? tr("finished") : entry.d ? `${formatTime(entry.t)} / ${formatTime(entry.d)}` : formatTime(entry.t))
])
]),
el("div", { class: "aop-actions" }, [
el("button", { class: "aop-btn aop-resume", onclick: () => {
const track = currentTrack(vm.$store.state);
if (track && trackWorkInfo(track).workId === entry.workId) {
return resumeFromHistory(vm, { ...trackWorkInfo(track), trackHash: track.hash });
}
const latest = historyStore.all().find((item) => item.workId === entry.workId);
if (latest) return resumeFromHistory(vm, latest);
} }, finished ? tr("replay") : tr("resume")),
el("button", { class: "aop-btn aop-flat", title: tr("removeFromHistory"), onclick: () => {
if (!pageWindow.confirm(tr("confirmRemoveHistory"))) return;
historyStore.remove(entry.workId);
rerender();
} }, tr("remove"))
])
]);
};
const body = [];
let currentGroup = null;
let card = null;
for (const entry of entries) {
const label = this.groupLabel(entry.updatedAt);
if (label !== currentGroup) {
currentGroup = label;
card = el("div", { class: "aop-card" });
body.push(el("div", { class: "aop-date-group" }, label), card);
}
card.append(row(entry));
}
container.replaceChildren(
el("div", { class: "aop-head" }, [
el("h1", {}, tr("history")),
el("span", { class: "aop-count" }, tr("workCount", entries.length)),
el("span", { class: "aop-spacer" }),
entries.length ? el("button", { class: "aop-btn aop-flat", onclick: () => {
if (pageWindow.confirm(tr("confirmClearHistory"))) {
historyStore.clear();
rerender();
}
} }, tr("clearHistory")) : null,
this.panel ? el("button", { class: "aop-btn aop-flat", onclick: () => this.togglePanel() }, tr("close")) : null
]),
...body.length ? body : [el("div", { class: "aop-card" }, el("div", { class: "aop-empty" }, tr("historyEmpty")))]
);
}
};
/*! 进度清除的用户操作层:负责确认、当前音轨重置和格式关联清除,不删除历史或关注。 */
var progressActions = {
matchesCurrent(vm, file) {
const player = vm.$store.state.AudioPlayer;
const track = currentTrack(vm.$store.state);
if (!track) return false;
if (track.hash === file.hash) return true;
return progressSiblings({ [track.hash]: { title: track.title, d: player.duration || track.duration || 0, updatedAt: 0 } }, file).includes(track.hash);
},
clearTrack(vm, file) {
if (!file?.hash) return;
const notice = this.matchesCurrent(vm, file) ? `
${tr("resetCurrentNotice")}` : "";
if (!pageWindow.confirm(tr("confirmClearTrackProgress") + notice)) return;
const reset = progressFeature.resetCurrent(file);
const changed = progressStore.clear(file.hash, file.title, file.duration);
if (reset && !changed) progressStore.changed();
},
clearAll(vm) {
const notice = currentTrack(vm.$store.state) ? `
${tr("resetCurrentNotice")}` : "";
if (!pageWindow.confirm(tr("confirmClearProgress") + notice)) return;
progressStore.clearAll();
},
trackButton(vm, file) {
const button = el("button", {
type: "button",
class: "aop-track-clear",
onclick: (event) => {
event.stopPropagation();
this.clearTrack(vm, button.aopFile);
},
oncontextmenu: (event) => event.stopPropagation(),
onkeydown: (event) => event.stopPropagation()
});
this.updateTrackButton(button, file);
return button;
},
updateTrackButton(button, file) {
button.aopFile = file;
const label = `${tr("clearTrackProgress")}:${file.title || file.hash}`;
if (button.getAttribute("aria-label") !== label) {
button.setAttribute("aria-label", label);
button.title = label;
}
const text = tr("clearTrackProgressButton");
if (button.textContent !== text) button.textContent = text;
}
};
/*! 网站 AudioElement 适配:定位原生播放器,并抑制清除进度后由 canplay 引起的自动播放。 */
var guardedComponents = new WeakSet();
function findAudioComponent(vm) {
const nodes = [vm.$root || vm], seen = new Set();
while (nodes.length) {
const component = nodes.pop();
if (!component || seen.has(component) || component._isDestroyed) continue;
seen.add(component);
if (component.$options?.name === "AudioElement") return component;
nodes.push(...component.$children || []);
}
return null;
}
function guardResetAutoplay(component, shouldStayPaused) {
if (!component || guardedComponents.has(component) || typeof component.onCanplay !== "function") return;
const onCanplay = component.onCanplay;
component.onCanplay = function(...args) {
if (!shouldStayPaused() || !this.player) return onCanplay.apply(this, args);
/*! 网站 onCanplay 会主动调用 player.play()。只在这次同步回调内屏蔽 play,
* 保留原回调的时长、媒体信息更新,并在 finally 中恢复,避免阻断之后的手动播放。
* 若网站将播放改成异步调用,需要重新检查这段拦截。
*/
const player = this.player, play = player.play;
player.play = () => Promise.resolve();
try {
return onCanplay.apply(this, args);
} finally {
player.play = play;
}
};
guardedComponents.add(component);
}
/*! 字幕适配:补载所选字幕、识别文本编码,并在续播/跳转后同步网站的 LRC 解析器。 */
function decodeSubtitle(bytes, charset) {
const decode = (encoding) => {
try {
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
} catch (err) {
return null;
}
};
if (bytes[0] === 255 && bytes[1] === 254) return new TextDecoder("utf-16le", { fatal: true }).decode(bytes);
if (bytes[0] === 254 && bytes[1] === 255) return new TextDecoder("utf-16be", { fatal: true }).decode(bytes);
if (charset) return new TextDecoder(charset, { fatal: true }).decode(bytes);
const utf8 = decode("utf-8");
if (utf8 != null) return utf8;
const sjis = decode("shift_jis");
if (sjis != null && !/[\uFF61-\uFF9F]/.test(sjis)) return sjis;
const gb = decode("gb18030");
if (gb != null) return gb;
if (sjis != null) return sjis;
throw new Error("无法识别字幕编码");
}
function createSubtitleSync(vm) {
let component = null, parser = null, content = null, hash = null, time = null;
let scheduled = false, force = false;
let pending = null, failed = null;
const loadSelected = (retry) => {
const store = vm.$store, state = store.state;
const queue = state.AudioPlayer.queue, index = state.AudioPlayer.queueIndex;
const track = queue[index], lyricIndex = track?.selectedLyricIndex;
const lyric = track?.availableLyrics?.[lyricIndex];
if (pending && (pending.track !== track || pending.lyric !== lyric || state.Settings?.enableSubtitle === false)) {
pending.controller.abort();
pending = null;
}
if (state.Settings?.enableSubtitle === false || !lyric || lyric.content || !lyric.mediaDownloadUrl || pending) return;
if (failed === lyric && !retry) return;
const controller = new AbortController();
const request = { track, lyric, controller };
pending = request;
(async () => {
try {
const url = new URL(lyric.mediaDownloadUrl);
if (url.protocol !== "https:" || url.username || url.password) throw new Error("不支持的字幕地址");
const response = await fetch(url.href, { credentials: "omit", redirect: "error", referrerPolicy: "no-referrer", signal: controller.signal });
if (!response.ok) throw new Error("字幕请求失败");
const buffer = await response.arrayBuffer(), bytes = new Uint8Array(buffer);
const charset = response.headers?.get("content-type")?.match(/charset\s*=\s*["']?([^;\s"']+)/i)?.[1];
const text = decodeSubtitle(bytes, charset);
if (controller.signal.aborted || state.Settings?.enableSubtitle === false || !text || state.AudioPlayer.queue !== queue || queue[index] !== track || state.AudioPlayer.queueIndex !== index || track.availableLyrics?.[lyricIndex] !== lyric || track.selectedLyricIndex !== lyricIndex) return;
store.commit("AudioPlayer/SET_LYRIC_CONTENT", { content: text, forQueueIndex: index, forSelectedLyricIndex: lyricIndex });
failed = null;
} catch (err) {
if (controller.signal.aborted) return;
const firstFailure = failed !== lyric;
failed = lyric;
console.error(LOG_PREFIX, "字幕正文加载失败");
if (firstFailure && currentTrack(state) === track) notify(vm, tr("subtitleLoadFailed"));
} finally {
if (pending === request) pending = null;
}
})();
};
const find = () => {
if (component && !component._isDestroyed) return component;
const nodes = [vm.$root || vm], seen = new Set();
while (nodes.length) {
const node = nodes.pop();
if (!node || seen.has(node) || node._isDestroyed) continue;
seen.add(node);
if (node.$options?.name === "AudioElement") return component = node;
nodes.push(...node.$children || []);
}
return null;
};
return (type) => {
if (!["SET_CURRENT_TIME", "SET_LYRIC_CONTENT", "SET_LYRIC_INDEX", "SET_LYRIC_INDEX_AUTO", "SET_QUEUE", "SET_TRACK", "NEXT_TRACK", "PREVIOUS_TRACK", "EMPTY_QUEUE", "REMOVE_FROM_QUEUE", "ON_PLAY", "ON_PAUSE", "WANT_SEEK", "SUBTITLE_SETTING"].includes(type)) return;
force ||= type !== "SET_CURRENT_TIME";
if (scheduled) return;
scheduled = true;
const run = () => {
scheduled = false;
const mustSync = force;
force = false;
try {
loadSelected(mustSync);
const audio = find();
if (!audio?.lrcObj || typeof audio.updateLyricPos !== "function") return;
const text = audio.lyricContent || "";
const trackHash = currentTrack(vm.$store.state)?.hash;
const changed = parser !== audio.lrcObj || content !== text || hash !== trackHash;
if (changed) {
parser = audio.lrcObj;
content = text;
hash = trackHash;
time = null;
if (text) parser.setLyric(text);
}
if (!text) return;
const currentTime = audio.player?.currentTime;
if (!Number.isFinite(currentTime) || currentTime < 0) return;
if (changed || mustSync || time == null || Math.abs(currentTime - time) > 1.5) audio.updateLyricPos(currentTime);
time = currentTime;
} catch (err) {
console.error(LOG_PREFIX, "字幕同步失败", err);
}
};
if (typeof vm.$nextTick === "function") vm.$nextTick(run);
else Promise.resolve().then(run);
};
}
/*! 播放事件协调:实时位置先留在内存,定时或在暂停、切歌、离开页面时写入;界面可实时读取网站状态。 */
/*! 历史与进度分开:开始播放即可记录历史;正数位置即可保存,无需先播放满 5 秒;听完则移除续播进度。 */
function recordPosition(track, t, d) {
if (!Number.isFinite(t) || t < 0) return;
d = Number.isFinite(d) && d > 0 ? d : 0;
if (isFinished(t, d)) progressStore.clear(track.hash, track.title, d);
else if (t > 0) progressStore.save(track.hash, t, d, track.title);
historyStore.touch({ ...trackWorkInfo(track), trackHash: track.hash, trackTitle: track.title || "", t, d });
}
var progressFeature = {
name: "progress",
clearedHash: null,
init(vm) {
const store = vm.$store;
const syncSubtitles = createSubtitleSync(vm);
let lastSaveAt = 0;
let resumedHash = null;
let lastKnown = null;
let forgottenHash = null;
let recorded = null;
let resetting = false;
let resetMedia = null;
let observedHash = currentTrack(store.state)?.hash;
let positionReady = true;
const record = (snapshot) => {
if (recorded && recorded.hash === snapshot.track.hash && recorded.t === snapshot.t) return;
recordPosition(snapshot.track, snapshot.t, snapshot.d);
recorded = { hash: snapshot.track.hash, t: snapshot.t };
};
historyStore.onDelete((workId) => {
resumeState.requestId++;
const track = currentTrack(store.state);
if (workId == null || track && trackWorkInfo(track).workId === workId) {
lastKnown = null;
recorded = null;
forgottenHash = track && track.hash;
}
});
const saveNow = () => {
if (lastKnown) record(lastKnown);
lastSaveAt = Date.now();
};
this.invalidate = () => {
resumeState.requestId++;
lastKnown = null;
recorded = null;
forgottenHash = currentTrack(store.state)?.hash;
this.clearedHash = null;
};
/*! 清除当前音轨必须同时处理原生 audio 与 Vuex 状态,并让保存快照失效。
* 重置期间忽略同步事件;重置后由 clearedHash 阻止迟到的自动播放,直到用户明确请求播放。
*/
this.resetCurrent = (target = null) => {
const track = currentTrack(store.state);
if (target && !progressActions.matchesCurrent(vm, target)) return false;
this.invalidate();
if (!track) return false;
this.clearedHash = track.hash;
resumedHash = track.hash;
const component = findAudioComponent(vm);
guardResetAutoplay(component, () => this.clearedHash === currentTrack(store.state)?.hash);
let media = component?.player?.media || component?.$el?.querySelector?.("audio");
if (!media) {
const audio = pageWindow.document.querySelectorAll("audio");
if (audio.length === 1) media = audio[0];
}
resetMedia = media;
resetting = true;
try {
store.commit("AudioPlayer/WANT_PAUSE");
if (media && typeof media.pause === "function") media.pause();
if (media) {
try {
media.currentTime = 0;
} catch {
}
}
store.commit("AudioPlayer/SET_CURRENT_TIME", 0);
store.commit("AudioPlayer/ON_PAUSE");
} finally {
resetting = false;
}
return true;
};
progressStore.onClear(() => this.resetCurrent());
store.subscribe((mutation, state) => {
if (resetting) return;
if (mutation.type.startsWith("AudioPlayer/")) syncSubtitles(mutation.type.slice("AudioPlayer/".length));
else if (mutation.type === "Settings/UPDATE_SETTING" && mutation.payload?.key === "enableSubtitle") syncSubtitles("SUBTITLE_SETTING");
const trackMutation = ["SET_QUEUE", "SET_TRACK", "NEXT_TRACK", "PREVIOUS_TRACK", "EMPTY_QUEUE", "REMOVE_FROM_QUEUE"].some((type) => mutation.type === `AudioPlayer/${type}`);
if (trackMutation) resumeState.requestId++;
const track = currentTrack(state);
/*! Vuex subscribe 在 mutation 执行后通知;此时队列已变化,要立即保存旧快照而不是读取新队列的时间。 */
if (observedHash !== track?.hash) {
if (lastKnown) record(lastKnown);
lastKnown = null;
observedHash = track?.hash;
resumedHash = null;
positionReady = false;
lastSaveAt = Date.now();
}
if (!track) return;
const player = state.AudioPlayer;
if (this.clearedHash === track.hash) {
const playRequested = mutation.type === "AudioPlayer/WANT_PLAY" || mutation.type === "AudioPlayer/TOGGLE_WANT_PLAYING" && !player.playing || mutation.type === "AudioPlayer/SET_TRACK" || mutation.type === "AudioPlayer/SET_QUEUE" && mutation.payload?.resetPlaying;
if (playRequested) this.clearedHash = null;
else if (mutation.type === "AudioPlayer/ON_PLAY") {
if (resetMedia && typeof resetMedia.pause === "function") resetMedia.pause();
store.commit("AudioPlayer/WANT_PAUSE");
store.commit("AudioPlayer/ON_PAUSE");
return;
}
}
if (mutation.type === "AudioPlayer/ON_PLAY") {
forgottenHash = null;
this.clearedHash = null;
lastKnown = {
track,
t: positionReady && Number.isFinite(player.currentTime) ? Math.max(0, player.currentTime) : 0,
d: positionReady ? player.duration : track.duration
};
record(lastKnown);
lastSaveAt = Date.now();
}
if (mutation.type === "AudioPlayer/SET_CURRENT_TIME") {
if (track.hash === forgottenHash && !player.playing) return;
forgottenHash = null;
this.clearedHash = null;
positionReady = true;
lastKnown = { track, t: player.currentTime, d: player.duration };
}
if (mutation.type === "AudioPlayer/SET_DURATION") {
if (resumedHash === track.hash) return;
resumedHash = track.hash;
const saved = progressStore.find({ hash: track.hash, title: track.title, duration: player.duration });
if (saved && saved.t > 0 && !isFinished(saved.t, player.duration)) {
store.commit("AudioPlayer/WANT_SEEK", saved.t);
}
} else if (mutation.type === "AudioPlayer/SET_CURRENT_TIME") {
if (Date.now() - lastSaveAt >= SAVE_INTERVAL_MS) saveNow();
} else if (mutation.type === "AudioPlayer/ON_PAUSE") {
saveNow();
} else if (trackMutation) {
resumedHash = null;
}
});
pageWindow.addEventListener("pagehide", saveNow);
pageWindow.document.addEventListener("visibilitychange", () => {
if (pageWindow.document.visibilityState === "hidden") saveNow();
});
historyView.mount(vm);
trackHints.mount(vm);
}
};
var trackHints = {
mount(vm) {
GM_addStyle(`
.aop-track-hint { flex: none; display: flex; align-items: center; gap: 10px; margin-left: 16px;
font-size: 12px; white-space: nowrap; }
.aop-track-hint .aop-bar { flex: none; width: 120px; height: 4px; }
.aop-track-clear { flex: none; min-height: 32px; padding: 5px 10px; border: 1px solid #b0bec5;
border-radius: 4px; background: transparent; color: #455a64; font: inherit; font-weight: 500; cursor: pointer; }
.aop-track-clear:hover { background: rgba(128,128,128,.15); }
.aop-track-clear:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.body--dark .aop-track-clear { color: #eceff1; border-color: #78909c; }
.q-item.bg-teal .aop-track-clear { color: #fff; border-color: rgba(255,255,255,.7); }
.q-item.bg-teal .aop-track-hint .aop-fill { background: #fff; }
@media (max-width: 600px) {
.aop-track-hint { gap: 8px; margin-left: 8px; }
.aop-track-hint .aop-bar { display: none; }
.aop-track-clear { padding: 5px 8px; }
}
`);
this.vm = vm;
let scheduled = false;
const run = () => {
if (scheduled) return;
scheduled = true;
pageWindow.requestAnimationFrame(() => {
scheduled = false;
try {
this.ensure();
} catch (err) {
console.error(LOG_PREFIX, "音轨进度提示", err);
}
});
};
new MutationObserver(run).observe(pageWindow.document.body, { childList: true, subtree: true });
progressStore.onChange(run);
onLocaleChange(run);
vm.$store.subscribe((mutation) => {
if (LIVE_MUTATIONS.includes(mutation.type)) run();
});
run();
},
// 当前音轨直接用实时状态;其他音轨使用持久化进度,因此 UI 更新不依赖 5 秒写盘。
positionOf(file) {
const player = this.vm && this.vm.$store.state.AudioPlayer;
if (progressFeature.clearedHash === file.hash && !player?.playing) return null;
const playing = player && currentTrack(this.vm.$store.state);
if (playing && playing.hash === file.hash && player.currentTime > 0) {
return { t: player.currentTime, d: player.duration || file.duration || 0 };
}
if (!this.saved || this.savedVersion !== progressStore.version) {
this.saved = progressStore.all();
this.savedVersion = progressStore.version;
}
const all = this.saved;
return all[file.hash] || all[progressSiblings(all, file)[0]] || null;
},
ensure() {
const root = pageWindow.document.querySelector("#work-tree");
let tree = null;
for (let comp = root && root.__vue__; comp && comp.$el === root; comp = comp.$parent) {
if (comp.$options && comp.$options.name === "WorkTree") tree = comp;
}
if (!tree || !Array.isArray(tree.currentFolder)) return;
for (const row of root.querySelectorAll(".q-item")) {
let index = null;
for (let comp = row.__vue__; comp && comp.$el === row; comp = comp.$parent) {
if (typeof comp.$vnode?.key === "number") index = comp.$vnode.key;
}
const file = index == null ? null : tree.currentFolder[index];
let hint = [...row.children].find((child) => child.classList.contains("aop-track-hint"));
let saved = null;
if (file && file.type === "audio" && file.hash) {
saved = this.positionOf(file);
}
if (!saved) {
if (hint) hint.remove();
continue;
}
if (!hint) {
hint = el("div", { class: "aop-track-hint" }, [
el("div", { class: "aop-bar" }, el("div", { class: "aop-fill" })),
el("span", { class: "aop-track-hint-text" }),
progressActions.trackButton(this.vm, file)
]);
row.append(hint);
}
progressActions.updateTrackButton(hint.querySelector(".aop-track-clear"), file);
const d = saved.d || file.duration || 0;
const width = `${d ? Math.min(100, saved.t / d * 100) : 0}%`;
const text = tr("listenedTo", formatTime(saved.t));
const label = hint.querySelector(".aop-track-hint-text");
if (label.textContent !== text) label.textContent = text;
const fill = hint.querySelector(".aop-fill");
if (fill.style.width !== width) fill.style.width = width;
}
}
};
/*! 备份边界:仅处理脚本本地数据,不导出网站登录令牌;导入采用校验、合并、写入和失败回滚。 */
var backupFeature = {
name: "backup",
read() {
return this.clean({ history: historyStore.all(), progress: progressStore.all(), favorites: favoritesStore.all(), favoritesTab: storage.get("favorites-tab", "va") }, { lenient: true });
},
skipped: 0,
/*! 读取本地数据时允许跳过坏记录,导入文件则严格校验;返回字段白名单,避免直接保存任意输入对象。 */
clean(data, { lenient = false } = {}) {
let skipped = 0;
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
const list = (value, max) => {
if (lenient && value == null) return [];
if (!Array.isArray(value) || !lenient && value.length > max) throw new Error(tr("backupBadList"));
return lenient ? value.slice(0, max) : value;
};
const each = (values, fn) => values.flatMap((value) => {
if (!lenient) return [fn(value)];
try {
return [fn(value)];
} catch {
skipped++;
return [];
}
});
const text = (value, max = 2048, empty = false) => {
if (typeof value !== "string" || !empty && !value.trim() || value.length > max) throw new Error(tr("backupBadText"));
return value;
};
const number = (value = 0) => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
if (lenient) return 0;
throw new Error(tr("backupBadNumber"));
}
return value;
};
const hash = (value) => {
if (!/^\d+\/[^\s]{1,512}$/.test(text(value, 530))) throw new Error(tr("backupBadTrack"));
return value;
};
if (lenient && object(data)) {
data = { ...data, favorites: object(data.favorites) ? data.favorites : {}, progress: object(data.progress) ? data.progress : {} };
}
if (!object(data) || !object(data.favorites) || !object(data.progress)) throw new Error(tr("backupBadStructure"));
const history = each(list(data.history, 5e3), (item) => {
if (!object(item) || !Number.isSafeInteger(item.workId) || item.workId <= 0) throw new Error(tr("backupBadWork"));
const sourceId = text(item.sourceId, 80);
if (!/^[a-zA-Z0-9_-]+$/.test(sourceId)) throw new Error(tr("backupBadSource"));
const trackHash = hash(item.trackHash);
if (Number(trackHash.split("/")[0]) !== item.workId) throw new Error(tr("backupTrackMismatch"));
return { workId: item.workId, sourceId, workTitle: text(item.workTitle), trackHash, trackTitle: text(item.trackTitle, 2048, true), t: number(item.t), d: number(item.d), updatedAt: number(item.updatedAt) };
});
let positions = Object.entries(data.progress);
if (positions.length > 5e4) {
if (!lenient) throw new Error(tr("backupTooManyProgress"));
positions = positions.sort(([, a], [, b]) => (b?.updatedAt || 0) - (a?.updatedAt || 0)).slice(0, 5e4);
}
const progress = Object.fromEntries(each(positions, ([key, item]) => {
if (!object(item)) throw new Error(tr("backupBadProgress"));
const title = typeof item.title === "string" && item.title.trim() && item.title.length <= 2048 ? { title: item.title } : {};
return [hash(key), { t: number(item.t), d: number(item.d), ...title, updatedAt: number(item.updatedAt) }];
}));
const favorites = { groups: {} };
for (const [type, { inbox }] of Object.entries(FAVORITE_TYPES)) {
const names = each(list(data.favorites.groups?.[type], 1e3), (value) => text(value, 256));
favorites.groups[type] = [...new Set([...names.map((name) => name === "未分组" ? inbox : name), inbox])];
favorites[type] = each(list(data.favorites[type], 2e4), (item) => {
if (!object(item)) throw new Error(tr("backupBadFollow"));
const id = typeof item.id === "number" ? String(number(item.id)) : text(item.id, 512);
const group = item.group === "未分组" ? inbox : item.group;
const result = { id, name: text(item.name, 512), group: favorites.groups[type].includes(group) ? group : inbox, addedAt: number(item.addedAt) };
if (item.siteId != null) result.siteId = typeof item.siteId === "number" ? number(item.siteId) : text(item.siteId, 512);
if (item.aliases != null) result.aliases = [...new Set(each(list(item.aliases, 1e3), (value) => text(value, 512)))];
return result;
});
}
this.skipped = skipped;
return { history, progress, favorites, favoritesTab: Object.hasOwn(FAVORITE_TYPES, data.favoritesTab) ? data.favoritesTab : "va" };
},
// version 是备份格式版本,scriptVersion 才是脚本版本;两者不要一起随发布递增。
make() {
return { format: "asmr-one-plus-backup", version: 1, scriptVersion: SCRIPT_VERSION, exportedAt: (new Date()).toISOString(), data: this.read() };
},
parse(text) {
if (typeof text !== "string" || text.length > 10 * 1024 * 1024) throw new Error(tr("backupInvalidFile"));
let file;
try {
file = JSON.parse(text);
} catch {
throw new Error(tr("backupNotJson"));
}
if (file?.format !== "asmr-one-plus-backup" || file.version !== 1) throw new Error(tr("backupUnsupported"));
return this.clean(file.data);
},
/*! 合并不覆盖已有关注的分组;历史按作品、进度按 hash 取较新记录,已有数据时保留本地选中的标签页。 */
merge(incoming, current = this.read()) {
const hadLocal = current.history.length || Object.keys(current.progress).length || Object.keys(FAVORITE_TYPES).some((type) => current.favorites[type].length || current.favorites.groups[type].length > 1);
const favorites = current.favorites;
for (const type of Object.keys(FAVORITE_TYPES)) {
favorites.groups[type] = [...new Set([...favorites.groups[type], ...incoming.favorites.groups[type]])];
for (const item of incoming.favorites[type]) {
const existing = favorites[type].find((entry) => sameFavorite(entry, item));
if (!existing) favorites[type].push(item);
else {
if (existing.siteId == null && item.siteId != null) existing.siteId = item.siteId;
existing.aliases = [...new Set([...favoriteNames(existing), ...favoriteNames(item)])];
}
}
}
const byWork = new Map();
for (const item of [...incoming.history, ...current.history]) if (!byWork.has(item.workId) || item.updatedAt >= byWork.get(item.workId).updatedAt) byWork.set(item.workId, item);
const history = [...byWork.values()].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, HISTORY_LIMIT);
const progress = current.progress;
for (const [key, value] of Object.entries(incoming.progress)) if (!progress[key] || value.updatedAt > progress[key].updatedAt) progress[key] = value;
for (const entry of history) if (isFinished(entry.t, entry.d) && progress[entry.trackHash] && entry.updatedAt >= progress[entry.trackHash].updatedAt) delete progress[entry.trackHash];
return { favorites, history, progress, favoritesTab: hadLocal ? current.favoritesTab : incoming.favoritesTab };
},
apply(incoming) {
const keys = ["history", "progress", "favorites", "favorites-tab"];
const before = Object.fromEntries(keys.map((key) => [key, storage.get(key, void 0)]));
const next = this.merge(this.clean(incoming), this.read());
try {
storage.set("history", next.history);
storage.set("progress", next.progress);
storage.set("favorites", { ...before.favorites, ...next.favorites, groups: { ...before.favorites?.groups, ...next.favorites.groups } });
storage.set("favorites-tab", next.favoritesTab);
} catch {
try {
for (const key of keys) {
if (before[key] === void 0) storage.remove(key);
else storage.set(key, before[key]);
}
} catch {
throw new Error(tr("storageFailed"));
}
throw new Error(tr("importRolledBack"));
}
progressFeature.invalidate?.();
progressStore.changed();
for (const fn of favoritesStore.listeners) fn();
if (historyView.container?.isConnected) historyView.renderInto(historyView.container);
},
download(vm) {
try {
const json = JSON.stringify(this.make(), null, 2);
if (this.skipped) notify(vm, tr("exportSkipped", this.skipped));
const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
const link = el("a", { href: url, download: `asmr-one-plus-data-${(new Date()).toISOString().replace(/[:.]/g, "-")}.json` });
pageWindow.document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1e3);
} catch {
notify(vm, tr("exportFailed"));
}
},
choose(vm) {
const input = el("input", { type: "file", accept: ".json,application/json", style: "display:none", "data-aop-backup-file": "" });
input.addEventListener("cancel", () => input.remove());
input.addEventListener("change", async () => {
try {
const file = input.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) throw new Error(tr("backupTooLarge"));
const data = this.parse(await file.text());
const count = Object.keys(FAVORITE_TYPES).reduce((sum, type) => sum + data.favorites[type].length, 0);
this.read();
if (!pageWindow.confirm(tr("importConfirm", count, data.history.length, Object.keys(data.progress).length, this.skipped))) return;
this.apply(data);
notify(vm, tr("importDone"));
} catch (err) {
notify(vm, err.message || tr("importFailed"));
} finally {
input.remove();
}
});
pageWindow.document.body.append(input);
input.click();
},
buttons(vm) {
const menu = el("details", { class: "aop-backup" });
if (!this.outsideCloser) {
this.outsideCloser = (event) => {
for (const open of pageWindow.document.querySelectorAll("details.aop-backup[open]")) if (!open.contains(event.target)) open.open = false;
};
pageWindow.document.addEventListener("click", this.outsideCloser, true);
}
const pick = (action) => () => {
menu.open = false;
action();
};
menu.append(
el("summary", { class: "aop-btn aop-data-button" }, tr("backup")),
el("div", { class: "aop-backup-menu" }, [
el("button", { class: "aop-btn aop-flat", "data-aop-backup": "export", onclick: pick(() => this.download(vm)) }, tr("exportBackup")),
el("button", { class: "aop-btn aop-flat", "data-aop-backup": "import", onclick: pick(() => this.choose(vm)) }, tr("importBackup"))
])
);
return [menu];
}
};
/*! 数据管理独立页面:备份的唯一界面入口,并提供全部进度/全部脚本数据的清除操作。 */
var DATA_PATH = "/script-data";
var dataManagementFeature = {
name: "data-management",
init(vm) {
this.vm = vm;
GM_addStyle(`
.aop-data-card { overflow: visible; }
.aop-data-row { display: flex; align-items: center; justify-content: space-between; gap: 20px;
padding: 20px; border-bottom: 1px solid rgba(128,128,128,.2); }
.aop-data-row:last-child { border-bottom: none; }
.aop-data-hint { margin: 0; font-size: 14px; line-height: 1.6; }
.aop-data-actions { display: flex; flex: none; flex-wrap: wrap; align-items: center; gap: 12px; max-width: 100%; }
.aop-data-actions .aop-backup-menu { left: auto; right: 0; max-width: calc(100vw - 56px); }
.aop-data-actions .aop-data-button { display: flex; align-items: center; justify-content: center;
box-sizing: border-box; width: 180px; max-width: 100%; min-height: 36px; padding: 6px 14px;
font-size: 13px; line-height: 22px; background: transparent; color: inherit; border: 1px solid rgba(128,128,128,.5); }
.aop-data-actions .aop-data-danger { color: #c10015; border-color: currentColor; }
.body--dark .aop-data-actions .aop-data-danger { color: #ff8a80; }
.aop-data-actions .aop-data-button:hover { background: rgba(128,128,128,.1); }
.aop-data-actions .aop-data-button:focus-visible { outline: 2px solid #1976d2; outline-offset: 2px; }
@media (max-width: 700px) {
.aop-data-row { flex-direction: column; align-items: flex-start; gap: 12px; padding: 16px; }
.aop-data-actions .aop-backup-menu { left: 0; right: auto; }
}
`);
const hasPage = addPage(vm, {
path: DATA_PATH,
title: () => `${tr("dataManagement")} - ASMR Online`,
renderInto: (container) => this.mountLive(container)
});
if (!hasPage) return;
addNavItem({
id: "data",
label: () => tr("dataManagement"),
iconSvg: ICONS.data,
href: DATA_PATH,
onClick: () => vm.$router.push(DATA_PATH).catch(() => {
}),
isActive: () => vm.$route?.path === DATA_PATH,
after: '[data-aop-nav="history"]'
});
},
mountLive(container) {
this.renderInto(container);
return onLocaleChange(() => this.renderInto(container));
},
renderInto(container) {
const row = (hint, actions) => el("div", { class: "aop-data-row" }, [
el("p", { class: "aop-data-hint" }, hint),
el("div", { class: "aop-data-actions" }, actions)
]);
container.replaceChildren(
el("div", { class: "aop-head" }, el("h1", {}, tr("dataManagement"))),
el("section", { class: "aop-card aop-data-card", "data-aop-data": "" }, [
row(tr("backupHint"), backupFeature.buttons(this.vm)),
row(tr("clearProgressHint"), el("button", {
type: "button",
class: "aop-btn aop-data-button",
onclick: () => progressActions.clearAll(this.vm)
}, tr("clearProgress"))),
row(tr("clearAllHint"), el("button", {
type: "button",
class: "aop-btn aop-data-button aop-data-danger",
onclick: () => {
if (!pageWindow.confirm(tr("clearAllConfirm"))) return;
historyStore.clear();
progressStore.clearAll();
favoritesStore.clear();
pageWindow.location.reload();
}
}, tr("clearAllMenu")))
])
);
}
};
/*! 声优/标签/社团目录缓存:按 API 地址和类型隔离,合并并发请求,供数量显示和实体识别共用。 */
function entityNames(item) {
return [...new Set([item.name, ...Object.values(item.i18n || {}).map((translation) => translation && translation.name)].filter(Boolean))];
}
function workCount(item) {
const value = item && item.count;
if (value == null || typeof value === "string" && !value.trim() || typeof value !== "number" && typeof value !== "string") return null;
const count = Number(value);
return Number.isFinite(count) && count >= 0 ? Math.floor(count) : null;
}
function sortByWorkCount(items, countOf = workCount) {
return items.map((item, index) => ({ item, index, count: countOf(item) })).sort((a, b) => (b.count == null ? -1 : b.count) - (a.count == null ? -1 : a.count) || a.index - b.index).map(({ item }) => item);
}
var COUNT_REFRESH_MS = 5 * 60 * 1e3;
var entityCatalog = {
entries: new Map(),
pending: new Map(),
listeners: new Set(),
key(vm, type) {
return `${getApiBase(vm)}:${type}`;
},
get(vm, type) {
return this.entries.get(this.key(vm, type));
},
ingest(vm, type, rows) {
if (!Array.isArray(rows)) throw new Error("目录数据格式不正确");
const key = this.key(vm, type);
if (this.entries.get(key)?.source === rows) return;
const items = rows.map((row) => ({ id: `site:${row.id}`, siteId: row.id, name: row.name, i18n: row.i18n, aliases: entityNames(row), count: workCount(row) }));
const byId = new Map(items.map((item) => [String(item.siteId), item]));
const byName = new Map();
for (const item of items) for (const name of item.aliases) {
if (!byName.has(name)) byName.set(name, item);
else if (byName.get(name)?.siteId !== item.siteId) byName.set(name, null);
}
this.entries.set(key, { source: rows, items, byId, byName, updatedAt: Date.now() });
for (const fn of this.listeners) fn(type);
},
find(vm, type, item) {
const entry = this.get(vm, type);
if (!entry) return void 0;
if (item.siteId != null) return entry.byId.get(String(item.siteId));
return favoriteNames(item).map((name) => entry.byName.get(name)).find(Boolean);
},
count(vm, type, item) {
return this.find(vm, type, item)?.count ?? null;
},
async load(vm, type, force = false) {
const key = this.key(vm, type);
const cached = this.get(vm, type);
if (!force && cached && Date.now() - cached.updatedAt < COUNT_REFRESH_MS) return;
if (this.pending.has(key)) return this.pending.get(key);
const request = apiGet(vm, `${type === "va" ? "vas" : type === "tag" ? "tags" : "circles"}/`, { fresh: true }).then((rows) => this.ingest(vm, type, rows)).finally(() => this.pending.delete(key));
this.pending.set(key, request);
return request;
},
onChange(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
};
/*! 我的关注页面:负责分组操作和按作品数量展示;页面级刷新定时器、订阅在退出时释放。 */
var FAVORITES_PATH = "/favorites";
var favoritesView = {
tab: "va",
countErrors: {},
watchCounts(container) {
const refresh = (force = false) => {
if (container.isConnected && pageWindow.document.visibilityState !== "hidden") return this.loadCounts(this.tab, force);
};
const timer = setInterval(() => refresh(true), COUNT_REFRESH_MS);
const onReturn = () => refresh();
pageWindow.addEventListener("focus", onReturn);
pageWindow.document.addEventListener("visibilitychange", onReturn);
return () => {
clearInterval(timer);
pageWindow.removeEventListener("focus", onReturn);
pageWindow.document.removeEventListener("visibilitychange", onReturn);
};
},
loadCounts(type, force = false) {
delete this.countErrors[type];
const request = entityCatalog.load(this.vm, type, force);
this.renderInto(this.container);
return request.catch(() => {
this.countErrors[type] = true;
}).finally(() => {
if (this.container && this.container.isConnected) this.renderInto(this.container);
});
},
mount(vm) {
GM_addStyle(`
.aop-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(128,128,128,.25); }
.aop-tab { border: none; background: none; padding: 10px 16px; font-size: 14px; cursor: pointer; color: #888;
border-bottom: 2px solid transparent; margin-bottom: -1px; }
.aop-tab.aop-on { color: #673ab7; border-bottom-color: #673ab7; font-weight: 500; }
.aop-chips { display: flex; flex-wrap: wrap; gap: 10px; }
.aop-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 8px 6px 14px; border-radius: 16px;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); font-size: 14px; }
.body--dark .aop-chip { background: #1d1d1d; }
.aop-chip-name { cursor: pointer; }
.aop-chip-name:hover { color: #673ab7; }
.aop-chip-x { border: none; background: none; color: #aaa; cursor: pointer; font-size: 16px; padding: 0 4px; line-height: 1; }
.aop-chip-x:hover { color: #c10015; }
.aop-work-count { color: #00897b; font-size: 12px; white-space: nowrap; }
.body--dark .aop-work-count { color: #80cbc4; }
.aop-refresh { margin-left: auto; align-self: center; border: none; background: none; color: #aaa; cursor: pointer;
font-size: 16px; line-height: 1; padding: 6px 8px; border-radius: 50%; }
.aop-refresh:hover { color: #673ab7; background: rgba(128,128,128,.1); }
.aop-refresh.aop-loading { cursor: default; animation: aop-spin 1s linear infinite; }
@keyframes aop-spin { to { transform: rotate(360deg); } }
.aop-count-error { color: #c10015; font-size: 13px; margin: -8px 0 12px; }
.aop-group { margin-bottom: 18px; padding: 12px 14px 14px; border-radius: 6px; border: 1px dashed transparent;
background: rgba(128,128,128,.06); }
.aop-group-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.aop-group-name { font-size: 16px; font-weight: 500; }
.aop-group-count { color: #888; font-size: 13px; }
.aop-group-tools { margin-left: auto; display: flex; gap: 4px; }
.aop-group-tools button, .aop-add-group { border: none; background: none; color: #888; cursor: pointer; font-size: 13px; padding: 2px 6px; }
.aop-group-tools button:hover, .aop-add-group:hover { color: #673ab7; }
.aop-group-empty { color: #999; font-size: 13px; padding: 4px 0; }
.aop-chip-move { appearance: none; -webkit-appearance: none; border: none; box-shadow: none; color: transparent; text-shadow: none;
background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m4 6 4 4 4-4' fill='none' stroke='%23999' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat center / 12px 12px;
font-size: 12px; cursor: pointer; width: 22px; min-width: 22px; max-width: 22px; flex: 0 0 22px; padding: 0;
opacity: 0; transition: opacity .15s; }
.aop-chip:hover .aop-chip-move, .aop-chip:focus-within .aop-chip-move { opacity: 1; }
@media (hover: none), (pointer: coarse) { .aop-chip-move { opacity: 1; } }
.aop-chip-move option { background: #fff; color: #222; }
.body--dark .aop-chip-move { color-scheme: dark; }
.body--dark .aop-chip-move option { background: #1d1d1d; color: #eee; }
`);
this.vm = vm;
try {
this.tab = storage.get("favorites-tab", "va");
} catch (err) {
this.tab = "va";
}
const hasPage = addPage(vm, { path: FAVORITES_PATH, title: () => `${tr("following")} - ASMR Online`, renderInto: (container) => this.mountLive(container) });
if (!hasPage) return;
const open = () => vm.$router.push(FAVORITES_PATH).catch(() => {
});
addNavItem({
id: "favorites",
label: () => tr("following"),
iconSvg: ICONS.star,
href: FAVORITES_PATH,
onClick: open,
isActive: () => vm.$route && vm.$route.path === FAVORITES_PATH,
after: "/playlists"
});
if (typeof GM_registerMenuCommand === "function") GM_registerMenuCommand(tr("following"), open);
},
mountLive(container) {
this.renderInto(container);
const stopFavorites = favoritesStore.onChange(() => this.renderInto(container));
const stopCounts = entityCatalog.onChange(() => this.renderInto(container));
const stopLocale = onLocaleChange(() => this.renderInto(container));
const stopRefresh = this.watchCounts(container);
this.loadCounts(this.tab);
return () => {
stopFavorites();
stopCounts();
stopLocale();
stopRefresh();
if (this.container === container) this.container = null;
};
},
renderInto(container) {
const vm = this.vm;
this.container = container;
const data = favoritesStore.all();
if (!Object.hasOwn(FAVORITE_TYPES, this.tab)) this.tab = "va";
const tabs = el("div", { class: "aop-tabs" }, Object.keys(FAVORITE_TYPES).map((type2) => el("button", {
class: `aop-tab${type2 === this.tab ? " aop-on" : ""}`,
"data-tab": type2,
onclick: () => {
this.tab = type2;
storage.set("favorites-tab", type2);
this.renderInto(container);
this.loadCounts(type2);
}
}, tr("tab", typeLabel(type2), data[type2].length))));
const type = this.tab;
const items = sortByWorkCount(data[type], (item) => entityCatalog.count(vm, type, item));
const groups = data.groups[type];
const loading = entityCatalog.pending.has(entityCatalog.key(vm, type));
const updatedAt = entityCatalog.get(vm, type)?.updatedAt;
const refreshTitle = loading ? tr("loadingCounts") : tr("refreshCounts", updatedAt ? new Date(updatedAt).toLocaleTimeString(LOCALE_INFO[currentLocale()].tag, { hour: "2-digit", minute: "2-digit" }) : null);
tabs.append(el("button", {
class: `aop-refresh${loading ? " aop-loading" : ""}`,
"data-aop-refresh": "",
title: refreshTitle,
"aria-label": refreshTitle,
...loading ? { disabled: "" } : {},
onclick: () => this.loadCounts(type, true)
}, "↻"));
const countError = this.countErrors[type] ? el("div", { class: "aop-count-error", role: "status" }, tr("countError")) : null;
const emptyHint = items.length ? null : tr("followHint", typeNoun(type));
const body = el("div", {}, groups.map((group) => this.renderGroup(type, group, items.filter((item) => item.group === group), emptyHint)));
const addGroup = el("button", {
class: "aop-add-group",
onclick: () => {
const name = pageWindow.prompt(tr("newGroupPrompt"));
if (name && !favoritesStore.addGroup(type, name)) notify(vm, tr("groupNameInvalid"));
}
}, tr("newGroup"));
container.replaceChildren(
el("div", { class: "aop-head" }, [el("h1", {}, tr("following"))]),
tabs,
...countError ? [countError] : [],
body,
addGroup
);
},
renderGroup(type, group, items, emptyHint = null) {
const vm = this.vm;
const isInbox = group === FAVORITE_TYPES[type].inbox;
const tools = [
el("button", {
title: tr("addTitle"),
onclick: () => {
const text = pageWindow.prompt(tr("addPrompt", groupName(type, group), typeNoun(type)));
if (!text) return;
for (const name of text.split(/[;;]+/).map((x) => x.trim()).filter(Boolean)) {
if (favoritesStore.has(type, name)) favoritesStore.move(type, name, group);
else favoritesStore.add(type, { id: name, name, group }, { append: true });
}
}
}, tr("add"))
];
if (items.length) {
tools.push(el("button", {
"data-aop-clear-group": "",
onclick: () => {
if (pageWindow.confirm(tr("confirmClearGroup", groupName(type, group), items.length, typeNoun(type)))) favoritesStore.clearGroup(type, group);
}
}, tr("clearGroup")));
}
if (!isInbox) {
tools.push(
el("button", {
onclick: () => {
const name = pageWindow.prompt(tr("renamePrompt"), group);
if (name && name !== group && !favoritesStore.renameGroup(type, group, name)) notify(vm, tr("groupNameInvalid"));
}
}, tr("rename")),
el("button", {
onclick: () => {
if (pageWindow.confirm(tr("confirmDeleteGroup", group, items.length, tr("inbox")))) {
favoritesStore.removeGroup(type, group);
}
}
}, tr("deleteGroup"))
);
}
const allGroups = favoritesStore.groups(type);
const chips = items.map((item) => {
const move = el("select", {
class: "aop-chip-move",
title: tr("moveToGroup"),
onchange: (event) => favoritesStore.move(type, item.id, event.target.value)
}, allGroups.map((g) => {
const option = el("option", { value: g }, groupName(type, g));
if (g === group) option.selected = true;
return option;
}));
const entry = entityCatalog.find(vm, type, item);
const count = entry ? entry.count : null;
return el("span", {
class: "aop-chip",
"data-id": String(item.id)
}, [
el("span", { class: "aop-chip-name", onclick: () => vm.$router.push(keywordRoute(type, item.name)).catch(() => {
}) }, localizedEntityName(entry, item.name)),
el("span", { class: "aop-work-count" }, count == null ? tr("unknown") : tr("works", count)),
move,
el("button", { class: "aop-chip-x", title: tr("unfollow"), onclick: () => favoritesStore.remove(type, item.id) }, "×")
]);
});
return el("div", {
class: "aop-group",
"data-group": group
}, [
el("div", { class: "aop-group-head" }, [
el("span", { class: "aop-group-name" }, groupName(type, group)),
el("span", { class: "aop-group-count" }, String(items.length)),
el("div", { class: "aop-group-tools" }, tools)
]),
items.length ? el("div", { class: "aop-chips" }, chips) : isInbox && emptyHint ? el("div", { class: "aop-group-empty" }, emptyHint) : null
]);
}
};
/*! 网站页面关注入口:向作品元数据、目录和搜索菜单补充星标;实体识别与存储交给 catalog/store。 */
var favoriteButtons = {
mount(vm) {
GM_addStyle(`
.aop-star { display: inline-flex; align-items: center; justify-content: center; border: none; background: none;
cursor: pointer; padding: 0 2px; margin: 0 6px 0 -2px; font-size: 18px; line-height: 1; color: #bbb;
vertical-align: middle; }
.aop-star:hover { color: #ffb300; }
.aop-star.aop-on { color: #ffb300; }
.aop-star.aop-directory-star { width: 22px; height: 22px; padding: 0; margin: 0 0 0 6px; vertical-align: middle; }
.aop-directory-star:not(.aop-on) { opacity: 0; }
.aop-directory-entry:hover .aop-directory-star,
.aop-directory-entry:focus-within .aop-directory-star { opacity: 1; }
@media (hover: none), (pointer: coarse) { .aop-star.aop-directory-star { opacity: 1; } }
.aop-search-star { font-size: 24px; margin-left: 4px; }
.aop-meta-anchor { position: relative; }
.aop-star.aop-floating-star { position: absolute; top: -2px; right: -2px; z-index: 1;
width: 21px; height: 21px; padding: 0; margin: 0; border-radius: 50%;
background: #fff; color: #b77900; box-shadow: 0 1px 3px rgba(0,0,0,.2);
font-size: 16px; line-height: 1; opacity: 0; pointer-events: none; transition: opacity .12s; }
.aop-star.aop-floating-star.aop-on { color: #ffb300; }
.body--dark .aop-star.aop-floating-star { background: #2d2d2d; color: #ffc44b; }
.aop-meta-anchor:hover > .aop-floating-star,
.aop-meta-anchor:focus-within > .aop-floating-star,
.aop-star.aop-floating-star.aop-on { opacity: 1; pointer-events: auto; }
@media (hover: none), (pointer: coarse) {
.aop-star.aop-floating-star { width: 24px; height: 24px; opacity: 1; pointer-events: auto; }
}
@media (prefers-reduced-motion: reduce) { .aop-star.aop-floating-star { transition: none; } }
.aop-follow-menu-item { appearance: none; width: 100%; border: 0; text-align: left;
font: inherit; color: inherit; background: transparent; }
.aop-follow-menu-item .q-item__section--avatar { min-width: 36px; }
`);
this.vm = vm;
let scheduled = false;
const run = () => {
if (scheduled) return;
scheduled = true;
pageWindow.requestAnimationFrame(() => {
scheduled = false;
try {
this.ensure();
} catch (err) {
console.error(LOG_PREFIX, "关注按钮", err);
}
});
};
new MutationObserver(run).observe(pageWindow.document.body, { childList: true, subtree: true, characterData: true });
favoritesStore.onChange(run);
onLocaleChange(run);
run();
},
ensure() {
const route = this.vm.$route || {};
const page = pageWindow.document.querySelector(".q-page-container");
if (page) {
if (/^\/work\//.test(route.path || "")) this.ensureFloatingStars(page);
directoryView.ensure(this.vm, page, route);
this.ensureSearchStar(page, route);
}
this.ensureMenus();
this.syncStars();
},
star(type, item, extraClass = "") {
const button = el("button", {
type: "button",
class: `aop-star ${extraClass}`.trim(),
"data-aop-star": type,
onclick: (event) => {
event.preventDefault();
event.stopPropagation();
let current = button.aopItem;
let currentType = button.dataset.aopStar;
if (button.aopAnchor) {
const anchor = button.aopAnchor;
const props = componentProps(anchor, (p) => FAVORITE_TYPES[p.searchType]);
if (!anchor.isConnected || !props || !props.searchContent) return;
currentType = props.searchType;
current = this.item(currentType, props.searchContent, anchor);
favoritesStore.reconcile(currentType, current);
button.aopItem = current;
button.dataset.aopStar = currentType;
}
const on = favoritesStore.toggle(currentType, current);
this.syncStars();
notify(this.vm, tr("followed", on, current.name));
}
});
button.aopItem = item;
return button;
},
syncStars() {
const lists = {};
for (const button of pageWindow.document.querySelectorAll("[data-aop-star]")) {
const type = button.dataset.aopStar;
lists[type] = lists[type] || favoritesStore.list(type) || [];
const on = lists[type].some((item) => sameFavorite(item, button.aopItem));
const label = tr("starLabel", on, button.aopItem.name);
if (button.classList.contains("aop-on") === on && button.dataset.aopSynced && button.getAttribute("aria-label") === label) continue;
button.dataset.aopSynced = "1";
button.classList.toggle("aop-on", on);
button.title = on ? tr("unfollow") : tr("follow");
button.setAttribute("aria-label", label);
button.setAttribute("aria-pressed", String(on));
button.textContent = on ? "★" : "☆";
}
},
reconcile(type, item) {
if (this.reconciledVersion !== favoritesStore.version) {
this.reconciled = new Set();
this.reconciledVersion = favoritesStore.version;
}
const key = JSON.stringify([type, item.name, item.siteId ?? null, item.aliases || []]);
if (this.reconciled.has(key)) return;
this.reconciled.add(key);
favoritesStore.reconcile(type, item);
},
item(type, name, anchor) {
const item = favoriteItem(findMetadata(anchor), type, name);
if (item.siteId != null) return item;
let entry;
if (DIRECTORY_TYPES[this.vm.$route?.path] === type) {
for (let comp = anchor.__vue__; comp && comp.$el === anchor; comp = comp.$parent) {
if (comp.$vnode?.key != null) entry = entityCatalog.get(this.vm, type)?.byId.get(String(comp.$vnode.key));
if (entry) break;
}
}
entry = entry || entityCatalog.find(this.vm, type, item);
return entry ? { ...item, id: entry.id, siteId: entry.siteId, aliases: entry.aliases } : item;
},
ensureFloatingStars(page) {
for (const button of page.querySelectorAll(".aop-floating-star")) {
const anchor = button.aopAnchor;
const props = anchor && componentProps(anchor, (p) => FAVORITE_TYPES[p.searchType]);
if (!props || !props.searchContent || button.parentElement !== anchor) {
button.remove();
if (anchor && anchor.classList.contains("aop-meta-anchor")) anchor.classList.toggle("aop-meta-anchor", false);
}
}
for (const node of page.querySelectorAll("*")) {
const props = componentProps(node, (p) => FAVORITE_TYPES[p.searchType]);
if (!props || !props.searchContent) continue;
const type = props.searchType;
const item = this.item(type, props.searchContent, node);
this.reconcile(type, item);
if (!node.classList.contains("aop-meta-anchor")) node.classList.toggle("aop-meta-anchor", true);
let button = [...node.querySelectorAll(".aop-floating-star")].find((child) => child.parentElement === node);
if (!button) {
button = this.star(type, item, "aop-floating-star");
node.append(button);
}
button.aopAnchor = node;
button.aopItem = item;
button.dataset.aopStar = type;
}
},
ensureMenus() {
for (const menu of pageWindow.document.querySelectorAll(".q-menu")) {
const context = searchMenuContext(menu);
const list = menu.querySelector(".q-list");
if (!context || !list) {
for (const old of menu.querySelectorAll("[data-aop-follow-menu]")) old.remove();
continue;
}
const { searchType: type, searchContent: name } = context.owner.$props;
const item = this.item(type, name, context.owner.$el);
this.reconcile(type, item);
let action = list.querySelector("[data-aop-follow-menu]");
if (!action) {
action = el("button", {
type: "button",
role: "menuitem",
"data-aop-follow-menu": type,
class: "q-item q-item-type row no-wrap q-item--clickable q-link cursor-pointer q-focusable q-hoverable aop-follow-menu-item",
onclick: (event) => {
event.preventDefault();
const current = searchMenuContext(menu);
if (!current) return;
const props = current.owner.$props;
const target = this.item(props.searchType, props.searchContent, current.owner.$el);
favoritesStore.reconcile(props.searchType, target);
const on2 = favoritesStore.toggle(props.searchType, target);
notify(this.vm, tr("followed", on2, target.name));
if (typeof current.controller.hide === "function") {
event.stopPropagation();
current.controller.hide(event);
}
}
}, [
el("span", { class: "q-item__section column q-item__section--avatar q-item__section--side justify-center" }, el("i", { class: "q-icon material-icons aop-follow-menu-icon", "aria-hidden": "true" })),
el("span", { class: "q-item__section column q-item__section--main justify-center aop-follow-menu-label" })
]);
list.append(action);
}
action.aopItem = item;
action.dataset.aopFollowMenu = type;
const on = favoritesStore.has(type, item);
const label = tr("followMenu", on, typeNoun(type));
const icon = action.querySelector(".aop-follow-menu-icon");
const text = action.querySelector(".aop-follow-menu-label");
if (icon.textContent !== (on ? "star" : "star_border")) icon.textContent = on ? "star" : "star_border";
if (text.textContent !== label) text.textContent = label;
action.setAttribute("aria-label", `${label}${currentLocale() === "en" ? ": " : ":"}${name}`);
}
},
ensureSearchStar(page, route) {
const keyword = route.path === "/works" && route.query && route.query.keyword;
const match = typeof keyword === "string" && keyword.trim().match(/^\$(va|tag|circle):([^$]+)\$$/);
const existing = page.querySelector(".aop-search-star");
const key = match ? `${match[1]}:${match[2]}` : null;
if (existing && existing.dataset.key === key) return;
if (existing) existing.remove();
if (!match) return;
const chip = page.querySelector("h1.text-h5 > span.inline-block");
if (!chip) return;
const [, type, name] = match;
const star = this.star(type, { id: name, name }, "aop-search-star");
star.dataset.key = key;
chip.after(star);
}
};
var DIRECTORY_TYPES = { "/vas": "va", "/tags": "tag", "/circles": "circle" };
var directoryView = {
bindings: new WeakMap(),
owner(page, type) {
const seen = new Set();
for (const node of [page, ...page.querySelectorAll("*")]) {
for (let comp = node.__vue__; comp && !seen.has(comp); comp = comp.$parent) {
seen.add(comp);
const restrict = comp.$props && comp.$props.restrict;
if (DIRECTORY_TYPES[`/${restrict}`] === type && Array.isArray(comp.items)) return comp;
}
}
return null;
},
ensure(vm, page, route) {
const type = DIRECTORY_TYPES[route.path];
if (!type) return;
const owner = this.owner(page, type);
const viewport = page.querySelector(".q-virtual-scroll");
if (!owner || !viewport) return;
let state = this.bindings.get(owner);
if (!state) {
state = { type, source: null };
this.bindings.set(owner, state);
}
if (state.type !== type) {
state.waiting = owner.items === state.source;
state.oldSource = owner.items;
state.type = type;
state.sortedSource = null;
}
if (state.waiting && owner.items === state.oldSource) {
for (const star of page.querySelectorAll(".aop-directory-star")) star.remove();
return;
}
state.waiting = false;
if (state.sortedSource !== owner.items) {
const ordered = sortByWorkCount(owner.items);
if (ordered.some((item, index) => item !== owner.items[index])) {
owner.items = ordered;
viewport.scrollTop = 0;
}
state.sortedSource = owner.items;
}
state.source = owner.items;
if (owner.items.length) entityCatalog.ingest(vm, type, owner.items);
for (const node of page.querySelectorAll("*")) {
const props = componentProps(node, (p) => p.searchType === type && p.searchContent);
if (!props) continue;
const label = node.querySelector(".q-item__label");
if (!label) continue;
const item = favoriteButtons.item(type, props.searchContent, node);
favoriteButtons.reconcile(type, item);
let star = label.querySelector(".aop-directory-star");
if (!star) {
star = favoriteButtons.star(type, item, "aop-directory-star");
label.append(star);
}
star.aopAnchor = node;
star.aopItem = item;
star.dataset.aopStar = type;
if (!node.classList.contains("aop-directory-entry")) node.classList.toggle("aop-directory-entry", true);
}
}
};
function searchMenuContext(menu) {
for (const node of [menu, ...menu.querySelectorAll("*")]) {
let controller = null;
for (let comp = node.__vue__; comp; comp = comp.$parent) {
if (comp.$options && comp.$options.name === "QMenu") controller = comp;
const props = comp.$props;
if (controller && props && FAVORITE_TYPES[props.searchType] && typeof props.searchContent === "string" && props.searchContent) {
if (comp._isDestroyed || !comp.$el || !comp.$el.isConnected) return null;
return { owner: comp, controller };
}
}
}
return null;
}
function componentProps(node, test) {
for (let comp = node.__vue__; comp && comp.$el === node; comp = comp.$parent) {
if (comp.$props && test(comp.$props)) return comp.$props;
}
return null;
}
function findMetadata(node) {
for (let n = node; n; n = n.parentElement) {
const props = componentProps(n, (p) => p.metadata);
if (props) return props.metadata;
}
return null;
}
function favoriteItem(metadata, type, name) {
const list = !metadata ? [] : type === "circle" ? [metadata.circle].filter(Boolean) : metadata[type === "va" ? "vas" : "tags"] || [];
const found = list.find((entry) => entityNames(entry).includes(name));
return { id: found && found.id != null ? `site:${found.id}` : name, name, ...found ? { siteId: found.id, aliases: entityNames(found) } : {} };
}
var favoritesFeature = {
name: "favorites",
init(vm) {
storage.remove("favorites-sort");
storage.remove("directory-sort");
favoritesView.mount(vm);
favoriteButtons.mount(vm);
}
};
/*! 原站收藏夹移出确认:捕获阶段拦住原点击,确认后只向仍存在的作品卡片发送一次 delete。 */
function playlistRemoveButton(node) {
for (let n = node; n && n.classList; n = n.parentElement) {
if (!n.classList.contains("like-btn")) continue;
const icon = n.querySelector(".q-icon");
return icon && icon.textContent.trim() === "playlist_remove" ? n : null;
}
return null;
}
function playlistRemoveCard(button) {
for (const node of [button, ...button.querySelectorAll("*")]) {
for (let comp = node.__vue__; comp; comp = comp.$parent) {
if (comp.$props && "showDeleteBtn" in comp.$props) return comp;
}
}
return null;
}
var playlistRemoveGuard = {
vm: null,
confirm(message, onOk) {
const q = this.vm && this.vm.$q;
if (q && typeof q.dialog === "function") {
q.dialog({
title: tr("playlistRemoveTitle"),
message,
cancel: { label: tr("cancel"), flat: true },
ok: { label: tr("playlistRemoveOk"), color: "negative", flat: true },
focus: "cancel"
}).onOk(onOk);
} else if (pageWindow.confirm(message)) {
onOk();
}
},
onClick(event) {
const button = playlistRemoveButton(event.target);
if (!button) return;
const card = playlistRemoveCard(button);
if (!card) return;
event.preventDefault();
event.stopImmediatePropagation();
const metadata = card.$props.metadata;
this.confirm(tr("playlistRemoveConfirm", metadata && metadata.source_id), () => {
if (!card._isDestroyed) card.$emit("delete");
});
},
label(root = pageWindow.document) {
const hint = tr("playlistRemoveHint");
for (const node of root.querySelectorAll(".like-btn")) {
if (playlistRemoveButton(node) !== node) continue;
if (node.getAttribute("title") !== hint) node.setAttribute("title", hint);
if (node.getAttribute("aria-label") !== hint) node.setAttribute("aria-label", hint);
}
}
};
var playlistRemoveFeature = {
name: "playlist-remove-guard",
init(vm) {
playlistRemoveGuard.vm = vm;
pageWindow.document.addEventListener("click", (event) => playlistRemoveGuard.onClick(event), true);
const label = () => playlistRemoveGuard.label();
label();
new MutationObserver(label).observe(pageWindow.document.body, { childList: true, subtree: true });
onLocaleChange(label);
}
};
/*! 为原站播放器按钮补充原生悬停提示和无障碍名称;仅识别 AudioPlayer/AudioPlayerClassic 内的按钮。 */
var PLAY_MODE_ICONS = new Set(["playlist_play", "repeat", "repeat_one", "shuffle"]);
function playerButtonContext(button) {
let icon = null;
for (let component = button.__vue__; component; component = component.$parent) {
if (component._isDestroyed) return null;
if (component.$el === button && typeof component.$props?.icon === "string") icon = component.$props.icon;
if (["AudioPlayer", "AudioPlayerClassic"].includes(component.$options?.name)) {
return { player: component, icon: icon || button.querySelector(".q-icon")?.textContent.trim() };
}
}
return null;
}
var playerHintsFeature = {
name: "player-hints",
label(button) {
const context = playerButtonContext(button);
if (!context) return null;
const { player, icon } = context;
if (icon === "queue_music") return tr("playerQueue");
if (PLAY_MODE_ICONS.has(icon)) {
const mode = player.playMode?.name || this.vm.$store.state.AudioPlayer.playMode?.name || "order";
const names = tr("playerModeNames");
return tr("playerModeHint", names[mode] || names.order);
}
if (icon === "picture_in_picture_alt") {
const mode = player.subtitleDisplayMode || this.vm.$store.state.AudioPlayer.subtitleDisplayMode;
return tr(mode === "pip" ? "playerInlineSubtitles" : "playerDesktopSubtitles");
}
if (icon === "subtitles") {
const track = player.currentPlayingFile || currentTrack(this.vm.$store.state);
return tr(track?.availableLyrics?.length ? "playerSelectSubtitles" : "playerLoadSubtitles");
}
if (icon === "more_horiz") return tr("playerMoreActions");
return null;
},
update(button) {
const label = this.label(button);
if (!label) return;
if (button.getAttribute("title") !== label) button.setAttribute("title", label);
if (button.getAttribute("aria-label") !== label) button.setAttribute("aria-label", label);
},
init(vm) {
this.vm = vm;
const doc = pageWindow.document;
const ensure = () => {
for (const button of doc.querySelectorAll(".q-btn")) this.update(button);
};
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
pageWindow.requestAnimationFrame(() => {
scheduled = false;
ensure();
});
};
new MutationObserver(schedule).observe(doc.body, { childList: true, subtree: true, characterData: true });
vm.$store.subscribe((mutation) => {
if (["AudioPlayer/CHANGE_PLAY_MODE", "AudioPlayer/SET_SUBTITLE_DISPLAY_MODE"].includes(mutation.type)) schedule();
});
const updateHovered = (event) => {
const button = event.target?.closest?.(".q-btn");
if (button) this.update(button);
};
doc.addEventListener("mouseover", updateHovered, true);
doc.addEventListener("focusin", updateHovered, true);
onLocaleChange(ensure);
ensure();
}
};
/*! 原站命名修正:覆盖 i18n 文案、侧栏图标和页面标题,不改网站数据字段或接口。 */
var nativeNavigationIcons = null;
function syncNativeIcons() {
const marks = [...pageWindow.document.querySelectorAll('a.q-item[href$="/favourites"] .q-icon')];
const collections = [...pageWindow.document.querySelectorAll('a.q-item[href$="/playlists"] .q-icon')];
if (!nativeNavigationIcons) {
if (!marks[0]?.innerHTML.trim() || !collections[0]?.innerHTML.trim()) return;
nativeNavigationIcons = { favourites: collections[0].innerHTML, playlists: marks[0].innerHTML };
}
for (const [path, icons] of [["favourites", marks], ["playlists", collections]]) {
for (const icon of icons) {
if (icon.innerHTML !== nativeNavigationIcons[path]) icon.innerHTML = nativeNavigationIcons[path];
icon.setAttribute("data-aop-native-icon", path);
}
}
}
var SITE_RENAMES = {
"zh-CN": {
sidebar: { playlists: "收藏夹", myFavourites: "我的标记" },
playlist: {
addToPlaylist: "加入收藏夹",
createPlaylist: "新建收藏夹",
editPlaylist: "编辑收藏夹",
deleteDialog: { title: "删除收藏夹", message: "删除后不可恢复,关注本收藏夹的人将无法再访问。确定要删除吗?" },
filterOptions: { liked: "我关注的" },
guestCanNotCreatePlaylist: "游客不能创建收藏夹",
canNotCreatePreservedPlaylist: "不能创建系统保留的收藏夹",
canNotDeletePreservedPlaylist: "不能删除系统保留的收藏夹",
canNotLikeOwnPlaylist: "不能关注自己的收藏夹",
playlistNotFound: "收藏夹不存在",
preservedPlaylistName: { __SYS_PLAYLIST_MARKED: "快速收藏" }
},
settings: {
quickMarkTarget: "默认收藏夹",
quickMarkTargetCaption: "点击作品卡片右上角按钮后,作品将被添加到这个收藏夹",
quickMarkTargetPlaylistReset: "默认收藏夹已重置为:{playlistName}"
},
favourites: { export: { exportToPlaylist: "导出到收藏夹", selectTargetPlaylist: "目标收藏夹" } },
workTree: { addToPlayList: "加入播放队列" }
},
en: {
sidebar: { playlists: "Collections", myFavourites: "My Marks" },
playlist: {
addToPlaylist: "Add to collection",
createPlaylist: "Create collection",
editPlaylist: "Edit collection",
deleteDialog: { title: "Delete collection", message: "This action cannot be undone. Are you sure you want to delete this collection?" },
guestCanNotCreatePlaylist: "Guests cannot create collections",
canNotCreatePreservedPlaylist: "Cannot create system built-in collection",
canNotDeletePreservedPlaylist: "Cannot delete system built-in collection",
canNotLikeOwnPlaylist: "Cannot follow your own collection",
playlistNotFound: "Collection not found",
preservedPlaylistName: { __SYS_PLAYLIST_MARKED: "Quick saves" }
},
settings: {
quickMarkTarget: "Default collection",
quickMarkTargetCaption: "If you click the quick-mark button in the top-right corner of a work card, it will be added to this collection.",
quickMarkTargetPlaylistReset: "The default collection has been reset to: {playlistName}."
},
favourites: { export: { exportToPlaylist: "Export to collection", selectTargetPlaylist: "Target collection" } },
workTree: { addToPlayList: "Add to queue" }
},
ja: {
sidebar: { playlists: "コレクション", myFavourites: "マーク" },
playlist: { addToPlaylist: "コレクションに追加", preservedPlaylistName: { __SYS_PLAYLIST_MARKED: "クイック保存" } },
workTree: { addToPlayList: "キューに追加" }
}
};
var SITE_TITLE_RENAMES = [
{ key: "sidebar.playlists", original: "Playlists", pattern: (names) => `^(?:${names}) - `, to: (name) => `${name} - ` },
{ key: "sidebar.myFavourites", original: "Favourites", pattern: (names) => `^(?:${names}) - `, to: (name) => `${name} - ` },
{
key: "sidebar.playlists",
original: "Playlist by",
pattern: (names) => ` \\| (?:${names}) `,
to: (name) => ` | ${name} · `,
renamed: (name) => `${name} ·`
}
];
var escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
var messageAt = (messages, key) => key.split(".").reduce((o, k) => o && o[k], messages);
var siteRenameFeature = {
name: "site-rename",
init(vm) {
const i18n = vm.$i18n;
if (i18n && typeof i18n.mergeLocaleMessage === "function") {
for (const [locale, messages] of Object.entries(SITE_RENAMES)) i18n.mergeLocaleMessage(locale, messages);
}
const rules = SITE_TITLE_RENAMES.map((rule) => {
const renamed = Object.values(SITE_RENAMES).map((m) => messageAt(m, rule.key)).filter(Boolean).map((name) => rule.renamed ? rule.renamed(name) : name);
const names = [rule.original, ...renamed].map(escapeRegExp).join("|");
return { ...rule, regexp: new RegExp(rule.pattern(names)) };
});
const fixTitle = () => {
const doc = pageWindow.document;
if (typeof vm.$t !== "function") return;
let title = doc.title;
for (const rule of rules) title = title.replace(rule.regexp, rule.to(vm.$t(rule.key)));
if (title !== doc.title) doc.title = title;
};
syncNativeIcons();
new MutationObserver(syncNativeIcons).observe(pageWindow.document.body, { childList: true, subtree: true, characterData: true });
fixTitle();
new MutationObserver(fixTitle).observe(pageWindow.document.head, { childList: true, subtree: true, characterData: true });
if (i18n && typeof vm.$watch === "function") vm.$watch(() => i18n.locale, fixTitle);
}
};
/*! 脚本公共样式;各功能特有的样式由各自 mount/init 注入。aop- 前缀避免影响原站组件。 */
var BASE_CSS = `
[data-aop-nav]:focus-visible { outline: 2px solid #1976d2; outline-offset: -3px; }
.body--dark [data-aop-nav]:focus-visible { outline-color: #90caf9; }
.aop-page { padding: 24px 32px 120px; max-width: 1400px; margin: 0 auto; color: #222; }
.body--dark .aop-page { color: #eee; }
.aop-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.aop-head h1 { font-size: 24px; line-height: 32px; margin: 0; font-weight: 500; color: inherit !important; }
.aop-head .aop-count { color: #888; font-size: 14px; }
.aop-head .aop-spacer { flex: 1; }
.aop-date-group { margin: 20px 0 8px; color: #888; font-size: 13px; font-weight: 500; }
.aop-card { background: #fff; border-radius: 4px; box-shadow: 0 1px 5px rgba(0,0,0,.2); overflow: hidden; }
.body--dark .aop-card, .body--dark .aop-panel { background: #1d1d1d; color: #eee; }
.aop-row { display: flex; gap: 16px; padding: 12px 16px; border-bottom: 1px solid rgba(128,128,128,.15); }
.aop-row:last-child { border-bottom: none; }
.aop-row:hover { background: rgba(128,128,128,.06); }
.aop-cover { width: 120px; height: 90px; object-fit: cover; border-radius: 4px; flex: none; cursor: pointer; }
.aop-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.aop-title { font-size: 15px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.aop-title:hover { color: #1976d2; }
.aop-sub { color: #888; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.aop-progress { display: flex; align-items: center; gap: 10px; color: #888; font-size: 12px; }
.aop-bar { flex: 0 1 280px; height: 4px; border-radius: 2px; background: rgba(128,128,128,.25); overflow: hidden; }
.aop-fill { height: 100%; background: #1976d2; transition: width .3s linear; }
.aop-actions { display: flex; align-items: center; gap: 8px; flex: none; }
.aop-btn { border: none; border-radius: 4px; padding: 6px 14px; font-size: 13px; cursor: pointer;
background: #1976d2; color: #fff; }
.aop-btn.aop-flat { background: none; color: #888; }
.aop-btn.aop-flat:hover { color: #c10015; }
.aop-empty { padding: 64px 24px; text-align: center; color: #888; }
.aop-backup { position: relative; }
.aop-backup > summary { list-style: none; user-select: none; }
.aop-backup > summary::-webkit-details-marker { display: none; }
.aop-backup[open] > summary { color: #673ab7; }
.aop-backup-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 10; width: 240px; padding: 6px;
display: flex; flex-direction: column; align-items: stretch; background: #fff; border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,.2); }
.body--dark .aop-backup-menu { background: #1d1d1d; }
.aop-backup-menu .aop-btn { text-align: left; color: inherit; }
.aop-backup-menu .aop-btn:hover { color: #673ab7; background: rgba(128,128,128,.08); }
@media (max-width: 700px) {
.aop-page { padding: 16px 12px 120px; }
.aop-row { flex-wrap: wrap; }
.aop-cover { width: 80px; height: 60px; }
.aop-actions { width: 100%; justify-content: flex-end; }
}
`;
/*! 启动入口:等待网站 Vue 实例就绪,再初始化各功能;子页面的监听清理由各 view 模块负责。 */
"use strict";
function main() {
if (pageWindow.location.protocol !== "https:" || !Object.hasOwn(SITE_API_KEYS, siteHost)) return;
const features = [siteRenameFeature, progressFeature, favoritesFeature, playlistRemoveFeature, playerHintsFeature, dataManagementFeature];
pageWindow.document.documentElement.setAttribute("data-asmr-one-plus-version", SCRIPT_VERSION);
log(`脚本 ${SCRIPT_VERSION} 已注入,等待网站加载…`);
GM_addStyle(BASE_CSS);
waitForApp().then((vm) => {
watchLocale(vm);
for (const feature of features) {
try {
feature.init(vm);
} catch (err) {
console.error(LOG_PREFIX, `${feature.name} 启动失败`, err);
}
}
log("已加载");
}).catch((err) => console.error(LOG_PREFIX, err));
}
main();
})();