From 43d2bd27cfc7b559ccf44d6dd04ca5845ca040e1 Mon Sep 17 00:00:00 2001 From: shinya Date: Mon, 7 Jul 2025 23:04:02 +0800 Subject: [PATCH] feat: finish new play page --- src/app/globals.css | 4 - src/app/new-play/page.tsx | 913 ------------ src/app/play/page.tsx | 2216 ++++++++++------------------ src/components/BackButton.tsx | 13 + src/components/EpisodeSelector.tsx | 18 +- src/components/MobileHeader.tsx | 17 +- src/components/PageLayout.tsx | 58 +- 7 files changed, 857 insertions(+), 2382 deletions(-) delete mode 100644 src/app/new-play/page.tsx create mode 100644 src/components/BackButton.tsx diff --git a/src/app/globals.css b/src/app/globals.css index d4407de..5ea5c46 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -153,10 +153,6 @@ div[data-media-provider] video { object-fit: contain; } -.art-notice { - display: none !important; -} - .art-poster { background-size: contain !important; /* 使图片完整展示 */ background-position: center center !important; /* 居中显示 */ diff --git a/src/app/new-play/page.tsx b/src/app/new-play/page.tsx deleted file mode 100644 index f8d4014..0000000 --- a/src/app/new-play/page.tsx +++ /dev/null @@ -1,913 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */ - -'use client'; - -import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; - -import { - deletePlayRecord, - generateStorageKey, - getAllPlayRecords, - savePlayRecord, -} from '@/lib/db.client'; -import { - type VideoDetail, - fetchVideoDetail, -} from '@/lib/fetchVideoDetail.client'; -import { SearchResult } from '@/lib/types'; - -import EpisodeSelector from '@/components/EpisodeSelector'; -import PageLayout from '@/components/PageLayout'; - -// 直接从 types.ts 导入 SearchResult 接口 - -function PlayPageClient() { - const searchParams = useSearchParams(); - - // 状态管理 - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [detail, setDetail] = useState(null); - - // 视频基本信息 - const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); - const videoYear = searchParams.get('year') || ''; - const [videoCover, setVideoCover] = useState(''); - - // 当前源和ID - const [currentSource, setCurrentSource] = useState( - searchParams.get('source') || '' - ); - const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); - - // 集数相关 - const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 - const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); - - // 视频播放地址 - const [videoUrl, setVideoUrl] = useState(''); - - // 总集数 - const totalEpisodes = detail?.episodes?.length || 0; - - // 用于记录是否需要在播放器 ready 后跳转到指定进度 - const resumeTimeRef = useRef(null); - - // 换源相关状态 - const [availableSources, setAvailableSources] = useState([]); - const [sourceSearchLoading, setSourceSearchLoading] = useState(false); - const [sourceSearchError, setSourceSearchError] = useState( - null - ); - - const currentSourceRef = useRef(currentSource); - const currentIdRef = useRef(currentId); - const videoTitleRef = useRef(videoTitle); - - // 同步最新值到 refs - useEffect(() => { - currentSourceRef.current = currentSource; - currentIdRef.current = currentId; - detailRef.current = detail; - currentEpisodeIndexRef.current = currentEpisodeIndex; - videoTitleRef.current = videoTitle; - }, [currentSource, currentId, detail, currentEpisodeIndex, videoTitle]); - - // 播放进度保存相关 - const saveIntervalRef = useRef(null); - const videoEventListenersRef = useRef<{ - video: HTMLVideoElement; - listeners: Array<{ event: string; handler: EventListener }>; - } | null>(null); - - // 动态导入的 Artplayer 与 Hls 实例 - const [{ Artplayer, Hls }, setPlayers] = useState<{ - Artplayer: any | null; - Hls: any | null; - }>({ Artplayer: null, Hls: null }); - const artPlayerRef = useRef(null); - const artRef = useRef(null); - - // 添加缺少的状态和 ref - const detailRef = useRef(detail); - const currentEpisodeIndexRef = useRef(currentEpisodeIndex); - - // 同步状态到 ref - useEffect(() => { - detailRef.current = detail; - currentEpisodeIndexRef.current = currentEpisodeIndex; - }, [detail, currentEpisodeIndex]); - - 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); - } - })(); - }, []); - - // 更新视频地址 - const updateVideoUrl = ( - detailData: VideoDetail | null, - episodeIndex: number - ) => { - if ( - !detailData || - !detailData.episodes || - episodeIndex >= detailData.episodes.length - ) { - setVideoUrl(''); - return; - } - const newUrl = detailData?.episodes[episodeIndex] || ''; - if (newUrl !== videoUrl) { - setVideoUrl(newUrl); - } - }; - - 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); - }, [detail, currentEpisodeIndex]); - - // 确保初始状态与URL参数同步 - useEffect(() => { - const urlSource = searchParams.get('source'); - const urlId = searchParams.get('id'); - - if (urlSource && urlSource !== currentSource) { - setCurrentSource(urlSource); - } - if (urlId && urlId !== currentId) { - setCurrentId(urlId); - } - }, [searchParams, currentSource, currentId]); - - // 获取视频详情 - useEffect(() => { - if (!currentSource || !currentId) { - setError('缺少必要参数'); - setLoading(false); - return; - } - - const fetchDetail = async () => { - try { - const detailData = await fetchVideoDetail({ - source: currentSource, - id: currentId, - fallbackTitle: videoTitle.trim(), - fallbackYear: videoYear, - }); - - // 更新状态保存详情 - setVideoTitle(detailData.title || videoTitle); - setVideoCover(detailData.poster); - setDetail(detailData); - - // 确保集数索引在有效范围内 - if (currentEpisodeIndex >= detailData.episodes.length) { - console.log('currentEpisodeIndex', currentEpisodeIndex); - setCurrentEpisodeIndex(0); - } - - // 清理URL参数(移除index参数) - if (searchParams.has('index')) { - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('index'); - newUrl.searchParams.delete('position'); - window.history.replaceState({}, '', newUrl.toString()); - } - } catch (err) { - setError(err instanceof Error ? err.message : '获取视频详情失败'); - } finally { - setLoading(false); - } - }; - - fetchDetail(); - }, [currentSource]); - - // 播放记录处理 - useEffect(() => { - // 仅在初次挂载时检查播放记录 - const initFromHistory = async () => { - if (!currentSource || !currentId) return; - - try { - const allRecords = await getAllPlayRecords(); - const key = generateStorageKey(currentSource, currentId); - const record = allRecords[key]; - - // URL 参数 - const urlIndexParam = searchParams.get('index'); - const urlPositionParam = searchParams.get('position'); - - // 当index参数存在时的处理逻辑 - if (urlIndexParam) { - const urlIndex = parseInt(urlIndexParam, 10) - 1; - let targetTime = 0; // 默认从0开始 - - // 只有index参数和position参数都存在时才生效position - if (urlPositionParam) { - targetTime = parseInt(urlPositionParam, 10); - } else if (record && urlIndex === record.index - 1) { - // 如果有同集播放记录则跳转到播放记录处 - targetTime = record.play_time; - } - // 否则从0开始(targetTime已经是0) - - // 更新当前选集索引 - if (urlIndex !== currentEpisodeIndex) { - setCurrentEpisodeIndex(urlIndex); - } - - // 保存待恢复的播放进度,待播放器就绪后跳转 - resumeTimeRef.current = targetTime; - } else if (record) { - // 没有index参数但有播放记录时,使用原有逻辑 - const targetIndex = record.index - 1; - const targetTime = record.play_time; - - // 更新当前选集索引 - if (targetIndex !== currentEpisodeIndex) { - setCurrentEpisodeIndex(targetIndex); - } - - // 保存待恢复的播放进度,待播放器就绪后跳转 - resumeTimeRef.current = targetTime; - } - } catch (err) { - console.error('读取播放记录失败:', err); - } - }; - - initFromHistory(); - }, []); - - // 处理换源搜索 - const handleSearchSources = async (query: string) => { - if (!query.trim()) { - setAvailableSources([]); - return; - } - - setSourceSearchLoading(true); - setSourceSearchError(null); - - try { - const response = await fetch( - `/api/search?q=${encodeURIComponent(query.trim())}` - ); - if (!response.ok) { - throw new Error('搜索失败'); - } - const data = await response.json(); - - // 处理搜索结果:每个数据源只展示一个,优先展示与title同名的结果 - const processedResults: SearchResult[] = []; - const sourceMap = new Map(); - - // 按数据源分组 - data.results?.forEach((result: SearchResult) => { - if (!sourceMap.has(result.source)) { - sourceMap.set(result.source, []); - } - const list = sourceMap.get(result.source); - if (list) { - list.push(result); - } - }); - - // 为每个数据源选择最佳结果 - sourceMap.forEach((results) => { - if (results.length === 0) return; - - // 只选择和当前视频标题完全匹配的结果,如果有年份,还需要年份完全匹配 - const exactMatch = results.find( - (result) => - result.title.toLowerCase() === videoTitle.toLowerCase() && - (videoYear - ? result.year.toLowerCase() === videoYear.toLowerCase() - : true) && - detail?.episodes.length && - ((detail?.episodes.length === 1 && result.episodes.length === 1) || - (detail?.episodes.length > 1 && result.episodes.length > 1)) - ); - - if (exactMatch) { - processedResults.push(exactMatch); - return; - } - }); - - // 直接使用 SearchResult 格式 - setAvailableSources(processedResults); - } catch (err) { - setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); - setAvailableSources([]); - } finally { - setSourceSearchLoading(false); - } - }; - - // 处理换源 - const handleSourceChange = async ( - newSource: string, - newId: string, - newTitle: string - ) => { - try { - // 记录当前播放进度(仅在同一集数切换时恢复) - const currentPlayTime = - artPlayerRef.current?.video?.currentTime || - artPlayerRef.current?.currentTime || - 0; - console.log('换源前当前播放时间:', currentPlayTime); - - // 显示加载状态 - setError(null); - - // 清除前一个历史记录 - if (currentSource && currentId) { - try { - await deletePlayRecord(currentSource, currentId); - console.log('已清除前一个播放记录'); - } catch (err) { - console.error('清除播放记录失败:', err); - } - } - - // 获取新源的详情 - const newDetail = await fetchVideoDetail({ - source: newSource, - id: newId, - fallbackTitle: newTitle.trim(), - fallbackYear: videoYear, - }); - - // 尝试跳转到当前正在播放的集数 - let targetIndex = currentEpisodeIndex; - - // 如果当前集数超出新源的范围,则跳转到第一集 - if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) { - targetIndex = 0; - } - - // 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度 - if (targetIndex === currentEpisodeIndex && currentPlayTime > 1) { - resumeTimeRef.current = currentPlayTime; - } else { - // 否则从头开始播放,防止影响后续选集逻辑 - resumeTimeRef.current = 0; - } - - // 更新URL参数(不刷新页面) - const newUrl = new URL(window.location.href); - newUrl.searchParams.set('source', newSource); - newUrl.searchParams.set('id', newId); - window.history.replaceState({}, '', newUrl.toString()); - - setVideoTitle(newDetail.title || newTitle); - setVideoCover(newDetail.poster); - setCurrentSource(newSource); - setCurrentId(newId); - setDetail(newDetail); - setCurrentEpisodeIndex(targetIndex); - } catch (err) { - setError(err instanceof Error ? err.message : '换源失败'); - } - }; - - // 处理集数切换 - const handleEpisodeChange = (episodeNumber: number) => { - if (episodeNumber >= 0 && episodeNumber < totalEpisodes) { - // 在更换集数前保存当前播放进度 - if ( - artPlayerRef.current && - artPlayerRef.current.video && - !artPlayerRef.current.video.paused - ) { - saveCurrentPlayProgress(); - } - setCurrentEpisodeIndex(episodeNumber); - } - }; - - const handleNextEpisode = () => { - 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 - ) { - saveCurrentPlayProgress(); - } - setCurrentEpisodeIndex(idx + 1); - } - }; - - // 保存播放进度 - const saveCurrentPlayProgress = async () => { - if ( - !artPlayerRef.current || - !currentSourceRef.current || - !currentIdRef.current || - !videoTitleRef.current || - !detailRef.current?.source_name - ) { - return; - } - - const player = artPlayerRef.current; - const currentTime = player.currentTime || 0; - const duration = player.duration || 0; - - // 如果播放时间太短(少于5秒)或者视频时长无效,不保存 - if (currentTime < 1 || !duration) { - return; - } - - try { - await savePlayRecord(currentSource, currentId, { - title: videoTitleRef.current, - source_name: detailRef.current?.source_name || '', - year: videoYear || detailRef.current?.year || '', - cover: videoCover, - index: currentEpisodeIndex + 1, // 转换为1基索引 - total_episodes: totalEpisodes, - play_time: Math.floor(currentTime), - total_time: Math.floor(duration), - save_time: Date.now(), - }); - console.log('播放进度已保存:', { - title: videoTitleRef.current, - episode: currentEpisodeIndexRef.current + 1, - progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`, - }); - } catch (err) { - console.error('保存播放进度失败:', err); - } - }; - - useLayoutEffect(() => { - const container = document.getElementById( - 'artplayer-container' - ) as HTMLDivElement; - - if ( - !Artplayer || - !Hls || - !videoUrl || - loading || - currentEpisodeIndex === null || - !container - ) { - return; - } - - artRef.current = container; - - // 确保选集索引有效 - 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) { - console.log('attachVideoEventListeners'); - attachVideoEventListeners( - artPlayerRef.current.video as HTMLVideoElement - ); - ensureVideoSource( - artPlayerRef.current.video as HTMLVideoElement, - videoUrl - ); - } - return; - } - - // WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的 - if (artPlayerRef.current) { - 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: '#22c55e', - 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(); - }, - }, - ], - }); - - // 监听播放器事件 - artPlayerRef.current.on('ready', () => { - setError(null); - }); - - // 监听视频可播放事件,这时恢复播放进度更可靠 - artPlayerRef.current.on('video:canplay', () => { - // 若存在需要恢复的播放进度,则跳转 - if (resumeTimeRef.current && resumeTimeRef.current > 0) { - try { - artPlayerRef.current.video.currentTime = resumeTimeRef.current; - console.log('成功恢复播放进度到:', resumeTimeRef.current); - } catch (err) { - console.warn('恢复播放进度失败:', err); - } - resumeTimeRef.current = null; - } - }); - - artPlayerRef.current.on('error', (err: any) => { - console.error('播放器错误:', err); - setError('视频播放失败'); - }); - - // 监听视频播放结束事件,自动播放下一集 - 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) { - console.log('attachVideoEventListeners'); - attachVideoEventListeners( - artPlayerRef.current.video as HTMLVideoElement - ); - ensureVideoSource( - artPlayerRef.current.video as HTMLVideoElement, - videoUrl - ); - } - } catch (err) { - console.error('创建播放器失败:', err); - setError('播放器初始化失败'); - } - }, [Artplayer, Hls, videoUrl]); - - const attachVideoEventListeners = (video: HTMLVideoElement) => { - if (!video) return; - - // 移除旧监听器(如果存在) - 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; - } - }; - - 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 }, - ], - }; - }; - - // 当组件卸载时清理定时器 - useEffect(() => { - return () => { - if (saveIntervalRef.current) { - clearInterval(saveIntervalRef.current); - } - }; - }, []); - - if (loading) { - return ( - -
-
-
-

- 加载中... -

-
-
-
- ); - } - - if (error) { - return ( - -
-
-
⚠️
-

- {error} -

- -
-
-
- ); - } - - return ( - -
- {/* 第一行:影片标题 */} -
-

- {videoTitle || '影片标题'} - {totalEpisodes > 1 && ( - - {` > 第 ${currentEpisodeIndex + 1} 集`} - - )} -

-
- {/* 第二行:播放器和选集 */} -
- {/* 播放器 */} -
-
-
- - {/* 选集和换源 */} -
- -
-
- - {/* 详情展示 */} -
- {/* 文字区 */} -
-
- {/* 标题 */} -

- {videoTitle || '影片标题'} -

- - {/* 关键信息行 */} -
- {detail?.class && ( - - {detail.class} - - )} - {(detail?.year || videoYear) && ( - {detail?.year || videoYear} - )} - {detail?.source_name && ( - - {detail.source_name} - - )} - {detail?.type_name && {detail.type_name}} -
- {/* 剧情简介 */} - {detail?.desc && ( -
- {detail.desc} -
- )} -
-
- - {/* 封面展示 */} -
-
-
- {videoCover ? ( - {videoTitle} - ) : ( - - 封面图片 - - )} -
-
-
-
-
-
- ); -} - -export default function PlayPage() { - return ( - Loading...}> - - - ); -} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 91c0f39..c015acf 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2,36 +2,11 @@ 'use client'; -import { - type MediaProviderAdapter, - AirPlayButton, - isHLSProvider, - MediaPlayer, - MediaProvider, - Menu, - RadioGroup, - SeekButton, -} from '@vidstack/react'; -import { - AirPlayIcon, - CheckIcon, - SeekBackward10Icon, - SeekForward10Icon, -} from '@vidstack/react/icons'; -import { - defaultLayoutIcons, - DefaultVideoLayout, -} from '@vidstack/react/player/layouts/default'; +import Artplayer from 'artplayer'; import Hls from 'hls.js'; import { Heart } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; -import { Suspense } from 'react'; -import { useCallback, 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 { Suspense, useEffect, useRef, useState } from 'react'; import { deletePlayRecord, @@ -47,6 +22,9 @@ import { } from '@/lib/fetchVideoDetail.client'; import { SearchResult } from '@/lib/types'; +import EpisodeSelector from '@/components/EpisodeSelector'; +import PageLayout from '@/components/PageLayout'; + // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { interface HTMLVideoElement { @@ -56,135 +34,18 @@ declare global { function PlayPageClient() { const searchParams = useSearchParams(); - // @ts-ignore - const playerRef = useRef(null); - const playerContainerRef = useRef(null); + + // ----------------------------------------------------------------------------- + // 状态变量(State) + // ----------------------------------------------------------------------------- const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - - // 使用 useState 保存视频详情 const [detail, setDetail] = useState(null); - // 初始标题:如果 URL 中携带 title 参数,则优先使用 - const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); - const videoYear = searchParams.get('year') || ''; - const [videoCover, setVideoCover] = useState(''); - - const [currentSource, setCurrentSource] = useState( - searchParams.get('source') || '' - ); - const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); - const [sourceChanging, setSourceChanging] = useState(false); - const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 - - const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); - const [showEpisodePanel, setShowEpisodePanel] = useState(false); - const [showSourcePanel, setShowSourcePanel] = useState(false); - const [showShortcutHint, setShowShortcutHint] = useState(false); - const [shortcutText, setShortcutText] = useState(''); - const [shortcutDirection, setShortcutDirection] = useState(''); - const [reverseEpisodeOrder, setReverseEpisodeOrder] = useState(false); - const shortcutHintTimeoutRef = useRef(null); - - // NEW STATE: 控制快进/快退按钮是否显示 - const [showSkipButtons, setShowSkipButtons] = useState(true); - - // 使用 ResizeObserver 根据 MediaPlayer 元素尺寸动态决定按钮显隐 - useEffect(() => { - if ( - typeof window === 'undefined' || - typeof ResizeObserver === 'undefined' - ) { - return; - } - - const updateShowSkipButtons = () => { - const el: HTMLElement | undefined = (playerRef.current as any)?.el; - if (!el) return; - const rect = el.getBoundingClientRect(); - // width < 576 或 height < 380 时隐藏 - setShowSkipButtons(!(rect.width < 576 || rect.height < 380)); - }; - - // 尝试立即更新一次 - updateShowSkipButtons(); - - const observer = new ResizeObserver(updateShowSkipButtons); - // 有可能此时 el 还未就绪,使用轮询确保绑定 - let retryTimer: NodeJS.Timeout | null = null; - const attachObserver = () => { - const el: HTMLElement | undefined = (playerRef.current as any)?.el; - if (el) { - observer.observe(el); - if (retryTimer) clearInterval(retryTimer); - } - }; - - attachObserver(); - if (!(playerRef.current as any)?.el) { - // 如果首次未获取到 el,继续重试直至获取 - retryTimer = setInterval(attachObserver, 200); - } - - // orientationchange 也可能影响高/宽 - window.addEventListener('orientationchange', updateShowSkipButtons); - - return () => { - observer.disconnect(); - if (retryTimer) clearInterval(retryTimer); - window.removeEventListener('orientationchange', updateShowSkipButtons); - }; - }, []); - - // 换源相关状态 - const [searchResults, setSearchResults] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - const [searchError, setSearchError] = useState(null); - const hasSearchedRef = useRef(false); - - // 视频播放地址 - const [videoUrl, setVideoUrl] = useState(''); - - // 播放进度保存相关 - const saveIntervalRef = useRef(null); - const lastSaveTimeRef = useRef(0); - - // 总集数:从 detail 中获取,保证随 detail 更新而变化 - const totalEpisodes = detail?.episodes?.length || 0; - // 收藏状态 const [favorited, setFavorited] = useState(false); - // 是否显示旋转提示(5s 后自动隐藏) - const [showOrientationTip, setShowOrientationTip] = useState(false); - const orientationTipTimeoutRef = useRef(null); - // 当前是否处于竖屏,用于控制"强制横屏"按钮显隐 - const [isPortrait, setIsPortrait] = useState( - typeof window !== 'undefined' - ? window.matchMedia('(orientation: portrait)').matches - : true - ); - const [isFullscreen, setIsFullscreen] = useState(false); - // 用于记录是否需要在播放器 ready 后跳转到指定进度 - const resumeTimeRef = useRef(null); - - const currentEpisodeIndexRef = useRef(currentEpisodeIndex); - const detailRef = useRef(detail); - - const currentSourceRef = useRef(currentSource); - const currentIdRef = useRef(currentId); - const videoTitleRef = useRef(videoTitle); - - // 标记是否已触发过一次 sourcechange(首次不重建播放器) - const hasSourceChangedRef = useRef(false); - - // 当播放器因重建而触发一次额外的 sourcechange 时,用于忽略那一次 - const ignoreSourceChangeRef = useRef(false); - - // 上次使用的音量,默认 0.7 - const lastVolumeRef = useRef(0.7); - - // 新增:去广告开关(从 localStorage 继承,默认 true) + // 去广告开关(从 localStorage 继承,默认 true) const [blockAdEnabled, _setBlockAdEnabled] = useState(() => { if (typeof window !== 'undefined') { const v = localStorage.getItem('enable_blockad'); @@ -193,11 +54,24 @@ function PlayPageClient() { return true; }); - // 长按三倍速相关 - const longPressTimeoutRef = useRef(null); - const normalPlaybackRateRef = useRef(1); - // 标记长按是否已生效 - const longPressActiveRef = useRef(false); + // 视频基本信息 + const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); + const videoYear = searchParams.get('year') || ''; + const [videoCover, setVideoCover] = useState(''); + // 当前源和ID + const [currentSource, setCurrentSource] = useState( + searchParams.get('source') || '' + ); + const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); + // 集数相关 + const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 + const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); + + const currentSourceRef = useRef(currentSource); + const currentIdRef = useRef(currentId); + const videoTitleRef = useRef(videoTitle); + const detailRef = useRef(detail); + const currentEpisodeIndexRef = useRef(currentEpisodeIndex); // 同步最新值到 refs useEffect(() => { @@ -208,28 +82,44 @@ function PlayPageClient() { videoTitleRef.current = videoTitle; }, [currentSource, currentId, detail, currentEpisodeIndex, videoTitle]); - // 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh - const setVH = useCallback(() => { - if (typeof window !== 'undefined') { - document.documentElement.style.setProperty( - '--vh', - `${window.innerHeight * 0.01}px` - ); - } - }, []); + // 视频播放地址 + const [videoUrl, setVideoUrl] = useState(''); - // 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh - useEffect(() => { - setVH(); - window.addEventListener('resize', setVH); - window.addEventListener('orientationchange', setVH); - return () => { - window.removeEventListener('resize', setVH); - window.removeEventListener('orientationchange', setVH); - }; - }, [setVH]); + // 总集数 + const totalEpisodes = detail?.episodes?.length || 0; - // 根据 detail 和集数索引更新视频地址(仅当地址真正变化时) + // 长按三倍速相关状态 + const [isLongPressing, setIsLongPressing] = useState(false); + const longPressTimeoutRef = useRef(null); + const originalPlaybackRateRef = useRef(1); + + // 用于记录是否需要在播放器 ready 后跳转到指定进度 + const resumeTimeRef = useRef(null); + // 上次使用的音量,默认 0.7 + const lastVolumeRef = useRef(0.7); + + // 换源相关状态 + const [availableSources, setAvailableSources] = useState([]); + const [sourceSearchLoading, setSourceSearchLoading] = useState(false); + const [sourceSearchError, setSourceSearchError] = useState( + null + ); + + // 播放进度保存相关 + const saveIntervalRef = useRef(null); + const lastSaveTimeRef = useRef(0); + const videoEventListenersRef = useRef<{ + video: HTMLVideoElement; + listeners: Array<{ event: string; handler: EventListener }>; + } | null>(null); + + const artPlayerRef = useRef(null); + const artRef = useRef(null); + + // ----------------------------------------------------------------------------- + // 工具函数(Utils) + // ----------------------------------------------------------------------------- + // 更新视频地址 const updateVideoUrl = ( detailData: VideoDetail | null, episodeIndex: number @@ -245,10 +135,79 @@ function PlayPageClient() { const newUrl = detailData?.episodes[episodeIndex] || ''; if (newUrl !== videoUrl) { setVideoUrl(newUrl); - playerContainerRef.current?.focus(); } }; + 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'); + } + }; + + // 去广告相关函数 + function filterAdsFromM3U8(m3u8Content: string): string { + if (!m3u8Content) return ''; + + // 按行分割M3U8内容 + const lines = m3u8Content.split('\n'); + const filteredLines = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // 只过滤#EXT-X-DISCONTINUITY标识 + if (!line.includes('#EXT-X-DISCONTINUITY')) { + filteredLines.push(line); + } + } + + return filteredLines.join('\n'); + } + + class CustomHlsJsLoader extends Hls.DefaultConfig.loader { + constructor(config: any) { + super(config); + const load = this.load.bind(this); + this.load = function (context: any, config: any, callbacks: any) { + // 拦截manifest和level请求 + if ( + (context as any).type === 'manifest' || + (context as any).type === 'level' + ) { + const onSuccess = callbacks.onSuccess; + callbacks.onSuccess = function ( + response: any, + stats: any, + context: any + ) { + // 如果是m3u8文件,处理内容以移除广告分段 + if (response.data && typeof response.data === 'string') { + // 过滤掉广告段 - 实现更精确的广告过滤逻辑 + response.data = filterAdsFromM3U8(response.data); + } + return onSuccess(response, stats, context, null); + }; + } + // 执行原始load方法 + load(context, config, callbacks); + }; + } + } + // 当集数索引变化时自动更新视频地址 useEffect(() => { updateVideoUrl(detail, currentEpisodeIndex); @@ -267,6 +226,7 @@ function PlayPageClient() { } }, [searchParams, currentSource, currentId]); + // 获取视频详情 useEffect(() => { if (!currentSource || !currentId) { setError('缺少必要参数'); @@ -311,7 +271,7 @@ function PlayPageClient() { fetchDetail(); }, [currentSource]); - /* -------------------- 播放记录处理 -------------------- */ + // 播放记录处理 useEffect(() => { // 仅在初次挂载时检查播放记录 const initFromHistory = async () => { @@ -368,171 +328,18 @@ function PlayPageClient() { initFromHistory(); }, []); - // 播放器事件处理 - const onCanPlay = () => { - console.log('播放器准备就绪'); - setError(null); - - // 若存在需要恢复的播放进度,则跳转 - if ( - playerRef.current && - resumeTimeRef.current && - resumeTimeRef.current > 0 - ) { - try { - const duration = playerRef.current.duration || 0; - let target = resumeTimeRef.current; - // 如果目标时间距离结尾过近,为避免自动触发下一集,向前偏移 5 秒 - if (duration && target >= duration - 2) { - target = Math.max(0, duration - 5); - } - playerRef.current.currentTime = target; - } catch (err) { - console.warn('恢复播放进度失败:', err); - } - resumeTimeRef.current = null; - } - - if (playerRef.current) { - setTimeout(() => { - try { - playerRef.current.volume = lastVolumeRef.current; - } catch (_) { - // 忽略异常 - } - }, 0); - } - - // 绑定长按三倍速事件 - playerRef.current?.addEventListener('touchstart', handleLongPressStart); - playerRef.current?.addEventListener('touchend', handleLongPressEnd); - }; - - const onEnded = () => { - const d = detailRef.current; - const idx = currentEpisodeIndexRef.current; - if (d && d.episodes && idx < d.episodes.length - 1) { - setTimeout(() => { - setCurrentEpisodeIndex(idx + 1); - }, 1000); - } - }; - - const onTimeUpdate = () => { - const now = Date.now(); - if (now - lastSaveTimeRef.current > 5000) { - saveCurrentPlayProgress(); - lastSaveTimeRef.current = now; - } - }; - - const handlePlayerError = (e: any) => { - console.error('播放器错误:', e); - setError('视频播放失败'); - }; - - // 页面卸载和隐藏时保存播放进度 - useEffect(() => { - // 页面即将卸载时保存播放进度 - const handleBeforeUnload = () => { - saveCurrentPlayProgress(); - }; - - // 页面可见性变化时保存播放进度 - const handleVisibilityChange = () => { - if (document.visibilityState === 'hidden') { - saveCurrentPlayProgress(); - } - }; - - // 添加事件监听器 - window.addEventListener('beforeunload', handleBeforeUnload); - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - // 清理事件监听器 - window.removeEventListener('beforeunload', handleBeforeUnload); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [currentEpisodeIndex, detail, playerRef.current]); - - // 清理定时器 - useEffect(() => { - return () => { - if (shortcutHintTimeoutRef.current) { - clearTimeout(shortcutHintTimeoutRef.current); - } - if (saveIntervalRef.current) { - clearInterval(saveIntervalRef.current); - } - if (longPressTimeoutRef.current) { - clearTimeout(longPressTimeoutRef.current); - } - }; - }, []); - - // 当视频标题变化时重置搜索状态 - useEffect(() => { - if (videoTitle) { - hasSearchedRef.current = false; - setSearchResults([]); - setSearchError(null); - } - }, [videoTitle]); - - // 添加键盘事件监听器 (使用 refs 避免重复绑定) - useEffect(() => { - document.addEventListener('keydown', handleKeyboardShortcuts); - return () => { - document.removeEventListener('keydown', handleKeyboardShortcuts); - }; - }, []); - - // 处理选集切换 - const handleEpisodeChange = (episodeIndex: number) => { - if (episodeIndex >= 0 && episodeIndex < totalEpisodes) { - // 在更换集数前保存当前播放进度 - if (playerRef.current && !playerRef.current.paused) { - saveCurrentPlayProgress(); - } - playerRef.current; - setCurrentEpisodeIndex(episodeIndex); - setShowEpisodePanel(false); - } - }; - - // 处理下一集 - const handleNextEpisode = () => { - const d = detailRef.current; - const idx = currentEpisodeIndexRef.current; - if (d && d.episodes && idx < d.episodes.length - 1) { - if (playerRef.current && !playerRef.current.paused) { - saveCurrentPlayProgress(); - } - setCurrentEpisodeIndex(idx + 1); - } - }; - - // 处理上一集 - const handlePreviousEpisode = () => { - const idx = currentEpisodeIndexRef.current; - if (detailRef.current && idx > 0) { - if (playerRef.current && !playerRef.current.paused) { - saveCurrentPlayProgress(); - } - setCurrentEpisodeIndex(idx - 1); - } - }; - - // 搜索视频源 - const handleSearch = async (query: string) => { + // --------------------------------------------------------------------------- + // 换源搜索与切换 + // --------------------------------------------------------------------------- + // 处理换源搜索 + const handleSearchSources = async (query: string) => { if (!query.trim()) { - setSearchResults([]); + setAvailableSources([]); return; } - setSearchLoading(true); - setSearchError(null); + setSourceSearchLoading(true); + setSourceSearchError(null); try { const response = await fetch( @@ -569,11 +376,9 @@ function PlayPageClient() { (videoYear ? result.year.toLowerCase() === videoYear.toLowerCase() : true) && - detailRef.current?.episodes.length && - ((detailRef.current?.episodes.length === 1 && - result.episodes.length === 1) || - (detailRef.current?.episodes.length > 1 && - result.episodes.length > 1)) + detail?.episodes.length && + ((detail?.episodes.length === 1 && result.episodes.length === 1) || + (detail?.episodes.length > 1 && result.episodes.length > 1)) ); if (exactMatch) { @@ -582,12 +387,12 @@ function PlayPageClient() { } }); - setSearchResults(processedResults); + setAvailableSources(processedResults); } catch (err) { - setSearchError(err instanceof Error ? err.message : '搜索失败'); - setSearchResults([]); + setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); + setAvailableSources([]); } finally { - setSearchLoading(false); + setSourceSearchLoading(false); } }; @@ -599,10 +404,10 @@ function PlayPageClient() { ) => { try { // 记录当前播放进度(仅在同一集数切换时恢复) - const currentPlayTime = playerRef.current?.currentTime || 0; + const currentPlayTime = artPlayerRef.current?.currentTime || 0; + console.log('换源前当前播放时间:', currentPlayTime); - // 显示换源加载状态 - setSourceChanging(true); + // 显示加载状态 setError(null); // 清除前一个历史记录 @@ -645,9 +450,6 @@ function PlayPageClient() { newUrl.searchParams.set('id', newId); window.history.replaceState({}, '', newUrl.toString()); - // 关闭换源面板 - setShowSourcePanel(false); - setVideoTitle(newDetail.title || newTitle); setVideoCover(newDetail.poster); setCurrentSource(newSource); @@ -656,39 +458,55 @@ function PlayPageClient() { setCurrentEpisodeIndex(targetIndex); } catch (err) { setError(err instanceof Error ? err.message : '换源失败'); - } finally { - setSourceChanging(false); } }; - // 处理播放源面板展开 - const handleSourcePanelOpen = () => { - setShowSourcePanel(true); - playerContainerRef.current?.focus(); - // 只在第一次展开时搜索 - if (videoTitle && !hasSearchedRef.current) { - handleSearch(videoTitle); - hasSearchedRef.current = true; + useEffect(() => { + document.addEventListener('keydown', handleKeyboardShortcuts); + return () => { + document.removeEventListener('keydown', handleKeyboardShortcuts); + }; + }, []); + + // --------------------------------------------------------------------------- + // 集数切换 + // --------------------------------------------------------------------------- + // 处理集数切换 + const handleEpisodeChange = (episodeNumber: number) => { + if (episodeNumber >= 0 && episodeNumber < totalEpisodes) { + // 在更换集数前保存当前播放进度 + if (artPlayerRef.current && artPlayerRef.current.paused) { + saveCurrentPlayProgress(); + } + setCurrentEpisodeIndex(episodeNumber); } }; - // 显示快捷键提示 - const displayShortcutHint = (text: string, direction: string) => { - setShortcutText(text); - setShortcutDirection(direction); - setShowShortcutHint(true); - - // 清除之前的超时 - if (shortcutHintTimeoutRef.current) { - clearTimeout(shortcutHintTimeoutRef.current); + const handlePreviousEpisode = () => { + const d = detailRef.current; + const idx = currentEpisodeIndexRef.current; + if (d && d.episodes && idx > 0) { + if (artPlayerRef.current && !artPlayerRef.current.paused) { + saveCurrentPlayProgress(); + } + setCurrentEpisodeIndex(idx - 1); } - - // 2秒后隐藏 - shortcutHintTimeoutRef.current = setTimeout(() => { - setShowShortcutHint(false); - }, 2000); }; + const handleNextEpisode = () => { + const d = detailRef.current; + const idx = currentEpisodeIndexRef.current; + if (d && d.episodes && idx < d.episodes.length - 1) { + if (artPlayerRef.current && !artPlayerRef.current.paused) { + saveCurrentPlayProgress(); + } + setCurrentEpisodeIndex(idx + 1); + } + }; + + // --------------------------------------------------------------------------- + // 键盘快捷键 + // --------------------------------------------------------------------------- // 处理全局快捷键 const handleKeyboardShortcuts = (e: KeyboardEvent) => { // 忽略输入框中的按键事件 @@ -702,11 +520,8 @@ function PlayPageClient() { if (e.altKey && e.key === 'ArrowLeft') { if (detailRef.current && currentEpisodeIndexRef.current > 0) { handlePreviousEpisode(); - displayShortcutHint('上一集', 'left'); - } else { - displayShortcutHint('已经是第一集了', 'error'); + e.preventDefault(); } - e.preventDefault(); } // Alt + 右箭头 = 下一集 @@ -715,91 +530,77 @@ function PlayPageClient() { const idx = currentEpisodeIndexRef.current; if (d && idx < d.episodes.length - 1) { handleNextEpisode(); - displayShortcutHint('下一集', 'right'); - } else { - displayShortcutHint('已经是最后一集了', 'error'); + e.preventDefault(); } - e.preventDefault(); } - if (!playerRef.current) return; - const player = playerRef.current; - // 左箭头 = 快退 if (!e.altKey && e.key === 'ArrowLeft') { - if (player.currentTime > 5) { - player.currentTime -= 10; - displayShortcutHint('快退', 'left'); + if (artPlayerRef.current && artPlayerRef.current.currentTime > 5) { + artPlayerRef.current.currentTime -= 10; + e.preventDefault(); } - e.preventDefault(); } // 右箭头 = 快进 if (!e.altKey && e.key === 'ArrowRight') { - if (player.currentTime < player.duration - 5) { - player.currentTime += 10; - displayShortcutHint('快进', 'right'); + if ( + artPlayerRef.current && + artPlayerRef.current.currentTime < artPlayerRef.current.duration - 5 + ) { + artPlayerRef.current.currentTime += 10; + e.preventDefault(); } - e.preventDefault(); } // 上箭头 = 音量+ if (e.key === 'ArrowUp') { - const currentVolume = player.volume; - if (currentVolume < 1) { - player.volume += 0.1; - displayShortcutHint( - `音量 ${Math.round((currentVolume + 0.1) * 100)}`, - 'up' - ); - } else { - displayShortcutHint('音量 100', 'up'); + if (artPlayerRef.current && artPlayerRef.current.volume < 1) { + artPlayerRef.current.volume = + Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10; + artPlayerRef.current.notice.show = `音量: ${Math.round( + artPlayerRef.current.volume * 100 + )}`; + e.preventDefault(); } - e.preventDefault(); } // 下箭头 = 音量- if (e.key === 'ArrowDown') { - const currentVolume = player.volume; - if (currentVolume > 0) { - player.volume -= 0.1; - displayShortcutHint( - `音量 ${Math.round((currentVolume - 0.1) * 100)}`, - 'down' - ); - } else { - displayShortcutHint('音量 0', 'down'); + if (artPlayerRef.current && artPlayerRef.current.volume > 0) { + artPlayerRef.current.volume = + Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10; + artPlayerRef.current.notice.show = `音量: ${Math.round( + artPlayerRef.current.volume * 100 + )}`; + e.preventDefault(); } - e.preventDefault(); } // 空格 = 播放/暂停 if (e.key === ' ') { - if (playerRef.current.paused) { - playerRef.current.play(); - displayShortcutHint('播放', 'play'); - } else { - playerRef.current.pause(); - displayShortcutHint('暂停', 'pause'); + if (artPlayerRef.current) { + artPlayerRef.current.toggle(); + e.preventDefault(); } - e.preventDefault(); } // f 键 = 切换全屏 if (e.key === 'f' || e.key === 'F') { - if (player.state.fullscreen) { - player.exitFullscreen(); - } else { - player.enterFullscreen(); + if (artPlayerRef.current) { + artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen; + e.preventDefault(); } - e.preventDefault(); } }; - // 保存播放进度的函数 + // --------------------------------------------------------------------------- + // 播放记录相关 + // --------------------------------------------------------------------------- + // 保存播放进度 const saveCurrentPlayProgress = async () => { if ( - !playerRef.current || + !artPlayerRef.current || !currentSourceRef.current || !currentIdRef.current || !videoTitleRef.current || @@ -808,7 +609,7 @@ function PlayPageClient() { return; } - const player = playerRef.current; + const player = artPlayerRef.current; const currentTime = player.currentTime || 0; const duration = player.duration || 0; @@ -818,12 +619,12 @@ function PlayPageClient() { } try { - await savePlayRecord(currentSourceRef.current, currentIdRef.current, { + await savePlayRecord(currentSource, currentId, { title: videoTitleRef.current, - source_name: detailRef.current?.source_name, + source_name: detailRef.current?.source_name || '', + year: videoYear || detailRef.current?.year || '', cover: videoCover, - year: detailRef.current?.year || videoYear || '', - index: currentEpisodeIndexRef.current + 1, // 转换为1基索引 + index: currentEpisodeIndex + 1, // 转换为1基索引 total_episodes: totalEpisodes, play_time: Math.floor(currentTime), total_time: Math.floor(duration), @@ -841,6 +642,45 @@ function PlayPageClient() { } }; + useEffect(() => { + // 页面即将卸载时保存播放进度 + const handleBeforeUnload = () => { + saveCurrentPlayProgress(); + }; + + // 页面可见性变化时保存播放进度 + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + saveCurrentPlayProgress(); + } + }; + + // 添加事件监听器 + window.addEventListener('beforeunload', handleBeforeUnload); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + // 清理事件监听器 + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [currentEpisodeIndex, detail, artPlayerRef.current]); + + // 清理定时器 + useEffect(() => { + return () => { + if (saveIntervalRef.current) { + clearInterval(saveIntervalRef.current); + } + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } + }; + }, []); + + // --------------------------------------------------------------------------- + // 收藏相关 + // --------------------------------------------------------------------------- // 每当 source 或 id 变化时检查收藏状态 useEffect(() => { if (!currentSource || !currentId) return; @@ -873,1033 +713,566 @@ function PlayPageClient() { } }; - // 监听屏幕方向变化:竖屏时显示提示蒙层 useEffect(() => { - if (typeof window === 'undefined') return; - const mql = window.matchMedia('(orientation: portrait)'); - - const update = () => { - const portrait = mql.matches; - - // 更新竖屏状态 - setIsPortrait(portrait); - - // 在进入竖屏时显示提示,5 秒后自动隐藏 - if (portrait) { - setShowOrientationTip(true); - if (orientationTipTimeoutRef.current) { - clearTimeout(orientationTipTimeoutRef.current); - } - orientationTipTimeoutRef.current = setTimeout(() => { - setShowOrientationTip(false); - }, 5000); - } else { - setShowOrientationTip(false); - if (orientationTipTimeoutRef.current) { - clearTimeout(orientationTipTimeoutRef.current); - orientationTipTimeoutRef.current = null; - } - } - }; - - // 初始执行一次 - update(); - - if (mql.addEventListener) { - mql.addEventListener('change', update); - } else { - // Safari < 14 - // @ts-ignore - mql.addListener(update); - } - - return () => { - if (mql.removeEventListener) { - mql.removeEventListener('change', update); - } else { - // @ts-ignore - mql.removeListener(update); - } - }; - }, []); - - // 用户点击悬浮按钮 -> 请求全屏并锁定横屏 - const handleForceLandscape = async () => { - try { - playerRef.current?.enterFullscreen(); - - if (screen.orientation && (screen.orientation as any).lock) { - await (screen.orientation as any).lock('landscape'); - } - } catch (err) { - console.warn('强制横屏失败:', err); - } - }; - - // 进入/退出全屏时锁定/解锁横屏(保持原有逻辑) - useEffect(() => { - if (typeof document === 'undefined') return; - - const lockLandscape = async () => { - try { - // 某些浏览器需要在用户手势触发后才能调用 - if (screen.orientation && (screen.orientation as any).lock) { - await (screen.orientation as any).lock('landscape'); - } - } catch (err) { - console.warn('横屏锁定失败:', err); - } - }; - - const unlock = () => { - try { - if (screen.orientation && (screen.orientation as any).unlock) { - (screen.orientation as any).unlock(); - } - } catch (_) { - // 忽略解锁屏幕方向失败的错误 - } - }; - - const player = playerRef.current; - if (!player) return; - - return player.subscribe(({ fullscreen }: any) => { - setIsFullscreen(fullscreen); - if (fullscreen) { - lockLandscape(); - } else { - unlock(); - // 强制重绘逻辑,解决退出全屏的黑屏/白边问题 - const playerEl = playerRef.current?.el as HTMLElement | null; - if (playerEl) { - playerEl.style.display = 'none'; - setTimeout(() => { - playerEl.style.display = ''; - setVH(); - }, 0); - } - } - }); - }, [playerRef.current, setVH]); - - useEffect(() => { - // 播放页挂载时,锁定页面滚动并消除 body 100vh 带来的额外空白 - if (typeof document === 'undefined') return; - - const { style: bodyStyle } = document.body; - const { style: htmlStyle } = document.documentElement; - - // 记录原始样式,供卸载时恢复 - const originalBodyMinH = bodyStyle.minHeight; - const originalBodyH = bodyStyle.height; - const originalBodyOverflow = bodyStyle.overflow; - const originalHtmlOverflow = htmlStyle.overflow; - - bodyStyle.minHeight = '0'; - bodyStyle.height = 'auto'; - bodyStyle.overflow = 'hidden'; - htmlStyle.overflow = 'hidden'; - - return () => { - bodyStyle.minHeight = originalBodyMinH; - bodyStyle.height = originalBodyH; - bodyStyle.overflow = originalBodyOverflow; - htmlStyle.overflow = originalHtmlOverflow; - }; - }, []); - - /* -------------------- 设置 meta theme-color 为纯黑 -------------------- */ - useEffect(() => { - const originalThemeColorTags = Array.from( - document.querySelectorAll('meta[name="theme-color"]') - ); - - // 移除已有的 theme-color 标签 - originalThemeColorTags.forEach((tag) => tag.remove()); - - // 添加播放页专用的 theme-color 标签 - const playerThemeColorTag = document.createElement('meta'); - playerThemeColorTag.name = 'theme-color'; - playerThemeColorTag.content = '#000000'; - document.head.appendChild(playerThemeColorTag); - - // 组件卸载时恢复原有的 theme-color 标签 - return () => { - playerThemeColorTag.remove(); - originalThemeColorTags.forEach((tag) => document.head.appendChild(tag)); - }; - }, []); - - // Safari(WebKit) 专用:用于强制重新挂载 ,实现"销毁并重建"效果 - const [playerReloadKey, setPlayerReloadKey] = useState(0); - - // 实时记录音量变化 - const handleVolumeChange = () => { - const v = playerRef.current?.volume; - if (typeof v === 'number' && !Number.isNaN(v)) { - lastVolumeRef.current = v; - } - }; - - // 长按三倍速处理 - const handleLongPressStart = (e: TouchEvent) => { - if (playerRef.current?.paused || playerRef.current?.playbackRate === 3.0) { - return; - } - const target = e.target as HTMLElement; if ( - target.closest('.custom_topbar') || - target.closest('.custom_episodes_panel') || - target.closest('.custom_source_panel') + !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) { + console.log('attachVideoEventListeners'); + attachVideoEventListeners( + artPlayerRef.current.video as HTMLVideoElement + ); + ensureVideoSource( + artPlayerRef.current.video as HTMLVideoElement, + videoUrl + ); + } + return; + } + + // WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的 + if (artPlayerRef.current) { + 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, + poster: videoCover, + volume: 0.7, + isLive: false, + muted: false, + autoplay: true, + pip: true, + 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: '#22c55e', + 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,超出后触发清理 + + /* 自定义loader */ + loader: blockAdEnabled + ? CustomHlsJsLoader + : Hls.DefaultConfig.loader, + }); + + 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: + '', + }, + settings: [ + { + html: blockAdEnabled ? '关闭去广告' : '开启去广告', + icon: 'AD', + tooltip: blockAdEnabled ? '当前开启' : '当前关闭', + onClick() { + const newVal = !blockAdEnabled; + try { + saveCurrentPlayProgress(); + localStorage.setItem('enable_blockad', String(newVal)); + } catch (_) { + // ignore + } + window.location.reload(); + return newVal ? '当前开启' : '当前关闭'; + }, + }, + ], + // 控制栏配置 + controls: [ + { + position: 'left', + index: 13, + html: '', + tooltip: '播放下一集', + click: function () { + handleNextEpisode(); + }, + }, + ], + }); + + // 监听播放器事件 + artPlayerRef.current.on('ready', () => { + setError(null); + }); + + artPlayerRef.current.on('video:volumechange', () => { + lastVolumeRef.current = artPlayerRef.current.volume; + }); + + // 监听视频可播放事件,这时恢复播放进度更可靠 + artPlayerRef.current.on('video:canplay', () => { + // 若存在需要恢复的播放进度,则跳转 + if (resumeTimeRef.current && resumeTimeRef.current > 0) { + try { + const duration = artPlayerRef.current.duration || 0; + let target = resumeTimeRef.current; + if (duration && target >= duration - 2) { + target = Math.max(0, duration - 5); + } + artPlayerRef.current.currentTime = target; + console.log('成功恢复播放进度到:', resumeTimeRef.current); + } catch (err) { + console.warn('恢复播放进度失败:', err); + } + resumeTimeRef.current = null; + } + + setTimeout(() => { + if ( + Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01 + ) { + artPlayerRef.current.volume = lastVolumeRef.current; + } + artPlayerRef.current.notice.show = ''; + }, 0); + }); + + artPlayerRef.current.on('error', (err: any) => { + console.error('播放器错误:', err); + setError('视频播放失败'); + }); + + // 监听视频播放结束事件,自动播放下一集 + 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); + } + }); + + artPlayerRef.current.on('fullscreen', async (state: boolean) => { + if (state) { + if (screen.orientation && (screen.orientation as any).lock) { + await (screen.orientation as any).lock('landscape'); + } + } else { + if (screen.orientation && (screen.orientation as any).unlock) { + (screen.orientation as any).unlock(); + } + } + }); + + artPlayerRef.current.on('video:timeupdate', () => { + const now = Date.now(); + if (now - lastSaveTimeRef.current > 5000) { + saveCurrentPlayProgress(); + lastSaveTimeRef.current = now; + } + }); + + artPlayerRef.current.on('pause', () => { + saveCurrentPlayProgress(); + }); + + if (artPlayerRef.current?.video) { + console.log('attachVideoEventListeners'); + attachVideoEventListeners( + artPlayerRef.current.video as HTMLVideoElement + ); + ensureVideoSource( + artPlayerRef.current.video as HTMLVideoElement, + videoUrl + ); + } + } catch (err) { + console.error('创建播放器失败:', err); + setError('播放器初始化失败'); + } + }, [Artplayer, Hls, videoUrl]); + + // --------------------------------------------------------------------------- + // 视频元素事件监听 + // --------------------------------------------------------------------------- + const attachVideoEventListeners = (video: HTMLVideoElement) => { + if (!video) return; + + // 移除旧监听器(如果存在) + if (videoEventListenersRef.current) { + const { video: oldVideo, listeners } = videoEventListenersRef.current; + listeners.forEach(({ event, handler }) => { + oldVideo.removeEventListener(event, handler); + }); + videoEventListenersRef.current = null; + } + + // 阻止移动端长按弹出系统菜单 + const contextMenuHandler = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }; + + video.addEventListener('contextmenu', contextMenuHandler); + + videoEventListenersRef.current = { + video, + listeners: [{ event: 'contextmenu', handler: contextMenuHandler }], + }; + }; + + // 当组件卸载时清理定时器 + useEffect(() => { + return () => { + if (saveIntervalRef.current) { + clearInterval(saveIntervalRef.current); + } + }; + }, []); + + // --------------------------------------------------------------------------- + // 移动端触摸(长按三倍速) + // --------------------------------------------------------------------------- + // 长按三倍速处理函数 + const handleTouchStart = (e: TouchEvent) => { + // 防止在控制栏区域触发 + const target = e.target as HTMLElement; + if ( + target.closest('.art-controls') || + target.closest('.art-contextmenu') || + target.closest('.art-layer') + ) { + return; + } + + // 仅在播放时触发 + if (!artPlayerRef.current?.playing) { + return; + } + + // 清除之前的定时器 if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); } + + // 设置长按检测定时器(500ms) longPressTimeoutRef.current = setTimeout(() => { - if (playerRef.current) { - normalPlaybackRateRef.current = playerRef.current.playbackRate || 1; - playerRef.current.playbackRate = 3.0; - longPressActiveRef.current = true; // 记录长按已激活 - displayShortcutHint('3倍速', 'play'); + if (artPlayerRef.current) { + // 保存原始播放速度 + originalPlaybackRateRef.current = artPlayerRef.current.playbackRate; + + // 设置三倍速 + artPlayerRef.current.playbackRate = 3; + + // 更新状态 + setIsLongPressing(true); + artPlayerRef.current.notice.show = '3x'; + + // 触发震动反馈(如果支持) + if (navigator.vibrate) { + navigator.vibrate(50); + } } - }, 300); // 按压 300ms 触发 + }, 500); }; - const handleLongPressEnd = () => { + const handleTouchEnd = () => { + // 清除长按检测定时器 if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } - // 只有在长按激活过且当前倍速为 3.0 时才恢复,防止误触 - if (playerRef.current && longPressActiveRef.current) { - playerRef.current.playbackRate = normalPlaybackRateRef.current || 1; - longPressActiveRef.current = false; + + // 如果正在长按,恢复原始播放速度 + if (isLongPressing && artPlayerRef.current) { + artPlayerRef.current.playbackRate = originalPlaybackRateRef.current; + setIsLongPressing(false); + artPlayerRef.current.notice.show = ''; } }; + // 添加触摸事件监听器 + useEffect(() => { + if (!artRef.current) 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); + + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('touchcancel', handleTouchEnd); + element.removeEventListener('contextmenu', disableContextMenu); + }; + }, [artRef.current, isLongPressing]); + if (loading) { return ( -
-
-
-
加载中...
+ +
+
+
+

+ 加载中... +

+
-
+ ); } if (error) { return ( -
-
-
- 播放失败 -
-
{error}
- -
-
- ); - } - - if (!detail) { - return ( -
-
-
未找到视频
- -
-
- ); - } - - 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 && ( -
{ - setShowEpisodePanel(true); - playerContainerRef.current?.focus(); - }} - > - 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集 -
- )} -
- -
- {totalEpisodes > 1 && ( -
{ - setShowEpisodePanel(true); - playerContainerRef.current?.focus(); - }} - > - 选集 -
- )} - {sourceName && ( - - {sourceName} - - )} + 重试 +
-
+ ); - }; - - function filterAdsFromM3U8(m3u8Content: string): string { - if (!m3u8Content) return ''; - - // 按行分割M3U8内容 - const lines = m3u8Content.split('\n'); - const filteredLines = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // 只过滤#EXT-X-DISCONTINUITY标识 - if (!line.includes('#EXT-X-DISCONTINUITY')) { - filteredLines.push(line); - } - } - - return filteredLines.join('\n'); } - class CustomHlsJsLoader extends Hls.DefaultConfig.loader { - constructor(config: any) { - super(config); - const load = this.load.bind(this); - this.load = function (context, config, callbacks) { - // 拦截manifest和level请求 - if ( - (context as any).type === 'manifest' || - (context as any).type === 'level' - ) { - const onSuccess = callbacks.onSuccess; - callbacks.onSuccess = function (response, stats, context) { - // 如果是m3u8文件,处理内容以移除广告分段 - if (response.data && typeof response.data === 'string') { - // 过滤掉广告段 - 实现更精确的广告过滤逻辑 - response.data = filterAdsFromM3U8(response.data); - } - return onSuccess(response, stats, context, null); - }; - } - // 执行原始load方法 - load(context, config, callbacks); - }; - } - } - const onProviderChange = (provider: MediaProviderAdapter | null) => { - class extendedHls extends Hls { - constructor(config: any) { - // 调用父类构造函数 - // @ts-ignore - super(config); - } - - attachMedia(media: HTMLMediaElement): void { - super.attachMedia(media); - - media.disableRemotePlayback = false; - media.autoplay = true; - } - } - if (isHLSProvider(provider)) { - provider.library = extendedHls; - provider.config = { - debug: false, // 关闭日志 - enableWorker: true, // WebWorker 解码,降低主线程压力 - lowLatencyMode: true, // 开启低延迟 LL-HLS - /* 缓冲/内存相关 */ - maxBufferLength: 30, // 前向缓冲最大 30s,过大容易导致高延迟 - backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用 - maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理 - /* 自定义loader */ - loader: blockAdEnabled ? CustomHlsJsLoader : Hls.DefaultConfig.loader, - }; - } - }; - - const onSourceChange = () => { - // 仅在 WebKit(Safari)环境下重建播放器,解决部分资源切换后黑屏或无法播放的问题 - const isWebkit = - typeof window !== 'undefined' && - typeof (window as any).webkitConvertPointFromNodeToPage === 'function'; - - if (ignoreSourceChangeRef.current) { - // 这一次是由我们手动重建引起的,直接忽略 - ignoreSourceChangeRef.current = false; - return; - } - - if (isWebkit) { - // 第一次真实的 sourcechange,仅设置标记,不重建 - if (!hasSourceChangedRef.current) { - hasSourceChangedRef.current = true; - return; - } - - // 第二次(用户真正切换源)开始重建播放器 - // 设置标志,下一次由重建带来的 sourcechange 忽略 - console.log('destory player and rebuild'); - ignoreSourceChangeRef.current = true; - setPlayerReloadKey((k) => k + 1); - } - }; - return ( -
- {/* 竖屏提示蒙层 */} - {showOrientationTip && ( -
- - - - 请横屏观看 -
- )} - - {/* 强制横屏按钮:仅在移动端竖屏时显示 */} - {isPortrait && ( - - )} - - {/* 换源加载遮罩 */} - {sourceChanging && ( -
-
-
-
换源中...
-
-
- )} - - {/* 播放器容器 */} - - - - - {totalEpisodes > 1 && ( - // 下一集按钮放在时间显示前 - - )} - - ), - beforeFullscreenButton: ( - <> - - - {/* 自定义 AirPlay 按钮 */} - - - - - ), - // 快退 10 秒按钮(根据播放器尺寸决定显隐) - beforePlayButton: ( - <> - {showSkipButtons && ( - - - - )} - - ), - afterPlayButton: ( - <> - {showSkipButtons && ( - - - - )} - - ), - }} - /> - - {/* 选集侧拉面板 */} - {totalEpisodes > 1 && ( -
- {/* 遮罩层 */} - {showEpisodePanel && ( -
{ - setShowEpisodePanel(false); - playerContainerRef.current?.focus(); - }} - /> + +
+ {/* 第一行:影片标题 */} +
+

+ {videoTitle || '影片标题'} + {totalEpisodes > 1 && ( + + {` > 第 ${currentEpisodeIndex + 1} 集`} + )} - - {/* 侧拉面板 */} +

+
+ {/* 第二行:播放器和选集 */} +
+ {/* 播放器 */} +
-
-
-
-

- 选集列表 -

- {/* 倒序小字 */} - setReverseEpisodeOrder((prev) => !prev)} - className={`text-sm cursor-pointer select-none transition-colors ${ - reverseEpisodeOrder - ? 'text-green-500' - : 'text-gray-400 hover:text-gray-500' - }`} - > - 倒序 - -
- -
- -
- 当前: 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集 -
- -
-
- {(reverseEpisodeOrder - ? Array.from( - { length: totalEpisodes }, - (_, i) => i - ).reverse() - : Array.from({ length: totalEpisodes }, (_, i) => i) - ).map((idx) => ( - - ))} -
-
-
-
+ ref={artRef} + className='bg-black w-full h-[300px] lg:h-full rounded-xl overflow-hidden border border-white/0 dark:border-white/30' + >
- )} - {/* 换源侧拉面板 */} -
- {/* 遮罩层 */} - {showSourcePanel && ( -
{ - setShowSourcePanel(false); - playerContainerRef.current?.focus(); - }} + {/* 选集和换源 */} +
+ - )} +
+
- {/* 侧拉面板 */} -
-
-
-

播放源

+ {/* 详情展示 */} +
+ {/* 文字区 */} +
+
+ {/* 标题 */} +

+ {videoTitle || '影片标题'} +

+ + {/* 关键信息行 */} +
+ {detail?.class && ( + + {detail.class} + + )} + {(detail?.year || videoYear) && ( + {detail?.year || videoYear} + )} + {detail?.source_name && ( + + {detail.source_name} + + )} + {detail?.type_name && {detail.type_name}}
+ {/* 剧情简介 */} + {detail?.desc && ( +
+ {detail.desc} +
+ )} +
+
- {/* 搜索结果 */} -
- {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) => - !( - 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 - ) - } - > - {/* 视频封面 */} -
- {result.title} - - {/* 集数圆形指示器 */} - {result.episodes && ( -
- - {result.episodes.length} - -
- )} - - {isCurrentSource && ( -
-
- 当前播放 -
-
- )} -
- - {/* 视频信息 */} -
-

- {result.title} -

-
-
- {result.source_name} -
-
-
-
- ); - })} -
+ {/* 封面展示 */} +
+
+
+ {videoCover ? ( + {videoTitle} + ) : ( + + 封面图片 + )}
- - {/* 快捷键提示 */} -
-
- - {shortcutDirection === 'left' && ( - - )} - {shortcutDirection === 'right' && ( - - )} - {shortcutDirection === 'up' && ( - - )} - {shortcutDirection === 'down' && ( - - )} - {shortcutDirection === 'play' && ( - - )} - {shortcutDirection === 'pause' && ( - - )} - {shortcutDirection === 'error' && ( - - )} - - {shortcutText} -
-
- -
+
+ ); } -const PlaybackRateButton = ({ - playerRef, - playerContainerRef, -}: { - playerRef: React.RefObject; - playerContainerRef: 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]); - - return ( - - - 倍速 - - - { - const player = playerRef.current; - if (!player) { - return; - } - player.playbackRate = Number(value); - playerContainerRef.current?.focus(); - }} - > - {[...rates].reverse().map((rate) => ( - - - {rate} - - ))} - - - - ); -}; - +// FavoriteIcon 组件 const FavoriteIcon = ({ filled }: { filled: boolean }) => { if (filled) { return ( @@ -1914,47 +1287,14 @@ const FavoriteIcon = ({ filled }: { filled: boolean }) => { ); } - return ; -}; - -// 新增:去广告图标组件 -const AdBlockIcon = ({ enabled }: { enabled: boolean }) => { return ( - - {/* "AD" 文字,居中显示 */} - - AD - - {enabled && ( - - )} - + ); }; export default function PlayPage() { return ( - + Loading...
}> ); diff --git a/src/components/BackButton.tsx b/src/components/BackButton.tsx new file mode 100644 index 0000000..149ee56 --- /dev/null +++ b/src/components/BackButton.tsx @@ -0,0 +1,13 @@ +import { ArrowLeft } from 'lucide-react'; + +export function BackButton() { + return ( + + ); +} diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 84f01b6..073856a 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -1,5 +1,6 @@ /* eslint-disable @next/next/no-img-element */ +import { useRouter } from 'next/navigation'; import React, { useCallback, useEffect, @@ -48,6 +49,7 @@ const EpisodeSelector: React.FC = ({ sourceSearchLoading = false, sourceSearchError = null, }) => { + const router = useRouter(); const pageCount = Math.ceil(totalEpisodes / episodesPerPage); // 主要的 tab 状态:'episodes' 或 'sources' @@ -123,7 +125,7 @@ const EpisodeSelector: React.FC = ({ ); return ( -
+
{/* 主要的 Tab 切换 - 无缝融入设计 */}
= ({
); })} +
+ +
)}
diff --git a/src/components/MobileHeader.tsx b/src/components/MobileHeader.tsx index 28ac1c5..6f9f877 100644 --- a/src/components/MobileHeader.tsx +++ b/src/components/MobileHeader.tsx @@ -2,14 +2,27 @@ import Link from 'next/link'; +import { BackButton } from './BackButton'; import { LogoutButton } from './LogoutButton'; import { useSite } from './SiteProvider'; import { ThemeToggle } from './ThemeToggle'; -const MobileHeader = () => { +interface MobileHeaderProps { + showBackButton?: boolean; +} + +const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => { const { siteName } = useSite(); return (
+ {/* 返回按钮 */} + {showBackButton && ( +
+ +
+ )} + + {/* 站点名称 */}
{ {siteName}
+ + {/* 右侧按钮 */}
diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 0eddb0f..c9f2d3b 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -1,7 +1,7 @@ +import { BackButton } from './BackButton'; import { LogoutButton } from './LogoutButton'; import MobileBottomNav from './MobileBottomNav'; import MobileHeader from './MobileHeader'; -import { useSidebar } from './Sidebar'; import Sidebar from './Sidebar'; import { ThemeToggle } from './ThemeToggle'; @@ -11,40 +11,48 @@ interface PageLayoutProps { } const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => { - const { isCollapsed } = useSidebar(); - return ( - <> - {/* 桌面端布局 */} -
- -
+
+ {/* 移动端头部 */} + + + {/* 主要布局容器 */} +
+ {/* 侧边栏 - 桌面端显示,移动端隐藏 */} +
+ +
+ + {/* 主内容区域 */} +
+ {/* 桌面端左上角返回按钮 */} +
+ +
+ + {/* 桌面端顶部按钮 */}
- {children} + + {/* 主内容 */} +
+ {children} +
- {/* 移动端布局 */} -
- -
- {children} -
+ {/* 移动端底部导航 */} +
- +
); };