├── src ├── player │ ├── index.js │ ├── state │ │ └── PlayerState.js │ ├── managers │ │ ├── DragManager.js │ │ ├── EventManager.js │ │ ├── SettingsManager.js │ │ ├── ProgressManager.js │ │ ├── LoopManager.js │ │ └── videoSwipeManager.js │ ├── CustomVideoPlayer.js │ ├── core │ │ └── PlayerCore.js │ └── ui │ │ └── FloatingButton.js ├── constants │ ├── index.js │ └── i18n.js ├── autologin │ ├── index.js │ ├── README.md │ ├── i18n.js │ ├── LoginManager.js │ ├── utils.js │ └── MissavLoginProvider.js ├── adblock │ ├── StyleManager.js │ ├── AdBlockConfig.js │ ├── sites │ │ └── missav.js │ ├── index.js │ ├── RequestBlocker.js │ └── DOMCleaner.js ├── userExperienceEnhancer │ ├── DetailExpander.js │ ├── QualityManager.js │ ├── index.js │ └── UrlRedirector.js ├── utils │ ├── AnimationTimer.js │ ├── DOMUtils.js │ ├── otherUtils.js │ ├── PerformanceMonitor.js │ ├── index.js │ └── utils.js └── index.js ├── .gitignore ├── .babelrc ├── dist ├── miss_player.proxy.user.js └── miss_player.meta.js ├── package.json ├── README.md ├── devin_suggestion.md └── webpack.config.js /src/player/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 播放器模块入口文件 3 | * 导出模块化的自定义视频播放器 4 | */ 5 | export { CustomVideoPlayer } from './CustomVideoPlayer.js'; -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 加载CSS样式 3 | */ 4 | export function initCSSVariables() { 5 | // 直接导入 CSS 文件,webpack 会通过 style-loader 处理 6 | require('../player/ui/style.css'); 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 依赖目录 2 | node_modules/ 3 | 4 | # 日志 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # 运行时数据 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # 编辑器配置 18 | .cursor/ 19 | .idea/ 20 | .vscode/ 21 | *.swp 22 | *.swo 23 | 24 | # 构建输出 25 | build/ 26 | 27 | # 操作系统文件 28 | .DS_Store 29 | Thumbs.db 30 | 31 | # other 32 | .pass 33 | 34 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | "last 2 Chrome versions", 9 | "last 2 Firefox versions", 10 | "last 2 Safari versions", 11 | "last 2 Edge versions" 12 | ] 13 | }, 14 | "modules": false 15 | } 16 | ] 17 | ], 18 | "plugins": [ 19 | "transform-remove-console", 20 | "transform-remove-debugger" 21 | ] 22 | } -------------------------------------------------------------------------------- /dist/miss_player.proxy.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Miss Player | 影院模式 (单手播放器) 3 | // @description MissAV去广告|单手模式|MissAV自动展开详情|MissAV自动高画质|MissAV重定向支持|MissAV自动登录|定制播放器|多语言支持 支持 jable po*nhub 等通用 4 | // @version 5.1.6 5 | // @author Chris_C 6 | // @match *://*.missav.ws/* 7 | // @match *://*.missav.ai/* 8 | // @match *://*.jable.tv/* 9 | // @match *://*/* 10 | // @grant none 11 | // @icon https://missav.ws/img/favicon.ico 12 | // @license MIT 13 | // @namespace loadingi.local 14 | // @noframes 15 | // @require http://localhost:8080/miss_player.user.js 16 | // @run-at document-start 17 | // ==/UserScript== 18 | -------------------------------------------------------------------------------- /src/autologin/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 自动登录模块 - 主入口 3 | * 支持多个网站的自动登录功能 4 | */ 5 | import { LoginManager } from './LoginManager.js'; 6 | import { LoginUtils } from './utils.js'; 7 | import { I18n } from './i18n.js'; 8 | import { MissavLoginProvider } from './MissavLoginProvider.js'; 9 | 10 | // 导出所有模块,方便其他模块使用 11 | export { 12 | LoginManager, 13 | LoginUtils, 14 | I18n, 15 | MissavLoginProvider 16 | }; 17 | 18 | /** 19 | * 初始化自动登录模块 20 | * @returns {Promise} 初始化后的登录管理器实例 21 | */ 22 | export async function initAutoLogin() { 23 | try { 24 | // 创建并初始化登录管理器 25 | const loginManager = new LoginManager(); 26 | await loginManager.init(); 27 | return loginManager; 28 | } catch (error) { 29 | console.error('自动登录模块初始化失败:', error); 30 | return null; 31 | } 32 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miss-noad-video-player", 3 | "main": "dist/miss_noad_player.user.js", 4 | "scripts": { 5 | "build": "webpack --mode production", 6 | "dev": "webpack --mode development --watch" 7 | }, 8 | "keywords": [ 9 | "油猴脚本", 10 | "视频播放器" 11 | ], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@babel/core": "^7.23.2", 16 | "@babel/preset-env": "^7.23.2", 17 | "babel-loader": "^9.1.3", 18 | "babel-plugin-transform-remove-console": "^6.9.4", 19 | "babel-plugin-transform-remove-debugger": "^6.9.4", 20 | "clean-webpack-plugin": "^4.0.0", 21 | "css-loader": "^7.1.2", 22 | "css-minimizer-webpack-plugin": "^7.0.2", 23 | "postcss": "^8.5.3", 24 | "postcss-discard-comments": "^7.0.3", 25 | "postcss-loader": "^8.1.1", 26 | "style-loader": "^4.0.0", 27 | "terser-webpack-plugin": "^5.3.14", 28 | "webpack": "^5.89.0", 29 | "webpack-cli": "^5.1.4", 30 | "webpack-userscript": "^3.2.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/autologin/README.md: -------------------------------------------------------------------------------- 1 | # 自动登录模块 2 | 3 | 这个模块提供了一套灵活的自动登录系统,可以在不同网站上自动保存和应用登录信息。 4 | 5 | ## 功能特点 6 | 7 | - 多网站支持 - 设计为可扩展的架构,可以轻松添加新网站支持 8 | - 自动登录选项 - 在登录表单中提供"自动登录"选项 9 | - 安全存储 - 使用本地存储保存登录信息 10 | - 多语言支持 - 内置多种语言的用户界面文本 11 | 12 | ## 当前支持的网站 13 | 14 | - MissAV (missav.ws, missav.ai, missav.com, thisav.com) 15 | 16 | ## 使用方法 17 | 18 | ```javascript 19 | // 导入自动登录模块 20 | import { initAutoLogin } from './autologin/index.js'; 21 | 22 | // 初始化自动登录功能 23 | const loginManager = await initAutoLogin(); 24 | ``` 25 | 26 | ## 模块结构 27 | 28 | - `index.js` - 主入口,提供导出和自动初始化 29 | - `LoginManager.js` - 登录管理器,负责整体流程和提供者选择 30 | - `MissavLoginProvider.js` - MissAV网站的登录提供者实现 31 | - `utils.js` - 工具函数,包括Toast通知、存储访问等 32 | - `i18n.js` - 多语言支持 33 | 34 | ## 扩展支持新网站 35 | 36 | 要添加对新网站的支持,需要: 37 | 38 | 1. 创建一个新的登录提供者类,实现必要的接口方法 39 | 2. 在 `LoginManager.js` 中注册新的提供者 40 | 41 | 例如,创建新的登录提供者: 42 | 43 | ```javascript 44 | export class NewSiteLoginProvider { 45 | constructor() { 46 | this.domains = ['example.com', 'example.org']; 47 | } 48 | 49 | isSupportedSite() { 50 | const currentDomain = window.location.hostname; 51 | return this.domains.some(domain => currentDomain.includes(domain)); 52 | } 53 | 54 | // 实现其他必要的方法... 55 | } 56 | ``` 57 | 58 | 然后在 `LoginManager.js` 中注册: 59 | 60 | ```javascript 61 | // 登录提供者列表 62 | this.providers = [ 63 | new MissavLoginProvider(), 64 | new NewSiteLoginProvider() 65 | ]; 66 | ``` -------------------------------------------------------------------------------- /src/adblock/StyleManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 样式管理类 3 | * 负责创建和应用广告屏蔽相关的CSS样式 4 | */ 5 | 6 | /** 7 | * 样式管理类 8 | */ 9 | class StyleManager { 10 | /** 11 | * 创建样式管理实例 12 | * @param {Object} config - 广告屏蔽配置 13 | */ 14 | constructor(config) { 15 | this.config = config; 16 | } 17 | 18 | /** 19 | * 创建并应用广告屏蔽样式 20 | */ 21 | applyAdBlockStyles() { 22 | // 如果无样式,则直接返回 23 | if (this.config.adSelectors.length === 0 && this.config.customStyles.length === 0) { 24 | return; 25 | } 26 | 27 | // 创建样式元素 28 | const styleElement = document.createElement('style'); 29 | styleElement.id = 'adblock-styles'; 30 | styleElement.type = 'text/css'; 31 | 32 | // 构建广告屏蔽CSS 33 | let css = ''; 34 | 35 | // 添加广告选择器样式 36 | if (this.config.adSelectors.length > 0) { 37 | css += this.config.adSelectors.join(', ') + 38 | ' { display: none !important; visibility: hidden !important; height: 0 !important; min-height: 0 !important; }'; 39 | } 40 | 41 | // 添加自定义样式 42 | if (this.config.customStyles.length > 0) { 43 | css += '\n' + this.config.customStyles.map(item => 44 | `${item.selector} { ${item.styles} }`).join('\n'); 45 | } 46 | 47 | // 设置样式内容 48 | styleElement.textContent = css; 49 | 50 | // 添加到文档头部 51 | document.head.appendChild(styleElement); 52 | 53 | console.log('已应用广告屏蔽样式'); 54 | } 55 | } 56 | 57 | export default StyleManager; -------------------------------------------------------------------------------- /dist/miss_player.meta.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Miss Player | 影院模式 (单手播放器) 3 | // @name:en Miss Player | Theater Mode (One-handed Player) 4 | // @name:ja Miss Player | シアターモード (片手プレーヤー) 5 | // @name:vi Miss Player | Chế Độ Rạp Hát (Trình Phát Một Tay) 6 | // @name:zh-CN Miss Player | 影院模式 (单手播放器) 7 | // @name:zh-TW Miss Player | 影院模式 (單手播放器) 8 | // @description MissAV去广告|单手模式|MissAV自动展开详情|MissAV自动高画质|MissAV重定向支持|MissAV自动登录|定制播放器|多语言支持 支持 jable po*nhub 等通用 9 | // @description:en MissAV ad-free|one-handed mode|MissAV auto-expand details|MissAV auto high quality|MissAV redirect support|MissAV auto login|custom player|multilingual support for jable po*nhub etc. 10 | // @description:ja MissAV広告ブロック|片手モード|MissAV自動詳細表示|MissAV自動高画質|MissAVリダイレクト対応|MissAV自動ログイン|カスタムプレーヤー|jable po*nhubなどに対応した多言語サポート 11 | // @description:vi MissAV không quảng cáo|chế độ một tay|MissAV tự động mở rộng chi tiết|MissAV tự động chất lượng cao|Hỗ trợ chuyển hướng MissAV|MissAV tự động đăng nhập|trình phát tùy chỉnh|hỗ trợ đa ngôn ngữ cho jable po*nhub v.v. 12 | // @description:zh-CN MissAV去广告|单手模式|MissAV自动展开详情|MissAV自动高画质|MissAV重定向支持|MissAV自动登录|定制播放器|多语言支持 支持 jable po*nhub 等通用 13 | // @description:zh-TW MissAV去廣告|單手模式|MissAV自動展開詳情|MissAV自動高畫質|MissAV重定向支持|MissAV自動登錄|定制播放器|多語言支持 支持 jable po*nhub 等通用 14 | // @version 5.1.6 15 | // @author Chris_C 16 | // @match *://*.missav.ws/* 17 | // @match *://*.missav.ai/* 18 | // @match *://*.jable.tv/* 19 | // @match *://*/* 20 | // @grant none 21 | // @icon https://missav.ws/img/favicon.ico 22 | // @license MIT 23 | // @namespace loadingi.local 24 | // @noframes 25 | // @run-at document-start 26 | // ==/UserScript== 27 | -------------------------------------------------------------------------------- /src/adblock/AdBlockConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 广告屏蔽配置管理类 3 | * 负责管理和提供各网站的广告屏蔽配置 4 | */ 5 | 6 | /** 7 | * 配置管理类 8 | */ 9 | class AdBlockConfig { 10 | /** 11 | * 创建配置管理实例 12 | * @param {Object} siteConfig - 站点特定配置 13 | */ 14 | constructor(siteConfig = {}) { 15 | // 广告选择器 16 | this.adSelectors = siteConfig.adSelectors || []; 17 | 18 | // 自定义样式 19 | this.customStyles = siteConfig.customStyles || []; 20 | 21 | // 被阻止的URL模式集合 22 | this.blockedUrlPatternsSet = new Set(siteConfig.blockedUrlPatterns || []); 23 | 24 | // 预编译的广告关键词正则表达式 25 | this.adKeywordsRegex = /ads|analytics|tracker|affiliate|stat|pixel|banner|pop|click|outstream\.video|vast|vmap|preroll|midroll|postroll|adserve/i; 26 | } 27 | 28 | /** 29 | * 检查配置是否为空 30 | * @returns {boolean} 是否为空配置 31 | */ 32 | isEmpty() { 33 | return this.adSelectors.length === 0 && 34 | this.customStyles.length === 0 && 35 | this.blockedUrlPatternsSet.size === 0; 36 | } 37 | 38 | /** 39 | * 检查URL是否应当被阻止 40 | * @param {string} url - 待检查的URL 41 | * @returns {boolean} 是否应当阻止 42 | */ 43 | shouldBlockUrl(url) { 44 | if (!url || typeof url !== 'string') return false; 45 | 46 | // 使用预编译的正则表达式检查 47 | if (this.adKeywordsRegex.test(url)) { 48 | return true; 49 | } 50 | 51 | // 使用Set的has方法更快地检查特定域名 52 | for (const pattern of this.blockedUrlPatternsSet) { 53 | if (url.includes(pattern)) { 54 | return true; 55 | } 56 | } 57 | 58 | return false; 59 | } 60 | } 61 | 62 | export default AdBlockConfig; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 油猴脚本,看片新姿势 4 | 5 | 「MissPlayer」小工具,主要解决竖屏看网页视频时画面面积小、跳转不方便、循环播放问题。顺便去除 missav 站的广告 6 | 7 | ## 它能干啥? 8 | 9 | ### 网站体验优化(目前只针对 missav 将来可能扩展到其他网站) 10 | - 干掉烦人广告(告别弹窗) 11 | - 自动登录(再也不用重复输密码啦) 12 | - 自动切换高画质(眼睛舒服多了) 13 | - 自动展开视频详情 14 | - 视频列表标题展开,省的只看开头几个字云里雾里 15 | 16 | ### 单手播放器 (适用于大部分网页视频) 17 | - 点击页面底部粉色按钮进入播放器 18 | - 单手模式超爽(特别适合手机竖屏单手握持) 19 | - 想看哪就放大哪(上下左右 拖一下视频下面的小白条就行) 20 | - 快速跳转(5秒、10秒、30秒、1分钟、5分钟、10分钟.想跳哪跳哪) 21 | - 还能循环播放你喜欢的片段(反复学习 嘿嘿嘿~) 22 | 23 | ### ToDo 24 | - 兼容纵向视频 25 | - 横屏模式与竖屏模式设置分别存储、持久化 26 | - 兼容更多视频网站 27 | 28 | ## 最近更新 29 | - 5.1.5版:针对 iOS Safari 浏览器不支持音量调节的特性,优化了在 iPhone 和 iPad 设备上的体验(隐藏音量控制滑杆);同时完善了网站重定向功能,现在会自动跳转到 missav.ai 域名; 油猴元数据国际化多语言支持。 30 | - 5.1.4版:增加音量滑杆控制,增加视频长按倍速播放,优化横屏时控制面板自动隐藏逻辑。 31 | - 5.1.3版:横屏模式下只有在控制面板显示的情况下才可以点击视频进行暂停。 32 | - 5.1.2版:把浮动按钮移到底部中间了,避免与其他插件位置干扰,还加了个呼吸灯效果 33 | 34 | ## 怎么安装? 35 | 36 | ### 苹果机(iOS) 37 | - **推荐方法1**: 38 | 👉 用[「Stay for Safari」(App Store 免费版就够用)](https://apps.apple.com/cn/app/stay-for-safari-%E6%B5%8F%E8%A7%88%E5%99%A8%E4%BC%B4%E4%BE%A3/id1591620171) 39 | 安装后去脚本页面,点右下角stay按钮就能装上 40 | 41 | - **推荐方法2**: 42 | 👉 用[「Userscript」(App Store有)](https://apps.apple.com/cn/app/userscripts/id1463298887) 43 | 44 | ### 安卓/Windows/Mac 45 | - 装个「Tampermonkey」就行了 46 | 👉 https://www.tampermonkey.net/ 47 | - Edge Android 版 似乎支持装插件了,各位可以试试 48 | 49 | ## 脚本下载地址 50 | - [**GreasyFork**](https://greasyfork.org/zh-CN/scripts/453300-missav-%E5%8E%BB%E5%B9%BF%E5%91%8A-%E5%BD%B1%E9%99%A2%E6%A8%A1%E5%BC%8F-%E5%8D%95%E6%89%8B%E6%92%AD%E6%94%BE%E5%99%A8) 51 | 52 | - [**OpenUserJs**](https://openuserjs.org/scripts/loadingi/MissAV_%E5%8E%BB%E5%B9%BF%E5%91%8A_%E5%BD%B1%E9%99%A2%E6%A8%A1%E5%BC%8F_(%E5%8D%95%E6%89%8B%E6%92%AD%E6%94%BE%E5%99%A8)) 53 | 54 | 有啥问题或建议都可以在评论区说哦!用得开心~ 55 | 56 | -------------------------------------------------------------------------------- /src/userExperienceEnhancer/DetailExpander.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 详情自动展开类 3 | * 负责自动展开视频的详细信息 4 | */ 5 | 6 | import { Utils } from '../utils/utils.js'; 7 | 8 | /** 9 | * 详情展开器类 10 | */ 11 | export class DetailExpander { 12 | constructor() { 13 | // 配置 14 | this.maxAttempts = 3; // 最大尝试次数 15 | this.attemptInterval = 1000; // 尝试间隔时间(ms) 16 | } 17 | 18 | /** 19 | * 展开详情的选择器 20 | * @type {string} 21 | */ 22 | get SHOW_MORE_SELECTOR() { 23 | return 'a.text-nord13.font-medium.flex.items-center'; 24 | } 25 | 26 | /** 27 | * 自动展开详情 28 | */ 29 | autoExpandDetails() { 30 | console.log('[DetailExpander] 尝试自动展开详情'); 31 | 32 | // 立即尝试展开一次 33 | this.expandDetailsSingle(); 34 | 35 | // 多次尝试,因为有时候页面加载较慢 36 | let attempts = 0; 37 | const attemptInterval = setInterval(() => { 38 | if (this.expandDetailsSingle() || ++attempts >= this.maxAttempts) { 39 | clearInterval(attemptInterval); 40 | console.log(`[DetailExpander] 完成尝试 (${attempts + 1}次)`); 41 | } 42 | }, this.attemptInterval); 43 | } 44 | 45 | /** 46 | * 执行单次展开尝试 47 | * @returns {boolean} 是否成功展开 48 | */ 49 | expandDetailsSingle() { 50 | try { 51 | const showMoreButton = document.querySelector(this.SHOW_MORE_SELECTOR); 52 | if (showMoreButton) { 53 | console.log('[DetailExpander] 找到"显示更多"按钮,点击展开'); 54 | showMoreButton.click(); 55 | return true; 56 | } 57 | } catch (error) { 58 | console.error('[DetailExpander] 展开详情时出错:', error); 59 | } 60 | return false; 61 | } 62 | } -------------------------------------------------------------------------------- /src/utils/AnimationTimer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 高效的定时器管理器类 3 | * 使用requestAnimationFrame实现更高效的定时器 4 | */ 5 | export class AnimationTimer { 6 | constructor() { 7 | this.timers = new Map(); 8 | } 9 | 10 | /** 11 | * 设置一个基于requestAnimationFrame的延迟执行函数 12 | * @param {Function} callback - 回调函数 13 | * @param {number} delay - 延迟时间(ms) 14 | * @param {string} id - 定时器ID 15 | * @returns {string} - 定时器ID 16 | */ 17 | setTimeout(callback, delay, id = null) { 18 | const timerId = id || Math.random().toString(36).substr(2, 9); 19 | const startTime = performance.now(); 20 | 21 | const timerLoop = (currentTime) => { 22 | if (!this.timers.has(timerId)) return; 23 | 24 | const elapsed = currentTime - startTime; 25 | if (elapsed >= delay) { 26 | callback(); 27 | this.clearTimeout(timerId); 28 | } else { 29 | const timerData = this.timers.get(timerId); 30 | timerData.rafId = requestAnimationFrame(timerLoop); 31 | this.timers.set(timerId, timerData); 32 | } 33 | }; 34 | 35 | this.timers.set(timerId, { 36 | rafId: requestAnimationFrame(timerLoop), 37 | callback, 38 | type: 'timeout' 39 | }); 40 | 41 | return timerId; 42 | } 43 | 44 | /** 45 | * 清除延迟执行函数 46 | * @param {string} id - 定时器ID 47 | */ 48 | clearTimeout(id) { 49 | if (this.timers.has(id)) { 50 | const timerData = this.timers.get(id); 51 | cancelAnimationFrame(timerData.rafId); 52 | this.timers.delete(id); 53 | } 54 | } 55 | 56 | /** 57 | * 清除所有定时器 58 | */ 59 | clearAll() { 60 | this.timers.forEach((timerData) => { 61 | cancelAnimationFrame(timerData.rafId); 62 | }); 63 | this.timers.clear(); 64 | } 65 | } 66 | 67 | // 创建全局定时器实例 68 | export const animationTimer = new AnimationTimer(); -------------------------------------------------------------------------------- /src/utils/DOMUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DOM操作工具类 3 | */ 4 | export class DOMUtils { 5 | /** 6 | * 创建并设置元素样式 7 | * @param {string} tag - 元素标签名 8 | * @param {string} className - 类名 9 | * @param {string} styleCSS - 内联样式 10 | * @returns {HTMLElement} 创建的元素 11 | */ 12 | static createElementWithStyle(tag, className, styleCSS) { 13 | const element = document.createElement(tag); 14 | if (className) element.className = className; 15 | if (styleCSS) element.style.cssText = styleCSS; 16 | return element; 17 | } 18 | 19 | /** 20 | * 创建SVG图标 21 | * @param {string} path - SVG路径 22 | * @param {number} size - 图标大小 23 | * @returns {SVGElement} 创建的SVG元素 24 | */ 25 | static createSVGIcon(path, size = 24) { 26 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 27 | svg.setAttribute('width', size); 28 | svg.setAttribute('height', size); 29 | svg.setAttribute('viewBox', '0 0 24 24'); 30 | svg.setAttribute('fill', 'none'); 31 | svg.setAttribute('stroke', 'currentColor'); 32 | svg.setAttribute('stroke-width', '2'); 33 | svg.setAttribute('stroke-linecap', 'round'); 34 | svg.setAttribute('stroke-linejoin', 'round'); 35 | 36 | const pathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 37 | pathElement.setAttribute('d', path); 38 | svg.appendChild(pathElement); 39 | 40 | return svg; 41 | } 42 | 43 | /** 44 | * 为元素添加事件委托 45 | * @param {HTMLElement} element - 父元素 46 | * @param {string} eventType - 事件类型 47 | * @param {string} selector - CSS选择器 48 | * @param {Function} handler - 处理函数 49 | * @param {Object} options - 事件选项 50 | */ 51 | static delegateEvent(element, eventType, selector, handler, options) { 52 | element.addEventListener(eventType, (event) => { 53 | const target = event.target.closest(selector); 54 | if (target && element.contains(target)) { 55 | handler.call(target, event); 56 | } 57 | }, options); 58 | } 59 | } -------------------------------------------------------------------------------- /src/adblock/sites/missav.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Missav网站广告屏蔽配置 3 | * 包含特定于missav.ws/ai/com域名的广告选择器和URL模式 4 | */ 5 | 6 | /** 7 | * missav网站的广告选择器 8 | */ 9 | const adSelectors = [ 10 | 'div[class="space-y-6 mb-6"]', // 页面右侧便 视频广告 11 | 'div[class*="root--"][class*="bottomRight--"]', // 页面右下角视频广告 12 | 'div[class="grid md:grid-cols-2 gap-8"]', // 视频下方新域名推广 13 | 'ul[class="mb-4 list-none text-nord14 grid grid-cols-2 gap-2"]', // 视频简介下方链接推广 14 | 'div[class="space-y-5 mb-5"]', // 页面底部视频广告 15 | 'iframe[src*="ads"]', 16 | 'iframe[src*="banner"]', 17 | 'iframe[src*="pop"]', 18 | 'iframe[data-ad]', 19 | 'iframe[id*="ads"]', 20 | 'iframe[class*="ads"]', 21 | 'iframe:not([src*="plyr.io"])' // 屏蔽所有非播放器的iframe 22 | ]; 23 | 24 | /** 25 | * missav网站的自定义样式 26 | */ 27 | const customStyles = [ 28 | { 29 | // 影片列表文字标题完整显示 30 | selector: 'div[class="my-2 text-sm text-nord4 truncate"]', 31 | styles: 'white-space: normal !important;' 32 | }, 33 | { 34 | // 设置页面背景色为黑色 35 | selector: 'body', 36 | styles: 'background-color: #000000 !important;' 37 | }, 38 | { 39 | // 设置z-max元素的z-index 40 | selector: 'div[class*="z-max"]', 41 | styles: 'z-index: 9000 !important;' 42 | } 43 | ]; 44 | 45 | /** 46 | * 需要屏蔽的URL模式 47 | */ 48 | const blockedUrlPatterns = [ 49 | 'exoclick.com', 50 | 'juicyads.com', 51 | 'popads.net', 52 | 'adsterra.com', 53 | 'trafficjunky.com', 54 | 'adnium.com', 55 | 'ad-maven.com', 56 | 'browser-update.org', 57 | 'mopvip.icu', 58 | 'toppages.pw', 59 | 'cpmstar.com', 60 | 'propellerads.com', 61 | 'tsyndicate.com', 62 | 'syndication.exosrv.com', 63 | 'ads.exosrv.com', 64 | 'tsyndicate.com/sdk', 65 | 'cdn.tsyndicate.com', 66 | 'adsco.re', 67 | 'adscpm.site', 68 | 'a-ads.com', 69 | 'ad-delivery.net', 70 | 'outbrain.com', 71 | 'taboola.com', 72 | 'mgid.com', 73 | 'revcontent.com', 74 | 'adnxs.com', 75 | 'pubmatic.com', 76 | 'rubiconproject.com', 77 | 'openx.net', 78 | 'criteo.com', 79 | 'doubleclick.net' 80 | ]; 81 | 82 | // 导出missav配置 83 | export default { 84 | adSelectors, 85 | customStyles, 86 | blockedUrlPatterns, 87 | // 未来可以添加网站特有的配置 88 | isVideoSite: true, 89 | domains: ['missav.ws', 'missav.ai', 'missav.com', 'thisav.com'] 90 | }; -------------------------------------------------------------------------------- /src/userExperienceEnhancer/QualityManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 视频画质管理类 3 | * 负责自动设置视频的最高画质 4 | */ 5 | 6 | /** 7 | * 画质管理器类 8 | */ 9 | export class QualityManager { 10 | constructor() { 11 | // 配置 12 | this.maxAttempts = 20; // 最大尝试次数 13 | this.attemptInterval = 500; // 尝试间隔时间(ms) 14 | } 15 | 16 | /** 17 | * 自动设置最高画质 18 | */ 19 | setupAutoHighestQuality() { 20 | console.log('[QualityManager] 尝试设置视频最高画质'); 21 | 22 | // 立即尝试一次 23 | if (this.setHighestQualitySingle()) { 24 | console.log('[QualityManager] 成功设置最高画质'); 25 | return; 26 | } 27 | 28 | // 失败则定时尝试 29 | let attempts = 0; 30 | const checkInterval = setInterval(() => { 31 | if (this.setHighestQualitySingle() || ++attempts >= this.maxAttempts) { 32 | clearInterval(checkInterval); 33 | console.log(`[QualityManager] 完成尝试 (${attempts + 1}次)`); 34 | } 35 | }, this.attemptInterval); 36 | 37 | // 页面完全加载后再尝试一次 38 | window.addEventListener('load', () => this.setHighestQualitySingle()); 39 | } 40 | 41 | /** 42 | * 执行单次设置最高画质尝试 43 | * @returns {boolean} 是否成功设置 44 | */ 45 | setHighestQualitySingle() { 46 | try { 47 | // 检查播放器 48 | const player = window.player || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.player : null); 49 | 50 | if (!player || !player.config || !player.config.quality || !player.config.quality.options || !player.config.quality.options.length) { 51 | return false; 52 | } 53 | 54 | // 设置最高画质 55 | const maxQuality = Math.max(...player.config.quality.options); 56 | console.log('[QualityManager] 设置画质:', maxQuality); 57 | 58 | // 同时设置属性和方法 59 | player.quality = maxQuality; 60 | player.config.quality.selected = maxQuality; 61 | 62 | if (typeof player.quality === 'function') { 63 | player.quality(maxQuality); 64 | } 65 | 66 | return true; 67 | } catch (error) { 68 | console.error('[QualityManager] 设置最高画质时出错:', error); 69 | return false; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/autologin/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 多语言系统 - 用于自动登录功能的国际化支持 3 | */ 4 | export class I18n { 5 | /** 6 | * 获取用户浏览器当前语言 7 | * @returns {string} 用户当前语言代码 8 | */ 9 | static get userLang() { 10 | return (navigator.languages && navigator.languages[0]) || navigator.language || 'en'; 11 | } 12 | 13 | /** 14 | * 语言字符串集合 15 | * @type {Object} 16 | */ 17 | static strings = { 18 | 'en': { 19 | accountNull: 'Error: Email or password is empty.', 20 | loginSuccess: 'Login successful, refreshing the page.', 21 | networkFailed: 'Status code error.', 22 | loginFailed: 'Login failed, incorrect email or password. Check console for error details.', 23 | autoLogin: 'Auto Login' 24 | }, 25 | 'zh-CN': { 26 | accountNull: '邮箱或密码为空', 27 | loginSuccess: '登录成功,即将刷新页面。', 28 | networkFailed: '状态码错误', 29 | loginFailed: '登录失败,邮箱或密码错误,可以在控制台查看错误信息。', 30 | autoLogin: '自动登录' 31 | }, 32 | 'zh-TW': { 33 | accountNull: '郵箱或密碼為空', 34 | loginSuccess: '登錄成功,即將刷新頁面。', 35 | networkFailed: '狀態碼錯誤', 36 | loginFailed: '登錄失敗,郵箱或密碼錯誤,可以在控制台查看錯誤信息。', 37 | autoLogin: '自動登錄' 38 | }, 39 | 'ja': { 40 | accountNull: 'エラー:メールアドレスまたはパスワードが空です。', 41 | loginSuccess: 'ログイン成功、ページを更新します。', 42 | networkFailed: 'ステータスコードエラー', 43 | loginFailed: 'ログインに失敗しました。メールアドレスまたはパスワードが間違っています。エラーの詳細はコンソールで確認できます。', 44 | autoLogin: '自動ログイン' 45 | }, 46 | 'vi': { 47 | accountNull: 'Lỗi: Email hoặc mật khẩu trống.', 48 | loginSuccess: 'Đăng nhập thành công, đang làm mới trang.', 49 | networkFailed: 'Lỗi mã trạng thái.', 50 | loginFailed: 'Đăng nhập không thành công, email hoặc mật khẩu không chính xác. Xem chi tiết lỗi trên bảng điều khiển.', 51 | autoLogin: 'Đăng nhập tự động' 52 | } 53 | }; 54 | 55 | /** 56 | * 翻译函数 - 将ID转换为当前语言的字符串 57 | * @param {string} id - 要翻译的字符串ID 58 | * @param {string} [lang=''] - 可选的指定语言,默认使用用户语言 59 | * @returns {string} 翻译后的字符串 60 | */ 61 | static translate(id, lang = '') { 62 | const selectedLang = lang || this.userLang; 63 | return (this.strings[selectedLang] || this.strings.en)[id] || this.strings.en[id]; 64 | } 65 | } -------------------------------------------------------------------------------- /src/userExperienceEnhancer/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 用户体验增强模块入口 3 | * 负责管理和协调各种增强用户体验的功能 4 | */ 5 | 6 | import { DetailExpander } from './DetailExpander.js'; 7 | import { QualityManager } from './QualityManager.js'; 8 | import { UrlRedirector } from './UrlRedirector.js'; 9 | import { Utils } from '../utils/utils.js'; 10 | 11 | // 立即创建并执行URL重定向检查,确保在页面加载最早阶段执行 12 | const earlyUrlRedirector = new UrlRedirector(); 13 | 14 | /** 15 | * 用户体验增强器类 16 | * 整合了自动展开详情、自动高画质、URL重定向等功能 17 | */ 18 | export class UserExperienceEnhancer { 19 | constructor() { 20 | this.detailExpander = new DetailExpander(); 21 | this.qualityManager = new QualityManager(); 22 | this.urlRedirector = earlyUrlRedirector; // 使用提前创建的重定向器实例 23 | } 24 | 25 | /** 26 | * 初始化用户体验增强功能 27 | * @param {boolean} skipRedirectCheck - 是否跳过URL重定向检查(如果在其他地方已经执行过) 28 | */ 29 | init(skipRedirectCheck = false) { 30 | console.log('[UserExperienceEnhancer] 初始化用户体验增强功能'); 31 | 32 | // 检查是否需要执行URL重定向 33 | if (!skipRedirectCheck) { 34 | // 执行URL重定向检查,如果已重定向则不继续执行后续功能 35 | if (this.urlRedirector.checkAndRedirect()) { 36 | return; 37 | } 38 | } 39 | 40 | // DOM加载完成后执行其他功能 41 | if (document.readyState === 'loading') { 42 | document.addEventListener('DOMContentLoaded', () => { 43 | this.initFeatures(); 44 | }); 45 | } else { 46 | this.initFeatures(); 47 | } 48 | } 49 | 50 | /** 51 | * 初始化各项功能 52 | */ 53 | initFeatures() { 54 | try { 55 | this.detailExpander.autoExpandDetails(); 56 | this.qualityManager.setupAutoHighestQuality(); 57 | } catch (error) { 58 | console.error('[UserExperienceEnhancer] 初始化功能时出错:', error); 59 | } 60 | } 61 | } 62 | 63 | // 导出各个组件,便于单独使用 64 | export { DetailExpander } from './DetailExpander.js'; 65 | export { QualityManager } from './QualityManager.js'; 66 | export { UrlRedirector } from './UrlRedirector.js'; 67 | export { earlyUrlRedirector }; // 导出提前初始化的实例 68 | 69 | /** 70 | * 初始化用户体验增强功能 71 | * @param {boolean} skipRedirectCheck - 是否跳过URL重定向检查 72 | * @returns {UserExperienceEnhancer} 用户体验增强器实例 73 | */ 74 | export function initUserExperienceEnhancer(skipRedirectCheck = false) { 75 | const enhancer = new UserExperienceEnhancer(); 76 | enhancer.init(skipRedirectCheck); 77 | return enhancer; 78 | } -------------------------------------------------------------------------------- /src/adblock/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 广告屏蔽模块入口 3 | * 此模块负责屏蔽网站上的广告内容,提供更好的用户体验 4 | */ 5 | 6 | import { Utils } from '../utils'; 7 | import AdBlockConfig from './AdBlockConfig'; 8 | import StyleManager from './StyleManager'; 9 | import DOMCleaner from './DOMCleaner'; 10 | import RequestBlocker from './RequestBlocker'; 11 | import missavConfig from './sites/missav'; 12 | 13 | /** 14 | * 根据网站URL获取适合的配置 15 | * @param {string} url - 当前网站URL 16 | * @returns {Object} 站点特定配置 17 | */ 18 | function getSiteConfig(url) { 19 | // 目前只支持missav,未来可以扩展 20 | if (/^https?:\/\/(www\.)?(missav|thisav)\.(com|ws|ai)/.test(url)) { 21 | return missavConfig; 22 | } 23 | 24 | // 返回默认空配置 25 | return { 26 | adSelectors: [], 27 | customStyles: [], 28 | blockedUrlPatterns: [] 29 | }; 30 | } 31 | 32 | /** 33 | * 广告屏蔽器类 34 | */ 35 | class AdBlocker { 36 | constructor() { 37 | const siteConfig = getSiteConfig(window.location.href); 38 | this.config = new AdBlockConfig(siteConfig); 39 | this.styleManager = new StyleManager(this.config); 40 | this.domCleaner = new DOMCleaner(this.config); 41 | this.requestBlocker = new RequestBlocker(this.config); 42 | } 43 | 44 | /** 45 | * 防止被检测到AdBlock 46 | */ 47 | preventDetection() { 48 | window.AdBlock = false; 49 | window.adblock = false; 50 | window.adsbygoogle = { loaded: true }; 51 | if (typeof unsafeWindow !== 'undefined') { 52 | unsafeWindow.AdBlock = false; 53 | unsafeWindow.adblock = false; 54 | unsafeWindow.adsbygoogle = { loaded: true }; 55 | } 56 | } 57 | 58 | /** 59 | * 设置定期清理 60 | */ 61 | setupPeriodicCleaning() { 62 | // 首次运行强制清理 63 | this.domCleaner.removeAdElements(true); 64 | this.domCleaner.observeDOMChanges(); 65 | 66 | // 定时检查,2秒一次 67 | setInterval(() => this.domCleaner.removeAdElements(), 2000); 68 | } 69 | 70 | /** 71 | * 初始化广告屏蔽器 72 | */ 73 | init() { 74 | // 检查当前网站是否需要启用广告屏蔽 75 | if (this.config.isEmpty()) { 76 | return; // 无配置,不启用 77 | } 78 | 79 | console.log('广告屏蔽模块已启用'); 80 | 81 | // 防止被检测 82 | this.preventDetection(); 83 | 84 | // 应用样式(尽早执行) 85 | this.styleManager.applyAdBlockStyles(); 86 | 87 | // 初始化请求拦截器 88 | this.requestBlocker.init(); 89 | 90 | // 当DOM加载后执行 91 | if (document.readyState === 'loading') { 92 | document.addEventListener('DOMContentLoaded', () => this.setupPeriodicCleaning()); 93 | } else { 94 | this.setupPeriodicCleaning(); 95 | } 96 | } 97 | } 98 | 99 | // 导出广告屏蔽器 100 | export default AdBlocker; -------------------------------------------------------------------------------- /src/userExperienceEnhancer/UrlRedirector.js: -------------------------------------------------------------------------------- 1 | /** 2 | * URL重定向类 3 | * 负责将特定域名重定向到目标域名 4 | */ 5 | 6 | /** 7 | * URL重定向器类 8 | */ 9 | export class UrlRedirector { 10 | constructor() { 11 | // 配置重定向规则 12 | this.redirectRules = [ 13 | { 14 | // 匹配missav.com和thisav.com和 missav.ws 和 missav123.com 和 missav.live 15 | pattern: /^https?:\/\/(www\.)?(missav|thisav|missav123)\.com\/?|^https?:\/\/(www\.)?missav\.ws\/?|^https?:\/\/(www\.)?missav\.live\/?/i, 16 | targetDomain: 'missav.ai' 17 | } 18 | ]; 19 | 20 | // 立即执行重定向检查 21 | this.immediateRedirect(); 22 | } 23 | 24 | /** 25 | * 立即执行重定向检查,在页面加载最早时执行 26 | */ 27 | immediateRedirect() { 28 | // 立即检查当前URL 29 | this.checkAndRedirect(); 30 | } 31 | 32 | /** 33 | * 检查当前URL并执行重定向 34 | * @returns {boolean} 是否执行了重定向 35 | */ 36 | checkAndRedirect() { 37 | const currentUrl = window.location.href; 38 | 39 | // 检查每条重定向规则 40 | for (const rule of this.redirectRules) { 41 | if (rule.pattern.test(currentUrl)) { 42 | console.log('[UrlRedirector] 匹配到重定向规则:', rule); 43 | 44 | // 执行重定向 45 | const newUrl = this.applyRedirect(currentUrl, rule); 46 | if (newUrl !== currentUrl) { 47 | console.log('[UrlRedirector] 重定向到:', newUrl); 48 | // 使用replace而不是href赋值,避免在浏览历史中留下记录 49 | window.location.replace(newUrl); 50 | return true; 51 | } 52 | } 53 | } 54 | 55 | // 未触发重定向 56 | return false; 57 | } 58 | 59 | /** 60 | * 应用重定向规则,生成新URL 61 | * @param {string} url - 当前URL 62 | * @param {Object} rule - 重定向规则 63 | * @returns {string} 重定向后的URL 64 | */ 65 | applyRedirect(url, rule) { 66 | // 替换域名部分 67 | if (rule.targetDomain) { 68 | // 处理各种域名情况 69 | let newUrl = url; 70 | 71 | // 处理.com域名 72 | newUrl = newUrl.replace(/^(https?:\/\/)(www\.)?(missav|thisav|missav123)\.com\/?/i, `$1${rule.targetDomain}/`); 73 | 74 | // 如果URL未变化,则尝试替换.ws域名 75 | if (newUrl === url) { 76 | newUrl = url.replace(/^(https?:\/\/)(www\.)?missav\.ws\/?/i, `$1${rule.targetDomain}/`); 77 | } 78 | 79 | // 如果URL还未变化,则尝试替换.live域名 80 | if (newUrl === url) { 81 | newUrl = url.replace(/^(https?:\/\/)(www\.)?missav\.live\/?/i, `$1${rule.targetDomain}/`); 82 | } 83 | 84 | return newUrl; 85 | } 86 | 87 | return url; 88 | } 89 | } -------------------------------------------------------------------------------- /src/player/state/PlayerState.js: -------------------------------------------------------------------------------- 1 | import { Utils } from '../../utils/utils.js'; 2 | 3 | /** 4 | * 播放器状态管理类 5 | */ 6 | export class PlayerState { 7 | constructor() { 8 | // 播放器设置 9 | this.settings = { 10 | showSeekControlRow: true, // 显示快进快退控制行 11 | showLoopControlRow: true, // 显示循环控制行 12 | showPlaybackControlRow: true // 显示播放控制行 13 | }; 14 | 15 | // 添加存储方法 16 | this._setupStorageMethods(); 17 | } 18 | 19 | /** 20 | * 设置存储方法 21 | * @private 22 | */ 23 | _setupStorageMethods() { 24 | // 检查是否有油猴API可用 25 | this.hasGMAPI = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; 26 | } 27 | 28 | /** 29 | * 安全获取存储的值 30 | * @param {string} key - 键名 31 | * @param {any} defaultValue - 默认值 32 | * @returns {any} - 存储的值或默认值 33 | */ 34 | getValue(key, defaultValue) { 35 | try { 36 | if (this.hasGMAPI) { 37 | return GM_getValue(key, defaultValue); 38 | } else { 39 | const value = localStorage.getItem(`missNoAD_${key}`); 40 | return value !== null ? JSON.parse(value) : defaultValue; 41 | } 42 | } catch (e) { 43 | console.debug('获取存储值失败:', e); 44 | return defaultValue; 45 | } 46 | } 47 | 48 | /** 49 | * 安全存储值 50 | * @param {string} key - 键名 51 | * @param {any} value - 要存储的值 52 | * @returns {boolean} - 是否成功存储 53 | */ 54 | setValue(key, value) { 55 | try { 56 | if (this.hasGMAPI) { 57 | GM_setValue(key, value); 58 | return true; 59 | } else { 60 | localStorage.setItem(`missNoAD_${key}`, JSON.stringify(value)); 61 | return true; 62 | } 63 | } catch (e) { 64 | console.debug('存储值失败:', e); 65 | return false; 66 | } 67 | } 68 | 69 | /** 70 | * 加载保存的设置 71 | */ 72 | loadSettings() { 73 | try { 74 | this.settings.showSeekControlRow = this.getValue('showSeekControlRow', true); 75 | this.settings.showLoopControlRow = this.getValue('showLoopControlRow', true); 76 | this.settings.showPlaybackControlRow = this.getValue('showPlaybackControlRow', true); 77 | } catch (error) { 78 | console.error('[PlayerState] 加载设置失败:', error); 79 | } 80 | } 81 | 82 | /** 83 | * 保存设置 84 | */ 85 | saveSettings() { 86 | try { 87 | this.setValue('showSeekControlRow', this.settings.showSeekControlRow); 88 | this.setValue('showLoopControlRow', this.settings.showLoopControlRow); 89 | this.setValue('showPlaybackControlRow', this.settings.showPlaybackControlRow); 90 | } catch (error) { 91 | console.error('[PlayerState] 保存设置失败:', error); 92 | } 93 | } 94 | 95 | /** 96 | * 更新设置 97 | * @param {string} key - 设置键名 98 | * @param {any} value - 设置值 99 | */ 100 | updateSetting(key, value) { 101 | if (key in this.settings) { 102 | this.settings[key] = value; 103 | this.saveSettings(); 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /src/utils/otherUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 其他辅助工具函数 3 | */ 4 | 5 | /** 6 | * 防抖函数 - 延迟执行函数直到停止调用一段时间后 7 | * @param {Function} fn - 需要防抖的函数 8 | * @param {number} delay - 延迟时间(毫秒) 9 | * @returns {Function} 防抖后的函数 10 | */ 11 | export function debounce(fn, delay) { 12 | let timer = null; 13 | return function(...args) { 14 | const context = this; 15 | clearTimeout(timer); 16 | timer = setTimeout(() => { 17 | fn.apply(context, args); 18 | }, delay); 19 | }; 20 | } 21 | 22 | /** 23 | * 存储数据到本地存储 24 | * @param {string} key - 存储键名 25 | * @param {any} value - 存储值 26 | */ 27 | export function setLocalStorage(key, value) { 28 | try { 29 | localStorage.setItem(key, JSON.stringify(value)); 30 | } catch (e) { 31 | console.error('本地存储写入失败:', e); 32 | } 33 | } 34 | 35 | /** 36 | * 从本地存储获取数据 37 | * @param {string} key - 存储键名 38 | * @param {any} defaultValue - 默认值 39 | * @returns {any} 获取的值 40 | */ 41 | export function getLocalStorage(key, defaultValue = null) { 42 | try { 43 | const item = localStorage.getItem(key); 44 | return item ? JSON.parse(item) : defaultValue; 45 | } catch (e) { 46 | console.error('本地存储读取失败:', e); 47 | return defaultValue; 48 | } 49 | } 50 | 51 | /** 52 | * 检测是否为移动设备 53 | * @returns {boolean} 是否为移动设备 54 | */ 55 | export function isMobileDevice() { 56 | return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 57 | } 58 | 59 | /** 60 | * 获取URL参数 61 | * @param {string} name - 参数名 62 | * @param {string} url - URL,默认为当前页面URL 63 | * @returns {string|null} 参数值 64 | */ 65 | export function getUrlParam(name, url = window.location.href) { 66 | name = name.replace(/[\[\]]/g, '\\$&'); 67 | const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'); 68 | const results = regex.exec(url); 69 | if (!results) return null; 70 | if (!results[2]) return ''; 71 | return decodeURIComponent(results[2].replace(/\+/g, ' ')); 72 | } 73 | 74 | /** 75 | * 复制文本到剪贴板 76 | * @param {string} text - 要复制的文本 77 | * @returns {Promise} 是否复制成功 78 | */ 79 | export function copyToClipboard(text) { 80 | return new Promise((resolve) => { 81 | // 尝试使用现代API 82 | if (navigator.clipboard && navigator.clipboard.writeText) { 83 | navigator.clipboard.writeText(text) 84 | .then(() => resolve(true)) 85 | .catch(() => { 86 | // 降级到传统方法 87 | fallbackCopyToClipboard(text) ? resolve(true) : resolve(false); 88 | }); 89 | } else { 90 | // 使用传统方法 91 | fallbackCopyToClipboard(text) ? resolve(true) : resolve(false); 92 | } 93 | }); 94 | } 95 | 96 | /** 97 | * 传统的复制到剪贴板方法 98 | * @param {string} text - 要复制的文本 99 | * @returns {boolean} 是否复制成功 100 | */ 101 | function fallbackCopyToClipboard(text) { 102 | const textArea = document.createElement('textarea'); 103 | textArea.value = text; 104 | textArea.style.position = 'fixed'; 105 | textArea.style.left = '-999999px'; 106 | textArea.style.top = '-999999px'; 107 | document.body.appendChild(textArea); 108 | textArea.focus(); 109 | textArea.select(); 110 | 111 | let success = false; 112 | try { 113 | success = document.execCommand('copy'); 114 | } catch (e) { 115 | console.error('剪贴板复制失败:', e); 116 | } 117 | 118 | document.body.removeChild(textArea); 119 | return success; 120 | } -------------------------------------------------------------------------------- /src/adblock/RequestBlocker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 请求拦截类 3 | * 负责拦截和阻止广告相关网络请求 4 | */ 5 | 6 | /** 7 | * 请求拦截类 8 | */ 9 | class RequestBlocker { 10 | /** 11 | * 创建请求拦截实例 12 | * @param {Object} config - 广告屏蔽配置 13 | */ 14 | constructor(config) { 15 | this.config = config; 16 | } 17 | 18 | /** 19 | * 拦截XMLHttpRequest和Fetch请求 20 | */ 21 | blockTrackingRequests() { 22 | // 拦截XMLHttpRequest 23 | const originalXHR = XMLHttpRequest.prototype.open; 24 | const config = this.config; 25 | 26 | // 使用普通函数而不是箭头函数,保留正确的this上下文 27 | XMLHttpRequest.prototype.open = function(method, url) { 28 | if (typeof url === 'string' && config.shouldBlockUrl(url)) { 29 | // 返回一个虚拟方法,避免脚本错误 30 | this.send = function(){}; 31 | this.onload = null; 32 | this.onerror = null; 33 | return; 34 | } 35 | return originalXHR.apply(this, arguments); 36 | }; 37 | 38 | // 拦截Fetch请求 39 | const originalFetch = window.fetch; 40 | window.fetch = function(url, options) { 41 | // 处理 Request 对象作为参数的情况 42 | let urlToCheck = url; 43 | if (url instanceof Request) { 44 | urlToCheck = url.url; 45 | } 46 | 47 | if (typeof urlToCheck === 'string' && config.shouldBlockUrl(urlToCheck)) { 48 | // 返回一个解析为空的Response,避免错误 49 | return Promise.resolve(new Response('', { 50 | status: 200, 51 | headers: {'Content-Type': 'text/plain'} 52 | })); 53 | } 54 | return originalFetch.apply(this, arguments); 55 | }; 56 | } 57 | 58 | /** 59 | * 拦截iframe加载 60 | */ 61 | blockIframeLoading() { 62 | const createElementOriginal = document.createElement; 63 | const config = this.config; 64 | 65 | document.createElement = function(tag) { 66 | const element = createElementOriginal.call(document, tag); 67 | if (tag.toLowerCase() === 'iframe') { 68 | // 正确实现src属性的拦截 69 | let originalSrc = element.src; 70 | Object.defineProperty(element, 'src', { 71 | set: function(value) { 72 | if (typeof value === 'string' && config.shouldBlockUrl(value)) { 73 | console.log('拦截iframe:', value); 74 | return; 75 | } 76 | originalSrc = value; 77 | }, 78 | get: function() { 79 | return originalSrc; 80 | } 81 | }); 82 | 83 | // 监控setAttribute 84 | const originalSetAttribute = element.setAttribute; 85 | element.setAttribute = function(name, value) { 86 | if (name === 'src' && typeof value === 'string' && config.shouldBlockUrl(value)) { 87 | console.log('拦截iframe setAttribute:', value); 88 | return; 89 | } 90 | return originalSetAttribute.call(this, name, value); 91 | }; 92 | } 93 | return element; 94 | }; 95 | } 96 | 97 | /** 98 | * 阻止弹窗 99 | */ 100 | blockPopups() { 101 | window.open = function() { return null; }; 102 | if (typeof unsafeWindow !== 'undefined') { 103 | unsafeWindow.open = function() { return null; }; 104 | } 105 | } 106 | 107 | /** 108 | * 初始化所有拦截功能 109 | */ 110 | init() { 111 | this.blockIframeLoading(); 112 | this.blockTrackingRequests(); 113 | this.blockPopups(); 114 | console.log('请求拦截已启用'); 115 | } 116 | } 117 | 118 | export default RequestBlocker; -------------------------------------------------------------------------------- /src/utils/PerformanceMonitor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 性能监测工具类 3 | * 用于测量和记录性能数据 4 | */ 5 | export class PerformanceMonitor { 6 | constructor() { 7 | this.measurements = {}; 8 | this.ongoing = {}; 9 | this.frames = []; 10 | this.listeners = {}; 11 | this.isMonitoringFPS = false; 12 | this.lastFrameTime = 0; 13 | this.frameCount = 0; 14 | } 15 | 16 | /** 17 | * 开始测量 18 | * @param {string} name - 测量名称 19 | */ 20 | startMeasure(name) { 21 | this.ongoing[name] = performance.now(); 22 | } 23 | 24 | /** 25 | * 结束测量并记录结果 26 | * @param {string} name - 测量名称 27 | * @returns {number} - 测量结果(ms) 28 | */ 29 | endMeasure(name) { 30 | if (!this.ongoing[name]) { 31 | console.warn(`未找到测量: ${name}`); 32 | return 0; 33 | } 34 | 35 | const endTime = performance.now(); 36 | const duration = endTime - this.ongoing[name]; 37 | 38 | if (!this.measurements[name]) { 39 | this.measurements[name] = []; 40 | } 41 | 42 | this.measurements[name].push(duration); 43 | delete this.ongoing[name]; 44 | 45 | return duration; 46 | } 47 | 48 | /** 49 | * 开始监测帧率 50 | */ 51 | startFPSMonitoring() { 52 | if (this.isMonitoringFPS) return; 53 | 54 | this.isMonitoringFPS = true; 55 | this.lastFrameTime = performance.now(); 56 | this.frameCount = 0; 57 | this.frames = []; 58 | 59 | const measureFPS = (timestamp) => { 60 | if (!this.isMonitoringFPS) return; 61 | 62 | const now = performance.now(); 63 | const elapsed = now - this.lastFrameTime; 64 | 65 | // 每秒计算一次帧率 66 | if (elapsed > 1000) { 67 | const fps = this.frameCount * 1000 / elapsed; 68 | this.frames.push(fps); 69 | this.frameCount = 0; 70 | this.lastFrameTime = now; 71 | } 72 | 73 | this.frameCount++; 74 | requestAnimationFrame(measureFPS); 75 | }; 76 | 77 | requestAnimationFrame(measureFPS); 78 | } 79 | 80 | /** 81 | * 停止监测帧率 82 | */ 83 | stopFPSMonitoring() { 84 | this.isMonitoringFPS = false; 85 | } 86 | 87 | /** 88 | * 获取平均帧率 89 | * @returns {number} - 平均帧率 90 | */ 91 | getAverageFPS() { 92 | if (this.frames.length === 0) return 0; 93 | 94 | const sum = this.frames.reduce((acc, val) => acc + val, 0); 95 | return sum / this.frames.length; 96 | } 97 | 98 | /** 99 | * 获取指定测量的统计信息 100 | * @param {string} name - 测量名称 101 | * @returns {Object} - 统计信息 102 | */ 103 | getStats(name) { 104 | if (!this.measurements[name] || this.measurements[name].length === 0) { 105 | return { 106 | min: 0, 107 | max: 0, 108 | avg: 0, 109 | count: 0 110 | }; 111 | } 112 | 113 | const values = this.measurements[name]; 114 | const min = Math.min(...values); 115 | const max = Math.max(...values); 116 | const avg = values.reduce((sum, val) => sum + val, 0) / values.length; 117 | 118 | return { 119 | min, 120 | max, 121 | avg, 122 | count: values.length 123 | }; 124 | } 125 | } 126 | 127 | // 创建全局性能监测实例 128 | export const performanceMonitor = new PerformanceMonitor(); -------------------------------------------------------------------------------- /src/adblock/DOMCleaner.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DOM清理类 3 | * 负责移除DOM中的广告元素和监控DOM变化 4 | */ 5 | 6 | /** 7 | * DOM清理类 8 | */ 9 | class DOMCleaner { 10 | /** 11 | * 创建DOM清理实例 12 | * @param {Object} config - 广告屏蔽配置 13 | */ 14 | constructor(config) { 15 | this.config = config; 16 | this.CLEANUP_THROTTLE = 500; // 节流时间:500ms 17 | this.observer = null; // MutationObserver实例 18 | } 19 | 20 | /** 21 | * 清理iframe - 优化为只清理新iframe 22 | * @param {NodeList} iframeElements - 可选的iframe元素列表 23 | */ 24 | cleanIframes(iframeElements = null) { 25 | const iframes = iframeElements || document.getElementsByTagName('iframe'); 26 | for (let i = 0; i < iframes.length; i++) { 27 | const iframe = iframes[i]; 28 | // 只保留播放器相关iframe,移除其他广告iframe 29 | if (iframe.src && !iframe.src.includes('plyr.io')) { 30 | iframe.remove(); 31 | } 32 | } 33 | } 34 | 35 | /** 36 | * 移除广告元素 37 | * @param {boolean} force - 是否强制清理 38 | */ 39 | removeAdElements(force = false) { 40 | if (this.config.adSelectors.length === 0) { 41 | return; // 无选择器,不需要清理 42 | } 43 | 44 | for (let i = 0; i < this.config.adSelectors.length; i++) { 45 | try { 46 | const elements = document.querySelectorAll(this.config.adSelectors[i]); 47 | for (let j = 0; j < elements.length; j++) { 48 | elements[j].remove(); 49 | } 50 | } catch (e) { 51 | // 忽略选择器错误 52 | } 53 | } 54 | } 55 | 56 | /** 57 | * 设置DOM变化监听 58 | */ 59 | observeDOMChanges() { 60 | // 如果已经在观察,则不重复设置 61 | if (this.observer) { 62 | return; 63 | } 64 | 65 | let pendingChanges = false; 66 | let frameChanges = false; 67 | let processingTimeout = null; 68 | 69 | const processChanges = () => { 70 | if (pendingChanges) { 71 | this.removeAdElements(); 72 | pendingChanges = false; 73 | } 74 | if (frameChanges) { 75 | this.cleanIframes(); 76 | frameChanges = false; 77 | } 78 | processingTimeout = null; 79 | }; 80 | 81 | this.observer = new MutationObserver(mutations => { 82 | let hasNewNodes = false; 83 | let hasNewIframes = false; 84 | 85 | // 快速检查是否有相关变化 86 | for (let i = 0; i < mutations.length; i++) { 87 | const mutation = mutations[i]; 88 | if (mutation.addedNodes.length) { 89 | hasNewNodes = true; 90 | // 检查是否有新增的iframe 91 | for (let j = 0; j < mutation.addedNodes.length; j++) { 92 | const node = mutation.addedNodes[j]; 93 | if (node.nodeName === 'IFRAME') { 94 | hasNewIframes = true; 95 | break; 96 | } 97 | } 98 | } 99 | if (hasNewNodes && hasNewIframes) break; // 找到所需信息后立即退出循环 100 | } 101 | 102 | if (hasNewNodes) { 103 | pendingChanges = true; 104 | } 105 | if (hasNewIframes) { 106 | frameChanges = true; 107 | } 108 | 109 | // 使用节流处理DOM变化 110 | if ((pendingChanges || frameChanges) && !processingTimeout) { 111 | processingTimeout = setTimeout(processChanges, 50); 112 | } 113 | }); 114 | 115 | // 开始观察整个文档 116 | this.observer.observe(document.documentElement, { 117 | childList: true, 118 | subtree: true 119 | }); 120 | 121 | console.log('DOM监听已启动'); 122 | } 123 | 124 | /** 125 | * 停止DOM变化监听 126 | */ 127 | disconnect() { 128 | if (this.observer) { 129 | this.observer.disconnect(); 130 | this.observer = null; 131 | console.log('DOM监听已停止'); 132 | } 133 | } 134 | } 135 | 136 | export default DOMCleaner; -------------------------------------------------------------------------------- /src/autologin/LoginManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 登录管理器 - 负责管理多个网站的登录逻辑 3 | */ 4 | import { LoginUtils } from './utils.js'; 5 | import { MissavLoginProvider } from './MissavLoginProvider.js'; 6 | 7 | export class LoginManager { 8 | /** 9 | * 构造函数 10 | */ 11 | constructor() { 12 | // 登录信息 13 | this.userEmail = ''; 14 | this.userPassword = ''; 15 | this.autoLogin = true; 16 | 17 | // 登录提供者列表 18 | this.providers = [ 19 | new MissavLoginProvider() 20 | ]; 21 | 22 | // 当前活跃的提供者 23 | this.activeProvider = null; 24 | } 25 | 26 | /** 27 | * 初始化登录管理器 28 | */ 29 | async init() { 30 | // 加载存储的登录信息 31 | this.loadLoginInfo(); 32 | 33 | // 根据当前URL选择合适的登录提供者 34 | this.activeProvider = this.getMatchingProvider(); 35 | 36 | // 如果没有合适的提供者,不执行后续操作 37 | if (!this.activeProvider) { 38 | console.debug('没有找到匹配的登录提供者'); 39 | return; 40 | } 41 | 42 | // 添加自动登录选项 43 | await this.activeProvider.addAutoLoginOption(this.handleLoginInfoChange.bind(this)); 44 | 45 | // 检查登录状态并执行自动登录 46 | await this.checkLoginAndAutoLogin(); 47 | } 48 | 49 | /** 50 | * 处理登录信息变更 51 | * @param {Object} info - 变更的登录信息 52 | * @param {string} [info.email] - 邮箱 53 | * @param {string} [info.password] - 密码 54 | * @param {boolean} [info.autoLogin] - 是否自动登录 55 | */ 56 | handleLoginInfoChange(info) { 57 | if (info.email !== undefined) { 58 | this.userEmail = info.email; 59 | LoginUtils.setValue('userEmail', info.email); 60 | } 61 | 62 | if (info.password !== undefined) { 63 | this.userPassword = info.password; 64 | LoginUtils.setValue('userPassword', info.password); 65 | } 66 | 67 | if (info.autoLogin !== undefined) { 68 | this.autoLogin = info.autoLogin; 69 | LoginUtils.setValue('autoLogin', info.autoLogin); 70 | } 71 | } 72 | 73 | /** 74 | * 加载存储的登录信息 75 | */ 76 | loadLoginInfo() { 77 | this.userEmail = LoginUtils.getValue('userEmail', ''); 78 | this.userPassword = LoginUtils.getValue('userPassword', ''); 79 | this.autoLogin = LoginUtils.getValue('autoLogin', true); 80 | } 81 | 82 | /** 83 | * 获取匹配当前网站的登录提供者 84 | * @returns {Object|null} 匹配的登录提供者或null 85 | */ 86 | getMatchingProvider() { 87 | for (const provider of this.providers) { 88 | if (provider.isSupportedSite()) { 89 | return provider; 90 | } 91 | } 92 | return null; 93 | } 94 | 95 | /** 96 | * 检查登录状态并执行自动登录 97 | */ 98 | async checkLoginAndAutoLogin() { 99 | if (!this.activeProvider) return; 100 | 101 | try { 102 | // 检查登录状态 103 | const isLoggedIn = await this.activeProvider.checkLoginStatus(); 104 | 105 | // 如果未登录且启用了自动登录,执行登录 106 | if (!isLoggedIn && this.autoLogin && this.userEmail && this.userPassword) { 107 | console.log('用户未登录,尝试自动登录'); 108 | await this.activeProvider.login(this.userEmail, this.userPassword); 109 | } 110 | } catch (error) { 111 | console.error('登录检查过程出错:', error); 112 | } 113 | } 114 | 115 | /** 116 | * 手动执行登录操作 117 | * @param {string} email - 用户邮箱 118 | * @param {string} password - 用户密码 119 | * @returns {Promise} 登录是否成功 120 | */ 121 | async login(email, password) { 122 | if (!this.activeProvider) { 123 | console.error('没有匹配的登录提供者'); 124 | return false; 125 | } 126 | 127 | // 更新登录信息 128 | this.handleLoginInfoChange({ 129 | email, 130 | password 131 | }); 132 | 133 | // 执行登录 134 | return await this.activeProvider.login(email, password); 135 | } 136 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { initCSSVariables } from './constants/index.js'; 2 | import { FloatingButton } from './player/ui/FloatingButton.js'; 3 | import { PlayerState } from './player/state/PlayerState.js'; 4 | import { Utils } from './utils/utils.js'; 5 | import { initAutoLogin } from './autologin/index.js'; 6 | import AdBlocker from './adblock'; 7 | import { initUserExperienceEnhancer, earlyUrlRedirector } from './userExperienceEnhancer'; 8 | import { I18n, __ } from './constants/i18n.js'; 9 | 10 | // 确保最早执行URL重定向检查 11 | earlyUrlRedirector.checkAndRedirect(); 12 | 13 | /** 14 | * 配置viewport以支持iOS安全区域 15 | */ 16 | function setupViewport() { 17 | let viewportMeta = document.querySelector('meta[name="viewport"]'); 18 | 19 | // 如果页面中没有viewport meta标签,则创建一个 20 | if (!viewportMeta) { 21 | viewportMeta = document.createElement('meta'); 22 | viewportMeta.name = 'viewport'; 23 | document.head.appendChild(viewportMeta); 24 | } 25 | 26 | // 更新viewport内容,添加viewport-fit=cover以支持安全区域 27 | viewportMeta.content = 'width=device-width, initial-scale=1.0, viewport-fit=cover'; 28 | console.log(`[${__('scriptName')}] ${__('viewportConfigured')}`); 29 | } 30 | 31 | /** 32 | * 脚本入口函数 33 | */ 34 | (function() { 35 | 'use strict'; 36 | 37 | // 全局状态管理和播放器实例 38 | let playerState = null; 39 | let videoPlayerInstance = null; 40 | 41 | /** 42 | * 注入CSS样式 43 | */ 44 | function injectStyles() { 45 | // 确保样式只注入一次 46 | if (document.getElementById('tm-player-styles')) return; 47 | 48 | // 配置viewport以支持安全区域 49 | setupViewport(); 50 | 51 | // 注入CSS变量和样式 52 | initCSSVariables(); 53 | 54 | // 控制台日志 - 便于调试 55 | console.log(`[${__('scriptName')}] ${__('stylesInjected')}`); 56 | } 57 | 58 | /** 59 | * 启动脚本 60 | */ 61 | async function startScript() { 62 | try { 63 | // 首先注入样式 64 | injectStyles(); 65 | 66 | // 初始化用户体验增强模块(包含URL重定向功能) 67 | // 传递true以跳过重定向检查,因为已经在前面执行过了 68 | const userExperienceEnhancer = initUserExperienceEnhancer(true); 69 | console.log(`[${__('scriptName')}] ${__('enhancerInitialized')}`); 70 | 71 | // 创建状态管理实例 72 | playerState = new PlayerState(); 73 | 74 | // 加载设置 75 | playerState.loadSettings(); 76 | 77 | // 创建浮动按钮实例并初始化 78 | const floatingButton = new FloatingButton({ 79 | playerState 80 | }); 81 | 82 | // 初始化浮动按钮 83 | floatingButton.init(); 84 | 85 | // 初始化自动登录模块 86 | const loginManager = await initAutoLogin(); 87 | if (loginManager) { 88 | console.log(`[${__('scriptName')}] ${__('loginModuleInitialized')}`); 89 | } 90 | 91 | // 初始化广告屏蔽模块 92 | const adBlocker = new AdBlocker(); 93 | adBlocker.init(); 94 | 95 | // 控制台日志 - 便于调试 96 | console.log(`[${__('scriptName')}] ${__('initializationComplete')}`); 97 | } catch (error) { 98 | console.error(`[${__('scriptName')}] ${__('initializationFailed')}:`, error); 99 | } 100 | } 101 | 102 | // 确保在 document idle 时执行 103 | if (document.readyState === 'complete' || document.readyState === 'interactive') { 104 | setTimeout(startScript, 100); // 延迟100ms确保DOM完全加载 105 | } else { 106 | document.addEventListener('DOMContentLoaded', () => setTimeout(startScript, 100)); 107 | } 108 | })(); 109 | 110 | /** 111 | * 统一导出API,使用模块化版本 112 | */ 113 | export { CustomVideoPlayer as default } from './player/index.js'; 114 | 115 | // 导出工具类和其他组件 116 | export { Utils } from './utils/utils.js'; 117 | export { VideoSwipeManager } from './player/managers/videoSwipeManager.js'; 118 | export { UserExperienceEnhancer } from './userExperienceEnhancer'; 119 | export { I18n, __ } from './constants/i18n.js'; 120 | 121 | // 为兼容性考虑,同时导出为ModularVideoPlayer 122 | export { CustomVideoPlayer as ModularVideoPlayer } from './player/index.js'; -------------------------------------------------------------------------------- /devin_suggestion.md: -------------------------------------------------------------------------------- 1 | # Miss_Player v5.1.6 优化分析报告 2 | 3 | ## 项目概述 4 | 根据对 Miss_Player 项目代码的分析,以下是按优先级排序的优化建议。 5 | 6 | ## 一、高优先级优化 7 | 8 | ### 1. 性能优化 9 | - **减少DOM操作与事件监听** 10 | - 问题:UIManager 中包含过多的DOM操作和事件监听器,可能导致页面渲染性能降低 11 | - 位置:`UIManager.js:493-597` 12 | - 1. DOM操作与事件监听过多 13 | - 问题分析: 14 | - UIManager.js中存在大量的事件监听器(mousemove、touchmove、touchstart、mouseenter、mouseleave等) 15 | - 多个相似的事件监听器分别绑定到不同元素上,逻辑重复 16 | - 每个监听事件中都执行相似的控制面板显示/隐藏逻辑 17 | - 优化方案: 18 | - 使用事件委托模式,将事件绑定到共同的父元素上,减少事件监听器数量 19 | - 合并相似功能的监听器,比如多个mouseenter/mouseleave事件可以统一处理 20 | - 使用IntersectionObserver代替手动监听鼠标位置判断元素是否可见 21 | - 减少不必要的DOM查询和样式修改,可以缓存DOM元素引用 22 | - 2. 定时器逻辑优化 23 | - 问题分析: 24 | - 控制面板自动隐藏使用了setTimeout,并且在多处重复设置和清除 25 | - 每次用户交互都会触发定时器的重置,导致频繁的定时器操作 26 | - 优化方案: 27 | - 使用requestAnimationFrame替代setTimeout,更符合浏览器渲染周期 28 | - 实现节流(throttle)机制,限制定时器重置的频率 29 | - 使用单一的定时器管理机制,避免多处创建和清除定时器的冗余代码 30 | - 考虑使用CSS动画代替JavaScript控制的显示/隐藏效果 31 | - 3. 屏幕方向变化处理优化 32 | - 问题分析: 33 | - 屏幕方向变化时执行了大量样式修改和DOM操作 34 | - 每次方向变化都重新计算和应用多个元素的样式 35 | - 优化方案: 36 | - 使用CSS媒体查询(media queries)处理屏幕方向变化,减少JavaScript干预 37 | - 预先定义横竖屏样式类,仅通过切换类名来改变样式,避免直接操作style属性 38 | - 减少不必要的重复计算,将视频比例计算结果缓存 39 | 40 | - **优化定时器逻辑** 41 | - 问题:控制面板的自动隐藏逻辑和长按检测使用了多个定时器 42 | - 建议:考虑使用 requestAnimationFrame 等更高效的方式 43 | - 位置:`UIManager.js:814-830` 44 | 45 | ### 2. 代码结构优化 46 | - **拆分大型类文件** 47 | - 问题:UIManager.js 有900多行代码,职责过多 48 | - 建议:拆分为多个专注于特定功能的小型组件 49 | - 位置:`UIManager.js:1-903` 50 | 51 | - **简化初始化流程** 52 | - 问题:CustomVideoPlayer 的 init 方法包含了过多逻辑 53 | - 建议:提取成多个小方法 54 | - 位置:`CustomVideoPlayer.js:35-168` 55 | 56 | ## 二、中优先级优化 57 | 58 | ### 3. 用户体验改进 59 | - **优化控制界面交互** 60 | - 问题:横屏模式下控制界面的显示/隐藏逻辑可能导致用户操作被中断 61 | - 位置:`UIManager.js:617-653` 62 | 63 | - **改进长按加速功能** 64 | - 建议:优化交互方式,添加更明显的视觉反馈 65 | - 位置:`UIManager.js:157-236` 66 | 67 | ### 4. 构建系统优化 68 | - **启用代码分割** 69 | - 问题:目前所有功能都打包在一个文件中 70 | - 建议:使用代码分割减小主包体积 71 | - 位置:`webpack.config.js:189-192` 72 | 73 | - **优化压缩配置** 74 | - 问题:webpack配置中禁用了许多压缩选项 75 | - 建议:重新评估可启用的压缩选项 76 | - 位置:`webpack.config.js:133-144` 77 | 78 | ## 三、低优先级优化 79 | 80 | ### 5. 兼容性和可访问性 81 | - **增强键盘操作支持** 82 | - 添加键盘快捷键支持,实现键盘控制播放器功能 83 | - **添加ARIA属性** 84 | - 为控制按钮和交互元素添加适当的ARIA属性 85 | - 提高屏幕阅读器兼容性 86 | 87 | ### 6. 测试覆盖与文档完善 88 | - **添加自动化测试** 89 | - 实现单元测试和集成测试 90 | - 确保代码变更不会引入回归问题 91 | - **完善文档** 92 | - 添加详细的API文档 93 | - 补充使用说明,便于维护和协作 94 | 95 | ## 优先级执行计划 96 | 1. **立即处理**:性能优化和代码结构改进 97 | 2. **近期规划**:用户体验改进和构建系统优化 98 | 3. **长期计划**:兼容性、可访问性、测试和文档完善 99 | 100 | ## 注意事项 101 | - 优化建议基于当前代码库分析 102 | - 建议采用渐进式改进策略,避免大规模重构风险 103 | - 部分优化(如代码分割)需要权衡文件大小和加载性能 104 | - 需特别考虑油猴脚本运行环境的特殊限制 105 | 106 | --- 107 | 108 | 根据查看的项目代码和结构,我现在可以分析该项目需要优化的地方: 109 | 110 | ## 项目需要优化的地方 111 | 112 | 1. **性能监测和度量实现**: 113 | - 已经有了 `PerformanceMonitor.js` 基本工具类,但在关键组件中缺乏系统性的集成和使用 114 | - 需要在更多的关键流程中添加性能指标测量,特别是用户交互和视频加载渲染过程 115 | 116 | 2. **代码拆分和模块化**: 117 | - `ControlManager.js` 文件过大(1296行),应考虑拆分为多个更小的管理器模块 118 | - 可以将播放控制、音量控制、进度条控制拆分为独立模块 119 | 120 | 3. **事件监听器优化**: 121 | - 部分组件中存在冗余的事件监听器绑定 122 | - 可以使用事件委托来减少事件监听器数量 123 | - 缺少适当的事件监听器清理机制,可能导致内存泄漏 124 | 125 | 4. **DOM操作优化**: 126 | - 存在频繁的 DOM 更新操作,尤其是进度条和时间显示 127 | - 可以使用虚拟 DOM 或者使用 requestAnimationFrame 来优化更新 128 | 129 | 5. **CSS 渲染优化**: 130 | - 频繁的样式切换可能导致不必要的重绘和回流 131 | - CSS 文件较大,可以考虑按需加载或减少复杂选择器 132 | 133 | 6. **移动设备适配优化**: 134 | - 对 iOS 等移动设备的优化不够完善 135 | - 触摸交互响应需要优化 136 | 137 | 7. **资源加载优化**: 138 | - 脚本加载没有使用异步或延迟加载策略 139 | - 缺少资源预加载机制 140 | 141 | 8. **缓存策略优化**: 142 | - 缺少有效的数据缓存策略,特别是用户设置和频繁访问的数据 143 | 144 | 9. **错误处理和恢复机制**: 145 | - 缺少全面的错误处理和用户友好的错误提示 146 | - 播放错误时的恢复机制不够健壮 147 | 148 | 10. **内存管理优化**: 149 | - 视频资源和大型 DOM 结构的内存管理需要改进 150 | - 可以引入垃圾回收辅助机制 151 | 152 | 11. **代码质量优化**: 153 | - 部分方法过长,职责不够单一 154 | - 命名约定不够统一 155 | 156 | 12. **用户体验优化**: 157 | - 加载过程中的用户反馈不够明确 158 | - 缺少网络状况不佳时的优化策略 159 | 160 | 13. **测试覆盖优化**: 161 | - 缺少自动化测试和性能基准测试 162 | 163 | 14. **兼容性优化**: 164 | - 不同浏览器之间的兼容性处理可以更完善 165 | 166 | 基于以上分析,推荐实施的优化方案: 167 | 168 | 1. 完善性能监测系统,集成到关键组件中 169 | 2. 拆分大型文件如 ControlManager.js 为多个功能单一的小模块 170 | 3. 重构事件处理系统,引入事件委托和生命周期管理 171 | 4. 优化频繁 DOM 操作,使用 requestAnimationFrame 控制更新频率 172 | 5. 优化 CSS 选择器和动画效果 173 | 6. 增强移动设备的触摸交互体验 174 | 7. 实现资源的懒加载和预加载策略 175 | 8. 建立更完善的缓存系统 176 | 9. 增强错误处理和恢复机制 177 | 10. 重构代码,提高可维护性和可测试性 178 | 179 | 这些优化将显著提升播放器的性能,改善用户体验,并增强代码的可维护性。 180 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 常用工具函数集合 3 | */ 4 | 5 | /** 6 | * 工具函数对象 7 | */ 8 | export const Utils = { 9 | /** 10 | * Toast 通知函数 11 | * @param {string} msg - 消息内容 12 | * @param {number} duration - 显示时长(毫秒) 13 | * @param {string} bgColor - 背景颜色 14 | * @param {string} textColor - 文字颜色 15 | * @param {string} position - 位置(top/bottom/center) 16 | */ 17 | Toast(msg, duration = 3000, bgColor = 'rgba(0, 0, 0, 0.8)', textColor = '#fff', position = 'top') { 18 | let toast = document.createElement('div'); 19 | toast.innerText = msg; 20 | toast.style.cssText = ` 21 | position: fixed; 22 | z-index: 100000; 23 | left: 50%; 24 | transform: translateX(-50%); 25 | padding: 10px 15px; 26 | border-radius: 4px; 27 | color: ${textColor}; 28 | background: ${bgColor}; 29 | font-size: 14px; 30 | max-width: 80%; 31 | text-align: center; 32 | word-break: break-all; 33 | `; 34 | 35 | // 设置位置 36 | if (position === 'top') { 37 | toast.style.top = '10%'; 38 | } else if (position === 'bottom') { 39 | toast.style.bottom = '10%'; 40 | } else if (position === 'center') { 41 | toast.style.top = '50%'; 42 | toast.style.transform = 'translate(-50%, -50%)'; 43 | } 44 | 45 | document.body.appendChild(toast); 46 | 47 | setTimeout(() => { 48 | toast.style.opacity = '0'; 49 | toast.style.transition = 'opacity 0.5s'; 50 | setTimeout(() => { 51 | document.body.removeChild(toast); 52 | }, 500); 53 | }, duration); 54 | }, 55 | 56 | /** 57 | * 节流函数 - 限制函数执行频率 58 | * @param {Function} fn - 需要节流的函数 59 | * @param {number} delay - 延迟时间(毫秒) 60 | * @returns {Function} 节流后的函数 61 | */ 62 | throttle(fn, delay) { 63 | let lastCall = 0; 64 | return function(...args) { 65 | const now = Date.now(); 66 | if (now - lastCall >= delay) { 67 | lastCall = now; 68 | return fn.apply(this, args); 69 | } 70 | }; 71 | }, 72 | 73 | /** 74 | * 等待DOM元素出现 75 | * @param {string} selector - CSS选择器 76 | * @param {number} timeout - 超时时间(毫秒) 77 | * @param {number} interval - 检查间隔(毫秒) 78 | * @returns {Promise} DOM元素 79 | */ 80 | waitForElement(selector, timeout = 10000, interval = 100) { 81 | return new Promise((resolve, reject) => { 82 | const element = document.querySelector(selector); 83 | if (element) { 84 | return resolve(element); 85 | } 86 | 87 | const start = Date.now(); 88 | const intervalId = setInterval(() => { 89 | const element = document.querySelector(selector); 90 | if (element) { 91 | clearInterval(intervalId); 92 | return resolve(element); 93 | } 94 | 95 | if (Date.now() - start > timeout) { 96 | clearInterval(intervalId); 97 | reject(new Error(`等待元素 ${selector} 超时`)); 98 | } 99 | }, interval); 100 | }); 101 | }, 102 | 103 | /** 104 | * 动态加载脚本 105 | * @param {string} url - 脚本URL 106 | * @returns {Promise} 加载完成的Promise 107 | */ 108 | loadScript(url) { 109 | return new Promise((resolve, reject) => { 110 | const script = document.createElement('script'); 111 | script.src = url; 112 | script.onload = () => resolve(); 113 | script.onerror = (e) => reject(new Error(`脚本加载失败: ${url}`)); 114 | document.head.appendChild(script); 115 | }); 116 | }, 117 | 118 | /** 119 | * 检查元素是否在视口中 120 | * @param {Element} element - 要检查的元素 121 | * @returns {boolean} 是否在视口中 122 | */ 123 | isInViewport(element) { 124 | const rect = element.getBoundingClientRect(); 125 | return ( 126 | rect.top >= 0 && 127 | rect.left >= 0 && 128 | rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && 129 | rect.right <= (window.innerWidth || document.documentElement.clientWidth) 130 | ); 131 | }, 132 | 133 | /** 134 | * 格式化时间 135 | * @param {number} seconds - 秒数 136 | * @returns {string} 格式化后的时间字符串 137 | */ 138 | formatTime(seconds) { 139 | const h = Math.floor(seconds / 3600); 140 | const m = Math.floor((seconds % 3600) / 60); 141 | const s = Math.floor(seconds % 60); 142 | 143 | const hDisplay = h > 0 ? String(h).padStart(2, '0') + ':' : ''; 144 | const mDisplay = String(m).padStart(2, '0') + ':'; 145 | const sDisplay = String(s).padStart(2, '0'); 146 | 147 | return hDisplay + mDisplay + sDisplay; 148 | } 149 | }; 150 | 151 | // 导出其他工具,如有需要 152 | export * from './otherUtils'; // 确保这行存在时,otherUtils.js文件已存在 -------------------------------------------------------------------------------- /src/autologin/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 工具函数集合 - 自动登录模块专用 3 | */ 4 | export class LoginUtils { 5 | /** 6 | * 显示Toast消息通知 7 | * @param {string} msg - 要显示的消息 8 | * @param {number} [duration=3000] - 显示持续时间(毫秒) 9 | * @param {string} [bgColor='rgba(0, 0, 0, 0.8)'] - 背景颜色 10 | * @param {string} [textColor='#fff'] - 文字颜色 11 | * @param {string} [position='top'] - 位置(top/bottom/center) 12 | */ 13 | static toast(msg, duration = 3000, bgColor = 'rgba(0, 0, 0, 0.8)', textColor = '#fff', position = 'top') { 14 | let toast = document.createElement('div'); 15 | toast.innerText = msg; 16 | toast.style.cssText = ` 17 | position: fixed; 18 | z-index: 100000; 19 | left: 50%; 20 | transform: translateX(-50%); 21 | padding: 10px 15px; 22 | border-radius: 4px; 23 | color: ${textColor}; 24 | background: ${bgColor}; 25 | font-size: 14px; 26 | max-width: 80%; 27 | text-align: center; 28 | word-break: break-all; 29 | `; 30 | 31 | // 设置位置 32 | if (position === 'top') { 33 | toast.style.top = '10%'; 34 | } else if (position === 'bottom') { 35 | toast.style.bottom = '10%'; 36 | } else if (position === 'center') { 37 | toast.style.top = '50%'; 38 | toast.style.transform = 'translate(-50%, -50%)'; 39 | } 40 | 41 | document.body.appendChild(toast); 42 | 43 | setTimeout(() => { 44 | toast.style.opacity = '0'; 45 | toast.style.transition = 'opacity 0.5s'; 46 | setTimeout(() => { 47 | document.body.removeChild(toast); 48 | }, 500); 49 | }, duration); 50 | } 51 | 52 | /** 53 | * 节流函数 - 限制函数执行频率 54 | * @param {Function} fn - 要执行的函数 55 | * @param {number} delay - 延迟时间(ms) 56 | * @returns {Function} - 节流后的函数 57 | */ 58 | static throttle(fn, delay) { 59 | let lastCall = 0; 60 | return function(...args) { 61 | const now = Date.now(); 62 | if (now - lastCall >= delay) { 63 | lastCall = now; 64 | return fn.apply(this, args); 65 | } 66 | }; 67 | } 68 | 69 | /** 70 | * 等待DOM元素出现 71 | * @param {string} selector - CSS选择器 72 | * @param {number} [timeout=10000] - 超时时间(ms) 73 | * @param {number} [interval=100] - 检查间隔(ms) 74 | * @returns {Promise} - 返回找到的元素 75 | */ 76 | static waitForElement(selector, timeout = 10000, interval = 100) { 77 | return new Promise((resolve, reject) => { 78 | const element = document.querySelector(selector); 79 | if (element) { 80 | return resolve(element); 81 | } 82 | 83 | const start = Date.now(); 84 | const intervalId = setInterval(() => { 85 | const element = document.querySelector(selector); 86 | if (element) { 87 | clearInterval(intervalId); 88 | return resolve(element); 89 | } 90 | 91 | if (Date.now() - start > timeout) { 92 | clearInterval(intervalId); 93 | reject(new Error(`等待元素 ${selector} 超时`)); 94 | } 95 | }, interval); 96 | }); 97 | } 98 | 99 | /** 100 | * 获取本地存储的数据 101 | * @param {string} key - 存储键名 102 | * @param {*} defaultValue - 默认值 103 | * @returns {*} - 存储的值或默认值 104 | */ 105 | static getValue(key, defaultValue) { 106 | try { 107 | // 优先使用localStorage 108 | const value = localStorage.getItem(`autologin_${key}`); 109 | if (value !== null) { 110 | try { 111 | return JSON.parse(value); 112 | } catch (e) { 113 | return value; 114 | } 115 | } 116 | return defaultValue; 117 | } catch (e) { 118 | console.error('获取存储值失败:', e); 119 | return defaultValue; 120 | } 121 | } 122 | 123 | /** 124 | * 设置本地存储数据 125 | * @param {string} key - 存储键名 126 | * @param {*} value - 要存储的值 127 | */ 128 | static setValue(key, value) { 129 | try { 130 | const storageValue = typeof value === 'object' ? JSON.stringify(value) : value; 131 | localStorage.setItem(`autologin_${key}`, storageValue); 132 | } catch (e) { 133 | console.error('设置存储值失败:', e); 134 | } 135 | } 136 | } -------------------------------------------------------------------------------- /src/player/managers/DragManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 拖动管理器类 - 负责拖动和大小调整功能 3 | */ 4 | export class DragManager { 5 | constructor(playerCore, uiElements) { 6 | // 核心引用 7 | this.playerCore = playerCore; 8 | this.targetVideo = playerCore.targetVideo; 9 | 10 | // UI元素引用 11 | this.uiElements = uiElements; 12 | this.container = uiElements.container; 13 | this.handle = uiElements.handle; 14 | 15 | // 拖动状态管理 16 | this.isDraggingHandle = false; // 是否正在拖动句柄 17 | this.startX = 0; 18 | this.startY = 0; 19 | this.startWidth = 0; 20 | this.startHeight = 0; 21 | this.handleMoveHandler = null; // 句柄移动事件处理函数 22 | this.handleEndHandler = null; // 句柄释放事件处理函数 23 | } 24 | 25 | /** 26 | * 初始化拖动管理器 27 | */ 28 | init() { 29 | // 设置拖动处理事件 30 | this.handle.addEventListener('mousedown', this.startHandleDrag.bind(this)); 31 | this.handle.addEventListener('touchstart', this.startHandleDrag.bind(this), { passive: false }); 32 | 33 | return this; 34 | } 35 | 36 | /** 37 | * 更新手柄位置 38 | */ 39 | updateHandlePosition() { 40 | if (!this.uiElements.handleContainer || !this.container) return; 41 | 42 | // 获取视频容器的位置信息 43 | const containerRect = this.container.getBoundingClientRect(); 44 | const videoWrapperRect = this.uiElements.videoWrapper.getBoundingClientRect(); 45 | 46 | // 设置手柄位置在视频容器下方 47 | this.uiElements.handleContainer.style.top = `${videoWrapperRect.bottom}px`; 48 | } 49 | 50 | /** 51 | * 开始手柄拖动 (只处理Y轴,X轴由swipeManager处理) 52 | */ 53 | startHandleDrag(e) { 54 | this.isDraggingHandle = true; 55 | this.handle.style.cursor = 'grabbing'; 56 | 57 | const touch = e.type.includes('touch'); 58 | this.startY = touch ? e.touches[0].clientY : e.clientY; 59 | this.startHeight = this.container.offsetHeight; 60 | 61 | const moveHandler = this._handleDragMove.bind(this); 62 | const endHandler = this._handleDragEnd.bind(this); 63 | 64 | if (touch) { 65 | document.addEventListener('touchmove', moveHandler, { passive: false }); 66 | document.addEventListener('touchend', endHandler); 67 | document.addEventListener('touchcancel', endHandler); 68 | } else { 69 | document.addEventListener('mousemove', moveHandler); 70 | document.addEventListener('mouseup', endHandler); 71 | } 72 | 73 | // 存储事件处理函数以便移除 74 | this.handleMoveHandler = moveHandler; 75 | this.handleEndHandler = endHandler; 76 | 77 | e.preventDefault(); 78 | } 79 | 80 | /** 81 | * 手柄移动处理 (只处理Y轴) 82 | */ 83 | _handleDragMove(e) { 84 | if (!this.isDraggingHandle) return; 85 | e.preventDefault(); 86 | 87 | const touch = e.type.includes('touch'); 88 | const currentY = touch ? e.touches[0].clientY : e.clientY; 89 | const deltaY = currentY - this.startY; 90 | 91 | // 获取容器当前的最小高度作为约束 92 | const minHeight = parseFloat(this.container.style.minHeight) || window.innerWidth * (9/16); 93 | 94 | // 处理Y轴 (调整高度) 95 | const newHeight = Math.max(minHeight, this.startHeight + deltaY); 96 | this.container.style.height = newHeight + 'px'; 97 | 98 | // updateHandlePosition会被ResizeObserver自动调用 99 | } 100 | 101 | /** 102 | * 手柄拖动结束 103 | */ 104 | _handleDragEnd(e) { 105 | if (!this.isDraggingHandle) return; 106 | this.isDraggingHandle = false; 107 | this.handle.style.cursor = 'grab'; 108 | 109 | // 移除监听器 110 | document.removeEventListener('touchmove', this.handleMoveHandler); 111 | document.removeEventListener('touchend', this.handleEndHandler); 112 | document.removeEventListener('touchcancel', this.handleEndHandler); 113 | document.removeEventListener('mousemove', this.handleMoveHandler); 114 | document.removeEventListener('mouseup', this.handleEndHandler); 115 | 116 | // 清理存储的引用 117 | this.handleMoveHandler = null; 118 | this.handleEndHandler = null; 119 | 120 | if (e.type.startsWith('touch')) { 121 | e.preventDefault(); 122 | } 123 | } 124 | 125 | /** 126 | * 处理鼠标按下事件 127 | */ 128 | handleMouseDown(event) { 129 | if (event.button !== 0) return; // 只处理左键点击 130 | 131 | this.isDraggingHandle = true; 132 | this.startY = event.clientY; 133 | this.startHeight = this.uiElements.handleContainer.offsetHeight; 134 | this.handleMoveHandler = this.handleMouseMove.bind(this); 135 | this.handleEndHandler = this.handleMouseUp.bind(this); 136 | 137 | // 添加事件监听器 138 | document.addEventListener('mousemove', this.handleMoveHandler); 139 | document.addEventListener('mouseup', this.handleEndHandler); 140 | 141 | // 更新手柄位置 142 | this.updateHandlePosition(); 143 | } 144 | 145 | /** 146 | * 处理鼠标移动事件 147 | */ 148 | handleMouseMove(event) { 149 | if (!this.isDraggingHandle) return; 150 | 151 | const deltaY = event.clientY - this.startY; 152 | const newHeight = this.startHeight + deltaY; 153 | 154 | if (newHeight < 50 || newHeight > 200) return; // 限制手柄高度范围 155 | 156 | this.uiElements.handleContainer.style.height = `${newHeight}px`; 157 | this.updateHandlePosition(); 158 | } 159 | 160 | /** 161 | * 处理鼠标释放事件 162 | */ 163 | handleMouseUp(event) { 164 | this.isDraggingHandle = false; 165 | 166 | // 移除事件监听器 167 | document.removeEventListener('mousemove', this.handleMoveHandler); 168 | document.removeEventListener('mouseup', this.handleEndHandler); 169 | 170 | // 更新手柄位置 171 | this.updateHandlePosition(); 172 | } 173 | 174 | /** 175 | * 处理鼠标离开事件 176 | */ 177 | handleMouseLeave(event) { 178 | this.isDraggingHandle = false; 179 | 180 | // 移除事件监听器 181 | document.removeEventListener('mousemove', this.handleMoveHandler); 182 | document.removeEventListener('mouseup', this.handleEndHandler); 183 | 184 | // 更新手柄位置 185 | this.updateHandlePosition(); 186 | } 187 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const WebpackUserscript = require('webpack-userscript').default; 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 6 | const package = require('./package.json'); 7 | 8 | module.exports = { 9 | entry: './src/index.js', 10 | mode: 'production', 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | filename: 'miss_player.user.js', 14 | // 不设置chunkFilename,确保不会生成额外的chunk文件 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | use: { 22 | loader: 'babel-loader', 23 | options: { 24 | presets: ['@babel/preset-env'], 25 | plugins: ['transform-remove-console', 'transform-remove-debugger'] 26 | } 27 | } 28 | }, 29 | { 30 | test: /\.css$/, 31 | use: [ 32 | 'style-loader', 33 | { 34 | loader: 'css-loader', 35 | options: { 36 | sourceMap: false, 37 | importLoaders: 2, 38 | import: { 39 | filter: (url, media, resourcePath) => { 40 | // 如果URL包含注释符号,不导入 41 | if (url.includes('/*') || url.includes('*/')) { 42 | return false; 43 | } 44 | return true; 45 | }, 46 | }, 47 | modules: false, 48 | } 49 | }, 50 | { 51 | loader: 'postcss-loader', 52 | options: { 53 | postcssOptions: { 54 | plugins: [ 55 | [ 56 | 'postcss-discard-comments', 57 | { 58 | removeAll: true, 59 | }, 60 | ], 61 | ], 62 | }, 63 | }, 64 | } 65 | ] 66 | } 67 | ] 68 | }, 69 | plugins: [ 70 | new CleanWebpackPlugin(), 71 | new WebpackUserscript({ 72 | headers: { 73 | name: 'Miss Player | 影院模式 (单手播放器)', 74 | namespace: 'loadingi.local', 75 | version: '5.1.6', 76 | description: "MissAV去广告|单手模式|MissAV自动展开详情|MissAV自动高画质|MissAV重定向支持|MissAV自动登录|定制播放器|多语言支持 支持 jable po*nhub 等通用", 77 | author: 'Chris_C', 78 | match: [ 79 | '*://*.missav.ws/*', 80 | '*://*.missav.ai/*', 81 | '*://*.jable.tv/*', 82 | '*://*/*', 83 | ], 84 | icon: 'https://missav.ws/img/favicon.ico', 85 | grant: [ 86 | 'none' 87 | ], 88 | 'run-at': 'document-start', 89 | noframes: true, 90 | license: package.license 91 | }, 92 | proxyScript: { 93 | filename: '[basename].proxy.user.js', 94 | enable: false 95 | }, 96 | i18n: { 97 | en: { 98 | name: 'Miss Player | Theater Mode (One-handed Player)', 99 | description: "MissAV ad-free|one-handed mode|MissAV auto-expand details|MissAV auto high quality|MissAV redirect support|MissAV auto login|custom player|multilingual support for jable po*nhub etc." 100 | }, 101 | 'zh-CN': { 102 | name: 'Miss Player | 影院模式 (单手播放器)', 103 | description: "MissAV去广告|单手模式|MissAV自动展开详情|MissAV自动高画质|MissAV重定向支持|MissAV自动登录|定制播放器|多语言支持 支持 jable po*nhub 等通用" 104 | }, 105 | 'zh-TW': { 106 | name: 'Miss Player | 影院模式 (單手播放器)', 107 | description: "MissAV去廣告|單手模式|MissAV自動展開詳情|MissAV自動高畫質|MissAV重定向支持|MissAV自動登錄|定制播放器|多語言支持 支持 jable po*nhub 等通用" 108 | }, 109 | ja: { 110 | name: 'Miss Player | シアターモード (片手プレーヤー)', 111 | description: "MissAV広告ブロック|片手モード|MissAV自動詳細表示|MissAV自動高画質|MissAVリダイレクト対応|MissAV自動ログイン|カスタムプレーヤー|jable po*nhubなどに対応した多言語サポート" 112 | }, 113 | vi: { 114 | name: 'Miss Player | Chế Độ Rạp Hát (Trình Phát Một Tay)', 115 | description: "MissAV không quảng cáo|chế độ một tay|MissAV tự động mở rộng chi tiết|MissAV tự động chất lượng cao|Hỗ trợ chuyển hướng MissAV|MissAV tự động đăng nhập|trình phát tùy chỉnh|hỗ trợ đa ngôn ngữ cho jable po*nhub v.v." 116 | } 117 | } 118 | }) 119 | ], 120 | optimization: { 121 | minimize: true, 122 | minimizer: [ 123 | new TerserPlugin({ 124 | parallel: true, 125 | extractComments: false, 126 | terserOptions: { 127 | ecma: 5, 128 | parse: {}, 129 | compress: { 130 | drop_console: true, 131 | drop_debugger: true, 132 | pure_funcs: ['console.log', 'console.info', 'console.debug', 'console.warn'], 133 | // 以下选项设置为 false 可以减少代码压缩,保留更多原始结构 134 | sequences: false, 135 | conditionals: false, 136 | comparisons: false, 137 | evaluate: false, 138 | booleans: false, 139 | loops: false, 140 | unused: false, 141 | toplevel: false, 142 | if_return: false, 143 | inline: false, 144 | join_vars: false 145 | }, 146 | mangle: { 147 | keep_classnames: true, 148 | keep_fnames: true, 149 | properties: false 150 | }, 151 | format: { 152 | beautify: true, 153 | indent_level: 2, 154 | indent_start: 0, 155 | quote_keys: true, 156 | keep_quoted_props: true, 157 | ascii_only: false, 158 | braces: true, 159 | // 只移除注释 160 | comments: false 161 | } 162 | } 163 | }), 164 | new CssMinimizerPlugin({ 165 | minimizerOptions: { 166 | preset: [ 167 | 'default', 168 | { 169 | discardComments: { removeAll: true }, 170 | // 禁用CSS压缩相关选项,保留格式 171 | normalizeWhitespace: false, 172 | mergeRules: false, 173 | mergeLonghand: false, 174 | minifyFontValues: false, 175 | minifyParams: false, 176 | minifySelectors: false, 177 | minifyGradients: false, 178 | cssDeclarationSorter: false, 179 | reduceInitial: false, 180 | reduceIdents: false, 181 | zindex: false, 182 | calc: false 183 | }, 184 | ], 185 | }, 186 | test: /\.css$/, 187 | }), 188 | ], 189 | // 禁用代码分割,确保不会产生额外的chunks 190 | splitChunks: false, 191 | runtimeChunk: false 192 | }, 193 | }; -------------------------------------------------------------------------------- /src/player/CustomVideoPlayer.js: -------------------------------------------------------------------------------- 1 | import { PlayerCore } from './core/PlayerCore.js'; 2 | import { UIManager } from './ui/UIManager.js'; 3 | import { ControlManager } from './managers/ControlManager.js'; 4 | import { DragManager } from './managers/DragManager.js'; 5 | import { LoopManager } from './managers/LoopManager.js'; 6 | import { ProgressManager } from './managers/ProgressManager.js'; 7 | import { EventManager } from './managers/EventManager.js'; 8 | import { SettingsManager } from './managers/SettingsManager.js'; 9 | import { VideoSwipeManager } from './managers/videoSwipeManager.js'; 10 | import { Utils } from '../utils/utils.js'; 11 | 12 | /** 13 | * 自定义视频播放器控制器 - 模块化重构版本 14 | */ 15 | export class CustomVideoPlayer { 16 | constructor(options = {}) { 17 | console.log('[CustomVideoPlayer] 初始化...'); 18 | 19 | // 创建核心播放器 20 | this.playerCore = new PlayerCore(options); 21 | 22 | // 保存调用按钮引用 23 | this.callingButton = options.callingButton || null; 24 | 25 | // 初始化管理器 26 | this.managers = {}; 27 | 28 | // 初始状态 29 | this.initialized = false; 30 | } 31 | 32 | /** 33 | * 初始化播放器 34 | */ 35 | init() { 36 | if (this.initialized) return; 37 | 38 | // 如果PlayerCore不存在,重新创建 39 | if (!this.playerCore) { 40 | this.playerCore = new PlayerCore({ 41 | callingButton: this.callingButton 42 | }); 43 | } 44 | 45 | // 初始化核心播放器 46 | this.playerCore.init(); 47 | 48 | if (!this.playerCore.targetVideo) { 49 | console.error('[CustomVideoPlayer] 核心初始化失败: 未找到视频元素'); 50 | // 如果是从浮动按钮调用的,则重新显示按钮 51 | if (this.callingButton) { 52 | this.callingButton.style.display = 'flex'; 53 | } 54 | return; 55 | } 56 | 57 | // 创建UI管理器 58 | const uiManager = new UIManager(this.playerCore); 59 | const uiElements = uiManager.createUI(); 60 | this.managers.uiManager = uiManager; 61 | 62 | // 创建设置管理器 63 | const settingsManager = new SettingsManager(this.playerCore, uiElements); 64 | settingsManager.init(); 65 | this.managers.settingsManager = settingsManager; 66 | 67 | // 创建控制管理器 68 | const controlManager = new ControlManager(this.playerCore, uiElements); 69 | const progressControls = controlManager.createProgressControls(); 70 | const controlButtons = controlManager.createControlButtonsContainer(); 71 | this.managers.controlManager = controlManager; 72 | 73 | // 将控制管理器设置到playerCore上,以便UIManager可以访问到它 74 | this.playerCore.controlManager = controlManager; 75 | 76 | // 创建进度管理器 77 | const progressManager = new ProgressManager(this.playerCore, uiElements); 78 | progressManager.init({ 79 | progressBarElement: controlManager.progressBarElement, 80 | progressIndicator: controlManager.progressIndicator, 81 | currentTimeDisplay: controlManager.currentTimeDisplay, 82 | totalDurationDisplay: controlManager.totalDurationDisplay, 83 | timeIndicator: controlManager.timeIndicator 84 | }); 85 | this.managers.progressManager = progressManager; 86 | 87 | // 创建循环管理器 88 | const loopManager = new LoopManager(this.playerCore, uiElements); 89 | loopManager.init({ 90 | loopStartMarker: controlManager.loopStartMarker, 91 | loopEndMarker: controlManager.loopEndMarker, 92 | loopRangeElement: controlManager.loopRangeElement, 93 | currentPositionDisplay: controlManager.currentPositionDisplay, 94 | durationDisplay: controlManager.durationDisplay, 95 | loopToggleButton: controlManager.loopToggleButton 96 | }); 97 | this.managers.loopManager = loopManager; 98 | 99 | // 设置循环管理器引用到控制管理器 100 | controlManager.setLoopManager(loopManager); 101 | 102 | // 创建拖动管理器 103 | const dragManager = new DragManager(this.playerCore, uiElements); 104 | dragManager.init(); 105 | this.managers.dragManager = dragManager; 106 | 107 | // 初始化VideoSwipeManager 108 | if (this.playerCore.targetVideo && uiElements.videoWrapper && uiElements.handle) { 109 | console.log('[CustomVideoPlayer] 初始化SwipeManager...'); 110 | this.swipeManager = new VideoSwipeManager( 111 | this.playerCore.targetVideo, 112 | uiElements.videoWrapper, 113 | uiElements.handle 114 | ); 115 | this.managers.swipeManager = this.swipeManager; 116 | } 117 | 118 | // 创建事件管理器 - 必须在所有其他管理器之后创建 119 | const eventManager = new EventManager(this.playerCore, uiElements, this.managers); 120 | eventManager.init(); 121 | this.managers.eventManager = eventManager; 122 | 123 | // 组装DOM - 移到所有管理器初始化之后 124 | uiManager.assembleDOM(); 125 | 126 | // 应用设置 127 | settingsManager.updateControlRowsVisibility(); 128 | 129 | // 恢复视频状态 130 | this.playerCore.restoreVideoState(); 131 | 132 | // 更新进度条 133 | progressManager.updateProgressBar(); 134 | 135 | // 更新当前时间显示 136 | progressManager.updateCurrentTimeDisplay(); 137 | 138 | // 为iOS设备的Safari浏览器设置统一的safe area背景色 139 | Utils.updateSafariThemeColor('#000000', true); 140 | 141 | // 立即更新视频大小和各UI元素位置 142 | setTimeout(() => { 143 | if (this.swipeManager) { 144 | this.swipeManager.updateSize(); 145 | } 146 | dragManager.updateHandlePosition(); 147 | }, 100); 148 | 149 | // 额外的延迟检查,确保URL参数相关的UI元素都被正确更新 150 | setTimeout(() => { 151 | console.log('[CustomVideoPlayer] 执行URL参数相关UI二次检查'); 152 | // 强制再次更新循环点时间显示 153 | if (loopManager) { 154 | loopManager._updateUI(); 155 | loopManager.updateLoopTimeDisplay(); 156 | loopManager.updateLoopMarkers(); 157 | } 158 | 159 | // 强制更新进度条和时间显示 160 | if (progressManager) { 161 | progressManager.updateProgressBar(); 162 | progressManager.updateCurrentTimeDisplay(); 163 | } 164 | }, 500); 165 | 166 | this.initialized = true; 167 | console.log('[CustomVideoPlayer] 初始化完成'); 168 | } 169 | 170 | /** 171 | * 关闭播放器 172 | */ 173 | close() { 174 | // 调用PlayerCore的close方法 175 | this.playerCore.close( 176 | this.managers.uiManager.overlay, 177 | this.managers.uiManager.container, 178 | this.managers.uiManager.playerContainer 179 | ); 180 | 181 | // 清理事件监听器 182 | if (this.managers.eventManager) { 183 | this.managers.eventManager.cleanup(); 184 | } 185 | 186 | // 清理SwipeManager 187 | if (this.swipeManager) { 188 | this.swipeManager.destroy(); 189 | this.swipeManager = null; 190 | } 191 | 192 | // 清理所有管理器 193 | for (const key in this.managers) { 194 | if (this.managers[key] && typeof this.managers[key].cleanup === 'function') { 195 | this.managers[key].cleanup(); 196 | } 197 | this.managers[key] = null; 198 | } 199 | 200 | // 重置状态 201 | this.initialized = false; 202 | this.managers = {}; 203 | this.playerCore = null; 204 | } 205 | } -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 工具类 - 通用功能函数集合 3 | */ 4 | export class Utils { 5 | // 缓存常用的检测结果 6 | static _cache = { 7 | isIOS: null, 8 | safeAreaInsets: null, 9 | lastOrientation: null 10 | }; 11 | 12 | // 主题颜色相关 13 | static _theme = { 14 | original: { 15 | light: null, 16 | dark: null 17 | } 18 | }; 19 | 20 | /** 21 | * 节流函数 - 限制函数执行频率 22 | * @param {Function} fn - 要执行的函数 23 | * @param {number} delay - 延迟时间(ms) 24 | * @returns {Function} - 节流后的函数 25 | */ 26 | static throttle(fn, delay = 200) { 27 | let lastCall = 0; 28 | return function(...args) { 29 | const now = Date.now(); 30 | if (now - lastCall < delay) return; 31 | lastCall = now; 32 | return fn.apply(this, args); 33 | }; 34 | } 35 | 36 | /** 37 | * 防抖函数 - 延迟执行直到操作停止 38 | * @param {Function} fn - 要执行的函数 39 | * @param {number} delay - 延迟时间(ms) 40 | * @returns {Function} - 防抖后的函数 41 | */ 42 | static debounce(fn, delay = 200) { 43 | let timer = null; 44 | return function(...args) { 45 | if (timer) clearTimeout(timer); 46 | timer = setTimeout(() => { 47 | fn.apply(this, args); 48 | timer = null; 49 | }, delay); 50 | }; 51 | } 52 | 53 | /** 54 | * 检测是否为iOS设备 55 | * @returns {boolean} - 是否为iOS设备 56 | */ 57 | static isIOS() { 58 | if (this._cache.isIOS === null) { 59 | this._cache.isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 60 | } 61 | return this._cache.isIOS; 62 | } 63 | 64 | /** 65 | * 检测是否为Safari浏览器 66 | * @returns {boolean} - 是否为Safari浏览器 67 | */ 68 | static isSafari() { 69 | return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 70 | } 71 | 72 | /** 73 | * 检测是否为竖屏模式 74 | * @returns {boolean} - 是否为竖屏模式 75 | */ 76 | static isPortrait() { 77 | return window.innerHeight > window.innerWidth; 78 | } 79 | 80 | /** 81 | * 检查设备和屏幕方向 82 | * @returns {boolean} - 当前是否为竖屏状态 83 | */ 84 | static checkDeviceAndOrientation() { 85 | return this.isPortrait(); 86 | } 87 | 88 | /** 89 | * 获取设备安全区域尺寸 90 | * @returns {Object} - 安全区域尺寸 {top, right, bottom, left} 91 | */ 92 | static getSafeAreaInsets() { 93 | const defaultTopInset = 44; // 默认顶部安全区域 94 | const defaultBottomInset = 34; // 默认底部安全区域 95 | const defaultSideInset = 16; // 默认左右安全区域 96 | 97 | const style = window.getComputedStyle(document.documentElement); 98 | return { 99 | top: parseInt(style.getPropertyValue('--sat') || 100 | style.getPropertyValue('--safe-area-inset-top') || '0', 10) || defaultTopInset, 101 | right: parseInt(style.getPropertyValue('--sar') || 102 | style.getPropertyValue('--safe-area-inset-right') || '0', 10) || defaultSideInset, 103 | bottom: parseInt(style.getPropertyValue('--sab') || 104 | style.getPropertyValue('--safe-area-inset-bottom') || '0', 10) || defaultBottomInset, 105 | left: parseInt(style.getPropertyValue('--sal') || 106 | style.getPropertyValue('--safe-area-inset-left') || '0', 10) || defaultSideInset 107 | }; 108 | } 109 | 110 | /** 111 | * 创建带样式的HTML元素 112 | * @param {string} tag - HTML标签名 113 | * @param {string} className - CSS类名 114 | * @param {string} style - 内联样式 115 | * @returns {HTMLElement} - 创建的元素 116 | */ 117 | static createElementWithStyle(tag, className, style) { 118 | const element = document.createElement(tag); 119 | if (className) element.className = className; 120 | if (style) element.style.cssText = style; 121 | return element; 122 | } 123 | 124 | /** 125 | * 创建SVG图标 126 | * @param {string} path - SVG路径 127 | * @param {number} size - 图标大小 128 | * @returns {SVGElement} - SVG图标元素 129 | */ 130 | static createSVGIcon(path, size = 24) { 131 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 132 | svg.setAttribute('width', size); 133 | svg.setAttribute('height', size); 134 | svg.setAttribute('viewBox', '0 0 24 24'); 135 | svg.setAttribute('fill', 'none'); 136 | svg.setAttribute('stroke', 'currentColor'); 137 | svg.setAttribute('stroke-width', '2'); 138 | svg.setAttribute('stroke-linecap', 'round'); 139 | svg.setAttribute('stroke-linejoin', 'round'); 140 | 141 | const pathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 142 | pathElement.setAttribute('d', path); 143 | svg.appendChild(pathElement); 144 | 145 | return svg; 146 | } 147 | 148 | /** 149 | * 检测页面中是否存在视频元素 150 | * @returns {HTMLVideoElement|null} - 找到的视频元素或null 151 | */ 152 | static findVideoElement() { 153 | // 常见视频选择器 154 | const specificSelectors = [ 155 | '#player video', // 常见ID 156 | '#video video', // 常见ID 157 | 'div.plyr__video-wrapper video', // Plyr 158 | '.video-js video', // Video.js 159 | '#player > video', // 直接子元素 160 | '#video-player > video', // 另一个常见ID 161 | 'video[preload]:not([muted])', // 可能是主要内容的视频 162 | 'video[src]', // 带有src属性的视频 163 | 'video.video-main', // 主视频类 164 | 'main video', // 主要内容区域中的视频 165 | 'video', // 所有视频(最低优先级) 166 | ]; 167 | 168 | // 按优先级顺序查找视频元素 169 | for (const selector of specificSelectors) { 170 | const videos = document.querySelectorAll(selector); 171 | if (videos.length > 0) { 172 | console.log(`[Utils] 找到视频元素:${selector}`); 173 | return videos[0]; // 返回第一个匹配的视频元素 174 | } 175 | } 176 | 177 | return null; // 未找到视频元素 178 | } 179 | 180 | /** 181 | * 格式化时间为 mm:ss 或 hh:mm:ss 182 | * @param {number} seconds - 秒数 183 | * @returns {string} - 格式化后的时间字符串 184 | */ 185 | static formatTime(seconds) { 186 | const hours = Math.floor(seconds / 3600); 187 | const minutes = Math.floor((seconds % 3600) / 60); 188 | const secs = Math.floor(seconds % 60); 189 | 190 | if (hours > 0) { 191 | return `${hours}:${minutes < 10 ? '0' : ''}${minutes}:${secs < 10 ? '0' : ''}${secs}`; 192 | } 193 | return `${minutes}:${secs < 10 ? '0' : ''}${secs}`; 194 | } 195 | 196 | /** 197 | * 设置或更新Safari的主题色 198 | * @param {string} color - 主题色 199 | * @param {boolean} saveOriginal - 是否保存原始颜色值 200 | */ 201 | static updateSafariThemeColor(color = '#000000', saveOriginal = false) { 202 | // 仅在Safari浏览器中执行 203 | if (!this.isSafari() && !this.isIOS()) return; 204 | 205 | // 获取当前主题色标签 206 | let metaThemeColor = document.querySelector('meta[name="theme-color"]'); 207 | 208 | // 保存原始颜色值(如果需要) 209 | if (saveOriginal && metaThemeColor && !this._theme.original.dark) { 210 | this._theme.original.dark = metaThemeColor.content; 211 | } 212 | 213 | // 如果标签不存在,创建新标签 214 | if (!metaThemeColor) { 215 | metaThemeColor = document.createElement('meta'); 216 | metaThemeColor.name = 'theme-color'; 217 | document.head.appendChild(metaThemeColor); 218 | } 219 | 220 | // 设置颜色值 221 | metaThemeColor.content = color; 222 | } 223 | 224 | /** 225 | * 恢复Safari的原始主题色 226 | */ 227 | static restoreSafariThemeColor() { 228 | // 仅在有保存的原始颜色时恢复 229 | if (this._theme.original.dark) { 230 | this.updateSafariThemeColor(this._theme.original.dark); 231 | } else { 232 | // 如果没有原始颜色,移除主题色标签 233 | const metaThemeColor = document.querySelector('meta[name="theme-color"]'); 234 | if (metaThemeColor && metaThemeColor.parentNode) { 235 | metaThemeColor.parentNode.removeChild(metaThemeColor); 236 | } 237 | } 238 | } 239 | } -------------------------------------------------------------------------------- /src/constants/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 多语言系统 - 用于整个脚本的国际化支持 3 | */ 4 | export class I18n { 5 | /** 6 | * 获取用户浏览器当前语言 7 | * @returns {string} 用户当前语言代码 8 | */ 9 | static get userLang() { 10 | return (navigator.languages && navigator.languages[0]) || navigator.language || 'en'; 11 | } 12 | 13 | /** 14 | * 语言字符串集合 - 包含所有脚本使用的文本 15 | * @type {Object} 16 | */ 17 | static strings = { 18 | 'en': { 19 | // 元数据 20 | scriptName: 'Miss Player | Cinema Mode (One-handed Player)', 21 | scriptDescription: 'MissAV ad-free|One-handed mode|MissAV auto-expand details|MissAV auto high quality|MissAV redirect support|MissAV auto login|Custom player supporting jable po*nhub etc', 22 | 23 | // 控制台日志信息 24 | viewportConfigured: 'Viewport configured to support safe area', 25 | stylesInjected: 'Styles injected', 26 | enhancerInitialized: 'User experience enhancer module initialized', 27 | loginModuleInitialized: 'Auto login module initialized', 28 | initializationComplete: 'Initialization complete', 29 | initializationFailed: 'Initialization failed', 30 | 31 | // 界面文本 32 | play: 'Play', 33 | pause: 'Pause', 34 | mute: 'Mute', 35 | unmute: 'Unmute', 36 | fullscreen: 'Fullscreen', 37 | exitFullscreen: 'Exit Fullscreen', 38 | settings: 'Settings', 39 | quality: 'Quality', 40 | speed: 'Speed', 41 | 42 | // 设置选项 43 | autoplay: 'Auto Play', 44 | loop: 'Loop', 45 | autoQuality: 'Auto Quality', 46 | 47 | // 消息提示 48 | loadingError: 'Failed to load video', 49 | networkError: 'Network error', 50 | loginSuccess: 'Login successful', 51 | loginFailed: 'Login failed' 52 | }, 53 | 'zh-CN': { 54 | // 元数据 55 | scriptName: 'Miss Player | 影院模式 (单手播放器)', 56 | scriptDescription: 'MissAV去广告|单手模式|MissAV自动展开详情|MissAV自动高画质|MissAV重定向支持|MissAV自动登录|定制播放器 支持 jable po*nhub 等通用', 57 | 58 | // 控制台日志信息 59 | viewportConfigured: '已配置viewport以支持安全区域', 60 | stylesInjected: '样式注入完成', 61 | enhancerInitialized: '用户体验增强模块已初始化', 62 | loginModuleInitialized: '自动登录模块已初始化', 63 | initializationComplete: '初始化完成', 64 | initializationFailed: '初始化失败', 65 | 66 | // 界面文本 67 | play: '播放', 68 | pause: '暂停', 69 | mute: '静音', 70 | unmute: '取消静音', 71 | fullscreen: '全屏', 72 | exitFullscreen: '退出全屏', 73 | settings: '设置', 74 | quality: '画质', 75 | speed: '速度', 76 | 77 | // 设置选项 78 | autoplay: '自动播放', 79 | loop: '循环播放', 80 | autoQuality: '自动画质', 81 | 82 | // 消息提示 83 | loadingError: '视频加载失败', 84 | networkError: '网络错误', 85 | loginSuccess: '登录成功', 86 | loginFailed: '登录失败' 87 | }, 88 | 'zh-TW': { 89 | // 元数据 90 | scriptName: 'Miss Player | 影院模式 (單手播放器)', 91 | scriptDescription: 'MissAV去廣告|單手模式|MissAV自動展開詳情|MissAV自動高畫質|MissAV重定向支持|MissAV自動登錄|定制播放器 支持 jable po*nhub 等通用', 92 | 93 | // 控制台日志信息 94 | viewportConfigured: '已配置viewport以支持安全區域', 95 | stylesInjected: '樣式注入完成', 96 | enhancerInitialized: '用戶體驗增強模塊已初始化', 97 | loginModuleInitialized: '自動登錄模塊已初始化', 98 | initializationComplete: '初始化完成', 99 | initializationFailed: '初始化失敗', 100 | 101 | // 界面文本 102 | play: '播放', 103 | pause: '暫停', 104 | mute: '靜音', 105 | unmute: '取消靜音', 106 | fullscreen: '全屏', 107 | exitFullscreen: '退出全屏', 108 | settings: '設置', 109 | quality: '畫質', 110 | speed: '速度', 111 | 112 | // 设置选项 113 | autoplay: '自動播放', 114 | loop: '循環播放', 115 | autoQuality: '自動畫質', 116 | 117 | // 消息提示 118 | loadingError: '視頻加載失敗', 119 | networkError: '網絡錯誤', 120 | loginSuccess: '登錄成功', 121 | loginFailed: '登錄失敗' 122 | }, 123 | 'ja': { 124 | // 元数据 125 | scriptName: 'Miss Player | シネマモード (片手プレーヤー)', 126 | scriptDescription: 'MissAV広告なし|片手モード|MissAV自動詳細展開|MissAV自動高画質|MissAVリダイレクトサポート|MissAV自動ログイン|jable po*nhub などをサポートするカスタムプレーヤー', 127 | 128 | // 控制台日志信息 129 | viewportConfigured: 'セーフエリアをサポートするためにビューポートを設定しました', 130 | stylesInjected: 'スタイルが注入されました', 131 | enhancerInitialized: 'ユーザー体験向上モジュールが初期化されました', 132 | loginModuleInitialized: '自動ログインモジュールが初期化されました', 133 | initializationComplete: '初期化が完了しました', 134 | initializationFailed: '初期化に失敗しました', 135 | 136 | // 界面文本 137 | play: '再生', 138 | pause: '一時停止', 139 | mute: 'ミュート', 140 | unmute: 'ミュート解除', 141 | fullscreen: '全画面', 142 | exitFullscreen: '全画面解除', 143 | settings: '設定', 144 | quality: '画質', 145 | speed: '速度', 146 | 147 | // 设置选项 148 | autoplay: '自動再生', 149 | loop: 'ループ再生', 150 | autoQuality: '自動画質', 151 | 152 | // 消息提示 153 | loadingError: '動画の読み込みに失敗しました', 154 | networkError: 'ネットワークエラー', 155 | loginSuccess: 'ログイン成功', 156 | loginFailed: 'ログイン失敗' 157 | }, 158 | 'vi': { 159 | // 元数据 160 | scriptName: 'Miss Player | Chế độ Rạp chiếu phim (Trình phát một tay)', 161 | scriptDescription: 'MissAV không quảng cáo|Chế độ một tay|MissAV tự động mở rộng chi tiết|MissAV tự động chất lượng cao|Hỗ trợ chuyển hướng MissAV|Đăng nhập tự động MissAV|Trình phát tùy chỉnh hỗ trợ jable po*nhub v.v.', 162 | 163 | // 控制台日志信息 164 | viewportConfigured: 'Đã cấu hình viewport để hỗ trợ vùng an toàn', 165 | stylesInjected: 'Đã tiêm CSS', 166 | enhancerInitialized: 'Đã khởi tạo mô-đun nâng cao trải nghiệm người dùng', 167 | loginModuleInitialized: 'Đã khởi tạo mô-đun đăng nhập tự động', 168 | initializationComplete: 'Khởi tạo hoàn tất', 169 | initializationFailed: 'Khởi tạo thất bại', 170 | 171 | // 界面文本 172 | play: 'Phát', 173 | pause: 'Tạm dừng', 174 | mute: 'Tắt tiếng', 175 | unmute: 'Bật tiếng', 176 | fullscreen: 'Toàn màn hình', 177 | exitFullscreen: 'Thoát toàn màn hình', 178 | settings: 'Cài đặt', 179 | quality: 'Chất lượng', 180 | speed: 'Tốc độ', 181 | 182 | // 设置选项 183 | autoplay: 'Tự động phát', 184 | loop: 'Lặp lại', 185 | autoQuality: 'Chất lượng tự động', 186 | 187 | // 消息提示 188 | loadingError: 'Không thể tải video', 189 | networkError: 'Lỗi mạng', 190 | loginSuccess: 'Đăng nhập thành công', 191 | loginFailed: 'Đăng nhập thất bại' 192 | } 193 | }; 194 | 195 | /** 196 | * 翻译函数 - 将ID转换为当前语言的字符串 197 | * @param {string} id - 要翻译的字符串ID 198 | * @param {string} [lang=''] - 可选的指定语言,默认使用用户语言 199 | * @returns {string} 翻译后的字符串 200 | */ 201 | static translate(id, lang = '') { 202 | const selectedLang = lang || this.userLang; 203 | // 首先尝试精确匹配语言代码,然后尝试匹配语言前缀,最后使用英语作为后备 204 | const langObj = 205 | this.strings[selectedLang] || 206 | this.strings[selectedLang.split('-')[0]] || 207 | this.strings.en; 208 | 209 | return langObj[id] || this.strings.en[id]; 210 | } 211 | } 212 | 213 | /** 214 | * 简便的翻译函数,可直接在代码中使用 215 | * @param {string} id - 要翻译的字符串ID 216 | * @param {string} [lang=''] - 可选的指定语言,默认使用用户语言 217 | * @returns {string} 翻译后的字符串 218 | */ 219 | export function __(id, lang = '') { 220 | return I18n.translate(id, lang); 221 | } -------------------------------------------------------------------------------- /src/player/core/PlayerCore.js: -------------------------------------------------------------------------------- 1 | import { Utils } from '../../utils/utils.js'; 2 | 3 | /** 4 | * 播放器核心类 - 负责播放器的基本功能和状态管理 5 | */ 6 | export class PlayerCore { 7 | constructor(options = {}) { 8 | console.log('[PlayerCore] 初始化...'); 9 | 10 | // 常量定义 11 | this.defaultPlaybackRate = 1.0; // 默认播放速度 12 | 13 | // 状态变量 14 | this.targetVideo = null; // 目标视频元素 15 | this.videoState = { 16 | currentTime: 0, 17 | isPlaying: false, 18 | volume: 1, 19 | playbackRate: 1 20 | }; 21 | 22 | // 配置和选项 23 | this.options = Object.assign({ 24 | containerId: 'tm-video-container', 25 | startLooped: false, 26 | startMuted: false, 27 | }, options); 28 | 29 | // 保存调用按钮 30 | this.callingButton = this.options.callingButton || null; 31 | 32 | // 状态标记 33 | this.initialized = false; 34 | } 35 | 36 | /** 37 | * 初始化播放器 38 | */ 39 | init() { 40 | if (this.initialized) return; 41 | 42 | // 清理可能存在的旧overlay 43 | this.cleanupExistingOverlays(); 44 | 45 | // 查找目标视频 46 | this.targetVideo = this.findTargetVideo(); 47 | 48 | if (!this.targetVideo) { 49 | console.error('[PlayerCore] 未找到视频元素'); 50 | // 如果是从浮动按钮调用的,则重新显示按钮 51 | if (this.callingButton) { 52 | this.callingButton.style.display = 'flex'; 53 | } 54 | return; 55 | } 56 | 57 | // 保存视频状态 58 | this.saveVideoState(); 59 | 60 | // 初始化完成标记 61 | this.initialized = true; 62 | console.log('[PlayerCore] 核心初始化完成'); 63 | 64 | return this.targetVideo; 65 | } 66 | 67 | /** 68 | * 清理可能存在的旧overlay元素 69 | */ 70 | cleanupExistingOverlays() { 71 | // 查找所有现有的overlay元素 72 | const existingOverlays = document.querySelectorAll('.tm-video-overlay'); 73 | 74 | if (existingOverlays.length > 0) { 75 | console.log(`[PlayerCore] 清理 ${existingOverlays.length} 个现有overlay元素`); 76 | 77 | existingOverlays.forEach(overlay => { 78 | if (overlay && overlay.parentNode) { 79 | overlay.parentNode.removeChild(overlay); 80 | } 81 | }); 82 | } 83 | } 84 | 85 | /** 86 | * 查找页面中的视频元素 87 | * @returns {HTMLVideoElement|null} 找到的视频元素或null 88 | */ 89 | findTargetVideo() { 90 | let potentialVideo = null; 91 | 92 | // --- Strategy 1: Specific known selectors --- 93 | const specificSelectors = [ 94 | '#player video', // Common ID 95 | '#video video', // Common ID 96 | 'div.plyr__video-wrapper video', // Plyr 97 | '.video-js video', // Video.js 98 | '#player > video', // Direct child 99 | '#video-player > video', // Another common ID 100 | 'video[preload]:not([muted])' // Videos likely to be main content 101 | ]; 102 | 103 | for (const selector of specificSelectors) { 104 | potentialVideo = document.querySelector(selector); 105 | if (potentialVideo) { 106 | console.log('[PlayerCore] 通过选择器找到视频:', selector); 107 | return potentialVideo; 108 | } 109 | } 110 | 111 | // --- Strategy 2: Find all videos and prioritize --- 112 | const allVideos = Array.from(document.querySelectorAll('video')); 113 | console.log('[PlayerCore] 找到视频元素数量:', allVideos.length); 114 | 115 | if (allVideos.length === 0) { 116 | console.log('[PlayerCore] 未找到视频元素'); 117 | return null; 118 | } 119 | 120 | if (allVideos.length === 1) { 121 | console.log('[PlayerCore] 找到单个视频元素'); 122 | return allVideos[0]; 123 | } 124 | 125 | // Filter out potentially hidden or invalid videos and calculate area 126 | const visibleVideos = allVideos 127 | .map(video => ({ 128 | element: video, 129 | rect: video.getBoundingClientRect(), 130 | })) 131 | .filter(item => item.rect.width > 50 && item.rect.height > 50) // Basic visibility/size check 132 | .map(item => ({ 133 | ...item, 134 | area: item.rect.width * item.rect.height 135 | })) 136 | .sort((a, b) => b.area - a.area); // Sort by area descending 137 | 138 | if (visibleVideos.length > 0) { 139 | console.log('[PlayerCore] 选择最大的可见视频'); 140 | return visibleVideos[0].element; 141 | } 142 | 143 | // --- Strategy 3: Fallback to first video if filtering fails --- 144 | console.log('[PlayerCore] 回退到第一个视频元素'); 145 | return allVideos[0]; 146 | } 147 | 148 | /** 149 | * 保存视频状态 150 | */ 151 | saveVideoState() { 152 | if (!this.targetVideo) return; 153 | 154 | this.originalParent = this.targetVideo.parentNode; 155 | this.originalIndex = Array.from(this.originalParent.children).indexOf(this.targetVideo); 156 | 157 | this.videoState = { 158 | currentTime: this.targetVideo.currentTime, 159 | isPaused: this.targetVideo.paused, 160 | videoSrc: this.targetVideo.src, 161 | posterSrc: this.targetVideo.poster, 162 | wasMuted: this.targetVideo.muted, 163 | controls: this.targetVideo.controls // 保存原始控制组件状态 164 | }; 165 | } 166 | 167 | /** 168 | * 恢复视频状态 169 | */ 170 | restoreVideoState() { 171 | try { 172 | // 设置默认播放速度 173 | this.targetVideo.playbackRate = this.defaultPlaybackRate; 174 | 175 | // 恢复播放位置 176 | this.targetVideo.currentTime = this.videoState.currentTime; 177 | 178 | // 尝试播放视频 179 | const playPromise = this.targetVideo.play(); 180 | 181 | if (playPromise !== undefined) { 182 | playPromise.catch(error => { 183 | console.log('视频自动播放被阻止: ', error); 184 | // 不再尝试静音播放,保持暂停状态 185 | // 可以考虑在这里添加一个UI提示,告知用户手动点击播放按钮 186 | }); 187 | } 188 | } catch (e) { 189 | console.error('尝试播放时出错: ', e); 190 | } 191 | } 192 | 193 | /** 194 | * 关闭播放器并恢复原始视频 195 | */ 196 | close(overlay, container, playerContainer) { 197 | if (!overlay) return; 198 | 199 | // 保存当前视频状态以便下次打开 200 | this.videoState.currentTime = this.targetVideo.currentTime; 201 | this.videoState.isPlaying = !this.targetVideo.paused; 202 | this.videoState.volume = this.targetVideo.volume; 203 | this.videoState.playbackRate = this.targetVideo.playbackRate; 204 | 205 | // 如果视频正在播放,暂停它 206 | if (!this.targetVideo.paused) { 207 | this.targetVideo.pause(); 208 | } 209 | 210 | // 恢复原始的视频样式 211 | if (this.originalParent && this.targetVideo && this.targetVideo.parentNode) { 212 | if (this.targetVideo.parentNode !== this.originalParent) { 213 | // 移动回原始位置 214 | if (this.originalIndex !== -1 && this.originalParent.childNodes.length > this.originalIndex) { 215 | this.originalParent.insertBefore(this.targetVideo, this.originalParent.childNodes[this.originalIndex]); 216 | } else { 217 | this.originalParent.appendChild(this.targetVideo); 218 | } 219 | 220 | // 移除自定义样式 221 | this.targetVideo.style.width = ''; 222 | this.targetVideo.style.height = ''; 223 | this.targetVideo.style.maxHeight = ''; 224 | this.targetVideo.style.margin = ''; 225 | this.targetVideo.style.position = ''; 226 | } 227 | } 228 | 229 | // 移除叠加层 230 | if (overlay.parentNode) { 231 | overlay.parentNode.removeChild(overlay); 232 | } 233 | 234 | // 移除播放器容器 235 | if (playerContainer && playerContainer.parentNode) { 236 | playerContainer.parentNode.removeChild(playerContainer); 237 | } 238 | 239 | // 移除body上的控制状态类 240 | document.body.classList.remove('controls-hidden'); 241 | 242 | // 如果添加了全屏切换样式,移除它 243 | const fullscreenStyle = document.getElementById('tm-fullscreen-style'); 244 | if (fullscreenStyle) { 245 | fullscreenStyle.parentNode.removeChild(fullscreenStyle); 246 | } 247 | 248 | // 重置状态 249 | this.initialized = false; 250 | 251 | // 恢复Safari主题色 252 | Utils.restoreSafariThemeColor(); 253 | 254 | // 如果是从浮动按钮调用的,则重新显示按钮 255 | if (this.callingButton) { 256 | this.callingButton.style.display = 'flex'; 257 | } 258 | } 259 | } -------------------------------------------------------------------------------- /src/autologin/MissavLoginProvider.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MissAV网站登录提供程序 3 | */ 4 | import { LoginUtils } from './utils.js'; 5 | import { I18n } from './i18n.js'; 6 | 7 | export class MissavLoginProvider { 8 | /** 9 | * 构造函数 10 | */ 11 | constructor() { 12 | // 域名列表 - 支持多个平行域名 13 | this.domains = [ 14 | 'missav.ws', 15 | 'missav.ai', 16 | 'missav.com', 17 | 'thisav.com' 18 | ]; 19 | } 20 | 21 | /** 22 | * 检查当前网站是否由本提供程序支持 23 | * @returns {boolean} 是否支持当前网站 24 | */ 25 | isSupportedSite() { 26 | const currentDomain = window.location.hostname; 27 | return this.domains.some(domain => currentDomain.includes(domain)); 28 | } 29 | 30 | /** 31 | * 登录处理函数 32 | * @param {string} email - 用户邮箱 33 | * @param {string} password - 用户密码 34 | * @returns {Promise} 登录是否成功 35 | */ 36 | async login(email, password) { 37 | if (!email || !password) { 38 | LoginUtils.toast(I18n.translate('accountNull'), 2000, '#FF0000', '#ffffff', 'top'); 39 | return false; 40 | } 41 | 42 | try { 43 | // 使用MissAV的登录API 44 | const response = await fetch('https://missav.ws/cn/api/login', { 45 | method: 'POST', 46 | headers: { 47 | 'Content-Type': 'application/json' 48 | }, 49 | body: JSON.stringify({ 50 | email: email, 51 | password: password, 52 | remember: true 53 | }) 54 | }); 55 | 56 | if (!response.ok) { 57 | const errorText = await response.text(); 58 | console.error('登录错误:', { 59 | status: response.status, 60 | statusText: response.statusText, 61 | responseText: errorText 62 | }); 63 | LoginUtils.toast(`登录失败: ${errorText}`, 2000, '#FF0000', '#ffffff', 'top'); 64 | throw new Error(I18n.translate('networkFailed')); 65 | } 66 | 67 | // 处理响应 68 | let data; 69 | if (response.headers.get('Content-Type')?.includes('application/json')) { 70 | data = await response.json(); 71 | } else { 72 | const text = await response.text(); 73 | console.error(I18n.translate('loginFailed'), { 74 | status: response.status, 75 | statusText: response.statusText, 76 | responseText: text 77 | }); 78 | LoginUtils.toast(I18n.translate('loginFailed'), 2000, '#FF0000', '#ffffff', 'top'); 79 | throw new Error(I18n.translate('loginFailed')); 80 | } 81 | 82 | console.log('登录成功:', data); 83 | LoginUtils.toast(I18n.translate('loginSuccess'), 2000, 'rgb(18, 187, 2)', '#ffffff', 'top'); 84 | 85 | // 登录成功后刷新页面 86 | setTimeout(() => { 87 | location.reload(); 88 | }, 1000); 89 | 90 | return true; 91 | } catch (error) { 92 | LoginUtils.toast(`错误发生: ${error.message}`, 2000, '#FF0000', '#ffffff', 'top'); 93 | return false; 94 | } 95 | } 96 | 97 | /** 98 | * 检查登录状态 99 | * @returns {Promise} 是否已登录 100 | */ 101 | async checkLoginStatus() { 102 | try { 103 | // 尝试使用API检测登录状态 104 | const isLoggedIn = await this.checkLoginByAPI(); 105 | if (isLoggedIn !== null) { 106 | return isLoggedIn; 107 | } 108 | 109 | // API检测失败时,尝试DOM检测 110 | return this.checkLoginByDOM(); 111 | } catch (error) { 112 | console.error('检查登录状态时出错:', error); 113 | return false; 114 | } 115 | } 116 | 117 | /** 118 | * 使用API检测登录状态 119 | * @returns {Promise} 登录状态或null(检测失败) 120 | */ 121 | async checkLoginByAPI() { 122 | try { 123 | const url = 'https://missav.ws/api/actresses/1016525/view'; 124 | 125 | const response = await fetch(url, { 126 | method: 'GET', 127 | credentials: 'include' // 确保发送cookies 128 | }); 129 | 130 | if (!response.ok) { 131 | return null; // API检测失败 132 | } 133 | 134 | const data = await response.json(); 135 | return data.user !== null; // 用户不为null则已登录 136 | } catch (error) { 137 | console.debug('API登录状态检查出错:', error.message); 138 | return null; // 检测过程出错 139 | } 140 | } 141 | 142 | /** 143 | * 使用DOM元素检测登录状态 144 | * @returns {boolean} 是否已登录 145 | */ 146 | checkLoginByDOM() { 147 | try { 148 | // 检查页面上的各种可能表明登录状态的元素 149 | const loginButton = document.querySelector('button[x-on\\:click="currentPage = \'login\'"]'); 150 | const userAvatar = document.querySelector('.relative.ml-3 img.h-8.w-8.rounded-full'); 151 | const userMenu = document.querySelector('[x-data="{userDropdownOpen: false}"]'); 152 | 153 | // 如果没有登录按钮或有用户相关元素,说明可能已登录 154 | return !loginButton || userAvatar || userMenu; 155 | } catch (error) { 156 | console.debug('DOM检测登录状态时出错:', error.message); 157 | return false; 158 | } 159 | } 160 | 161 | /** 162 | * 添加自动登录选项到登录表单 163 | * @param {Function} onLoginInfoChange - 登录信息变更回调 164 | * @returns {Promise} 165 | */ 166 | async addAutoLoginOption(onLoginInfoChange) { 167 | try { 168 | // 等待登录表单加载完成 169 | const loginRememberContainer = await LoginUtils.waitForElement('form[x-show="currentPage === \'login\'"] .relative.flex.items-start.justify-between'); 170 | 171 | // 创建自动登录选项 172 | const autoLoginDiv = document.createElement('div'); 173 | autoLoginDiv.className = 'flex'; 174 | autoLoginDiv.innerHTML = ` 175 |
176 | 177 |
178 |
179 | 180 |
181 | `; 182 | 183 | // 获取"记住我"元素 184 | const rememberMeDiv = loginRememberContainer.querySelector('.flex'); 185 | 186 | // 在记住我和忘记密码之间插入自动登录选项 187 | rememberMeDiv.parentNode.insertBefore(autoLoginDiv, rememberMeDiv.nextSibling); 188 | 189 | // 加载自动登录设置状态,默认为勾选状态 190 | const autoLogin = LoginUtils.getValue('autoLogin', true); 191 | document.getElementById('auto_login').checked = autoLogin; 192 | 193 | // 监听自动登录复选框变化 194 | document.getElementById('auto_login').addEventListener('change', () => { 195 | const isAutoLogin = document.getElementById('auto_login').checked; 196 | LoginUtils.setValue('autoLogin', isAutoLogin); 197 | if (onLoginInfoChange) { 198 | onLoginInfoChange({ autoLogin: isAutoLogin }); 199 | } 200 | }); 201 | 202 | // 监听登录表单提交 203 | const loginForm = document.querySelector('form[x-show="currentPage === \'login\'"]'); 204 | if (loginForm) { 205 | // 监听登录按钮点击 206 | const loginButton = loginForm.querySelector('button[type="submit"]'); 207 | if (loginButton) { 208 | loginButton.addEventListener('click', () => { 209 | setTimeout(() => { 210 | const emailInput = document.getElementById('login_email'); 211 | const passwordInput = document.getElementById('login_password'); 212 | const autoLoginCheckbox = document.getElementById('auto_login'); 213 | 214 | if (emailInput && passwordInput && autoLoginCheckbox && autoLoginCheckbox.checked) { 215 | const email = emailInput.value; 216 | const password = passwordInput.value; 217 | 218 | // 保存登录信息 219 | LoginUtils.setValue('userEmail', email); 220 | LoginUtils.setValue('userPassword', password); 221 | 222 | if (onLoginInfoChange) { 223 | onLoginInfoChange({ 224 | email, 225 | password, 226 | autoLogin: true 227 | }); 228 | } 229 | } 230 | }, 100); 231 | }); 232 | } 233 | } 234 | } catch (error) { 235 | console.error('添加自动登录选项时出错:', error); 236 | } 237 | } 238 | } -------------------------------------------------------------------------------- /src/player/ui/FloatingButton.js: -------------------------------------------------------------------------------- 1 | import { Utils } from '../../utils/utils.js'; 2 | import { CustomVideoPlayer } from '../index.js'; 3 | 4 | /** 5 | * 浮动按钮类 6 | */ 7 | export class FloatingButton { 8 | constructor(options = {}) { 9 | this.button = null; 10 | this.videoPlayer = null; 11 | this.resizeTimeout = null; 12 | this.playerState = options.playerState || null; 13 | this.videoCheckInterval = null; 14 | this.mutationObserver = null; 15 | } 16 | 17 | /** 18 | * 初始化浮动按钮 19 | */ 20 | init() { 21 | // 清理可能存在的旧按钮 22 | this.cleanupExistingButtons(); 23 | 24 | // 检查页面是否存在视频元素 25 | if (Utils.findVideoElement()) { 26 | // 创建新按钮 27 | this.createButton(); 28 | 29 | // 监听窗口大小变化,更新按钮位置 30 | window.addEventListener('resize', this.handleResize.bind(this)); 31 | 32 | // 监听屏幕方向变化 33 | window.matchMedia("(orientation: portrait)").addEventListener("change", this.handleResize.bind(this)); 34 | 35 | // 监听DOM变化,处理视频元素的动态添加/移除 36 | this.setupMutationObserver(); 37 | 38 | console.log('[FloatingButton] 已创建浮动按钮,页面中存在视频元素'); 39 | } else { 40 | console.log('[FloatingButton] 页面中未检测到视频元素,不显示浮动按钮'); 41 | 42 | // 开始周期性检查视频元素 43 | this.startVideoElementCheck(); 44 | 45 | // 监听DOM变化,处理视频元素的动态添加 46 | this.setupMutationObserver(); 47 | } 48 | } 49 | 50 | /** 51 | * 设置MutationObserver监听DOM变化 52 | */ 53 | setupMutationObserver() { 54 | // 清理可能存在的旧观察者 55 | if (this.mutationObserver) { 56 | this.mutationObserver.disconnect(); 57 | } 58 | 59 | // 创建新的观察者 60 | this.mutationObserver = new MutationObserver(this.handleDomMutations.bind(this)); 61 | 62 | // 开始观察整个文档的变化 63 | this.mutationObserver.observe(document.body, { 64 | childList: true, 65 | subtree: true 66 | }); 67 | } 68 | 69 | /** 70 | * 处理DOM变化 71 | */ 72 | handleDomMutations() { 73 | // 使用防抖函数限制调用频率 74 | if (this.mutationTimeout) clearTimeout(this.mutationTimeout); 75 | 76 | this.mutationTimeout = setTimeout(() => { 77 | const hasVideo = Utils.findVideoElement(); 78 | 79 | // 如果有视频元素但没有按钮,创建按钮 80 | if (hasVideo && !this.button) { 81 | this.createButton(); 82 | 83 | // 监听窗口大小变化,更新按钮位置 84 | window.addEventListener('resize', this.handleResize.bind(this)); 85 | 86 | // 监听屏幕方向变化 87 | window.matchMedia("(orientation: portrait)").addEventListener("change", this.handleResize.bind(this)); 88 | 89 | console.log('[FloatingButton] DOM变化检测到视频元素,已创建浮动按钮'); 90 | } 91 | // 如果有按钮但没有视频元素,隐藏按钮 92 | else if (!hasVideo && this.button) { 93 | this.button.style.display = 'none'; 94 | console.log('[FloatingButton] DOM变化检测到视频元素已移除,已隐藏浮动按钮'); 95 | } 96 | // 如果有视频元素且有按钮,确保按钮显示 97 | else if (hasVideo && this.button && this.button.style.display === 'none') { 98 | this.button.style.display = 'flex'; 99 | console.log('[FloatingButton] DOM变化检测到视频元素已添加,已显示浮动按钮'); 100 | } 101 | }, 300); 102 | } 103 | 104 | /** 105 | * 开始周期性检查视频元素 106 | * 针对动态加载视频的网站,可能初始加载时没有视频,但后续会加载 107 | */ 108 | startVideoElementCheck() { 109 | // 清除可能存在的旧计时器 110 | if (this.videoCheckInterval) { 111 | clearInterval(this.videoCheckInterval); 112 | } 113 | 114 | // 设置新计时器,每2秒检查一次 115 | this.videoCheckInterval = setInterval(() => { 116 | if (Utils.findVideoElement()) { 117 | // 只有当按钮不存在时才创建 118 | if (!this.button) { 119 | // 找到视频元素,创建按钮 120 | this.createButton(); 121 | 122 | // 监听窗口大小变化,更新按钮位置 123 | window.addEventListener('resize', this.handleResize.bind(this)); 124 | 125 | // 监听屏幕方向变化 126 | window.matchMedia("(orientation: portrait)").addEventListener("change", this.handleResize.bind(this)); 127 | 128 | console.log('[FloatingButton] 定时检测到视频元素,已创建浮动按钮'); 129 | } else if (this.button.style.display === 'none') { 130 | // 按钮存在但被隐藏,重新显示 131 | this.button.style.display = 'flex'; 132 | console.log('[FloatingButton] 定时检测到视频元素,已显示浮动按钮'); 133 | } 134 | 135 | // 停止检查 136 | clearInterval(this.videoCheckInterval); 137 | this.videoCheckInterval = null; 138 | } 139 | }, 2000); 140 | } 141 | 142 | /** 143 | * 清理可能存在的旧浮动按钮 144 | */ 145 | cleanupExistingButtons() { 146 | // 查找所有现有的浮动按钮 147 | const existingButtons = document.querySelectorAll('.tm-floating-button'); 148 | 149 | if (existingButtons.length > 0) { 150 | console.log(`[FloatingButton] 清理 ${existingButtons.length} 个现有浮动按钮`); 151 | 152 | existingButtons.forEach(button => { 153 | if (button && button.parentNode) { 154 | button.parentNode.removeChild(button); 155 | } 156 | }); 157 | } 158 | } 159 | 160 | /** 161 | * 处理窗口大小变化 162 | */ 163 | handleResize() { 164 | // 使用节流函数限制调用频率 165 | if (this.resizeTimeout) clearTimeout(this.resizeTimeout); 166 | 167 | this.resizeTimeout = setTimeout(() => { 168 | // 检查页面是否存在视频元素 169 | if (Utils.findVideoElement()) { 170 | // 无论横屏还是竖屏都显示按钮 171 | this.button.style.display = 'flex'; 172 | 173 | // 更新按钮位置 174 | this.updateButtonPosition(); 175 | } else { 176 | // 没有视频元素,隐藏按钮 177 | if (this.button) this.button.style.display = 'none'; 178 | } 179 | }, 200); 180 | } 181 | 182 | /** 183 | * 创建浮动按钮 184 | */ 185 | createButton() { 186 | // 创建浮动按钮 - 使用CSS类而不是内联样式 187 | this.button = Utils.createElementWithStyle('button', 'tm-floating-button'); 188 | 189 | // 使用更简洁的播放按钮SVG图标 190 | const icon = ` 191 | 192 | 193 | 194 | 195 | `; 196 | this.button.innerHTML = icon; 197 | 198 | // 添加点击事件处理器 199 | this.button.addEventListener('click', () => { 200 | this.handleButtonClick(); 201 | }); 202 | 203 | // 显示按钮 204 | this.button.style.display = 'flex'; 205 | 206 | // 添加到文档 207 | document.body.appendChild(this.button); 208 | 209 | // 初始位置 210 | this.updateButtonPosition(); 211 | 212 | return this.button; 213 | } 214 | 215 | /** 216 | * 更新按钮位置,考虑安全区域和屏幕方向 217 | */ 218 | updateButtonPosition() { 219 | if (!this.button) return; 220 | 221 | const safeArea = Utils.getSafeAreaInsets(); 222 | 223 | // 获取当前屏幕方向 224 | const isPortrait = Utils.isPortrait(); 225 | 226 | if (isPortrait) { 227 | // 竖屏模式 - 按钮在底部居中 228 | this.button.style.bottom = `${Math.max(20, safeArea.bottom)}px`; 229 | this.button.style.right = 'auto'; 230 | this.button.style.left = '50%'; 231 | // 重置transform以保持hover和active效果正常 232 | this.button.style.transform = 'translateX(-50%)'; 233 | } else { 234 | // 横屏模式 - 按钮在右下角且加大安全距离 235 | this.button.style.bottom = `${Math.max(20, safeArea.bottom + 10)}px`; 236 | this.button.style.right = `${Math.max(20, safeArea.right + 10)}px`; 237 | this.button.style.left = 'auto'; 238 | // 重置transform以保持hover和active效果正常 239 | this.button.style.transform = 'translateX(0)'; 240 | } 241 | 242 | // 确保z-index始终正确设置,防止在屏幕旋转时被覆盖 243 | this.button.style.zIndex = '9980'; 244 | } 245 | 246 | /** 247 | * 处理按钮点击 248 | */ 249 | handleButtonClick() { 250 | // 隐藏按钮 251 | this.button.style.display = 'none'; 252 | 253 | // 每次点击都创建新的视频播放器实例 254 | this.videoPlayer = new CustomVideoPlayer({ 255 | playerState: this.playerState, 256 | callingButton: this.button // 确保传递按钮引用 257 | }); 258 | 259 | // 初始化播放器 260 | this.videoPlayer.init(); 261 | } 262 | 263 | /** 264 | * 移除浮动按钮 265 | */ 266 | remove() { 267 | if (this.button && this.button.parentNode) { 268 | this.button.parentNode.removeChild(this.button); 269 | } 270 | 271 | // 移除事件监听器 272 | window.removeEventListener('resize', this.handleResize); 273 | 274 | // 清除计时器 275 | if (this.videoCheckInterval) { 276 | clearInterval(this.videoCheckInterval); 277 | this.videoCheckInterval = null; 278 | } 279 | 280 | // 断开MutationObserver 281 | if (this.mutationObserver) { 282 | this.mutationObserver.disconnect(); 283 | this.mutationObserver = null; 284 | } 285 | 286 | // 清除引用 287 | this.button = null; 288 | } 289 | } -------------------------------------------------------------------------------- /src/player/managers/EventManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 事件管理器类 - 负责事件监听和处理 3 | */ 4 | export class EventManager { 5 | constructor(playerCore, uiElements, managers) { 6 | // 核心引用 7 | this.playerCore = playerCore; 8 | this.targetVideo = playerCore.targetVideo; 9 | 10 | // UI元素引用 11 | this.uiElements = uiElements; 12 | 13 | // 管理器引用 14 | this.managers = managers; 15 | 16 | // 状态变量 17 | this.resizeObserver = null; // ResizeObserver 实例 18 | this.clickLock = false; // 防止快速多次点击视频区域 19 | this.clickLockTimeout = null; // 点击锁定计时器 20 | } 21 | 22 | /** 23 | * 初始化事件管理器 24 | */ 25 | init() { 26 | console.log('[EventManager] 初始化事件管理器'); 27 | 28 | // 绑定基本方法 29 | this.handleWindowResizeBound = this.handleWindowResize.bind(this); 30 | this.handleContainerResizeBound = this.handleContainerResize.bind(this); 31 | this.handleOverlayTouchMoveBound = (e) => e.preventDefault(); 32 | 33 | // 初始化点击状态锁 34 | this.clickLock = false; 35 | this.clickLockTimeout = null; 36 | 37 | // 注释绑定视频包装器点击,由UIManager统一处理 38 | // this.handleVideoWrapperClickBound = this.handleVideoWrapperClick.bind(this); 39 | // this.uiElements.videoWrapper.addEventListener('click', this.handleVideoWrapperClickBound); 40 | 41 | // 绑定按钮事件 42 | this.handleCloseButtonClickBound = this.handleCloseButtonClick.bind(this); 43 | if (this.uiElements.closeBtn) { 44 | this.uiElements.closeBtn.addEventListener('click', this.handleCloseButtonClickBound); 45 | } 46 | 47 | this.handleSettingsButtonClickBound = this.handleSettingsButtonClick.bind(this); 48 | if (this.uiElements.settingsBtn) { 49 | this.uiElements.settingsBtn.addEventListener('click', this.handleSettingsButtonClickBound); 50 | } 51 | 52 | // 添加窗口大小变化事件 53 | window.addEventListener('resize', this.handleWindowResizeBound); 54 | 55 | // 设置容器大小观察器 56 | if (this.uiElements.container && typeof ResizeObserver !== 'undefined') { 57 | this.resizeObserver = new ResizeObserver(this.handleContainerResizeBound); 58 | this.resizeObserver.observe(this.uiElements.container); 59 | } 60 | 61 | // 阻止overlay上的默认触摸行为,防止iOS Safari上的橡皮筋效果 62 | if (this.uiElements.overlay) { 63 | this.uiElements.overlay.addEventListener('touchmove', this.handleOverlayTouchMoveBound, { passive: false }); 64 | } 65 | 66 | // 设置视频事件监听器 67 | this.initVideoEventListeners(); 68 | } 69 | 70 | /** 71 | * 初始化视频事件监听器 72 | */ 73 | initVideoEventListeners() { 74 | // 视频元数据加载完成监听 75 | this.handleMetadataLoadedBound = () => { 76 | if (this.managers.progressManager) { 77 | this.managers.progressManager.updateProgressBar(); 78 | } 79 | 80 | if (this.managers.loopManager) { 81 | this.managers.loopManager.updateLoopTimeDisplay(); 82 | this.managers.loopManager.updateLoopMarkers(); 83 | } 84 | 85 | if (this.managers.dragManager) { 86 | this.managers.dragManager.updateHandlePosition(); 87 | } 88 | 89 | if (this.managers.uiManager) { 90 | this.managers.uiManager.updateContainerMinHeight(); 91 | } 92 | 93 | // 更新SwipeManager以处理动态视频宽度 94 | if (this.managers.swipeManager) { 95 | this.managers.swipeManager.updateSize(); 96 | } 97 | }; 98 | this.targetVideo.addEventListener('loadedmetadata', this.handleMetadataLoadedBound); 99 | 100 | // 视频可以播放时也更新容器高度 101 | this.handleCanPlayBound = () => { 102 | if (this.managers.uiManager) { 103 | this.managers.uiManager.updateContainerMinHeight(); 104 | } 105 | 106 | // 更新SwipeManager以处理动态视频宽度 107 | if (this.managers.swipeManager) { 108 | this.managers.swipeManager.updateSize(); 109 | } 110 | }; 111 | this.targetVideo.addEventListener('canplay', this.handleCanPlayBound); 112 | 113 | // 视频尺寸变化时更新 114 | this.handleVideoResizeBound = () => { 115 | if (this.managers.uiManager) { 116 | this.managers.uiManager.updateContainerMinHeight(); 117 | } 118 | 119 | // 更新SwipeManager以处理动态视频宽度 120 | if (this.managers.swipeManager) { 121 | this.managers.swipeManager.updateSize(); 122 | } 123 | }; 124 | this.targetVideo.addEventListener('resize', this.handleVideoResizeBound); 125 | 126 | // 监听视频播放状态变化 127 | this.handlePlayBound = () => { 128 | if (this.managers.controlManager) { 129 | this.managers.controlManager.updatePlayPauseButton(); 130 | } 131 | }; 132 | this.targetVideo.addEventListener('play', this.handlePlayBound); 133 | 134 | this.handlePauseBound = () => { 135 | if (this.managers.controlManager) { 136 | this.managers.controlManager.updatePlayPauseButton(); 137 | this.managers.controlManager.showPauseIndicator(); 138 | } 139 | }; 140 | this.targetVideo.addEventListener('pause', this.handlePauseBound); 141 | } 142 | 143 | /** 144 | * 处理视频包装器点击事件 145 | */ 146 | handleVideoWrapperClick(e) { 147 | console.log('[EventManager] 视频包装器点击事件触发'); 148 | // 确保点击事件不被其他控制元素阻挡 149 | if (e.target === this.uiElements.videoWrapper || e.target === this.targetVideo) { 150 | // 检查锁定状态,防止快速多次触发 151 | if (this.clickLock) { 152 | console.log('[EventManager] 点击锁定中,忽略此次点击'); 153 | return; 154 | } 155 | 156 | // 检查是否刚完成拖动操作,如果是则不触发暂停/播放 157 | if (this.managers.swipeManager && typeof this.managers.swipeManager.wasRecentlyDragging === 'function' 158 | && this.managers.swipeManager.wasRecentlyDragging()) { 159 | console.log('[EventManager] 忽略拖动后的点击'); 160 | return; 161 | } 162 | 163 | // 设置点击锁定,防止短时间内重复触发 164 | this.clickLock = true; 165 | // 清除可能存在的旧定时器 166 | if (this.clickLockTimeout) { 167 | clearTimeout(this.clickLockTimeout); 168 | } 169 | // 500毫秒后解除锁定 170 | this.clickLockTimeout = setTimeout(() => { 171 | this.clickLock = false; 172 | this.clickLockTimeout = null; 173 | }, 500); 174 | 175 | console.log('[EventManager] 触发视频点击事件,当前状态:', this.targetVideo.paused ? '已暂停' : '正在播放'); 176 | 177 | if (this.targetVideo.paused) { 178 | this.targetVideo.play(); 179 | } else { 180 | this.targetVideo.pause(); 181 | if (this.managers.controlManager) { 182 | this.managers.controlManager.showPauseIndicator(); 183 | } 184 | } 185 | 186 | if (this.managers.controlManager) { 187 | this.managers.controlManager.updatePlayPauseButton(); 188 | } 189 | } 190 | } 191 | 192 | /** 193 | * 处理关闭按钮点击事件 194 | */ 195 | handleCloseButtonClick() { 196 | console.log('[EventManager] 处理关闭按钮点击'); 197 | // 先移除所有事件监听器 198 | this.cleanup(); 199 | // 然后关闭播放器 200 | this.playerCore.close( 201 | this.uiElements.overlay, 202 | this.uiElements.container, 203 | this.uiElements.playerContainer 204 | ); 205 | } 206 | 207 | /** 208 | * 处理设置按钮点击事件 209 | */ 210 | handleSettingsButtonClick() { 211 | if (this.managers.settingsManager) { 212 | this.managers.settingsManager.toggleSettingsPanel(); 213 | } 214 | } 215 | 216 | /** 217 | * 处理窗口大小变化 218 | */ 219 | handleWindowResize() { 220 | // 更新视频大小和位置 221 | if (this.managers.uiManager) { 222 | this.managers.uiManager.updateContainerMinHeight(); 223 | } 224 | 225 | if (this.managers.dragManager) { 226 | this.managers.dragManager.updateHandlePosition(); 227 | } 228 | 229 | // 更新SwipeManager以处理动态视频宽度 230 | if (this.managers.swipeManager) { 231 | this.managers.swipeManager.updateSize(); 232 | } 233 | } 234 | 235 | /** 236 | * 处理容器大小变化 (由 ResizeObserver 触发) 237 | */ 238 | handleContainerResize() { 239 | // 更新拖动手柄位置 240 | if (this.managers.dragManager) { 241 | this.managers.dragManager.updateHandlePosition(); 242 | } 243 | 244 | // 更新SwipeManager以处理动态视频宽度 245 | if (this.managers.swipeManager) { 246 | this.managers.swipeManager.updateSize(); 247 | } 248 | } 249 | 250 | /** 251 | * 清理所有事件监听器 252 | */ 253 | cleanup() { 254 | console.log('[EventManager] 清理所有事件监听器'); 255 | 256 | // 移除窗口事件 257 | window.removeEventListener('resize', this.handleWindowResizeBound); 258 | 259 | // 停止ResizeObserver 260 | if (this.resizeObserver) { 261 | this.resizeObserver.disconnect(); 262 | this.resizeObserver = null; 263 | } 264 | 265 | // 清除计时器 266 | if (this.clickLockTimeout) { 267 | clearTimeout(this.clickLockTimeout); 268 | this.clickLockTimeout = null; 269 | } 270 | 271 | // 视频包装器点击事件已由UIManager统一处理,此处不需要移除 272 | 273 | // 移除关闭按钮事件 274 | if (this.uiElements.closeBtn) { 275 | this.uiElements.closeBtn.removeEventListener('click', this.handleCloseButtonClickBound); 276 | } 277 | 278 | // 移除设置按钮事件 279 | if (this.uiElements.settingsBtn) { 280 | this.uiElements.settingsBtn.removeEventListener('click', this.handleSettingsButtonClickBound); 281 | } 282 | 283 | // 移除视频事件监听器 284 | if (this.targetVideo) { 285 | this.targetVideo.removeEventListener('loadedmetadata', this.handleMetadataLoadedBound); 286 | this.targetVideo.removeEventListener('canplay', this.handleCanPlayBound); 287 | this.targetVideo.removeEventListener('resize', this.handleVideoResizeBound); 288 | this.targetVideo.removeEventListener('play', this.handlePlayBound); 289 | this.targetVideo.removeEventListener('pause', this.handlePauseBound); 290 | } 291 | 292 | // 移除overlay的touchmove事件 293 | if (this.uiElements.overlay) { 294 | this.uiElements.overlay.removeEventListener('touchmove', this.handleOverlayTouchMoveBound); 295 | } 296 | } 297 | } -------------------------------------------------------------------------------- /src/player/managers/SettingsManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 设置管理器类 - 负责播放器设置功能 3 | */ 4 | export class SettingsManager { 5 | constructor(playerCore, uiElements) { 6 | // 核心引用 7 | this.playerCore = playerCore; 8 | this.targetVideo = playerCore.targetVideo; 9 | 10 | // UI元素引用 11 | this.uiElements = uiElements; 12 | this.settingsPanel = uiElements.settingsPanel; 13 | 14 | // 事件处理器 15 | this.overlayClickHandler = null; 16 | 17 | // 用户设置 18 | this.settings = { 19 | showSeekControlRow: true, // 显示快进快退按钮 20 | showLoopControlRow: true, // 显示循环控制按钮 21 | showPlaybackControlRow: true, // 显示播放控制按钮 22 | showProgressBar: true, // 显示进度条 23 | }; 24 | } 25 | 26 | /** 27 | * 初始化设置管理器 28 | */ 29 | init() { 30 | // 加载保存的设置 31 | this.loadSettings(); 32 | 33 | // 创建设置面板内容 34 | this.createSettingsPanel(); 35 | 36 | return this; 37 | } 38 | 39 | /** 40 | * 创建设置面板内容 41 | */ 42 | createSettingsPanel() { 43 | // 添加设置选项 44 | const settingsOptions = document.createElement('div'); 45 | settingsOptions.className = 'tm-settings-options'; 46 | settingsOptions.style.display = 'flex'; 47 | settingsOptions.style.flexDirection = 'column'; 48 | settingsOptions.style.gap = '12px'; 49 | 50 | // 添加显示进度条选项 51 | const progressBarOption = this.createSettingOption( 52 | '显示-进度条', 53 | 'showProgressBar', 54 | this.settings.showProgressBar, 55 | (checked) => { 56 | this.settings.showProgressBar = checked; 57 | this.saveSettings(); 58 | this.updateControlRowsVisibility(); 59 | } 60 | ); 61 | 62 | // 添加显示快进快退控制行选项 63 | const seekControlRowOption = this.createSettingOption( 64 | '显示-进度跳转', 65 | 'showSeekControlRow', 66 | this.settings.showSeekControlRow, 67 | (checked) => { 68 | this.settings.showSeekControlRow = checked; 69 | this.saveSettings(); 70 | this.updateControlRowsVisibility(); 71 | } 72 | ); 73 | 74 | // 添加显示循环控制行选项 75 | const loopControlRowOption = this.createSettingOption( 76 | '显示-循环控制', 77 | 'showLoopControlRow', 78 | this.settings.showLoopControlRow, 79 | (checked) => { 80 | this.settings.showLoopControlRow = checked; 81 | this.saveSettings(); 82 | this.updateControlRowsVisibility(); 83 | } 84 | ); 85 | 86 | // 添加显示播放控制行选项 87 | const playbackControlRowOption = this.createSettingOption( 88 | '显示-播放倍速', 89 | 'showPlaybackControlRow', 90 | this.settings.showPlaybackControlRow, 91 | (checked) => { 92 | this.settings.showPlaybackControlRow = checked; 93 | this.saveSettings(); 94 | this.updateControlRowsVisibility(); 95 | } 96 | ); 97 | 98 | settingsOptions.appendChild(progressBarOption); 99 | settingsOptions.appendChild(seekControlRowOption); 100 | settingsOptions.appendChild(loopControlRowOption); 101 | settingsOptions.appendChild(playbackControlRowOption); 102 | 103 | this.settingsPanel.appendChild(settingsOptions); 104 | } 105 | 106 | /** 107 | * 创建设置选项 108 | * @param {string} labelText 选项标签文本 109 | * @param {string} settingKey 设置键名 110 | * @param {boolean} initialValue 初始值 111 | * @param {Function} onChange 值变化时的回调函数 112 | * @returns {HTMLElement} 设置选项元素 113 | */ 114 | createSettingOption(labelText, settingKey, initialValue, onChange) { 115 | const optionContainer = document.createElement('div'); 116 | optionContainer.className = 'tm-settings-option'; 117 | optionContainer.id = `tm-setting-${settingKey}`; 118 | 119 | const label = document.createElement('label'); 120 | label.className = 'tm-settings-label'; 121 | label.textContent = labelText; 122 | 123 | // 创建一个开关样式的复选框 124 | const toggleContainer = document.createElement('div'); 125 | toggleContainer.className = 'tm-toggle-switch'; 126 | 127 | // 创建一个隐藏的复选框用于状态保存 128 | const toggleInput = document.createElement('input'); 129 | toggleInput.type = 'checkbox'; 130 | toggleInput.checked = initialValue; 131 | toggleInput.className = 'tm-toggle-input'; 132 | 133 | const toggleSlider = document.createElement('span'); 134 | toggleSlider.className = initialValue ? 'tm-toggle-slider checked' : 'tm-toggle-slider'; 135 | 136 | // 添加 tabIndex 使其可聚焦 137 | optionContainer.tabIndex = 0; 138 | 139 | toggleContainer.appendChild(toggleInput); 140 | toggleContainer.appendChild(toggleSlider); 141 | 142 | // 添加点击处理 143 | const toggleSwitch = (e) => { 144 | e.preventDefault(); 145 | e.stopPropagation(); 146 | 147 | // 更新复选框状态 148 | toggleInput.checked = !toggleInput.checked; 149 | 150 | // 更新样式 151 | if (toggleInput.checked) { 152 | toggleSlider.className = 'tm-toggle-slider checked'; 153 | } else { 154 | toggleSlider.className = 'tm-toggle-slider'; 155 | } 156 | 157 | // 执行回调函数 158 | if (typeof onChange === 'function') { 159 | onChange(toggleInput.checked); 160 | } 161 | }; 162 | 163 | // 让整个选项可点击 164 | optionContainer.addEventListener('click', toggleSwitch); 165 | 166 | // 添加键盘支持 167 | optionContainer.addEventListener('keydown', (e) => { 168 | if (e.key === 'Enter' || e.key === ' ') { 169 | e.preventDefault(); 170 | toggleSwitch(e); 171 | } 172 | }); 173 | 174 | optionContainer.appendChild(label); 175 | optionContainer.appendChild(toggleContainer); 176 | 177 | return optionContainer; 178 | } 179 | 180 | /** 181 | * 切换设置面板显示状态 182 | */ 183 | toggleSettingsPanel() { 184 | const isVisible = this.settingsPanel.classList.contains('active'); 185 | if (isVisible) { 186 | this.closeSettingsPanel(); 187 | } else { 188 | this.settingsPanel.style.display = 'block'; 189 | 190 | // 使用动画淡入 191 | setTimeout(() => { 192 | this.settingsPanel.classList.add('active'); 193 | }, 10); 194 | 195 | // 添加点击overlay背景关闭设置面板的事件 196 | this.overlayClickHandler = (e) => { 197 | // 如果点击的不是设置面板内的元素,则关闭设置面板 198 | if (!this.settingsPanel.contains(e.target) && e.target !== this.uiElements.settingsBtn) { 199 | this.closeSettingsPanel(); 200 | } 201 | }; 202 | 203 | // 延迟添加事件监听器,避免与当前点击冲突 204 | setTimeout(() => { 205 | if (this.uiElements.overlay) { 206 | this.uiElements.overlay.addEventListener('click', this.overlayClickHandler); 207 | } 208 | }, 50); 209 | } 210 | } 211 | 212 | /** 213 | * 关闭设置面板 214 | */ 215 | closeSettingsPanel() { 216 | this.settingsPanel.classList.remove('active'); 217 | 218 | // 移除点击事件监听器 219 | if (this.uiElements.overlay && this.overlayClickHandler) { 220 | this.uiElements.overlay.removeEventListener('click', this.overlayClickHandler); 221 | this.overlayClickHandler = null; 222 | } 223 | 224 | // 等待动画完成后隐藏 225 | setTimeout(() => { 226 | this.settingsPanel.style.display = 'none'; 227 | }, 300); 228 | } 229 | 230 | /** 231 | * 加载设置 232 | */ 233 | loadSettings() { 234 | try { 235 | // 创建临时函数来获取设置 236 | const getValue = (key, defaultValue) => { 237 | try { 238 | if (typeof GM_getValue === 'function') { 239 | return GM_getValue(key, defaultValue); 240 | } else { 241 | const value = localStorage.getItem(`missNoAD_${key}`); 242 | return value !== null ? JSON.parse(value) : defaultValue; 243 | } 244 | } catch (e) { 245 | console.debug(`获取${key}设置失败:`, e); 246 | return defaultValue; 247 | } 248 | }; 249 | 250 | // 加载设置,如果不存在则使用默认值 251 | this.settings.showProgressBar = getValue('showProgressBar', true); 252 | this.settings.showSeekControlRow = getValue('showSeekControlRow', true); 253 | this.settings.showLoopControlRow = getValue('showLoopControlRow', true); 254 | this.settings.showPlaybackControlRow = getValue('showPlaybackControlRow', true); 255 | 256 | } catch (error) { 257 | console.error('加载设置时出错:', error); 258 | } 259 | } 260 | 261 | /** 262 | * 保存设置 263 | */ 264 | saveSettings() { 265 | try { 266 | // 创建临时函数来保存设置 267 | const setValue = (key, value) => { 268 | try { 269 | if (typeof GM_setValue === 'function') { 270 | GM_setValue(key, value); 271 | return true; 272 | } else { 273 | localStorage.setItem(`missNoAD_${key}`, JSON.stringify(value)); 274 | return true; 275 | } 276 | } catch (e) { 277 | console.debug(`保存${key}设置失败:`, e); 278 | return false; 279 | } 280 | }; 281 | setValue('showProgressBar', this.settings.showProgressBar); 282 | setValue('showSeekControlRow', this.settings.showSeekControlRow); 283 | setValue('showLoopControlRow', this.settings.showLoopControlRow); 284 | setValue('showPlaybackControlRow', this.settings.showPlaybackControlRow); 285 | 286 | } catch (error) { 287 | console.error('保存设置时出错:', error); 288 | } 289 | } 290 | 291 | /** 292 | * 更新控制行的可见性 293 | */ 294 | updateControlRowsVisibility() { 295 | const controlButtonsContainer = document.querySelector('.tm-control-buttons'); 296 | if (!controlButtonsContainer) return; 297 | 298 | const seekControlRow = controlButtonsContainer.querySelector('.tm-seek-control-row'); 299 | const loopControlRow = controlButtonsContainer.querySelector('.tm-loop-control-row'); 300 | const playbackControlRow = controlButtonsContainer.querySelector('.tm-playback-control-row'); 301 | const progressRow = controlButtonsContainer.querySelector('.tm-progress-row'); 302 | 303 | if (progressRow) { 304 | progressRow.style.display = this.settings.showProgressBar ? 'flex' : 'none'; 305 | } 306 | 307 | if (seekControlRow) { 308 | seekControlRow.style.display = this.settings.showSeekControlRow ? 'flex' : 'none'; 309 | } 310 | 311 | if (loopControlRow) { 312 | loopControlRow.style.display = this.settings.showLoopControlRow ? 'flex' : 'none'; 313 | } 314 | 315 | if (playbackControlRow) { 316 | playbackControlRow.style.display = this.settings.showPlaybackControlRow ? 'flex' : 'none'; 317 | } 318 | 319 | } 320 | 321 | /** 322 | * 更新设置项 323 | * @param {string} key 设置键名 324 | * @param {any} value 设置值 325 | */ 326 | updateSetting(key, value) { 327 | if (this.settings.hasOwnProperty(key)) { 328 | this.settings[key] = value; 329 | this.saveSettings(); 330 | 331 | // 如果涉及UI可见性,更新UI 332 | if (key.startsWith('show') && key.endsWith('Row')) { 333 | this.updateControlRowsVisibility(); 334 | } 335 | } 336 | } 337 | } -------------------------------------------------------------------------------- /src/player/managers/ProgressManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 进度管理器类 - 负责进度条和时间显示功能 3 | */ 4 | export class ProgressManager { 5 | constructor(playerCore, uiElements) { 6 | // 核心引用 7 | this.playerCore = playerCore; 8 | this.targetVideo = playerCore.targetVideo; 9 | 10 | // UI元素引用 11 | this.uiElements = uiElements; 12 | this.progressBarElement = null; // 进度条元素 13 | this.progressIndicator = null; // 进度指示器 14 | this.currentTimeDisplay = null; // 当前时间显示 15 | this.totalDurationDisplay = null; // 总时长显示 16 | this.timeIndicator = null; // 时间指示器 17 | 18 | // 拖动状态 19 | this.isDraggingProgress = false; // 是否正在拖动进度条 20 | this.progressHandleMoveHandler = null; // 进度条移动事件处理函数 21 | this.progressHandleUpHandler = null; // 进度条释放事件处理函数 22 | this.lastDragX = 0; // 上次拖动位置的X坐标 23 | this.isTouchDevice = 'ontouchstart' in window; // 检测是否为触摸设备 24 | } 25 | 26 | /** 27 | * 初始化进度管理器 28 | */ 29 | init(progressElements) { 30 | this.progressBarElement = progressElements.progressBarElement; 31 | this.progressIndicator = progressElements.progressIndicator; 32 | this.currentTimeDisplay = progressElements.currentTimeDisplay; 33 | this.totalDurationDisplay = progressElements.totalDurationDisplay; 34 | this.timeIndicator = progressElements.timeIndicator; 35 | 36 | // 进度条容器元素 (父元素) 37 | this.progressBarContainer = this.progressBarElement.parentElement; 38 | 39 | // 添加进度条事件监听 40 | this.progressBarElement.addEventListener('click', this.handleProgressClick.bind(this)); 41 | 42 | // 为整个进度条容器添加拖动事件监听,增加可点击/拖动区域 43 | this.progressBarContainer.addEventListener('mousedown', this.startProgressDrag.bind(this)); 44 | this.progressBarContainer.addEventListener('touchstart', this.startProgressDrag.bind(this), { passive: false }); 45 | 46 | // 监听视频时间更新 47 | this.targetVideo.addEventListener('timeupdate', this.updateProgressBar.bind(this)); 48 | 49 | return this; 50 | } 51 | 52 | /** 53 | * 更新进度条 54 | */ 55 | updateProgressBar() { 56 | if (!this.targetVideo || !this.progressBarElement || !this.progressIndicator) return; 57 | 58 | const currentTime = this.targetVideo.currentTime; 59 | const duration = this.targetVideo.duration; 60 | 61 | if (isNaN(duration) || duration <= 0) return; 62 | 63 | // 计算进度百分比 64 | const progressPercent = (currentTime / duration) * 100; 65 | 66 | // 更新进度指示器的宽度 67 | this.progressIndicator.style.width = `${progressPercent}%`; 68 | 69 | // 更新时间显示 70 | this.updateCurrentTimeDisplay(); 71 | } 72 | 73 | /** 74 | * 更新当前时间显示 75 | */ 76 | updateCurrentTimeDisplay() { 77 | if (!this.targetVideo || !this.currentTimeDisplay || !this.totalDurationDisplay) return; 78 | 79 | const currentTime = this.targetVideo.currentTime; 80 | const duration = this.targetVideo.duration; 81 | 82 | if (isNaN(duration)) return; 83 | 84 | // 更新当前时间显示 85 | this.currentTimeDisplay.textContent = this.formatTime(currentTime); 86 | 87 | // 计算并显示剩余时长,而不是总时长 88 | const remainingTime = duration - currentTime; 89 | this.totalDurationDisplay.textContent = `-${this.formatTime(remainingTime)}`; 90 | } 91 | 92 | /** 93 | * 格式化时间 94 | */ 95 | formatTime(seconds) { 96 | if (isNaN(seconds) || seconds < 0) { 97 | return '00:00:00'; 98 | } 99 | const totalSeconds = Math.floor(seconds); 100 | const hours = Math.floor(totalSeconds / 3600); 101 | const minutes = Math.floor((totalSeconds % 3600) / 60); 102 | const remainingSeconds = totalSeconds % 60; 103 | 104 | return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; 105 | } 106 | 107 | /** 108 | * 处理进度条点击 109 | */ 110 | handleProgressClick(e) { 111 | // 如果正在拖动进度条,则忽略点击事件 112 | if (this.isDraggingProgress) return; 113 | 114 | // 获取进度条的位置信息 115 | const rect = this.progressBarElement.getBoundingClientRect(); 116 | 117 | // 计算点击位置相对于进度条的比例 (0-1) 118 | const relativePos = (e.clientX - rect.left) / rect.width; 119 | 120 | // 计算目标时间 121 | const duration = this.targetVideo.duration; 122 | if (isNaN(duration)) return; 123 | 124 | const targetTime = duration * relativePos; 125 | 126 | // 设置视频当前时间 127 | this.targetVideo.currentTime = targetTime; 128 | 129 | // 更新进度条 130 | this.updateProgressBar(); 131 | } 132 | 133 | /** 134 | * 相对时间跳转 135 | */ 136 | seekRelative(seconds) { 137 | if (!this.targetVideo) return; 138 | 139 | const newTime = Math.max(0, Math.min(this.targetVideo.duration, this.targetVideo.currentTime + seconds)); 140 | this.targetVideo.currentTime = newTime; 141 | } 142 | 143 | /** 144 | * 开始进度条拖动 145 | */ 146 | startProgressDrag(e) { 147 | // 阻止默认行为,防止选择文本或触发其他事件 148 | e.preventDefault(); 149 | e.stopPropagation(); 150 | 151 | // 设置拖动状态 152 | this.isDraggingProgress = true; 153 | 154 | // 保存初始点击位置 155 | this.lastDragX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; 156 | 157 | // 保持进度条高度以获得更好的拖动体验 158 | this.progressBarElement.classList.add('tm-progress-bar-expanded'); 159 | this.progressBarElement.classList.remove('tm-progress-bar-normal'); 160 | 161 | // 添加拖动状态标记 162 | this.progressBarElement.classList.add('tm-dragging'); 163 | 164 | // 显示时间指示器 165 | if (this.timeIndicator) { 166 | this.timeIndicator.style.display = 'block'; 167 | this.timeIndicator.style.opacity = '1'; 168 | this.updateTimeIndicator(e); 169 | } 170 | 171 | // 绑定移动和释放事件处理函数 172 | const moveHandler = this.handleProgressMove.bind(this); 173 | const upHandler = this.handleProgressUp.bind(this); 174 | 175 | // 清除之前可能存在的事件监听 176 | this.removeProgressEventListeners(); 177 | 178 | // 添加事件监听 - 使用 document 以捕获所有移动事件,即使鼠标移出进度条 179 | if (e.type.includes('touch')) { 180 | document.addEventListener('touchmove', moveHandler, { passive: false }); 181 | document.addEventListener('touchend', upHandler, { passive: false }); 182 | document.addEventListener('touchcancel', upHandler, { passive: false }); 183 | } else { 184 | document.addEventListener('mousemove', moveHandler); 185 | document.addEventListener('mouseup', upHandler); 186 | document.addEventListener('mouseleave', upHandler); 187 | } 188 | 189 | this.progressHandleMoveHandler = moveHandler; 190 | this.progressHandleUpHandler = upHandler; 191 | 192 | // 立即调整到点击位置(与handleProgressClick的功能一致) 193 | const rect = this.progressBarElement.getBoundingClientRect(); 194 | const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; 195 | let relativePos = (clientX - rect.left) / rect.width; 196 | relativePos = Math.max(0, Math.min(1, relativePos)); 197 | 198 | const duration = this.targetVideo.duration; 199 | if (!isNaN(duration)) { 200 | const newTime = duration * relativePos; 201 | this.targetVideo.currentTime = newTime; 202 | this.progressIndicator.style.width = `${relativePos * 100}%`; 203 | this.updateCurrentTimeDisplay(); 204 | } 205 | } 206 | 207 | /** 208 | * 处理进度条拖动移动 209 | */ 210 | handleProgressMove(e) { 211 | // 如果不是处于拖动状态,则退出 212 | if (!this.isDraggingProgress) return; 213 | 214 | // 阻止默认行为 215 | e.preventDefault(); 216 | 217 | // 获取当前位置 218 | const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; 219 | 220 | // 更新时间指示器 221 | this.updateTimeIndicator(e); 222 | 223 | // 计算新的进度位置 224 | const rect = this.progressBarElement.getBoundingClientRect(); 225 | 226 | // 确保进度条元素可见并有宽度 227 | if (rect.width <= 0) return; 228 | 229 | // 计算相对位置 (0-1) 230 | let relativePos = (clientX - rect.left) / rect.width; 231 | relativePos = Math.max(0, Math.min(1, relativePos)); 232 | 233 | // 计算新时间 234 | const duration = this.targetVideo.duration; 235 | if (isNaN(duration)) return; 236 | 237 | const newTime = duration * relativePos; 238 | 239 | // 更新进度指示器位置 240 | this.progressIndicator.style.width = `${relativePos * 100}%`; 241 | 242 | // 实时更新视频播放位置 243 | this.targetVideo.currentTime = newTime; 244 | 245 | // 更新时间显示 246 | this.currentTimeDisplay.textContent = this.formatTime(newTime); 247 | const remainingTime = duration - newTime; 248 | this.totalDurationDisplay.textContent = `-${this.formatTime(remainingTime)}`; 249 | 250 | // 更新最后拖动位置 251 | this.lastDragX = clientX; 252 | } 253 | 254 | /** 255 | * 处理进度条释放 256 | */ 257 | handleProgressUp(e) { 258 | // 如果不是处于拖动状态,则退出 259 | if (!this.isDraggingProgress) return; 260 | 261 | // 计算最终位置并设置视频时间 262 | const rect = this.progressBarElement.getBoundingClientRect(); 263 | const clientX = e.type.includes('touch') ? 264 | (e.changedTouches && e.changedTouches[0] ? e.changedTouches[0].clientX : this.lastDragX) : 265 | (e.clientX || this.lastDragX); 266 | 267 | // 计算相对位置 (0-1) 268 | let relativePos = (clientX - rect.left) / rect.width; 269 | relativePos = Math.max(0, Math.min(1, relativePos)); 270 | 271 | // 设置视频当前时间 272 | const duration = this.targetVideo.duration; 273 | if (!isNaN(duration)) { 274 | this.targetVideo.currentTime = duration * relativePos; 275 | } 276 | 277 | // 隐藏时间指示器 278 | if (this.timeIndicator) { 279 | this.timeIndicator.style.opacity = '0'; 280 | } 281 | 282 | // 移除拖动状态标记 283 | this.progressBarElement.classList.remove('tm-dragging'); 284 | 285 | // 恢复进度条高度 286 | if (!this.progressBarElement.classList.contains('tm-progress-bar-hovered')) { 287 | this.progressBarElement.classList.add('tm-progress-bar-normal'); 288 | this.progressBarElement.classList.remove('tm-progress-bar-expanded'); 289 | } 290 | 291 | // 清理状态和事件 292 | this.isDraggingProgress = false; 293 | this.lastDragX = 0; // 重置拖动坐标 294 | 295 | // 移除事件监听 296 | this.removeProgressEventListeners(); 297 | } 298 | 299 | /** 300 | * 移除进度条相关事件监听 301 | */ 302 | removeProgressEventListeners() { 303 | if (this.progressHandleMoveHandler) { 304 | document.removeEventListener('mousemove', this.progressHandleMoveHandler); 305 | document.removeEventListener('touchmove', this.progressHandleMoveHandler); 306 | } 307 | 308 | if (this.progressHandleUpHandler) { 309 | document.removeEventListener('mouseup', this.progressHandleUpHandler); 310 | document.removeEventListener('touchend', this.progressHandleUpHandler); 311 | document.removeEventListener('touchcancel', this.progressHandleUpHandler); 312 | document.removeEventListener('mouseleave', this.progressHandleUpHandler); 313 | } 314 | 315 | this.progressHandleMoveHandler = null; 316 | this.progressHandleUpHandler = null; 317 | } 318 | 319 | /** 320 | * 更新时间指示器位置和内容 321 | */ 322 | updateTimeIndicator(e) { 323 | if (!this.timeIndicator || !this.targetVideo) return; 324 | 325 | const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; 326 | const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY; 327 | 328 | // 获取视频容器的位置 329 | const videoRect = this.uiElements.videoWrapper.getBoundingClientRect(); 330 | const progressRect = this.progressBarElement.getBoundingClientRect(); 331 | 332 | // 计算指示器位置,确保始终在视频区域内部 333 | let leftPos = Math.max(videoRect.left + 10, Math.min(videoRect.right - 10, clientX)); 334 | 335 | // 计算垂直位置 - 放在进度条上方一定距离 336 | let topPos = progressRect.top - 20; 337 | 338 | // 设置指示器位置 339 | this.timeIndicator.style.left = `${leftPos}px`; 340 | this.timeIndicator.style.top = `${topPos}px`; 341 | 342 | // 计算时间 343 | const relativePos = (clientX - progressRect.left) / progressRect.width; 344 | const boundedPos = Math.max(0, Math.min(1, relativePos)); 345 | 346 | const duration = this.targetVideo.duration; 347 | if (isNaN(duration)) return; 348 | 349 | const time = duration * boundedPos; 350 | 351 | // 更新指示器内容 352 | this.timeIndicator.textContent = `${this.formatTime(time)} / ${this.formatTime(duration)}`; 353 | } 354 | } -------------------------------------------------------------------------------- /src/player/managers/LoopManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 循环管理器类 - 负责循环播放功能 3 | */ 4 | export class LoopManager { 5 | constructor(playerCore, uiElements) { 6 | // 核心引用 7 | this.playerCore = playerCore; 8 | this.targetVideo = playerCore.targetVideo; 9 | 10 | // UI元素引用 11 | this.uiElements = uiElements; 12 | 13 | // 循环控制相关 14 | this.loopStartTime = null; 15 | this.loopEndTime = null; 16 | this.loopActive = false; 17 | this.loopStartMarker = null; 18 | this.loopEndMarker = null; 19 | this.loopRangeElement = null; 20 | this.currentPositionDisplay = null; 21 | this.durationDisplay = null; 22 | this.loopToggleButton = null; 23 | 24 | // 时间更新处理器 25 | this._handleLoopTimeUpdate = this._handleLoopTimeUpdate.bind(this); 26 | } 27 | 28 | /** 29 | * 初始化循环管理器 30 | */ 31 | init(loopElements) { 32 | this.loopStartMarker = loopElements.loopStartMarker; 33 | this.loopEndMarker = loopElements.loopEndMarker; 34 | this.loopRangeElement = loopElements.loopRangeElement; 35 | this.currentPositionDisplay = loopElements.currentPositionDisplay; 36 | this.durationDisplay = loopElements.durationDisplay; 37 | this.loopToggleButton = loopElements.loopToggleButton; 38 | 39 | // 解析URL参数设置循环点 40 | this._parseUrlHashParams(); 41 | 42 | return this; 43 | } 44 | 45 | /** 46 | * 状态管理方法 - 统一更新状态并触发UI更新 47 | * @param {Object} newState - 要更新的状态对象 48 | */ 49 | setState(newState) { 50 | // 记录状态变化的日志(便于调试) 51 | console.log('[LoopManager] 状态更新:', 52 | Object.keys(newState).map(key => `${key}: ${newState[key]}`).join(', ')); 53 | 54 | // 更新状态 55 | Object.assign(this, newState); 56 | 57 | // 触发UI更新 58 | this._updateUI(); 59 | 60 | // 返回this以支持链式调用 61 | return this; 62 | } 63 | 64 | /** 65 | * 解析URL hash参数并设置循环点 66 | */ 67 | _parseUrlHashParams() { 68 | if (!window.location.hash) return; 69 | 70 | const hash = window.location.hash.substring(1); // 去掉# 71 | 72 | // 检查是否有时间区间 (格式: 00:00:09-00:00:13) 73 | if (hash.includes('-')) { 74 | const [startTime, endTime] = hash.split('-'); 75 | const startSeconds = this._parseTimeString(startTime); 76 | const endSeconds = this._parseTimeString(endTime); 77 | 78 | if (startSeconds !== null && endSeconds !== null) { 79 | console.log(`[LoopManager] 从URL解析循环区间: ${startTime}-${endTime}`); 80 | 81 | // 设置循环点(不使用setState避免提前更新UI) 82 | const newState = { 83 | loopStartTime: startSeconds, 84 | loopEndTime: endSeconds 85 | }; 86 | 87 | // 等视频元数据加载完成后再跳转和启用循环 88 | const handleMetadata = () => { 89 | // 直接更新时间显示,避免时序问题 90 | if (this.currentPositionDisplay) { 91 | this.currentPositionDisplay.textContent = this.formatTimeWithHours(startSeconds); 92 | this.currentPositionDisplay.classList.add('active'); 93 | const startContainer = document.querySelector('.tm-start-time-container'); 94 | if (startContainer) startContainer.classList.add('active'); 95 | } 96 | 97 | if (this.durationDisplay) { 98 | this.durationDisplay.textContent = this.formatTimeWithHours(endSeconds); 99 | this.durationDisplay.classList.add('active'); 100 | const endContainer = document.querySelector('.tm-end-time-container'); 101 | if (endContainer) endContainer.classList.add('active'); 102 | } 103 | 104 | // 跳转到开始点 105 | this.targetVideo.currentTime = startSeconds; 106 | 107 | // 保留对missav网站的特殊检查 108 | if (window.location.hostname.includes('missav')) { 109 | // 在missav网站上,循环播放是默认启用的 110 | newState.loopActive = true; 111 | console.log('[LoopManager] 在missav网站上设置循环状态'); 112 | } else { 113 | // 在其他网站上,也需要启用循环 114 | newState.loopActive = true; 115 | console.log('[LoopManager] 在其他网站上设置循环状态'); 116 | } 117 | 118 | // 统一更新状态和UI(将触发_updateUI,更新所有按钮状态) 119 | this.setState(newState); 120 | 121 | // 强制更新标记点和进度条 122 | this.updateLoopMarkers(); 123 | 124 | // 添加事件监听器 125 | this.targetVideo.removeEventListener('timeupdate', this._handleLoopTimeUpdate); 126 | this.targetVideo.addEventListener('timeupdate', this._handleLoopTimeUpdate); 127 | 128 | // 自动播放视频 129 | if (this.targetVideo.paused) { 130 | this.targetVideo.play().catch(error => { 131 | console.log('视频自动播放被阻止: ', error); 132 | // 不再尝试静音播放,保持暂停状态 133 | }); 134 | } 135 | 136 | // 移除监听器 137 | this.targetVideo.removeEventListener('loadedmetadata', handleMetadata); 138 | }; 139 | 140 | if (this.targetVideo.readyState >= 1) { 141 | handleMetadata(); 142 | } else { 143 | this.targetVideo.addEventListener('loadedmetadata', handleMetadata); 144 | } 145 | } 146 | } 147 | // 检查是否只有单个时间点 (格式: 00:00:09) 148 | else if (hash.match(/^\d{2}:\d{2}:\d{2}$/)) { 149 | const startSeconds = this._parseTimeString(hash); 150 | 151 | if (startSeconds !== null) { 152 | console.log(`[LoopManager] 从URL解析时间点: ${hash}`); 153 | 154 | // 等视频元数据加载完成后再跳转 155 | const handleMetadata = () => { 156 | // 直接更新时间显示,避免时序问题 157 | if (this.currentPositionDisplay) { 158 | this.currentPositionDisplay.textContent = this.formatTimeWithHours(startSeconds); 159 | this.currentPositionDisplay.classList.add('active'); 160 | const startContainer = document.querySelector('.tm-start-time-container'); 161 | if (startContainer) startContainer.classList.add('active'); 162 | } 163 | 164 | // 跳转到指定时间点并更新状态 165 | this.targetVideo.currentTime = startSeconds; 166 | 167 | // 更新状态(将触发_updateUI,更新A按钮样式) 168 | this.setState({ loopStartTime: startSeconds }); 169 | 170 | // 强制更新标记点 171 | this.updateLoopMarkers(); 172 | 173 | // 移除监听器 174 | this.targetVideo.removeEventListener('loadedmetadata', handleMetadata); 175 | }; 176 | 177 | if (this.targetVideo.readyState >= 1) { 178 | handleMetadata(); 179 | } else { 180 | this.targetVideo.addEventListener('loadedmetadata', handleMetadata); 181 | } 182 | } 183 | } 184 | } 185 | 186 | /** 187 | * 将时间字符串解析为秒数 188 | * @param {string} timeString - 格式为 "HH:MM:SS" 的时间字符串 189 | * @returns {number|null} - 解析出的秒数,或null(如果解析失败) 190 | */ 191 | _parseTimeString(timeString) { 192 | if (!timeString) return null; 193 | 194 | const match = timeString.match(/^(\d{2}):(\d{2}):(\d{2})$/); 195 | if (!match) return null; 196 | 197 | const hours = parseInt(match[1], 10); 198 | const minutes = parseInt(match[2], 10); 199 | const seconds = parseInt(match[3], 10); 200 | 201 | return hours * 3600 + minutes * 60 + seconds; 202 | } 203 | 204 | /** 205 | * 更新URL,添加循环点信息 206 | */ 207 | _updateUrlHash() { 208 | let hash = ''; 209 | 210 | if (this.loopStartTime !== null) { 211 | hash = this.formatTimeWithHours(this.loopStartTime); 212 | 213 | if (this.loopEndTime !== null) { 214 | hash += `-${this.formatTimeWithHours(this.loopEndTime)}`; 215 | } 216 | } 217 | 218 | // 使用history.replaceState更新URL而不添加历史记录 219 | if (hash) { 220 | const newUrl = window.location.pathname + window.location.search + '#' + hash; 221 | window.history.replaceState(null, '', newUrl); 222 | console.log(`[LoopManager] 更新URL: ${newUrl}`); 223 | } 224 | } 225 | 226 | // 模拟点击-复制开始时间 227 | _clickCopyStartTime() { 228 | const startTimeButton = document.querySelector('input#clip-start-time + a'); 229 | startTimeButton.click(); 230 | } 231 | 232 | // 模拟点击-复制结束时间 233 | _clickCopyEndTime() { 234 | const endTimeButton = document.querySelector('input#clip-end-time + a'); 235 | endTimeButton.click(); 236 | } 237 | 238 | // 模拟点击-切换循环播放 239 | _toggleLooping() { 240 | const loopButton = document.querySelector('.sm\\:ml-6 button'); 241 | loopButton.click(); 242 | } 243 | 244 | /** 245 | * 设置循环结束点 - 复制当前播放时间 246 | */ 247 | setLoopEnd() { 248 | if (!this.targetVideo) return; 249 | 250 | const currentTime = this.targetVideo.currentTime; 251 | 252 | if (window.location.hostname.includes('missav')) { 253 | this._clickCopyEndTime(); 254 | // 使用setState更新状态 255 | this.setState({ loopEndTime: currentTime }); 256 | } else { 257 | 258 | // 如果开始点已设置,确保结束点在开始点之后 259 | if (this.loopStartTime !== null && currentTime <= this.loopStartTime) { 260 | console.log('[LoopManager] 循环结束点必须在开始点之后'); 261 | return; 262 | } 263 | 264 | // 使用setState更新状态 265 | this.setState({ loopEndTime: currentTime }); 266 | console.log(`[LoopManager] 设置循环结束点: ${this.formatTimeWithHours(currentTime)}`); 267 | 268 | // 更新URL 269 | this._updateUrlHash(); 270 | } 271 | 272 | // 触觉反馈 273 | if (window.navigator.vibrate) { 274 | window.navigator.vibrate(10); 275 | } 276 | } 277 | 278 | /** 279 | * 设置循环开始点 - 复制当前播放时间 280 | */ 281 | setLoopStart() { 282 | if (!this.targetVideo) return; 283 | 284 | const currentTime = this.targetVideo.currentTime; 285 | 286 | if (window.location.hostname.includes('missav')) { 287 | this._clickCopyStartTime(); 288 | // 使用setState更新状态 289 | this.setState({ loopStartTime: currentTime }); 290 | } else { 291 | 292 | // 如果结束点已设置,确保开始点在结束点之前 293 | if (this.loopEndTime !== null && currentTime >= this.loopEndTime) { 294 | console.log('[LoopManager] 循环开始点必须在结束点之前'); 295 | return; 296 | } 297 | 298 | // 使用setState更新状态 299 | this.setState({ loopStartTime: currentTime }); 300 | console.log(`[LoopManager] 设置循环开始点: ${this.formatTimeWithHours(currentTime)}`); 301 | 302 | // 更新URL 303 | this._updateUrlHash(); 304 | } 305 | 306 | // 触觉反馈 307 | if (window.navigator.vibrate) { 308 | window.navigator.vibrate(10); 309 | } 310 | } 311 | 312 | /** 313 | * 启用/禁用循环播放 - 切换循环状态 314 | */ 315 | toggleLoop() { 316 | if (window.location.hostname.includes('missav')) { 317 | this._toggleLooping(); 318 | } else { 319 | // 检查是否已设置开始和结束时间 320 | if (this.loopStartTime === null || this.loopEndTime === null) { 321 | console.log("请先使用 A 和 B 按钮记录循环的开始和结束时间。"); 322 | return; 323 | } 324 | 325 | // 切换循环状态 326 | const newLoopActive = !this.loopActive; 327 | 328 | // 根据新状态执行相应操作 329 | if (newLoopActive) { 330 | this.enableLoop(); 331 | } else { 332 | this.disableLoop(); 333 | } 334 | } 335 | } 336 | 337 | /** 338 | * 启用循环播放 339 | */ 340 | enableLoop() { 341 | if (!this.targetVideo || this.loopStartTime === null || this.loopEndTime === null) { 342 | console.log('[LoopManager] 无法启用循环: 循环点未设置'); 343 | return; 344 | } 345 | 346 | console.log(`[LoopManager] 启用循环播放: ${this.formatTimeWithHours(this.loopStartTime)} - ${this.formatTimeWithHours(this.loopEndTime)}`); 347 | 348 | // 更新状态 349 | this.setState({ loopActive: true }); 350 | 351 | // 移除原有监听器,确保不重复添加 352 | this.targetVideo.removeEventListener('timeupdate', this._handleLoopTimeUpdate); 353 | 354 | // 添加时间更新监听器 355 | this.targetVideo.addEventListener('timeupdate', this._handleLoopTimeUpdate); 356 | 357 | // 如果当前时间不在循环范围内,跳转到循环起始点 358 | if (this.targetVideo.currentTime < this.loopStartTime || this.targetVideo.currentTime > this.loopEndTime) { 359 | this.targetVideo.currentTime = this.loopStartTime; 360 | } 361 | 362 | // 无论视频是否暂停,都开始播放 363 | if (this.targetVideo.paused) { 364 | this.targetVideo.play().catch(error => { 365 | console.log('视频自动播放被阻止: ', error); 366 | // 不再尝试静音播放,保持暂停状态 367 | }); 368 | } 369 | 370 | // 触觉反馈 371 | if (window.navigator.vibrate) { 372 | window.navigator.vibrate([10, 30, 10]); 373 | } 374 | } 375 | 376 | /** 377 | * 禁用循环播放 378 | */ 379 | disableLoop() { 380 | if (!this.loopActive) return; 381 | 382 | console.log('[LoopManager] 禁用循环播放'); 383 | 384 | // 移除事件监听器 385 | this.targetVideo.removeEventListener('timeupdate', this._handleLoopTimeUpdate); 386 | 387 | // 更新状态 388 | this.setState({ loopActive: false }); 389 | } 390 | 391 | /** 392 | * 循环播放时间更新处理器 393 | * 在播放到结束点时自动跳回开始点 394 | */ 395 | _handleLoopTimeUpdate() { 396 | if (!this.loopActive || this.loopStartTime === null || this.loopEndTime === null) return; 397 | 398 | const currentTime = this.targetVideo.currentTime; 399 | 400 | // 如果当前时间超过了循环结束点或小于开始点,跳回循环开始点 401 | if (currentTime >= this.loopEndTime || currentTime < this.loopStartTime) { 402 | this.targetVideo.currentTime = this.loopStartTime; 403 | } 404 | } 405 | 406 | /** 407 | * 更新所有UI元素 408 | */ 409 | _updateUI() { 410 | console.log('[LoopManager] 更新UI元素 - 循环状态:', 411 | this.loopActive ? '激活' : '未激活', 412 | '开始点:', this.loopStartTime !== null ? this.formatTimeWithHours(this.loopStartTime) : '未设置', 413 | '结束点:', this.loopEndTime !== null ? this.formatTimeWithHours(this.loopEndTime) : '未设置'); 414 | 415 | // 更新循环时间显示(A和B按钮) 416 | this.updateLoopTimeDisplay(); 417 | 418 | // 更新循环标记点 419 | this.updateLoopMarkers(); 420 | 421 | // 更新循环按钮样式 422 | this._updateLoopButtonStyle(); 423 | } 424 | 425 | /** 426 | * 更新循环开关按钮状态 427 | */ 428 | _updateLoopButtonStyle() { 429 | if (!this.loopToggleButton) return; 430 | 431 | if (this.loopActive) { 432 | // 激活状态 - 使用CSS类控制样式 433 | this.loopToggleButton.classList.add('active'); 434 | 435 | // 更新指示器圆圈颜色 - 通过CSS类控制 436 | const indicator = this.loopToggleButton.querySelector('.tm-loop-indicator-circle'); 437 | if (indicator) { 438 | indicator.setAttribute('fill', 'hsl(var(--shadcn-red))'); 439 | } 440 | 441 | // 更新标签样式 - 通过CSS类控制 442 | const label = this.loopToggleButton.querySelector('.tm-loop-toggle-label'); 443 | if (label) { 444 | label.classList.add('active'); // 添加.active类 445 | } 446 | } else { 447 | // 非激活状态 - 移除CSS类 448 | this.loopToggleButton.classList.remove('active'); 449 | 450 | // 更新指示器圆圈颜色 451 | const indicator = this.loopToggleButton.querySelector('.tm-loop-indicator-circle'); 452 | if (indicator) { 453 | indicator.setAttribute('fill', 'hsl(var(--shadcn-muted-foreground) / 0.5)'); 454 | } 455 | 456 | // 更新标签样式 457 | const label = this.loopToggleButton.querySelector('.tm-loop-toggle-label'); 458 | if (label) { 459 | label.classList.remove('active'); // 移除.active类 460 | } 461 | } 462 | } 463 | 464 | /** 465 | * 更新开始时间容器样式 466 | */ 467 | _updateStartTimeContainerStyle() { 468 | const startContainer = document.querySelector('.tm-start-time-container'); 469 | if (!startContainer) return; 470 | 471 | if (this.loopStartTime !== null) { 472 | // 更新时间文本 473 | this.currentPositionDisplay.textContent = this.formatTimeWithHours(this.loopStartTime); 474 | 475 | // 添加激活样式 476 | this.currentPositionDisplay.classList.add('active'); 477 | startContainer.classList.add('active'); 478 | 479 | // 确保A按钮的样式已应用 480 | const aButton = startContainer.querySelector('.tm-loop-start-button'); 481 | if (aButton) { 482 | aButton.classList.add('active'); 483 | } 484 | } else { 485 | // 未设置开始时间的默认样式 486 | this.currentPositionDisplay.textContent = '00:00:00'; 487 | 488 | // 移除激活样式 489 | this.currentPositionDisplay.classList.remove('active'); 490 | startContainer.classList.remove('active'); 491 | 492 | // 重置A按钮样式 493 | const aButton = startContainer.querySelector('.tm-loop-start-button'); 494 | if (aButton) { 495 | aButton.classList.remove('active'); 496 | } 497 | } 498 | } 499 | 500 | /** 501 | * 更新结束时间容器样式 502 | */ 503 | _updateEndTimeContainerStyle() { 504 | const endContainer = document.querySelector('.tm-end-time-container'); 505 | if (!endContainer) return; 506 | 507 | if (this.loopEndTime !== null) { 508 | // 更新时间文本 509 | this.durationDisplay.textContent = this.formatTimeWithHours(this.loopEndTime); 510 | 511 | // 添加激活样式 512 | this.durationDisplay.classList.add('active'); 513 | endContainer.classList.add('active'); 514 | 515 | // 确保B按钮的样式已应用 516 | const bButton = endContainer.querySelector('.tm-loop-end-button'); 517 | if (bButton) { 518 | bButton.classList.add('active'); 519 | } 520 | } else { 521 | // 未设置结束时间的默认样式 522 | this.durationDisplay.textContent = '00:00:00'; 523 | 524 | // 移除激活样式 525 | this.durationDisplay.classList.remove('active'); 526 | endContainer.classList.remove('active'); 527 | 528 | // 重置B按钮样式 529 | const bButton = endContainer.querySelector('.tm-loop-end-button'); 530 | if (bButton) { 531 | bButton.classList.remove('active'); 532 | } 533 | } 534 | } 535 | 536 | /** 537 | * 更新循环时间显示 538 | */ 539 | updateLoopTimeDisplay() { 540 | // 更新开始时间显示 541 | this._updateStartTimeContainerStyle(); 542 | 543 | // 更新结束时间显示 544 | this._updateEndTimeContainerStyle(); 545 | } 546 | 547 | /** 548 | * 创建和更新循环标记点 549 | */ 550 | updateLoopMarkers() { 551 | if (!this.targetVideo || !this.loopStartMarker || !this.loopEndMarker) return; 552 | 553 | const progressBarElement = document.querySelector('.tm-progress-bar'); 554 | if (!progressBarElement) return; 555 | 556 | const progressWidth = progressBarElement.offsetWidth; 557 | const duration = this.targetVideo.duration; 558 | 559 | if (duration <= 0 || !progressWidth) return; 560 | 561 | // 创建标记点辅助函数 562 | const createMarker = (time, isStart) => { 563 | const marker = isStart ? this.loopStartMarker : this.loopEndMarker; 564 | 565 | if (time !== null && !isNaN(time) && time >= 0 && time <= duration) { 566 | const position = (time / duration) * 100; 567 | marker.style.left = `${position}%`; 568 | marker.style.display = 'block'; 569 | 570 | // 更新标记状态 - 循环激活时应用active类 571 | if (this.loopActive) { 572 | marker.classList.add('active'); 573 | } else { 574 | marker.classList.remove('active'); 575 | } 576 | 577 | // 添加悬停提示 578 | marker.setAttribute('title', isStart ? 579 | `循环起点: ${this.formatTimeWithHours(time)}` : 580 | `循环终点: ${this.formatTimeWithHours(time)}`); 581 | 582 | // 设置额外的数据属性用于显示标签 583 | marker.setAttribute('data-time', this.formatTimeWithHours(time)); 584 | } else { 585 | marker.style.display = 'none'; 586 | } 587 | }; 588 | 589 | // 更新 A 和 B 点位置 590 | createMarker(this.loopStartTime, true); 591 | createMarker(this.loopEndTime, false); 592 | 593 | // 如果循环已激活且两个标记点都存在,创建视觉连接效果 594 | if (this.loopActive && this.loopStartTime !== null && this.loopEndTime !== null) { 595 | // 使用CSS类管理状态 596 | this.loopStartMarker.classList.add('active'); 597 | this.loopEndMarker.classList.add('active'); 598 | 599 | // 更新循环区间连接元素 600 | if (this.loopRangeElement) { 601 | const startPosition = (this.loopStartTime / duration) * 100; 602 | const endPosition = (this.loopEndTime / duration) * 100; 603 | 604 | // 计算宽度和位置 605 | const width = endPosition - startPosition; 606 | if (width > 0) { 607 | this.loopRangeElement.style.left = `${startPosition}%`; 608 | this.loopRangeElement.style.width = `${width}%`; 609 | this.loopRangeElement.style.display = 'block'; 610 | this.loopRangeElement.classList.add('active'); 611 | } else { 612 | this.loopRangeElement.style.display = 'none'; 613 | } 614 | } 615 | } else { 616 | this.loopStartMarker.classList.remove('active'); 617 | this.loopEndMarker.classList.remove('active'); 618 | 619 | // 隐藏循环区间连接元素 620 | if (this.loopRangeElement) { 621 | this.loopRangeElement.classList.remove('active'); 622 | this.loopRangeElement.style.display = 'none'; 623 | } 624 | } 625 | } 626 | 627 | /** 628 | * 格式化时间(含小时) 629 | */ 630 | formatTimeWithHours(timeInSeconds) { 631 | if (isNaN(timeInSeconds) || timeInSeconds < 0) { 632 | return '00:00:00'; 633 | } 634 | const totalSeconds = Math.floor(timeInSeconds); 635 | const hours = Math.floor(totalSeconds / 3600); 636 | const minutes = Math.floor((totalSeconds % 3600) / 60); 637 | const seconds = totalSeconds % 60; 638 | 639 | return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 640 | } 641 | } -------------------------------------------------------------------------------- /src/player/managers/videoSwipeManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 视频水平移动管理器 - 全新模块化设计 3 | */ 4 | export class VideoSwipeManager { 5 | constructor(videoElement, containerElement, handleElement) { 6 | // 核心元素引用 7 | this.video = videoElement; 8 | this.container = containerElement; 9 | this.handle = handleElement; 10 | 11 | // 状态管理 12 | this.offset = 0; // 当前水平偏移量 13 | this.maxOffset = 0; // 最大偏移量限制 14 | this.isDragging = false; // 视频拖动状态 15 | this.isHandleDragging = false; // 手柄拖动状态 16 | this.startX = 0; // 拖动起始X坐标 17 | this.startOffset = 0; // 拖动起始偏移量 18 | this.lastSnapPosition = null; // 上次吸附位置,用于判断是否需要震动 19 | this.wasDragging = false; // 新增:标记是否刚完成拖动操作 20 | this.dragEndTimestamp = 0; // 新增:记录拖动结束的时间戳 21 | this.dragDistance = 0; // 新增:记录拖动距离 22 | this.minDragDistance = 10; // 新增:最小有效拖动距离(像素) 23 | 24 | // 视频尺寸信息 25 | this.videoWidth = 0; // 视频自然宽度 26 | this.videoHeight = 0; // 视频自然高度 27 | this.containerWidth = 0; // 容器宽度 28 | this.containerHeight = 0; // 容器高度 29 | this.videoScale = 1; // 视频缩放比例 30 | 31 | // 惯性滚动相关 32 | this.velocityTracker = { 33 | positions: [], // 存储最近的位置记录 34 | lastTimestamp: 0, // 上次记录时间 35 | currentVelocity: 0 // 当前速度 36 | }; 37 | 38 | // 手柄惯性滚动数据 39 | this.handleVelocityTracker = { 40 | positions: [], // 存储最近的位置记录 41 | lastTimestamp: 0, // 上次记录时间 42 | currentVelocity: 0 // 当前速度 43 | }; 44 | 45 | // 动画状态 46 | this.animation = { 47 | active: false, // 是否有动画正在进行 48 | rafId: null, // requestAnimationFrame ID 49 | targetOffset: 0, // 动画目标偏移量 50 | startTime: 0, // 动画开始时间 51 | duration: 0 // 动画持续时间 52 | }; 53 | 54 | // 事件处理函数(使用箭头函数绑定this) 55 | this._pointerDownHandler = this._handlePointerDown.bind(this); 56 | this._pointerMoveHandler = this._handlePointerMove.bind(this); 57 | this._pointerUpHandler = this._handlePointerUp.bind(this); 58 | 59 | // 手柄事件处理函数 60 | this._handlePointerDownHandler = this._handleHandlePointerDown.bind(this); 61 | this._handlePointerMoveHandler = this._handleHandlePointerMove.bind(this); 62 | this._handlePointerUpHandler = this._handleHandlePointerUp.bind(this); 63 | 64 | // 初始化 65 | this._init(); 66 | } 67 | 68 | /** 69 | * 初始化管理器 70 | */ 71 | _init() { 72 | console.log('[VideoSwipeManager] 初始化管理器'); 73 | // 设置视频元素性能相关样式,不修改原始布局样式 74 | this.video.style.willChange = 'transform'; // 优化性能 75 | this.video.style.transition = 'transform 0.2s cubic-bezier(0.215, 0.61, 0.355, 1)'; 76 | 77 | // 注意:不再设置 width, height 或 objectFit,尊重原始样式 78 | 79 | // 添加视频事件监听 80 | this.video.addEventListener('pointerdown', this._pointerDownHandler); 81 | 82 | // 添加手柄事件监听 83 | if (this.handle) { 84 | this.handle.style.willChange = 'transform, left'; // 优化性能 85 | this.handle.style.transition = 'left 0.2s cubic-bezier(0.215, 0.61, 0.355, 1), width 0.2s ease'; 86 | this.handle.addEventListener('pointerdown', this._handlePointerDownHandler); 87 | } 88 | 89 | // 初始更新约束条件 90 | this._updateConstraints(); 91 | 92 | // 视频加载或尺寸变化时更新约束 93 | this.video.addEventListener('loadedmetadata', () => { 94 | console.log('[VideoSwipeManager] 视频元数据加载完成,更新约束'); 95 | this._updateConstraints(); 96 | }); 97 | 98 | this.video.addEventListener('canplay', () => { 99 | console.log('[VideoSwipeManager] 视频可播放,更新约束'); 100 | this._updateConstraints(); 101 | }); 102 | } 103 | 104 | /** 105 | * 计算视频的有效边界和可移动范围 106 | * @private 107 | */ 108 | _updateVideoDimensions() { 109 | // 获取视频自然尺寸 110 | this.videoWidth = this.video.videoWidth || this.video.naturalWidth || 0; 111 | this.videoHeight = this.video.videoHeight || this.video.naturalHeight || 0; 112 | 113 | // 获取容器尺寸 114 | this.containerWidth = this.container.offsetWidth; 115 | this.containerHeight = this.container.offsetHeight; 116 | 117 | // 如果视频或容器尺寸无效,则不继续计算 118 | if (this.videoWidth <= 0 || this.videoHeight <= 0 || 119 | this.containerWidth <= 0 || this.containerHeight <= 0) { 120 | this.videoScale = 1; 121 | this.maxOffset = 0; 122 | return false; 123 | } 124 | 125 | // 获取视频元素的当前实际尺寸 126 | const videoRect = this.video.getBoundingClientRect(); 127 | const actualVideoWidth = videoRect.width; 128 | const actualVideoHeight = videoRect.height; 129 | 130 | // 计算视频缩放比例 131 | this.videoScale = actualVideoHeight / this.videoHeight; 132 | 133 | // 计算最大水平偏移量 (视频超出容器的部分的一半) 134 | const overflow = Math.max(0, actualVideoWidth - this.containerWidth); 135 | this.maxOffset = overflow / 2; 136 | 137 | return true; 138 | } 139 | 140 | /** 141 | * 更新约束条件(如最大偏移量) 142 | */ 143 | _updateConstraints() { 144 | // 更新视频尺寸和约束 145 | const dimensionsUpdated = this._updateVideoDimensions(); 146 | 147 | // 如果尺寸计算失败或视频宽度不超过容器,则无需移动 148 | if (!dimensionsUpdated || this.maxOffset <= 0) { 149 | // 重置到居中位置 150 | this._applyOffset(0, false); 151 | // 更新手柄状态为禁用移动,但宽度相应设置 152 | this._updateHandleState(false); 153 | return false; 154 | } 155 | 156 | // 限制当前偏移量在新的范围内 157 | this.offset = Math.max(-this.maxOffset, Math.min(this.offset, this.maxOffset)); 158 | 159 | // 应用可能调整后的偏移量 160 | this._applyOffset(this.offset, false); 161 | 162 | // 更新手柄状态为可用 163 | this._updateHandleState(true); 164 | 165 | return true; 166 | } 167 | 168 | /** 169 | * 应用偏移量到视频元素 170 | * @param {number} offset - 要应用的偏移量 171 | * @param {boolean} animate - 是否使用动画过渡 172 | */ 173 | _applyOffset(offset, animate = true) { 174 | // 确保偏移量在有效范围内 175 | this.offset = Math.max(-this.maxOffset, Math.min(offset, this.maxOffset)); 176 | 177 | if (animate) { 178 | this.video.style.transition = 'transform 0.2s cubic-bezier(0.215, 0.61, 0.355, 1)'; 179 | } else { 180 | this.video.style.transition = 'none'; 181 | } 182 | 183 | // 使用transform而不是left,利用GPU加速 184 | this.video.style.transform = `translateX(${this.offset}px)`; 185 | 186 | // 同步更新手柄位置 187 | this._updateHandlePosition(); 188 | 189 | return this; 190 | } 191 | 192 | /** 193 | * 更新手柄状态 194 | * @param {boolean} enabled - 手柄是否应该启用 195 | */ 196 | _updateHandleState(enabled) { 197 | if (!this.handle) return; 198 | 199 | // 更新手柄宽度 200 | this._updateHandleWidth(); 201 | 202 | if (enabled) { 203 | this.handle.style.cursor = 'grab'; 204 | this.video.style.cursor = 'grab'; 205 | 206 | // 只有在视频比容器宽时才允许移动 207 | const handleContainer = this.handle.parentElement; 208 | if (handleContainer) { 209 | handleContainer.style.cursor = 'grab'; 210 | } 211 | } else { 212 | // 视频完全可见或未超出容器时,手柄宽度适应但禁用拖动 213 | this.handle.style.cursor = 'default'; 214 | this.video.style.cursor = 'default'; 215 | // 不再设置手柄位置,使用_updateHandlePosition方法统一处理 216 | } 217 | 218 | // 立即更新手柄位置以反映视频偏移 219 | this._updateHandlePosition(); 220 | } 221 | 222 | /** 223 | * 更新手柄宽度 224 | */ 225 | _updateHandleWidth() { 226 | if (!this.handle) return; 227 | 228 | // 使用固定的手柄宽度,不再动态计算 229 | const handleWidthPercent = 30; // 固定宽度30% 230 | 231 | // 应用手柄宽度 232 | this.handle.style.width = `${handleWidthPercent}%`; 233 | } 234 | 235 | /** 236 | * 更新手柄位置 237 | */ 238 | _updateHandlePosition() { 239 | if (!this.handle) return; 240 | 241 | const handleContainer = this.handle.parentElement; 242 | if (!handleContainer) return; 243 | 244 | // 如果视频宽度不超过容器,居中显示手柄 245 | if (this.maxOffset <= 0) { 246 | // 居中手柄 247 | this.handle.style.left = '50%'; 248 | this.handle.style.transform = 'translateX(-50%)'; 249 | return; 250 | } 251 | 252 | const containerWidth = handleContainer.offsetWidth; 253 | const handleWidth = this.handle.offsetWidth; 254 | 255 | // 计算手柄可移动的范围 256 | const handleMovableRange = containerWidth - handleWidth; 257 | 258 | // 视频偏移范围: [-maxOffset, maxOffset] 259 | // 手柄位置范围: [0, handleMovableRange] 260 | // 调整为反向移动:当视频偏移为最大负值时,手柄位置为最右侧,反之亦然 261 | const offsetRatio = 1 - ((this.offset + this.maxOffset) / (2 * this.maxOffset)); 262 | const handleLeftPx = offsetRatio * handleMovableRange; 263 | 264 | // 更新手柄位置 (使用百分比让布局更灵活) 265 | const handleLeftPercent = (handleLeftPx / containerWidth) * 100; 266 | 267 | // // 平滑过渡 268 | // if (!this.isHandleDragging) { 269 | // this.handle.style.transition = 'left 0.2s cubic-bezier(0.215, 0.61, 0.355, 1)'; 270 | // } else { 271 | // this.handle.style.transition = 'none'; 272 | // } 273 | 274 | this.handle.style.left = `${handleLeftPercent}%`; 275 | this.handle.style.transform = ''; // 清除可能存在的transform 276 | } 277 | 278 | /** 279 | * 记录速度数据 280 | * @param {number} x - 当前x坐标 281 | */ 282 | _trackVelocity(x) { 283 | const now = Date.now(); 284 | const tracker = this.velocityTracker; 285 | 286 | // 添加新位置记录 287 | tracker.positions.push({ 288 | x: x, 289 | time: now 290 | }); 291 | 292 | // 只保留最近100ms内的记录 293 | while ( 294 | tracker.positions.length > 1 && 295 | now - tracker.positions[0].time > 100 296 | ) { 297 | tracker.positions.shift(); 298 | } 299 | 300 | // 计算当前速度 (像素/毫秒) 301 | if (tracker.positions.length > 1) { 302 | const first = tracker.positions[0]; 303 | const last = tracker.positions[tracker.positions.length - 1]; 304 | const deltaTime = last.time - first.time; 305 | 306 | if (deltaTime > 0) { 307 | tracker.currentVelocity = (last.x - first.x) / deltaTime; 308 | } 309 | } 310 | 311 | tracker.lastTimestamp = now; 312 | } 313 | 314 | /** 315 | * 应用惯性滚动 316 | */ 317 | _applyInertia() { 318 | if (Math.abs(this.velocityTracker.currentVelocity) < 0.1) return; 319 | 320 | // 计算最终位置 321 | const velocity = this.velocityTracker.currentVelocity; // 像素/毫秒 322 | const deceleration = 0.002; // 减速率 323 | const distance = (velocity * velocity) / (2 * deceleration) * Math.sign(velocity); 324 | 325 | // 计算目标偏移量(考虑边界) 326 | let targetOffset = this.offset + distance; 327 | targetOffset = Math.max(-this.maxOffset, Math.min(targetOffset, this.maxOffset)); 328 | 329 | // 计算动画持续时间(速度越快,时间越长) 330 | const duration = Math.min( 331 | Math.abs(velocity / deceleration) * 0.8, // 基于物理的持续时间 332 | 400 // 最大不超过400ms 333 | ); 334 | 335 | // 开始动画 336 | this._animateTo(targetOffset, duration); 337 | } 338 | 339 | /** 340 | * 动画滚动到指定偏移量 341 | * @param {number} targetOffset - 目标偏移量 342 | * @param {number} duration - 动画持续时间(毫秒) 343 | */ 344 | _animateTo(targetOffset, duration = 300) { 345 | // 取消可能正在进行的动画 346 | if (this.animation.active) { 347 | cancelAnimationFrame(this.animation.rafId); 348 | } 349 | 350 | // 更新动画状态 351 | this.animation.active = true; 352 | this.animation.targetOffset = targetOffset; 353 | this.animation.startTime = Date.now(); 354 | this.animation.duration = duration; 355 | 356 | // 开始动画帧循环 357 | const animate = () => { 358 | const now = Date.now(); 359 | const elapsed = now - this.animation.startTime; 360 | 361 | if (elapsed >= duration) { 362 | // 动画结束 363 | this._applyOffset(targetOffset, false); 364 | this.animation.active = false; 365 | return; 366 | } 367 | 368 | // 使用easeOutCubic缓动函数计算当前位置 369 | const progress = 1 - Math.pow(1 - elapsed / duration, 3); 370 | const currentOffset = this.offset + (targetOffset - this.offset) * progress; 371 | 372 | this._applyOffset(currentOffset, false); 373 | this.animation.rafId = requestAnimationFrame(animate); 374 | }; 375 | 376 | this.animation.rafId = requestAnimationFrame(animate); 377 | } 378 | 379 | /** 380 | * 处理指针按下事件 (视频元素) 381 | * @param {PointerEvent} e - 指针事件 382 | */ 383 | _handlePointerDown(e) { 384 | // 如果视频宽度不超过容器,则不处理 385 | if (this.maxOffset <= 0) return; 386 | 387 | // 只处理主指针 388 | if (!e.isPrimary) return; 389 | 390 | // 停止可能正在进行的动画 391 | if (this.animation.active) { 392 | cancelAnimationFrame(this.animation.rafId); 393 | this.animation.active = false; 394 | } 395 | 396 | // 初始化拖动状态 397 | this.isDragging = true; 398 | this.startX = e.clientX; 399 | this.startOffset = this.offset; 400 | this.dragDistance = 0; // 重置拖动距离 401 | 402 | // 重置速度追踪器 403 | this.velocityTracker.positions = []; 404 | this.velocityTracker.lastTimestamp = Date.now(); 405 | this.velocityTracker.currentVelocity = 0; 406 | 407 | // 记录初始位置 408 | this._trackVelocity(e.clientX); 409 | 410 | // 更新视觉状态 411 | this.video.style.cursor = 'grabbing'; 412 | this.video.style.transition = 'none'; 413 | 414 | // 如果支持指针捕获,捕获指针 415 | if (this.video.setPointerCapture) { 416 | this.video.setPointerCapture(e.pointerId); 417 | } 418 | 419 | // 添加事件监听 420 | this.video.addEventListener('pointermove', this._pointerMoveHandler); 421 | this.video.addEventListener('pointerup', this._pointerUpHandler); 422 | this.video.addEventListener('pointercancel', this._pointerUpHandler); 423 | 424 | // 触觉反馈 (如果设备支持) 425 | if (window.navigator.vibrate) { 426 | window.navigator.vibrate(5); 427 | } 428 | 429 | // 阻止默认行为,如文本选择 430 | e.preventDefault(); 431 | } 432 | 433 | /** 434 | * 处理指针移动事件 (视频元素) 435 | * @param {PointerEvent} e - 指针事件 436 | */ 437 | _handlePointerMove(e) { 438 | if (!this.isDragging || !e.isPrimary) return; 439 | 440 | // 计算位移 441 | const deltaX = e.clientX - this.startX; 442 | this.dragDistance = Math.max(this.dragDistance, Math.abs(deltaX)); // 更新最大拖动距离 443 | 444 | const newOffset = Math.max( 445 | -this.maxOffset, 446 | Math.min(this.startOffset + deltaX, this.maxOffset) 447 | ); 448 | 449 | // 应用新偏移量 450 | this._applyOffset(newOffset, false); 451 | 452 | // 记录位置用于计算速度 453 | this._trackVelocity(e.clientX); 454 | 455 | // 阻止默认行为,如页面滚动 456 | e.preventDefault(); 457 | } 458 | 459 | /** 460 | * 处理指针抬起/取消事件 (视频元素) 461 | * @param {PointerEvent} e - 指针事件 462 | */ 463 | _handlePointerUp(e) { 464 | if (!this.isDragging || !e.isPrimary) return; 465 | 466 | // 更新状态 467 | this.isDragging = false; 468 | 469 | // 只有当拖动距离超过最小值时才设置拖动标记 470 | if (this.dragDistance > this.minDragDistance) { 471 | this.wasDragging = true; 472 | this.dragEndTimestamp = Date.now(); 473 | } else { 474 | this.wasDragging = false; 475 | } 476 | 477 | // 释放指针捕获 478 | if (this.video.releasePointerCapture) { 479 | this.video.releasePointerCapture(e.pointerId); 480 | } 481 | 482 | // 移除事件监听 483 | this.video.removeEventListener('pointermove', this._pointerMoveHandler); 484 | this.video.removeEventListener('pointerup', this._pointerUpHandler); 485 | this.video.removeEventListener('pointercancel', this._pointerUpHandler); 486 | 487 | // 恢复视觉状态 488 | this.video.style.cursor = 'grab'; 489 | 490 | // 应用惯性滚动 491 | this._applyInertia(); 492 | 493 | // 阻止默认行为 494 | e.preventDefault(); 495 | } 496 | 497 | /** 498 | * 处理手柄的指针按下事件 499 | * @param {PointerEvent} e - 指针事件 500 | */ 501 | _handleHandlePointerDown(e) { 502 | // 如果视频宽度不超过容器,则不处理 503 | if (this.maxOffset <= 0) return; 504 | 505 | // 只处理主指针 506 | if (!e.isPrimary) return; 507 | 508 | // 停止可能正在进行的动画 509 | if (this.animation.active) { 510 | cancelAnimationFrame(this.animation.rafId); 511 | this.animation.active = false; 512 | } 513 | 514 | // 初始化拖动状态 515 | this.isHandleDragging = true; 516 | this.startX = e.clientX; 517 | this.dragDistance = 0; // 重置拖动距离 518 | 519 | // 记录初始偏移和手柄位置 520 | this.startOffset = this.offset; 521 | const handleContainer = this.handle.parentElement; 522 | const containerWidth = handleContainer ? handleContainer.offsetWidth : 0; 523 | 524 | // 如果手柄容器有效,计算手柄位置比例 525 | if (containerWidth > 0) { 526 | const handleRect = this.handle.getBoundingClientRect(); 527 | this.startHandleLeft = handleRect.left - (handleContainer.getBoundingClientRect().left); 528 | this.startHandleLeftPercent = (this.startHandleLeft / containerWidth) * 100; 529 | } else { 530 | this.startHandleLeft = 0; 531 | this.startHandleLeftPercent = 0; 532 | } 533 | 534 | // 更新视觉状态 535 | this.handle.style.cursor = 'grabbing'; 536 | this.handle.style.transition = 'none'; 537 | 538 | // 如果支持指针捕获,捕获指针 539 | if (this.handle.setPointerCapture) { 540 | this.handle.setPointerCapture(e.pointerId); 541 | } 542 | 543 | // 添加事件监听 544 | this.handle.addEventListener('pointermove', this._handlePointerMoveHandler); 545 | this.handle.addEventListener('pointerup', this._handlePointerUpHandler); 546 | this.handle.addEventListener('pointercancel', this._handlePointerUpHandler); 547 | 548 | // 触觉反馈 (如果设备支持) 549 | if (window.navigator.vibrate) { 550 | window.navigator.vibrate(5); 551 | } 552 | 553 | // 阻止默认行为 554 | e.preventDefault(); 555 | } 556 | 557 | /** 558 | * 处理手柄的指针移动事件 559 | * @param {PointerEvent} e - 指针事件 560 | */ 561 | _handleHandlePointerMove(e) { 562 | if (!this.isHandleDragging || !e.isPrimary) return; 563 | 564 | const handleContainer = this.handle.parentElement; 565 | if (!handleContainer) return; 566 | 567 | const containerWidth = handleContainer.offsetWidth; 568 | const handleWidth = this.handle.offsetWidth; 569 | 570 | // 如果容器或手柄宽度无效,则不处理 571 | if (containerWidth <= 0 || handleWidth <= 0) return; 572 | 573 | // 计算位移 574 | const deltaX = e.clientX - this.startX; 575 | this.dragDistance = Math.max(this.dragDistance, Math.abs(deltaX)); // 更新最大拖动距离 576 | 577 | // 计算手柄新位置 (像素) 578 | let newHandleLeft = this.startHandleLeft + deltaX; 579 | 580 | // 限制手柄位置在容器范围内 581 | const maxHandleLeft = containerWidth - handleWidth; 582 | newHandleLeft = Math.max(0, Math.min(newHandleLeft, maxHandleLeft)); 583 | 584 | // 记录位置用于计算手柄速度 585 | this._trackHandleVelocity(newHandleLeft); 586 | 587 | // 定义吸附位置和阈值 588 | const snapPositions = [ 589 | 0, // 左侧 590 | maxHandleLeft / 2, // 中间 591 | maxHandleLeft // 右侧 592 | ]; 593 | const snapThreshold = 15; // 吸附阈值(像素) 594 | let didSnap = false; 595 | 596 | // 检查是否需要吸附 597 | for (const snapPos of snapPositions) { 598 | if (Math.abs(newHandleLeft - snapPos) < snapThreshold) { 599 | // 距离小于阈值,进行吸附 600 | newHandleLeft = snapPos; 601 | didSnap = true; 602 | 603 | // 如果设备支持触觉反馈且之前未吸附到此位置 604 | if (window.navigator.vibrate && (!this.lastSnapPosition || this.lastSnapPosition !== snapPos)) { 605 | window.navigator.vibrate(15); // 较强的震动表示吸附 606 | this.lastSnapPosition = snapPos; 607 | } 608 | break; 609 | } 610 | } 611 | 612 | // 如果未吸附,重置上次吸附位置记录 613 | if (!didSnap) { 614 | this.lastSnapPosition = null; 615 | } 616 | 617 | // 计算手柄位置百分比 618 | const newHandleLeftPercent = (newHandleLeft / containerWidth) * 100; 619 | 620 | // 应用手柄位置 621 | this.handle.style.left = `${newHandleLeftPercent}%`; 622 | 623 | // 将手柄位置映射回视频偏移 - 反向移动 624 | // 手柄位置范围: [0, maxHandleLeft] 625 | // 视频偏移范围: [-this.maxOffset, this.maxOffset] 626 | const handleRatio = maxHandleLeft > 0 ? newHandleLeft / maxHandleLeft : 0; // 0 到 1, 避免除零 627 | // 改为反向映射 (1 - handleRatio) 来反转方向 628 | const newOffset = ((1 - handleRatio) * 2 * this.maxOffset) - this.maxOffset; 629 | 630 | // 应用视频偏移 631 | this.video.style.transform = `translateX(${newOffset}px)`; 632 | this.video.style.transition = 'none'; 633 | this.offset = newOffset; 634 | 635 | // 阻止默认行为 636 | e.preventDefault(); 637 | } 638 | 639 | /** 640 | * 处理手柄的指针抬起/取消事件 641 | * @param {PointerEvent} e - 指针事件 642 | */ 643 | _handleHandlePointerUp(e) { 644 | if (!this.isHandleDragging || !e.isPrimary) return; 645 | 646 | // 更新状态 647 | this.isHandleDragging = false; 648 | 649 | // 只有当拖动距离超过最小值时才设置拖动标记 650 | if (this.dragDistance > this.minDragDistance) { 651 | this.wasDragging = true; 652 | this.dragEndTimestamp = Date.now(); 653 | } else { 654 | this.wasDragging = false; 655 | } 656 | 657 | // 重置上次吸附位置记录,以便下次拖动时能正确触发震动 658 | this.lastSnapPosition = null; 659 | 660 | // 释放指针捕获 661 | if (this.handle.releasePointerCapture) { 662 | this.handle.releasePointerCapture(e.pointerId); 663 | } 664 | 665 | // 移除事件监听 666 | this.handle.removeEventListener('pointermove', this._handlePointerMoveHandler); 667 | this.handle.removeEventListener('pointerup', this._handlePointerUpHandler); 668 | this.handle.removeEventListener('pointercancel', this._handlePointerUpHandler); 669 | 670 | // 恢复视觉状态 671 | this.handle.style.cursor = 'grab'; 672 | 673 | // 应用手柄惯性 674 | this._applyHandleInertia(); 675 | 676 | // 阻止默认行为 677 | e.preventDefault(); 678 | } 679 | 680 | /** 681 | * 记录手柄速度数据 682 | * @param {number} position - 当前手柄位置 683 | */ 684 | _trackHandleVelocity(position) { 685 | const now = Date.now(); 686 | const tracker = this.handleVelocityTracker; 687 | 688 | // 添加新位置记录 689 | tracker.positions.push({ 690 | position: position, 691 | time: now 692 | }); 693 | 694 | // 只保留最近100ms内的记录 695 | while ( 696 | tracker.positions.length > 1 && 697 | now - tracker.positions[0].time > 100 698 | ) { 699 | tracker.positions.shift(); 700 | } 701 | 702 | // 计算当前速度 (像素/毫秒) 703 | if (tracker.positions.length > 1) { 704 | const first = tracker.positions[0]; 705 | const last = tracker.positions[tracker.positions.length - 1]; 706 | const deltaTime = last.time - first.time; 707 | 708 | if (deltaTime > 0) { 709 | tracker.currentVelocity = (last.position - first.position) / deltaTime; 710 | } 711 | } 712 | 713 | tracker.lastTimestamp = now; 714 | } 715 | 716 | /** 717 | * 应用手柄惯性滚动 718 | */ 719 | _applyHandleInertia() { 720 | if (Math.abs(this.handleVelocityTracker.currentVelocity) < 0.1) return; 721 | 722 | const handleContainer = this.handle.parentElement; 723 | if (!handleContainer) return; 724 | 725 | const containerWidth = handleContainer.offsetWidth; 726 | const handleWidth = this.handle.offsetWidth; 727 | const maxHandleLeft = containerWidth - handleWidth; 728 | 729 | // 获取当前手柄位置(像素) 730 | const handleRect = this.handle.getBoundingClientRect(); 731 | const containerRect = handleContainer.getBoundingClientRect(); 732 | const currentHandleLeft = handleRect.left - containerRect.left; 733 | 734 | // 计算最终位置 735 | const velocity = this.handleVelocityTracker.currentVelocity; // 像素/毫秒 736 | const deceleration = 0.002; // 减速率 737 | const distance = (velocity * velocity) / (2 * deceleration) * Math.sign(velocity); 738 | 739 | // 计算目标手柄位置(考虑边界) 740 | let targetHandleLeft = currentHandleLeft + distance; 741 | targetHandleLeft = Math.max(0, Math.min(targetHandleLeft, maxHandleLeft)); 742 | 743 | // 检查最终位置是否需要吸附 744 | const snapPositions = [ 745 | 0, // 左侧 746 | maxHandleLeft / 2, // 中间 747 | maxHandleLeft // 右侧 748 | ]; 749 | const snapThreshold = 30; // 惯性滚动的吸附阈值更大 750 | 751 | // 寻找最近的吸附点 752 | let closestSnapPos = targetHandleLeft; 753 | let minDistance = Number.MAX_VALUE; 754 | 755 | for (const snapPos of snapPositions) { 756 | const distance = Math.abs(targetHandleLeft - snapPos); 757 | if (distance < snapThreshold && distance < minDistance) { 758 | closestSnapPos = snapPos; 759 | minDistance = distance; 760 | } 761 | } 762 | 763 | // 应用吸附 764 | if (minDistance < Number.MAX_VALUE) { 765 | targetHandleLeft = closestSnapPos; 766 | } 767 | 768 | // 计算手柄位置百分比 769 | const targetHandleLeftPercent = (targetHandleLeft / containerWidth) * 100; 770 | 771 | // 计算对应的视频偏移量 - 反向映射 772 | const handleRatio = maxHandleLeft > 0 ? targetHandleLeft / maxHandleLeft : 0; 773 | const targetOffset = ((1 - handleRatio) * 2 * this.maxOffset) - this.maxOffset; 774 | 775 | // 设置过渡效果 776 | this.handle.style.transition = 'left 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; 777 | this.video.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; 778 | 779 | // 应用最终位置 780 | this.handle.style.left = `${targetHandleLeftPercent}%`; 781 | this.video.style.transform = `translateX(${targetOffset}px)`; 782 | this.offset = targetOffset; 783 | 784 | // 触觉反馈 (如果设备支持且吸附到某个位置) 785 | if (minDistance < Number.MAX_VALUE && window.navigator.vibrate) { 786 | window.navigator.vibrate(10); 787 | } 788 | 789 | // 重置速度追踪器 790 | this.handleVelocityTracker.positions = []; 791 | this.handleVelocityTracker.currentVelocity = 0; 792 | } 793 | 794 | /** 795 | * 设置视频偏移量 796 | * @param {number} offset - 要设置的偏移量 797 | * @param {boolean} animate - 是否使用动画过渡 798 | */ 799 | setOffset(offset, animate = true) { 800 | return this._applyOffset(offset, animate); 801 | } 802 | 803 | /** 804 | * 重置管理器到初始状态 805 | * @param {boolean} animate - 是否使用动画 806 | * @returns {VideoSwipeManager} 当前实例,支持链式调用 807 | */ 808 | reset(animate = true) { 809 | this._applyOffset(0, animate); 810 | this.wasDragging = false; // 重置拖动标记 811 | return this; 812 | } 813 | 814 | /** 815 | * 更新尺寸和约束 816 | * @returns {VideoSwipeManager} 当前实例,支持链式调用 817 | */ 818 | updateSize() { 819 | console.log('[VideoSwipeManager] 更新尺寸和约束'); 820 | // 强制获取视频和容器的最新尺寸 821 | if (this.video && this.container) { 822 | // 输出诊断信息 823 | const videoRect = this.video.getBoundingClientRect(); 824 | const containerRect = this.container.getBoundingClientRect(); 825 | console.log(`[VideoSwipeManager] 视频尺寸: ${videoRect.width}x${videoRect.height}, 容器尺寸: ${containerRect.width}x${containerRect.height}`); 826 | 827 | // 更新约束 828 | const result = this._updateConstraints(); 829 | console.log(`[VideoSwipeManager] 约束更新结果: ${result}, 最大偏移量: ${this.maxOffset}`); 830 | } else { 831 | console.error('[VideoSwipeManager] 视频或容器元素不存在'); 832 | } 833 | return this; 834 | } 835 | 836 | /** 837 | * 销毁管理器并清理资源 838 | */ 839 | destroy() { 840 | // 移除事件监听 841 | if (this.video) { 842 | this.video.removeEventListener('pointerdown', this._pointerDownHandler); 843 | this.video.style.transform = ''; 844 | this.video.style.willChange = ''; 845 | this.video.style.transition = ''; 846 | this.video.style.cursor = ''; 847 | } 848 | 849 | if (this.handle) { 850 | this.handle.removeEventListener('pointerdown', this._handlePointerDownHandler); 851 | this.handle.style.willChange = ''; 852 | this.handle.style.transition = ''; 853 | this.handle.style.left = ''; 854 | this.handle.style.width = ''; 855 | this.handle.style.cursor = ''; 856 | } 857 | 858 | // 取消可能正在进行的动画 859 | if (this.animation.active) { 860 | cancelAnimationFrame(this.animation.rafId); 861 | this.animation.active = false; 862 | } 863 | 864 | // 重置标记 865 | this.wasDragging = false; 866 | } 867 | 868 | /** 869 | * 检查是否刚完成拖动操作 870 | * @param {number} threshold - 时间阈值(毫秒) 871 | * @returns {boolean} 是否刚完成拖动 872 | */ 873 | wasRecentlyDragging(threshold = 150) { 874 | if (!this.wasDragging) return false; 875 | 876 | const timeSinceDragEnd = Date.now() - this.dragEndTimestamp; 877 | 878 | // 如果超过阈值,重置标记 879 | if (timeSinceDragEnd > threshold) { 880 | this.wasDragging = false; 881 | return false; 882 | } 883 | 884 | return true; 885 | } 886 | } --------------------------------------------------------------------------------