diff --git a/package.json b/package.json index d107717..5ebfd99 100644 --- a/package.json +++ b/package.json @@ -20,19 +20,20 @@ "dependencies": { "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", - "artplayer": "^5.2.3", + "@vidstack/react": "^1.12.13", "clsx": "^2.0.0", "framer-motion": "^12.18.1", "hls.js": "^1.6.5", "lucide-react": "^0.438.0", "next": "^14.2.23", + "next-pwa": "^5.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.4.0", "swiper": "^11.2.8", "tailwind-merge": "^2.6.0", - "zod": "^3.24.1", - "next-pwa": "^5.6.0" + "vidstack": "^0.6.15", + "zod": "^3.24.1" }, "devDependencies": { "@commitlint/cli": "^16.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 607a56d..239ff14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,9 @@ importers: '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) - artplayer: - specifier: ^5.2.3 - version: 5.2.3 + '@vidstack/react': + specifier: ^1.12.13 + version: 1.12.13(@types/react@18.3.23)(react@18.3.1) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -50,6 +50,9 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.0 + vidstack: + specifier: ^0.6.15 + version: 0.6.15 zod: specifier: ^3.24.1 version: 3.25.67 @@ -1039,6 +1042,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@maverick-js/signals@5.11.5': + resolution: {integrity: sha512-/GO94awrwN9ROYZDMTeByordjvbhcm3CMvB/2aL/sEUy9Va8nM/2GmNgOOe+rrooTGnz8/DzO73xomuBRrnYWw==} + '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} @@ -1605,6 +1611,13 @@ packages: cpu: [x64] os: [win32] + '@vidstack/react@1.12.13': + resolution: {integrity: sha512-zyNydy1+HtoK6cJ8EmqFNkPPGHIFMrr2KH+ef3654EqXx4IcJ8A5LCNMXBuALQE8IMxtk040JMoR9OKyeXjBOQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1823,9 +1836,6 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} - artplayer@5.2.3: - resolution: {integrity: sha512-WaOZQrpZn/L+GgI2f0TEsoAL3Wb+v16Mu0JmWh7qKFYuvr11WNt3dWhWeIaCfoHy3NtkCWM9jTP+xwwsxdElZQ==} - ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -3515,12 +3525,24 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + maverick.js@0.37.0: + resolution: {integrity: sha512-1Dk/9rienLiihlktVvH04ADC2UJTMflC1fOMVQCCaQAaz7hgzDI5i0p/arFbDM52hFFiIcq4RdXtYz47SgsLgw==} + engines: {node: '>=16'} + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + media-captions@0.0.18: + resolution: {integrity: sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ==} + engines: {node: '>=16'} + + media-captions@1.0.4: + resolution: {integrity: sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==} + engines: {node: '>=16'} + meow@8.1.2: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} @@ -3722,9 +3744,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - option-validator@2.0.6: - resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4628,6 +4647,10 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + type-fest@3.13.1: + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4724,6 +4747,10 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vidstack@0.6.15: + resolution: {integrity: sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==} + engines: {node: '>=16'} + w3c-hr-time@1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} deprecated: Use your platform's native performance.now() and performance.timeOrigin. @@ -6173,6 +6200,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@maverick-js/signals@5.11.5': {} + '@napi-rs/wasm-runtime@0.2.11': dependencies: '@emnapi/core': 1.4.3 @@ -6756,6 +6785,13 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.9.0': optional: true + '@vidstack/react@1.12.13(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.1 + '@types/react': 18.3.23 + media-captions: 1.0.4 + react: 18.3.1 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -7018,10 +7054,6 @@ snapshots: arrify@1.0.1: {} - artplayer@5.2.3: - dependencies: - option-validator: 2.0.6 - ast-types-flow@0.0.8: {} astral-regex@2.0.0: {} @@ -9147,10 +9179,19 @@ snapshots: math-intrinsics@1.1.0: {} + maverick.js@0.37.0: + dependencies: + '@maverick-js/signals': 5.11.5 + type-fest: 3.13.1 + mdn-data@2.0.28: {} mdn-data@2.0.30: {} + media-captions@0.0.18: {} + + media-captions@1.0.4: {} + meow@8.1.2: dependencies: '@types/minimist': 1.2.5 @@ -9375,10 +9416,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - option-validator@2.0.6: - dependencies: - kind-of: 6.0.3 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -10257,6 +10294,8 @@ snapshots: type-fest@0.8.1: {} + type-fest@3.13.1: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -10384,6 +10423,12 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vidstack@0.6.15: + dependencies: + maverick.js: 0.37.0 + media-captions: 0.0.18 + type-fest: 3.13.1 + w3c-hr-time@1.0.2: dependencies: browser-process-hrtime: 1.0.0 diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 041aebc..b19ed81 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2,12 +2,21 @@ 'use client'; +import { MediaPlayer, MediaProvider } from '@vidstack/react'; +import { + defaultLayoutIcons, + DefaultVideoLayout, +} from '@vidstack/react/player/layouts/default'; import { Heart } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { Suspense } from 'react'; import { useEffect, useRef, useState } from 'react'; import React from 'react'; +import 'vidstack/styles/defaults.css'; +import '@vidstack/react/player/styles/default/theme.css'; +import '@vidstack/react/player/styles/default/layouts/video.css'; + import { deletePlayRecord, generateStorageKey, @@ -37,8 +46,8 @@ interface SearchResult { function PlayPageClient() { const searchParams = useSearchParams(); - const artRef = useRef(null); - const artPlayerRef = useRef(null); + // @ts-ignore + const playerRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -59,7 +68,6 @@ function PlayPageClient() { const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); const [showEpisodePanel, setShowEpisodePanel] = useState(false); const [showSourcePanel, setShowSourcePanel] = useState(false); - const [showTopBar, setShowTopBar] = useState(true); const [showShortcutHint, setShowShortcutHint] = useState(false); const [shortcutText, setShortcutText] = useState(''); const [shortcutDirection, setShortcutDirection] = useState(''); @@ -77,10 +85,6 @@ function PlayPageClient() { // 播放进度保存相关 const saveIntervalRef = useRef(null); const lastSaveTimeRef = useRef(0); - const videoEventListenersRef = useRef<{ - video: HTMLVideoElement; - listeners: Array<{ event: string; handler: EventListener }>; - } | null>(null); // 总集数:从 detail 中获取,保证随 detail 更新而变化 const totalEpisodes = detail?.episodes?.length || 0; @@ -107,24 +111,6 @@ function PlayPageClient() { // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); - // 动态导入的 Artplayer 与 Hls 实例 - const [{ Artplayer, Hls }, setPlayers] = useState<{ - Artplayer: any | null; - Hls: any | null; - }>({ Artplayer: null, Hls: null }); - - // 新增:业务层容器引用 - const topLayerRef = useRef(null); - const episodeLayerRef = useRef(null); - const sourceLayerRef = useRef(null); - - // 缓存业务层节点的引用,避免在销毁重建过程中丢失 - const cachedNodesRef = useRef<{ - topDom: HTMLElement | null; - episodeDom: HTMLElement | null; - sourceDom: HTMLElement | null; - }>({ topDom: null, episodeDom: null, sourceDom: null }); - const currentEpisodeIndexRef = useRef(currentEpisodeIndex); const detailRef = useRef(detail); @@ -151,26 +137,6 @@ function PlayPageClient() { detailRef.current = detail; }, [detail]); - /** - * 在销毁旧播放器实例之前,将业务层节点缓存起来,防止节点随播放器一起被移除。 - */ - const cacheBusinessNodes = () => { - // 直接从文档中查找业务层节点并缓存引用 - const topDom = document.querySelector('[data-top-bar]') as HTMLElement; - const episodeDom = document.querySelector( - '[data-episode-panel]' - ) as HTMLElement; - const sourceDom = document.querySelector( - '[data-source-panel]' - ) as HTMLElement; - - cachedNodesRef.current = { - topDom, - episodeDom, - sourceDom, - }; - }; - // 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh useEffect(() => { const setVH = () => { @@ -195,33 +161,20 @@ function PlayPageClient() { detailData: VideoDetail | null, episodeIndex: number ) => { + if ( + !detailData || + !detailData.episodes || + episodeIndex >= detailData.episodes.length + ) { + setVideoUrl(''); + return; + } const newUrl = detailData?.episodes[episodeIndex] || ''; - if (newUrl != videoUrl) { + if (newUrl !== videoUrl) { setVideoUrl(newUrl); } }; - // 在指定 video 元素内确保存在 ,供部分浏览器兼容 - const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => { - if (!video || !url) return; - const sources = Array.from(video.getElementsByTagName('source')); - const existed = sources.some((s) => s.src === url); - if (!existed) { - // 移除旧的 source,保持唯一 - sources.forEach((s) => s.remove()); - const sourceEl = document.createElement('source'); - sourceEl.src = url; - video.appendChild(sourceEl); - } - - // 始终允许远程播放(AirPlay / Cast) - video.disableRemotePlayback = false; - // 如果曾经有禁用属性,移除之 - if (video.hasAttribute('disableRemotePlayback')) { - video.removeAttribute('disableRemotePlayback'); - } - }; - // 当集数索引变化时自动更新视频地址 useEffect(() => { updateVideoUrl(detail, currentEpisodeIndex); @@ -240,26 +193,6 @@ function PlayPageClient() { } }, [searchParams, currentSource, currentId]); - // 动态加载 Artplayer 与 Hls 并保存到 state - useEffect(() => { - (async () => { - try { - const [ArtplayerModule, HlsModule] = await Promise.all([ - import('artplayer'), - import('hls.js'), - ]); - setPlayers({ - Artplayer: ArtplayerModule.default, - Hls: HlsModule.default, - }); - } catch (err) { - console.error('Failed to load players:', err); - setError('播放器加载失败'); - setLoading(false); - } - })(); - }, []); - useEffect(() => { if (!currentSource || !currentId) { setError('缺少必要参数'); @@ -363,335 +296,48 @@ function PlayPageClient() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const attachVideoEventListeners = (video: HTMLVideoElement) => { - if (!video) return; + // 播放器事件处理 + const onCanPlay = () => { + console.log('播放器准备就绪'); + setError(null); - // 移除旧监听器(如果存在) - if (videoEventListenersRef.current) { - const { video: oldVideo, listeners } = videoEventListenersRef.current; - listeners.forEach(({ event, handler }) => { - oldVideo.removeEventListener(event, handler); - }); - videoEventListenersRef.current = null; - } - - // 暂停时立即保存 - const pauseHandler = () => { - saveCurrentPlayProgress(); - }; - - // 阻止移动端长按弹出系统菜单 - const contextMenuHandler = (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - }; - - // timeupdate 节流(5 秒)保存 - let lastSave = 0; - const timeUpdateHandler = () => { - const now = Date.now(); - if (now - lastSave > 5000) { - saveCurrentPlayProgress(); - lastSave = now; + // 若存在需要恢复的播放进度,则跳转 + if ( + playerRef.current && + resumeTimeRef.current && + resumeTimeRef.current > 0 + ) { + try { + playerRef.current.currentTime = resumeTimeRef.current; + } catch (err) { + console.warn('恢复播放进度失败:', err); } - }; - - video.addEventListener('pause', pauseHandler); - video.addEventListener('timeupdate', timeUpdateHandler); - video.addEventListener('contextmenu', contextMenuHandler); - - videoEventListenersRef.current = { - video, - listeners: [ - { event: 'pause', handler: pauseHandler }, - { event: 'timeupdate', handler: timeUpdateHandler }, - { event: 'contextmenu', handler: contextMenuHandler }, - ], - }; + resumeTimeRef.current = null; + } }; - // 播放器创建/切换逻辑,只依赖视频URL和集数索引 - useEffect(() => { - if ( - !Artplayer || - !Hls || - !videoUrl || - loading || - currentEpisodeIndex === null || - !artRef.current - ) - return; - - // 确保选集索引有效 - if ( - !detail || - !detail.episodes || - currentEpisodeIndex >= detail.episodes.length || - currentEpisodeIndex < 0 - ) { - setError(`选集索引无效,当前共 ${totalEpisodes} 集`); - return; - } - - if (!videoUrl) { - setError('视频地址无效'); - return; - } - console.log(videoUrl); - - // 检测是否为WebKit浏览器 - const isWebkit = - typeof window !== 'undefined' && - typeof (window as any).webkitConvertPointFromNodeToPage === 'function'; - - // 非WebKit浏览器且播放器已存在,使用switch方法切换 - if (!isWebkit && artPlayerRef.current) { - artPlayerRef.current.switch = videoUrl; - artPlayerRef.current.title = `${videoTitle} - 第${ - currentEpisodeIndex + 1 - }集`; - artPlayerRef.current.poster = videoCover; - if (artPlayerRef.current?.video) { - attachVideoEventListeners( - artPlayerRef.current.video as HTMLVideoElement - ); - ensureVideoSource( - artPlayerRef.current.video as HTMLVideoElement, - videoUrl - ); - } - return; - } - - // WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的 - if (artPlayerRef.current) { - // 先缓存业务层 DOM,避免被销毁 - cacheBusinessNodes(); - - if (artPlayerRef.current.video && artPlayerRef.current.video.hls) { - artPlayerRef.current.video.hls.destroy(); - } - // 销毁播放器实例 - artPlayerRef.current.destroy(); - artPlayerRef.current = null; - } - - try { - // 创建新的播放器实例 - Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; - artPlayerRef.current = new Artplayer({ - container: artRef.current, - url: videoUrl, - title: `${videoTitle} - 第${currentEpisodeIndex + 1}集`, - poster: videoCover, - volume: 0.7, - isLive: false, - muted: false, - autoplay: true, - pip: false, - autoSize: false, - autoMini: false, - screenshot: false, - setting: true, - loop: false, - flip: false, - playbackRate: true, - aspectRatio: false, - fullscreen: true, - fullscreenWeb: false, - subtitleOffset: false, - miniProgressBar: false, - mutex: true, - backdrop: true, - playsInline: true, - autoPlayback: false, - airplay: true, - theme: '#23ade5', - lang: 'zh-cn', - hotkey: false, - moreVideoAttr: { - crossOrigin: 'anonymous', - }, - // HLS 支持配置 - customType: { - m3u8: function (video: HTMLVideoElement, url: string) { - if (!Hls) { - console.error('HLS.js 未加载'); - return; - } - - if (video.hls) { - video.hls.destroy(); - } - const hls = new Hls({ - debug: false, // 关闭日志 - enableWorker: true, // WebWorker 解码,降低主线程压力 - lowLatencyMode: true, // 开启低延迟 LL-HLS - - /* 缓冲/内存相关 */ - maxBufferLength: 30, // 前向缓冲最大 30s,过大容易导致高延迟 - backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用 - maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理 - }); - - hls.loadSource(url); - hls.attachMedia(video); - video.hls = hls; - - ensureVideoSource(video, url); - - hls.on(Hls.Events.ERROR, function (event: any, data: any) { - console.error('HLS Error:', event, data); - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - console.log('网络错误,尝试恢复...'); - hls.startLoad(); - break; - case Hls.ErrorTypes.MEDIA_ERROR: - console.log('媒体错误,尝试恢复...'); - hls.recoverMediaError(); - break; - default: - console.log('无法恢复的错误'); - hls.destroy(); - break; - } - } - }); - }, - }, - icons: { - loading: - '', - }, - // 控制栏配置 - controls: [ - { - position: 'left', - index: 13, - html: '', - tooltip: '播放下一集', - click: function () { - handleNextEpisode(); - }, - }, - { - position: 'right', - html: '选集', - tooltip: '选择集数', - click: function () { - setShowEpisodePanel(true); - }, - }, - ], - }); - - // 监听播放器事件 - artPlayerRef.current.on('ready', () => { - console.log('播放器准备就绪'); - setError(null); - - // 若存在需要恢复的播放进度,则跳转 - if (resumeTimeRef.current && resumeTimeRef.current > 0) { - try { - artPlayerRef.current.video.currentTime = resumeTimeRef.current; - } catch (err) { - console.warn('恢复播放进度失败:', err); - } - resumeTimeRef.current = null; - } - }); - - artPlayerRef.current.on('error', (err: any) => { - console.error('播放器错误:', err); - setError('视频播放失败'); - }); - - // 监听控制栏显隐事件,同步 topbar 的显隐 - artPlayerRef.current.on('control', (visible: boolean) => { - setShowTopBar(visible); - }); - - // 监听视频播放结束事件,自动播放下一集 - artPlayerRef.current.on('video:ended', () => { - const d = detailRef.current; - const idx = currentEpisodeIndexRef.current; - if (d && d.episodes && idx < d.episodes.length - 1) { - setTimeout(() => { - setCurrentEpisodeIndex(idx + 1); - }, 1000); - } - }); - if (artPlayerRef.current?.video) { - attachVideoEventListeners( - artPlayerRef.current.video as HTMLVideoElement - ); - ensureVideoSource( - artPlayerRef.current.video as HTMLVideoElement, - videoUrl - ); - } - - // ===== 在创建完成后,注入业务层并搬运 DOM ===== - const topLayerEl = document.createElement('div'); - const episodeLayerEl = document.createElement('div'); - const sourceLayerEl = document.createElement('div'); - - artPlayerRef.current.layers.add({ - name: 'topbar', - html: topLayerEl, - index: 3, - }); - artPlayerRef.current.layers.add({ - name: 'episodepanel', - html: episodeLayerEl, - index: 2, - }); - artPlayerRef.current.layers.add({ - name: 'sourcepanel', - html: sourceLayerEl, - index: 1, - }); - - topLayerRef.current = topLayerEl; - episodeLayerRef.current = episodeLayerEl; - sourceLayerRef.current = sourceLayerEl; - - // 将业务层节点移动到对应图层容器 + const onEnded = () => { + const d = detailRef.current; + const idx = currentEpisodeIndexRef.current; + if (d && d.episodes && idx < d.episodes.length - 1) { setTimeout(() => { - // 优先使用缓存的节点,如果没有则从 document 获取(首次创建时) - let { topDom, episodeDom, sourceDom } = cachedNodesRef.current; - - if (!topDom) { - topDom = document.querySelector('[data-top-bar]') as HTMLElement; - } - if (!episodeDom) { - episodeDom = document.querySelector( - '[data-episode-panel]' - ) as HTMLElement; - } - if (!sourceDom) { - sourceDom = document.querySelector( - '[data-source-panel]' - ) as HTMLElement; - } - - if (topDom && !topLayerEl.contains(topDom)) { - topLayerEl.appendChild(topDom); - } - if (episodeDom && !episodeLayerEl.contains(episodeDom)) { - episodeLayerEl.appendChild(episodeDom); - } - if (sourceDom && !sourceLayerEl.contains(sourceDom)) { - sourceLayerEl.appendChild(sourceDom); - } - }, 0); - } catch (err) { - console.error('创建播放器失败:', err); - setError('播放器初始化失败'); + setCurrentEpisodeIndex(idx + 1); + }, 1000); } - }, [Artplayer, Hls, videoUrl]); + }; + + const onTimeUpdate = () => { + const now = Date.now(); + if (now - lastSaveTimeRef.current > 5000) { + saveCurrentPlayProgress(); + lastSaveTimeRef.current = now; + } + }; + + const handlePlayerError = (e: any) => { + console.error('播放器错误:', e); + setError('视频播放失败'); + }; // 页面卸载和隐藏时保存播放进度 useEffect(() => { @@ -716,7 +362,7 @@ function PlayPageClient() { window.removeEventListener('beforeunload', handleBeforeUnload); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [currentEpisodeIndex, detail, artPlayerRef.current]); + }, [currentEpisodeIndex, detail, playerRef.current]); // 清理定时器 useEffect(() => { @@ -733,15 +379,6 @@ function PlayPageClient() { if (speedTipTimeoutRef.current) { clearTimeout(speedTipTimeoutRef.current); } - - // 清理视频事件监听器 - if (videoEventListenersRef.current) { - const { video, listeners } = videoEventListenersRef.current; - listeners.forEach(({ event, handler }) => { - video.removeEventListener(event, handler); - }); - videoEventListenersRef.current = null; - } }; }, []); @@ -766,11 +403,7 @@ function PlayPageClient() { const handleEpisodeChange = (episodeIndex: number) => { if (episodeIndex >= 0 && episodeIndex < totalEpisodes) { // 在更换集数前保存当前播放进度 - if ( - artPlayerRef.current && - artPlayerRef.current.video && - !artPlayerRef.current.video.paused - ) { + if (playerRef.current && !playerRef.current.paused) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(episodeIndex); @@ -783,11 +416,7 @@ function PlayPageClient() { const d = detailRef.current; const idx = currentEpisodeIndexRef.current; if (d && d.episodes && idx < d.episodes.length - 1) { - if ( - artPlayerRef.current && - artPlayerRef.current.video && - !artPlayerRef.current.video.paused - ) { + if (playerRef.current && !playerRef.current.paused) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(idx + 1); @@ -798,11 +427,7 @@ function PlayPageClient() { const handlePreviousEpisode = () => { const idx = currentEpisodeIndexRef.current; if (detailRef.current && idx > 0) { - if ( - artPlayerRef.current && - artPlayerRef.current.video && - !artPlayerRef.current.video.paused - ) { + if (playerRef.current && !playerRef.current.paused) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(idx - 1); @@ -982,14 +607,13 @@ function PlayPageClient() { } } + if (!playerRef.current) return; + const player = playerRef.current; + // 左箭头 = 快退 if (!e.altKey && e.key === 'ArrowLeft') { - if ( - artPlayerRef.current && - artPlayerRef.current.video && - artPlayerRef.current.video.currentTime > 5 - ) { - artPlayerRef.current.video.currentTime -= 10; + if (player.currentTime > 5) { + player.currentTime -= 10; displayShortcutHint('快退', 'left'); e.preventDefault(); } @@ -997,13 +621,8 @@ function PlayPageClient() { // 右箭头 = 快进 if (!e.altKey && e.key === 'ArrowRight') { - if ( - artPlayerRef.current && - artPlayerRef.current.video && - artPlayerRef.current.video.currentTime < - artPlayerRef.current.video.duration - 5 - ) { - artPlayerRef.current.video.currentTime += 10; + if (player.currentTime < player.duration - 5) { + player.currentTime += 10; displayShortcutHint('快进', 'right'); e.preventDefault(); } @@ -1011,57 +630,44 @@ function PlayPageClient() { // 上箭头 = 音量+ if (e.key === 'ArrowUp') { - if ( - artPlayerRef.current && - artPlayerRef.current.video && - artPlayerRef.current.video.volume < 1 - ) { - artPlayerRef.current.video.volume += 0.1; - displayShortcutHint( - `音量 ${Math.round(artPlayerRef.current.video.volume * 100)}`, - 'up' - ); + if (player.volume < 1) { + player.volume += 0.1; + displayShortcutHint(`音量 ${Math.round(player.volume * 100)}`, 'up'); e.preventDefault(); } } // 下箭头 = 音量- if (e.key === 'ArrowDown') { - if ( - artPlayerRef.current && - artPlayerRef.current.video && - artPlayerRef.current.video.volume > 0 - ) { - artPlayerRef.current.video.volume -= 0.1; - displayShortcutHint( - `音量 ${Math.round(artPlayerRef.current.video.volume * 100)}`, - 'down' - ); + if (player.volume > 0) { + player.volume -= 0.1; + displayShortcutHint(`音量 ${Math.round(player.volume * 100)}`, 'down'); e.preventDefault(); } } // 空格 = 播放/暂停 if (e.key === ' ') { - if (artPlayerRef.current) { - artPlayerRef.current.toggle(); - e.preventDefault(); - } + if (player.paused) player.play(); + else player.pause(); + e.preventDefault(); } // f 键 = 切换全屏 if (e.key === 'f' || e.key === 'F') { - if (artPlayerRef.current) { - artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen; - e.preventDefault(); + if (player.state.fullscreen) { + player.exitFullscreen(); + } else { + player.enterFullscreen(); } + e.preventDefault(); } }; // 保存播放进度的函数 const saveCurrentPlayProgress = async () => { if ( - !artPlayerRef.current?.video || + !playerRef.current || !currentSourceRef.current || !currentIdRef.current || !videoTitleRef.current || @@ -1070,9 +676,9 @@ function PlayPageClient() { return; } - const video = artPlayerRef.current.video; - const currentTime = video.currentTime || 0; - const duration = video.duration || 0; + const player = playerRef.current; + const currentTime = player.currentTime || 0; + const duration = player.duration || 0; // 如果播放时间太短(少于5秒)或者视频时长无效,不保存 if (currentTime < 1 || !duration) { @@ -1186,7 +792,7 @@ function PlayPageClient() { // 用户点击悬浮按钮 -> 请求全屏并锁定横屏 const handleForceLandscape = async () => { try { - const el: any = artRef.current || document.documentElement; + const el: any = document.documentElement; if (el.requestFullscreen) { await el.requestFullscreen(); } else if (el.webkitRequestFullscreen) { @@ -1226,21 +832,17 @@ function PlayPageClient() { } }; - const handleFsChange = () => { - const isFs = !!document.fullscreenElement; - if (isFs) { + const player = playerRef.current; + if (!player) return; + + return player.subscribe(({ fullscreen }: any) => { + if (fullscreen) { lockLandscape(); } else { unlock(); } - }; - - document.addEventListener('fullscreenchange', handleFsChange); - return () => { - document.removeEventListener('fullscreenchange', handleFsChange); - unlock(); - }; - }, []); + }); + }, [playerRef.current]); useEffect(() => { // 播放页挂载时,锁定页面滚动并消除 body 100vh 带来的额外空白 @@ -1273,15 +875,15 @@ function PlayPageClient() { // 防止在控制栏区域触发 const target = e.target as HTMLElement; if ( - target.closest('.art-controls') || - target.closest('.art-contextmenu') || - target.closest('.art-layer') + target.closest('.vds-controls') || + target.closest('.vds-context-menu') || + target.closest('.vds-lazy-gesture') ) { return; } // 仅在播放时触发 - if (!artPlayerRef.current?.playing) { + if (!playerRef.current?.playing) { return; } @@ -1292,13 +894,12 @@ function PlayPageClient() { // 设置长按检测定时器(500ms) longPressTimeoutRef.current = setTimeout(() => { - if (artPlayerRef.current && artPlayerRef.current.video) { + if (playerRef.current) { // 保存原始播放速度 - originalPlaybackRateRef.current = - artPlayerRef.current.video.playbackRate; + originalPlaybackRateRef.current = playerRef.current.playbackRate; // 设置三倍速 - artPlayerRef.current.video.playbackRate = 3; + playerRef.current.playbackRate = 3; // 更新状态 setIsLongPressing(true); @@ -1328,8 +929,8 @@ function PlayPageClient() { } // 如果正在长按,恢复原始播放速度 - if (isLongPressing && artPlayerRef.current && artPlayerRef.current.video) { - artPlayerRef.current.video.playbackRate = originalPlaybackRateRef.current; + if (isLongPressing && playerRef.current) { + playerRef.current.playbackRate = originalPlaybackRateRef.current; setIsLongPressing(false); setShowSpeedTip(false); @@ -1343,26 +944,34 @@ function PlayPageClient() { // 添加触摸事件监听器 useEffect(() => { - if (!artRef.current) return; + const playerEl = playerRef.current?.el; + if (!playerEl) return; - const element = artRef.current; const disableContextMenu = (e: Event) => { e.preventDefault(); e.stopPropagation(); }; - element.addEventListener('touchstart', handleTouchStart, { passive: true }); - element.addEventListener('touchend', handleTouchEnd, { passive: true }); - element.addEventListener('touchcancel', handleTouchEnd, { passive: true }); - element.addEventListener('contextmenu', disableContextMenu); + // @ts-ignore + playerEl.addEventListener('touchstart', handleTouchStart, { + passive: true, + }); + // @ts-ignore + playerEl.addEventListener('touchend', handleTouchEnd, { passive: true }); + // @ts-ignore + playerEl.addEventListener('touchcancel', handleTouchEnd, { passive: true }); + playerEl.addEventListener('contextmenu', disableContextMenu); return () => { - element.removeEventListener('touchstart', handleTouchStart); - element.removeEventListener('touchend', handleTouchEnd); - element.removeEventListener('touchcancel', handleTouchEnd); - element.removeEventListener('contextmenu', disableContextMenu); + // @ts-ignore + playerEl.removeEventListener('touchstart', handleTouchStart); + // @ts-ignore + playerEl.removeEventListener('touchend', handleTouchEnd); + // @ts-ignore + playerEl.removeEventListener('touchcancel', handleTouchEnd); + playerEl.removeEventListener('contextmenu', disableContextMenu); }; - }, [artRef.current, isLongPressing]); + }, [playerRef.current, isLongPressing]); if (loading) { return ( @@ -1410,6 +1019,91 @@ function PlayPageClient() { ); } + const PlayerUITopbar = ({ + videoTitle, + favorited, + totalEpisodes, + currentEpisodeIndex, + sourceName, + onToggleFavorite, + onOpenSourcePanel, + }: { + videoTitle: string; + favorited: boolean; + totalEpisodes: number; + currentEpisodeIndex: number; + sourceName: string; + onToggleFavorite: () => void; + onOpenSourcePanel: () => void; + }) => { + return ( +
+
+ {/* 返回按钮 */} + + + {/* 中央标题及集数信息 */} +
+
+ + {videoTitle} + + +
+ + {totalEpisodes > 1 && ( +
+ 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集 +
+ )} +
+ + {/* 数据源徽章放置在右侧,不影响标题居中 */} + {sourceName && ( + + {sourceName} + + )} +
+
+ ); + }; + return (
-
- - {/* 顶栏 */} -
-
- {/* 返回按钮 */} - - - {/* 中央标题及集数信息 */} -
-
- - {videoTitle} - + + + + 1 ? ( + // Desktop-only next button -
+ ) : null, + beforeCurrentTime: + totalEpisodes > 1 ? ( + // Mobile-only next button + + ) : null, + beforeFullscreenButton: ( + <> + {totalEpisodes > 1 && ( + + )} + + + ), + }} + /> - {totalEpisodes > 1 && ( -
- 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集 + {/* 选集侧拉面板 */} + {totalEpisodes > 1 && ( +
+ {/* 遮罩层 */} + {showEpisodePanel && ( +
setShowEpisodePanel(false)} + /> + )} + + {/* 侧拉面板 */} +
+
+
+

选集列表

+
- )} + +
+ 当前: 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集 +
+ +
+
+ {Array.from({ length: totalEpisodes }, (_, idx) => ( + + ))} +
+
+
- - {/* 数据源徽章放置在右侧,不影响标题居中 */} - {detail?.videoInfo?.source_name && ( - - {detail.videoInfo.source_name} - - )}
-
-
+ )} - {/* 快捷键提示 */} -
-
- - {shortcutDirection === 'left' && ( - - )} - {shortcutDirection === 'right' && ( - - )} - {shortcutDirection === 'up' && ( - - )} - {shortcutDirection === 'down' && ( - - )} - - {shortcutText} -
-
- - {/* 三倍速提示 */} -
-
- - - - 3x 倍速 -
-
- - {/* 选集侧拉面板 */} - {totalEpisodes > 1 && ( -
+ {/* 换源侧拉面板 */} +
{/* 遮罩层 */} - {showEpisodePanel && ( + {showSourcePanel && (
setShowEpisodePanel(false)} + onClick={() => setShowSourcePanel(false)} /> )} {/* 侧拉面板 */}
-

选集列表

+

播放源

-
- 当前: 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集 -
- + {/* 搜索结果 */}
-
- {Array.from({ length: totalEpisodes }, (_, idx) => ( - - ))} -
-
-
-
-
- )} + {searchLoading && ( +
+
+ 搜索中... +
+ )} - {/* 换源侧拉面板 */} -
- {/* 遮罩层 */} - {showSourcePanel && ( -
setShowSourcePanel(false)} - /> - )} + {searchError && ( +
+ {searchError} +
+ )} - {/* 侧拉面板 */} -
-
-
-

播放源

- -
+ {!searchLoading && + !searchError && + searchResults.length === 0 && ( +
+ 未找到相关视频源 +
+ )} - {/* 搜索结果 */} -
- {searchLoading && ( -
-
- 搜索中... -
- )} - - {searchError && ( -
- {searchError} -
- )} - - {!searchLoading && !searchError && searchResults.length === 0 && ( -
- 未找到相关视频源 -
- )} - - {!searchLoading && !searchError && searchResults.length > 0 && ( -
- {[ - ...searchResults.filter( - (r) => - r.source === currentSource && - String(r.id) === String(currentId) - ), - ...searchResults.filter( - (r) => - !( + {!searchLoading && !searchError && searchResults.length > 0 && ( +
+ {[ + ...searchResults.filter( + (r) => r.source === currentSource && String(r.id) === String(currentId) - ) - ), - ].map((result) => { - const isCurrentSource = - result.source === currentSource && - String(result.id) === String(currentId); - return ( -
- !isCurrentSource && - handleSourceChange( - result.source, - result.id, - result.title + ), + ...searchResults.filter( + (r) => + !( + r.source === currentSource && + String(r.id) === String(currentId) ) - } - > - {/* 视频封面 */} -
- {result.title} + ), + ].map((result) => { + const isCurrentSource = + result.source === currentSource && + String(result.id) === String(currentId); + return ( +
+ !isCurrentSource && + handleSourceChange( + result.source, + result.id, + result.title + ) + } + > + {/* 视频封面 */} +
+ {result.title} - {/* 集数圆形指示器 */} - {result.episodes && ( -
- - {result.episodes} - -
- )} - - {isCurrentSource && ( -
-
- 当前播放 + {/* 集数圆形指示器 */} + {result.episodes && ( +
+ + {result.episodes} +
-
- )} -
+ )} - {/* 视频信息 */} -
-

- {result.title} -

-
-
- {result.source_name} + {isCurrentSource && ( +
+
+ 当前播放 +
+
+ )} +
+ + {/* 视频信息 */} +
+

+ {result.title} +

+
+
+ {result.source_name} +
-
- ); - })} -
- )} + ); + })} +
+ )} +
-
+ + {/* 快捷键提示 */} +
+
+ + {shortcutDirection === 'left' && ( + + )} + {shortcutDirection === 'right' && ( + + )} + {shortcutDirection === 'up' && ( + + )} + {shortcutDirection === 'down' && ( + + )} + + {shortcutText} +
+
+ + {/* 三倍速提示 */} +
+
+ + + + 3x 倍速 +
+
+
); } +const PlaybackRateButton = ({ + playerRef, +}: { + playerRef: React.RefObject; +}) => { + const [rate, setRate] = useState(1); + const rates = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; + + useEffect(() => { + const player = playerRef.current; + if (!player) return; + // @ts-ignore + const unsubscribe = player.subscribe(({ playbackRate }) => { + setRate(playbackRate); + }); + return unsubscribe; + }, [playerRef]); + + const cycleRate = () => { + const player = playerRef.current; + if (!player) return; + const currentIndex = rates.indexOf(rate); + const nextIndex = (currentIndex + 1) % rates.length; + player.playbackRate = rates[nextIndex]; + }; + + return ( + + ); +}; + const FavoriteIcon = ({ filled }: { filled: boolean }) => { if (filled) { return (