B站稍后再看功能增强

与稍后再看功能相关,一切你能想到和想不到的功能

目前為 2023-04-21 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name B站稍后再看功能增强
  3. // @version 4.33.7.20230422
  4. // @namespace laster2800
  5. // @author Laster2800
  6. // @description 与稍后再看功能相关,一切你能想到和想不到的功能
  7. // @icon https://www.bilibili.com/favicon.ico
  8. // @homepageURL https://gf.qytechs.cn/zh-CN/scripts/395456
  9. // @supportURL https://gf.qytechs.cn/zh-CN/scripts/395456/feedback
  10. // @license LGPL-3.0
  11. // @include *://www.bilibili.com/*
  12. // @include *://t.bilibili.com/*
  13. // @include *://message.bilibili.com/*
  14. // @include *://search.bilibili.com/*
  15. // @include *://space.bilibili.com/*
  16. // @include *://account.bilibili.com/*
  17. // @exclude *://message.bilibili.com/*/*
  18. // @exclude *://t.bilibili.com/h5/*
  19. // @exclude *://www.bilibili.com/correspond/*
  20. // @exclude *://www.bilibili.com/page-proxy/*
  21. // @require https://gf.qytechs.cn/scripts/409641-userscriptapi/code/UserscriptAPI.js?version=1161014
  22. // @require https://gf.qytechs.cn/scripts/431998-userscriptapidom/code/UserscriptAPIDom.js?version=1161016
  23. // @require https://gf.qytechs.cn/scripts/432000-userscriptapimessage/code/UserscriptAPIMessage.js?version=1095149
  24. // @require https://gf.qytechs.cn/scripts/432002-userscriptapiwait/code/UserscriptAPIWait.js?version=1161015
  25. // @require https://gf.qytechs.cn/scripts/432003-userscriptapiweb/code/UserscriptAPIWeb.js?version=1160007
  26. // @require https://gf.qytechs.cn/scripts/432936-pushqueue/code/PushQueue.js?version=1161000
  27. // @require https://gf.qytechs.cn/scripts/432807-inputnumber/code/InputNumber.js?version=1160998
  28. // @grant GM_registerMenuCommand
  29. // @grant GM_notification
  30. // @grant GM_xmlhttpRequest
  31. // @grant GM_setValue
  32. // @grant GM_getValue
  33. // @grant GM_deleteValue
  34. // @grant GM_listValues
  35. // @grant GM_addValueChangeListener
  36. // @connect api.bilibili.com
  37. // @run-at document-start
  38. // @compatible edge 版本不小于 93
  39. // @compatible chrome 版本不小于 93
  40. // @compatible firefox 版本不小于 92
  41. // ==/UserScript==
  42.  
  43. /* global UserscriptAPI, PushQueue */
  44. (function() {
  45. 'use strict'
  46.  
  47. if (GM_info.scriptHandler !== 'Tampermonkey') {
  48. const { script } = GM_info
  49. script.author ??= 'Laster2800'
  50. script.homepage ??= 'https://gf.qytechs.cn/zh-CN/scripts/395456'
  51. script.supportURL ??= 'https://gf.qytechs.cn/zh-CN/scripts/395456/feedback'
  52. }
  53.  
  54. const sortType = {
  55. default: 'serial',
  56. defaultR: 'serial:R',
  57. duration: 'duration',
  58. durationR: 'duration:R',
  59. pubtime: 'pubtime',
  60. pubtimeR: 'pubtime:R',
  61. progress: 'progress',
  62. uploader: 'uploader',
  63. title: 'vTitle',
  64. fixed: 'fixed',
  65. }
  66. /**
  67. * 脚本内用到的枚举定义
  68. */
  69. const Enums = {
  70. /**
  71. * @readonly
  72. * @enum {string}
  73. */
  74. headerButtonOp: {
  75. openListInCurrent: 'openListInCurrent',
  76. openListInNew: 'openListInNew',
  77. playAllInCurrent: 'playAllInCurrent',
  78. playAllInNew: 'playAllInNew',
  79. clearWatchlater: 'clearWatchlater',
  80. clearWatchedInWatchlater: 'clearWatchedInWatchlater',
  81. openUserSetting: 'openUserSetting',
  82. openRemoveHistory: 'openRemoveHistory',
  83. openBatchAddManager: 'openBatchAddManager',
  84. exportWatchlaterList: 'exportWatchlaterList',
  85. noOperation: 'noOperation',
  86. },
  87. /**
  88. * @readonly
  89. * @enum {string}
  90. */
  91. headerMenu: {
  92. enable: 'enable',
  93. enableSimple: 'enableSimple',
  94. disable: 'disable',
  95. },
  96. /**
  97. * @readonly
  98. * @enum {string}
  99. */
  100. headerCompatible: {
  101. none: 'none',
  102. bilibiliEvolved: 'bilibiliEvolved',
  103. },
  104. /**
  105. * @readonly
  106. * @enum {string}
  107. */
  108. sortType,
  109. /**
  110. * @readonly
  111. * @enum {string}
  112. */
  113. autoSort: {
  114. auto: 'auto',
  115. ...sortType,
  116. },
  117. /**
  118. * @readonly
  119. * @enum {string}
  120. */
  121. openHeaderMenuLink: {
  122. openInCurrent: 'openInCurrent',
  123. openInNew: 'openInNew',
  124. },
  125. /**
  126. * @readonly
  127. * @enum {string}
  128. */
  129. removeHistorySavePoint: {
  130. list: 'list',
  131. listAndMenu: 'listAndMenu',
  132. anypage: 'anypage',
  133. },
  134. /**
  135. * @readonly
  136. * @enum {string}
  137. */
  138. fillWatchlaterStatus: {
  139. dynamic: 'dynamic',
  140. dynamicAndVideo: 'dynamicAndVideo',
  141. anypage: 'anypage',
  142. never: 'never',
  143. },
  144. /**
  145. * @readonly
  146. * @enum {string}
  147. */
  148. autoRemove: {
  149. always: 'always',
  150. openFromList: 'openFromList',
  151. never: 'never',
  152. absoluteNever: 'absoluteNever',
  153. },
  154. /**
  155. * @readonly
  156. * @enum {string}
  157. */
  158. openListVideo: {
  159. openInCurrent: 'openInCurrent',
  160. openInNew: 'openInNew',
  161. },
  162. /**
  163. * @readonly
  164. * @enum {string}
  165. */
  166. menuScrollbarSetting: {
  167. beautify: 'beautify',
  168. hidden: 'hidden',
  169. original: 'original',
  170. },
  171. /**
  172. * @readonly
  173. * @enum {string}
  174. */
  175. mainRunAt: {
  176. DOMContentLoaded: 'DOMContentLoaded',
  177. load: 'load',
  178. },
  179. }
  180. // 将名称不完全对应的补上,这样校验才能生效
  181. Enums.headerButtonOpL = Enums.headerButtonOpR = Enums.headerButtonOpM = Enums.headerButtonOp
  182.  
  183. const gmId = 'gm395456'
  184. /**
  185. * 全局对象
  186. * @typedef GMObject
  187. * @property {string} id 脚本标识
  188. * @property {number} configVersion 配置版本,为最后一次执行初始化设置或功能性更新设置时脚本对应的配置版本号
  189. * @property {number} configUpdate 当前版本对应的配置版本号,只要涉及到配置的修改都要更新;若同一天修改多次,可以追加小数来区分
  190. * @property {URLSearchParams} searchParams URL 查询参数
  191. * @property {GMObject_config} config 用户配置
  192. * @property {GMObject_configMap} configMap 用户配置属性
  193. * @property {GMObject_infoMap} infoMap 信息属性
  194. * @property {GMObject_runtime} runtime 运行时变量
  195. * @property {string[]} configDocumentStart document-start 时期配置
  196. * @property {GMObject_data} data 脚本数据
  197. * @property {GMObject_url} url URL
  198. * @property {GMObject_regex} regex 正则表达式
  199. * @property {{[c: string]: *}} const 常量
  200. * @property {GMObject_panel} panel 面板
  201. * @property {{[s: string]: HTMLElement}} el HTML 元素
  202. */
  203. /**
  204. * @typedef GMObject_config
  205. * @property {boolean} headerButton 顶栏入口
  206. * @property {headerButtonOp} headerButtonOpL 顶栏入口左键点击行为
  207. * @property {headerButtonOp} headerButtonOpR 顶栏入口右键点击行为
  208. * @property {headerButtonOp} headerButtonOpM 顶栏入口中键点击行为
  209. * @property {headerMenu} headerMenu 顶栏入口弹出面板设置
  210. * @property {openHeaderMenuLink} openHeaderMenuLink 弹出面板内链接点击行为
  211. * @property {boolean} headerMenuKeepRemoved 弹出面板保留被移除稿件
  212. * @property {boolean} headerMenuSearch 弹出面板搜索框
  213. * @property {boolean} headerMenuSortControl 弹出面板排序控制器
  214. * @property {boolean} headerMenuAutoRemoveControl 弹出面板自动移除控制器
  215. * @property {boolean} headerMenuFnSetting 弹出面板:设置
  216. * @property {boolean} headerMenuFnHistory 弹出面板:历史
  217. * @property {boolean} headerMenuFnExport 弹出面板:导出
  218. * @property {boolean} headerMenuFnBatchAdd 弹出面板:批量添加
  219. * @property {boolean} headerMenuFnRemoveAll 弹出面板:清空
  220. * @property {boolean} headerMenuFnRemoveWatched 弹出面板:移除已看
  221. * @property {boolean} headerMenuFnShowAll 弹出面板:显示
  222. * @property {boolean} headerMenuFnPlayAll 弹出面板:播放
  223. * @property {boolean} removeHistory 稍后再看移除记录
  224. * @property {removeHistorySavePoint} removeHistorySavePoint 保存稍后再看历史数据的时间点
  225. * @property {number} removeHistorySavePeriod 数据保存最小时间间隔
  226. * @property {number} removeHistoryFuzzyCompare 模糊比对深度
  227. * @property {number} removeHistorySaves 稍后再看历史数据记录保存数
  228. * @property {boolean} removeHistoryTimestamp 使用时间戳优化移除记录
  229. * @property {number} removeHistorySearchTimes 历史回溯深度
  230. * @property {boolean} batchAddLoadForward 批量添加:加载关注者转发的稿件
  231. * @property {boolean} batchAddLoadAfterTimeSync 批量添加:执行时间同步后是否自动加载稿件
  232. * @property {string} batchAddManagerSnapshotPrefix 批量添加:文件快照前缀
  233. * @property {fillWatchlaterStatus} fillWatchlaterStatus 填充稍后再看状态
  234. * @property {boolean} searchDefaultValue 激活搜索框默认值功能
  235. * @property {autoSort} autoSort 自动排序
  236. * @property {boolean} videoButton 视频播放页稍后再看状态快速切换
  237. * @property {autoRemove} autoRemove 自动将稿件从播放列表移除
  238. * @property {boolean} redirect 稍后再看模式重定向至常规模式播放
  239. * @property {boolean} dynamicBatchAddManagerButton 动态主页批量添加管理器按钮
  240. * @property {number} autoReloadList 自动刷新列表页面
  241. * @property {openListVideo} openListVideo 列表页面稿件点击行为
  242. * @property {boolean} listStickControl 列表页面控制栏随页面滚动
  243. * @property {boolean} listSearch 列表页面搜索框
  244. * @property {boolean} listSortControl 列表页面排序控制器
  245. * @property {boolean} listAutoRemoveControl 列表页面自动移除控制器
  246. * @property {boolean} listExportWatchlaterListButton 列表页面列表导出按钮
  247. * @property {boolean} listBatchAddManagerButton 列表页面批量添加管理器按钮
  248. * @property {boolean} removeButton_playAll 移除「全部播放」按钮
  249. * @property {boolean} removeButton_removeAll 移除「一键清空」按钮
  250. * @property {boolean} removeButton_removeWatched 移除「移除已观看视频」按钮
  251. * @property {boolean} headerCompatible 兼容第三方顶栏
  252. * @property {menuScrollbarSetting} menuScrollbarSetting 弹出面板的滚动条设置
  253. * @property {mainRunAt} mainRunAt 主要逻辑运行时期
  254. * @property {number} watchlaterListCacheValidPeriod 稍后再看列表数据本地缓存有效期(单位:秒)
  255. * @property {boolean} hideDisabledSubitems 设置页隐藏被禁用项的子项
  256. * @property {boolean} reloadAfterSetting 设置生效后刷新页面
  257. * @property {string} importWl_regex 稍后再看列表导入:正则表达式
  258. * @property {string} importWl_aid 稍后再看列表导入:捕获组/AID
  259. * @property {string} importWl_bvid 稍后再看列表导入:捕获组/BVID
  260. * @property {string} importWl_title 稍后再看列表导入:捕获组/标题
  261. * @property {string} importWl_source 稍后再看列表导入:捕获组/来源
  262. * @property {string} importWl_tsS 稍后再看列表导入:捕获组/时间节点(秒)
  263. * @property {string} importWl_tsMs 稍后再看列表导入:捕获组/时间节点(毫秒)
  264. */
  265. /**
  266. * @typedef {{[config: string]: GMObject_configMap_item}} GMObject_configMap
  267. */
  268. /**
  269. * @typedef GMObject_configMap_item
  270. * @property {*} default 默认值
  271. * @property {'string' | 'boolean' | 'int' | 'float'} [type] 数据类型
  272. * @property {'checked' | 'value' | 'none'} attr 对应 `DOM` 元素上的属性,`none` 表示无对应元素
  273. * @property {boolean} [manual] 配置保存时是否需要手动处理
  274. * @property {boolean} [needNotReload] 配置改变后是否不需要重新加载就能生效
  275. * @property {number} [min] 最小值
  276. * @property {number} [max] 最大值
  277. * @property {number} [configVersion] 涉及配置更改的最后配置版本
  278. */
  279. /**
  280. * @typedef {{[info: string]: GMObject_infoMap_item}} GMObject_infoMap
  281. */
  282. /**
  283. * @typedef GMObject_infoMap_item
  284. * @property {number} [configVersion] 涉及信息更改的最后配置版本
  285. */
  286. /**
  287. * @typedef GMObject_runtime
  288. * @property {'old' | '2022' | '3rd-party'} headerType 顶栏版本
  289. * @property {boolean} reloadWatchlaterListData 刷新稍后再看列表数据
  290. * @property {boolean} loadingWatchlaterListData 正在加载稍后再看列表数据
  291. * @property {*} watchlaterListDataError 稍后再看列表数据加载过程错误(无错误为 `null`);发现错误时 `gm.data.watchlaterListData()` 将获取到旧列表数据
  292. * @property {boolean} savingRemoveHistoryData 正在存储稍后再看历史数据
  293. * @property {number} autoReloadListTid 列表页面自动刷新定时器 ID
  294. */
  295. /**
  296. * @callback removeHistoryData 通过懒加载方式获取稍后再看历史数据
  297. * @param {boolean} [remove] 是否将稍后再看历史数据移除
  298. * @returns {PushQueue<GMObject_data_item>} 稍后再看历史数据
  299. */
  300. /**
  301. * @callback watchlaterListData 通过懒加载方式获取稍后再看列表数据
  302. * @param {boolean} [reload] 是否重新加载稍后再看列表数据
  303. * @param {boolean} [pageCache=false] 是否使用页面缓存
  304. * @param {boolean} [localCache=true] 是否使用本地缓存
  305. * @returns {Promise<GMObject_data_item0[]>} 稍后再看列表数据
  306. */
  307. /**
  308. * `api_queryWatchlaterList` 返回数据中的稿件单元
  309. * @typedef GMObject_data_item0
  310. * @property {number} aid 稿件 AV 号,务必统一为字符串格式再使用
  311. * @property {string} bvid 稿件 BV 号
  312. * @property {string} title 稿件标题
  313. * @property {number} state 稿件状态
  314. * @property {string} [pic] 稿件封面
  315. * @property {Object} [owner] UP主信息
  316. * @property {number} [owner.mid] UP主 ID
  317. * @property {string} [owner.name] UP主名字
  318. * @property {number} [progress] 稿件播放进度
  319. * @property {number} [duration] 稿件时长
  320. * @property {number} [pubdate] 稿件发布时间
  321. * @property {number} [videos] 稿件分P数
  322. * @see {@link https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/history%26toview/toview.md#获取稍后再看视频列表 获取稍后再看视频列表}
  323. */
  324. /**
  325. * @typedef {[bvid: string, title: string, lastModified: number]} GMObject_data_item
  326. * `bvid` 稿件 BV 号
  327. *
  328. * `title` 稿件标题
  329. *
  330. * `[lastModified]` 时间戳:最后被观察到的时间点
  331. */
  332. /**
  333. * @callback fixedItem 访问固定列表项
  334. * @param {string} id 项目标识
  335. * @param {boolean} [op] 不设置 - 只读;`true` - 添加;`false` - 移除
  336. * @returns {boolean} 访问后项目是否在固定列表项中
  337. */
  338. /**
  339. * @typedef GMObject_data
  340. * @property {removeHistoryData} removeHistoryData 稍后再看历史数据
  341. * @property {watchlaterListData} watchlaterListData 当前稍后再看列表数据
  342. * @property {fixedItem} fixedItem 固定列表项
  343. */
  344. /**
  345. * @callback page_userSpace
  346. * @param {string} [uid] `uid`
  347. * @returns {string} 用户空间 URL
  348. */
  349. /**
  350. * @typedef GMObject_url
  351. * @property {string} api_queryWatchlaterList 稍后再看列表数据
  352. * @property {string} api_addToWatchlater 将稿件添加至稍后再看
  353. * @property {string} api_removeFromWatchlater 将稿件从稍后再看移除
  354. * @property {string} api_clearWatchlater 清空稍后再看,要求 POST 一个含 `csrf` 的表单
  355. * @property {string} api_listFav 列出所有收藏夹
  356. * @property {string} api_dealFav 将稿件添加/移除至收藏夹
  357. * @property {string} api_favResourceList 获取收藏夹内容明细列表
  358. * @property {string} api_dynamicList 动态列表
  359. * @property {string} page_watchlaterList 列表页面
  360. * @property {string} page_videoNormalMode 常规播放页
  361. * @property {string} page_videoWatchlaterMode 稍后再看播放页
  362. * @property {string} page_listWatchlaterMode 列表播放页(稍后再看)
  363. * @property {string} page_watchlaterPlayAll 稍后再看播放全部(临时禁用重定向)
  364. * @property {string} page_dynamic 动态页
  365. * @property {page_userSpace} page_userSpace 用户空间
  366. * @property {string} gm_changelog 更新日志
  367. */
  368. /**
  369. * @typedef GMObject_regex
  370. * @property {RegExp} page_watchlaterList 匹配列表页面
  371. * @property {RegExp} page_videoNormalMode 匹配常规播放页
  372. * @property {RegExp} page_videoWatchlaterMode 匹配稍后再看播放页
  373. * @property {RegExp} page_listMode 匹配列表播放页
  374. * @property {RegExp} page_listWatchlaterMode 匹配列表播放页(稍后再看)
  375. * @property {RegExp} page_dynamic 匹配动态页面
  376. * @property {RegExp} page_dynamicMenu 匹配旧版动态面板
  377. * @property {RegExp} page_userSpace 匹配用户空间
  378. * @property {RegExp} page_search 匹配搜索页面
  379. */
  380. /**
  381. * @typedef GMObject_panel
  382. * @property {GMObject_panel_item} setting 设置
  383. * @property {GMObject_panel_item} history 移除记录
  384. * @property {GMObject_panel_item} batchAddManager 批量添加管理器
  385. * @property {GMObject_panel_item} entryPopup 入口弹出面板
  386. */
  387. /**
  388. * @typedef GMObject_panel_item
  389. * @property {0 | 1 | 2 | 3 | -1} state 打开状态(关闭 | 开启中 | 打开 | 关闭中 | 错误)
  390. * @property {0 | 1 | 2} wait 等待阻塞状态(无等待阻塞 | 等待开启 | 等待关闭)
  391. * @property {HTMLElement} el 面板元素
  392. * @property {() => (void | Promise<void>)} [openHandler] 打开面板的回调函数
  393. * @property {() => (void | Promise<void>)} [closeHandler] 关闭面板的回调函数
  394. * @property {() => void} [openedHandler] 彻底打开面板后的回调函数
  395. * @property {() => void} [closedHandler] 彻底关闭面板后的回调函数
  396. */
  397. /**
  398. * 全局对象
  399. * @type {GMObject}
  400. */
  401. const gm = {
  402. id: gmId,
  403. configVersion: GM_getValue('configVersion'),
  404. configUpdate: 20230422,
  405. searchParams: new URL(location.href).searchParams,
  406. config: {},
  407. configMap: {
  408. headerButton: { default: true, attr: 'checked' },
  409. headerButtonOpL: { default: Enums.headerButtonOp.openListInCurrent, attr: 'value', configVersion: 20221008 },
  410. headerButtonOpR: { default: Enums.headerButtonOp.openUserSetting, attr: 'value', configVersion: 20221008 },
  411. headerButtonOpM: { default: Enums.headerButtonOp.openListInNew, attr: 'value', configVersion: 20221008 },
  412. headerMenu: { default: Enums.headerMenu.enable, attr: 'value', configVersion: 20210706 },
  413. openHeaderMenuLink: { default: Enums.openHeaderMenuLink.openInCurrent, attr: 'value', configVersion: 20200717 },
  414. headerMenuKeepRemoved: { default: true, attr: 'checked', needNotReload: true, configVersion: 20210724 },
  415. headerMenuSearch: { default: true, attr: 'checked', configVersion: 20210323.1 },
  416. headerMenuSortControl: { default: true, attr: 'checked', configVersion: 20210810 },
  417. headerMenuAutoRemoveControl: { default: true, attr: 'checked', configVersion: 20210723 },
  418. headerMenuFnSetting: { default: true, attr: 'checked', configVersion: 20210322 },
  419. headerMenuFnHistory: { default: true, attr: 'checked', configVersion: 20210322 },
  420. headerMenuFnExport: { default: false, attr: 'checked', configVersion: 20221008 },
  421. headerMenuFnBatchAdd: { default: false, attr: 'checked', configVersion: 20221008 },
  422. headerMenuFnRemoveAll: { default: false, attr: 'checked', configVersion: 20210322 },
  423. headerMenuFnRemoveWatched: { default: false, attr: 'checked', configVersion: 20210723 },
  424. headerMenuFnShowAll: { default: false, attr: 'checked', configVersion: 20210322 },
  425. headerMenuFnPlayAll: { default: true, attr: 'checked', configVersion: 20210322 },
  426. removeHistory: { default: true, attr: 'checked', manual: true, configVersion: 20210911 },
  427. removeHistorySavePoint: { default: Enums.removeHistorySavePoint.listAndMenu, attr: 'value', configVersion: 20210628 },
  428. removeHistorySavePeriod: { default: 60, type: 'int', attr: 'value', max: 600, needNotReload: true, configVersion: 20210908 },
  429. removeHistoryFuzzyCompare: { default: 1, type: 'int', attr: 'value', max: 5, needNotReload: true, configVersion: 20210722 },
  430. removeHistorySaves: { default: 100, type: 'int', attr: 'value', manual: true, needNotReload: true, min: 10, max: 500, configVersion: 20210808 },
  431. removeHistoryTimestamp: { default: true, attr: 'checked', needNotReload: true, configVersion: 20210703 },
  432. removeHistorySearchTimes: { default: 100, type: 'int', attr: 'value', manual: true, needNotReload: true, min: 1, max: 500, configVersion: 20210819 },
  433. batchAddLoadForward: { default: true, attr: 'checked', configVersion: 20220607, needNotReload: true },
  434. batchAddLoadAfterTimeSync: { default: true, attr: 'checked', configVersion: 20220513, needNotReload: true },
  435. batchAddManagerSnapshotPrefix: { default: 'bwpBAM-snapshot', attr: 'value', configVersion: 20230422, needNotReload: true },
  436. fillWatchlaterStatus: { default: Enums.fillWatchlaterStatus.dynamic, attr: 'value', configVersion: 20200819 },
  437. searchDefaultValue: { default: true, attr: 'checked', configVersion: 20220606 },
  438. autoSort: { default: Enums.autoSort.auto, attr: 'value', configVersion: 20220115 },
  439. videoButton: { default: true, attr: 'checked' },
  440. autoRemove: { default: Enums.autoRemove.openFromList, attr: 'value', configVersion: 20210612 },
  441. redirect: { default: false, attr: 'checked', configVersion: 20210322.1 },
  442. dynamicBatchAddManagerButton: { default: true, attr: 'checked', configVersion: 20210902 },
  443. autoReloadList: { default: 0, type: 'int', attr: 'value', min: 5, max: 600, configVersion: 20220710 },
  444. openListVideo: { default: Enums.openListVideo.openInCurrent, attr: 'value', configVersion: 20200717 },
  445. listStickControl: { default: true, attr: 'checked', configVersion: 20220410 },
  446. listSearch: { default: true, attr: 'checked', configVersion: 20210810.1 },
  447. listSortControl: { default: true, attr: 'checked', configVersion: 20210810 },
  448. listAutoRemoveControl: { default: true, attr: 'checked', configVersion: 20210908 },
  449. listExportWatchlaterListButton: { default: true, attr: 'checked', configVersion: 20221008 },
  450. listBatchAddManagerButton: { default: true, attr: 'checked', configVersion: 20210908 },
  451. removeButton_playAll: { default: false, attr: 'checked', configVersion: 20221008 },
  452. removeButton_removeAll: { default: false, attr: 'checked', configVersion: 20200722 },
  453. removeButton_removeWatched: { default: false, attr: 'checked', configVersion: 20200722 },
  454. headerCompatible: { default: Enums.headerCompatible.none, attr: 'value', configVersion: 20220410 },
  455. menuScrollbarSetting: { default: Enums.menuScrollbarSetting.beautify, attr: 'value', configVersion: 20210808.1 },
  456. mainRunAt: { default: Enums.mainRunAt.DOMContentLoaded, attr: 'value', needNotReload: true, configVersion: 20210726 },
  457. watchlaterListCacheValidPeriod: { default: 15, type: 'int', attr: 'value', needNotReload: true, min: 8, max: 600, configVersion: 20210908 },
  458. hideDisabledSubitems: { default: true, attr: 'checked', configVersion: 20210505 },
  459. reloadAfterSetting: { default: true, attr: 'checked', needNotReload: true, configVersion: 20200715 },
  460.  
  461. importWl_regex: { default: 'bv[\\dA-Za-z]{10}', attr: 'none', configVersion: 20230419 },
  462. importWl_aid: { default: -1, type: 'int', attr: 'none', configVersion: 20230419 },
  463. importWl_bvid: { default: 0, type: 'int', attr: 'none', configVersion: 20230419 },
  464. importWl_title: { default: -1, type: 'int', attr: 'none', configVersion: 20230419 },
  465. importWl_source: { default: -1, type: 'int', attr: 'none', configVersion: 20230419 },
  466. importWl_tsS: { default: -1, type: 'int', attr: 'none', configVersion: 20230419 },
  467. importWl_tsMs: { default: -1, type: 'int', attr: 'none', configVersion: 20230419 },
  468. },
  469. infoMap: {
  470. clearRemoveHistoryData: {},
  471. watchlaterMediaList: { configVersion: 20210822 },
  472. exportWatchlaterList: { configVersion: 20221008 },
  473. importWatchlaterList: { configVersion: 20230419 },
  474. },
  475. runtime: {},
  476. configDocumentStart: ['redirect', 'menuScrollbarSetting', 'mainRunAt'],
  477. data: {},
  478. url: {
  479. api_queryWatchlaterList: 'https://api.bilibili.com/x/v2/history/toview/web',
  480. api_addToWatchlater: 'https://api.bilibili.com/x/v2/history/toview/add',
  481. api_removeFromWatchlater: 'https://api.bilibili.com/x/v2/history/toview/del',
  482. api_clearWatchlater: 'https://api.bilibili.com/x/v2/history/toview/clear',
  483. api_listFav: 'https://api.bilibili.com/x/v3/fav/folder/created/list-all',
  484. api_favResourceList: 'https://api.bilibili.com/x/v3/fav/resource/list',
  485. api_dealFav: 'https://api.bilibili.com/x/v3/fav/resource/deal',
  486. api_dynamicList: 'https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all',
  487. page_watchlaterList: 'https://www.bilibili.com/watchlater/#/list',
  488. page_videoNormalMode: 'https://www.bilibili.com/video',
  489. page_videoWatchlaterMode: 'https://www.bilibili.com/medialist/play/watchlater',
  490. page_listWatchlaterMode: 'https://www.bilibili.com/list/watchlater',
  491. page_watchlaterPlayAll: `https://www.bilibili.com/list/watchlater?${gmId}_disable_redirect=true`,
  492. page_dynamic: 'https://t.bilibili.com',
  493. page_userSpace: uid => `https://space.bilibili.com/${uid}`,
  494. gm_changelog: 'https://gitee.com/liangjiancang/userscript/blob/master/script/BilibiliWatchlaterPlus/changelog.md',
  495. },
  496. regex: {
  497. // 只要第一个「#」后是「/list([/?#]|$)」即被视为列表页面
  498. // B站并不会将「#/list」之后的「[/?#]」视为锚点的一部分,这不符合 URL 规范,但只能将错就错了
  499. page_watchlaterList: /\.com\/watchlater\/[^#]*#\/list([#/?]|$)/,
  500. page_videoNormalMode: /\.com\/video([#/?]|$)/,
  501. page_videoWatchlaterMode: /\.com\/medialist\/play\/(watchlater|ml\d+)([#/?]|$)/,
  502. page_listMode: /\.com\/list\/.+/,
  503. page_listWatchlaterMode: /\.com\/list\/watchlater([#/?]|$)/,
  504. page_dynamic: /\/t\.bilibili\.com(\/|$)/,
  505. page_dynamicMenu: /\.com\/pages\/nav\/index_new([#/?]|$)/,
  506. page_userSpace: /space\.bilibili\.com([#/?]|$)/,
  507. page_search: /search\.bilibili\.com\/.+/, // 不含搜索主页
  508. },
  509. const: {
  510. fadeTime: 400,
  511. textFadeTime: 100,
  512. noticeTimeout: 5600,
  513. updateHighlightColor: '#4cff9c',
  514. inputThrottleWait: 250,
  515. batchAddRequestInterval: 350,
  516. fixerHint: '固定在列表最后,并禁用自动移除及排序功能\n右键点击可取消所有固定项',
  517. searchDefaultValueHint: '右键点击保存默认值,中键点击清空默认值\n当前默认值:$1',
  518. exportWatchlaterList_default: '导出至剪贴板 = 是\n导出至新页面 = 否\n导出至文件 = 否\n稿件导出模板 = \'https://www.bilibili.com/video/${ITEM.bvid}\'',
  519. },
  520. panel: {
  521. setting: { state: 0, wait: 0, el: null },
  522. history: { state: 0, wait: 0, el: null },
  523. batchAddManager: { state: 0, wait: 0, el: null },
  524. entryPopup: { state: 0, wait: 0, el: null },
  525. },
  526. el: {
  527. gmRoot: null,
  528. setting: null,
  529. history: null,
  530. },
  531. }
  532.  
  533. const api = new UserscriptAPI({
  534. id: gm.id,
  535. label: GM_info.script.name,
  536. fadeTime: gm.const.fadeTime,
  537. })
  538.  
  539. /** @type {Script} */
  540. let script = null
  541. /** @type {Webpage} */
  542. let webpage = null
  543.  
  544. /**
  545. * 脚本运行的抽象,为脚本本身服务的核心功能
  546. */
  547. class Script {
  548. /** 内部数据 */
  549. #data = {}
  550.  
  551. /** 通用方法 */
  552. method = {
  553. /**
  554. * GM 读取流程
  555. *
  556. * 一般情况下,读取用户配置;如果配置出错,则沿用默认值,并将默认值写入配置中
  557. * @param {string} gmKey 键名
  558. * @param {*} defaultValue 默认值
  559. * @param {boolean} [writeback=true] 配置出错时是否将默认值回写入配置中
  560. * @returns {*} 通过校验时是配置值,不能通过校验时是默认值
  561. */
  562. getConfig(gmKey, defaultValue, writeback = true) {
  563. let invalid = false
  564. let value = GM_getValue(gmKey)
  565. if (Enums && gmKey in Enums) {
  566. if (!Object.values(Enums[gmKey]).includes(value)) {
  567. invalid = true
  568. }
  569. } else if (typeof value === typeof defaultValue) { // 对象默认赋 null 无需额外处理
  570. const { type } = gm.configMap[gmKey]
  571. if (type === 'int' || type === 'float') {
  572. invalid = gm.configMap[gmKey].min > value || gm.configMap[gmKey].max < value
  573. }
  574. } else {
  575. invalid = true
  576. }
  577. if (invalid) {
  578. value = defaultValue
  579. writeback && GM_setValue(gmKey, value)
  580. }
  581. return value
  582. },
  583.  
  584. /**
  585. * 重置脚本
  586. */
  587. reset() {
  588. const gmKeys = GM_listValues()
  589. for (const gmKey of gmKeys) {
  590. GM_deleteValue(gmKey)
  591. }
  592. },
  593. }
  594.  
  595. /**
  596. * document-start 级别初始化
  597. */
  598. initAtDocumentStart() {
  599. if (gm.configVersion > 0) {
  600. for (const name of gm.configDocumentStart) {
  601. gm.config[name] = this.method.getConfig(name, gm.configMap[name].default)
  602. }
  603. }
  604. }
  605.  
  606. /**
  607. * 初始化
  608. */
  609. init() {
  610. try {
  611. this.initGMObject()
  612. this.updateVersion()
  613. this.readConfig()
  614.  
  615. if (self === top) {
  616. if (gm.config.searchDefaultValue) {
  617. GM_addValueChangeListener('searchDefaultValue_value', (name, oldVal, newVal) => window.dispatchEvent(new CustomEvent('updateSearchTitle', { detail: { value: newVal } })))
  618. }
  619. }
  620. } catch (e) {
  621. api.logger.error(e)
  622. api.message.confirm('初始化错误!是否彻底清空内部数据以重置脚本?').then(result => {
  623. if (result) {
  624. this.method.reset()
  625. location.reload()
  626. }
  627. })
  628. }
  629. }
  630.  
  631. /**
  632. * 初始化全局对象
  633. */
  634. initGMObject() {
  635. gm.data = {
  636. ...gm.data,
  637. removeHistoryData: remove => {
  638. const $data = this.#data
  639. if (remove) {
  640. $data.removeHistoryData = undefined
  641. return
  642. }
  643. if ($data.removeHistoryData == null) {
  644. /** @type {PushQueue<GMObject_data_item>} */
  645. let data = GM_getValue('removeHistoryData')
  646. if (data && typeof data === 'object') {
  647. Reflect.setPrototypeOf(data, PushQueue.prototype) // 初始化替换原型不会影响内联缓存
  648. if (data.maxSize !== gm.config.removeHistorySaves) {
  649. data.setMaxSize(gm.config.removeHistorySaves)
  650. GM_setValue('removeHistoryData', data)
  651. }
  652. } else {
  653. data = new PushQueue(gm.config.removeHistorySaves)
  654. GM_setValue('removeHistoryData', data)
  655. }
  656. $data.removeHistoryData = data
  657. }
  658. return $data.removeHistoryData
  659. },
  660. watchlaterListData: async (reload, pageCache, localCache = true) => {
  661. const $data = this.#data
  662. gm.runtime.watchlaterListDataError = null
  663. if (gm.runtime.reloadWatchlaterListData) {
  664. reload = true
  665. gm.runtime.reloadWatchlaterListData = false
  666. }
  667. if ($data.watchlaterListData == null || reload || !pageCache) {
  668. if (gm.runtime.loadingWatchlaterListData) {
  669. // 一旦数据已在加载中,那么直接等待该次加载完成
  670. // 无论加载成功与否,所有被阻塞的数据请求均都使用该次加载的结果,完全保持一致
  671. // 注意:加载失败时,返回的空数组并非同一对象
  672. try {
  673. return await api.wait.waitForConditionPassed({
  674. condition: () => {
  675. if (!gm.runtime.loadingWatchlaterListData) {
  676. return $data.watchlaterListData ?? []
  677. }
  678. },
  679. })
  680. } catch (e) {
  681. gm.runtime.watchlaterListDataError = e
  682. gm.runtime.loadingWatchlaterListData = false
  683. api.logger.error(e)
  684. return $data.watchlaterListData ?? []
  685. }
  686. }
  687.  
  688. if (!reload && localCache && gm.config.watchlaterListCacheValidPeriod > 0) {
  689. const cacheTime = GM_getValue('watchlaterListCacheTime')
  690. if (cacheTime) {
  691. const current = Date.now()
  692. if (current - cacheTime < gm.config.watchlaterListCacheValidPeriod * 1000) {
  693. const list = GM_getValue('watchlaterListCache')
  694. if (list) {
  695. $data.watchlaterListData = list
  696. return list // 默认缓存不为空
  697. }
  698. }
  699. }
  700. }
  701.  
  702. gm.runtime.loadingWatchlaterListData = true
  703. try {
  704. const resp = await api.web.request({
  705. url: gm.url.api_queryWatchlaterList,
  706. }, { check: r => r.code === 0 })
  707. const current = resp.data.list ?? []
  708. if (gm.config.watchlaterListCacheValidPeriod > 0) {
  709. GM_setValue('watchlaterListCacheTime', Date.now())
  710. GM_setValue('watchlaterListCache', current.map(item => ({
  711. aid: item.aid,
  712. bvid: item.bvid,
  713. title: item.title,
  714. state: item.state,
  715. pic: item.pic,
  716. owner: {
  717. mid: item.owner.mid,
  718. name: item.owner.name,
  719. },
  720. progress: item.progress,
  721. duration: item.duration,
  722. pubdate: item.pubdate,
  723. videos: item.videos,
  724. })))
  725. }
  726. $data.watchlaterListData = current
  727. return current
  728. } catch (e) {
  729. api.logger.error(e)
  730. gm.runtime.watchlaterListDataError = e
  731. return $data.watchlaterListData ?? []
  732. } finally {
  733. gm.runtime.loadingWatchlaterListData = false
  734. }
  735. } else {
  736. return $data.watchlaterListData
  737. }
  738. },
  739. fixedItem: (id, op) => {
  740. const items = GM_getValue('fixedItems') ?? []
  741. const idx = items.indexOf(id)
  742. const fixed = idx >= 0
  743. if (op == null) return fixed
  744. if (op) {
  745. if (!fixed) {
  746. items.push(id)
  747. GM_setValue('fixedItems', items)
  748. }
  749. return true
  750. } else {
  751. if (fixed) {
  752. items.splice(idx, 1)
  753. GM_setValue('fixedItems', items)
  754. }
  755. return false
  756. }
  757. },
  758. }
  759.  
  760. gm.el.gmRoot = document.createElement('div')
  761. gm.el.gmRoot.id = gm.id
  762. api.wait.executeAfterElementLoaded({ // body 已存在时无异步
  763. selector: 'body',
  764. callback: body => body.append(gm.el.gmRoot),
  765. })
  766. }
  767.  
  768. /**
  769. * 版本更新处理
  770. */
  771. updateVersion() {
  772. if (gm.configVersion >= 20211013) { // 4.23.12.20211013
  773. if (gm.configVersion < gm.configUpdate) {
  774. // 必须按从旧到新的顺序写
  775. // 内部不能使用 gm.configUpdate,必须手写更新后的配置版本号!
  776.  
  777. // 4.24.1.20220104
  778. if (gm.configVersion < 20220104) {
  779. GM_deleteValue('hideWatchlaterInCollect')
  780. }
  781.  
  782. // 4.24.4.20220115
  783. if (gm.configVersion < 20220115) {
  784. GM_deleteValue('watchlaterListCacheTime')
  785. GM_deleteValue('watchlaterListCache')
  786. }
  787.  
  788. // 4.26.13.20220513
  789. if (gm.configVersion < 20220513) {
  790. GM_deleteValue('batchAddLoadAfterTimeSync')
  791. }
  792.  
  793. // 4.27.0.20220605
  794. if (gm.configVersion < 20220605) {
  795. const bp = GM_getValue('batchParams')
  796. if (bp && (!bp.id4a || Number.parseInt(bp.id4a) < 350)) {
  797. bp.id4a = '350'
  798. GM_setValue('batchParams', bp)
  799. }
  800. }
  801.  
  802. // 功能性更新后更新此处配置版本,通过时跳过功能性更新设置,否则转至 readConfig() 中处理
  803. if (gm.configVersion >= 20230422) {
  804. gm.configVersion = gm.configUpdate
  805. GM_setValue('configVersion', gm.configVersion)
  806. }
  807. }
  808. } else {
  809. this.method.reset()
  810. gm.configVersion = null
  811. }
  812. }
  813.  
  814. /**
  815. * 用户配置读取
  816. */
  817. readConfig() {
  818. if (gm.configVersion > 0) {
  819. for (const [name, item] of Object.entries(gm.configMap)) {
  820. if (!gm.configDocumentStart.includes(name)) {
  821. gm.config[name] = this.method.getConfig(name, item.default)
  822. }
  823. }
  824. if (gm.configVersion !== gm.configUpdate) {
  825. this.openUserSetting(2)
  826. }
  827. } else {
  828. // 用户强制初始化,或第一次安装脚本,或版本过旧
  829. gm.configVersion = 0
  830. for (const [name, item] of Object.entries(gm.configMap)) {
  831. gm.config[name] = item.default
  832. GM_setValue(name, item.default)
  833. }
  834. this.openUserSetting(1)
  835. }
  836. }
  837.  
  838. /**
  839. * 添加脚本菜单
  840. */
  841. addScriptMenu() {
  842. // 用户配置设置
  843. GM_registerMenuCommand('用户设置', () => this.openUserSetting())
  844. // 批量添加管理器
  845. GM_registerMenuCommand('批量添加管理器', () => this.openBatchAddManager())
  846. if (gm.config.removeHistory) {
  847. // 稍后再看移除记录
  848. GM_registerMenuCommand('稍后再看移除记录', () => this.openRemoveHistory())
  849. }
  850. GM_registerMenuCommand('导出稍后再看列表', () => this.exportWatchlaterList())
  851. // 强制初始化
  852. GM_registerMenuCommand('初始化脚本', () => this.resetScript())
  853. }
  854.  
  855. /**
  856. * 打开用户设置
  857. * @param {number} [type=0] 常规 `0` | 初始化 `1` | 功能性更新 `2`
  858. */
  859. openUserSetting(type = 0) {
  860. if (gm.el.setting) {
  861. this.openPanelItem('setting')
  862. } else {
  863. /** @type {{[n: string]: HTMLElement}} */
  864. const el = {}
  865. setTimeout(() => {
  866. initSetting()
  867. processConfigItem()
  868. processSettingItem()
  869. this.openPanelItem('setting')
  870. })
  871.  
  872. /**
  873. * 设置页初始化
  874. */
  875. const initSetting = () => {
  876. gm.el.setting = gm.el.gmRoot.appendChild(document.createElement('div'))
  877. gm.panel.setting.el = gm.el.setting
  878. gm.el.setting.className = 'gm-setting gm-modal-container'
  879. if (gm.config.hideDisabledSubitems) {
  880. gm.el.setting.classList.add('gm-hideDisabledSubitems')
  881. }
  882.  
  883. const getItemHTML = (label, ...items) => {
  884. let html = `<div class="gm-item-container"><div class="gm-item-label">${label}</div><div class="gm-item-content">`
  885. for (const item of items) {
  886. html += `<div class="gm-item${item.className ? ` ${item.className}` : ''}"${item.desc ? ` title="${item.desc}"` : ''}>${item.html}</div>`
  887. }
  888. html += '</div></div>'
  889. return html
  890. }
  891. let itemsHTML = ''
  892. itemsHTML += getItemHTML('全局功能', {
  893. desc: '在顶栏「动态」和「收藏」之间加入稍后再看入口,鼠标移至上方时弹出列表面板,支持点击功能设置。',
  894. html: `<label>
  895. <span>顶栏稍后再看入口</span>
  896. <input id="gm-headerButton" type="checkbox">
  897. </label>`,
  898. }, {
  899. desc: '选择左键点击入口时执行的操作。',
  900. html: `<div>
  901. <span>在入口上点击鼠标左键时</span>
  902. <select id="gm-headerButtonOpL"></select>
  903. </div>`,
  904. }, {
  905. desc: '选择右键点击入口时执行的操作。',
  906. html: `<div>
  907. <span>在入口上点击鼠标右键时</span>
  908. <select id="gm-headerButtonOpR"></select>
  909. </div>`,
  910. }, {
  911. desc: '选择中键点击入口时执行的操作。',
  912. html: `<div>
  913. <span>在入口上点击鼠标中键时</span>
  914. <select id="gm-headerButtonOpM"></select>
  915. </div>`,
  916. }, {
  917. desc: '设置入口弹出面板。',
  918. html: `<div>
  919. <span>将鼠标移动至入口上方时</span>
  920. <select id="gm-headerMenu">
  921. <option value="${Enums.headerMenu.enable}">弹出稍后再看列表</option>
  922. <option value="${Enums.headerMenu.enableSimple}">弹出简化的稍后再看列表</option>
  923. <option value="${Enums.headerMenu.disable}">不执行操作</option>
  924. </select>
  925. </div>`,
  926. }, {
  927. desc: '选择在弹出面板中点击链接的行为。',
  928. html: `<div>
  929. <span>在弹出面板中点击链接时</span>
  930. <select id="gm-openHeaderMenuLink">
  931. <option value="${Enums.openHeaderMenuLink.openInCurrent}">在当前页面打开稿件</option>
  932. <option value="${Enums.openHeaderMenuLink.openInNew}">在新页面打开稿件</option>
  933. </select>
  934. </div>`,
  935. }, {
  936. desc: '在弹出面板中显示自当前页面打开以来从弹出面板移除的稿件。',
  937. html: `<label>
  938. <span>在弹出面板中显示被移除的稿件</span>
  939. <input id="gm-headerMenuKeepRemoved" type="checkbox">
  940. </label>`,
  941. }, {
  942. desc: '在弹出面板顶部显示搜索框。',
  943. html: `<label>
  944. <span>在弹出面板顶部显示搜索框</span>
  945. <input id="gm-headerMenuSearch" type="checkbox">
  946. </label>`,
  947. }, {
  948. desc: '在弹出面板底部显示排序控制器。',
  949. html: `<label>
  950. <span>在弹出面板底部显示排序控制器</span>
  951. <input id="gm-headerMenuSortControl" type="checkbox">
  952. </label>`,
  953. }, {
  954. desc: '在弹出面板底部显示自动移除控制器。',
  955. html: `<label>
  956. <span>在弹出面板底部显示自动移除控制器</span>
  957. <input id="gm-headerMenuAutoRemoveControl" type="checkbox">
  958. </label>`,
  959. }, {
  960. desc: '设置在弹出列表显示的快捷功能。',
  961. html: `<div>
  962. <span>在弹出面板底部显示:</span>
  963. <span class="gm-lineitems">
  964. <label class="gm-lineitem">
  965. <span>设置</span><input id="gm-headerMenuFnSetting" type="checkbox">
  966. </label>
  967. <label class="gm-lineitem">
  968. <span>历史</span><input id="gm-headerMenuFnHistory" type="checkbox">
  969. </label>
  970. <label class="gm-lineitem">
  971. <span>导出</span><input id="gm-headerMenuFnExport" type="checkbox">
  972. </label>
  973. <label class="gm-lineitem">
  974. <span>批量添加</span><input id="gm-headerMenuFnBatchAdd" type="checkbox">
  975. </label>
  976. <label class="gm-lineitem">
  977. <span>清空</span><input id="gm-headerMenuFnRemoveAll" type="checkbox">
  978. </label>
  979. <label class="gm-lineitem">
  980. <span>移除已看</span><input id="gm-headerMenuFnRemoveWatched" type="checkbox">
  981. </label>
  982. <label class="gm-lineitem">
  983. <span>显示</span><input id="gm-headerMenuFnShowAll" type="checkbox">
  984. </label>
  985. <label class="gm-lineitem">
  986. <span>播放</span><input id="gm-headerMenuFnPlayAll" type="checkbox">
  987. </label>
  988. </span>
  989. </div>`,
  990. })
  991. itemsHTML += getItemHTML('全局功能', {
  992. desc: '保留稍后再看列表中的数据,以查找出一段时间内将哪些稿件移除出稍后再看,用于拯救误删操作。关闭该选项会将内部历史数据清除!',
  993. html: `<label>
  994. <span>稍后再看移除记录</span>
  995. <input id="gm-removeHistory" type="checkbox">
  996. <span id="gm-rhWarning" class="gm-warning" title>⚠</span>
  997. </label>`,
  998. }, {
  999. desc: '选择在何时保存稍后再看历史数据。',
  1000. html: `<div>
  1001. <span>为生成移除记录,</span>
  1002. <select id="gm-removeHistorySavePoint">
  1003. <option value="${Enums.removeHistorySavePoint.list}">在打开列表页面时保存数据</option>
  1004. <option value="${Enums.removeHistorySavePoint.listAndMenu}">在打开列表页面或弹出面板时保存数据</option>
  1005. <option value="${Enums.removeHistorySavePoint.anypage}">在打开任意相关页面时保存数据</option>
  1006. </select>
  1007. </div>`,
  1008. }, {
  1009. desc: '距离上一次保存稍后再看历史数据间隔超过该时间,才会再次进行保存。',
  1010. html: `<div>
  1011. <span>数据保存最小时间间隔(单位:秒)</span>
  1012. <input is="laster2800-input-number" id="gm-removeHistorySavePeriod" value="${gm.configMap.removeHistorySavePeriod.default}" max="${gm.configMap.removeHistorySavePeriod.max}">
  1013. </div>`,
  1014. }, {
  1015. desc: '设置模糊比对深度以快速舍弃重复数据从而降低开销,但可能会造成部分记录遗漏。',
  1016. html: `<div>
  1017. <span>模糊比对模式深度</span>
  1018. <span id="gm-rhfcInformation" class="gm-information" title>💬</span>
  1019. <input is="laster2800-input-number" id="gm-removeHistoryFuzzyCompare" value="${gm.configMap.removeHistoryFuzzyCompare.default}" max="${gm.configMap.removeHistoryFuzzyCompare.max}">
  1020. </div>`,
  1021. }, {
  1022. desc: '较大的数值可能会带来较大的开销(具体参考右侧弹出说明)。将该项修改为比原来小的值会清理过期数据,无法恢复!',
  1023. html: `<div>
  1024. <span>不重复数据记录保存数</span>
  1025. <span id="gm-rhsInformation" class="gm-information" title>💬</span>
  1026. <span id="gm-clearRemoveHistoryData" class="gm-info" title="清理已保存的稍后再看历史数据,不可恢复!">清空数据(0条)</span>
  1027. <input is="laster2800-input-number" id="gm-removeHistorySaves" value="${gm.configMap.removeHistorySaves.default}" min="${gm.configMap.removeHistorySaves.min}" max="${gm.configMap.removeHistorySaves.max}">
  1028. </div>`,
  1029. }, {
  1030. desc: '在稍后再看历史数据记录中保存时间戳,以其优化对数据记录的排序及展示。',
  1031. html: `<label>
  1032. <span>使用时间戳优化移除记录</span>
  1033. <span id="gm-rhtInformation" class="gm-information" title>💬</span>
  1034. <input id="gm-removeHistoryTimestamp" type="checkbox">
  1035. </label>`,
  1036. }, {
  1037. desc: '搜寻时在最近多少条数据记录中查找,设置较小的值能较好地定位最近被添加到稍后再看的稿件。',
  1038. html: `<div>
  1039. <span>默认历史回溯深度</span>
  1040. <input is="laster2800-input-number" id="gm-removeHistorySearchTimes" value="${gm.configMap.removeHistorySearchTimes.default}" min="${gm.configMap.removeHistorySearchTimes.min}" max="${gm.configMap.removeHistorySearchTimes.max}">
  1041. </div>`,
  1042. })
  1043. itemsHTML += getItemHTML('全局功能', {
  1044. html: '<div class="gm-holder-item">批量添加:</div>',
  1045. }, {
  1046. desc: '在批量添加管理器中,执行加载步骤时是否加载关注者转发的稿件?',
  1047. html: `<label>
  1048. <span>加载关注者转发的稿件</span>
  1049. <input id="gm-batchAddLoadForward" type="checkbox">
  1050. </label>`,
  1051. }, {
  1052. desc: '在批量添加管理器中,执行时间同步后,是否自动执行稿件加载步骤?',
  1053. html: `<label>
  1054. <span>执行时间同步后是否自动加载稿件</span>
  1055. <span id="gm-balatsInformation" class="gm-information" title>💬</span>
  1056. <input id="gm-batchAddLoadAfterTimeSync" type="checkbox">
  1057. </label>`,
  1058. }, {
  1059. desc: '设置批量添加管理器快照文件名称前缀。',
  1060. html: `<label>
  1061. <span>文件快照前缀:</span>
  1062. <input id="gm-batchAddManagerSnapshotPrefix" type="text">
  1063. </label>`,
  1064. })
  1065. itemsHTML += getItemHTML('全局功能', {
  1066. desc: '填充默认情况下缺失的稍后再看状态信息。',
  1067. html: `<div>
  1068. <span>填充缺失的稍后再看状态信息:</span>
  1069. <select id="gm-fillWatchlaterStatus">
  1070. <option value="${Enums.fillWatchlaterStatus.dynamic}">仅动态页面</option>
  1071. <option value="${Enums.fillWatchlaterStatus.dynamicAndVideo}">仅动态和视频播放页面</option>
  1072. <option value="${Enums.fillWatchlaterStatus.anypage}">所有页面</option>
  1073. <option value="${Enums.fillWatchlaterStatus.never}">禁用功能</option>
  1074. </select>
  1075. <span id="gm-fwsInformation" class="gm-information" title>💬</span>
  1076. </div>`,
  1077. })
  1078. itemsHTML += getItemHTML('全局功能', {
  1079. desc: '激活后在搜索框上右键点击保存默认值,中键点击清空默认值。',
  1080. html: `<label>
  1081. <span>搜索:激活搜索框默认值功能</span>
  1082. <span id="gm-sdvInformation" class="gm-information" title>💬</span>
  1083. <input id="gm-searchDefaultValue" type="checkbox">
  1084. </label>`,
  1085. })
  1086. itemsHTML += getItemHTML('全局功能', {
  1087. desc: '决定首次打开列表页面或弹出面板时,如何对稍后再看列表内容进行排序。',
  1088. html: `<div>
  1089. <span>自动排序:</span>
  1090. <select id="gm-autoSort">
  1091. <option value="${Enums.autoSort.auto}">使用上一次排序控制器的选择</option>
  1092. <option value="${Enums.autoSort.default}">禁用功能</option>
  1093. <option value="${Enums.autoSort.defaultR}">使用 [ 默认↓ ] 排序</option>
  1094. <option value="${Enums.autoSort.duration}">使用 [ 时长 ] 排序</option>
  1095. <option value="${Enums.autoSort.durationR}">使用 [ 时长↓ ] 排序</option>
  1096. <option value="${Enums.autoSort.pubtime}">使用 [ 发布 ] 排序</option>
  1097. <option value="${Enums.autoSort.pubtimeR}">使用 [ 发布↓ ] 排序</option>
  1098. <option value="${Enums.autoSort.progress}">使用 [ 进度 ] 排序</option>
  1099. <option value="${Enums.autoSort.uploader}">使用 [ UP ] 排序</option>
  1100. <option value="${Enums.autoSort.title}">使用 [ 标题 ] 排序</option>
  1101. <option value="${Enums.autoSort.fixed}">使用 [ 固定 ] 排序</option>
  1102. </select>
  1103. </div>`,
  1104. })
  1105. itemsHTML += getItemHTML('全局功能', {
  1106. desc: '指定使用收藏功能时,将稿件从稍后再看移动至哪个收藏夹。',
  1107. html: `<div>
  1108. <span>稍后再看收藏夹</span>
  1109. <span id="gm-watchlaterMediaList" class="gm-info">设置</span>
  1110. </div>`,
  1111. })
  1112. itemsHTML += getItemHTML('全局功能', {
  1113. desc: '设置稍后再看列表导出方式。',
  1114. html: `<div>
  1115. <span>导出稍后再看列表</span>
  1116. <span id="gm-exportWatchlaterList" class="gm-info">设置</span>
  1117. </div>`,
  1118. }, {
  1119. desc: '设置稍后再看列表导入方式。该功能入口在批量添加管理器中。',
  1120. html: `<div>
  1121. <span>导入稍后再看列表</span>
  1122. <span id="gm-importWatchlaterList" class="gm-info">设置</span>
  1123. <span id="gm-iwlInformation" class="gm-information" title>💬</span>
  1124. </div>`,
  1125. })
  1126. itemsHTML += getItemHTML('播放页面', {
  1127. desc: '在播放页面中加入能将稿件快速添加或移除出稍后再看列表的按钮。',
  1128. html: `<label>
  1129. <span>加入快速切换稿件稍后再看状态的按钮</span>
  1130. <input id="gm-videoButton" type="checkbox">
  1131. </label>`,
  1132. })
  1133. itemsHTML += getItemHTML('播放页面', {
  1134. desc: '打开播放页面时,自动将稿件从稍后再看列表中移除,或在特定条件下执行自动移除。',
  1135. html: `<div>
  1136. <span>打开页面时,</span>
  1137. <select id="gm-autoRemove">
  1138. <option value="${Enums.autoRemove.always}">若稿件在稍后再看中,则移除出稍后再看</option>
  1139. <option value="${Enums.autoRemove.openFromList}">若是从列表页面或弹出面板点击进入,则移除出稍后再看</option>
  1140. <option value="${Enums.autoRemove.never}">不执行自动移除功能(可通过自动移除控制器临时开启)</option>
  1141. <option value="${Enums.autoRemove.absoluteNever}">彻底禁用自动移除功能</option>
  1142. </select>
  1143. </div>`,
  1144. })
  1145. itemsHTML += getItemHTML('播放页面', {
  1146. desc: `打开「${gm.url.page_listWatchlaterMode}」或「${gm.url.page_videoWatchlaterMode}」页面时,自动切换至「${gm.url.page_videoNormalMode}」页面进行播放,但不影响「播放全部」等相关功能。`,
  1147. html: `<label>
  1148. <span>从稍后再看模式强制切换到常规模式播放(重定向)</span>
  1149. <input id="gm-redirect" type="checkbox">
  1150. </label>`,
  1151. })
  1152. itemsHTML += getItemHTML('动态主页', {
  1153. desc: '批量添加管理器可以将投稿批量添加到稍后再看。',
  1154. html: `<label>
  1155. <span>显示批量添加管理器按钮</span>
  1156. <input id="gm-dynamicBatchAddManagerButton" type="checkbox">
  1157. </label>`,
  1158. })
  1159. itemsHTML += getItemHTML('列表页面', {
  1160. desc: `设置「${gm.url.page_watchlaterList}」页面的自动刷新策略。`,
  1161. html: `<div>
  1162. <span>自动刷新时间间隔(单位:分钟)</span>
  1163. <span id="gm-arlInformation" class="gm-information" title>💬</span>
  1164. <input is="laster2800-input-number" id="gm-autoReloadList" value="${gm.configMap.autoReloadList.default}" min="${gm.configMap.autoReloadList.min}" max="${gm.configMap.autoReloadList.max}" allow-zero="true">
  1165. </div>`,
  1166. })
  1167. itemsHTML += getItemHTML('列表页面', {
  1168. desc: `设置在「${gm.url.page_watchlaterList}」页面点击稿件时的行为。`,
  1169. html: `<div>
  1170. <span>点击稿件时</span>
  1171. <select id="gm-openListVideo">
  1172. <option value="${Enums.openListVideo.openInCurrent}">在当前页面打开</option>
  1173. <option value="${Enums.openListVideo.openInNew}">在新页面打开</option>
  1174. </select>
  1175. </div>`,
  1176. })
  1177. itemsHTML += getItemHTML('列表页面', {
  1178. desc: '控制栏跟随页面滚动,建议配合「[相关调整] 将顶栏固定在页面顶部」使用。',
  1179. html: `<label>
  1180. <span>控制栏随页面滚动</span>
  1181. <input id="gm-listStickControl" type="checkbox">
  1182. </label>`,
  1183. })
  1184. itemsHTML += getItemHTML('列表页面', {
  1185. desc: '在列表页面显示……',
  1186. html: `<div>
  1187. <span>显示组件:</span>
  1188. <span class="gm-lineitems">
  1189. <label class="gm-lineitem">
  1190. <span>搜索框</span><input id="gm-listSearch" type="checkbox">
  1191. </label>
  1192. <label class="gm-lineitem">
  1193. <span>排序控制器</span><input id="gm-listSortControl" type="checkbox">
  1194. </label>
  1195. <label class="gm-lineitem">
  1196. <span>自动移除控制器</span><input id="gm-listAutoRemoveControl" type="checkbox">
  1197. </label>
  1198. <label class="gm-lineitem">
  1199. <span>列表导出按钮</span><input id="gm-listExportWatchlaterListButton" type="checkbox">
  1200. </label>
  1201. <label class="gm-lineitem">
  1202. <span>批量添加管理器按钮</span><input id="gm-listBatchAddManagerButton" type="checkbox">
  1203. </label>
  1204. </span>
  1205. </div>`,
  1206. })
  1207. itemsHTML += getItemHTML('列表页面', {
  1208. desc: '在列表页面移除……',
  1209. html: `<div>
  1210. <span>移除组件:</span>
  1211. <span class="gm-lineitems">
  1212. <label class="gm-lineitem">
  1213. <span>全部播放</span><input id="gm-removeButton_playAll" type="checkbox">
  1214. </label>
  1215. <label class="gm-lineitem">
  1216. <span>一键清空</span><input id="gm-removeButton_removeAll" type="checkbox">
  1217. </label>
  1218. <label class="gm-lineitem">
  1219. <span>移除已观看视频</span><input id="gm-removeButton_removeWatched" type="checkbox">
  1220. </label>
  1221. </span>
  1222. </div>`,
  1223. })
  1224. itemsHTML += getItemHTML('相关调整', {
  1225. desc: '无须兼容第三方顶栏时务必选择「无」,否则脚本无法正常工作!\n若列表中没有提供你需要的第三方顶栏,且该第三方顶栏有一定用户基数,可在脚本反馈页发起请求。',
  1226. html: `<div>
  1227. <span>兼容第三方顶栏:</span>
  1228. <select id="gm-headerCompatible">
  1229. <option value="${Enums.headerCompatible.none}">无</option>
  1230. <option value="${Enums.headerCompatible.bilibiliEvolved}">Bilibili Evolved</option>
  1231. </select>
  1232. <span id="gm-hcWarning" class="gm-warning gm-trailing" title>⚠</span>
  1233. </div>`,
  1234. })
  1235. itemsHTML += getItemHTML('相关调整', {
  1236. desc: '对顶栏各入口弹出面板中滚动条的样式进行设置。',
  1237. html: `<div>
  1238. <span>对于弹出面板中的滚动条</span>
  1239. <select id="gm-menuScrollbarSetting">
  1240. <option value="${Enums.menuScrollbarSetting.beautify}">修改其外观为现代风格</option>
  1241. <option value="${Enums.menuScrollbarSetting.hidden}">将其隐藏(不影响鼠标滚动)</option>
  1242. <option value="${Enums.menuScrollbarSetting.original}">维持官方的滚动条样式</option>
  1243. </select>
  1244. </div>`,
  1245. })
  1246. itemsHTML += getItemHTML('脚本设置', {
  1247. desc: '选择脚本主要逻辑的运行时期。',
  1248. html: `<div>
  1249. <span>脚本运行时期:</span>
  1250. <select id="gm-mainRunAt">
  1251. <option value="${Enums.mainRunAt.DOMContentLoaded}">DOMContentLoaded</option>
  1252. <option value="${Enums.mainRunAt.load}">load</option>
  1253. </select>
  1254. <span id="gm-mraInformation" class="gm-information" title>💬</span>
  1255. </div>`,
  1256. })
  1257. itemsHTML += getItemHTML('脚本设置', {
  1258. desc: '稍后再看列表数据本地缓存有效期(单位:秒)',
  1259. html: `<div>
  1260. <span>稍后再看列表数据本地缓存有效期(单位:秒)</span>
  1261. <span id="gm-wlcvpInformation" class="gm-information" title>💬</span>
  1262. <input is="laster2800-input-number" id="gm-watchlaterListCacheValidPeriod" value="${gm.configMap.watchlaterListCacheValidPeriod.default}" min="${gm.configMap.watchlaterListCacheValidPeriod.min}" max="${gm.configMap.watchlaterListCacheValidPeriod.max}">
  1263. </div>`,
  1264. })
  1265. itemsHTML += getItemHTML('用户设置', {
  1266. desc: '一般情况下,是否在用户设置中隐藏被禁用项的子项?',
  1267. html: `<label>
  1268. <span>一般情况下隐藏被禁用项的子项</span>
  1269. <input id="gm-hideDisabledSubitems" type="checkbox">
  1270. </label>`,
  1271. })
  1272. itemsHTML += getItemHTML('用户设置', {
  1273. desc: '如果更改的配置需要重新加载才能生效,那么在设置完成后重新加载页面。',
  1274. html: `<label>
  1275. <span>必要时在设置完成后重新加载页面</span>
  1276. <input id="gm-reloadAfterSetting" type="checkbox">
  1277. </label>`,
  1278. })
  1279.  
  1280. gm.el.setting.innerHTML = `
  1281. <div class="gm-setting-page gm-modal">
  1282. <div class="gm-title">
  1283. <a class="gm-maintitle" title="${GM_info.script.homepage}" href="${GM_info.script.homepage}" target="_blank">
  1284. <span>${GM_info.script.name}</span>
  1285. </a>
  1286. <div class="gm-subtitle">V${GM_info.script.version} by ${GM_info.script.author}</div>
  1287. </div>
  1288. <div class="gm-items">${itemsHTML}</div>
  1289. <div class="gm-bottom">
  1290. <button class="gm-save">保存</button>
  1291. <button class="gm-cancel">取消</button>
  1292. </div>
  1293. <div class="gm-reset" title="重置脚本设置及内部数据(稍后再看历史数据除外),也许能解决脚本运行错误的问题。无法解决请联系脚本作者:${GM_info.script.supportURL}">初始化脚本</div>
  1294. <a class="gm-changelog" title="显示更新日志" href="${gm.url.gm_changelog}" target="_blank">更新日志</a>
  1295. </div>
  1296. <div class="gm-shadow"></div>
  1297. `
  1298.  
  1299. // 找出配置对应的元素
  1300. for (const name of Object.keys({ ...gm.configMap, ...gm.infoMap })) {
  1301. el[name] = gm.el.setting.querySelector(`#gm-${name}`)
  1302. }
  1303.  
  1304. el.settingPage = gm.el.setting.querySelector('.gm-setting-page')
  1305. el.items = gm.el.setting.querySelector('.gm-items')
  1306. el.maintitle = gm.el.setting.querySelector('.gm-maintitle')
  1307. el.changelog = gm.el.setting.querySelector('.gm-changelog')
  1308. switch (type) {
  1309. case 1: {
  1310. el.settingPage.dataset.type = 'init'
  1311. el.maintitle.innerHTML += '<br><span style="font-size:0.8em">(初始化设置)</span>'
  1312. break
  1313. }
  1314. case 2: {
  1315. el.settingPage.dataset.type = 'updated'
  1316. el.maintitle.innerHTML += '<br><span style="font-size:0.8em">(功能性更新设置)</span>'
  1317. for (const [name, item] of Object.entries({ ...gm.configMap, ...gm.infoMap })) {
  1318. if (el[name] && item.configVersion > gm.configVersion) {
  1319. const updated = el[name].closest('.gm-item, .gm-lineitem')
  1320. updated?.classList.add('gm-updated')
  1321. }
  1322. }
  1323. break
  1324. }
  1325. default: {
  1326. break
  1327. }
  1328. }
  1329. el.save = gm.el.setting.querySelector('.gm-save')
  1330. el.cancel = gm.el.setting.querySelector('.gm-cancel')
  1331. el.shadow = gm.el.setting.querySelector('.gm-shadow')
  1332. el.reset = gm.el.setting.querySelector('.gm-reset')
  1333.  
  1334. // 提示信息
  1335. el.rhfcInformation = gm.el.setting.querySelector('#gm-rhfcInformation')
  1336. api.message.hoverInfo(el.rhfcInformation, `
  1337. <div style="text-indent:2em;line-height:1.6em">
  1338. <p>模糊比对模式:设当前时间点获取到的稍后再看列表数据为 A,上一次获取到的数据为 B。若 A B 的前 <b>N</b> 项均一致就认为这段时间没有往稍后再看中添加新稿件,直接跳过后续处理。</p>
  1339. <p>其中,<b>N</b> 即为模糊比对深度。注意,<b>深度设置过大反而会降低比对效率</b>,建议先设置较小的值,若后续观察到有记录被误丢弃,再增加该项的值。最佳参数与个人使用习惯相关,请根据自身情况微调。你也可以选择设置 <b>0</b> 以关闭模糊比对模式(不推荐)。</p>
  1340. </div>
  1341. `, null, { width: '36em', position: { top: '80%' } })
  1342. el.rhsInformation = gm.el.setting.querySelector('#gm-rhsInformation')
  1343. api.message.hoverInfo(el.rhsInformation, `
  1344. <div style="line-height:1.6em">
  1345. 即使突破限制将该项设置为最大限制值的两倍,保存与读取对页面加载的影响仍可忽略不计(毫秒级),最坏情况下生成移除记录的耗时也能被控制在 1 秒以内。但仍不建议取太大的值,原因是移除记录本质上是一种误删后的挽回手段,非常近期的历史足以达到效果。
  1346. </div>
  1347. `, null, { width: '36em', position: { top: '80%' } })
  1348. el.rhtInformation = gm.el.setting.querySelector('#gm-rhtInformation')
  1349. api.message.hoverInfo(el.rhtInformation, `
  1350. <div style="line-height:1.6em">
  1351. 在历史数据记录中添加时间戳,用于改善移除记录中的数据排序,使得排序以「稿件『最后一次』被观察到处于稍后再看的时间点」为基准,而非以「稿件『第一次』被观察到处于稍后再看的时间点」为基准;同时也利于数据展示与查看。注意,此功能在数据存读及处理上都有额外开销。
  1352. </div>
  1353. `, null, { width: '36em', position: { top: '80%' } })
  1354. el.balatsInformation = gm.el.setting.querySelector('#gm-balatsInformation')
  1355. api.message.hoverInfo(el.balatsInformation, '若同步时间距离当前时间超过 48 小时,则不会执行自动加载。')
  1356. el.fwsInformation = gm.el.setting.querySelector('#gm-fwsInformation')
  1357. api.message.hoverInfo(el.fwsInformation, `
  1358. <div style="text-indent:2em;line-height:1.6em">
  1359. <p>在动态页、视频播放页以及其他页面,稿件卡片的右下角方存在一个将稿件加入或移除出稍后再看的快捷按钮。然而,在刷新页面后,B站不会为之加载稍后再看的状态——即使稿件已经在稍后再看中,也不会显示出来。启用该功能后,会自动填充这些缺失的状态信息。</p>
  1360. <p>第三项「所有页面」,会用一套固定的逻辑对脚本能匹配到的所有非特殊页面尝试进行信息填充。脚本本身没有匹配所有B站页面,如果有需要,请在脚本管理器(如 Tampermonkey)中为脚本设置额外的页面匹配规则。由于B站各页面的设计不是很规范,某些页面中稿件卡片的设计可能跟其他地方不一致,所以不保证必定能填充成功。</p>
  1361. </div>
  1362. `, null, { width: '36em', position: { top: '80%' } })
  1363. el.sdvInformation = gm.el.setting.querySelector('#gm-sdvInformation')
  1364. api.message.hoverInfo(el.sdvInformation, '激活后在搜索框上右键点击保存默认值,中键点击清空默认值。')
  1365. el.iwlInformation = gm.el.setting.querySelector('#gm-iwlInformation')
  1366. api.message.hoverInfo(el.iwlInformation, '该功能入口在批量添加管理器中。')
  1367. el.mraInformation = gm.el.setting.querySelector('#gm-mraInformation')
  1368. api.message.hoverInfo(el.mraInformation, `
  1369. <div style="line-height:1.6em">
  1370. <p style="margin-bottom:0.5em"><b>DOMContentLoaded</b>:与页面内容同步加载,避免脚本在页面加载度较高时才对页面作修改。上述情况会给人页面加载时间过长的错觉,并且伴随页面变化突兀的不适感。</p>
  1371. <p><b>load</b>:在页面初步加载完成时运行。从理论上来说这个时间点更为合适,且能保证脚本在网页加载速度极慢时仍可正常工作。但要注意的是,以上所说「网页加载速度极慢」的情况并不常见,以下为常见原因:1. 短时间内(在后台)打开十几乃至数十个网页;2. 网络问题。</p>
  1372. </div>
  1373. `, null, { width: '36em', flagSize: '2em', position: { top: '80%' } })
  1374. el.arlInformation = gm.el.setting.querySelector('#gm-arlInformation')
  1375. api.message.hoverInfo(el.arlInformation, `
  1376. <div style="line-height:1.6em">
  1377. <p>设置列表页面自动刷新的时间间隔。</p>
  1378. <p>设置为 <b>0</b> 时禁用自动刷新。</p>
  1379. </div>
  1380. `)
  1381. el.wlcvpInformation = gm.el.setting.querySelector('#gm-wlcvpInformation')
  1382. api.message.hoverInfo(el.wlcvpInformation, `
  1383. <div style="line-height:1.6em">
  1384. 在有效期内使用本地缓存代替网络请求——除非是须确保数据正确性的场合。有效期过大会导致各种诡异现象,取值最好能匹配自身的B站使用习惯。
  1385. </div>
  1386. `, null, { width: '36em', flagSize: '2em' })
  1387.  
  1388. el.hcWarning = gm.el.setting.querySelector('#gm-hcWarning')
  1389. api.message.hoverInfo(el.hcWarning, '无须兼容第三方顶栏时务必选择「无」,否则脚本无法正常工作!', '⚠')
  1390. el.rhWarning = gm.el.setting.querySelector('#gm-rhWarning')
  1391. api.message.hoverInfo(el.rhWarning, '关闭移除记录,或将稍后再看历史数据保存次数设置为比原来小的值,都会造成对内部过期历史数据的清理!', '⚠')
  1392.  
  1393. el.headerButtonOpL.innerHTML = el.headerButtonOpR.innerHTML = el.headerButtonOpM.innerHTML = `
  1394. <option value="${Enums.headerButtonOp.openListInCurrent}">在当前页面打开列表页面</option>
  1395. <option value="${Enums.headerButtonOp.openListInNew}">在新页面打开列表页面</option>
  1396. <option value="${Enums.headerButtonOp.playAllInCurrent}">在当前页面播放全部</option>
  1397. <option value="${Enums.headerButtonOp.playAllInNew}">在新页面播放全部</option>
  1398. <option value="${Enums.headerButtonOp.clearWatchlater}">清空稍后再看</option>
  1399. <option value="${Enums.headerButtonOp.clearWatchedInWatchlater}">移除稍后再看已观看视频</option>
  1400. <option value="${Enums.headerButtonOp.openUserSetting}">打开用户设置</option>
  1401. <option value="${Enums.headerButtonOp.openRemoveHistory}">打开稍后再看移除记录</option>
  1402. <option value="${Enums.headerButtonOp.openBatchAddManager}">打开批量添加管理器</option>
  1403. <option value="${Enums.headerButtonOp.exportWatchlaterList}">导出稍后再看列表</option>
  1404. <option value="${Enums.headerButtonOp.noOperation}">不执行操作</option>
  1405. `
  1406. }
  1407.  
  1408. /**
  1409. * 维护与设置项相关的数据和元素
  1410. */
  1411. const processConfigItem = () => {
  1412. // 子项与父项相关联
  1413. const subitemChange = (target, disabled) => {
  1414. const content = target.closest('.gm-item-content')
  1415. for (const option of content.querySelectorAll('[id|=gm]:not(:first-child)')) {
  1416. if (!target.contains(option)) {
  1417. option.disabled = disabled
  1418. }
  1419. }
  1420. for (let i = 1; i < content.childElementCount; i++) {
  1421. const item = content.children[i]
  1422. if (disabled) {
  1423. item.setAttribute('disabled', '')
  1424. } else {
  1425. item.removeAttribute('disabled')
  1426. }
  1427. }
  1428. }
  1429. el.headerMenuFn = el.headerMenuFnSetting.parentElement.parentElement
  1430. el.headerButton.init = () => {
  1431. const target = el.headerButton
  1432. subitemChange(target, !target.checked)
  1433. }
  1434. el.headerButton.addEventListener('change', el.headerButton.init)
  1435. el.headerCompatible.init = () => setHcWarning()
  1436. el.headerCompatible.addEventListener('change', el.headerCompatible.init)
  1437. el.removeHistory.init = () => {
  1438. const target = el.removeHistory
  1439. subitemChange(target, !target.checked)
  1440. setRhWaring()
  1441. }
  1442. el.removeHistory.addEventListener('change', el.removeHistory.init)
  1443. el.removeHistorySaves.addEventListener('input', setRhWaring)
  1444. el.removeHistorySaves.addEventListener('blur', setRhWaring)
  1445. }
  1446.  
  1447. /**
  1448. * 处理与设置页相关的数据和元素
  1449. */
  1450. const processSettingItem = () => {
  1451. gm.panel.setting.openHandler = onOpen
  1452. gm.panel.setting.openedHandler = onOpened
  1453. gm.el.setting.fadeInDisplay = 'flex'
  1454. el.save.addEventListener('click', onSave)
  1455. el.cancel.addEventListener('click', () => this.closePanelItem('setting'))
  1456. el.shadow.addEventListener('click', () => {
  1457. if (!el.shadow.hasAttribute('disabled')) {
  1458. this.closePanelItem('setting')
  1459. }
  1460. })
  1461. el.reset.addEventListener('click', () => this.resetScript())
  1462. el.clearRemoveHistoryData.addEventListener('click', () => {
  1463. el.removeHistory.checked && this.clearRemoveHistoryData()
  1464. })
  1465. el.watchlaterMediaList.addEventListener('click', async () => {
  1466. const uid = webpage.method.getDedeUserID()
  1467. const mlid = await api.message.prompt(`
  1468. <p>指定使用收藏功能时,将稿件从稍后再看移动至哪个收藏夹。</p>
  1469. <p>下方应填入目标收藏夹 ID,置空时使用默认收藏夹。收藏夹页面网址为 <code>https://space.bilibili.com/\${uid}/favlist?fid=\${mlid}</code>,<code>mlid</code> 即收藏夹 ID。</p>
  1470. `, GM_getValue(`watchlaterMediaList_${uid}`) ?? undefined, { html: true })
  1471. if (mlid != null) {
  1472. GM_setValue(`watchlaterMediaList_${uid}`, mlid)
  1473. api.message.info('已保存稍后再看收藏夹设置')
  1474. }
  1475. })
  1476. el.importWatchlaterList.addEventListener('click', () => this.setImportWatchlaterList())
  1477. el.exportWatchlaterList.addEventListener('click', () => this.setExportWatchlaterList())
  1478. if (type > 0) {
  1479. if (type === 2) {
  1480. el.save.title = '向下滚动……'
  1481. el.save.disabled = true
  1482. }
  1483. el.cancel.disabled = true
  1484. el.shadow.setAttribute('disabled', '')
  1485. }
  1486. }
  1487.  
  1488. let needReload = false
  1489. /**
  1490. * 设置保存时执行
  1491. */
  1492. const onSave = () => {
  1493. // 通用处理
  1494. for (const [name, item] of Object.entries(gm.configMap)) {
  1495. if (!item.manual && item.attr !== 'none') {
  1496. const change = saveConfig(name, item.attr)
  1497. if (!item.needNotReload) {
  1498. needReload ||= change
  1499. }
  1500. }
  1501. }
  1502.  
  1503. let shutDownRemoveHistory = false
  1504. // removeHistory
  1505. if (gm.config.removeHistory !== el.removeHistory.checked) {
  1506. gm.config.removeHistory = el.removeHistory.checked
  1507. GM_setValue('removeHistory', gm.config.removeHistory)
  1508. shutDownRemoveHistory = true
  1509. needReload = true
  1510. }
  1511. // 「因」中无 removeHistory,就说明 needReload 需要设置为 true,除非「果」不需要刷新页面就能生效
  1512. if (gm.config.removeHistory) {
  1513. const rhsV = Number.parseInt(el.removeHistorySaves.value)
  1514. if (rhsV !== gm.config.removeHistorySaves && !Number.isNaN(rhsV)) {
  1515. // 因:removeHistorySaves
  1516. // 果:removeHistorySaves & removeHistoryData
  1517. const data = gm.data.removeHistoryData()
  1518. data.setMaxSize(rhsV)
  1519. gm.config.removeHistorySaves = rhsV
  1520. GM_setValue('removeHistorySaves', rhsV)
  1521. GM_setValue('removeHistoryData', data)
  1522. // 不需要修改 needReload
  1523. }
  1524. // 因:removeHistorySearchTimes
  1525. // 果:removeHistorySearchTimes
  1526. const rhstV = Number.parseInt(el.removeHistorySearchTimes.value)
  1527. if (rhstV !== gm.config.removeHistorySearchTimes && !Number.isNaN(rhstV)) {
  1528. gm.config.removeHistorySearchTimes = rhstV
  1529. GM_setValue('removeHistorySearchTimes', rhstV)
  1530. // 不需要修改 needReload
  1531. }
  1532. } else if (shutDownRemoveHistory) {
  1533. // 因:removeHistory
  1534. // 果:most thing about history
  1535. gm.data.removeHistoryData(true)
  1536. GM_deleteValue('removeHistoryData')
  1537. GM_deleteValue('removeHistoryFuzzyCompare')
  1538. GM_deleteValue('removeHistoryFuzzyCompareReference')
  1539. GM_deleteValue('removeHistorySaves')
  1540. }
  1541.  
  1542. this.closePanelItem('setting')
  1543. if (type > 0) {
  1544. // 更新配置版本
  1545. gm.configVersion = gm.configUpdate
  1546. GM_setValue('configVersion', gm.configVersion)
  1547. // 关闭特殊状态
  1548. setTimeout(() => {
  1549. delete el.settingPage.dataset.type
  1550. el.maintitle.textContent = GM_info.script.name
  1551. el.cancel.disabled = false
  1552. el.shadow.removeAttribute('disabled')
  1553. }, gm.const.fadeTime)
  1554. }
  1555.  
  1556. if (gm.config.reloadAfterSetting && needReload) {
  1557. needReload = false
  1558. location.reload()
  1559. }
  1560. }
  1561.  
  1562. /**
  1563. * 设置打开时执行
  1564. */
  1565. const onOpen = () => {
  1566. for (const [name, item] of Object.entries(gm.configMap)) {
  1567. const { attr } = item
  1568. if (attr !== 'none') {
  1569. el[name][attr] = gm.config[name]
  1570. }
  1571. }
  1572. for (const name of Object.keys(gm.configMap)) {
  1573. // 需要等所有配置读取完成后再进行初始化
  1574. el[name]?.init?.()
  1575. }
  1576. el.clearRemoveHistoryData.textContent = gm.config.removeHistory ? `清空数据(${gm.data.removeHistoryData().size}条)` : '清空数据(0条)'
  1577. }
  1578.  
  1579. /**
  1580. * 设置打开后执行
  1581. */
  1582. const onOpened = () => {
  1583. el.items.scrollTop = 0
  1584. if (type === 2) {
  1585. const resetSave = () => {
  1586. el.save.title = ''
  1587. el.save.disabled = false
  1588. }
  1589.  
  1590. const points = []
  1591. const totalLength = el.items.scrollHeight
  1592. const items = el.items.querySelectorAll('.gm-updated')
  1593. for (const item of items) {
  1594. points.push(item.offsetTop / totalLength * 100)
  1595. }
  1596.  
  1597. if (points.length > 0) {
  1598. let range = 5 // 显示宽度
  1599. const actualRange = items[0].offsetHeight / totalLength * 100 // 实际宽度
  1600. let realRange = actualRange // 校正后原点到真实末尾的宽度
  1601. if (actualRange > range) {
  1602. range = actualRange
  1603. } else {
  1604. const offset = (actualRange - range) / 2
  1605. for (let i = 0; i < points.length; i++) {
  1606. points[i] += offset
  1607. }
  1608. realRange = range + offset
  1609. }
  1610. const start = []
  1611. const end = []
  1612. let currentStart = points[0]
  1613. let currentEnd = points[0] + range
  1614. for (let i = 1; i < points.length; i++) {
  1615. const point = points[i]
  1616. if (point < currentEnd) {
  1617. currentEnd = point + range
  1618. } else {
  1619. start.push(currentStart)
  1620. end.push(currentEnd)
  1621. currentStart = point
  1622. currentEnd = point + range
  1623. if (currentEnd >= 100) {
  1624. currentEnd = 100
  1625. break
  1626. }
  1627. }
  1628. }
  1629. start.push(currentStart)
  1630. end.push(currentEnd)
  1631.  
  1632. let linear = ''
  1633. for (const [idx, val] of start.entries()) {
  1634. linear += `, transparent ${val}%, ${gm.const.updateHighlightColor} ${val}%, ${gm.const.updateHighlightColor} ${end[idx]}%, transparent ${end[idx]}%`
  1635. }
  1636. linear = linear.slice(2)
  1637.  
  1638. api.base.addStyle(`
  1639. #${gm.id} [data-type=updated] .gm-items::-webkit-scrollbar {
  1640. background: linear-gradient(${linear})
  1641. }
  1642. `)
  1643.  
  1644. if (el.items.scrollHeight === el.items.clientHeight) {
  1645. resetSave()
  1646. } else {
  1647. const last = Math.min((points.pop() + realRange) / 100, 0.95) // 给计算误差留点余地
  1648. const onScroll = api.base.throttle(() => {
  1649. const { items } = el
  1650. const bottom = (items.scrollTop + items.clientHeight) / items.scrollHeight
  1651. if (bottom > last) { // 可视区底部超过最后一个更新点
  1652. resetSave()
  1653. items.removeEventListener('scroll', onScroll)
  1654. }
  1655. }, 200)
  1656. el.items.addEventListener('scroll', onScroll)
  1657. el.items.dispatchEvent(new Event('scroll'))
  1658. }
  1659. } else {
  1660. resetSave()
  1661. }
  1662. }
  1663. }
  1664.  
  1665. /**
  1666. * 保存配置
  1667. * @param {string} name 配置名称
  1668. * @param {string} attr 从对应元素的什么属性读取
  1669. * @returns {boolean} 是否有实际更新
  1670. */
  1671. const saveConfig = (name, attr) => {
  1672. let val = el[name][attr]
  1673. const { type } = gm.configMap[name]
  1674. if (type === 'int' || type === 'float') {
  1675. if (typeof val !== 'number') {
  1676. val = type === 'int' ? Number.parseInt(val) : Number.parseFloat(val)
  1677. }
  1678. if (Number.isNaN(val)) {
  1679. val = gm.configMap[name].default
  1680. }
  1681. }
  1682. if (gm.config[name] === val) return false
  1683. gm.config[name] = val
  1684. GM_setValue(name, gm.config[name])
  1685. return true
  1686. }
  1687.  
  1688. /**
  1689. * 设置 headerCompatible 警告项
  1690. */
  1691. const setHcWarning = () => {
  1692. const warn = el.headerCompatible.value !== Enums.headerCompatible.none
  1693. if (el.hcWarning.show) {
  1694. if (!warn) {
  1695. api.dom.fade(false, el.hcWarning)
  1696. el.hcWarning.show = false
  1697. }
  1698. } else {
  1699. if (warn) {
  1700. api.dom.fade(true, el.hcWarning)
  1701. el.hcWarning.show = true
  1702. }
  1703. }
  1704. }
  1705.  
  1706. /**
  1707. * 设置 removeHistory 警告项
  1708. */
  1709. const setRhWaring = () => {
  1710. let warn = false
  1711. const rh = el.removeHistory.checked
  1712. if (!rh && gm.config.removeHistory) {
  1713. warn = true
  1714. } else {
  1715. let rhs = Number.parseInt(el.removeHistorySaves.value)
  1716. if (Number.isNaN(rhs)) {
  1717. rhs = 0
  1718. }
  1719. if (rhs < gm.config.removeHistorySaves && gm.config.removeHistory) {
  1720. warn = true
  1721. }
  1722. }
  1723.  
  1724. if (el.rhWarning.show) {
  1725. if (!warn) {
  1726. api.dom.fade(false, el.rhWarning)
  1727. el.rhWarning.show = false
  1728. }
  1729. } else {
  1730. if (warn) {
  1731. api.dom.fade(true, el.rhWarning)
  1732. el.rhWarning.show = true
  1733. }
  1734. }
  1735. }
  1736. }
  1737. }
  1738.  
  1739. /**
  1740. * 打开批量添加管理器
  1741. */
  1742. openBatchAddManager() {
  1743. if (gm.el.batchAddManager) {
  1744. this.openPanelItem('batchAddManager')
  1745. } else {
  1746. /** @type {{[n: string]: HTMLElement}} */
  1747. const el = {}
  1748. let history = null
  1749. if (gm.config.removeHistory) {
  1750. const records = gm.data.removeHistoryData().toArray(50) // 回溯限制到 50 条
  1751. if (records.length > 0) {
  1752. history = new Set()
  1753. for (const record of records) {
  1754. history.add(webpage.method.bvTool.bv2av(record[0]))
  1755. }
  1756. }
  1757. }
  1758. setTimeout(() => {
  1759. initManager()
  1760. processItem()
  1761. this.openPanelItem('batchAddManager')
  1762. })
  1763.  
  1764. /**
  1765. * 初始化管理器
  1766. */
  1767. const initManager = () => {
  1768. gm.el.batchAddManager = gm.el.gmRoot.appendChild(document.createElement('div'))
  1769. gm.panel.batchAddManager.el = gm.el.batchAddManager
  1770. gm.el.batchAddManager.className = 'gm-batchAddManager gm-modal-container'
  1771. gm.el.batchAddManager.innerHTML = `
  1772. <div class="gm-batchAddManager-page gm-modal">
  1773. <div class="gm-title">批量添加管理器</div>
  1774. <div class="gm-comment">
  1775. <div>执行以下步骤以将投稿批量添加到稍后再看。执行过程中可以关闭对话框,但不能关闭页面;也不建议将当前页面置于后台,否则浏览器可能会暂缓甚至暂停任务执行。</div>
  1776. <div>常规模式下脚本优先添加投稿时间较早的投稿,达到稍后再看容量上限 100 时终止执行。注意,该功能会在短时间内向后台发起大量请求,滥用可能会导致一段时间内无法正常访问B站,你可以增加平均请求间隔以降低触发拦截机制的概率。</div>
  1777. <div>① 加载最近 <input is="laster2800-input-number" id="gm-batch-1a" value="24" digits="Infinity"> <select id="gm-batch-1b" style="border:none;margin: 0 -4px">
  1778. <option value="${3600 * 24}">天</option>
  1779. <option value="3600" selected>小时</option>
  1780. <option value="60">分钟</option>
  1781. </select> 以内发布且不存在于稍后再看的视频投稿<button id="gm-batch-1c">执行</button><button id="gm-batch-1d" disabled>终止</button></div>
  1782. <div style="text-indent:1.4em">或者从以下位置导入稿件:<button id="gm-batch-1e" style="margin-left:0.4em" title="右键点击可进行导入设置"><input type="file" multiple><span>文件</span></button><button id="gm-batch-1f">收藏夹</button></div>
  1783. <div>② 缩小时间范围到 <input is="laster2800-input-number" id="gm-batch-2a" digits="Infinity"> <select id="gm-batch-2b" style="border:none;margin: 0 -4px">
  1784. <option value="${3600 * 24}">天</option>
  1785. <option value="3600" selected>小时</option>
  1786. <option value="60">分钟</option>
  1787. </select> 以内;可使用上下方向键(配合 Alt/Shift/Ctrl)调整数值大小<button id="gm-batch-2c" disabled hidden>执行</button></div>
  1788. <div>③ 筛选 <input id="gm-batch-3a" type="text" style="width:10em">,过滤 <input id="gm-batch-3b" type="text" style="width:10em">;支持通配符 ( ? * ),使用 | 分隔关键词<button id="gm-batch-3c" disabled hidden>执行</button></div>
  1789. <div>④ 将选定稿件添加到稍后再看(平均请求间隔:<input is="laster2800-input-number" id="gm-batch-4a" value="${gm.const.batchAddRequestInterval}" min="250">ms)<button id="gm-batch-4b" disabled>执行</button><button id="gm-batch-4c" disabled>终止</button></div>
  1790. </div>
  1791. <div class="gm-items"></div>
  1792. <div class="gm-bottom"><div>
  1793. <button id="gm-last-add-time">时间同步</button>
  1794. <button id="gm-unchecked-display"></button>
  1795. <button id="gm-select-all">选中全部</button>
  1796. <button id="gm-deselect-all">取消全部</button>
  1797. <button id="gm-save-snapshot">保存快照</button>
  1798. <button id="gm-load-snapshot"><input type="file"><span>读取快照</span></button>
  1799. <button id="gm-save-batch-params" title="已保存参数会在加载页面后自动读取。\n右键点击以重置参数,刷新页面后生效。">保存参数</button>
  1800. <button id="gm-load-batch-params">读取参数</button>
  1801. </div></div>
  1802. </div>
  1803. <div class="gm-shadow"></div>
  1804. `
  1805. const ids = ['1a', '1b', '1c', '1d', '1e', '1f', '2a', '2b', '2c', '3a', '3b', '3c', '4a', '4b', '4c']
  1806. for (const id of ids) {
  1807. el[`id${id}`] = gm.el.batchAddManager.querySelector(`#gm-batch-${id}`)
  1808. }
  1809. el.items = gm.el.batchAddManager.querySelector('.gm-items')
  1810. el.bottom = gm.el.batchAddManager.querySelector('.gm-bottom')
  1811. el.lastAddTime = gm.el.batchAddManager.querySelector('#gm-last-add-time')
  1812. el.uncheckedDisplay = gm.el.batchAddManager.querySelector('#gm-unchecked-display')
  1813. el.selectAll = gm.el.batchAddManager.querySelector('#gm-select-all')
  1814. el.deselectAll = gm.el.batchAddManager.querySelector('#gm-deselect-all')
  1815. el.saveSnapshot = gm.el.batchAddManager.querySelector('#gm-save-snapshot')
  1816. el.loadSnapshot = gm.el.batchAddManager.querySelector('#gm-load-snapshot')
  1817. el.saveParams = gm.el.batchAddManager.querySelector('#gm-save-batch-params')
  1818. el.loadParams = gm.el.batchAddManager.querySelector('#gm-load-batch-params')
  1819. el.shadow = gm.el.batchAddManager.querySelector('.gm-shadow')
  1820.  
  1821. el.saveParams.paramIds = ['1a', '1b', '3a', '3b', '4a']
  1822. const batchParams = GM_getValue('batchParams')
  1823. setBatchParamsToManager(batchParams)
  1824. }
  1825.  
  1826. let busy = false
  1827. /**
  1828. * 设置 BUSY 状态
  1829. * @param {boolean} status BUSY 状态
  1830. */
  1831. const setBusy = status => {
  1832. busy = status
  1833. el.id1b.disabled = status
  1834. el.id1c.disabled = status
  1835. el.id1e.disabled = status
  1836. el.id1f.disabled = status
  1837. el.id4b.disabled = status
  1838. if (status) {
  1839. el.bottom.setAttribute('disabled', '')
  1840. el.bottom.firstElementChild.style.pointerEvents = 'none'
  1841. } else {
  1842. el.bottom.removeAttribute('disabled')
  1843. el.bottom.firstElementChild.style.pointerEvents = ''
  1844. }
  1845. }
  1846.  
  1847. /**
  1848. * 从批量添加管理器获取参数
  1849. * @returns {Object} 参数
  1850. */
  1851. const getBatchParamsFromManager = () => {
  1852. const params = {}
  1853. for (const id of el.saveParams.paramIds) {
  1854. params[`id${id}`] = el[`id${id}`].value
  1855. }
  1856. return params
  1857. }
  1858. /**
  1859. * 将参数设置到批量添加管理器
  1860. */
  1861. const setBatchParamsToManager = params => {
  1862. if (params) {
  1863. for (const id of el.saveParams.paramIds) {
  1864. el[`id${id}`].value = params[`id${id}`]
  1865. }
  1866. }
  1867. }
  1868.  
  1869. /**
  1870. * 维护内部元素和数据
  1871. */
  1872. const processItem = () => {
  1873. gm.el.batchAddManager.fadeInDisplay = 'flex'
  1874. el.shadow.addEventListener('click', () => this.closePanelItem('batchAddManager'))
  1875.  
  1876. // 时间同步
  1877. const setLastAddTime = (time = null, writeBack = true) => {
  1878. writeBack && GM_setValue('batchLastAddTime', time)
  1879. el.lastAddTime.val = time
  1880. el.lastAddTime.title = `将一个合适的时间点同步到加载步骤中,以便与上次批量添加操作无缝对接。\n若上一次执行加载步骤时,没有找到新稿件,同步「加载完成时间」。\n若上一次执行添加步骤成功,同步「加载完成时间」;否则(失败或中断),同步「最后一个添加成功的稿件的投稿时间」。${time ? `\n当前同步时间:${new Date(time).toLocaleString()}` : ''}`
  1881. el.lastAddTime.disabled = !time
  1882. }
  1883. setLastAddTime(GM_getValue('batchLastAddTime'), false)
  1884. el.lastAddTime.addEventListener('click', () => {
  1885. if (busy) return api.message.info('执行中,无法同步')
  1886. const target = el.lastAddTime
  1887. if (target.val == null) return
  1888. const secInterval = (Date.now() - target.val) / 1000
  1889. el.id1a.value = secInterval / el.id1b.value // 取精确时间要比向上取整好
  1890. if (gm.config.batchAddLoadAfterTimeSync) {
  1891. if ((Date.now() - target.val) / (1000 * 3600) <= 48) {
  1892. el.id1c.dispatchEvent(new Event('click'))
  1893. } else {
  1894. api.message.info(`已同步到 ${new Date(target.val).toLocaleString()}。同步时间距离当前时间超过 48 小时,不执行自动加载。`, { ms: 2000 })
  1895. }
  1896. } else {
  1897. api.message.info(`已同步到 ${new Date(target.val).toLocaleString()}`)
  1898. }
  1899. })
  1900. // 避免不同页面中脚本实例互相影响而产生的同步时间错误
  1901. GM_addValueChangeListener('batchLastAddTime', (name, oldVal, newVal, remote) => remote && setLastAddTime(newVal))
  1902.  
  1903. // 非选显示
  1904. const setUncheckedDisplayText = () => {
  1905. el.uncheckedDisplay.textContent = el.uncheckedDisplay._hide ? '显示非选' : '隐藏非选'
  1906. }
  1907. el.uncheckedDisplay._hide = GM_getValue('batchUncheckedDisplay') ?? false
  1908. setUncheckedDisplayText()
  1909. el.uncheckedDisplay.addEventListener('click', () => {
  1910. const target = el.uncheckedDisplay
  1911. target._hide = !target._hide
  1912. GM_setValue('batchUncheckedDisplay', target._hide)
  1913. setUncheckedDisplayText()
  1914. const display = target._hide ? 'none' : ''
  1915. for (let i = 0; i < el.items.childElementCount; i++) {
  1916. const item = el.items.children[i]
  1917. if (!item.firstElementChild.checked) {
  1918. item.style.display = display
  1919. }
  1920. }
  1921. })
  1922. el.items.addEventListener('click', e => {
  1923. if (e.target.type === 'checkbox' && !e.target.checked && el.uncheckedDisplay._hide) {
  1924. e.target.parentElement.style.display = 'none'
  1925. }
  1926. })
  1927.  
  1928. // 选中全部
  1929. el.selectAll.addEventListener('click', () => {
  1930. const hide = el.uncheckedDisplay._hide
  1931. for (let i = 0; i < el.items.childElementCount; i++) {
  1932. const item = el.items.children[i]
  1933. const cb = item.firstElementChild
  1934. if (!cb.checked && !cb.disabled) {
  1935. cb.checked = true
  1936. if (hide) {
  1937. item.style.display = ''
  1938. }
  1939. }
  1940. }
  1941. })
  1942. // 取消全部
  1943. el.deselectAll.addEventListener('click', () => {
  1944. const hide = el.uncheckedDisplay._hide
  1945. for (let i = 0; i < el.items.childElementCount; i++) {
  1946. const item = el.items.children[i]
  1947. const cb = item.firstElementChild
  1948. if (cb.checked) {
  1949. cb.checked = false
  1950. if (hide) {
  1951. item.style.display = 'none'
  1952. }
  1953. }
  1954. }
  1955. })
  1956.  
  1957. // 快照
  1958. el.saveSnapshot.addEventListener('click', () => {
  1959. const snapshot = {
  1960. params: getBatchParamsFromManager(),
  1961. items: el.items.innerHTML,
  1962. }
  1963. const filename = `${gm.config.batchAddManagerSnapshotPrefix}.${webpage.method.getTimeString(null, '', '', '-')}.json`
  1964. const file = new Blob([JSON.stringify(snapshot)], { type: 'text/plain' })
  1965. const a = document.createElement('a')
  1966. a.href = URL.createObjectURL(file)
  1967. a.download = filename
  1968. a.click()
  1969. api.message.info('保存成功', 1800)
  1970. })
  1971. const loadSnapshotF = el.loadSnapshot.firstElementChild
  1972. el.loadSnapshot.addEventListener('click', () => loadSnapshotF.click())
  1973. loadSnapshotF.addEventListener('change', async () => {
  1974. if (busy) return
  1975. const file = loadSnapshotF.files[0]
  1976. try {
  1977. setBusy(true)
  1978. if (file) {
  1979. const content = await new Promise((resolve, reject) => {
  1980. const reader = new FileReader()
  1981. reader.addEventListener('load', () => resolve(reader.result))
  1982. reader.addEventListener('error', e => reject(e))
  1983. reader.readAsText(file)
  1984. })
  1985. const snapshot = JSON.parse(content)
  1986. setBatchParamsToManager(snapshot.params)
  1987. el.items.innerHTML = snapshot.items
  1988. initItemHints()
  1989. el.id2a.value = el.id2a.defaultValue = el.id2a.max = ''
  1990. api.message.info('读取成功', 1800)
  1991. }
  1992. } catch (e) {
  1993. api.logger.error(e)
  1994. api.message.alert(`快照 <code>${file.name}</code> 读取失败。`, { html: true })
  1995. } finally {
  1996. setBusy(false)
  1997. loadSnapshotF.value = '' // 重置控件,否则重新选择相同文件不会触发 change 事件;置空行为不会触发 change 事件
  1998. }
  1999. })
  2000.  
  2001. // 参数
  2002. el.saveParams.addEventListener('click', () => {
  2003. GM_setValue('batchParams', getBatchParamsFromManager())
  2004. api.message.info('保存成功')
  2005. })
  2006. el.saveParams.addEventListener('contextmenu', e => {
  2007. e.preventDefault()
  2008. GM_deleteValue('batchParams')
  2009. api.message.info('重置成功,刷新页面后生效', 1800)
  2010. })
  2011. el.loadParams.addEventListener('click', () => {
  2012. const params = GM_getValue('batchParams')
  2013. if (params) {
  2014. setBatchParamsToManager(params)
  2015. el.id3c.dispatchEvent(new Event('click')) // 自动执行第三步
  2016. api.message.info('读取成功')
  2017. } else {
  2018. api.message.info('未读取到参数')
  2019. }
  2020. })
  2021.  
  2022. let loadTime = 0
  2023. let stopLoad = false
  2024. let readers = []
  2025. // 加载投稿
  2026. el.id1c.addEventListener('click', async () => {
  2027. if (busy) return
  2028. let error = false
  2029. try {
  2030. setBusy(true)
  2031. let page = 1
  2032. let offset = -1
  2033. const tzo = new Date().getTimezoneOffset()
  2034. const v1a = Number.parseFloat(el.id1a.value)
  2035. if (Number.isNaN(v1a)) throw new TypeError('v1a is NaN')
  2036. el.id1a.value = v1a
  2037. el.id1c.textContent = '执行中'
  2038. el.id1d.disabled = false
  2039. el.id2a.defaultValue = el.id2a.max = v1a
  2040. el.id2b.syncVal = el.id1b.value
  2041. el.items.textContent = ''
  2042. loadTime = Date.now() // 提前记录 loadTime,这样衔接时绝对不会遗漏动态
  2043. const end = loadTime - v1a * el.id1b.value * 1000
  2044. const avSet = new Set()
  2045. gm.runtime.reloadWatchlaterListData = true
  2046. // eslint-disable-next-line no-unmodified-loop-condition
  2047. while (!stopLoad) {
  2048. const data = new URLSearchParams()
  2049. data.append('timezone_offset', tzo)
  2050. data.append('type', 'all') // video 分类会遗漏一些内容,需手动筛选
  2051. data.append('page', page++) // page 似乎只在第 1 页有意义
  2052. if (offset > 0) { // 后续通过 offset 而非 page 确定位置
  2053. data.append('offset', offset)
  2054. }
  2055. const resp = await api.web.request({
  2056. url: `${gm.url.api_dynamicList}?${data.toString()}`,
  2057. }, { check: r => r.code === 0 })
  2058. const { items, has_more } = resp.data
  2059. if (!items || items.length === 0) return // -> finally
  2060. offset = resp.data.offset // data.offset 是字符串类型,不会丢失精度;无需 +1 额外偏移
  2061. let html = ''
  2062. for (let item of items) {
  2063. let ts = -1
  2064. let fwSrc = null // 转发源
  2065. let fwSrcHint = null // 转发源说明
  2066. // 关注者转发的动态
  2067. if (gm.config.batchAddLoadForward && item.type === 'DYNAMIC_TYPE_FORWARD') {
  2068. fwSrc = `${gm.url.page_dynamic}/${item.id_str}`
  2069. fwSrcHint = item.modules.module_author.name
  2070. ts = item.modules.module_author.pub_ts // 使用转发时间
  2071. item = item.orig
  2072. }
  2073. // [视频投稿, 已订阅合集]
  2074. if (['DYNAMIC_TYPE_AV', 'DYNAMIC_TYPE_UGC_SEASON'].includes(item.type)) {
  2075. const { modules } = item
  2076. const author = modules.module_author
  2077. if (ts < 0) ts = author.pub_ts
  2078. if (ts * 1000 < end) {
  2079. el.items.insertAdjacentHTML('afterbegin', html)
  2080. return // -> finally
  2081. }
  2082. const { major } = modules.module_dynamic
  2083. const core = major[major.type.replace(/^MAJOR_TYPE_/, '').toLowerCase()]
  2084. const aid = String(core.aid)
  2085. if (!await webpage.method.getVideoWatchlaterStatusByAid(aid, false, true)) { // 完全跳过存在于稍后再看的稿件
  2086. if (avSet.has(aid)) continue
  2087. avSet.add(aid)
  2088. const uncheck = history?.has(aid)
  2089. const displayNone = uncheck && el.uncheckedDisplay._hide
  2090. html = `<label class="gm-item" data-aid="${aid}" data-timestamp="${ts}"${fwSrcHint ? ` data-src-hint="${fwSrcHint}" ` : ''}${displayNone ? ' style="display:none"' : ''}><input type="checkbox"${uncheck ? '' : ' checked'}> <span>${author.label ? `[${author.label}]` : ''}[${author.name}] ${core.title}</span>${fwSrc ? `<a href="${fwSrc}" target="_blank">来源</a>` : ''}</label>` + html
  2091. }
  2092. }
  2093. }
  2094. el.items.insertAdjacentHTML('afterbegin', html)
  2095. if (!has_more) return // -> finally
  2096. await new Promise(resolve => setTimeout(resolve, 250 * (Math.random() * 0.5 + 0.75))) // 切线程,顺便给请求留点间隔
  2097. }
  2098. // 执行到这里只有一个原因:stopLoad 导致任务终止
  2099. api.message.info('批量添加:任务终止', 1800)
  2100. } catch (e) {
  2101. error = true
  2102. loadTime = 0
  2103. api.message.alert('批量添加:执行失败')
  2104. api.logger.error(e)
  2105. } finally {
  2106. if (!error && !stopLoad) {
  2107. api.message.info('批量添加:稿件加载完成', 1800)
  2108. if (loadTime > 0 && el.items.querySelectorAll('.gm-item input:checked').length === 0) {
  2109. // 无有效新稿件时直接更新同步时间
  2110. setLastAddTime(loadTime)
  2111. }
  2112. }
  2113. initItemHints()
  2114. setBusy(false)
  2115. stopLoad = false
  2116. el.id1c.textContent = '重新执行'
  2117. el.id1d.disabled = true
  2118. el.id4b.textContent = '执行'
  2119. // 更新第二步的时间范围
  2120. if (el.id2a.defaultValue && el.id2b.syncVal) {
  2121. el.id2a.value = el.id2a.defaultValue
  2122. el.id2b.value = el.id2b.syncVal // 非用户操作不会触发 change 事件
  2123. el.id2b.prevVal = el.id2b.value
  2124. }
  2125. // 自动执行第三步
  2126. el.id3c.dispatchEvent(new Event('click'))
  2127. }
  2128. })
  2129. el.id1a.addEventListener('keyup', e => {
  2130. if (e.key === 'Enter') {
  2131. const target = el[busy ? 'id1d' : 'id1c']
  2132. if (!target.disabled) {
  2133. target.dispatchEvent(new Event('click'))
  2134. }
  2135. }
  2136. })
  2137. // 稍后再看列表导入
  2138. async function importWatchlaterList(content, avSet) {
  2139. const gr = new RegExp(gm.config.importWl_regex, 'gi')
  2140. const r = new RegExp(gm.config.importWl_regex, 'i')
  2141. const strs = content.match(gr)
  2142. let html = ''
  2143. for (const str of strs) {
  2144. const m = r.exec(str)
  2145. let aid = m?.[gm.config.importWl_aid]
  2146. if (!aid) {
  2147. try {
  2148. aid = webpage.method.bvTool.bv2av(m?.[gm.config.importWl_bvid])
  2149. } catch { /* BV 号有问题,忽略 */ }
  2150. }
  2151. if (aid) {
  2152. if (avSet.has(aid)) continue
  2153. avSet.add(aid)
  2154. const exist = await webpage.method.getVideoWatchlaterStatusByAid(aid, false, true) // 不跳过已存在稿件,仅作提示
  2155. const uncheck = history?.has(aid) || exist
  2156. const displayNone = uncheck && el.uncheckedDisplay._hide
  2157. const disabledStr = exist ? ' disabled' : ''
  2158. const title = m?.[gm.config.importWl_title]
  2159. const source = m?.[gm.config.importWl_source]
  2160. let tsS = m?.[gm.config.importWl_tsS]
  2161. if (!tsS) {
  2162. const tsMs = m?.[gm.config.importWl_tsS]
  2163. if (tsMs) {
  2164. tsS = Math.round(Number.parseInt(tsMs) / 1000)
  2165. }
  2166. }
  2167. html = `<label class="gm-item" data-aid="${aid}" data-timestamp="${tsS ?? ''}" data-search-str="${source ?? ''} ${title ?? ''}"${displayNone ? ' style="display:none"' : ''}${disabledStr}><input type="checkbox"${uncheck ? '' : ' checked'}${disabledStr}> <span>${source ? `[${source}] ` : ''}${title ?? `AV${aid}`}</span></label>` + html
  2168. }
  2169. }
  2170. el.items.insertAdjacentHTML('afterbegin', html)
  2171. }
  2172. const id1eF = el.id1e.firstElementChild
  2173. el.id1e.addEventListener('click', () => id1eF.click())
  2174. el.id1e.addEventListener('contextmenu', e => {
  2175. this.setImportWatchlaterList()
  2176. e.preventDefault()
  2177. })
  2178. id1eF.addEventListener('change', async () => {
  2179. if (busy) return
  2180. let error = false
  2181. try {
  2182. setBusy(true)
  2183. el.id1d.disabled = false
  2184. el.id1e.children[1].textContent = '文件导入中'
  2185. el.id2a.value = el.id2a.defaultValue = el.id2a.max = ''
  2186. el.items.textContent = ''
  2187. const ps = []
  2188. const avSet = new Set()
  2189. for (const file of id1eF.files) {
  2190. ps.push(new Promise((resolve, reject) => {
  2191. const reader = new FileReader()
  2192. reader.addEventListener('load', async () => {
  2193. try {
  2194. await importWatchlaterList(reader.result, avSet)
  2195. resolve()
  2196. } catch (e) {
  2197. api.message.alert(`文件 <code>${file.name}</code> 读取失败,终止导入。`, { html: true })
  2198. reject(e)
  2199. }
  2200. })
  2201. reader.addEventListener('abort', () => resolve(''))
  2202. reader.addEventListener('error', e => {
  2203. api.message.alert(`文件 <code>${file.name}</code> 读取失败,终止导入。`, { html: true })
  2204. reject(e)
  2205. })
  2206. reader.readAsText(file)
  2207. readers.push(reader)
  2208. }))
  2209. }
  2210. await Promise.all(ps)
  2211. } catch (e) {
  2212. error = true
  2213. api.logger.error(e)
  2214. if (readers.length > 0) {
  2215. for (const r of readers) {
  2216. r.abort()
  2217. }
  2218. }
  2219. } finally {
  2220. if (stopLoad) {
  2221. api.message.info('批量添加:任务终止', 1800)
  2222. } else if (!error) {
  2223. api.message.info('批量添加:稍后再看列表导入成功', 1800)
  2224. }
  2225. readers = []
  2226. setBusy(false)
  2227. stopLoad = false
  2228. el.id1d.disabled = true
  2229. el.id1e.children[1].textContent = '文件'
  2230. // 自动执行第三步
  2231. el.id3c.dispatchEvent(new Event('click'))
  2232. id1eF.value = '' // 重置控件,否则重新选择相同文件不会触发 change 事件;置空行为不会触发 change 事件
  2233. }
  2234. })
  2235. // 收藏夹导入
  2236. el.id1f.addEventListener('click', async () => {
  2237. let favExecuted = false
  2238. if (busy) return
  2239. try {
  2240. setBusy(true)
  2241. el.id1d.disabled = true
  2242. el.id1f.textContent = '收藏夹导入中'
  2243. el.id2a.value = el.id2a.defaultValue = el.id2a.max = ''
  2244. el.items.textContent = ''
  2245. let mlid = await api.message.prompt(`
  2246. <p>指定需导入的收藏夹。下方应填入目标收藏夹 ID,可使用英文逗号「<code>,</code>」分隔多个收藏夹。置空时使用稍后再看收藏夹。</p>
  2247. <p style="word-break:break-all">收藏夹页面网址为 <code>https://space.bilibili.com/\${uid}/favlist?fid=\${mlid}</code>,<code>mlid</code> 即收藏夹 ID。</p>
  2248. `, null, { html: true })
  2249. if (mlid == null) return
  2250. if (mlid.trim() === '') {
  2251. const uid = webpage.method.getDedeUserID()
  2252. mlid = GM_getValue(`watchlaterMediaList_${uid}`)
  2253. if (!mlid) {
  2254. api.message.info('没有设置稍后再看收藏夹')
  2255. return
  2256. }
  2257. }
  2258. let error = false
  2259. try {
  2260. favExecuted = true
  2261. el.id1d.disabled = false
  2262. const avSet = new Set()
  2263. const favIds = mlid.split(',')
  2264. // eslint-disable-next-line no-unreachable-loop
  2265. id1fFavLoop: for (const favId of favIds) {
  2266. let page = 1
  2267. // eslint-disable-next-line no-unmodified-loop-condition
  2268. while (!stopLoad) {
  2269. const data = new URLSearchParams()
  2270. data.append('media_id', favId)
  2271. data.append('ps', '20') // 每页数,最大 20
  2272. data.append('pn', page++)
  2273. const resp = await api.web.request({
  2274. url: `${gm.url.api_favResourceList}?${data.toString()}`,
  2275. }, { check: r => r.code === 0 })
  2276. const { medias, info, has_more } = resp.data
  2277. if (!medias || medias.length === 0) continue id1fFavLoop
  2278. const source = info.title
  2279. let html = ''
  2280. for (const item of medias) {
  2281. const aid = String(item.id)
  2282. if (avSet.has(aid)) continue
  2283. avSet.add(aid)
  2284. const exist = await webpage.method.getVideoWatchlaterStatusByAid(aid, false, true) // 不跳过已存在稿件,仅作提示
  2285. const uncheck = history?.has(aid) || exist
  2286. const displayNone = uncheck && el.uncheckedDisplay._hide
  2287. const disabledStr = exist ? ' disabled' : ''
  2288. html = `<label class="gm-item" data-aid="${aid}" data-timestamp="${item.pubtime}"${displayNone ? ' style="display:none"' : ''}${disabledStr}><input type="checkbox"${uncheck ? '' : ' checked'}${disabledStr}> <span>[${source}][${item.upper.name}] ${item.title}</span></label>` + html
  2289. }
  2290. el.items.insertAdjacentHTML('afterbegin', html)
  2291. if (!has_more) continue id1fFavLoop
  2292. await new Promise(resolve => setTimeout(resolve, 250 * (Math.random() * 0.5 + 0.75))) // 切线程,顺便给请求留点间隔
  2293. }
  2294. // 执行到这里只有一个原因:stopLoad 导致任务终止
  2295. api.message.info('批量添加:任务终止', 1800)
  2296. break
  2297. }
  2298. } catch (e) {
  2299. error = true
  2300. api.message.alert('批量添加:执行失败')
  2301. api.logger.error(e)
  2302. } finally {
  2303. if (!error && !stopLoad) {
  2304. api.message.info('批量添加:稿件加载完成', 1800)
  2305. }
  2306.  
  2307. }
  2308. } finally {
  2309. setBusy(false)
  2310. stopLoad = false
  2311. el.id1d.disabled = true
  2312. el.id1f.textContent = '收藏夹'
  2313. if (favExecuted) {
  2314. // 自动执行第三步
  2315. el.id3c.dispatchEvent(new Event('click'))
  2316. }
  2317. }
  2318. })
  2319. // 终止加载 / 导入
  2320. el.id1d.addEventListener('click', () => {
  2321. stopLoad = true
  2322. if (readers.length > 0) {
  2323. for (const r of readers) {
  2324. r.abort()
  2325. }
  2326. }
  2327. })
  2328.  
  2329. // 时间过滤
  2330. function filterTime() {
  2331. if (busy) return
  2332. try {
  2333. busy = true
  2334. const v2a = Number.parseFloat(el.id2a.value)
  2335. if (Number.isNaN(v2a)) {
  2336. for (let i = 0; i < el.items.childElementCount; i++) {
  2337. el.items.children[i].classList.remove('gm-filtered-time')
  2338. }
  2339. } else {
  2340. const newEnd = Date.now() - v2a * el.id2b.value * 1000
  2341. for (let i = 0; i < el.items.childElementCount; i++) {
  2342. const item = el.items.children[i]
  2343. const timestamp = Number.parseInt(item.dataset.timestamp)
  2344. if (timestamp * 1000 < newEnd) {
  2345. item.classList.add('gm-filtered-time')
  2346. } else {
  2347. item.classList.remove('gm-filtered-time')
  2348. }
  2349. }
  2350. }
  2351. } catch (e) {
  2352. api.message.alert('批量添加:执行失败')
  2353. api.logger.error(e)
  2354. } finally {
  2355. busy = false
  2356. }
  2357. }
  2358. const throttledFilterTime = api.base.throttle(filterTime, gm.const.inputThrottleWait)
  2359. el.id2a.addEventListener('input', throttledFilterTime)
  2360. el.id2a.addEventListener('change', throttledFilterTime)
  2361. el.id2b.addEventListener('change', filterTime)
  2362. el.id2c.addEventListener('click', filterTime)
  2363.  
  2364. // 正则过滤
  2365. function filterRegex() {
  2366. if (busy) return
  2367. try {
  2368. const getRegex = str => {
  2369. let result = null
  2370. str = str.trim()
  2371. if (str !== '') {
  2372. try {
  2373. str = str.replaceAll(/\s*\|\s*/g, '|') // 移除关键词首末空白符
  2374. .replaceAll(/[$()+.[\\\]^{}]/g, '\\$&') // escape regex except |
  2375. .replaceAll('?', '.').replaceAll('*', '.*') // 通配符
  2376. result = new RegExp(str, 'i')
  2377. } catch {}
  2378. }
  2379. return result
  2380. }
  2381. busy = true
  2382. el.id3a.value = el.id3a.value.trimStart()
  2383. el.id3b.value = el.id3b.value.trimStart()
  2384. const v3a = getRegex(el.id3a.value)
  2385. const v3b = getRegex(el.id3b.value)
  2386. for (let i = 0; i < el.items.childElementCount; i++) {
  2387. const item = el.items.children[i]
  2388. const ss = item.dataset.searchStr ?? item.textContent
  2389. if ((v3a && !v3a.test(ss)) || v3b?.test(ss)) {
  2390. item.classList.add('gm-filtered-regex')
  2391. } else {
  2392. item.classList.remove('gm-filtered-regex')
  2393. }
  2394. }
  2395. } catch (e) {
  2396. api.message.alert('批量添加:执行失败')
  2397. api.logger.error(e)
  2398. } finally {
  2399. busy = false
  2400. }
  2401. }
  2402. const throttledFilterRegex = api.base.throttle(filterRegex, gm.const.inputThrottleWait)
  2403. el.id3a.addEventListener('input', throttledFilterRegex)
  2404. el.id3b.addEventListener('input', throttledFilterRegex)
  2405. el.id3c.addEventListener('click', throttledFilterRegex)
  2406.  
  2407. // 添加到稍后再看
  2408. let stopAdd = false
  2409. el.id4b.addEventListener('click', async () => {
  2410. if (busy) return
  2411. let added = false
  2412. let lastAddTime = 0
  2413. try {
  2414. setBusy(true)
  2415. let v4a = Number.parseFloat(el.id4a.value)
  2416. v4a = Number.isNaN(v4a) ? gm.const.batchAddRequestInterval : Math.max(v4a, 250)
  2417. el.id4a.value = v4a
  2418. el.id4b.textContent = '执行中'
  2419. el.id4c.disabled = false
  2420. let available = 100 - (await gm.data.watchlaterListData()).length
  2421. const checks = el.items.querySelectorAll('.gm-item:not([class*=gm-filtered-]) input:checked')
  2422. for (const check of checks) {
  2423. if (stopAdd) return api.message.info('批量添加:任务终止', 1800) // -> finally
  2424. if (available <= 0) return api.message.info('批量添加:稍后再看已满', 1800) // -> finally
  2425. const item = check.parentElement
  2426. const success = await webpage.method.switchVideoWatchlaterStatus(item.dataset.aid)
  2427. if (!success) throw new Error('add request error')
  2428. lastAddTime = item.dataset.timestamp
  2429. check.checked = false
  2430. if (el.uncheckedDisplay._hide) {
  2431. item.style.display = 'none'
  2432. }
  2433. available -= 1
  2434. added = true
  2435. await new Promise(resolve => setTimeout(resolve, v4a * (Math.random() * 0.5 + 0.75)))
  2436. }
  2437. lastAddTime = loadTime
  2438. api.message.info('批量添加:已将所有选定稿件添加到稍后再看', 1800)
  2439. } catch (e) {
  2440. api.message.alert('批量添加:执行失败。可能是因为目标稿件不可用或稍后再看不支持该稿件类型(如互动视频),请尝试取消勾选当前列表中第一个选定的稿件后重新执行。')
  2441. api.logger.error(e)
  2442. } finally {
  2443. if (lastAddTime) {
  2444. if (lastAddTime !== loadTime) {
  2445. lastAddTime = Number.parseInt(lastAddTime) * 1000
  2446. }
  2447. if (lastAddTime > 0) {
  2448. setLastAddTime(lastAddTime)
  2449. }
  2450. }
  2451. setBusy(false)
  2452. stopAdd = false
  2453. el.id4b.textContent = '重新执行'
  2454. el.id4c.disabled = true
  2455. gm.runtime.reloadWatchlaterListData = true
  2456. window.dispatchEvent(new CustomEvent('reloadWatchlaterListData'))
  2457.  
  2458. if (added && api.base.urlMatch(gm.regex.page_watchlaterList)) {
  2459. webpage.reloadWatchlaterListPage(null)
  2460. }
  2461. }
  2462. })
  2463. el.id4c.addEventListener('click', () => {
  2464. stopAdd = true
  2465. })
  2466. el.id4a.addEventListener('keyup', e => {
  2467. if (e.key === 'Enter') {
  2468. const target = el[busy ? 'id4c' : 'id4b']
  2469. if (!target.disabled) {
  2470. target.dispatchEvent(new Event('click'))
  2471. }
  2472. }
  2473. })
  2474.  
  2475. // 时间单位转换
  2476. const syncTimeUnit = (unitEl, valEl) => {
  2477. unitEl.prevVal = unitEl.value
  2478. unitEl.addEventListener('change', () => {
  2479. if (valEl.max !== Number.POSITIVE_INFINITY) {
  2480. valEl.max = (valEl.max * unitEl.prevVal / unitEl.value).toFixed(1)
  2481. }
  2482. if (valEl.defaultValue) {
  2483. valEl.defaultValue = (valEl.defaultValue * unitEl.prevVal / unitEl.value).toFixed(1)
  2484. }
  2485. if (valEl.value) {
  2486. valEl.value = (valEl.value * unitEl.prevVal / unitEl.value).toFixed(1)
  2487. unitEl.prevVal = unitEl.value
  2488. }
  2489. }, true)
  2490. }
  2491. syncTimeUnit(el.id1b, el.id1a)
  2492. syncTimeUnit(el.id2b, el.id2a)
  2493. }
  2494.  
  2495. /**
  2496. * 初始化项目鼠标悬浮提示
  2497. */
  2498. const initItemHints = () => {
  2499. const hintEls = el.items.querySelectorAll('[data-src-hint]')
  2500. for (const el of hintEls) {
  2501. api.message.hoverInfo(el, `转发者:${el.dataset.srcHint}`)
  2502. }
  2503. }
  2504. }
  2505. }
  2506.  
  2507. /**
  2508. * 打开移除记录
  2509. */
  2510. openRemoveHistory() {
  2511. if (!gm.config.removeHistory) {
  2512. api.message.info('请在设置中开启稍后再看移除记录')
  2513. return
  2514. }
  2515. GM_deleteValue('removeHistorySaveTime') // 保险起见,清理一下
  2516.  
  2517. /** @type {{[n: string]: HTMLElement}} */
  2518. const el = {}
  2519. if (gm.el.history) {
  2520. el.searchTimes = gm.el.history.querySelector('#gm-history-search-times')
  2521. el.searchTimes.value = gm.config.removeHistorySearchTimes
  2522. el.searchTimes.current = el.searchTimes.value
  2523. el.sort = gm.el.history.querySelector('#gm-history-sort')
  2524. if (el.sort.type !== 0) {
  2525. el.sort.type = 0 // 降序
  2526. }
  2527. this.openPanelItem('history')
  2528. } else {
  2529. setTimeout(() => {
  2530. initHistory()
  2531. processItem()
  2532. this.openPanelItem('history')
  2533. })
  2534.  
  2535. /**
  2536. * 初始化移除记录页面
  2537. */
  2538. const initHistory = () => {
  2539. gm.el.history = gm.el.gmRoot.appendChild(document.createElement('div'))
  2540. gm.panel.history.el = gm.el.history
  2541. gm.el.history.className = 'gm-history gm-modal-container'
  2542. gm.el.history.innerHTML = `
  2543. <div class="gm-history-page gm-modal">
  2544. <div class="gm-title">稍后再看移除记录</div>
  2545. <div class="gm-comment">
  2546. <div>根据<span id="gm-history-new-or-old" style="padding-right:0"></span><span id="gm-history-save-times">0</span>条不重复数据记录生成,共筛选出<span id="gm-history-removed-num">0</span>条移除记录。排序由稿件<span id="gm-history-time-point"></span>被观察到处于稍后再看的时间决定,与被移除出稍后再看的时间无关。如果记录太少请设置增加历史回溯深度;记录太多则减少之,并善用浏览器搜索功能辅助定位。</div>
  2547. <div style="text-align:right;font-weight:bold">
  2548. <span id="gm-history-sort" style="text-decoration:underline;cursor:pointer"></span>
  2549. <span title="搜寻时在最近/最早保存的多少条稍后再看历史数据记录中查找。按下回车键或输入框失去焦点时刷新数据。">历史回溯深度:<input is="laster2800-input-number" id="gm-history-search-times" value="${gm.config.removeHistorySearchTimes}" min="${gm.configMap.removeHistorySearchTimes.min}" max="${gm.configMap.removeHistorySearchTimes.max}"></span>
  2550. </div>
  2551. </div>
  2552. <div class="gm-content"></div>
  2553. </div>
  2554. <div class="gm-shadow"></div>
  2555. `
  2556. el.historyPage = gm.el.history.querySelector('.gm-history-page')
  2557. el.comment = gm.el.history.querySelector('.gm-comment')
  2558. el.content = gm.el.history.querySelector('.gm-content')
  2559. el.sort = gm.el.history.querySelector('#gm-history-sort')
  2560. el.timePoint = gm.el.history.querySelector('#gm-history-time-point')
  2561. el.saveTimes = gm.el.history.querySelector('#gm-history-save-times')
  2562. el.removedNum = gm.el.history.querySelector('#gm-history-removed-num')
  2563. el.searchTimes = gm.el.history.querySelector('#gm-history-search-times')
  2564. el.newOrOld = gm.el.history.querySelector('#gm-history-new-or-old')
  2565. el.shadow = gm.el.history.querySelector('.gm-shadow')
  2566. }
  2567.  
  2568. /**
  2569. * 维护内部元素和数据
  2570. */
  2571. const processItem = () => {
  2572. el.content.fadeOutDisplay = 'block'
  2573. el.content.fadeInTime = gm.const.textFadeTime
  2574. el.content.fadeOutTime = gm.const.textFadeTime
  2575. el.searchTimes.current = el.searchTimes.value
  2576. el.searchTimes.addEventListener('blur', () => {
  2577. const target = el.searchTimes
  2578. if (target.value !== el.searchTimes.current) {
  2579. el.searchTimes.current = target.value
  2580. gm.panel.history.openHandler()
  2581. }
  2582. })
  2583. el.searchTimes.addEventListener('keyup', e => {
  2584. if (e.key === 'Enter') {
  2585. el.searchTimes.dispatchEvent(new Event('blur'))
  2586. }
  2587. })
  2588.  
  2589. el.content.addEventListener('click', async e => {
  2590. if (e.target.type === 'checkbox') {
  2591. const box = e.target
  2592. const status = box.checked
  2593. const { bvid } = box.dataset
  2594. const note = status ? '添加到稍后再看' : '从稍后再看移除'
  2595. const success = await webpage?.method.switchVideoWatchlaterStatus(bvid, status)
  2596. if (success) {
  2597. api.message.info(`${note}成功`)
  2598. } else {
  2599. box.checked = !status
  2600. api.message.info(`${note}失败${status ? ',可能是因为该稿件不可用' : ''}`)
  2601. }
  2602. }
  2603. })
  2604.  
  2605. // 排序方式
  2606. const typeText = ['降序', '升序', '完全升序']
  2607. const typeDesc = [
  2608. '降序回溯历史,降序显示结果',
  2609. '降序回溯历史,升序显示结果',
  2610. '升序回溯历史,升序显示结果',
  2611. ]
  2612. Reflect.defineProperty(el.sort, 'type', {
  2613. get() { return Number.parseInt(this.dataset.type) },
  2614. set(val) {
  2615. this.dataset.type = val
  2616. this.textContent = typeText[val]
  2617. this.title = typeDesc[val]
  2618. el.newOrOld.textContent = val < 2 ? '最近' : '最早'
  2619. },
  2620. })
  2621. el.sort.type = 0
  2622. el.sort.addEventListener('click', () => {
  2623. const target = el.sort
  2624. target.type = (target.type + 1) % typeText.length
  2625. gm.panel.history.openHandler()
  2626. })
  2627.  
  2628. gm.panel.history.openHandler = onOpen
  2629. gm.el.history.fadeInDisplay = 'flex'
  2630. el.shadow.addEventListener('click', () => this.closePanelItem('history'))
  2631. }
  2632.  
  2633. /**
  2634. * 移除记录打开时执行
  2635. */
  2636. const onOpen = async () => {
  2637. api.dom.fade(false, el.content)
  2638. el.timePoint.textContent = gm.config.removeHistoryTimestamp ? '最后一次' : '第一次'
  2639.  
  2640. try {
  2641. const map = await webpage.method.getWatchlaterDataMap(item => item.bvid, 'bvid', true)
  2642. const depth = Number.parseInt(el.searchTimes.value)
  2643. let data = null
  2644. if (el.sort.type < 2) {
  2645. data = gm.data.removeHistoryData().toArray(depth)
  2646. } else {
  2647. const rhd = gm.data.removeHistoryData()
  2648. data = rhd.toArray(depth, rhd.size - depth)
  2649. }
  2650. el.saveTimes.textContent = data.length
  2651. const history = []
  2652. const result = []
  2653. for (const record of data) {
  2654. if (!map.has(record[0])) {
  2655. history.push(record)
  2656. }
  2657. }
  2658.  
  2659. if (gm.config.removeHistoryTimestamp) { // 两种情况有大量同类项,但合并后处理速度会降不少
  2660. if (history.length > 1) {
  2661. // ES2019 后 Array#sort() 为稳定排序
  2662. history.sort((a, b) => (b[2] ?? 0) - (a[2] ?? 0))
  2663. if (el.sort.type >= 1) {
  2664. history.reverse()
  2665. }
  2666. }
  2667. for (const rm of history) {
  2668. result.push(`
  2669. <div>
  2670. <a href="${gm.url.page_videoNormalMode}/${rm[0]}" target="_blank">${rm[1]}</a>
  2671. <input type="checkbox" data-bvid="${rm[0]}">
  2672. ${rm[2] ? `<div class="gm-history-date">${new Date(rm[2]).toLocaleString()}</div>` : ''}
  2673. </div>
  2674. `)
  2675. }
  2676. } else {
  2677. if (history.length > 1 && el.sort.type >= 1) {
  2678. history.reverse()
  2679. }
  2680. for (const rm of history) {
  2681. result.push(`
  2682. <div>
  2683. <a href="${gm.url.page_videoNormalMode}/${rm[0]}" target="_blank">${rm[1]}</a>
  2684. <input type="checkbox" data-bvid="${rm[0]}">
  2685. </div>
  2686. `)
  2687. }
  2688. }
  2689. el.removedNum.textContent = result.length
  2690.  
  2691. if (result.length > 0) {
  2692. el.content.innerHTML = result.join('')
  2693. el.content.scrollTop = 0
  2694. } else {
  2695. setEmptyContent('没有找到移除记录,请尝试增大历史回溯深度')
  2696. }
  2697. } catch (e) {
  2698. setEmptyContent(`网络连接错误或内部数据错误,初始化脚本或清空稍后再看历史数据或许能解决问题。无法解决时请提供反馈:<br><a style="color:inherit;font-weight:normal" href="${GM_info.script.supportURL}" target="_blank">${GM_info.script.supportURL}<a>`)
  2699. api.logger.error(e)
  2700. } finally {
  2701. api.dom.fade(true, el.content)
  2702. }
  2703. }
  2704.  
  2705. const setEmptyContent = text => {
  2706. el.content.innerHTML = `<div class="gm-empty"><div>${text}</div></div>`
  2707. }
  2708. }
  2709. }
  2710.  
  2711. /**
  2712. * 初始化脚本
  2713. */
  2714. async resetScript() {
  2715. const result = await api.message.confirm('是否要初始化脚本?本操作不会清理稍后再看历史数据,要清理之请在用户设置中操作。')
  2716. if (result) {
  2717. const keyNoReset = { removeHistoryData: true, removeHistorySaves: true }
  2718. const gmKeys = GM_listValues()
  2719. for (const gmKey of gmKeys) {
  2720. if (!keyNoReset[gmKey]) {
  2721. GM_deleteValue(gmKey)
  2722. }
  2723. }
  2724. gm.configVersion = 0
  2725. GM_setValue('configVersion', gm.configVersion)
  2726. location.reload()
  2727. }
  2728. }
  2729.  
  2730. /**
  2731. * 清空 removeHistoryData
  2732. */
  2733. async clearRemoveHistoryData() {
  2734. const result = await api.message.confirm('是否要清空稍后再看历史数据?')
  2735. if (result) {
  2736. GM_deleteValue('removeHistoryData')
  2737. GM_deleteValue('removeHistoryFuzzyCompareReference')
  2738. location.reload()
  2739. }
  2740. }
  2741.  
  2742. /**
  2743. * 取消所有固定项
  2744. */
  2745. async clearFixedItems() {
  2746. const result = await api.message.confirm('是否要取消所有固定项?')
  2747. if (result) {
  2748. GM_setValue('fixedItems', [])
  2749. for (const item of document.querySelectorAll('.gm-fixed')) {
  2750. item.classList?.remove('gm-fixed')
  2751. }
  2752. api.message.info('已取消所有固定项')
  2753. }
  2754. }
  2755.  
  2756. /**
  2757. * 打开面板项
  2758. * @param {string} name 面板项名称
  2759. * @param {(panel: GMObject_panel_item) => void} [callback] 打开面板项后的回调函数
  2760. * @param {boolean} [keepOthers] 打开时保留其他面板项
  2761. * @returns {Promise<boolean>} 操作是否成功
  2762. */
  2763. async openPanelItem(name, callback, keepOthers) {
  2764. let success = false
  2765. /** @type {GMObject_panel_item} */
  2766. const panel = gm.panel[name]
  2767. if (panel.wait > 0) return false
  2768. try {
  2769. try {
  2770. if (panel.state === 1) {
  2771. panel.wait = 1
  2772. await api.wait.waitForConditionPassed({
  2773. condition: () => panel.state === 2,
  2774. timeout: 1500 + (panel.el.fadeInTime ?? gm.const.fadeTime),
  2775. })
  2776. return true
  2777. } else if (panel.state === 3) {
  2778. panel.wait = 1
  2779. await api.wait.waitForConditionPassed({
  2780. condition: () => panel.state === 0,
  2781. timeout: 1500 + (panel.el.fadeOutTime ?? gm.const.fadeTime),
  2782. })
  2783. }
  2784. } catch (e) {
  2785. panel.state = -1
  2786. api.logger.error(e)
  2787. } finally {
  2788. panel.wait = 0
  2789. }
  2790. if (panel.state === 0 || panel.state === -1) {
  2791. panel.state = 1
  2792. if (!keepOthers) {
  2793. for (const [key, curr] of Object.entries(gm.panel)) {
  2794. if (key === name || curr.state === 0) continue
  2795. this.closePanelItem(key)
  2796. }
  2797. }
  2798. await panel.openHandler?.()
  2799. await new Promise(resolve => {
  2800. api.dom.fade(true, panel.el, () => {
  2801. resolve()
  2802. panel.openedHandler?.()
  2803. callback?.(panel)
  2804. })
  2805. })
  2806. panel.state = 2
  2807. success = true
  2808. }
  2809. if (success && document.fullscreenElement) {
  2810. document.exitFullscreen()
  2811. }
  2812. } catch (e) {
  2813. panel.state = -1
  2814. api.logger.error(e)
  2815. }
  2816. return success
  2817. }
  2818.  
  2819. /**
  2820. * 关闭面板项
  2821. * @param {string} name 面板项名称
  2822. * @param {(panel: GMObject_panel_item) => void} [callback] 关闭面板项后的回调函数
  2823. * @returns {Promise<boolean>} 操作是否成功
  2824. */
  2825. async closePanelItem(name, callback) {
  2826. /** @type {GMObject_panel_item} */
  2827. const panel = gm.panel[name]
  2828. if (panel.wait > 0) return
  2829. try {
  2830. try {
  2831. if (panel.state === 1) {
  2832. panel.wait = 2
  2833. await api.wait.waitForConditionPassed({
  2834. condition: () => panel.state === 2,
  2835. timeout: 1500 + (panel.el.fadeInTime ?? gm.const.fadeTime),
  2836. })
  2837. } else if (panel.state === 3) {
  2838. panel.wait = 2
  2839. await api.wait.waitForConditionPassed({
  2840. condition: () => panel.state === 0,
  2841. timeout: 1500 + (panel.el.fadeOutTime ?? gm.const.fadeTime),
  2842. })
  2843. return true
  2844. }
  2845. } catch (e) {
  2846. panel.state = -1
  2847. api.logger.error(e)
  2848. } finally {
  2849. panel.wait = 0
  2850. }
  2851. if (panel.state === 2 || panel.state === -1) {
  2852. panel.state = 3
  2853. await panel.closeHandler?.()
  2854. await new Promise(resolve => {
  2855. api.dom.fade(false, panel.el, () => {
  2856. resolve()
  2857. panel.closedHandler?.()
  2858. callback?.(panel)
  2859. })
  2860. })
  2861. panel.state = 0
  2862. return true
  2863. }
  2864. } catch (e) {
  2865. panel.state = -1
  2866. api.logger.error(e)
  2867. }
  2868. return false
  2869. }
  2870.  
  2871. /**
  2872. * 导出稍后再看列表
  2873. */
  2874. async exportWatchlaterList() {
  2875. try {
  2876. const ITEMS = await gm.data.watchlaterListData(true)
  2877.  
  2878. /* eslint-disable no-eval */
  2879. /* eslint-disable no-unused-vars */
  2880. const = true
  2881. const = false
  2882. /* eslint-disable prefer-const */
  2883. let 导出至剪贴板 = true
  2884. let 导出至新页面 = false
  2885. let 导出至文件 = false
  2886. let 导出文件名 = null
  2887. let 相邻稿件换行 = true
  2888. let 前置内容 = null
  2889. let 后置内容 = null
  2890. let 稿件导出模板 = null
  2891. /* eslint-enable prefer-const */
  2892.  
  2893. let config = GM_getValue('exportWatchlaterListConfig')
  2894. if (!config || config.trim() === '') {
  2895. config = gm.const.exportWatchlaterList_default
  2896. GM_setValue('exportWatchlaterListConfig', config)
  2897. }
  2898. eval(config)
  2899.  
  2900. const front = 前置内容 ? eval('`' + 前置内容 + '`') : ''
  2901. const rear = 后置内容 ? eval('`' + 后置内容 + '`') : ''
  2902. const items = []
  2903. for (const [idx, ITEM] of ITEMS.entries()) {
  2904. const INDEX = idx + 1
  2905. items.push(eval('`' + 稿件导出模板 + '`'))
  2906. }
  2907.  
  2908. if (导出至剪贴板 || 导出至文件) {
  2909. const content = `${front}${相邻稿件换行 ? items.join('\n') : items.join('')}${rear}`
  2910. if (导出至剪贴板) {
  2911. await navigator.clipboard.writeText(content).then(
  2912. () => api.message.info('稍后再看列表已导出至剪贴板'),
  2913. () => api.message.info('稍后再看列表写入剪贴板失败', 3000),
  2914. )
  2915. }
  2916. if (导出至文件) {
  2917. const filename = 导出文件名 ? eval('`' + 导出文件名 + '`') : `稍后再看列表.${Date.now()}.txt`
  2918. const file = new Blob([content], { type: 'text/plain' })
  2919. const a = document.createElement('a')
  2920. a.href = URL.createObjectURL(file)
  2921. a.download = filename
  2922. a.click()
  2923. }
  2924. }
  2925. if (导出至新页面) {
  2926. const center = 相邻稿件换行 ? items.join('</p><p>') : items.join('')
  2927. const content = `${front !== '' ? `<p>${front}</p>` : ''}<p>${center}</p>${rear !== '' ? `<p>${rear}</p>` : ''}`.replaceAll(/\n(?!<\/p>)/g, '<br>').replaceAll('\n', '')
  2928. const w = window.open()
  2929. w.document.write(content)
  2930. w.document.close()
  2931. w.document.title = `稍后再看列表@${new Date().toLocaleString()}`
  2932. }
  2933. /* eslint-enable no-eval */
  2934. /* eslint-enable no-unused-vars */
  2935. } catch (e) {
  2936. api.logger.error(e)
  2937. const result = await api.message.confirm('稍后再看列表导出失败,可能是导出方式配置错误(错误信息详见控制台)。是否打开导出设置?')
  2938. if (result) {
  2939. this.setExportWatchlaterList()
  2940. }
  2941. }
  2942. }
  2943.  
  2944. /**
  2945. * 设置稍后再看列表导入方式
  2946. */
  2947. setImportWatchlaterList() {
  2948. const msg = `<div class="gm-import-wl-container">
  2949. <div>
  2950. <div>设置稍后再看列表导入方式。默认简单读取所有形如 <code>BV###</code> 的字符串。</div>
  2951. <div>若有进一步的需求,请提前设计好稍后再看列表文件的格式,使用正则表达式(不区分大小写)指定每个稿件对应的文本,然后指定稿件 ID、稿件标题、来源(建议:上传者名称)、时间节点等信息对应的捕获组。</div>
  2952. <div>可填写 <code>-1</code> 禁用某项信息,但 <code>aid / bvid</code> 至少填写一个(冲突时优先使用「AV 号」)。时间节点在批量添加管理器中被用于步骤 ②(缩小时间范围),根据用户需要可设定为稿件发布时间或文件导出时间等,冲突时优先使用「时间节点(秒)」。</div>
  2953. </div>
  2954. <div class="gm-group-container">
  2955. <div>正则表达式:</div>
  2956. <input class="gm-interactive" type="text" id="gm-import-wl-regex">
  2957. </div>
  2958. <div class="gm-group-container">
  2959. <div>捕获组:</div>
  2960. <div class="gm-capturing-group">
  2961. <div>
  2962. <div>AV 号</div>
  2963. <input class="gm-interactive" is="laster2800-input-number" id="gm-import-wl-aid" min="-1">
  2964. </div>
  2965. <div>
  2966. <div>BV 号</div>
  2967. <input class="gm-interactive" is="laster2800-input-number" id="gm-import-wl-bvid" min="-1">
  2968. </div>
  2969. <div>
  2970. <div>标题</div>
  2971. <input class="gm-interactive" is="laster2800-input-number" id="gm-import-wl-title" min="-1">
  2972. </div>
  2973. <div>
  2974. <div>来源</div>
  2975. <input class="gm-interactive" is="laster2800-input-number" id="gm-import-wl-source" min="-1">
  2976. </div>
  2977. <div>
  2978. <div>时间节点(秒)</div>
  2979. <input class="gm-interactive" is="laster2800-input-number" id="gm-import-wl-ts-s" min="-1">
  2980. </div>
  2981. <div>
  2982. <div>时间节点(毫秒)</div>
  2983. <input class="gm-interactive" is="laster2800-input-number" id="gm-import-wl-ts-ms" min="-1">
  2984. </div>
  2985. </div>
  2986. </div>
  2987. </div>`
  2988. const btnText = ['重置', '确定', '取消']
  2989. const dialog = api.message.dialog(msg, { html: true, buttons: btnText })
  2990. const [regex, aid, bvid, title, source, tsS, tsMs, reset, confirm, cancel] = dialog.interactives
  2991. const config = { regex, aid, bvid, title, source, tsS, tsMs }
  2992. reset.addEventListener('click', () => {
  2993. for (const [n, el] of Object.entries(config)) {
  2994. el.value = gm.configMap[`importWl_${n}`].default
  2995. }
  2996. })
  2997. confirm.addEventListener('click', () => {
  2998. dialog.close()
  2999. for (const [n, el] of Object.entries(config)) {
  3000. const k = `importWl_${n}`
  3001. const v = gm.configMap[k]?.type === 'int' ? Number.parseInt(el.value) : el.value
  3002. gm.config[k] = v
  3003. GM_setValue(k, v)
  3004. }
  3005. api.message.info('已保存稍后再看列表导入设置')
  3006. })
  3007. cancel.addEventListener('click', () => dialog.close())
  3008. for (const [n, el] of Object.entries(config)) {
  3009. el.value = gm.config[`importWl_${n}`]
  3010. }
  3011. dialog.open()
  3012. }
  3013.  
  3014. /**
  3015. * 设置稍后再看列表导出方式
  3016. */
  3017. setExportWatchlaterList() {
  3018. const msg = '设置稍后再看列表导出方式。默认情况下简单地导出各稿件的普通播放页 URL 到剪贴板,如需使用其他导出模板或使用文件等方式导出,请参考「示例」进行定义。置空时使用默认值。'
  3019. const btnText = ['示例', '重置', '确定', '取消']
  3020. const dialog = api.message.dialog(msg, {
  3021. buttons: btnText,
  3022. boxInput: true,
  3023. })
  3024. const [input, example, reset, confirm, cancel] = dialog.interactives
  3025. const config = GM_getValue('exportWatchlaterListConfig')
  3026. input.value = (config && config.trim() !== '') ? config : gm.const.exportWatchlaterList_default
  3027. input.style.height = '20em'
  3028. input.style.fontSize = '0.8em'
  3029. input.style.fontFamily = 'monospace'
  3030. input.focus({ preventScroll: true })
  3031. example.addEventListener('click', async () => {
  3032. let ref = ''
  3033. const data = await gm.data.watchlaterListData(true)
  3034. if (data[0]) {
  3035. const attrs = []
  3036. for (const attr in data[0]) {
  3037. if (Object.hasOwn(data[0], attr)) {
  3038. attrs.push(attr)
  3039. }
  3040. }
  3041. ref = `// ITEM 属性如下行所示,可在点击「示例」后在控制台查看详细内容结构\n// ${attrs.join(', ')}\n`
  3042. api.logger.info('ITEM 内容结构如下:')
  3043. api.logger.info(data[0])
  3044. }
  3045. input.value = `// 不需要的配置直接删除行即可,缺省配置会使用默认值\n// 使用 \${} 引用变量,配合单引号 '' 或双引号 "" 使用(而非反引号 \`\`)\n// - \${INDEX}: 稿件在列表中的位置(从 1 开始)\n// - \${ITEMS}: 稿件项目数组\n// - \${ITEM}: 稿件项目\n${ref}\n导出至剪贴板 = 否\n导出至新页面 = 否\n导出至文件 = 是\n导出文件名 = '稍后再看列表.\${Date.now()}.txt' // 注意文件名是否合法\n相邻稿件换行 = 是\n\n前置内容 = '稍后再看列表@\${new Date().toLocaleString()}\\n'\n后置内容 = '\\n--------------- 共 \${ITEMS.length} 个稿件 ---------------'\n稿件导出模板 = '[\${INDEX}] www.bilibili.com/video/\${ITEM.bvid}'`
  3046. })
  3047. reset.addEventListener('click', () => {
  3048. input.value = gm.const.exportWatchlaterList_default
  3049. })
  3050. confirm.addEventListener('click', () => {
  3051. dialog.close()
  3052. GM_setValue('exportWatchlaterListConfig', input.value)
  3053. api.message.info('已保存稍后再看列表导出设置')
  3054. })
  3055. cancel.addEventListener('click', () => dialog.close())
  3056. dialog.open()
  3057. }
  3058. }
  3059.  
  3060. /**
  3061. * 页面处理的抽象,脚本围绕网站的特化部分
  3062. */
  3063. class Webpage {
  3064. /** 内部数据 */
  3065. #data = {}
  3066.  
  3067. /** 通用方法 */
  3068. method = {
  3069. /** @type {Webpage} */
  3070. obj: null,
  3071.  
  3072. /**
  3073. * 获取指定 Cookie
  3074. * @param {string} key 键
  3075. * @returns {string} 值
  3076. * @see {@link https://developer.mozilla.org/zh-CN/docs/Web/API/Document/cookie#示例2_得到名为test2的cookie Document.cookie - Web API 接口参考 | MDN}
  3077. */
  3078. cookie(key) {
  3079. return document.cookie.replace(new RegExp(String.raw`(?:(?:^|.*;\s*)${key}\s*=\s*([^;]*).*$)|^.*$`), '$1')
  3080. },
  3081.  
  3082. /**
  3083. * 判断用户是否已登录(不可用)
  3084. * @returns {boolean} 用户是否已登录(不可用)
  3085. */
  3086. isLogin() {
  3087. return Boolean(this.getCSRF())
  3088. },
  3089.  
  3090. /**
  3091. * 获取当前登录(不可用)用户 ID
  3092. * @returns {string} `DedeUserID`
  3093. */
  3094. getDedeUserID() {
  3095. return this.cookie('DedeUserID')
  3096. },
  3097.  
  3098. /**
  3099. * 获取 CSRF
  3100. * @returns {string} `csrf`
  3101. */
  3102. getCSRF() {
  3103. return this.cookie('bili_jct')
  3104. },
  3105.  
  3106. /**
  3107. * av/bv 互转工具类
  3108. *
  3109. * 保证 av < 2 ** 27 时正确,同时应该在 av < 2 ** 30 时正确。
  3110. *
  3111. * 结合 `xor` 与 `add` 可推断出,运算过程中不会出现超过 `2 ** 34 - 1` 的数值,远不会触及到 `Number.MAX_SAFE_INTEGER === 2 ** 53 - 1`,故无须引入 BigInt 进行计算。
  3112. * @see {@link https://www.zhihu.com/question/381784377/answer/1099438784 如何看待 2020 年 3 月 23 日哔哩哔哩将稿件的「av 号」变更为「BV 号」? - 知乎 - mcfx 的回答}
  3113. */
  3114. bvTool: new class BvTool {
  3115. constructor() {
  3116. const table = 'fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF'
  3117. const tr = Object.fromEntries([...table].map((c, i) => [c, i]))
  3118. const s = [11, 10, 3, 8, 4, 6]
  3119. const xor = 177451812
  3120. const add = 8728348608
  3121. const tl = table.length
  3122. const sl = s.length
  3123. this.bv2av = dec
  3124. this.av2bv = enc
  3125.  
  3126. function dec(x) {
  3127. let r = 0
  3128. for (let i = 0; i < sl; i++) {
  3129. r += tr[x[s[i]]] * tl ** i
  3130. }
  3131. return String((r - add) ^ xor)
  3132. }
  3133.  
  3134. function enc(x) {
  3135. x = Number.parseInt(x)
  3136. x = (x ^ xor) + add
  3137. const r = [...'BV1 4 1 7 ']
  3138. for (let i = 0; i < sl; i++) {
  3139. r[s[i]] = table[Math.floor(x / tl ** i) % tl]
  3140. }
  3141. return r.join('')
  3142. }
  3143. }
  3144. }(),
  3145.  
  3146. /**
  3147. * 从 URL 获取稿件 ID
  3148. * @param {string} [url=location.href] 提取稿件 ID 的源字符串
  3149. * @returns {{id: string, type: 'aid' | 'bvid'}} `{id, type}`
  3150. */
  3151. getVid(url = location.href) {
  3152. let m = null
  3153. if ((m = /(\/|bvid=)bv([\da-z]+)([#&/?]|$)/i.exec(url))) {
  3154. return { id: 'BV' + m[2], type: 'bvid' }
  3155. } else if ((m = /(\/(av)?|aid=)(\d+)([#&/?]|$)/i.exec(url))) { // 兼容 BV 号被第三方修改为 AV 号的情况
  3156. return { id: m[3], type: 'aid' }
  3157. }
  3158. return null
  3159. },
  3160.  
  3161. /**
  3162. * 从 URL 获取稿件 `aid`
  3163. * @param {string} [url=location.href] 提取稿件 `aid` 的源字符串
  3164. * @returns {string} `aid`
  3165. */
  3166. getAid(url = location.href) {
  3167. const vid = this.getVid(url)
  3168. if (!vid) return null
  3169. return (vid.type === 'bvid') ? this.bvTool.bv2av(vid.id) : vid.id
  3170. },
  3171.  
  3172. /**
  3173. * 从 URL 获取稿件 `bvid`
  3174. * @param {string} [url=location.href] 提取稿件 `bvid` 的源字符串
  3175. * @returns {string} `bvid`
  3176. */
  3177. getBvid(url = location.href) {
  3178. const vid = this.getVid(url)
  3179. if (!vid) return null
  3180. return (vid.type === 'aid') ? this.bvTool.av2bv(vid.id) : vid.id
  3181. },
  3182.  
  3183. /**
  3184. * 根据 `aid` 获取稿件的稍后再看状态
  3185. * @param {string | number} aid 稿件 `aid`
  3186. * @param {boolean} [reload] 是否重新加载
  3187. * @param {boolean} [pageCache] 是否禁用页面缓存
  3188. * @param {boolean} [localCache=true] 是否使用本地缓存
  3189. * @returns {Promise<boolean>} 稿件是否在稍后再看中
  3190. */
  3191. async getVideoWatchlaterStatusByAid(aid, reload, pageCache, localCache = true) {
  3192. const map = await this.getWatchlaterDataMap(item => String(item.aid), 'aid', reload, pageCache, localCache)
  3193. return map.has(String(aid))
  3194. },
  3195.  
  3196. /**
  3197. * 将稿件加入稍后再看,或从稍后再看移除
  3198. * @param {string} id 稿件 `aid` 或 `bvid`(执行移除时优先选择 `aid`)
  3199. * @param {boolean} [status=true] 添加 `true` / 移除 `false`
  3200. * @returns {Promise<boolean>} 操作是否成功(稿件不在稍后在看中不被判定为失败)
  3201. */
  3202. async switchVideoWatchlaterStatus(id, status = true) {
  3203. try {
  3204. let typeA = /^\d+$/.test(id)
  3205. if (!typeA && !status) { // 移除 API 只支持 aid,先作转换
  3206. id = this.bvTool.bv2av(id)
  3207. typeA = true
  3208. }
  3209. const data = new URLSearchParams()
  3210. if (typeA) {
  3211. data.append('aid', id)
  3212. } else {
  3213. data.append('bvid', id)
  3214. }
  3215. data.append('csrf', this.getCSRF())
  3216. return await api.web.request({
  3217. method: 'POST',
  3218. url: status ? gm.url.api_addToWatchlater : gm.url.api_removeFromWatchlater,
  3219. data,
  3220. }, { parser: 'check', check: r => r.code === 0 })
  3221. } catch (e) {
  3222. api.logger.error(e)
  3223. return false
  3224. }
  3225. },
  3226.  
  3227. /**
  3228. * 清空稍后再看
  3229. * @returns {Promise<boolean>} 操作是否成功
  3230. */
  3231. async clearWatchlater() {
  3232. try {
  3233. const data = new URLSearchParams()
  3234. data.append('csrf', this.getCSRF())
  3235. const success = await api.web.request({
  3236. method: 'POST',
  3237. url: gm.url.api_clearWatchlater,
  3238. data,
  3239. }, { parser: 'check', check: r => r.code === 0 })
  3240. if (success) {
  3241. gm.runtime.reloadWatchlaterListData = true
  3242. window.dispatchEvent(new CustomEvent('reloadWatchlaterListData'))
  3243. }
  3244. return success
  3245. } catch (e) {
  3246. api.logger.error(e)
  3247. return false
  3248. }
  3249. },
  3250.  
  3251. /**
  3252. * 移除稍后再看已观看稿件
  3253. * @returns {Promise<boolean>} 操作是否成功
  3254. */
  3255. async clearWatchedInWatchlater() {
  3256. try {
  3257. const data = new URLSearchParams()
  3258. data.append('viewed', true)
  3259. data.append('csrf', this.getCSRF())
  3260. const success = await api.web.request({
  3261. method: 'POST',
  3262. url: gm.url.api_removeFromWatchlater,
  3263. data,
  3264. }, { parser: 'check', check: r => r.code === 0 })
  3265. if (success) {
  3266. gm.runtime.reloadWatchlaterListData = true
  3267. window.dispatchEvent(new CustomEvent('reloadWatchlaterListData'))
  3268. }
  3269. return success
  3270. } catch (e) {
  3271. api.logger.error(e)
  3272. return false
  3273. }
  3274. },
  3275.  
  3276. /**
  3277. * 使用稍后再看列表数据更新稍后再看历史数据
  3278. * @param {boolean} [reload] 是否重新加载稍后再看列表数据
  3279. */
  3280. async updateRemoveHistoryData(reload) {
  3281. if (gm.config.removeHistory) {
  3282. const removeHistorySaveTime = GM_getValue('removeHistorySaveTime') ?? 0
  3283. const removeHistorySavePeriod = GM_getValue('removeHistorySavePeriod') ?? gm.configMap.removeHistorySavePeriod.default
  3284. if ((Date.now() - removeHistorySaveTime > removeHistorySavePeriod * 1000) && !gm.runtime.savingRemoveHistoryData) {
  3285. gm.runtime.savingRemoveHistoryData = true
  3286. await gm.data.watchlaterListData(reload).then(current => {
  3287. if (current.length > 0) {
  3288. if (gm.config.removeHistoryFuzzyCompare > 0) {
  3289. const ref = GM_getValue('removeHistoryFuzzyCompareReference')
  3290. let same = true
  3291. if (ref) {
  3292. for (let i = 0; i < gm.config.removeHistoryFuzzyCompare; i++) {
  3293. const c = current[i]
  3294. const r = ref[i]
  3295. if (c) { // 如果 current 没有数据直接跳过得了
  3296. if (r) {
  3297. if (c.bvid !== r) {
  3298. same = false
  3299. break
  3300. }
  3301. } else {
  3302. same = false
  3303. break
  3304. }
  3305. }
  3306. }
  3307. } else {
  3308. same = false
  3309. }
  3310. if (same) {
  3311. GM_setValue('removeHistorySaveTime', Date.now())
  3312. return
  3313. }
  3314. if (current.length >= gm.config.removeHistoryFuzzyCompare) {
  3315. const newRef = []
  3316. for (let i = 0; i < gm.config.removeHistoryFuzzyCompare; i++) {
  3317. newRef.push(current[i].bvid)
  3318. }
  3319. GM_setValue('removeHistoryFuzzyCompareReference', newRef)
  3320. } else {
  3321. // 若 current 长度不够,那么加进去也白搭
  3322. GM_deleteValue('removeHistoryFuzzyCompareReference')
  3323. }
  3324.  
  3325. }
  3326.  
  3327. const data = gm.data.removeHistoryData()
  3328. let updated = false
  3329. if (gm.config.removeHistoryTimestamp) {
  3330. const timestamp = Date.now()
  3331. const map = new Map()
  3332. for (const [index, record] of data.entries()) {
  3333. map.set(record[0], index)
  3334. }
  3335. for (let i = current.length - 1; i >= 0; i--) {
  3336. const item = current[i]
  3337. if (map.has(item.bvid)) {
  3338. const idx = map.get(item.bvid)
  3339. data.data[idx][2] = timestamp
  3340. } else {
  3341. data.enqueue([item.bvid, item.title, timestamp])
  3342. }
  3343. }
  3344. updated = true
  3345. } else {
  3346. const set = new Set()
  3347. for (const record of data) {
  3348. set.add(record[0])
  3349. }
  3350. for (let i = current.length - 1; i >= 0; i--) {
  3351. const item = current[i]
  3352. if (!set.has(item.bvid)) {
  3353. data.enqueue([item.bvid, item.title])
  3354. updated = true
  3355. }
  3356. }
  3357. }
  3358. if (updated) {
  3359. GM_setValue('removeHistoryData', data)
  3360. }
  3361. // current.length === 0 时不更新
  3362. // 不要提到前面,否则时间不准确
  3363. GM_setValue('removeHistorySaveTime', Date.now())
  3364. }
  3365. }).finally(() => {
  3366. gm.runtime.savingRemoveHistoryData = false
  3367. })
  3368. }
  3369. }
  3370. },
  3371.  
  3372. /**
  3373. * 获取稍后再看列表数据以指定值为键的映射
  3374. * @param {(item: GMObject_data_item0) => *} key 计算键值的方法
  3375. * @param {string} [cacheId] 缓存 ID,传入空值时不缓存
  3376. * @param {boolean} [reload] 是否重新加载
  3377. * @param {boolean} [pageCache] 是否使用页面缓存
  3378. * @param {boolean} [localCache=true] 是否使用本地缓存
  3379. * @returns {Promise<Map<string, GMObject_data_item0>>} 稍后再看列表数据以指定值为键的映射
  3380. */
  3381. async getWatchlaterDataMap(key, cacheId, reload, pageCache, localCache = true) {
  3382. if (gm.runtime.reloadWatchlaterListData) {
  3383. reload = true
  3384. }
  3385. let obj = null
  3386. if (cacheId) {
  3387. const $data = this.obj.#data
  3388. if (!$data.watchlaterDataSet) {
  3389. $data.watchlaterDataSet = {}
  3390. }
  3391. obj = $data.watchlaterDataSet
  3392. }
  3393. if (!obj?.[cacheId] || reload || !pageCache) {
  3394. const map = new Map()
  3395. const current = await gm.data.watchlaterListData(reload, pageCache, localCache)
  3396. for (const item of current) {
  3397. map.set(key(item), item)
  3398. }
  3399. if (cacheId) {
  3400. obj[cacheId] = map
  3401. } else {
  3402. obj = map
  3403. }
  3404. }
  3405. return cacheId ? obj[cacheId] : obj
  3406. },
  3407.  
  3408. /**
  3409. * 清理 URL 上的查询参数
  3410. */
  3411. cleanSearchParams() {
  3412. if (!location.search.includes(gm.id)) return
  3413. let removed = false
  3414. const url = new URL(location.href)
  3415. for (const key of gm.searchParams.keys()) {
  3416. if (key.startsWith(gm.id)) {
  3417. url.searchParams.delete(key)
  3418. removed ||= true
  3419. }
  3420. }
  3421. if (removed && location.href !== url.href) {
  3422. history.replaceState({}, null, url.href)
  3423. }
  3424. },
  3425.  
  3426. /**
  3427. * 获取格式化时间字符串
  3428. * @param {number} [ts] Unix 时间戳
  3429. * @param {string} [dd='-'] 年月日分隔符
  3430. * @param {string} [tt=':'] 时分秒分隔符
  3431. * @param {string} [td=' '] 日期/时间分隔符
  3432. * @returns {string} 格式化时间字符串
  3433. */
  3434. getTimeString(ts, dd = '-', tt = ':', dt = ' ') {
  3435. const pad = n => n.toString().padStart(2, '0')
  3436. const date = ts ? new Date(ts) : new Date()
  3437. return (
  3438. [
  3439. date.getFullYear(),
  3440. pad(date.getMonth() + 1),
  3441. pad(date.getDay()),
  3442. ].join(dd) + dt + [
  3443. pad(date.getHours()),
  3444. pad(date.getMinutes()),
  3445. pad(date.getSeconds()),
  3446. ].join(tt)
  3447. )
  3448. },
  3449.  
  3450. /**
  3451. * 将秒格式的时间转换为字符串形式
  3452. * @param {number} sTime 秒格式的时间
  3453. * @returns {string} 字符串形式
  3454. */
  3455. getSTimeString(sTime) {
  3456. let iH = 0
  3457. let iM = Math.floor(sTime / 60)
  3458. if (iM >= 60) {
  3459. iH = Math.floor(iM / 60)
  3460. iM %= 60
  3461. }
  3462. const iS = sTime % 60
  3463.  
  3464. let sH = ''
  3465. if (iH > 0) {
  3466. sH = String(iH)
  3467. if (sH.length < 2) {
  3468. sH = '0' + sH
  3469. }
  3470. }
  3471. let sM = String(iM)
  3472. if (sM.length < 2) {
  3473. sM = '0' + sM
  3474. }
  3475. let sS = String(iS)
  3476. if (sS.length < 2) {
  3477. sS = '0' + sS
  3478. }
  3479. return `${sH ? sH + ':' : ''}${sM}:${sS}`
  3480. },
  3481.  
  3482. /**
  3483. * 获取默认收藏夹 ID
  3484. * @param {string} [uid] 用户 ID,缺省时指定当前登录(不可用)用户
  3485. * @returns {Promise<string>} `mlid`
  3486. */
  3487. async getDefaultMediaListId(uid = this.getDedeUserID()) {
  3488. let mlid = GM_getValue(`defaultMediaList_${uid}`)
  3489. if (!mlid) {
  3490. const data = new URLSearchParams()
  3491. data.append('up_mid', uid)
  3492. data.append('type', 2)
  3493. const resp = await api.web.request({
  3494. url: `${gm.url.api_listFav}?${data.toString()}`,
  3495. }, { check: r => r.code === 0 })
  3496. mlid = String(resp.data.list[0].id)
  3497. GM_setValue(`defaultMediaList_${uid}`, mlid)
  3498. }
  3499. return mlid
  3500. },
  3501.  
  3502. /**
  3503. * 将稿件添加到收藏夹
  3504. * @param {string} aid `aid`
  3505. * @param {string} mlid 收藏夹 ID
  3506. * @returns {Promise<boolean>} 操作是否成功
  3507. */
  3508. async addToFav(aid, mlid) {
  3509. try {
  3510. const data = new URLSearchParams()
  3511. data.append('rid', aid)
  3512. data.append('type', 2)
  3513. data.append('add_media_ids', mlid)
  3514. data.append('csrf', this.getCSRF())
  3515. return await api.web.request({
  3516. method: 'POST',
  3517. url: gm.url.api_dealFav,
  3518. data,
  3519. }, { parser: 'check', check: r => r.code === 0 })
  3520. } catch (e) {
  3521. api.logger.error(e)
  3522. return false
  3523. }
  3524. },
  3525.  
  3526. /**
  3527. * 获取稿件 `state` 说明
  3528. * @param {number} state 稿件状态
  3529. * @returns {string} 说明
  3530. * @see {@link https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/video/attribute_data.md#state字段值稿件状态 state字段值(稿件状态)}
  3531. */
  3532. getItemStateDesc(state) {
  3533. return ({
  3534. 1: '橙色通过',
  3535. 0: '开放浏览',
  3536. [-1]: '待审',
  3537. [-2]: '被打回',
  3538. [-3]: '网警锁定',
  3539. [-4]: '被锁定',
  3540. [-5]: '管理员锁定',
  3541. [-6]: '修复待审',
  3542. [-7]: '暂缓审核',
  3543. [-8]: '补档待审',
  3544. [-9]: '等待转码',
  3545. [-10]: '延迟审核',
  3546. [-11]: '视频源待修',
  3547. [-12]: '转储失败',
  3548. [-13]: '允许评论待审',
  3549. [-14]: '临时回收站',
  3550. [-15]: '分发中',
  3551. [-16]: '转码失败',
  3552. [-20]: '创建未提交',
  3553. [-30]: '创建已提交',
  3554. [-40]: '定时发布',
  3555. [-100]: '用户删除',
  3556. })[state] ?? '未知状态'
  3557. },
  3558. }
  3559.  
  3560. constructor() {
  3561. this.method.obj = this
  3562. }
  3563.  
  3564. /**
  3565. * 顶栏中加入稍后再看入口
  3566. */
  3567. async addHeaderButton() {
  3568. const _self = this
  3569. if (gm.config.headerCompatible === Enums.headerCompatible.bilibiliEvolved) {
  3570. api.wait.$('.custom-navbar [data-name=watchlater]').then(el => {
  3571. gm.runtime.headerType = '3rd-party'
  3572. const watchlater = el.parentElement.appendChild(el.cloneNode(true))
  3573. el.style.display = 'none'
  3574. watchlater.querySelector('a.main-content').removeAttribute('href')
  3575. watchlater.querySelector('.popup-container').style.display = 'none'
  3576. processClickEvent(watchlater)
  3577. processPopup(watchlater)
  3578. const ob = new MutationObserver((mutations, observer) => {
  3579. for (const mutation of mutations) {
  3580. if (mutation.attributeName) {
  3581. watchlater.setAttribute(mutation.attributeName, el.getAttribute(mutation.attributeName))
  3582. }
  3583. }
  3584. observer.disconnect()
  3585. watchlater.style.display = ''
  3586. el.style.display = 'none'
  3587. observer.observe(el, { attributes: true })
  3588. })
  3589. ob.observe(el, { attributes: true })
  3590. })
  3591. api.base.addStyle(`
  3592. #${gm.id} .gm-entrypopup[data-compatible="${gm.config.headerCompatible}"] {
  3593. padding-top: 1em;
  3594. }
  3595. #${gm.id} .gm-entrypopup[data-compatible="${gm.config.headerCompatible}"] .gm-popup-arrow {
  3596. display: none;
  3597. }
  3598. #${gm.id} .gm-entrypopup[data-compatible="${gm.config.headerCompatible}"] .gm-entrypopup-page {
  3599. box-shadow: rgb(0 0 0 / 20%) 0 4px 8px 0;
  3600. border-radius: 8px;
  3601. margin-top: -12px;
  3602. }
  3603. `)
  3604. } else {
  3605. const anchor = await api.wait.$('.user-con.signin, .bili-header__bar .right-entry .v-popover-wrap')
  3606. if (anchor.classList.contains('user-con')) { // 传统顶栏
  3607. gm.runtime.headerType = 'old'
  3608. const collect = anchor.children[4]
  3609. const watchlater = document.createElement('div')
  3610. watchlater.className = 'item'
  3611. watchlater.innerHTML = '<a><span class="name">稍后再看</span></a>'
  3612. collect.before(watchlater)
  3613. processClickEvent(watchlater)
  3614. processPopup(watchlater)
  3615. } else { // 新版顶栏
  3616. gm.runtime.headerType = '2022'
  3617. const collect = anchor.parentElement.children[4]
  3618. const watchlater = document.createElement('li')
  3619. watchlater.className = 'v-popover-wrap'
  3620. watchlater.innerHTML = '<a class="right-entry__outside" style="cursor:pointer"><svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg" class="right-entry-icon"><path d="M3.7 3.7l13.9 6.8-13.9 6.8V3.7z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"></path></svg><span class="right-entry-text">稍后再看</span></a>'
  3621. collect.before(watchlater)
  3622. processClickEvent(watchlater)
  3623. processPopup(watchlater)
  3624. }
  3625. }
  3626.  
  3627. /**
  3628. * 处理清空稍后再看
  3629. * @returns {Promise<boolean>} 是否清空成功
  3630. */
  3631. async function clearWatchlater() {
  3632. let success = false
  3633. const result = await api.message.confirm('是否清空稍后再看?')
  3634. if (result) {
  3635. success = await _self.method.clearWatchlater()
  3636. if (success && api.base.urlMatch(gm.regex.page_watchlaterList)) {
  3637. location.reload()
  3638. } else {
  3639. api.message.info(`清空稍后再看${success ? '成功' : '失败'}`)
  3640. }
  3641. }
  3642. return success
  3643. }
  3644.  
  3645. /**
  3646. * 移除稍后再看已观看视频
  3647. * @returns {Promise<boolean>} 是否移除成功
  3648. */
  3649. async function clearWatchedInWatchlater() {
  3650. let success = false
  3651. const result = await api.message.confirm('是否移除已观看视频?')
  3652. if (result) {
  3653. success = await _self.method.clearWatchedInWatchlater()
  3654. if (success && api.base.urlMatch(gm.regex.page_watchlaterList)) {
  3655. location.reload()
  3656. } else {
  3657. api.message.info(`移除已观看视频${success ? '成功' : '失败'}`)
  3658. }
  3659. }
  3660. return success
  3661. }
  3662.  
  3663. /**
  3664. * 处理鼠标点击事件
  3665. * @param {HTMLElement} watchlater 稍后再看入口元素
  3666. */
  3667. function processClickEvent(watchlater) {
  3668. const config = [gm.config.headerButtonOpL, gm.config.headerButtonOpM, gm.config.headerButtonOpR]
  3669. /**
  3670. * 处理鼠标点击事件
  3671. * @param {1 | 2 | 3} button 左键 | 中键 | 右键
  3672. */
  3673. const process = button => {
  3674. const cfg = config[button]
  3675. switch (cfg) {
  3676. case Enums.headerButtonOp.openListInCurrent:
  3677. case Enums.headerButtonOp.openListInNew:
  3678. case Enums.headerButtonOp.playAllInCurrent:
  3679. case Enums.headerButtonOp.playAllInNew: {
  3680. const action = getHeaderButtonOpConfig(cfg)
  3681. action.href && window.open(action.href, action.target)
  3682. break
  3683. }
  3684. case Enums.headerButtonOp.clearWatchlater: {
  3685. clearWatchlater()
  3686. break
  3687. }
  3688. case Enums.headerButtonOp.clearWatchedInWatchlater: {
  3689. clearWatchedInWatchlater()
  3690. break
  3691. }
  3692. case Enums.headerButtonOp.openUserSetting: {
  3693. script.openUserSetting()
  3694. break
  3695. }
  3696. case Enums.headerButtonOp.openRemoveHistory: {
  3697. script.openRemoveHistory()
  3698. break
  3699. }
  3700. case Enums.headerButtonOp.openBatchAddManager: {
  3701. script.openBatchAddManager()
  3702. break
  3703. }
  3704. case Enums.headerButtonOp.exportWatchlaterList: {
  3705. script.exportWatchlaterList()
  3706. break
  3707. }
  3708. default: {
  3709. break
  3710. }
  3711. }
  3712. }
  3713. watchlater.addEventListener('mousedown', e => {
  3714. if (e.button !== 2) {
  3715. process(e.button)
  3716. e.preventDefault()
  3717. }
  3718. })
  3719. watchlater.addEventListener('contextmenu', e => {
  3720. process(2) // 整合写进 mousedown 中会导致无法阻止右键菜单弹出
  3721. e.preventDefault()
  3722. })
  3723. }
  3724.  
  3725. /**
  3726. * 处理弹出面板
  3727. * @param {HTMLElement} watchlater 稍后再看元素
  3728. */
  3729. function processPopup(watchlater) {
  3730. if (gm.config.headerMenu === Enums.headerMenu.disable) return
  3731. gm.panel.entryPopup.el = document.createElement('div')
  3732. const popup = gm.panel.entryPopup.el
  3733. // 模仿官方顶栏弹出面板的弹出与关闭效果
  3734. popup.fadeInFunction = 'cubic-bezier(0.68, -0.55, 0.27, 1.55)'
  3735. popup.fadeOutFunction = 'cubic-bezier(0.6, -0.3, 0.65, 1)'
  3736. popup.fadeOutNoInteractive = true
  3737. // 此处必须用 over;若用 enter,且网页刚加载完成时鼠标正好在入口上,无法轻移鼠标以触发事件
  3738. watchlater.addEventListener('mouseover', onOverWatchlater)
  3739. watchlater.addEventListener('mouseleave', onLeaveWatchlater)
  3740. popup.addEventListener('mouseenter', onEnterPopup)
  3741. popup.addEventListener('mouseleave', onLeavePopup)
  3742.  
  3743. /**
  3744. * 鼠标是否在顶栏内
  3745. * @param {MouseEvent} e 事件
  3746. */
  3747. function withinHeader(e) {
  3748. const y = e.clientY
  3749. const rect = watchlater.getBoundingClientRect()
  3750. const trim = 2 // e.clientY 在旧标准中为长整型,向内修正以确保正确性(此处理论取 1 即可)
  3751. return y >= rect.top + trim && y <= rect.bottom - trim
  3752. }
  3753.  
  3754. /**
  3755. * 进入稍后再看入口的处理
  3756. */
  3757. function onOverWatchlater() {
  3758. if (watchlater._mouseOver) return
  3759. watchlater._mouseOver = true
  3760. // 预加载数据,延时以在避免误触与加载速度间作平衡
  3761. if (gm.config.watchlaterListCacheValidPeriod > 0) {
  3762. setTimeout(() => {
  3763. if (watchlater._mouseOver) {
  3764. gm.data.watchlaterListData()
  3765. }
  3766. }, 25) // 以鼠标快速掠过不触发为准
  3767. }
  3768. // 完整加载,延时以避免误触
  3769. // 误触率与弹出速度正相关,与数据加载时间无关
  3770. setTimeout(() => {
  3771. if (watchlater._mouseOver) {
  3772. const isHeaderFixed = api.dom.findAncestor(watchlater, el => {
  3773. const { position } = window.getComputedStyle(el)
  3774. return position === 'fixed' || position === 'sticky'
  3775. }, true)
  3776. popup.style.position = isHeaderFixed ? 'fixed' : ''
  3777. const rect = watchlater.getBoundingClientRect()
  3778. popup.style.top = `${rect.bottom}px`
  3779. popup.style.left = `calc(${(rect.left + rect.right) / 2}px - 16em)`
  3780. openEntryPopup()
  3781. }
  3782. }, 125) // 以鼠标中速掠过不触发为准
  3783. }
  3784.  
  3785. /**
  3786. * 离开稍后再看入口的处理
  3787. * @param {MouseEvent} e 事件
  3788. */
  3789. function onLeaveWatchlater(e) {
  3790. watchlater._mouseOver = false
  3791. if (withinHeader(e)) {
  3792. script.closePanelItem('entryPopup')
  3793. } else {
  3794. setTimeout(() => {
  3795. if (!watchlater._mouseOver && !popup._mouseOver) {
  3796. script.closePanelItem('entryPopup')
  3797. }
  3798. }, 150)
  3799. }
  3800. }
  3801.  
  3802. /**
  3803. * 进入弹出面板的处理
  3804. */
  3805. function onEnterPopup() {
  3806. popup._mouseOver = true
  3807. }
  3808.  
  3809. /**
  3810. * 离开弹出面板的处理
  3811. */
  3812. function onLeavePopup() {
  3813. popup._mouseOver = false
  3814. setTimeout(() => {
  3815. if (!popup._mouseOver && !watchlater._mouseOver) {
  3816. script.closePanelItem('entryPopup')
  3817. }
  3818. }, 50)
  3819. }
  3820. }
  3821.  
  3822. /**
  3823. * 打开弹出面板
  3824. */
  3825. function openEntryPopup() {
  3826. if (gm.el.entryPopup) {
  3827. script.openPanelItem('entryPopup')
  3828. } else {
  3829. /** @type {{[n: string]: HTMLElement}} */
  3830. const el = {}
  3831. setTimeout(() => {
  3832. initPopup()
  3833. processPopup()
  3834. script.openPanelItem('entryPopup')
  3835. })
  3836.  
  3837. /**
  3838. * 初始化
  3839. */
  3840. const initPopup = () => {
  3841. const openLinkInCurrent = gm.config.openHeaderMenuLink === Enums.openHeaderMenuLink.openInCurrent
  3842. const target = openLinkInCurrent ? '_self' : '_blank'
  3843. gm.el.entryPopup = gm.el.gmRoot.appendChild(gm.panel.entryPopup.el)
  3844. gm.el.entryPopup.dataset.headerType = gm.runtime.headerType ?? '2022'
  3845. if (gm.config.headerCompatible !== Enums.headerCompatible.none) {
  3846. gm.el.entryPopup.dataset.compatible = gm.config.headerCompatible
  3847. }
  3848. gm.el.entryPopup.className = 'gm-entrypopup'
  3849. gm.el.entryPopup.innerHTML = `
  3850. <div class="gm-popup-arrow"></div>
  3851. <div class="gm-entrypopup-page">
  3852. <div class="gm-popup-header">
  3853. <div class="gm-search">
  3854. <input type="text" placeholder="搜索... 支持关键字排除 ( - ) 及通配符 ( ? * )">
  3855. <div class="gm-search-clear">✖</div>
  3856. </div>
  3857. <div class="gm-popup-total" title="列表条目数">0</div>
  3858. </div>
  3859. <div class="gm-entry-list-empty">稍后再看列表为空</div>
  3860. <div class="gm-entry-list"></div>
  3861. <div class="gm-entry-list gm-entry-removed-list"></div>
  3862. <div class="gm-entry-bottom">
  3863. <a class="gm-entry-button" fn="setting">设置</a>
  3864. <a class="gm-entry-button" fn="history">历史</a>
  3865. <a class="gm-entry-button" fn="export" title="右键点击可进行导出设置">导出</a>
  3866. <a class="gm-entry-button" fn="batchAdd">批量添加</a>
  3867. <a class="gm-entry-button" fn="removeAll">清空</a>
  3868. <a class="gm-entry-button" fn="removeWatched">移除已看</a>
  3869. <a class="gm-entry-button" fn="showAll" href="${gm.url.page_watchlaterList}" target="${target}">显示</a>
  3870. <a class="gm-entry-button" fn="playAll" href="${gm.url.page_watchlaterPlayAll}" target="${target}">播放</a>
  3871. <a class="gm-entry-button" fn="sortControl">
  3872. <div class="gm-select">
  3873. <div class="gm-selected" data-value="">排序</div>
  3874. <div class="gm-options">
  3875. <div class="gm-option" data-value="${Enums.sortType.fixed}">固定</div>
  3876. <div class="gm-option" data-value="${Enums.sortType.title}">标题</div>
  3877. ${gm.config.headerMenu === Enums.headerMenu.enable ? `
  3878. <div class="gm-option" data-value="${Enums.sortType.uploader}">UP主</div>
  3879. <div class="gm-option" data-value="${Enums.sortType.progress}">进度</div>
  3880. ` : ''}
  3881. <div class="gm-option" data-value="${Enums.sortType.pubtimeR}">发布↓</div>
  3882. <div class="gm-option" data-value="${Enums.sortType.pubtime}">发布</div>
  3883. <div class="gm-option" data-value="${Enums.sortType.durationR}">时长↓</div>
  3884. <div class="gm-option" data-value="${Enums.sortType.duration}">时长</div>
  3885. <div class="gm-option" data-value="${Enums.sortType.defaultR}">默认↓</div>
  3886. <div class="gm-option gm-option-selected" data-value="${Enums.sortType.default}">默认</div>
  3887. </div>
  3888. </div>
  3889. </a>
  3890. <a class="gm-entry-button" fn="autoRemoveControl">移除</a>
  3891. </div>
  3892. </div>
  3893. `
  3894. el.entryList = gm.el.entryPopup.querySelector('.gm-entry-list')
  3895. el.entryRemovedList = gm.el.entryPopup.querySelector('.gm-entry-removed-list')
  3896. el.entryListEmpty = gm.el.entryPopup.querySelector('.gm-entry-list-empty')
  3897. el.entryHeader = gm.el.entryPopup.querySelector('.gm-popup-header')
  3898. el.searchBox = gm.el.entryPopup.querySelector('.gm-search')
  3899. el.search = el.searchBox.querySelector('.gm-search input')
  3900. el.searchClear = el.searchBox.querySelector('.gm-search-clear')
  3901. el.popupTotal = gm.el.entryPopup.querySelector('.gm-popup-total')
  3902. el.entryBottom = gm.el.entryPopup.querySelector('.gm-entry-bottom')
  3903. }
  3904.  
  3905. /**
  3906. * 维护内部元素
  3907. */
  3908. const processPopup = () => {
  3909. gm.panel.entryPopup.openHandler = onOpen
  3910. gm.panel.entryPopup.openedHandler = () => {
  3911. if (gm.config.headerMenuSearch) {
  3912. el.search.setSelectionRange(0, el.search.value.length)
  3913. el.search.focus()
  3914. }
  3915. }
  3916.  
  3917. if (gm.config.headerMenuSearch) {
  3918. el.search.addEventListener('input', () => {
  3919. const { search, searchClear } = el
  3920. const m = /^\s+(.*)/.exec(search.value)
  3921. if (m) {
  3922. search.value = m[1]
  3923. search.setSelectionRange(0, 0)
  3924. }
  3925. searchClear.style.visibility = search.value.length > 0 ? 'visible' : ''
  3926. })
  3927. el.search.addEventListener('input', api.base.throttle(() => {
  3928. let val = el.search.value.trim()
  3929. let include = null
  3930. let exclude = null
  3931. const isIncluded = str => str && include?.test(str)
  3932. const isExcluded = str => str && exclude?.test(str)
  3933. const lists = gm.config.headerMenuKeepRemoved ? [el.entryList, el.entryRemovedList] : [el.entryList]
  3934. if (val.length > 0) {
  3935. try {
  3936. val = val.replaceAll(/[$()+.[\\\]^{|}]/g, '\\$&') // escape regex
  3937. .replaceAll('?', '.').replaceAll('*', '.*') // 通配符
  3938. for (const part of val.split(' ')) {
  3939. if (part) {
  3940. if (part.startsWith('-')) {
  3941. if (part.length === 1) continue
  3942. if (exclude) {
  3943. exclude += '|' + part.slice(1)
  3944. } else {
  3945. exclude = part.slice(1)
  3946. }
  3947. } else {
  3948. if (include) {
  3949. include += '|' + part
  3950. } else {
  3951. include = part
  3952. }
  3953. }
  3954. }
  3955. }
  3956. if (!include && exclude) {
  3957. include = '.*'
  3958. }
  3959. include &&= new RegExp(include, 'i')
  3960. exclude &&= new RegExp(exclude, 'i')
  3961. } catch {
  3962. include = exclude = null
  3963. }
  3964. }
  3965. const cnt = [0, 0]
  3966. for (const [i, list] of lists.entries()) {
  3967. if (list.total > 0) {
  3968. for (let j = 0; j < list.childElementCount; j++) {
  3969. let valid = false
  3970. const card = list.children[j]
  3971. if (include || exclude) {
  3972. if ((isIncluded(card.vTitle) || isIncluded(card.uploader)) && !(isExcluded(card.vTitle) || isExcluded(card.uploader))) {
  3973. valid = true
  3974. }
  3975. } else {
  3976. valid = true
  3977. }
  3978. if (valid) {
  3979. cnt[i] += 1
  3980. card.classList.remove('gm-filtered')
  3981. } else {
  3982. card.classList.add('gm-filtered')
  3983. }
  3984. }
  3985. list.scrollTop = 0
  3986. }
  3987. }
  3988. el.popupTotal.textContent = `${cnt[0]}${cnt[1] > 0 ? `/${cnt[0] + cnt[1]}` : ''}`
  3989. el.entryListEmpty.style.display = cnt[0] ? '' : 'unset'
  3990. }, gm.const.inputThrottleWait))
  3991. el.searchClear.addEventListener('click', () => {
  3992. el.search.value = ''
  3993. el.search.dispatchEvent(new Event('input'))
  3994. })
  3995. if (gm.config.searchDefaultValue) {
  3996. el.search.addEventListener('mousedown', e => {
  3997. if (e.button === 1) {
  3998. GM_deleteValue('searchDefaultValue_value')
  3999. api.message.info('已清空搜索框默认值')
  4000. e.preventDefault()
  4001. } else if (e.button === 2) {
  4002. GM_setValue('searchDefaultValue_value', el.search.value)
  4003. api.message.info('已保存搜索框默认值')
  4004. e.preventDefault()
  4005. }
  4006. })
  4007. el.search.addEventListener('contextmenu', e => e.preventDefault())
  4008.  
  4009. const updateSearchTitle = e => {
  4010. let v = e ? e.detail.value : GM_getValue('searchDefaultValue_value')
  4011. if (!v) v = v === '' ? '[ 空 ]' : '[ 未设置 ]'
  4012. el.searchBox.title = gm.const.searchDefaultValueHint.replace('$1', v)
  4013. }
  4014. updateSearchTitle()
  4015. window.addEventListener('updateSearchTitle', updateSearchTitle)
  4016. }
  4017. } else {
  4018. el.entryHeader.style.display = 'none'
  4019. }
  4020.  
  4021. el.entryFn = {}
  4022. for (const button of el.entryBottom.querySelectorAll('.gm-entry-button')) {
  4023. const fn = button.getAttribute('fn')
  4024. if (fn) {
  4025. el.entryFn[fn] = button
  4026. }
  4027. }
  4028.  
  4029. // 排序控制器
  4030. {
  4031. el.entryFn.sortControl.control = el.entryFn.sortControl.firstElementChild
  4032. const { control } = el.entryFn.sortControl
  4033. const selected = control.selected = control.children[0]
  4034. const options = control.options = control.children[1]
  4035.  
  4036. const defaultSelect = options.querySelector('.gm-option-selected') ?? options.firstElementChild
  4037. if (gm.config.autoSort !== Enums.autoSort.default) {
  4038. let type = gm.config.autoSort
  4039. if (type === Enums.autoSort.auto) {
  4040. type = GM_getValue('autoSort_auto')
  4041. if (!type) {
  4042. type = Enums.sortType.default
  4043. GM_setValue('autoSort_auto', type)
  4044. }
  4045. }
  4046. selected.option = options.querySelector(`[data-value="${type}"]`)
  4047. if (selected.option) {
  4048. defaultSelect?.classList.remove('gm-option-selected')
  4049. selected.option.classList.add('gm-option-selected')
  4050. selected.dataset.value = selected.option.dataset.value
  4051. } else if (gm.config.autoSort === Enums.autoSort.auto) {
  4052. type = Enums.sortType.default
  4053. GM_setValue('autoSort_auto', type)
  4054. }
  4055. }
  4056. if (!selected.option) {
  4057. selected.option = defaultSelect
  4058. if (selected.option) {
  4059. selected.option.classList.add('gm-option-selected')
  4060. selected.dataset.value = selected.option.dataset.value
  4061. }
  4062. }
  4063.  
  4064. if (gm.config.headerMenuSortControl) {
  4065. el.entryFn.sortControl.setAttribute('enabled', '')
  4066. options.fadeOutNoInteractive = true
  4067.  
  4068. el.entryFn.sortControl.addEventListener('click', () => {
  4069. if (!control.selecting) {
  4070. control.selecting = true
  4071. api.dom.fade(true, options)
  4072. }
  4073. })
  4074. el.entryFn.sortControl.addEventListener('mouseenter', () => {
  4075. control.selecting = true
  4076. api.dom.fade(true, options)
  4077. })
  4078. el.entryFn.sortControl.addEventListener('mouseleave', () => {
  4079. control.selecting = false
  4080. api.dom.fade(false, options)
  4081. })
  4082. options.addEventListener('click', /** @param {MouseEvent} e */ e => {
  4083. control.selecting = false
  4084. api.dom.fade(false, options)
  4085. const val = e.target.dataset.value
  4086. if (selected.dataset.value !== val) {
  4087. selected.option.classList.remove('gm-option-selected')
  4088. selected.dataset.value = val
  4089. selected.option = e.target
  4090. selected.option.classList.add('gm-option-selected')
  4091. if (gm.config.autoSort === Enums.autoSort.auto) {
  4092. GM_setValue('autoSort_auto', val)
  4093. }
  4094. sort(val)
  4095. }
  4096. })
  4097. }
  4098. }
  4099.  
  4100. // 自动移除控制器
  4101. const cfgAutoRemove = gm.config.autoRemove
  4102. const autoRemove = cfgAutoRemove === Enums.autoRemove.always || cfgAutoRemove === Enums.autoRemove.openFromList
  4103. el.entryFn.autoRemoveControl.autoRemove = autoRemove
  4104. if (gm.config.headerMenuAutoRemoveControl) {
  4105. if (cfgAutoRemove === Enums.autoRemove.absoluteNever) {
  4106. el.entryFn.autoRemoveControl.setAttribute('disabled', '')
  4107. el.entryFn.autoRemoveControl.addEventListener('click', () => {
  4108. api.message.info('当前彻底禁用自动移除功能,无法执行操作')
  4109. })
  4110. } else {
  4111. if (autoRemove) {
  4112. el.entryFn.autoRemoveControl.classList.add('gm-popup-auto-remove')
  4113. }
  4114. el.entryFn.autoRemoveControl.addEventListener('click', () => {
  4115. const target = el.entryFn.autoRemoveControl
  4116. if (target.autoRemove) {
  4117. target.classList.remove('gm-popup-auto-remove')
  4118. api.message.info('已临时关闭自动移除功能')
  4119. } else {
  4120. target.classList.add('gm-popup-auto-remove')
  4121. api.message.info('已临时开启自动移除功能')
  4122. }
  4123. target.autoRemove = !target.autoRemove
  4124. })
  4125. }
  4126. el.entryFn.autoRemoveControl.setAttribute('enabled', '')
  4127. }
  4128. // 常规项
  4129. if (gm.config.headerMenuFnSetting) {
  4130. el.entryFn.setting.setAttribute('enabled', '')
  4131. el.entryFn.setting.addEventListener('click', () => script.openUserSetting())
  4132. }
  4133. if (gm.config.headerMenuFnHistory) {
  4134. el.entryFn.history.setAttribute('enabled', '')
  4135. el.entryFn.history.addEventListener('click', () => script.openRemoveHistory())
  4136. }
  4137. if (gm.config.headerMenuFnExport) {
  4138. el.entryFn.export.setAttribute('enabled', '')
  4139. el.entryFn.export.addEventListener('click', () => script.exportWatchlaterList())
  4140. el.entryFn.export.addEventListener('contextmenu', e => {
  4141. e.preventDefault()
  4142. script.setExportWatchlaterList()
  4143. })
  4144. }
  4145. if (gm.config.headerMenuFnBatchAdd) {
  4146. el.entryFn.batchAdd.setAttribute('enabled', '')
  4147. el.entryFn.batchAdd.addEventListener('click', () => script.openBatchAddManager())
  4148. }
  4149. if (gm.config.headerMenuFnRemoveAll) {
  4150. el.entryFn.removeAll.setAttribute('enabled', '')
  4151. el.entryFn.removeAll.addEventListener('click', () => {
  4152. script.closePanelItem('entryPopup')
  4153. clearWatchlater()
  4154. })
  4155. }
  4156. if (gm.config.headerMenuFnRemoveWatched) {
  4157. el.entryFn.removeWatched.setAttribute('enabled', '')
  4158. el.entryFn.removeWatched.addEventListener('click', () => {
  4159. script.closePanelItem('entryPopup')
  4160. clearWatchedInWatchlater()
  4161. })
  4162. }
  4163. if (gm.config.headerMenuFnShowAll) {
  4164. el.entryFn.showAll.setAttribute('enabled', '')
  4165. }
  4166. if (gm.config.headerMenuFnPlayAll) {
  4167. el.entryFn.playAll.setAttribute('enabled', '')
  4168. }
  4169. if (el.entryBottom.querySelectorAll('[enabled]').length === 0) {
  4170. el.entryBottom.style.display = 'none'
  4171. }
  4172. }
  4173.  
  4174. /**
  4175. * 打开时弹出面板时执行
  4176. */
  4177. const onOpen = async () => {
  4178. // 上半区被移除卡片先于下半区被查询到,恰巧使得后移除稿件最后生成在被移除列表前方,无须额外排序
  4179. const rmCards = gm.config.headerMenuKeepRemoved ? gm.el.entryPopup.querySelectorAll('.gm-removed') : null
  4180. let rmBvid = null
  4181. if (rmCards?.length > 0) {
  4182. rmBvid = new Set()
  4183. for (const rmCard of rmCards) {
  4184. rmBvid.add(rmCard.bvid)
  4185. }
  4186. }
  4187. gm.panel.entryPopup.sortType = Enums.sortType.default
  4188. el.popupTotal.textContent = '0'
  4189. el.entryList.textContent = ''
  4190. el.entryList.total = 0
  4191. el.entryRemovedList.textContent = ''
  4192. el.entryRemovedList.total = 0
  4193. const data = await gm.data.watchlaterListData()
  4194. const simplePopup = gm.config.headerMenu === Enums.headerMenu.enableSimple
  4195. let serial = 0
  4196. if (data.length > 0) {
  4197. const fixedItems = GM_getValue('fixedItems') ?? []
  4198. const openLinkInCurrent = gm.config.openHeaderMenuLink === Enums.openHeaderMenuLink.openInCurrent
  4199. const { autoRemoveControl } = el.entryFn
  4200. for (const item of data) {
  4201. /** @type {HTMLAnchorElement} */
  4202. const card = el.entryList.appendChild(document.createElement('a'))
  4203. card.serial = serial++
  4204. const valid = item.state >= 0
  4205. card.vTitle = item.title
  4206. card.bvid = item.bvid
  4207. card.duration = item.duration
  4208. card.pubtime = item.pubdate
  4209. if ((rmBvid?.size > 0) && rmBvid.has(card.bvid)) {
  4210. rmBvid.delete(card.bvid)
  4211. }
  4212. if (simplePopup) {
  4213. if (valid) {
  4214. card.textContent = card.vTitle
  4215. } else {
  4216. card.innerHTML = `<b>[${_self.method.getItemStateDesc(item.state)}]</b> ${card.vTitle}`
  4217. }
  4218. card.className = 'gm-entry-list-simple-item'
  4219. } else {
  4220. card.uploader = item.owner.name
  4221. const multiP = item.videos > 1
  4222. const duration = _self.method.getSTimeString(item.duration)
  4223. const durationP = multiP ? `${item.videos}P` : duration
  4224. if (item.progress < 0) {
  4225. item.progress = card.duration
  4226. }
  4227. const played = item.progress > 0
  4228. card.progress = (multiP && played) ? card.duration : item.progress
  4229. let progress = ''
  4230. if (played) {
  4231. progress = multiP ? '已观看' : _self.method.getSTimeString(item.progress)
  4232. }
  4233. card.className = `gm-entry-list-item${multiP ? ' gm-card-multiP' : ''}`
  4234. card.innerHTML = `
  4235. <div class="gm-card-left">
  4236. <img class="gm-card-cover" src="${item.pic}@156w_88h_1c_100q.webp">
  4237. <div class="gm-card-switcher"></div>
  4238. <div class="gm-card-duration">
  4239. <div${multiP ? ' class="gm-hover"' : ''}>${duration}</div>
  4240. ${multiP ? `<div>${durationP}</div>` : ''}
  4241. </div>
  4242. </div>
  4243. <div class="gm-card-right">
  4244. <div class="gm-card-title" title="${card.vTitle}">${valid ? card.vTitle : `<b>[${_self.method.getItemStateDesc(item.state)}]</b> ${card.vTitle}`}</div>
  4245. <a class="gm-card-uploader" target="_blank" href="${gm.url.page_userSpace(item.owner.mid)}">${card.uploader}</a>
  4246. <div class="gm-card-corner">
  4247. <span class="gm-card-progress">${progress}</span>
  4248. <span class="gm-card-fixer gm-hover" title="${gm.const.fixerHint}">固定</span>
  4249. <span class="gm-card-collector gm-hover" title="将稿件移动至指定收藏夹">收藏</span>
  4250. </div>
  4251. </div>
  4252. `
  4253. if (played) {
  4254. card.querySelector('.gm-card-progress').style.display = 'unset'
  4255. }
  4256.  
  4257. const switchStatus = async (status, dispInfo = true) => {
  4258. if (status) { // 先改了 UI 再说,不要给用户等待感
  4259. card.classList.remove('gm-removed')
  4260. } else {
  4261. card.classList.add('gm-removed')
  4262. }
  4263. const note = status ? '添加到稍后再看' : '从稍后再看移除'
  4264. const success = await _self.method.switchVideoWatchlaterStatus(item.aid, status)
  4265. if (success) {
  4266. card.added = status
  4267. if (card.fixed) {
  4268. card.fixed = false
  4269. gm.data.fixedItem(card.bvid, false)
  4270. card.classList.remove('gm-fixed')
  4271. }
  4272. dispInfo && api.message.info(`${note}成功`)
  4273. gm.runtime.reloadWatchlaterListData = true
  4274. window.dispatchEvent(new CustomEvent('reloadWatchlaterListData'))
  4275. } else {
  4276. if (card.added) {
  4277. card.classList.remove('gm-removed')
  4278. } else {
  4279. card.classList.add('gm-removed')
  4280. }
  4281. dispInfo && api.message.info(`${note}失败`)
  4282. }
  4283. }
  4284.  
  4285. card.added = true
  4286. card.querySelector('.gm-card-switcher').addEventListener('click', e => {
  4287. e.preventDefault()
  4288. e.stopPropagation() // 兼容第三方的「链接转点击事件」处理
  4289. switchStatus(!card.added)
  4290. })
  4291.  
  4292. card.querySelector('.gm-card-collector').addEventListener('click', e => {
  4293. e.preventDefault() // 不能放到 async 中
  4294. e.stopPropagation() // 兼容第三方的「链接转点击事件」处理
  4295. setTimeout(async () => {
  4296. const uid = _self.method.getDedeUserID()
  4297. let mlid = GM_getValue(`watchlaterMediaList_${uid}`)
  4298. let dmlid = false
  4299. if (!mlid) {
  4300. mlid = await _self.method.getDefaultMediaListId(uid)
  4301. dmlid = true
  4302. }
  4303. const success = await _self.method.addToFav(item.aid, mlid)
  4304. if (success) {
  4305. api.message.info(dmlid ? '移动至默认收藏夹成功' : '移动至指定收藏夹成功')
  4306. if (card.added) {
  4307. switchStatus(false, false)
  4308. }
  4309. } else {
  4310. api.message.info(dmlid ? '移动至默认收藏夹失败' : `移动至收藏夹 ${mlid} 失败,请确认该收藏夹是否存在`)
  4311. }
  4312. })
  4313. })
  4314.  
  4315. const fixer = card.querySelector('.gm-card-fixer')
  4316. fixer.addEventListener('click', e => {
  4317. e.preventDefault()
  4318. e.stopPropagation() // 兼容第三方的「链接转点击事件」处理
  4319. if (card.fixed) {
  4320. card.classList.remove('gm-fixed')
  4321. } else {
  4322. card.classList.add('gm-fixed')
  4323. }
  4324. card.fixed = !card.fixed
  4325. gm.data.fixedItem(card.bvid, card.fixed)
  4326. })
  4327. fixer.addEventListener('contextmenu', e => {
  4328. e.preventDefault()
  4329. script.clearFixedItems()
  4330. })
  4331. }
  4332. const fixedIdx = fixedItems.indexOf(card.bvid)
  4333. if (fixedIdx >= 0) {
  4334. fixedItems.splice(fixedIdx, 1)
  4335. card.fixed = true
  4336. card.classList.add('gm-fixed')
  4337. }
  4338. if (valid) {
  4339. card.target = openLinkInCurrent ? '_self' : '_blank'
  4340. card.href = gm.config.redirect ? `${gm.url.page_videoNormalMode}/${card.bvid}` : `${gm.url.page_listWatchlaterMode}?bvid=${card.bvid}`
  4341. if (gm.config.autoRemove !== Enums.autoRemove.absoluteNever) {
  4342. const excludes = '.gm-card-switcher, .gm-card-uploader, .gm-card-fixer, .gm-card-collector'
  4343. card._href = card.href
  4344. card.addEventListener('mousedown', e => {
  4345. if (e.button === 0 || e.button === 1) { // 左键或中键
  4346. if (card.fixed) return
  4347. if (!simplePopup && e.target.matches(excludes)) return
  4348. if (autoRemoveControl.autoRemove) {
  4349. if (gm.config.autoRemove !== Enums.autoRemove.always) {
  4350. const url = new URL(card.href)
  4351. url.searchParams.set(`${gm.id}_remove`, 'true')
  4352. card.href = url.href
  4353. } else {
  4354. card.href = card._href
  4355. }
  4356. } else {
  4357. if (gm.config.autoRemove === Enums.autoRemove.always) {
  4358. const url = new URL(card.href)
  4359. url.searchParams.set(`${gm.id}_disable_remove`, 'true')
  4360. card.href = url.href
  4361. } else {
  4362. card.href = card._href
  4363. }
  4364. }
  4365. }
  4366. })
  4367. card.addEventListener('mouseup', e => {
  4368. if (e.button === 0 || e.button === 1) { // 左键或中键
  4369. if (card.fixed) return
  4370. if (!simplePopup) {
  4371. if (!card.added) return
  4372. if (e.target.matches(excludes)) return
  4373. }
  4374. if (autoRemoveControl.autoRemove) {
  4375. card.classList.add('gm-removed')
  4376. card.added = false
  4377. gm.runtime.reloadWatchlaterListData = true
  4378. // 移除由播放页控制,此时并为实际发生,不分发重载列表事件
  4379. }
  4380. }
  4381. })
  4382. }
  4383. } else {
  4384. card.classList.add('gm-invalid')
  4385. }
  4386. }
  4387. el.entryList.total = data.length
  4388. el.entryListEmpty.style.display = ''
  4389.  
  4390. // 现在仍在 fixedItems 中的是无效固定项,将它们移除
  4391. // 仅在列表项不为空时才执行移除,因为「列表项为空」有可能是一些特殊情况造成的误判
  4392. for (const item of fixedItems) {
  4393. gm.data.fixedItem(item, false)
  4394. }
  4395. } else {
  4396. el.entryListEmpty.style.display = 'unset'
  4397. }
  4398.  
  4399. // 添加已移除稿件
  4400. if (rmCards?.length > 0) {
  4401. const addedBvid = new Set()
  4402. for (const rmCard of rmCards) {
  4403. rmCard.serial = serial++
  4404. const { bvid } = rmCard
  4405. if (addedBvid.has(bvid)) continue
  4406. if (rmBvid.has(bvid)) {
  4407. if (rmCard.style.display === 'none') {
  4408. rmCard.style.display = ''
  4409. }
  4410. } else {
  4411. rmCard.style.display = 'none'
  4412. }
  4413. el.entryRemovedList.append(rmCard)
  4414. addedBvid.add(bvid)
  4415. }
  4416. }
  4417. if (rmBvid?.size > 0) {
  4418. const only1 = rmBvid.size === 1
  4419. const h = simplePopup ? (only1 ? 6 : 9) : (only1 ? 6.4 : 11)
  4420. el.entryList.style.height = `${42 - h}em`
  4421. el.entryRemovedList.style.height = `${h}em`
  4422. el.entryRemovedList.style.display = 'flex'
  4423. el.entryRemovedList.total = rmBvid.size
  4424. for (const fixedEl of el.entryRemovedList.querySelectorAll('.gm-fixed')) {
  4425. fixedEl.classList.remove('gm-fixed')
  4426. fixedEl.fixed = false
  4427. }
  4428. } else {
  4429. el.entryList.style.height = ''
  4430. el.entryRemovedList.style.display = ''
  4431. }
  4432.  
  4433. el.popupTotal.textContent = `${el.entryList.total}${el.entryRemovedList.total > 0 ? `/${el.entryList.total + el.entryRemovedList.total}` : ''}`
  4434. if (gm.config.removeHistory && gm.config.removeHistorySavePoint === Enums.removeHistorySavePoint.listAndMenu) {
  4435. _self.method.updateRemoveHistoryData()
  4436. }
  4437.  
  4438. gm.el.entryPopup.style.display = 'unset'
  4439. el.entryList.scrollTop = 0
  4440. el.entryRemovedList.scrollTop = 0
  4441.  
  4442. const sortType = el.entryFn.sortControl.control.selected.dataset.value
  4443. sortType && sort(sortType)
  4444.  
  4445. if (gm.config.searchDefaultValue) {
  4446. const sdv = GM_getValue('searchDefaultValue_value')
  4447. if (typeof sdv === 'string') {
  4448. el.search.value = sdv
  4449. }
  4450. }
  4451. if (el.search.value.length > 0) {
  4452. el.search.dispatchEvent(new Event('input'))
  4453. }
  4454. }
  4455.  
  4456. /**
  4457. * 对弹出面板列表中的内容进行排序
  4458. * @param {sortType} type 排序类型
  4459. */
  4460. const sort = type => {
  4461. if (type === gm.panel.entryPopup.sortType) return
  4462. const prevBase = gm.panel.entryPopup.sortType.replace(/:R$/, '')
  4463. gm.panel.entryPopup.sortType = type
  4464. if (type === Enums.sortType.fixed) {
  4465. type = Enums.sortType.default
  4466. el.entryList.setAttribute('sort-type-fixed', '')
  4467. } else {
  4468. el.entryList.removeAttribute('sort-type-fixed')
  4469. }
  4470. const reverse = type.endsWith(':R')
  4471. const k = type.replace(/:R$/, '')
  4472.  
  4473. const lists = []
  4474. if (el.entryList.total > 1) {
  4475. lists.push(el.entryList)
  4476. }
  4477. if (el.entryRemovedList.total > 1) {
  4478. lists.push(el.entryRemovedList)
  4479. }
  4480. for (const list of lists) {
  4481. if (k !== prevBase) {
  4482. const cards = [...list.querySelectorAll('.gm-entry-list-item')]
  4483. cards.sort((a, b) => {
  4484. const va = a[k]
  4485. const vb = b[k]
  4486. return (typeof va === 'string') ? va.localeCompare(vb) : (va - vb)
  4487. })
  4488. for (const [idx, card] of cards.entries()) {
  4489. card.style.order = idx
  4490. }
  4491. }
  4492. if (reverse) {
  4493. list.setAttribute('gm-list-reverse', '')
  4494. list.scrollTop = -list.scrollHeight
  4495.  
  4496. // column-reverse + order + flex-end 无法生成滚动条
  4497. // 只能改用一个定位元素加 margin: auto 来实现 flex-end 效果
  4498. if (!list.querySelector('.gm-list-reverse-end')) {
  4499. const listEnd = document.createElement('div')
  4500. listEnd.className = 'gm-list-reverse-end'
  4501. list.append(listEnd)
  4502. }
  4503. } else {
  4504. list.removeAttribute('gm-list-reverse')
  4505. list.scrollTop = 0
  4506. }
  4507. }
  4508. }
  4509. }
  4510. }
  4511.  
  4512. /**
  4513. * 获取入口点击的链接设置
  4514. * @param {headerButtonOp} op
  4515. * @returns {{href: string, target: '_self' | '_blank'}}
  4516. */
  4517. function getHeaderButtonOpConfig(op) {
  4518. const result = {}
  4519. switch (op) {
  4520. case Enums.headerButtonOp.openListInCurrent:
  4521. case Enums.headerButtonOp.openListInNew: {
  4522. result.href = gm.url.page_watchlaterList
  4523. break
  4524. }
  4525. case Enums.headerButtonOp.playAllInCurrent:
  4526. case Enums.headerButtonOp.playAllInNew: {
  4527. result.href = gm.url.page_watchlaterPlayAll
  4528. break
  4529. }
  4530. default: {
  4531. break
  4532. }
  4533. }
  4534. if (result.href) {
  4535. switch (op) {
  4536. case Enums.headerButtonOp.openListInNew:
  4537. case Enums.headerButtonOp.playAllInNew: {
  4538. result.target = '_blank'
  4539. break
  4540. }
  4541. default: {
  4542. result.target = '_self'
  4543. }
  4544. }
  4545. }
  4546. return result
  4547. }
  4548. }
  4549.  
  4550. /**
  4551. * 填充稍后再看状态
  4552. */
  4553. fillWatchlaterStatus() {
  4554. const _self = this
  4555. /** @type {Map<string, GMObject_data_item0>} */
  4556. let map = null
  4557. const initMap = async () => {
  4558. map = await this.method.getWatchlaterDataMap(item => String(item.aid), 'aid', false, true)
  4559. }
  4560. if (api.base.urlMatch(gm.regex.page_dynamicMenu)) { // 必须在动态页之前匹配
  4561. fillWatchlaterStatus_dynamicMenu() // 旧版动态面板
  4562. } else {
  4563. if (api.base.urlMatch(gm.regex.page_dynamic)) {
  4564. if (location.pathname === '/') { // 仅动态主页
  4565. api.wait.$('.bili-dyn-list').then(async () => {
  4566. api.wait.executeAfterElementLoaded({
  4567. selector: '.bili-dyn-list-tabs__item:not(#gm-batch-manager-btn)',
  4568. base: await api.wait.$('.bili-dyn-list-tabs__list'),
  4569. multiple: true,
  4570. callback: tab => {
  4571. tab.addEventListener('click', refillDynamicWatchlaterStatus)
  4572. },
  4573. })
  4574. fillWatchlaterStatus_dynamic()
  4575. })
  4576. }
  4577. } else if (api.base.urlMatch(gm.regex.page_userSpace)) {
  4578. // 虽然长得跟动态主页一样,但这里用的是老代码,不过估计拖个半年又会改成跟动态主页一样吧……
  4579. // 用户空间中也有动态,但用户未必切换到动态子页,故需长时间等待
  4580. api.wait.waitForElementLoaded({
  4581. selector: '.feed-card',
  4582. timeout: 0,
  4583. }).then(async () => {
  4584. await initMap()
  4585. api.wait.executeAfterElementLoaded({
  4586. selector: '.video-container',
  4587. base: await api.wait.$('.feed-card'),
  4588. multiple: true,
  4589. repeat: true,
  4590. timeout: 0,
  4591. callback: video => {
  4592. const vue = video.__vue__
  4593. if (vue) {
  4594. const aid = String(vue.aid)
  4595. if (map.has(aid)) {
  4596. vue.seeLaterStatus = 1
  4597. }
  4598. }
  4599. },
  4600. })
  4601. })
  4602.  
  4603. if (gm.config.fillWatchlaterStatus === Enums.fillWatchlaterStatus.anypage) {
  4604. fillWatchlaterStatus_main()
  4605. }
  4606. } else {
  4607. // 两部分 URL 刚好不会冲突,放到 else 中即可
  4608. switch (gm.config.fillWatchlaterStatus) {
  4609. case Enums.fillWatchlaterStatus.dynamicAndVideo: {
  4610. if (api.base.urlMatch([gm.regex.page_videoNormalMode, gm.regex.page_videoWatchlaterMode, gm.regex.page_listMode])) {
  4611. fillWatchlaterStatus_main()
  4612. }
  4613. break
  4614. }
  4615. case Enums.fillWatchlaterStatus.anypage: {
  4616. fillWatchlaterStatus_main()
  4617. break
  4618. }
  4619. default: {
  4620. break
  4621. }
  4622. }
  4623. }
  4624. fillWatchlaterStatus_dynamicPopup()
  4625.  
  4626. window.addEventListener('reloadWatchlaterListData', api.base.debounce(refillDynamicWatchlaterStatus, 2000))
  4627. }
  4628.  
  4629. /**
  4630. * 填充动态页稍后再看状态
  4631. */
  4632. async function fillWatchlaterStatus_dynamic() {
  4633. await initMap()
  4634. const feed = await api.wait.$('.bili-dyn-list__items')
  4635. api.wait.executeAfterElementLoaded({
  4636. selector: '.bili-dyn-card-video',
  4637. base: feed,
  4638. multiple: true,
  4639. repeat: true,
  4640. timeout: 0,
  4641. callback: async video => {
  4642. let vue = video.__vue__
  4643. if (vue) {
  4644. // 初始的卡片的 Vue 对象中缺少关键数据、缺少操作稍后再看状态按钮的方法与状态
  4645. // 需要用户将鼠标移至稍后再看按钮,才会对以上数据、状态等进行加载,这里要模拟一下这个操作
  4646. if (!vue.data.aid || !vue.mark) {
  4647. const mark = await api.wait.$('.bili-dyn-card-video__mark', video)
  4648. mark.dispatchEvent(new Event('mouseenter')) // 触发初始化
  4649. await api.wait.waitForConditionPassed({
  4650. condition: () => video.__vue__.data.aid && video.__vue__.mark,
  4651. })
  4652. vue = video.__vue__ // 此时卡片 Vue 对象发生了替换!
  4653. }
  4654. const aid = String(vue.data.aid)
  4655. if (map.has(aid)) {
  4656. vue.mark.done = true
  4657. }
  4658. }
  4659. },
  4660. })
  4661. }
  4662.  
  4663. /**
  4664. * 填充动态面板稍后再看状态
  4665. */
  4666. async function fillWatchlaterStatus_dynamicPopup() {
  4667. await initMap()
  4668. api.wait.executeAfterElementLoaded({
  4669. selector: '.dynamic-video-item',
  4670. multiple: true,
  4671. repeat: true,
  4672. timeout: 0,
  4673. callback: async item => {
  4674. const aid = webpage.method.getAid(item.href)
  4675. if (map.has(aid)) {
  4676. // 官方的实现太复杂,这里改一下显示效果算了
  4677. const svg = await api.wait.$('.watch-later svg', item)
  4678. svg.innerHTML = '<path d="M176.725 56.608c1.507 1.508 2.44 3.591 2.44 5.892s-.932 4.384-2.44 5.892l-92.883 92.892c-2.262 2.264-5.388 3.664-8.842 3.664s-6.579-1.4-8.842-3.664l-51.217-51.225a8.333 8.333 0 1 1 11.781-11.785l48.277 48.277 89.942-89.942c1.508-1.507 3.591-2.44 5.892-2.44s4.384.932 5.892 2.44z" fill="currentColor"></path>'
  4679. }
  4680. },
  4681. })
  4682. }
  4683.  
  4684. /**
  4685. * 填充旧版动态面板稍后再看状态
  4686. */
  4687. async function fillWatchlaterStatus_dynamicMenu() {
  4688. await initMap()
  4689. api.wait.executeAfterElementLoaded({
  4690. selector: '.list-item',
  4691. base: await api.wait.$('.video-list'),
  4692. multiple: true,
  4693. repeat: true,
  4694. timeout: 0,
  4695. callback: video => {
  4696. const vue = video.__vue__
  4697. if (vue) {
  4698. const aid = String(vue.aid)
  4699. if (map.has(aid)) {
  4700. vue.added = true
  4701. }
  4702. }
  4703. },
  4704. })
  4705. }
  4706.  
  4707. /**
  4708. * 填充稍后再看状态(通用逻辑)
  4709. */
  4710. async function fillWatchlaterStatus_main() {
  4711. await initMap()
  4712. api.wait.executeAfterElementLoaded({
  4713. selector: '.watch-later-video, .watch-later-trigger, .watch-later, .w-later',
  4714. base: document.body,
  4715. multiple: true,
  4716. repeat: true,
  4717. timeout: 0,
  4718. callback: video => {
  4719. const vue = video.__vue__
  4720. if (vue) {
  4721. const aid = String(vue.aid)
  4722. if (map.has(aid)) {
  4723. vue.added = true
  4724. }
  4725. }
  4726. },
  4727. })
  4728.  
  4729. if (api.base.urlMatch(gm.regex.page_search)) {
  4730. // 新版搜索页面
  4731. api.wait.executeAfterElementLoaded({
  4732. selector: '.bili-video-card .bili-video-card__wrap > [data-mod="search-card"]',
  4733. base: document.body,
  4734. multiple: true,
  4735. repeat: true,
  4736. timeout: 0,
  4737. callback: async card => {
  4738. const aid = webpage.method.getAid(card.href)
  4739. if (map.has(aid)) {
  4740. const svg = await api.wait.$('.bili-watch-later svg', card)
  4741. svg.innerHTML = '<use xlink:href="#widget-watch-save"></use>'
  4742. }
  4743. },
  4744. })
  4745. } else if (api.base.urlMatch(gm.regex.page_userSpace)) {
  4746. // 用户空间
  4747. api.wait.executeAfterElementLoaded({
  4748. selector: '.section.video [data-aid]',
  4749. base: document.body,
  4750. multiple: true,
  4751. repeat: true,
  4752. timeout: 0,
  4753. callback: async item => {
  4754. const aid = webpage.method.bvTool.bv2av(item.dataset.aid) // data-aid 实际上是 bvid
  4755. if (map.has(aid)) {
  4756. const wl = await api.wait.$('.i-watchlater', item)
  4757. wl.classList.add('has-select')
  4758. }
  4759. },
  4760. })
  4761. }
  4762. }
  4763.  
  4764. /**
  4765. * 重新填充与动态相关的稍后再看状态
  4766. */
  4767. async function refillDynamicWatchlaterStatus() {
  4768. map = await _self.method.getWatchlaterDataMap(item => String(item.aid), 'aid', true)
  4769.  
  4770. // 更新动态主页稍后再看状态
  4771. if (api.base.urlMatch(gm.regex.page_dynamic)) {
  4772. // map 更新期间,ob 偷跑可能会将错误的数据写入,重新遍历并修正之
  4773. const feed = document.querySelector('.bili-dyn-list') // 更新已有项状态,同步找就行了
  4774. if (feed) {
  4775. for (const video of feed.querySelectorAll('.bili-dyn-card-video')) {
  4776. const vue = video.__vue__
  4777. if (vue && vue.data.aid && vue.mark) {
  4778. const aid = String(vue.data.aid)
  4779. vue.mark.done = map.has(aid)
  4780. }
  4781. }
  4782. }
  4783. }
  4784.  
  4785. // 更新顶栏动态面板稍后再看状态
  4786. for (const item of document.querySelectorAll('.dynamic-video-item')) {
  4787. const aid = webpage.method.getAid(item.href)
  4788. const svg = await api.wait.$('.watch-later svg', item)
  4789. svg.innerHTML = map.has(aid) ? '<path d="M176.725 56.608c1.507 1.508 2.44 3.591 2.44 5.892s-.932 4.384-2.44 5.892l-92.883 92.892c-2.262 2.264-5.388 3.664-8.842 3.664s-6.579-1.4-8.842-3.664l-51.217-51.225a8.333 8.333 0 1 1 11.781-11.785l48.277 48.277 89.942-89.942c1.508-1.507 3.591-2.44 5.892-2.44s4.384.932 5.892 2.44z" fill="currentColor"></path>' : '<path d="M17.5 100c0-45.563 36.937-82.5 82.501-82.5 44.504 0 80.778 35.238 82.442 79.334l-7.138-7.137a7.5 7.5 0 0 0-10.607 10.606l20.001 20a7.5 7.5 0 0 0 10.607 0l20.002-20a7.5 7.5 0 0 0-10.607-10.606l-7.245 7.245c-1.616-52.432-44.63-94.441-97.455-94.441-53.848 0-97.501 43.652-97.501 97.5s43.653 97.5 97.501 97.5c32.719 0 61.673-16.123 79.346-40.825a7.5 7.5 0 0 0-12.199-8.728c-14.978 20.934-39.472 34.553-67.147 34.553-45.564 0-82.501-36.937-82.501-82.5zm109.888-12.922c9.215 5.743 9.215 20.101 0 25.843l-29.62 18.46c-9.215 5.742-20.734-1.436-20.734-12.922V81.541c0-11.486 11.519-18.664 20.734-12.921l29.62 18.459z" fill="currentColor"></path>'
  4790. }
  4791. }
  4792. }
  4793.  
  4794. /**
  4795. * 在播放页加入快速切换稍后再看状态的按钮
  4796. */
  4797. async addVideoButton() {
  4798. const _self = this
  4799. let bus = {}
  4800.  
  4801. const app = await api.wait.$('#app')
  4802. const atr = await api.wait.$('#arc_toolbar_report, #playlistToolbar', app)
  4803. const original = await api.wait.$('.van-watchlater', atr)
  4804. api.wait.waitForConditionPassed({
  4805. condition: () => app.__vue__,
  4806. }).then(async () => {
  4807. const btn = document.createElement('label')
  4808. btn.id = `${gm.id}-video-btn`
  4809. const cb = btn.appendChild(document.createElement('input'))
  4810. cb.type = 'checkbox'
  4811. const text = btn.appendChild(document.createElement('span'))
  4812. text.textContent = '稍后再看'
  4813. cb.addEventListener('click', () => processSwitch())
  4814.  
  4815. const version = (atr.classList.contains('video-toolbar-v1') || atr.id === 'playlistToolbar') ? '2022' : 'old'
  4816. btn.dataset.toolbarVersion = version
  4817. if (version === '2022') {
  4818. const right = await api.wait.$('.toolbar-right, .video-toolbar-right', atr)
  4819. right.prepend(btn)
  4820. } else {
  4821. btn.className = 'appeal-text'
  4822. atr.append(btn)
  4823. }
  4824.  
  4825. let aid = this.method.getAid()
  4826. if (!aid) {
  4827. aid = await api.wait.waitForConditionPassed({
  4828. condition: () => this.method.getAid(),
  4829. interval: 1000,
  4830. })
  4831. }
  4832. bus = { btn, cb, aid }
  4833. initButtonStatus()
  4834. original.parentElement.style.display = 'none'
  4835.  
  4836. window.addEventListener('urlchange', async () => {
  4837. const aid = this.method.getAid()
  4838. if (bus.aid === aid) return // 并非切换稿件(如切分P)
  4839. bus.aid = aid
  4840. let reloaded = false
  4841. gm.searchParams = new URL(location.href).searchParams
  4842. const removed = await this.processAutoRemove()
  4843. if (gm.config.removeHistory && gm.config.removeHistorySavePoint === Enums.removeHistorySavePoint.anypage) {
  4844. // 本来没必要强制刷新,但后面查询状态必须要新数据,搭个顺风车
  4845. await this.method.updateRemoveHistoryData(true)
  4846. reloaded = true
  4847. }
  4848. const status = removed ? false : await this.method.getVideoWatchlaterStatusByAid(bus.aid, !reloaded)
  4849. btn.added = status
  4850. cb.checked = status
  4851. })
  4852. })
  4853.  
  4854. /**
  4855. * 初始化按钮的稍后再看状态
  4856. */
  4857. function initButtonStatus() {
  4858. const setStatus = async status => {
  4859. status ??= await _self.method.getVideoWatchlaterStatusByAid(bus.aid, false, true)
  4860. bus.btn.added = status
  4861. bus.cb.checked = status
  4862. }
  4863. if (gm.data.fixedItem(_self.method.getBvid())) {
  4864. setStatus(true)
  4865. } else {
  4866. const alwaysAutoRemove = gm.config.autoRemove === Enums.autoRemove.always
  4867. const spRemove = gm.searchParams.get(`${gm.id}_remove`) === 'true'
  4868. const spDisableRemove = gm.searchParams.get(`${gm.id}_disable_remove`) === 'true'
  4869. if ((!alwaysAutoRemove && !spRemove) || spDisableRemove) {
  4870. setStatus()
  4871. }
  4872. }
  4873. // 如果当前稿件应当被移除,那就不必读取状态了
  4874. // 注意,哪处代码先执行不确定,不过从理论上来说这里应该是会晚执行
  4875. // 当然,自动移除的操作有可能会失败,但两处代码联动太麻烦了,还会涉及到切换其他稿件的问题,综合考虑之下对这种小概率事件不作处理
  4876. }
  4877.  
  4878. /**
  4879. * 处理稿件状态的切换
  4880. */
  4881. async function processSwitch() {
  4882. const { aid, btn, cb } = bus
  4883. const note = btn.added ? '从稍后再看移除' : '添加到稍后再看'
  4884. const success = await _self.method.switchVideoWatchlaterStatus(aid, !btn.added)
  4885. if (success) {
  4886. btn.added = !btn.added
  4887. cb.checked = btn.added
  4888. api.message.info(`${note}成功`)
  4889. } else {
  4890. cb.checked = btn.added
  4891. api.message.info(`${note}失败${!btn.added ? ',可能是因为稍后再看不支持该稿件类型(如互动视频)' : ''}`)
  4892. }
  4893. }
  4894. }
  4895.  
  4896. /**
  4897. * 稍后再看模式重定向至常规模式播放
  4898. */
  4899. async redirect() {
  4900. // stop() 并不能带来有效的重定向速度改善,反而可能会引起已加载脚本执行错误,也许会造成意外的不良影响
  4901. try {
  4902. let id = null
  4903. const vid = this.method.getVid() // 必须从 URL 直接反推 bvid,其他方式都比这个慢
  4904. if (vid) {
  4905. id = (vid.type === 'aid') ? `av${vid.id}` : vid.id
  4906. } else { // URL 中无 vid 时等同于稍后再看中的第一个稿件
  4907. const resp = await api.web.request({
  4908. url: gm.url.api_queryWatchlaterList,
  4909. }, { check: r => r.code === 0 })
  4910. id = resp.data.list[0].bvid
  4911. }
  4912. let { search } = location
  4913. if (search) {
  4914. let removed = false
  4915. const url = new URL(location.href)
  4916. for (const key of gm.searchParams.keys()) {
  4917. if (['aid', 'bvid', 'oid'].includes(key)) {
  4918. url.searchParams.delete(key)
  4919. removed ||= true
  4920. }
  4921. }
  4922. if (removed) {
  4923. search = url.search
  4924. }
  4925. }
  4926. location.replace(`${gm.url.page_videoNormalMode}/${id}/${search}${location.hash}`)
  4927. } catch (e) {
  4928. api.logger.error(e)
  4929. const result = await api.message.confirm('重定向错误,是否临时关闭此功能?')
  4930. if (result) {
  4931. const url = new URL(location.href)
  4932. url.searchParams.set(`${gmId}_disable_redirect`, 'true')
  4933. location.replace(url.href)
  4934. } else {
  4935. location.replace(gm.url.page_watchlaterList)
  4936. }
  4937. }
  4938. }
  4939.  
  4940. /**
  4941. * 初始化列表页面
  4942. */
  4943. async initWatchlaterListPage() {
  4944. const r_con = await api.wait.$('.watch-later-list header .r-con')
  4945. // 移除「播放全部」按钮
  4946. if (gm.config.removeButton_playAll) {
  4947. r_con.children[0].style.display = 'none'
  4948. } else {
  4949. // 页面上本来就存在的「全部播放」按钮不要触发重定向
  4950. const setPlayAll = el => {
  4951. el.href = gm.url.page_watchlaterPlayAll
  4952. el.target = gm.config.openListVideo === Enums.openListVideo.openInCurrent ? '_self' : '_blank'
  4953. }
  4954. const playAll = r_con.children[0]
  4955. if (playAll.classList.contains('s-btn')) {
  4956. // 理论上不会进来
  4957. setPlayAll(playAll)
  4958. } else {
  4959. const ob = new MutationObserver((records, observer) => {
  4960. setPlayAll(records[0].target)
  4961. observer.disconnect()
  4962. })
  4963. ob.observe(playAll, { attributeFilter: ['href'] })
  4964. }
  4965. }
  4966. // 移除「一键清空」按钮
  4967. if (gm.config.removeButton_removeAll) {
  4968. r_con.children[1].style.display = 'none'
  4969. }
  4970. // 移除「移除已观看视频」按钮
  4971. if (gm.config.removeButton_removeWatched) {
  4972. r_con.children[2].style.display = 'none'
  4973. }
  4974. // 加入「批量添加」
  4975. if (gm.config.listBatchAddManagerButton) {
  4976. const batchButton = r_con.appendChild(document.createElement('div'))
  4977. batchButton.textContent = '批量添加'
  4978. batchButton.className = 's-btn'
  4979. batchButton.addEventListener('click', () => script.openBatchAddManager())
  4980. }
  4981. // 加入「移除记录」
  4982. if (gm.config.removeHistory) {
  4983. const removeHistoryButton = r_con.appendChild(document.createElement('div'))
  4984. removeHistoryButton.textContent = '移除记录'
  4985. removeHistoryButton.className = 's-btn'
  4986. removeHistoryButton.addEventListener('click', () => script.openRemoveHistory())
  4987. }
  4988. // 加入「增强设置」
  4989. const plusButton = r_con.appendChild(document.createElement('div'))
  4990. plusButton.textContent = '增强设置'
  4991. plusButton.className = 's-btn'
  4992. plusButton.addEventListener('click', () => script.openUserSetting())
  4993. // 加入「导出列表」
  4994. if (gm.config.listExportWatchlaterListButton) {
  4995. const exportButton = document.createElement('div')
  4996. exportButton.textContent = '导出列表'
  4997. exportButton.className = 's-btn'
  4998. exportButton.title = '导出稍后再看列表。\n右键点击可进行导出设置。'
  4999. r_con.prepend(exportButton)
  5000. exportButton.addEventListener('click', () => script.exportWatchlaterList())
  5001. exportButton.addEventListener('contextmenu', e => {
  5002. e.preventDefault()
  5003. script.setExportWatchlaterList()
  5004. })
  5005. }
  5006. // 加入「刷新列表」
  5007. const reloadButton = document.createElement('div')
  5008. reloadButton.id = 'gm-list-reload'
  5009. reloadButton.textContent = '刷新列表'
  5010. reloadButton.className = 's-btn'
  5011. r_con.prepend(reloadButton)
  5012. reloadButton.addEventListener('click', async () => {
  5013. let search = null
  5014. if (gm.config.listSearch && gm.config.searchDefaultValue) {
  5015. const sdv = GM_getValue('searchDefaultValue_value')
  5016. if (typeof sdv === 'string') {
  5017. search = document.querySelector('#gm-list-search > input')
  5018. search.value = sdv
  5019. }
  5020. }
  5021. const success = await this.reloadWatchlaterListPage()
  5022. if (!success && search) { // 若刷新成功,说明已执行搜索逻辑,否则需手动执行
  5023. search.dispatchEvent(new Event('input'))
  5024. }
  5025. })
  5026.  
  5027. // 增加搜索框
  5028. if (gm.config.listSearch) {
  5029. api.base.addStyle(`
  5030. #gm-list-search.gm-search {
  5031. display: inline-block;
  5032. font-size: 1.6em;
  5033. line-height: 2em;
  5034. margin: 10px 21px 0;
  5035. padding: 0 0.5em;
  5036. border-radius: 3px;
  5037. transition: box-shadow ${gm.const.fadeTime}ms ease-in-out;
  5038. }
  5039. #gm-list-search.gm-search:hover,
  5040. #gm-list-search.gm-search.gm-active {
  5041. box-shadow: var(--${gm.id}-box-shadow);
  5042. }
  5043. #gm-list-search.gm-search input[type=text] {
  5044. border: none;
  5045. width: 18em;
  5046. }
  5047. `)
  5048. const searchContainer = r_con.insertAdjacentElement('afterend', document.createElement('div'))
  5049. searchContainer.className = 'gm-list-search-container'
  5050. searchContainer.innerHTML = `
  5051. <div id="gm-list-search" class="gm-search">
  5052. <input type="text" placeholder="搜索... 支持关键字排除 ( - ) 及通配符 ( ? * )">
  5053. <div class="gm-search-clear">✖</div>
  5054. </div>
  5055. `
  5056. const searchBox = searchContainer.firstElementChild
  5057. const [search, searchClear] = searchBox.children
  5058.  
  5059. search.addEventListener('mouseenter', () => search.focus())
  5060. search.addEventListener('input', () => {
  5061. const m = /^\s+(.*)/.exec(search.value)
  5062. if (m) {
  5063. search.value = m[1]
  5064. search.setSelectionRange(0, 0)
  5065. }
  5066. if (search.value.length > 0) {
  5067. searchBox.classList.add('gm-active')
  5068. searchClear.style.visibility = 'visible'
  5069. } else {
  5070. searchBox.classList.remove('gm-active')
  5071. searchClear.style.visibility = ''
  5072. }
  5073. })
  5074. search.addEventListener('input', api.base.throttle(async () => {
  5075. await this.searchWatchlaterListPage()
  5076. await this.updateWatchlaterListPageTotal()
  5077. this.triggerWatchlaterListPageContentLoad()
  5078. }, gm.const.inputThrottleWait))
  5079. searchClear.addEventListener('click', () => {
  5080. search.value = ''
  5081. search.dispatchEvent(new Event('input'))
  5082. })
  5083. if (gm.config.searchDefaultValue) {
  5084. search.addEventListener('mousedown', e => {
  5085. if (e.button === 1) {
  5086. GM_deleteValue('searchDefaultValue_value')
  5087. api.message.info('已清空搜索框默认值')
  5088. e.preventDefault()
  5089. } else if (e.button === 2) {
  5090. GM_setValue('searchDefaultValue_value', search.value)
  5091. api.message.info('已保存搜索框默认值')
  5092. e.preventDefault()
  5093. }
  5094. })
  5095. search.addEventListener('contextmenu', e => e.preventDefault())
  5096.  
  5097. const sdv = GM_getValue('searchDefaultValue_value')
  5098. if (sdv) {
  5099. search.value = sdv
  5100. searchBox.classList.add('gm-active')
  5101. searchClear.style.visibility = 'visible'
  5102. }
  5103. const updateSearchTitle = e => {
  5104. let v = e ? e.detail.value : sdv
  5105. if (!v) v = v === '' ? '[ 空 ]' : '[ 未设置 ]'
  5106. searchBox.title = gm.const.searchDefaultValueHint.replace('$1', v)
  5107. }
  5108. updateSearchTitle()
  5109. window.addEventListener('updateSearchTitle', updateSearchTitle)
  5110. }
  5111. }
  5112.  
  5113. // 增加排序控制
  5114. {
  5115. const sortControlButton = document.createElement('div')
  5116. const control = sortControlButton.appendChild(document.createElement('select'))
  5117. sortControlButton.className = 'gm-list-sort-control-container'
  5118. control.id = 'gm-list-sort-control'
  5119. control.innerHTML = `
  5120. <option value="${Enums.sortType.default}" selected>排序:默认</option>
  5121. <option value="${Enums.sortType.defaultR}">排序:默认↓</option>
  5122. <option value="${Enums.sortType.duration}">排序:时长</option>
  5123. <option value="${Enums.sortType.durationR}">排序:时长↓</option>
  5124. <option value="${Enums.sortType.pubtime}">排序:发布</option>
  5125. <option value="${Enums.sortType.pubtimeR}">排序:发布↓</option>
  5126. <option value="${Enums.sortType.progress}">排序:进度</option>
  5127. <option value="${Enums.sortType.uploader}">排序:UP主</option>
  5128. <option value="${Enums.sortType.title}">排序:标题</option>
  5129. <option value="${Enums.sortType.fixed}">排序:固定</option>
  5130. `
  5131. control.prevVal = control.value
  5132. r_con.prepend(sortControlButton)
  5133.  
  5134. if (gm.config.autoSort !== Enums.autoSort.default) {
  5135. let type = gm.config.autoSort
  5136. if (type === Enums.autoSort.auto) {
  5137. type = GM_getValue('autoSort_auto')
  5138. if (!type) {
  5139. type = Enums.sortType.default
  5140. GM_setValue('autoSort_auto', type)
  5141. }
  5142. }
  5143. control.value = type
  5144. }
  5145.  
  5146. if (gm.config.listSortControl) {
  5147. /*
  5148. * 在 control 外套一层,借助这层给 control 染色的原因是:
  5149. * 如果不这样做,那么点击 control 弹出的下拉框与 control 之间有几个像素的距离,鼠标从 control 移动到
  5150. * 下拉框的过程中,若鼠标移动速度较慢,会使 control 脱离 hover 状态。
  5151. * 不管是标准还是浏览器的的锅:凭什么鼠标移动到 option 上 select「不一定」是 hover 状态——哪怕设计成
  5152. * 「一定不」都是合理的。
  5153. */
  5154. api.base.addStyle(`
  5155. .gm-list-sort-control-container {
  5156. display: inline-block;
  5157. padding-bottom: 5px;
  5158. }
  5159. .gm-list-sort-control-container:hover select {
  5160. background: #00a1d6;
  5161. color: #fff;
  5162. }
  5163. .gm-list-sort-control-container select {
  5164. appearance: none;
  5165. text-align-last: center;
  5166. line-height: 16.6px;
  5167. }
  5168. .gm-list-sort-control-container option {
  5169. background: var(--${gm.id}-background-color);
  5170. color: var(--${gm.id}-text-color);
  5171. }
  5172. `)
  5173. control.className = 's-btn'
  5174.  
  5175. control.addEventListener('change', () => {
  5176. if (gm.config.autoSort === Enums.autoSort.auto) {
  5177. GM_setValue('autoSort_auto', control.value)
  5178. }
  5179. this.sortWatchlaterListPage()
  5180. })
  5181. } else {
  5182. sortControlButton.style.display = 'none'
  5183. }
  5184. }
  5185.  
  5186. // 增加自动移除控制器
  5187. {
  5188. const autoRemoveControl = document.createElement('div')
  5189. autoRemoveControl.id = 'gm-list-auto-remove-control'
  5190. autoRemoveControl.textContent = '自动移除'
  5191. if (!gm.config.listAutoRemoveControl) {
  5192. autoRemoveControl.style.display = 'none'
  5193. }
  5194. r_con.prepend(autoRemoveControl)
  5195. if (gm.config.autoRemove !== Enums.autoRemove.absoluteNever) {
  5196. api.base.addStyle(`
  5197. #gm-list-auto-remove-control {
  5198. background: #fff;
  5199. color: #00a1d6;
  5200. }
  5201. #gm-list-auto-remove-control[enabled] {
  5202. background: #00a1d6;
  5203. color: #fff;
  5204. }
  5205. `)
  5206. const autoRemove = gm.config.autoRemove === Enums.autoRemove.always || gm.config.autoRemove === Enums.autoRemove.openFromList
  5207. autoRemoveControl.className = 's-btn'
  5208. autoRemoveControl.title = '临时切换在当前页面打开稿件后是否将其自动移除出「稍后再看」。若要默认开启/关闭自动移除功能,请在「用户设置」中配置。'
  5209. autoRemoveControl.autoRemove = autoRemove
  5210. if (autoRemove) {
  5211. autoRemoveControl.setAttribute('enabled', '')
  5212. }
  5213. autoRemoveControl.addEventListener('click', () => {
  5214. if (autoRemoveControl.autoRemove) {
  5215. autoRemoveControl.removeAttribute('enabled')
  5216. api.message.info('已临时关闭自动移除功能')
  5217. } else {
  5218. autoRemoveControl.setAttribute('enabled', '')
  5219. api.message.info('已临时开启自动移除功能')
  5220. }
  5221. autoRemoveControl.autoRemove = !autoRemoveControl.autoRemove
  5222. })
  5223. } else {
  5224. autoRemoveControl.className = 'd-btn'
  5225. autoRemoveControl.style.cursor = 'not-allowed'
  5226. autoRemoveControl.addEventListener('click', () => {
  5227. api.message.info('当前彻底禁用自动移除功能,无法执行操作')
  5228. })
  5229. }
  5230. }
  5231.  
  5232. // 将顶栏固定在页面顶部
  5233. if (gm.config.listStickControl) {
  5234. let p1 = '-0.3em'
  5235. let p2 = '2.8em'
  5236.  
  5237. if (gm.config.headerCompatible === Enums.headerCompatible.bilibiliEvolved) {
  5238. api.base.addStyle(`
  5239. .custom-navbar.transparent::before {
  5240. height: calc(1.3 * var(--navbar-height)) !important;
  5241. }
  5242. `)
  5243. p1 = '-3.5em'
  5244. p2 = '6em'
  5245. } else {
  5246. const header = await api.wait.$('#internationalHeader .mini-header')
  5247. const style = window.getComputedStyle(header)
  5248. const isGm430292Fixed = style.position === 'fixed' && style.backgroundImage.startsWith('linear-gradient')
  5249. if (isGm430292Fixed) { // https://gf.qytechs.cn/zh-CN/scripts/430292
  5250. p1 = '-3.1em'
  5251. p2 = '5.6em'
  5252. }
  5253. }
  5254.  
  5255. api.base.addStyle(`
  5256. .watch-later-list {
  5257. position: relative;
  5258. top: ${p1};
  5259. }
  5260.  
  5261. .watch-later-list > header {
  5262. position: sticky;
  5263. top: 0;
  5264. margin-top: 0;
  5265. padding-top: ${p2};
  5266. background: white;
  5267. z-index: 1;
  5268. }
  5269. `)
  5270. }
  5271. }
  5272.  
  5273. /**
  5274. * 对稍后再看列表页面进行处理
  5275. * @param {boolean} byReload 由页内刷新触发
  5276. * @returns {Promise<0 | 1 | 2>} 处理状态 - [0]初始化失败 | [1]存在处理错误的项目 | [2]成功
  5277. */
  5278. async processWatchlaterListPage(byReload) {
  5279. const _self = this
  5280. const fixedItems = GM_getValue('fixedItems') ?? []
  5281. const sortable = gm.config.autoSort !== Enums.autoSort.default || gm.config.listSortControl
  5282. let autoRemoveControl = null
  5283. if (gm.config.autoRemove !== Enums.autoRemove.absoluteNever) {
  5284. autoRemoveControl = await api.wait.$('#gm-list-auto-remove-control')
  5285. }
  5286. const listContainer = await api.wait.$('.watch-later-list')
  5287. const listBox = await api.wait.$('.list-box', listContainer)
  5288. const items = listBox.querySelectorAll('.av-item')
  5289.  
  5290. // data 的获取必须放在 listBox 的获取后:
  5291. // 如果 listBox 能够被获取到,说明页面能够正常加载,这至少说明 a. 网络没有问题、b. 当前页面没有被浏览器视为二等公民。
  5292. // 因此,此时获取稍后再看列表数据,必然不会因为各种奇葩的原因获取失败。否则,在后台打开很多个页面(其中包含列表页
  5293. // 面),或是刚打开列表页面就将浏览器切到后台,那么当用户回到列表页面时,会发现 data 加载失败而导致报错。如果将 data
  5294. // 获取置于 listBox 获取之后(也就是当前方案),那么当用户回到列表页面时,代码才会运行至此,此时再加载 data 就能得
  5295. // 到正确的数据(说到这里,不禁感叹 UserscriptAPI.wait 这一套方案是真的太好用了!)。
  5296. const data = await gm.data.watchlaterListData(true)
  5297. if (gm.runtime.watchlaterListDataError != null) {
  5298. if (byReload) {
  5299. api.message.alert('加载稍后再看列表数据失败,无法处理稍后再看列表页面。你可以点击「刷新列表」按钮或刷新页面以重试。')
  5300. } else {
  5301. api.message.alert('加载稍后再看列表数据失败,无法处理稍后再看列表页面。你可以刷新页面以重试(点击「刷新列表」按钮无法确保完整的处理)。')
  5302. }
  5303. return 0
  5304. }
  5305.  
  5306. let success = 2
  5307. const vueData = listContainer.__vue__.listData
  5308. for (const [idx, item] of items.entries()) {
  5309. if (item._uninit) {
  5310. delete item._uninit
  5311. } else if (item.serial != null) {
  5312. item.serial = idx
  5313. continue
  5314. }
  5315. // info
  5316. let d = data[idx]
  5317. const vd = vueData[idx]
  5318. const vueBvid = vd.bvid
  5319. // 稿件失效时 a.t href 为空,不妨使用 vueBvid 代替(不一定准确)
  5320. const itemBvid = this.method.getBvid(item.querySelector('a.t').href) ?? vueBvid
  5321. // 若页面正常加载,DOM、VUE、DATA 理应一一对应
  5322. if (itemBvid !== vueBvid || d.bvid !== vueBvid) {
  5323. let error = true
  5324. // DOM、VUE 在绝大多数情况下都是一致的,不必关注
  5325. // 但 DOM / VUE 偶尔会出现相邻两项顺序调换的情况,这会使得与 DATA 不一致
  5326. // 这种情况很诡异,不知道怎么发生的,而且多刷新几遍 DOM / VUE 中的顺序又可能会变成正确的
  5327. // 由于这种情况的发生过于频繁,如果列表较大几乎必出现,不得不将 DATA 遍历一遍以最大程度地修正这一问题
  5328. if (itemBvid === vueBvid) {
  5329. for (const e of data) {
  5330. if (e.bvid === itemBvid) {
  5331. d = e
  5332. error = false
  5333. break
  5334. }
  5335. }
  5336. }
  5337. if (error) {
  5338. item._uninit = true
  5339. // 这里附加一些绝对正确的属性,使得初始化失败的情况下依然能使用一些基本功能
  5340. item.state = itemBvid === vueBvid ? vd.state : 0
  5341. item.serial = idx
  5342. item.aid = this.method.bvTool.bv2av(itemBvid)
  5343. item.bvid = itemBvid
  5344. api.logger.error('DOM-VUE-DATA 不一致', item.bvid, `${vueBvid}(${vd.title?.slice(0, 6) ?? '[NO TITLE]'})`, `${d.bvid}(${d.title?.slice(0, 6) ?? '[NO TITLE]'})`)
  5345. processItem(item)
  5346. success = 1
  5347. continue
  5348. }
  5349. }
  5350. item.state = d.state
  5351. item.serial = idx
  5352. item.aid = String(d.aid)
  5353. item.bvid = d.bvid
  5354. item.vTitle = d.title
  5355. item.uploader = d.owner.name
  5356. item.duration = d.duration
  5357. item.pubtime = d.pubdate
  5358. item.multiP = d.videos > 1
  5359. if (d.progress < 0) {
  5360. d.progress = d.duration
  5361. }
  5362. item.progress = (d.videos > 1 && d.progress > 0) ? d.duration : d.progress
  5363.  
  5364. processItem(item)
  5365. for (const link of item.querySelectorAll('a:not([class=user])')) {
  5366. processLink(item, link, autoRemoveControl)
  5367. }
  5368. }
  5369. await this.searchWatchlaterListPage()
  5370. this.updateWatchlaterListPageTotal()
  5371.  
  5372. if (sortable) {
  5373. const sortControl = await api.wait.$('#gm-list-sort-control')
  5374. if (byReload || sortControl.value !== sortControl.prevVal) {
  5375. this.sortWatchlaterListPage()
  5376. }
  5377. }
  5378.  
  5379. if (!byReload) {
  5380. // 现在仍在 fixedItems 中的是无效固定项,将它们移除
  5381. // 仅在列表项不为空时才执行移除,因为「列表项为空」有可能是一些特殊情况造成的误判
  5382. if (items.length > 0) {
  5383. for (const item of fixedItems) {
  5384. gm.data.fixedItem(item, false)
  5385. }
  5386. }
  5387.  
  5388. this.handleAutoReloadWatchlaterListPage()
  5389. }
  5390. return success
  5391.  
  5392. /**
  5393. * 处理项目
  5394. *
  5395. * 初始化正常项目,给非正常项目添加初始化失败提示。
  5396. * @param {HTMLElement} item 目标项元素
  5397. */
  5398. function processItem(item) {
  5399. const state = item.querySelector('.info .state')
  5400.  
  5401. let tooltip = item.querySelector('.gm-list-item-fail-tooltip')
  5402. if (item._uninit) {
  5403. if (!tooltip) {
  5404. tooltip = document.createElement('span')
  5405. tooltip.className = 'gm-list-item-fail-tooltip'
  5406. tooltip.textContent = '初始化失败'
  5407. tooltip.addEventListener('click', () => webpage.reloadWatchlaterListPage())
  5408. api.message.hoverInfo(tooltip, '稿件初始化失败,部分功能在该稿件上无法正常使用。点击失败提示或「刷新列表」可重新初始化稿件。如果仍然无法解决问题,请重新加载页面。')
  5409. state.append(tooltip)
  5410. }
  5411. } else {
  5412. if (tooltip) {
  5413. tooltip.remove()
  5414. }
  5415. }
  5416.  
  5417. if (state.querySelector('.gm-list-item-tools')) return
  5418.  
  5419. state.insertAdjacentHTML('beforeend', `
  5420. <span class="gm-list-item-tools">
  5421. <span class="gm-list-item-fixer" title="${gm.const.fixerHint}">固定</span>
  5422. <span class="gm-list-item-collector" title="将稿件移动至指定收藏夹">收藏</span>
  5423. <input class="gm-list-item-switcher" type="checkbox" checked>
  5424. </span>
  5425. `)
  5426. const tools = state.querySelector('.gm-list-item-tools')
  5427. const [fixer, collector, switcher] = tools.children
  5428. item.switcher = switcher
  5429.  
  5430. const fixedIdx = fixedItems.indexOf(item.bvid)
  5431. if (fixedIdx >= 0) {
  5432. fixedItems.splice(fixedIdx, 1)
  5433. item.fixed = true
  5434. item.classList.add('gm-fixed')
  5435. }
  5436.  
  5437. item.added = true
  5438. const switchStatus = async (status, dispInfo = true) => {
  5439. if (status) { // 先改了 UI 再说,不要给用户等待感
  5440. item.classList.remove('gm-removed')
  5441. } else {
  5442. item.classList.add('gm-removed')
  5443. }
  5444. const note = status ? '添加到稍后再看' : '从稍后再看移除'
  5445. const success = await _self.method.switchVideoWatchlaterStatus(item.aid, status)
  5446. if (success) {
  5447. item.added = status
  5448. if (item.fixed) {
  5449. item.fixed = false
  5450. gm.data.fixedItem(item.bvid, false)
  5451. item.classList.remove('gm-fixed')
  5452. }
  5453. dispInfo && api.message.info(`${note}成功`)
  5454. setTimeout(() => {
  5455. if (sortable) {
  5456. _self.sortWatchlaterListPage()
  5457. }
  5458. _self.updateWatchlaterListPageTotal()
  5459. }, 100)
  5460. } else {
  5461. if (item.added) {
  5462. item.classList.remove('gm-removed')
  5463. } else {
  5464. item.classList.add('gm-removed')
  5465. }
  5466. dispInfo && api.message.info(`${note}失败`)
  5467. }
  5468. switcher.checked = item.added
  5469. }
  5470.  
  5471. switcher.addEventListener('click', () => {
  5472. switchStatus(!item.added)
  5473. })
  5474.  
  5475. collector.addEventListener('click', async () => {
  5476. const uid = _self.method.getDedeUserID()
  5477. let mlid = GM_getValue(`watchlaterMediaList_${uid}`)
  5478. let dmlid = false
  5479. if (!mlid) {
  5480. mlid = await _self.method.getDefaultMediaListId(uid)
  5481. dmlid = true
  5482. }
  5483. const success = await _self.method.addToFav(item.aid, mlid)
  5484. if (success) {
  5485. api.message.info(dmlid ? '移动至默认收藏夹成功' : '移动至指定收藏夹成功')
  5486. if (item.added) {
  5487. switchStatus(false, false)
  5488. }
  5489. } else {
  5490. api.message.info(dmlid ? '移动至默认收藏夹失败' : `移动至收藏夹 ${mlid} 失败,请确认该收藏夹是否存在`)
  5491. }
  5492. })
  5493.  
  5494. fixer.addEventListener('click', () => {
  5495. if (item.fixed) {
  5496. item.classList.remove('gm-fixed')
  5497. } else {
  5498. item.classList.add('gm-fixed')
  5499. }
  5500. item.fixed = !item.fixed
  5501. gm.data.fixedItem(item.bvid, item.fixed)
  5502. })
  5503. fixer.addEventListener('contextmenu', e => {
  5504. e.preventDefault()
  5505. script.clearFixedItems()
  5506. })
  5507.  
  5508. // 存在 state == 0 稿件却不可用的情况,此时将稿件标识为未知状态
  5509. const title = item.querySelector('.av-about .t')
  5510. const href = title.getAttribute('href')
  5511. if ((href ?? '') === '') {
  5512. item.state = -20221006
  5513. }
  5514. if (item.state < 0) {
  5515. item.classList.add('gm-invalid')
  5516. title.innerHTML = `<b>[${_self.method.getItemStateDesc(item.state)}]</b> ${title.textContent}`
  5517. }
  5518.  
  5519. if (item.progress > 0) {
  5520. let progress = state.querySelector('.looked')
  5521. if (progress) {
  5522. if (item.multiP) return
  5523. } else {
  5524. progress = document.createElement('span')
  5525. progress.className = 'looked'
  5526. state.prepend(progress)
  5527. }
  5528. progress.textContent = item.multiP ? '已观看' : _self.method.getSTimeString(item.progress)
  5529. }
  5530. }
  5531.  
  5532. /**
  5533. * 根据 `autoRemove` 处理链接
  5534. * @param {HTMLElement} base 基元素
  5535. * @param {HTMLAnchorElement} link 链接元素
  5536. * @param {HTMLElement} [arc] 自动移除按钮,为 `null` 时表示彻底禁用自动移除功能
  5537. */
  5538. function processLink(base, link, arc) {
  5539. // 过滤稿件被和谐或其他特殊情况
  5540. if (base.state >= 0) {
  5541. link.target = gm.config.openListVideo === Enums.openListVideo.openInCurrent ? '_self' : '_blank'
  5542. if (gm.config.redirect) {
  5543. link.href = `${gm.url.page_videoNormalMode}/${base.bvid}`
  5544. }
  5545. if (arc) {
  5546. link.addEventListener('mousedown', e => {
  5547. if (e.button === 0 || e.button === 1) { // 左键或中键
  5548. if (base.fixed) return
  5549. // 若点击前已选择了内容,清空之;必须在这样做以后,下次在 mouseup 获取到不为空
  5550. // 的 selection 时,才能说明此次 mousedown 到下次 mouseup 之间选择了内容
  5551. const selection = window.getSelection()
  5552. if (selection.toString() !== '') {
  5553. selection.removeAllRanges()
  5554. }
  5555. if (!link._href) {
  5556. link._href = link.href
  5557. }
  5558. if (arc.autoRemove) {
  5559. if (gm.config.autoRemove !== Enums.autoRemove.always) {
  5560. const url = new URL(link.href)
  5561. url.searchParams.set(`${gm.id}_remove`, 'true')
  5562. link.href = url.href
  5563. } else {
  5564. link.href = link._href
  5565. }
  5566. } else {
  5567. if (gm.config.autoRemove === Enums.autoRemove.always) {
  5568. const url = new URL(link.href)
  5569. url.searchParams.set(`${gm.id}_disable_remove`, 'true')
  5570. link.href = url.href
  5571. } else {
  5572. link.href = link._href
  5573. }
  5574. }
  5575. }
  5576. })
  5577. link.addEventListener('mouseup', e => {
  5578. if (e.button === 0 || e.button === 1) { // 左键或中键
  5579. if (base.fixed) return
  5580. if (window.getSelection().toString() !== '') return // 选中文字并释放也会触发 mouseup
  5581. if (arc.autoRemove) {
  5582. // 添加移除样式并移动至列表末尾
  5583. base.classList.add('gm-removed')
  5584. base.added = false
  5585. base.switcher.checked = false
  5586. setTimeout(() => {
  5587. if (sortable) {
  5588. _self.sortWatchlaterListPage()
  5589. }
  5590. _self.updateWatchlaterListPageTotal()
  5591. }, 100)
  5592. }
  5593. }
  5594. })
  5595. }
  5596. } else {
  5597. link.removeAttribute('href')
  5598. }
  5599. }
  5600. }
  5601.  
  5602. /**
  5603. * 对稍后再看列表进行搜索
  5604. */
  5605. async searchWatchlaterListPage() {
  5606. const search = await api.wait.$('#gm-list-search input')
  5607. let val = search.value.trim()
  5608. let include = null
  5609. let exclude = null
  5610. const isIncluded = str => str && include?.test(str)
  5611. const isExcluded = str => str && exclude?.test(str)
  5612. if (val.length > 0) {
  5613. try {
  5614. val = val.replaceAll(/[$()+.[\\\]^{|}]/g, '\\$&') // escape regex
  5615. .replaceAll('?', '.').replaceAll('*', '.*') // 通配符
  5616. for (const part of val.split(' ')) {
  5617. if (part) {
  5618. if (part.startsWith('-')) {
  5619. if (part.length === 1) continue
  5620. if (exclude) {
  5621. exclude += '|' + part.slice(1)
  5622. } else {
  5623. exclude = part.slice(1)
  5624. }
  5625. } else {
  5626. if (include) {
  5627. include += '|' + part
  5628. } else {
  5629. include = part
  5630. }
  5631. }
  5632. }
  5633. }
  5634. if (!include && exclude) {
  5635. include = '.*'
  5636. }
  5637. include &&= new RegExp(include, 'i')
  5638. exclude &&= new RegExp(exclude, 'i')
  5639. } catch {
  5640. include = exclude = null
  5641. }
  5642. }
  5643.  
  5644. const listBox = await api.wait.$('.watch-later-list .list-box')
  5645. for (const item of listBox.querySelectorAll('.av-item')) {
  5646. let valid = false
  5647. if (include || exclude) {
  5648. if ((isIncluded(item.vTitle) || isIncluded(item.uploader)) && !(isExcluded(item.vTitle) || isExcluded(item.uploader))) {
  5649. valid = true
  5650. }
  5651. } else {
  5652. valid = true
  5653. }
  5654. if (valid) {
  5655. item.classList.remove('gm-filtered')
  5656. } else {
  5657. item.classList.add('gm-filtered')
  5658. }
  5659. }
  5660. }
  5661.  
  5662. /**
  5663. * 对稍后再看列表页面进行排序
  5664. */
  5665. async sortWatchlaterListPage() {
  5666. const sortControl = await api.wait.$('#gm-list-sort-control')
  5667. const listBox = await api.wait.$('.watch-later-list .list-box')
  5668. let type = sortControl.value
  5669. sortControl.prevVal = type
  5670. if (type === Enums.sortType.fixed) {
  5671. type = Enums.sortType.default
  5672. listBox.firstElementChild.setAttribute('sort-type-fixed', '')
  5673. } else {
  5674. listBox.firstElementChild.removeAttribute('sort-type-fixed')
  5675. }
  5676. const reverse = type.endsWith(':R')
  5677. const k = type.replace(/:R$/, '')
  5678.  
  5679. const lists = [
  5680. [...listBox.querySelectorAll('.av-item:not(.gm-removed)')],
  5681. [...listBox.querySelectorAll('.av-item.gm-removed')],
  5682. ]
  5683. let order = -1000
  5684. for (const items of lists) {
  5685. order += 1000
  5686. items.sort((a, b) => {
  5687. let result = 0
  5688. const va = a[k]
  5689. const vb = b[k]
  5690.  
  5691. // 无数据时排在最后(出现在未初始化的 item 上)
  5692. if (va == null) {
  5693. return 1
  5694. } else if (vb == null) {
  5695. return -1
  5696. }
  5697.  
  5698. result = (typeof va === 'string') ? va.localeCompare(vb) : (va - vb)
  5699. return reverse ? -result : result
  5700. })
  5701. for (const item of items) {
  5702. item.style.order = order++
  5703. }
  5704. }
  5705. this.triggerWatchlaterListPageContentLoad()
  5706. }
  5707.  
  5708. /**
  5709. * 刷新稍后再看列表页面
  5710. * @param {[string, string]} msg [执行成功信息, 执行失败信息],设置为 null 或对应项为空时静默执行
  5711. * @returns {Promise<boolean>} 刷新是否成功
  5712. */
  5713. async reloadWatchlaterListPage(msg = ['刷新成功', '刷新失败']) {
  5714. const list = await api.wait.$('.watch-later-list')
  5715. const vue = await api.wait.waitForConditionPassed({
  5716. condition: () => list.__vue__,
  5717. })
  5718. vue.state = 'loading' // 内部刷新过程中 state 依然保留原来的 loaded / error,很呆,手动改一下
  5719. vue.getListData() // 更新内部 listData,其数据会同步到 DOM 上
  5720. await api.wait.waitForConditionPassed({
  5721. condition: () => vue.state !== 'loading',
  5722. stopOnTimeout: false,
  5723. })
  5724. let success = vue.state === 'loaded'
  5725. if (success) {
  5726. // 刷新成功后,所有不存在的 item 都会被移除,没有被移除就说明该 item 又被重新加回稍后再看中
  5727. for (const item of list.querySelectorAll('.av-item.gm-removed')) {
  5728. item.added = true
  5729. item.classList.remove('gm-removed')
  5730. item.querySelector('.gm-list-item-switcher').checked = true
  5731. }
  5732. // 虽然 state === 'loaded',但事实上 DOM 未调整完毕,需要等待一小段时间
  5733. await new Promise(resolve => setTimeout(resolve, 400))
  5734. const status = await this.processWatchlaterListPage(true)
  5735. success = status === 2
  5736. if (status >= 1) {
  5737. if (gm.config.removeHistory) {
  5738. this.method.updateRemoveHistoryData()
  5739. }
  5740. this.handleAutoReloadWatchlaterListPage()
  5741. }
  5742. }
  5743. msg &&= success ? msg[0] : msg[1]
  5744. msg && api.message.info(msg)
  5745. return success
  5746. }
  5747.  
  5748. /**
  5749. * 处理稍后再看列表页面自动刷新
  5750. */
  5751. async handleAutoReloadWatchlaterListPage() {
  5752. if (gm.config.autoReloadList > 0) {
  5753. if (gm.runtime.autoReloadListTid != null) {
  5754. clearTimeout(gm.runtime.autoReloadListTid)
  5755. }
  5756. const interval = gm.config.autoReloadList * 60 * 1000
  5757. const autoReload = () => {
  5758. gm.runtime.autoReloadListTid = null
  5759. this.reloadWatchlaterListPage(null)
  5760. }
  5761. gm.runtime.autoReloadListTid = setTimeout(autoReload, interval)
  5762.  
  5763. const reloadBtn = await api.wait.$('#gm-list-reload')
  5764. reloadBtn.title = `刷新时间:${new Date().toLocaleString()}\n下次自动刷新时间:${new Date(Date.now() + interval).toLocaleString()}`
  5765. }
  5766. }
  5767.  
  5768. /**
  5769. * 触发列表页面内容加载
  5770. */
  5771. triggerWatchlaterListPageContentLoad() {
  5772. window.dispatchEvent(new Event('scroll'))
  5773. }
  5774.  
  5775. /**
  5776. * 更新列表页面上方的稿件总数统计
  5777. */
  5778. async updateWatchlaterListPageTotal() {
  5779. const container = await api.wait.$('.watch-later-list')
  5780. const listBox = await api.wait.$('.list-box', container)
  5781. const elTotal = await api.wait.$('header .t em')
  5782. const all = listBox.querySelectorAll('.av-item:not(.gm-filtered)').length
  5783. const total = all - listBox.querySelectorAll('.gm-removed:not(.gm-filtered)').length
  5784. elTotal.textContent = `(${total}/${all})`
  5785.  
  5786. const empty = container.querySelector('.abnormal-item')
  5787. if (all > 0) {
  5788. if (empty) {
  5789. empty.style.display = 'none'
  5790. }
  5791. } else {
  5792. if (empty) {
  5793. empty.style.display = ''
  5794. } else {
  5795. container.insertAdjacentHTML('beforeend', '<div class="abnormal-item"><img src="//s1.hdslb.com/bfs/static/jinkela/watchlater/asserts/emptylist.png" class="pic"><div class="txt"><p>稍后再看列表还是空的哦,你可以通过以上方式添加~</p></div></div>')
  5796. }
  5797. }
  5798. }
  5799.  
  5800. /**
  5801. * 根据 URL 上的查询参数作进一步处理
  5802. */
  5803. async processSearchParams() {
  5804. if (api.base.urlMatch([gm.regex.page_videoNormalMode, gm.regex.page_videoWatchlaterMode, gm.regex.page_listMode])) {
  5805. await this.processAutoRemove()
  5806. }
  5807. }
  5808.  
  5809. /**
  5810. * 根据用户配置或 URL 上的查询参数,将稿件从稍后再看移除
  5811. * @returns {Promise<boolean>} 执行后稿件是否已经不在稍后再看中(可能是在本方法内被移除,也可能是本身就不在)
  5812. */
  5813. async processAutoRemove() {
  5814. try {
  5815. const alwaysAutoRemove = gm.config.autoRemove === Enums.autoRemove.always
  5816. const spRemove = gm.searchParams.get(`${gm.id}_remove`) === 'true'
  5817. const spDisableRemove = gm.searchParams.get(`${gm.id}_disable_remove`) === 'true'
  5818. if ((alwaysAutoRemove || spRemove) && !spDisableRemove) {
  5819. if (gm.data.fixedItem(this.method.getBvid())) return
  5820. const aid = this.method.getAid()
  5821. // 稍后再看播放页中,必须等右侧稍后再看列表初始化完成再移除,否则会影响其初始化。
  5822. // 列表播放页(稍后再看)并不需要进行这一操作,因为该页面可以是给收藏夹列表的,猜测
  5823. // 官方在设计时就考虑到播放过程中稿件被移除出列表的问题。
  5824. if (api.base.urlMatch(gm.regex.page_videoWatchlaterMode)) {
  5825. await api.wait.$('.player-auxiliary-wraplist-playlist')
  5826. await new Promise(resolve => setTimeout(resolve, 5000))
  5827. }
  5828. const success = await this.method.switchVideoWatchlaterStatus(aid, false)
  5829. if (!success) {
  5830. api.message.info('从稍后再看移除失败')
  5831. }
  5832. return success
  5833. }
  5834. } catch (e) {
  5835. api.logger.error(e)
  5836. }
  5837. return false
  5838. }
  5839.  
  5840. /**
  5841. * 根据 `removeHistorySavePoint` 保存稍后再看历史数据
  5842. */
  5843. processWatchlaterListDataSaving() {
  5844. switch (gm.config.removeHistorySavePoint) {
  5845. case Enums.removeHistorySavePoint.list: {
  5846. if (api.base.urlMatch(gm.regex.page_watchlaterList)) {
  5847. this.method.updateRemoveHistoryData()
  5848. }
  5849. break
  5850. }
  5851. case Enums.removeHistorySavePoint.anypage: {
  5852. if (!api.base.urlMatch(gm.regex.page_dynamicMenu)) {
  5853. this.method.updateRemoveHistoryData()
  5854. }
  5855. break
  5856. }
  5857. case Enums.removeHistorySavePoint.listAndMenu:
  5858. default: {
  5859. if (api.base.urlMatch(gm.regex.page_watchlaterList)) {
  5860. this.method.updateRemoveHistoryData()
  5861. }
  5862. break
  5863. }
  5864. }
  5865. }
  5866.  
  5867. /**
  5868. * 添加批量添加管理器按钮
  5869. */
  5870. addBatchAddManagerButton() {
  5871. if (location.pathname === '/') { // 仅动态主页
  5872. api.wait.$('.bili-dyn-list-tabs__list').then(bar => {
  5873. const btn = bar.firstElementChild.cloneNode(true)
  5874. btn.id = 'gm-batch-manager-btn'
  5875. btn.classList.remove('active')
  5876. btn.textContent = '批量添加'
  5877. btn.addEventListener('click', () => script.openBatchAddManager())
  5878. bar.append(btn)
  5879. })
  5880. }
  5881. }
  5882.  
  5883. /**
  5884. * 添加弹出面板的滚动条样式
  5885. */
  5886. addMenuScrollbarStyle() {
  5887. const popup = `#${gm.id} .gm-entrypopup .gm-entry-list`
  5888. const oldTooltip = '[role=tooltip]' // 旧版顶栏弹出面板
  5889. const oldDynamic = '#app > .out-container > .container' // 旧版动态弹出面板
  5890. switch (gm.config.menuScrollbarSetting) {
  5891. case Enums.menuScrollbarSetting.beautify: {
  5892. // 目前在不借助 JavaScript 的情况下,无法完美实现类似于移动端滚动条浮动在内容上的效果
  5893. api.base.addStyle(`
  5894. :root {
  5895. --${gm.id}-scrollbar-background-color: transparent;
  5896. --${gm.id}-scrollbar-thumb-color: #0000002b;
  5897. }
  5898.  
  5899. ${popup}::-webkit-scrollbar,
  5900. ${oldTooltip} ::-webkit-scrollbar,
  5901. ${oldDynamic}::-webkit-scrollbar {
  5902. width: 4px;
  5903. height: 5px;
  5904. background-color: var(--${gm.id}-scrollbar-background-color);
  5905. }
  5906.  
  5907. ${popup}::-webkit-scrollbar-thumb,
  5908. ${oldTooltip} ::-webkit-scrollbar-thumb,
  5909. ${oldDynamic}::-webkit-scrollbar-thumb {
  5910. border-radius: 4px;
  5911. background-color: var(--${gm.id}-scrollbar-background-color);
  5912. }
  5913.  
  5914. ${popup}:hover::-webkit-scrollbar-thumb,
  5915. ${oldTooltip} :hover::-webkit-scrollbar-thumb,
  5916. ${oldDynamic}:hover::-webkit-scrollbar-thumb {
  5917. border-radius: 4px;
  5918. background-color: var(--${gm.id}-scrollbar-thumb-color);
  5919. }
  5920.  
  5921. ${popup}::-webkit-scrollbar-corner,
  5922. ${oldTooltip} ::-webkit-scrollbar-corner,
  5923. ${oldDynamic}::-webkit-scrollbar-corner {
  5924. background-color: var(--${gm.id}-scrollbar-background-color);
  5925. }
  5926.  
  5927. /* 优化官方顶栏弹出面板中的滚动条样式 */
  5928. .dynamic-panel-popover .header-tabs-panel__content::-webkit-scrollbar,
  5929. .history-panel-popover .header-tabs-panel__content::-webkit-scrollbar,
  5930. .favorite-panel-popover__content .content-scroll::-webkit-scrollbar,
  5931. .favorite-panel-popover__nav::-webkit-scrollbar {
  5932. height: 5px !important;
  5933. }
  5934. `)
  5935. break
  5936. }
  5937. case Enums.menuScrollbarSetting.hidden: {
  5938. api.base.addStyle(`
  5939. ${popup}::-webkit-scrollbar,
  5940. ${oldTooltip} ::-webkit-scrollbar,
  5941. ${oldDynamic}::-webkit-scrollbar {
  5942. display: none;
  5943. }
  5944.  
  5945. /* 隐藏官方顶栏弹出面板中的滚动条 */
  5946. .dynamic-panel-popover .header-tabs-panel__content::-webkit-scrollbar,
  5947. .history-panel-popover .header-tabs-panel__content::-webkit-scrollbar,
  5948. .favorite-panel-popover__content .content-scroll::-webkit-scrollbar,
  5949. .favorite-panel-popover__nav::-webkit-scrollbar {
  5950. display: none !important;
  5951. }
  5952. `)
  5953. break
  5954. }
  5955. default: {
  5956. break
  5957. }
  5958. }
  5959. }
  5960.  
  5961. /**
  5962. * 添加脚本样式
  5963. */
  5964. addStyle() {
  5965. if (self === top) {
  5966. this.addMenuScrollbarStyle()
  5967. // 通用样式
  5968. api.base.addStyle(`
  5969. :root {
  5970. --${gm.id}-text-color: #0d0d0d;
  5971. --${gm.id}-text-bold-color: #3a3a3a;
  5972. --${gm.id}-light-text-color: white;
  5973. --${gm.id}-hint-text-color: gray;
  5974. --${gm.id}-light-hint-text-color: #909090;
  5975. --${gm.id}-hint-text-emphasis-color: #666666;
  5976. --${gm.id}-hint-text-highlight-color: #555555;
  5977. --${gm.id}-background-color: white;
  5978. --${gm.id}-background-highlight-color: #ebebeb;
  5979. --${gm.id}-update-highlight-color: ${gm.const.updateHighlightColor};
  5980. --${gm.id}-update-highlight-hover-color: red;
  5981. --${gm.id}-border-color: black;
  5982. --${gm.id}-light-border-color: #e7e7e7;
  5983. --${gm.id}-shadow-color: #000000bf;
  5984. --${gm.id}-text-shadow-color: #00000080;
  5985. --${gm.id}-highlight-color: #0075ff;
  5986. --${gm.id}-important-color: red;
  5987. --${gm.id}-warn-color: #e37100;
  5988. --${gm.id}-disabled-color: gray;
  5989. --${gm.id}-scrollbar-background-color: transparent;
  5990. --${gm.id}-scrollbar-thumb-color: #0000002b;
  5991. --${gm.id}-box-shadow: #00000033 0px 3px 6px;
  5992. --${gm.id}-opacity-fade-transition: opacity ${gm.const.fadeTime}ms ease-in-out;
  5993. --${gm.id}-opacity-fade-quick-transition: opacity ${gm.const.fadeTime}ms cubic-bezier(0.68, -0.55, 0.27, 1.55);
  5994. }
  5995.  
  5996. #${gm.id} {
  5997. color: var(--${gm.id}-text-color);
  5998. }
  5999. #${gm.id} * {
  6000. box-sizing: content-box;
  6001. }
  6002.  
  6003. #${gm.id} .gm-entrypopup {
  6004. font-size: 12px;
  6005. line-height: normal;
  6006. transition: var(--${gm.id}-opacity-fade-transition);
  6007. opacity: 0;
  6008. display: none;
  6009. position: absolute;
  6010. z-index: 900000;
  6011. user-select: none;
  6012. width: 32em;
  6013. padding-top: 1.3em;
  6014. }
  6015. #${gm.id} .gm-entrypopup[data-header-type=old] {
  6016. padding-top: 1em;
  6017. }
  6018. #${gm.id} .gm-entrypopup[data-header-type=old] .gm-popup-arrow {
  6019. position: absolute;
  6020. top: calc(1em - 6px);
  6021. left: calc(16em - 6px);
  6022. width: 0;
  6023. height: 0;
  6024. border-width: 6px;
  6025. border-top-width: 0;
  6026. border-style: solid;
  6027. border-color: transparent;
  6028. border-bottom-color: #dfdfdf; /* 必须在 border-color 后 */
  6029. z-index: 1;
  6030. }
  6031. #${gm.id} .gm-entrypopup[data-header-type=old] .gm-popup-arrow::after {
  6032. content: " ";
  6033. position: absolute;
  6034. top: 1px;
  6035. width: 0;
  6036. height: 0;
  6037. margin-left: -6px;
  6038. border-width: 6px;
  6039. border-top-width: 0;
  6040. border-style: solid;
  6041. border-color: transparent;
  6042. border-bottom-color: var(--${gm.id}-background-color); /* 必须在 border-color 后 */
  6043. }
  6044. #${gm.id} .gm-entrypopup .gm-entrypopup-page {
  6045. position: relative;
  6046. border-radius: 4px;
  6047. border: none;
  6048. box-shadow: var(--${gm.id}-box-shadow);
  6049. background-color: var(--${gm.id}-background-color);
  6050. overflow: hidden;
  6051. }
  6052. #${gm.id} .gm-entrypopup .gm-popup-header {
  6053. position: relative;
  6054. height: 2.8em;
  6055. border-bottom: 1px solid var(--${gm.id}-light-border-color);
  6056. }
  6057. #${gm.id} .gm-entrypopup .gm-popup-total {
  6058. position: absolute;
  6059. line-height: 2.6em;
  6060. right: 1.3em;
  6061. top: 0;
  6062. font-size: 1.2em;
  6063. color: var(--${gm.id}-hint-text-color);
  6064. }
  6065.  
  6066. #${gm.id} .gm-entrypopup .gm-entry-list {
  6067. display: flex;
  6068. flex-direction: column;
  6069. position: relative;
  6070. height: 42em;
  6071. overflow-y: auto;
  6072. overflow-anchor: none;
  6073. padding: 0.2em 0;
  6074. }
  6075. #${gm.id} .gm-entrypopup .gm-entry-list.gm-entry-removed-list {
  6076. border-top: 3px solid var(--${gm.id}-light-border-color);
  6077. display: none;
  6078. }
  6079. #${gm.id} .gm-entrypopup .gm-entry-list-empty {
  6080. position: absolute;
  6081. display: none;
  6082. top: 20%;
  6083. left: calc(50% - 7em);
  6084. line-height: 4em;
  6085. width: 14em;
  6086. font-size: 1.4em;
  6087. text-align: center;
  6088. color: var(--${gm.id}-hint-text-color);
  6089. }
  6090.  
  6091. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item {
  6092. display: flex;
  6093. height: 4.4em;
  6094. padding: 0.5em 1em;
  6095. color: var(--${gm.id}-text-color);
  6096. font-size: 1.15em;
  6097. cursor: pointer;
  6098. }
  6099. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item.gm-invalid {
  6100. cursor: not-allowed;
  6101. }
  6102. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item.gm-invalid,
  6103. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item.gm-removed {
  6104. filter: grayscale(1);
  6105. color: var(--${gm.id}-hint-text-color);
  6106. }
  6107. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-left {
  6108. position: relative;
  6109. flex: none;
  6110. cursor: default;
  6111. }
  6112. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-cover {
  6113. width: 7.82em; /* 16:9 */
  6114. height: 4.40em;
  6115. border-radius: 2px;
  6116. }
  6117. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-switcher {
  6118. position: absolute;
  6119. background: center / contain no-repeat #00000099 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56 56'%3E%3Cpath fill='%23fff' fill-rule='evenodd' d='M35 17v-3H21v3h-8v3h5v22h20V20h5v-3h-8zm-9 22h-5V20h5v19zm9 0h-5V20h5v19z' clip-rule='evenodd'/%3E%3C/svg%3E");
  6120. border-radius: 2px;
  6121. width: 30px;
  6122. height: 30px;
  6123. top: calc(2.20em - 15px); /* 与缩略图显示尺寸匹配 */
  6124. left: calc(3.91em - 15px);
  6125. z-index: 2;
  6126. display: none;
  6127. cursor: pointer;
  6128. }
  6129. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item.gm-removed .gm-card-switcher {
  6130. background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56 56'%3E%3Cpath d='M39.9 25.6h-9.5v-9.5c0-.9-.5-1.5-1.2-1.5h-2.4c-.7 0-1.2.6-1.2 1.5v9.5h-9.5c-.9 0-1.5.5-1.5 1.2v2.4c0 .7.6 1.2 1.5 1.2h9.5v9.5c0 .9.5 1.5 1.2 1.5h2.4c.7 0 1.2-.6 1.2-1.5v-9.5h9.5c.9 0 1.5-.5 1.5-1.2v-2.4c.1-.7-.6-1.2-1.5-1.2z' fill='%23fff'/%3E%3C/svg%3E");
  6131. }
  6132. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item:hover .gm-card-switcher {
  6133. display: unset;
  6134. }
  6135. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item:not(.gm-card-multiP) .gm-card-duration,
  6136. #${gm.id} .gm-entrypopup .gm-entry-list .gm-card-multiP .gm-card-duration > * {
  6137. position: absolute;
  6138. bottom: 0;
  6139. right: 0;
  6140. background: var(--${gm.id}-text-shadow-color);
  6141. color: var(--${gm.id}-light-text-color);
  6142. border-radius: 2px 0 2px 0; /* 需与缩略图圆角匹配 */
  6143. padding: 1.5px 2px 0 3px;
  6144. font-size: 0.8em;
  6145. z-index: 1;
  6146. word-break: keep-all;
  6147. }
  6148. #${gm.id} .gm-entrypopup .gm-entry-list .gm-card-multiP .gm-card-duration > * {
  6149. transition: var(--${gm.id}-opacity-fade-quick-transition);
  6150. }
  6151. #${gm.id} .gm-entrypopup .gm-entry-list .gm-card-multiP:not(:hover) .gm-card-duration > .gm-hover,
  6152. #${gm.id} .gm-entrypopup .gm-entry-list .gm-card-multiP:hover .gm-card-duration > :not(.gm-hover) {
  6153. opacity: 0;
  6154. }
  6155. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-right {
  6156. position: relative;
  6157. display: flex;
  6158. flex-direction: column;
  6159. justify-content: space-between;
  6160. flex: auto;
  6161. margin-left: 0.8em;
  6162. }
  6163. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-title {
  6164. display: -webkit-box;
  6165. -webkit-line-clamp: 2;
  6166. -webkit-box-orient: vertical;
  6167. overflow: hidden;
  6168. text-overflow: ellipsis;
  6169. word-break: break-all;
  6170. text-align: justify;
  6171. height: 2.8em;
  6172. line-height: 1.4em;
  6173. }
  6174. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item.gm-removed .gm-card-title {
  6175. text-decoration: line-through;
  6176. }
  6177. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-uploader {
  6178. font-size: 0.8em;
  6179. text-overflow: ellipsis;
  6180. word-break: keep-all;
  6181. overflow: hidden;
  6182. width: fit-content;
  6183. max-width: 15em;
  6184. color: var(--${gm.id}-hint-text-color);
  6185. cursor: pointer;
  6186. }
  6187. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-corner {
  6188. position: absolute;
  6189. bottom: 0;
  6190. right: 0;
  6191. font-size: 0.8em;
  6192. color: var(--${gm.id}-hint-text-color);
  6193. }
  6194. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-corner > span {
  6195. margin-left: 2px;
  6196. cursor: pointer;
  6197. }
  6198. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item:hover .gm-card-corner > :not(.gm-hover),
  6199. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item:not(:hover) .gm-card-corner > .gm-hover {
  6200. display: none !important;
  6201. }
  6202. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-progress {
  6203. display: none;
  6204. }
  6205. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-progress::before {
  6206. content: "▶";
  6207. padding-right: 1px;
  6208. }
  6209. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item.gm-removed .gm-card-fixer {
  6210. display: none;
  6211. }
  6212. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-uploader:hover,
  6213. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item .gm-card-corner > span:hover {
  6214. text-decoration: underline;
  6215. font-weight: bold;
  6216. color: var(--${gm.id}-text-bold-color);
  6217. }
  6218.  
  6219. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-simple-item {
  6220. display: block;
  6221. color: var(--${gm.id}-text-color);
  6222. font-size: 1.2em;
  6223. padding: 0.5em 1em;
  6224. cursor: pointer;
  6225. }
  6226. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-simple-item:not(:last-child) {
  6227. border-bottom: 1px solid var(--${gm.id}-light-border-color);
  6228. }
  6229. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-simple-item.gm-invalid,
  6230. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-simple-item.gm-invalid:hover {
  6231. cursor: not-allowed;
  6232. color: var(--${gm.id}-hint-text-color);
  6233. }
  6234. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-simple-item.gm-removed {
  6235. text-decoration: line-through;
  6236. color: var(--${gm.id}-hint-text-color);
  6237. }
  6238.  
  6239. #${gm.id} .gm-entrypopup .gm-entry-bottom {
  6240. display: flex;
  6241. border-top: 1px solid var(--${gm.id}-light-border-color);
  6242. height: 3em;
  6243. }
  6244. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button {
  6245. flex: 1 0 auto;
  6246. text-align: center;
  6247. padding: 0.6em 0;
  6248. font-size: 1.2em;
  6249. cursor: pointer;
  6250. color: var(--${gm.id}-text-color);
  6251. }
  6252. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button:not([enabled]) {
  6253. display: none;
  6254. }
  6255.  
  6256. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button .gm-select {
  6257. position: relative;
  6258. }
  6259. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button .gm-options {
  6260. position: absolute;
  6261. bottom: 1.8em;
  6262. left: calc(50% - 2.5em);
  6263. width: 5em;
  6264. border-radius: 4px;
  6265. box-shadow: var(--${gm.id}-box-shadow);
  6266. background-color: var(--${gm.id}-background-color);
  6267. color: var(--${gm.id}-text-color);
  6268. padding: 0.15em 0;
  6269. display: none;
  6270. opacity: 0;
  6271. transition: var(--${gm.id}-opacity-fade-quick-transition);
  6272. z-index: 10;
  6273. }
  6274. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button .gm-option {
  6275. padding: 0.15em 0.6em;
  6276. }
  6277. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button .gm-option:hover {
  6278. color: var(--${gm.id}-highlight-color);
  6279. background-color: var(--${gm.id}-background-highlight-color);
  6280. }
  6281. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button .gm-option.gm-option-selected {
  6282. font-weight: bold;
  6283. }
  6284.  
  6285. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button[fn=autoRemoveControl],
  6286. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button[fn=autoRemoveControl]:hover {
  6287. color: var(--${gm.id}-text-color);
  6288. }
  6289. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button.gm-popup-auto-remove[fn=autoRemoveControl] {
  6290. color: var(--${gm.id}-highlight-color);
  6291. }
  6292.  
  6293. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-item:hover,
  6294. #${gm.id} .gm-entrypopup .gm-entry-list .gm-entry-list-simple-item:hover,
  6295. #${gm.id} .gm-entrypopup .gm-entry-bottom .gm-entry-button:hover {
  6296. color: var(--${gm.id}-highlight-color);
  6297. background-color: var(--${gm.id}-background-highlight-color);
  6298. }
  6299.  
  6300. #${gm.id} .gm-modal-container {
  6301. display: none;
  6302. position: fixed;
  6303. justify-content: center;
  6304. align-items: center;
  6305. top: 0;
  6306. left: 0;
  6307. width: 100%;
  6308. height: 100%;
  6309. z-index: 1000000;
  6310. font-size: 12px;
  6311. line-height: normal;
  6312. user-select: none;
  6313. opacity: 0;
  6314. transition: var(--${gm.id}-opacity-fade-transition);
  6315. }
  6316.  
  6317. #${gm.id} .gm-modal {
  6318. position: relative;
  6319. background-color: var(--${gm.id}-background-color);
  6320. border-radius: 10px;
  6321. z-index: 1;
  6322. }
  6323.  
  6324. #${gm.id} .gm-setting .gm-setting-page {
  6325. min-width: 54em;
  6326. max-width: 84em;
  6327. padding: 1em 1.4em;
  6328. }
  6329.  
  6330. #${gm.id} .gm-setting .gm-maintitle {
  6331. cursor: pointer;
  6332. color: var(--${gm.id}-text-color);
  6333. }
  6334. #${gm.id} .gm-setting .gm-maintitle:hover {
  6335. color: var(--${gm.id}-highlight-color);
  6336. }
  6337.  
  6338. #${gm.id} .gm-setting .gm-items {
  6339. position: relative;
  6340. display: flex;
  6341. flex-direction: column;
  6342. gap: 0.2em;
  6343. margin: 0 0.2em;
  6344. padding: 0 1.8em 0 2.2em;
  6345. font-size: 1.2em;
  6346. max-height: 66vh;
  6347. overflow-y: auto;
  6348. }
  6349. #${gm.id} .gm-setting .gm-item-container {
  6350. display: flex;
  6351. align-items: baseline;
  6352. gap: 1em;
  6353. }
  6354. #${gm.id} .gm-setting .gm-item-label {
  6355. flex: none;
  6356. font-weight: bold;
  6357. color: var(--${gm.id}-text-bold-color);
  6358. width: 4em;
  6359. margin-top: 0.2em;
  6360. }
  6361. #${gm.id} .gm-setting .gm-item-content {
  6362. display: flex;
  6363. flex-direction: column;
  6364. flex: auto;
  6365. }
  6366. #${gm.id} .gm-setting .gm-item {
  6367. padding: 0.2em;
  6368. border-radius: 2px;
  6369. }
  6370. #${gm.id} .gm-setting .gm-item > * {
  6371. display: flex;
  6372. align-items: center;
  6373. }
  6374. #${gm.id} .gm-setting .gm-item:not(.gm-holder-item):hover,
  6375. #${gm.id} .gm-setting .gm-lineitem:not(.gm-holder-item):hover {
  6376. color: var(--${gm.id}-highlight-color);
  6377. }
  6378. #${gm.id} .gm-setting .gm-lineitems {
  6379. display: inline-flex;
  6380. flex-flow: wrap;
  6381. gap: 0.3em;
  6382. width: 24em;
  6383. color: var(--${gm.id}-text-color);
  6384. }
  6385. #${gm.id} .gm-setting .gm-lineitem {
  6386. display: inline-flex;
  6387. align-items: center;
  6388. gap: 0.1em;
  6389. padding: 0 0.2em;
  6390. border-radius: 2px;
  6391. }
  6392. #${gm.id} .gm-setting .gm-lineitem > * {
  6393. flex: none;
  6394. }
  6395.  
  6396. #${gm.id} .gm-setting input[type=checkbox] {
  6397. margin-top: 0.2em;
  6398. margin-left: auto;
  6399. }
  6400. #${gm.id} .gm-setting input[type=text] {
  6401. border-width: 0 0 1px 0;
  6402. width: 20em;
  6403. }
  6404. #${gm.id} .gm-setting input[is=laster2800-input-number] {
  6405. border-width: 0 0 1px 0;
  6406. width: 3.4em;
  6407. text-align: right;
  6408. padding: 0 0.2em;
  6409. margin-left: auto;
  6410. }
  6411. #${gm.id} .gm-setting select {
  6412. border-width: 0 0 1px 0;
  6413. cursor: pointer;
  6414. }
  6415.  
  6416. #${gm.id} .gm-setting .gm-information {
  6417. margin: 0 0.4em;
  6418. cursor: pointer;
  6419. }
  6420. #${gm.id} .gm-setting [disabled] .gm-information {
  6421. pointer-events: none;
  6422. }
  6423. #${gm.id} .gm-setting .gm-warning {
  6424. position: absolute;
  6425. color: var(--${gm.id}-warn-color);
  6426. font-size: 1.4em;
  6427. line-height: 0.8em;
  6428. transition: var(--${gm.id}-opacity-fade-transition);
  6429. opacity: 0;
  6430. display: none;
  6431. cursor: pointer;
  6432. }
  6433. #${gm.id} .gm-setting .gm-warning.gm-trailing {
  6434. position: static;
  6435. margin-left: 0.5em;
  6436. }
  6437. #${gm.id} .gm-setting .gm-warning:not(.gm-trailing) {
  6438. right: 0.3em;
  6439. }
  6440. #${gm.id} .gm-setting [disabled] .gm-warning {
  6441. visibility: hidden;
  6442. }
  6443.  
  6444. #${gm.id} .gm-hideDisabledSubitems .gm-setting-page:not([data-type]) .gm-item[disabled] {
  6445. display: none;
  6446. }
  6447.  
  6448. #${gm.id} .gm-history .gm-history-page {
  6449. width: 60vw;
  6450. min-width: 40em;
  6451. max-width: 80em;
  6452. }
  6453.  
  6454. #${gm.id} .gm-history .gm-comment {
  6455. margin: 0 2em;
  6456. color: var(--${gm.id}-hint-text-color);
  6457. text-indent: 2em;
  6458. }
  6459. #${gm.id} .gm-history .gm-comment span,
  6460. #${gm.id} .gm-history .gm-comment input {
  6461. padding: 0 0.2em;
  6462. font-weight: bold;
  6463. color: var(--${gm.id}-hint-text-emphasis-color);
  6464. }
  6465. #${gm.id} .gm-history .gm-comment input {
  6466. text-align: center;
  6467. width: 3.5em;
  6468. border-width: 0 0 1px 0;
  6469. }
  6470.  
  6471. #${gm.id} .gm-history .gm-content {
  6472. margin: 0.6em 0.2em 2em 0.2em;
  6473. padding: 0 1.8em;
  6474. font-size: 1.2em;
  6475. line-height: 1.6em;
  6476. text-align: center;
  6477. overflow-y: auto;
  6478. overflow-wrap: break-word;
  6479. height: 60vh;
  6480. max-height: 60em;
  6481. user-select: text;
  6482. opacity: 0;
  6483. }
  6484. #${gm.id} .gm-history .gm-content > * {
  6485. position: relative;
  6486. margin: 1.6em 2em;
  6487. }
  6488. #${gm.id} .gm-history .gm-content a {
  6489. color: var(--${gm.id}-text-color);
  6490. }
  6491. #${gm.id} .gm-history .gm-content input[type=checkbox] {
  6492. position: absolute;
  6493. right: -2em;
  6494. height: 1.5em;
  6495. width: 1em;
  6496. cursor: pointer;
  6497. }
  6498. #${gm.id} .gm-history .gm-content .gm-history-date {
  6499. font-size: 0.5em;
  6500. color: var(--${gm.id}-hint-text-color);
  6501. }
  6502. #${gm.id} .gm-history .gm-content > *:hover input[type=checkbox] {
  6503. filter: brightness(0.9);
  6504. }
  6505. #${gm.id} .gm-history .gm-content > *:hover a {
  6506. font-weight: bold;
  6507. color: var(--${gm.id}-highlight-color);
  6508. }
  6509. #${gm.id} .gm-history .gm-content .gm-empty {
  6510. display: flex;
  6511. justify-content: center;
  6512. font-size: 1.5em;
  6513. line-height: 1.6em;
  6514. margin-top: 3.6em;
  6515. color: gray;
  6516. }
  6517. #${gm.id} .gm-history .gm-content .gm-empty > * {
  6518. width: fit-content;
  6519. text-align: left;
  6520. }
  6521.  
  6522. #${gm.id} .gm-bottom {
  6523. margin: 1.4em 2em 1em 2em;
  6524. text-align: center;
  6525. }
  6526.  
  6527. #${gm.id} .gm-bottom button {
  6528. font-size: 1em;
  6529. padding: 0.3em 1em;
  6530. margin: 0 0.8em;
  6531. cursor: pointer;
  6532. background-color: var(--${gm.id}-background-color);
  6533. border: 1px solid var(--${gm.id}-border-color);
  6534. border-radius: 2px;
  6535. }
  6536. #${gm.id} .gm-bottom button:hover {
  6537. background-color: var(--${gm.id}-background-highlight-color);
  6538. }
  6539. #${gm.id} .gm-bottom button[disabled] {
  6540. border-color: var(--${gm.id}-disabled-color);
  6541. background-color: var(--${gm.id}-background-color);
  6542. }
  6543.  
  6544. #${gm.id} .gm-info {
  6545. font-size: 0.8em;
  6546. color: var(--${gm.id}-hint-text-color);
  6547. text-decoration: underline;
  6548. padding: 0 0.2em;
  6549. cursor: pointer;
  6550. }
  6551. #${gm.id} .gm-info:hover {
  6552. color: var(--${gm.id}-important-color);
  6553. }
  6554.  
  6555. #${gm.id} .gm-reset {
  6556. position: absolute;
  6557. right: 0;
  6558. bottom: 0;
  6559. margin: 1em 1.6em;
  6560. color: var(--${gm.id}-hint-text-color);
  6561. cursor: pointer;
  6562. }
  6563.  
  6564. #${gm.id} .gm-changelog {
  6565. position: absolute;
  6566. right: 0;
  6567. bottom: 1.8em;
  6568. margin: 1em 1.6em;
  6569. color: var(--${gm.id}-hint-text-color);
  6570. cursor: pointer;
  6571. }
  6572. #${gm.id} [data-type=updated] .gm-changelog {
  6573. font-weight: bold;
  6574. color: var(--${gm.id}-update-highlight-hover-color);
  6575. }
  6576. #${gm.id} [data-type=updated] .gm-changelog:hover {
  6577. color: var(--${gm.id}-update-highlight-hover-color);
  6578. }
  6579. #${gm.id} [data-type=updated] .gm-updated,
  6580. #${gm.id} [data-type=updated] .gm-updated input,
  6581. #${gm.id} [data-type=updated] .gm-updated select {
  6582. background-color: var(--${gm.id}-update-highlight-color);
  6583. }
  6584. #${gm.id} [data-type=updated] .gm-updated option {
  6585. background-color: var(--${gm.id}-background-color);
  6586. }
  6587. #${gm.id} [data-type=updated] .gm-updated:hover {
  6588. color: var(--${gm.id}-update-highlight-hover-color);
  6589. font-weight: bold;
  6590. }
  6591.  
  6592. #${gm.id} .gm-reset:hover,
  6593. #${gm.id} .gm-changelog:hover {
  6594. color: var(--${gm.id}-hint-text-highlight-color);
  6595. text-decoration: underline;
  6596. }
  6597.  
  6598. #${gm.id} .gm-title {
  6599. font-size: 1.6em;
  6600. margin: 1.6em 0.8em 0.8em 0.8em;
  6601. text-align: center;
  6602. }
  6603.  
  6604. #${gm.id} .gm-subtitle {
  6605. font-size: 0.4em;
  6606. margin-top: 0.4em;
  6607. }
  6608.  
  6609. #${gm.id} .gm-batchAddManager .gm-batchAddManager-page {
  6610. width: 70em;
  6611. height: 60em;
  6612. }
  6613. #${gm.id} .gm-batchAddManager .gm-comment {
  6614. margin: 1.4em 2.5em 0.5em;
  6615. font-size: 1.2em;
  6616. line-height: 1.8em;
  6617. }
  6618. #${gm.id} .gm-batchAddManager .gm-comment button {
  6619. margin-left: 1em;
  6620. padding: 0.1em 0.3em;
  6621. border-radius: 2px;
  6622. cursor: pointer;
  6623. }
  6624. #${gm.id} .gm-batchAddManager .gm-comment button:not([disabled]):hover {
  6625. background-color: var(--${gm.id}-background-highlight-color);
  6626. }
  6627. #${gm.id} .gm-batchAddManager .gm-comment input {
  6628. width: 3em;
  6629. padding: 0 0.2em;
  6630. border-width: 0 0 1px 0;
  6631. text-align: center;
  6632. }
  6633. #${gm.id} .gm-batchAddManager .gm-comment input,
  6634. #${gm.id} .gm-batchAddManager .gm-comment button {
  6635. line-height: normal;
  6636. }
  6637. #${gm.id} .gm-batchAddManager .gm-items {
  6638. width: calc(100% - 2.5em * 2);
  6639. height: 25.5em;
  6640. padding: 0.4em 0;
  6641. margin: 0 2.5em;
  6642. font-size: 1.1em;
  6643. border: 1px solid var(--${gm.id}-scrollbar-thumb-color);
  6644. border-radius: 4px;
  6645. overflow-y: scroll;
  6646. }
  6647. #${gm.id} .gm-batchAddManager .gm-items .gm-item {
  6648. display: block;
  6649. padding: 0.2em 1em;
  6650. }
  6651. #${gm.id} .gm-batchAddManager .gm-items .gm-item:hover {
  6652. background-color: var(--${gm.id}-background-highlight-color);
  6653. }
  6654. #${gm.id} .gm-batchAddManager .gm-items .gm-item input {
  6655. vertical-align: -0.15em;
  6656. }
  6657. #${gm.id} .gm-batchAddManager .gm-items .gm-item a {
  6658. margin-left: 0.5em;
  6659. color: var(--${gm.id}-hint-text-color);
  6660. }
  6661. #${gm.id} .gm-batchAddManager .gm-items .gm-item:hover a {
  6662. color: var(--${gm.id}-highlight-color);
  6663. }
  6664. #${gm.id} .gm-batchAddManager .gm-items .gm-item a:hover {
  6665. font-weight: bold;
  6666. }
  6667. #${gm.id} .gm-batchAddManager .gm-bottom button {
  6668. margin: 0 0.4em;
  6669. padding: 0.3em 0.7em;
  6670. }
  6671.  
  6672. #${gm.id} .gm-shadow {
  6673. background-color: var(--${gm.id}-shadow-color);
  6674. position: fixed;
  6675. top: 0%;
  6676. left: 0%;
  6677. width: 100%;
  6678. height: 100%;
  6679. }
  6680. #${gm.id} .gm-shadow[disabled] {
  6681. cursor: unset !important;
  6682. }
  6683.  
  6684. #${gm.id} label {
  6685. cursor: pointer;
  6686. }
  6687.  
  6688. #${gm.id} input,
  6689. #${gm.id} select,
  6690. #${gm.id} button {
  6691. font-size: 100%;
  6692. appearance: auto;
  6693. outline: none;
  6694. border: 1px solid var(--${gm.id}-border-color);
  6695. border-radius: 0;
  6696. color: var(--${gm.id}-text-color);
  6697. background-color: var(--${gm.id}-background-color);
  6698. }
  6699. #${gm.id} button input[type=file] {
  6700. display: none;
  6701. }
  6702.  
  6703. #${gm.id} [disabled],
  6704. #${gm.id} [disabled] * {
  6705. cursor: not-allowed !important;
  6706. color: var(--${gm.id}-disabled-color) !important;
  6707. }
  6708.  
  6709. #${gm.id}-video-btn {
  6710. display: flex;
  6711. align-items: center;
  6712. user-select: none;
  6713. cursor: pointer;
  6714. }
  6715. #${gm.id}-video-btn input[type=checkbox] {
  6716. margin-right: 2px;
  6717. cursor: pointer;
  6718. }
  6719. #${gm.id}-video-btn[data-toolbar-version="2022"] {
  6720. margin-right: 18px;
  6721. }
  6722. #${gm.id}-video-btn[data-toolbar-version="2022"]:hover {
  6723. color: var(--brand_blue); /* 官方提供的 CSS 变量 */
  6724. }
  6725.  
  6726. #${gm.id} .gm-items::-webkit-scrollbar,
  6727. #${gm.id} .gm-history .gm-content::-webkit-scrollbar {
  6728. width: 6px;
  6729. height: 6px;
  6730. background-color: var(--${gm.id}-scrollbar-background-color);
  6731. }
  6732. #${gm.id} .gm-history .gm-content::-webkit-scrollbar-thumb {
  6733. border-radius: 3px;
  6734. background-color: var(--${gm.id}-scrollbar-background-color);
  6735. }
  6736. #${gm.id} .gm-items::-webkit-scrollbar-thumb,
  6737. #${gm.id} .gm-history .gm-content:hover::-webkit-scrollbar-thumb {
  6738. border-radius: 3px;
  6739. background-color: var(--${gm.id}-scrollbar-thumb-color);
  6740. }
  6741. #${gm.id} gm-items::-webkit-scrollbar-corner,
  6742. #${gm.id} .gm-history .gm-content::-webkit-scrollbar-corner {
  6743. background-color: var(--${gm.id}-scrollbar-background-color);
  6744. }
  6745.  
  6746. #${gm.id} .gm-entrypopup .gm-search {
  6747. font-size: 1.3em;
  6748. line-height: 2.6em;
  6749. padding-left: 0.9em;
  6750. }
  6751. #${gm.id} .gm-entrypopup .gm-search input[type=text] {
  6752. border: none;
  6753. width: 18em;
  6754. }
  6755.  
  6756. .${gm.id}-dialog code {
  6757. font-family: Consolas, Courier New, monospace;
  6758. }
  6759. .${gm.id}-dialog .gm-import-wl-container {
  6760. font-size: 0.8em;
  6761. }
  6762. .${gm.id}-dialog .gm-import-wl-container .gm-group-container {
  6763. margin: 0.5em 0;
  6764. }
  6765. .${gm.id}-dialog .gm-import-wl-container .gm-interactive {
  6766. margin-top: 0;
  6767. border-width: 0 0 1px 0;
  6768. font-family: Consolas, Courier New, monospace;
  6769. }
  6770. .${gm.id}-dialog .gm-import-wl-container #gm-import-wl-regex {
  6771. width: calc(100% - 4em);
  6772. margin: 0 2em;
  6773. }
  6774. .${gm.id}-dialog .gm-import-wl-container .gm-capturing-group {
  6775. display: flex;
  6776. padding-left: 2em;
  6777. }
  6778. .${gm.id}-dialog .gm-import-wl-container .gm-capturing-group > div {
  6779. text-align: center;
  6780. margin-right: 1em;
  6781. }
  6782. .${gm.id}-dialog .gm-import-wl-container .gm-capturing-group .gm-interactive {
  6783. width: 3.6em;
  6784. text-align: center;
  6785. }
  6786.  
  6787. .gm-search input[type=text] {
  6788. line-height: normal;
  6789. outline: none;
  6790. padding-right: 6px;
  6791. color: var(--${gm.id}-text-color);
  6792. }
  6793. .gm-search input[type=text]::placeholder {
  6794. font-size: 0.9em;
  6795. color: var(--${gm.id}-light-hint-text-color);
  6796. }
  6797. .gm-search-clear {
  6798. display: inline-block;
  6799. color: var(--${gm.id}-hint-text-color);
  6800. cursor: pointer;
  6801. visibility: hidden;
  6802. }
  6803. .gm-filtered,
  6804. [class*=gm-filtered-] {
  6805. display: none !important;
  6806. }
  6807.  
  6808. .watch-later-list .list-box > span {
  6809. display: flex;
  6810. flex-direction: column;
  6811. overflow-anchor: none; /* 禁用滚动锚定,避免滚动跟随项目位置变化 */
  6812. }
  6813. .watch-later-list .btn-del {
  6814. display: none;
  6815. }
  6816. .watch-later-list .gm-list-item-fail-tooltip {
  6817. font-weight: bold;
  6818. text-decoration: underline;
  6819. }
  6820. .watch-later-list .gm-list-item-fail-tooltip:hover {
  6821. color: black;
  6822. }
  6823. .watch-later-list .gm-list-item-tools,
  6824. .watch-later-list .gm-list-item-fail-tooltip {
  6825. color: #999;
  6826. }
  6827. .watch-later-list .gm-list-item-tools > *,
  6828. .watch-later-list .gm-list-item-fail-tooltip {
  6829. margin: 0 5px;
  6830. cursor: pointer;
  6831. }
  6832. .watch-later-list .gm-list-item-tools span:hover {
  6833. text-decoration: underline;
  6834. font-weight: bold;
  6835. }
  6836. .watch-later-list .gm-list-item-tools input {
  6837. vertical-align: -3px;
  6838. }
  6839. .watch-later-list .gm-removed .gm-list-item-fixer {
  6840. display: none;
  6841. }
  6842. .watch-later-list .gm-removed,
  6843. .watch-later-list .gm-invalid {
  6844. filter: grayscale(1);
  6845. }
  6846. .watch-later-list .gm-fixed .key,
  6847. .watch-later-list .gm-removed .key {
  6848. visibility: hidden;
  6849. }
  6850. .watch-later-list .gm-removed .t {
  6851. text-decoration: line-through !important;
  6852. }
  6853. .watch-later-list .gm-invalid .t {
  6854. font-weight: unset !important;
  6855. }
  6856. .watch-later-list .gm-removed .t,
  6857. .watch-later-list .gm-invalid .t {
  6858. color: var(--${gm.id}-hint-text-color) !important;
  6859. }
  6860. .watch-later-list .gm-invalid a:not(.user) {
  6861. cursor: not-allowed !important;
  6862. }
  6863.  
  6864. .gm-fixed {
  6865. order: 1000 !important;
  6866. }
  6867. .gm-fixed .gm-list-item-fixer,
  6868. .gm-fixed .gm-card-fixer {
  6869. font-weight: bold;
  6870. }
  6871. .watch-later-list .list-box > [sort-type-fixed] .gm-fixed,
  6872. #${gm.id} .gm-entrypopup .gm-entry-list[gm-list-reverse] .gm-fixed,
  6873. #${gm.id} .gm-entrypopup .gm-entry-list[sort-type-fixed] .gm-fixed {
  6874. order: -1000 !important;
  6875. }
  6876.  
  6877. [gm-list-reverse] {
  6878. flex-direction: column-reverse !important;
  6879. }
  6880. .gm-list-reverse-end {
  6881. order: unset !important;
  6882. }
  6883. [gm-list-reverse] .gm-list-reverse-end {
  6884. margin-top: auto !important;
  6885. order: -9999 !important;
  6886. }
  6887.  
  6888. .gm-fixed {
  6889. border: 2px dashed var(--${gm.id}-light-hint-text-color) !important;
  6890. }
  6891. `)
  6892. } else {
  6893. if (api.base.urlMatch(gm.regex.page_dynamicMenu)) {
  6894. this.addMenuScrollbarStyle()
  6895. }
  6896. }
  6897. }
  6898. }
  6899.  
  6900. (function() {
  6901. script = new Script()
  6902. webpage = new Webpage()
  6903. if (!webpage.method.isLogin()) {
  6904. api.logger.info('终止执行:脚本只能工作在B站登录(不可用)状态下。')
  6905. return
  6906. }
  6907.  
  6908. script.initAtDocumentStart()
  6909. if (api.base.urlMatch([gm.regex.page_videoWatchlaterMode, gm.regex.page_listWatchlaterMode])) {
  6910. if (gm.config.redirect && gm.searchParams.get(`${gm.id}_disable_redirect`) !== 'true') {
  6911. webpage.redirect()
  6912. return
  6913. }
  6914. }
  6915.  
  6916. webpage.method.cleanSearchParams()
  6917. webpage.addStyle()
  6918. if (gm.config.mainRunAt === Enums.mainRunAt.DOMContentLoaded) {
  6919. document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', main) : main()
  6920. } else {
  6921. document.readyState !== 'complete' ? window.addEventListener('load', main) : main()
  6922. }
  6923.  
  6924. function main() {
  6925. script.init()
  6926. if (self === top) {
  6927. script.addScriptMenu()
  6928. api.base.initUrlchangeEvent()
  6929.  
  6930. if (gm.config.headerButton) {
  6931. webpage.addHeaderButton()
  6932. }
  6933. if (gm.config.removeHistory) {
  6934. webpage.processWatchlaterListDataSaving()
  6935. }
  6936. if (gm.config.fillWatchlaterStatus !== Enums.fillWatchlaterStatus.never) {
  6937. webpage.fillWatchlaterStatus()
  6938. }
  6939.  
  6940. if (api.base.urlMatch(gm.regex.page_watchlaterList)) {
  6941. webpage.initWatchlaterListPage()
  6942. webpage.processWatchlaterListPage()
  6943. } else if (api.base.urlMatch([gm.regex.page_videoNormalMode, gm.regex.page_videoWatchlaterMode, gm.regex.page_listMode])) {
  6944. if (gm.config.videoButton) {
  6945. webpage.addVideoButton()
  6946. }
  6947. } else if (api.base.urlMatch(gm.regex.page_dynamic)) {
  6948. if (gm.config.dynamicBatchAddManagerButton) {
  6949. webpage.addBatchAddManagerButton()
  6950. }
  6951. }
  6952.  
  6953. webpage.processSearchParams()
  6954. } else {
  6955. if (api.base.urlMatch(gm.regex.page_dynamicMenu)) {
  6956. if (gm.config.fillWatchlaterStatus !== Enums.fillWatchlaterStatus.never) {
  6957. webpage.fillWatchlaterStatus()
  6958. }
  6959. }
  6960. }
  6961. }
  6962. })()
  6963. })()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址