diff --git a/package.json b/package.json index 8d8a0a3..89402c2 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,10 @@ "@heroicons/react": "^2.2.0", "@upstash/redis": "^1.25.0", "@vidstack/react": "^1.12.13", + "artplayer": "^5.2.3", "clsx": "^2.0.0", "framer-motion": "^12.18.1", - "hls.js": "^1.6.5", + "hls.js": "^1.6.6", "lucide-react": "^0.438.0", "media-icons": "^1.1.5", "next": "^14.2.23", @@ -42,11 +43,11 @@ "react-dom": "^18.2.0", "react-icons": "^5.4.0", "redis": "^4.6.7", + "sweetalert2": "^11.11.0", "swiper": "^11.2.8", "tailwind-merge": "^2.6.0", "vidstack": "^0.6.15", - "zod": "^3.24.1", - "sweetalert2": "^11.11.0" + "zod": "^3.24.1" }, "devDependencies": { "@commitlint/cli": "^16.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d405333..77353cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@vidstack/react': specifier: ^1.12.13 version: 1.12.13(@types/react@18.3.23)(react@18.3.1) + artplayer: + specifier: ^5.2.3 + version: 5.2.3 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -42,8 +45,8 @@ importers: specifier: ^12.18.1 version: 12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) hls.js: - specifier: ^1.6.5 - version: 1.6.5 + specifier: ^1.6.6 + version: 1.6.6 lucide-react: specifier: ^0.438.0 version: 0.438.0(react@18.3.1) @@ -2412,6 +2415,9 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + artplayer@5.2.3: + resolution: {integrity: sha512-WaOZQrpZn/L+GgI2f0TEsoAL3Wb+v16Mu0JmWh7qKFYuvr11WNt3dWhWeIaCfoHy3NtkCWM9jTP+xwwsxdElZQ==} + as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} @@ -3857,8 +3863,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hls.js@1.6.5: - resolution: {integrity: sha512-KMn5n7JBK+olC342740hDPHnGWfE8FiHtGMOdJPfUjRdARTWj9OB+8c13fnsf9sk1VtpuU2fKSgUjHvg4rNbzQ==} + hls.js@1.6.6: + resolution: {integrity: sha512-S4uTCwTHOtImW+/jxMjzG7udbHy5z682YQRbm/4f7VXuVNEoGBRjPJnD3Fxrufomdhzdtv24KnxRhPMXSvL6Fw==} hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -4888,6 +4894,9 @@ 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'} @@ -8961,6 +8970,10 @@ snapshots: arrify@1.0.1: {} + artplayer@5.2.3: + dependencies: + option-validator: 2.0.6 + as-table@1.0.55: dependencies: printable-characters: 1.0.42 @@ -10497,7 +10510,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hls.js@1.6.5: {} + hls.js@1.6.6: {} hosted-git-info@2.8.9: {} @@ -11786,6 +11799,10 @@ 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 diff --git a/src/app/aggregate/page.tsx b/src/app/aggregate/page.tsx deleted file mode 100644 index 7a8cf1f..0000000 --- a/src/app/aggregate/page.tsx +++ /dev/null @@ -1,354 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps, no-console */ - -'use client'; - -import { Heart, LinkIcon } from 'lucide-react'; -import Image from 'next/image'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useState } from 'react'; - -import { isFavorited, toggleFavorite } from '@/lib/db.client'; -import { SearchResult } from '@/lib/types'; - -import PageLayout from '@/components/PageLayout'; - -function AggregatePageClient() { - const searchParams = useSearchParams(); - const query = searchParams.get('q') || ''; - const title = searchParams.get('title') || ''; - const year = searchParams.get('year') || ''; - const type = searchParams.get('type') || ''; - - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const router = useRouter(); - - useEffect(() => { - if (!query) { - setError('缺少搜索关键词'); - setLoading(false); - return; - } - - const fetchData = async () => { - try { - const res = await fetch( - `/api/search?q=${encodeURIComponent(query.trim())}` - ); - if (!res.ok) { - throw new Error('搜索失败'); - } - const data = await res.json(); - const all: SearchResult[] = data.results || []; - const map = new Map(); - all.forEach((r) => { - // 根据传入参数进行精确匹配: - // 1. 如果提供了 title,则按 title 精确匹配,否则按 query 精确匹配; - // 2. 如果还提供了 year,则额外按 year 精确匹配。 - const titleMatch = title ? r.title === title : r.title === query; - const yearMatch = year ? r.year === year : true; - if (!titleMatch || !yearMatch) { - return; - } - // 如果还传入了 type,则按 type 精确匹配 - if (type === 'tv' && r.episodes.length === 1) { - return; - } - if (type === 'movie' && r.episodes.length !== 1) { - return; - } - const key = `${r.title}-${r.year}`; - const arr = map.get(key) || []; - arr.push(r); - map.set(key, arr); - }); - if (map.size === 0 && type) { - // 无匹配,忽略 type 做重新匹配 - all.forEach((r) => { - const titleMatch = title ? r.title === title : r.title === query; - const yearMatch = year ? r.year === year : true; - if (!titleMatch || !yearMatch) { - return; - } - const key = `${r.title}-${r.year}`; - const arr = map.get(key) || []; - arr.push(r); - map.set(key, arr); - }); - } - if (map.size == 1) { - setResults(Array.from(map.values()).flat()); - } else if (map.size > 1) { - // 存在多个匹配,跳转到搜索页 - router.push(`/search?q=${encodeURIComponent(query.trim())}`); - } - } catch (e) { - setError(e instanceof Error ? e.message : '搜索失败'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [query, router]); - - // 选出信息最完整的字段 - const chooseString = (vals: (string | undefined)[]): string | undefined => { - return vals.reduce((best, v) => { - if (!v) return best; - if (!best) return v; - return v.length > best.length ? v : best; - }, undefined); - }; - // 出现次数最多的非 0 数字 - const chooseNumber = (vals: (number | undefined)[]): number | undefined => { - const countMap = new Map(); - vals.forEach((v) => { - if (v !== undefined && v !== 0) { - countMap.set(v, (countMap.get(v) || 0) + 1); - } - }); - let selected: number | undefined = undefined; - let maxCount = 0; - countMap.forEach((cnt, num) => { - if (cnt > maxCount) { - maxCount = cnt; - selected = num; - } - }); - return selected; - }; - - const aggregatedInfo = { - title: title || query, - cover: chooseString(results.map((d) => d.poster)), - desc: chooseString(results.map((d) => d.desc)), - type: chooseString(results.map((d) => d.type_name)), - year: chooseString(results.map((d) => d.year)), - remarks: chooseString(results.map((d) => d.class)), - douban_id: chooseNumber(results.map((d) => d.douban_id)), - }; - - const infoReady = Boolean( - aggregatedInfo.cover || - aggregatedInfo.desc || - aggregatedInfo.type || - aggregatedInfo.year || - aggregatedInfo.remarks - ); - - const uniqueSources = Array.from( - new Map(results.map((r) => [r.source, r])).values() - ); - - // 详情映射,便于快速获取每个源的集数 - const sourceDetailMap = new Map(results.map((d) => [d.source, d])); - - // 新增:播放源卡片组件,包含收藏逻辑 - const SourceCard = ({ src }: { src: SearchResult }) => { - const d = sourceDetailMap.get(src.source); - const epCount = d ? d.episodes.length : src.episodes.length; - - const [favorited, setFavorited] = useState(false); - - // 初次加载检查收藏状态 - useEffect(() => { - (async () => { - try { - const fav = await isFavorited(src.source, src.id); - setFavorited(fav); - } catch { - /* 忽略错误 */ - } - })(); - }, [src.source, src.id]); - - // 切换收藏状态 - const handleToggleFavorite = async ( - e: React.MouseEvent - ) => { - e.preventDefault(); - e.stopPropagation(); - - try { - const newState = await toggleFavorite(src.source, src.id, { - title: src.title, - source_name: src.source_name, - year: src.year, - cover: src.poster, - total_episodes: src.episodes.length, - save_time: Date.now(), - }); - setFavorited(newState); - } catch { - /* 忽略错误 */ - } - }; - - return ( - - {/* 收藏爱心 */} - - - - - {/* 名称 */} - - {src.source_name} - - {/* 集数徽标 */} - {epCount && epCount > 1 ? ( - - {epCount}集 - - ) : null} - - ); - }; - - return ( - -
- {loading ? ( -
-
-
- ) : error ? ( -
-
-
加载失败
-
{error}
-
-
- ) : !infoReady ? ( -
-
-
未找到匹配结果
-
-
- ) : ( -
- {/* 主信息区:左图右文 */} -
- {/* 返回按钮 */} - - {/* 封面 */} -
- {aggregatedInfo.title} -
- {/* 右侧信息 */} -
-

- {aggregatedInfo.title} - {aggregatedInfo.douban_id && ( - e.stopPropagation()} - className='ml-2' - > - - - )} -

-
- {aggregatedInfo.remarks && ( - - {aggregatedInfo.remarks} - - )} - {aggregatedInfo.year && {aggregatedInfo.year}} - {aggregatedInfo.type && {aggregatedInfo.type}} -
-
- {aggregatedInfo.desc} -
-
-
- {/* 选播放源 */} - {uniqueSources.length > 0 && ( -
-
-
选择播放源
-
- 共 {uniqueSources.length} 个 -
-
-
- {uniqueSources.map((src) => ( - - ))} -
-
- )} -
- )} -
-
- ); -} - -export default function AggregatePage() { - return ( - - - - ); -} diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx deleted file mode 100644 index 4b856ee..0000000 --- a/src/app/detail/page.tsx +++ /dev/null @@ -1,383 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps, no-console */ - -'use client'; - -import { Heart, LinkIcon } from 'lucide-react'; -import Image from 'next/image'; -import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useState } from 'react'; - -import type { PlayRecord } from '@/lib/db.client'; -import { - generateStorageKey, - getAllPlayRecords, - isFavorited, - toggleFavorite, -} from '@/lib/db.client'; -import { - type VideoDetail, - fetchVideoDetail, -} from '@/lib/fetchVideoDetail.client'; - -import PageLayout from '@/components/PageLayout'; - -function DetailPageClient() { - const searchParams = useSearchParams(); - const [detail, setDetail] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [playRecord, setPlayRecord] = useState(null); - const [favorited, setFavorited] = useState(false); - // 是否倒序显示选集 - const [reverseEpisodeOrder, setReverseEpisodeOrder] = useState(false); - - const fallbackTitle = searchParams.get('title') || ''; - const fallbackYear = searchParams.get('year') || ''; - - // 格式化剩余时间(如 1h 50m) - const formatDuration = (seconds: number) => { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const parts: string[] = []; - if (h) parts.push(`${h}h`); - if (m) parts.push(`${m}m`); - if (parts.length === 0) parts.push('0m'); - return parts.join(' '); - }; - - useEffect(() => { - const source = searchParams.get('source'); - const id = searchParams.get('id'); - - if (!source || !id) { - setError('缺少必要参数'); - setLoading(false); - return; - } - - const fetchData = async () => { - try { - // 获取视频详情 - const detailData = await fetchVideoDetail({ - source, - id, - fallbackTitle: fallbackTitle.trim(), - fallbackYear, - }); - setDetail(detailData); - - // 获取播放记录 - const allRecords = await getAllPlayRecords(); - const key = generateStorageKey(source, id); - setPlayRecord(allRecords[key] || null); - - // 检查收藏状态 - try { - const fav = await isFavorited(source, id); - setFavorited(fav); - } catch (checkErr) { - console.error('检查收藏状态失败:', checkErr); - } - } catch (err) { - setError(err instanceof Error ? err.message : '获取详情失败'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [searchParams]); - - // 切换收藏状态 - const handleToggleFavorite = async () => { - const source = searchParams.get('source'); - const id = searchParams.get('id'); - if (!source || !id || !detail) return; - - try { - const newState = await toggleFavorite(source, id, { - title: detail.title, - source_name: detail.source_name, - year: detail.year || fallbackYear || '', - cover: detail.poster || '', - total_episodes: detail.episodes.length || 1, - save_time: Date.now(), - }); - setFavorited(newState); - } catch (err) { - console.error('切换收藏失败:', err); - } - }; - - return ( - -
- {/* 顶部返回按钮已移入右侧信息容器 */} - {loading ? ( -
-
-
- ) : error ? ( -
-
-
加载失败
-
{error}
-
-
- ) : !detail ? ( -
-
-
未找到视频详情
-
-
- ) : ( -
- {/* 主信息区:左图右文 */} -
- {/* 返回按钮放置在主信息区左上角 */} - - {/* 封面 */} -
- {detail.title -
- {/* 右侧信息 */} -
-

- {detail.title || fallbackTitle} - {detail.douban_id && ( - e.stopPropagation()} - className='ml-2' - > - - - )} -

-
- {detail.class && ( - - {detail.class} - - )} - {(detail.year || fallbackYear) && ( - {detail.year || fallbackYear} - )} - {detail.source_name && ( - - {detail.source_name} - - )} - {detail.type_name && {detail.type_name}} -
- {/* 按钮区域 */} -
- {playRecord ? ( - <> - {/* 恢复播放 */} - -
- 恢复播放 -
- {/* 从头开始 */} - -
- 从头开始 -
- - ) : ( - <> - {/* 播放 */} - -
- 播放 -
- - )} - {/* 爱心按钮 */} - -
- {/* 播放记录进度条 */} - {playRecord && ( -
- {/* 进度条 */} -
-
-
- {/* 剩余时间 */} - - {playRecord.total_episodes > 1 - ? `第${playRecord.index}集 剩余 ` - : '剩余 '} - {formatDuration( - playRecord.total_time - playRecord.play_time - )} - -
- )} - {detail.desc && ( -
- {detail.desc} -
- )} -
-
- {/* 选集按钮区 */} - {detail.episodes && detail.episodes.length > 0 && ( -
-
-
选集
-
- 共 {detail.episodes.length} 集 -
- {/* 倒序切换 */} - setReverseEpisodeOrder((prev) => !prev)} - className={`ml-4 text-sm cursor-pointer select-none transition-colors ${ - reverseEpisodeOrder - ? 'text-green-500' - : 'text-gray-400 hover:text-gray-500' - }`} - > - 倒序 - -
-
- {(reverseEpisodeOrder - ? Array.from( - { length: detail.episodes.length }, - (_, i) => i - ).reverse() - : Array.from( - { length: detail.episodes.length }, - (_, i) => i - ) - ).map((idx) => ( - - {idx + 1} - - ))} -
-
- )} -
- )} -
-
- ); -} - -export default function DetailPage() { - return ( - - - - ); -} diff --git a/src/app/globals.css b/src/app/globals.css index 12a7561..5ea5c46 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -153,20 +153,9 @@ div[data-media-provider] video { object-fit: contain; } -/* Vidstack Menu 自定义样式:背景黑色,文字白色 */ -.vds-menu-items { - background-color: #000; - color: #fff; -} - -.vds-radio { - color: #fff !important; -} - -.vds-radio:hover { - background-color: rgba(245, 245, 245, 0.1) !important; -} - -.vds-radio .vds-icon { - color: #fff !important; +.art-poster { + background-size: contain !important; /* 使图片完整展示 */ + background-position: center center !important; /* 居中显示 */ + background-repeat: no-repeat !important; /* 防止重复 */ + background-color: #000 !important; /* 其余区域填充为黑色 */ } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 91c0f39..e7b2aee 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,28 @@ function PlayPageClient() { return true; }); - // 长按三倍速相关 - const longPressTimeoutRef = useRef(null); - const normalPlaybackRateRef = useRef(1); - // 标记长按是否已生效 - const longPressActiveRef = useRef(false); + // 视频基本信息 + const [videoType, setVideoType] = useState(searchParams.get('type') || ''); + const [videoDoubanId, setVideoDoubanId] = useState( + searchParams.get('douban_id') || '' + ); + const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); + const [videoYear, setVideoYear] = useState(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 +86,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 +139,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,51 +230,81 @@ function PlayPageClient() { } }, [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 { + const fetchDetailAsync = async () => { + if (!currentSource && !currentId && !videoTitle) { + setError('缺少必要参数'); setLoading(false); + return; } + + if (!currentSource && !currentId) { + // 只包含视频标题,搜索视频 + setLoading(true); + const searchResults = await handleSearchSources(videoTitle); + console.log('searchResults', searchResults); + if (searchResults.length == 0) { + setError('未找到匹配结果'); + setLoading(false); + return; + } + setCurrentSource(searchResults[0].source); + setCurrentId(searchResults[0].id); + setVideoYear(searchResults[0].year); + setVideoType(''); + setVideoDoubanId(''); // 清空豆瓣ID + // 替换URL参数 + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('source', searchResults[0].source); + newUrl.searchParams.set('id', searchResults[0].id); + newUrl.searchParams.set('year', searchResults[0].year); + newUrl.searchParams.delete('douban_id'); + window.history.replaceState({}, '', newUrl.toString()); + 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) { + console.error('获取视频详情失败:', err); + } finally { + setLoading(false); + } + }; + + fetchDetail(); }; - fetchDetail(); - }, [currentSource]); + fetchDetailAsync(); + }, [currentSource, currentId]); - /* -------------------- 播放记录处理 -------------------- */ + // 播放记录处理 useEffect(() => { // 仅在初次挂载时检查播放记录 const initFromHistory = async () => { @@ -368,171 +361,20 @@ 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 + ): Promise => { if (!query.trim()) { - setSearchResults([]); - return; + setAvailableSources([]); + return []; } - setSearchLoading(true); - setSearchError(null); + setSourceSearchLoading(true); + setSourceSearchError(null); try { const response = await fetch( @@ -563,31 +405,39 @@ function PlayPageClient() { if (results.length === 0) return; // 只选择和当前视频标题完全匹配的结果,如果有年份,还需要年份完全匹配 - const exactMatch = results.find( + const exactMatchs = results.filter( (result) => result.title.toLowerCase() === videoTitle.toLowerCase() && (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 + ? (detail.episodes.length === 1 && + result.episodes.length === 1) || + (detail.episodes.length > 1 && result.episodes.length > 1) + : true) && + (videoDoubanId && result.douban_id + ? result.douban_id.toString() === videoDoubanId + : true) && + (videoType + ? (videoType === 'movie' && result.episodes.length === 1) || + (videoType === 'tv' && result.episodes.length > 1) + : true) ); - - if (exactMatch) { - processedResults.push(exactMatch); - return; + if (exactMatchs.length > 0) { + processedResults.push(...exactMatchs); } }); + console.log('processedResults', processedResults); - setSearchResults(processedResults); + setAvailableSources(processedResults); + return processedResults; } catch (err) { - setSearchError(err instanceof Error ? err.message : '搜索失败'); - setSearchResults([]); + setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); + setAvailableSources([]); + return []; } finally { - setSearchLoading(false); + setSourceSearchLoading(false); } }; @@ -599,10 +449,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 +495,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 +503,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 +565,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 +575,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 +654,7 @@ function PlayPageClient() { return; } - const player = playerRef.current; + const player = artPlayerRef.current; const currentTime = player.currentTime || 0; const duration = player.duration || 0; @@ -818,12 +664,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 +687,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 +758,572 @@ 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} - - )} + {videoTitle ? '返回搜索' : '返回'} +
-
+ ); - }; - - 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 +1338,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/app/search/page.tsx b/src/app/search/page.tsx index 80325af..958f3c8 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -186,11 +186,7 @@ function SearchPageClient() { ? aggregatedResults.map(([mapKey, group]) => { return (
- +
); }) diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx index e48249a..fa79569 100644 --- a/src/components/AggregateCard.tsx +++ b/src/components/AggregateCard.tsx @@ -19,7 +19,6 @@ interface SearchResult { interface AggregateCardProps { /** 同一标题下的多个搜索结果 */ - query?: string; year?: string; items: SearchResult[]; } @@ -58,11 +57,7 @@ function PlayCircleSolid({ * 点击播放按钮 -> 跳到第一个源播放 * 点击卡片其他区域 -> 跳到聚合详情页 (/aggregate) */ -const AggregateCard: React.FC = ({ - query = '', - year = 0, - items, -}) => { +const AggregateCard: React.FC = ({ year = 0, items }) => { // 使用列表中的第一个结果做展示 & 播放 const first = items[0]; const [playHover, setPlayHover] = useState(false); @@ -118,11 +113,9 @@ const AggregateCard: React.FC = ({ return ( 1 ? 'tv' : 'movie'}`} + href={`/play?source=${first.source}&id=${ + first.id + }&title=${encodeURIComponent(first.title)}${year ? `&year=${year}` : ''}`} >
{/* 封面图片 2:3 */} @@ -162,7 +155,7 @@ const AggregateCard: React.FC = ({ first.id }&title=${encodeURIComponent(first.title)}${ year ? `&year=${year}` : '' - }&from=aggregate` + }` ); }} onMouseEnter={() => setPlayHover(true)} 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/DemoCard.tsx b/src/components/DemoCard.tsx index f5a19ee..e062afc 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -1,4 +1,4 @@ -import { Link as LinkIcon, Search } from 'lucide-react'; +import { Link as LinkIcon } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { useRef, useState } from 'react'; @@ -13,7 +13,7 @@ interface DemoCardProps { type?: string; } -function SearchCircle({ +function PlayCircleSolid({ className = '', fillColor = 'none', }: { @@ -37,11 +37,7 @@ function SearchCircle({ strokeWidth='1.5' fill={fillColor} /> - -
- -
-
+ ); } @@ -54,7 +50,9 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { const handleClick = () => { router.push( - `/aggregate?q=${encodeURIComponent(title.trim())}&type=${type}` + `/play?title=${encodeURIComponent( + title.trim() + )}&douban_id=${id}&type=${type}` ); }; @@ -103,7 +101,7 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { hover ? 'scale-110 rotate-12' : 'scale-90' }`} > - +
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx new file mode 100644 index 0000000..9e4bb44 --- /dev/null +++ b/src/components/EpisodeSelector.tsx @@ -0,0 +1,389 @@ +/* eslint-disable @next/next/no-img-element */ + +import { useRouter } from 'next/navigation'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { SearchResult } from '@/lib/types'; + +interface EpisodeSelectorProps { + /** 总集数 */ + totalEpisodes: number; + /** 每页显示多少集,默认 50 */ + episodesPerPage?: number; + /** 当前选中的集数(1 开始) */ + value?: number; + /** 用户点击选集后的回调 */ + onChange?: (episodeNumber: number) => void; + /** 换源相关 */ + onSourceChange?: (source: string, id: string, title: string) => void; + currentSource?: string; + currentId?: string; + videoTitle?: string; + videoYear?: string; + availableSources?: SearchResult[]; + onSearchSources?: (query: string) => void; + sourceSearchLoading?: boolean; + sourceSearchError?: string | null; +} + +/** + * 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。 + */ +const EpisodeSelector: React.FC = ({ + totalEpisodes, + episodesPerPage = 50, + value = 1, + onChange, + onSourceChange, + currentSource, + currentId, + videoTitle, + availableSources = [], + onSearchSources, + sourceSearchLoading = false, + sourceSearchError = null, +}) => { + const router = useRouter(); + const pageCount = Math.ceil(totalEpisodes / episodesPerPage); + + // 主要的 tab 状态:'episodes' 或 'sources' + // 当只有一集时默认展示 "换源",并隐藏 "选集" 标签 + const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>( + totalEpisodes > 1 ? 'episodes' : 'sources' + ); + + // 当前分页索引(0 开始) + const initialPage = Math.floor((value - 1) / episodesPerPage); + const [currentPage, setCurrentPage] = useState(initialPage); + + // 是否倒序显示 + const [descending, setDescending] = useState(false); + + // 升序分页标签 + const categoriesAsc = useMemo(() => { + return Array.from({ length: pageCount }, (_, i) => { + const start = i * episodesPerPage + 1; + const end = Math.min(start + episodesPerPage - 1, totalEpisodes); + return `${start}-${end}`; + }); + }, [pageCount, episodesPerPage, totalEpisodes]); + + // 分页标签始终保持升序 + const categories = categoriesAsc; + + const categoryContainerRef = useRef(null); + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + + // 当分页切换时,将激活的分页标签滚动到视口中间 + useEffect(() => { + const btn = buttonRefs.current[currentPage]; + if (btn) { + btn.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }); + } + }, [currentPage, pageCount]); + + // 处理换源tab点击,只在点击时才搜索 + const handleSourceTabClick = () => { + setActiveTab('sources'); + // 只在点击时搜索,且只搜索一次 + if (availableSources.length === 0 && videoTitle && onSearchSources) { + onSearchSources(videoTitle); + } + }; + + const handleCategoryClick = useCallback((index: number) => { + setCurrentPage(index); + }, []); + + const handleEpisodeClick = useCallback( + (episodeNumber: number) => { + onChange?.(episodeNumber); + }, + [onChange] + ); + + const handleSourceClick = useCallback( + (source: SearchResult) => { + onSourceChange?.(source.source, source.id, source.title); + }, + [onSourceChange] + ); + + // 如果组件初始即显示 "换源",自动触发搜索一次 + useEffect(() => { + if ( + activeTab === 'sources' && + availableSources.length === 0 && + videoTitle && + onSearchSources + ) { + onSearchSources(videoTitle); + } + // 只在依赖变化时尝试,availableSources 长度变化可阻止重复搜索 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, availableSources.length, videoTitle]); + + const currentStart = currentPage * episodesPerPage + 1; + const currentEnd = Math.min( + currentStart + episodesPerPage - 1, + totalEpisodes + ); + + return ( +
+ {/* 主要的 Tab 切换 - 无缝融入设计 */} +
+ {totalEpisodes > 1 && ( +
setActiveTab('episodes')} + className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium + ${ + activeTab === 'episodes' + ? 'text-green-500 dark:text-green-400' + : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' + } + `.trim()} + > + 选集 +
+ )} +
+ 换源 +
+
+ + {/* 选集 Tab 内容 */} + {activeTab === 'episodes' && ( + <> + {/* 分类标签 */} +
+
+
+ {categories.map((label, idx) => { + const isActive = idx === currentPage; + return ( + + ); + })} +
+
+ {/* 向上/向下按钮 */} + +
+ + {/* 集数网格 */} +
+ {(() => { + const len = currentEnd - currentStart + 1; + const episodes = Array.from({ length: len }, (_, i) => + descending ? currentEnd - i : currentStart + i + ); + return episodes; + })().map((episodeNumber) => { + const isActive = episodeNumber === value; + return ( + + ); + })} +
+ + )} + + {/* 换源 Tab 内容 */} + {activeTab === 'sources' && ( +
+ {sourceSearchLoading && ( +
+
+ + 搜索中... + +
+ )} + + {sourceSearchError && ( +
+
+
⚠️
+

+ {sourceSearchError} +

+
+
+ )} + + {!sourceSearchLoading && + !sourceSearchError && + availableSources.length === 0 && ( +
+
+
📺
+

+ 暂无可用的换源 +

+
+
+ )} + + {!sourceSearchLoading && + !sourceSearchError && + availableSources.length > 0 && ( +
+ {availableSources + .sort((a, b) => { + const aIsCurrent = + a.source?.toString() === currentSource?.toString() && + a.id?.toString() === currentId?.toString(); + const bIsCurrent = + b.source?.toString() === currentSource?.toString() && + b.id?.toString() === currentId?.toString(); + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + return 0; + }) + .map((source) => { + const isCurrentSource = + source.source?.toString() === currentSource?.toString() && + source.id?.toString() === currentId?.toString(); + return ( +
+ !isCurrentSource && handleSourceClick(source) + } + className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 + ${ + isCurrentSource + ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border' + : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02]' + }`.trim()} + > + {/* 封面 */} +
+ {source.episodes && source.episodes.length > 0 && ( + {source.title} { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> + )} +
+ + {/* 信息区域 */} +
+
+
+

+ {source.title} +

+
+ + {source.source_name} + +
+ {source.episodes.length > 1 && ( + + 共 {source.episodes.length} 集 + + )} +
+
+
+
+ ); + })} +
+ +
+
+ )} +
+ )} +
+ ); +}; + +export default EpisodeSelector; 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..993ceb5 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,50 @@ interface PageLayoutProps { } const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => { - const { isCollapsed } = useSidebar(); - return ( - <> - {/* 桌面端布局 */} -
- -
+
+ {/* 移动端头部 */} + + + {/* 主要布局容器 */} +
+ {/* 侧边栏 - 桌面端显示,移动端隐藏 */} +
+ +
+ + {/* 主内容区域 */} +
+ {/* 桌面端左上角返回按钮 */} + {['/play'].includes(activePath) && ( +
+ +
+ )} + + {/* 桌面端顶部按钮 */}
- {children} + + {/* 主内容 */} +
+ {children} +
- {/* 移动端布局 */} -
- -
- {children} -
+ {/* 移动端底部导航 */} +
- +
); }; diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 4c92e56..3b114f0 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -153,7 +153,7 @@ export default function VideoCard({ return (