☰

asmr.one plus

为 asmr.one 提供播放进度保存、播放历史、本地关注和数据管理,并修正界面命名、补充播放器按钮提示。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴、Greasemonkey 油猴子 或 Violentmonkey 暴力猴,才能安装此脚本。

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();