diff --git a/package.json b/package.json index a3a7a71..e25f0eb 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,10 @@ "dependencies": { "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", + "artplayer": "^5.2.3", "clsx": "^2.0.0", "framer-motion": "^12.18.1", + "hls.js": "^1.6.5", "lucide-react": "^0.438.0", "next": "^14.2.23", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f3dee8..509f6b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,18 @@ importers: '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) + artplayer: + specifier: ^5.2.3 + version: 5.2.3 clsx: specifier: ^2.0.0 version: 2.1.1 framer-motion: 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 lucide-react: specifier: ^0.438.0 version: 0.438.0(react@18.3.1) @@ -1670,6 +1676,9 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + artplayer@5.2.3: + resolution: {integrity: sha512-WaOZQrpZn/L+GgI2f0TEsoAL3Wb+v16Mu0JmWh7qKFYuvr11WNt3dWhWeIaCfoHy3NtkCWM9jTP+xwwsxdElZQ==} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -2594,6 +2603,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hls.js@1.6.5: + resolution: {integrity: sha512-KMn5n7JBK+olC342740hDPHnGWfE8FiHtGMOdJPfUjRdARTWj9OB+8c13fnsf9sk1VtpuU2fKSgUjHvg4rNbzQ==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -3392,6 +3404,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'} @@ -6288,6 +6303,10 @@ snapshots: arrify@1.0.1: {} + artplayer@5.2.3: + dependencies: + option-validator: 2.0.6 + ast-types-flow@0.0.8: {} astral-regex@2.0.0: {} @@ -7405,6 +7424,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hls.js@1.6.5: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: @@ -8470,6 +8491,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/api/detail/route.ts b/src/app/api/detail/route.ts index e4229fe..1b892a0 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -4,6 +4,17 @@ import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config'; const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g; +// 清理 HTML 标签的工具函数 +function cleanHtmlTags(text: string): string { + if (!text) return ''; + return text + .replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行 + .replace(/\n+/g, '\n') // 将多个连续换行合并为一个 + .replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符 + .replace(/^\n+|\n+$/g, '') // 去掉首尾换行 + .trim(); // 去掉首尾空格 +} + export interface VideoDetail { code: number; episodes: string[]; @@ -71,9 +82,7 @@ async function handleSpecialSourceDetail( const descMatch = html.match( /]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/ ); - const descText = descMatch - ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() - : ''; + const descText = descMatch ? cleanHtmlTags(descMatch[1]) : ''; const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g); const coverUrl = coverMatch ? coverMatch[0].trim() : ''; @@ -158,7 +167,7 @@ async function getDetailFromApi( videoInfo: { title: videoDetail.vod_name, cover: videoDetail.vod_pic, - desc: videoDetail.vod_content, + desc: cleanHtmlTags(videoDetail.vod_content), type: videoDetail.type_name, year: videoDetail.vod_year, area: videoDetail.vod_area, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index f08f9cd..7b8fbd2 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -4,7 +4,7 @@ import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config'; import { getVideoDetail } from '../detail/route'; -interface SearchResult { +export interface SearchResult { id: string; title: string; poster: string; diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 3e3da08..17458de 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -45,6 +45,7 @@ export default function DetailPage() { return (
+ {/* 顶部返回按钮已移入右侧信息容器 */} {loading ? (
@@ -65,7 +66,34 @@ export default function DetailPage() { ) : (
{/* 主信息区:左图右文 */} -
+
+ {/* 返回按钮放置在主信息区左上角 */} + {/* 封面 */}
{/* 右侧信息 */} -
-
-

- {detail.videoInfo.title} -

-
- {detail.videoInfo.remarks && ( - - {detail.videoInfo.remarks} - - )} - {detail.videoInfo.year && ( - {detail.videoInfo.year} - )} - {detail.videoInfo.source_name && ( - {detail.videoInfo.source_name} - )} - {detail.videoInfo.type && ( - {detail.videoInfo.type} - )} -
-
- - -
- {detail.videoInfo.desc && ( -
- {detail.videoInfo.desc} -
+
+

+ {detail.videoInfo.title} +

+
+ {detail.videoInfo.remarks && ( + + {detail.videoInfo.remarks} + + )} + {detail.videoInfo.year && ( + {detail.videoInfo.year} + )} + {detail.videoInfo.source_name && ( + {detail.videoInfo.source_name} + )} + {detail.videoInfo.type && ( + {detail.videoInfo.type} )}
+
+ +
+ 播放 +
+ +
+ {detail.videoInfo.desc && ( +
+ {detail.videoInfo.desc} +
+ )}
{/* 选集按钮区 */} @@ -139,14 +176,14 @@ export default function DetailPage() { 共 {detail.episodes.length} 集
-
+
{detail.episodes.map((episode, idx) => ( 第{idx + 1}集 diff --git a/src/app/page.tsx b/src/app/page.tsx index 173d54f..9957f32 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,8 +1,18 @@ 'use client'; +import { + Film, + MessageCircleHeart, + MountainSnow, + Star, + Swords, + Tv, + VenetianMask, +} from 'lucide-react'; import { useEffect, useState } from 'react'; import CapsuleSwitch from '@/components/CapsuleSwitch'; +import CollectionCard from '@/components/CollectionCard'; import DemoCard from '@/components/DemoCard'; import PageLayout from '@/components/layout/PageLayout'; import ScrollableRow from '@/components/ScrollableRow'; @@ -92,6 +102,29 @@ const mockData = { ], }; +// 合集数据 +const collections = [ + { + icon: Film, + title: '热门电影', + href: '/douban?type=movie&tag=热门&title=热门电影', + }, + { + icon: Tv, + title: '热门剧集', + href: '/douban?type=tv&tag=热门&title=热门剧集', + }, + { + icon: Star, + title: '豆瓣 Top250', + href: '/douban?type=movie&tag=top250&title=豆瓣 Top250', + }, + { icon: Swords, title: '美剧', href: '/douban?type=tv&tag=美剧' }, + { icon: MessageCircleHeart, title: '韩剧', href: '/douban?type=tv&tag=韩剧' }, + { icon: MountainSnow, title: '日剧', href: '/douban?type=tv&tag=日剧' }, + { icon: VenetianMask, title: '日漫', href: '/douban?type=tv&tag=日本动画' }, +]; + export default function Home() { const [activeTab, setActiveTab] = useState('home'); const [hotMovies, setHotMovies] = useState([]); @@ -142,6 +175,24 @@ export default function Home() {
+ {/* 推荐 */} +
+

+ 推荐 +

+ + {collections.map((collection) => ( +
+ +
+ ))} +
+
+ {/* 继续观看 */}

diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx new file mode 100644 index 0000000..745a910 --- /dev/null +++ b/src/app/play/page.tsx @@ -0,0 +1,1094 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */ + +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import React from 'react'; + +import { VideoDetail } from '../api/detail/route'; + +// 动态导入 Artplayer 和 Hls 以避免 SSR 问题 +let Artplayer: any = null; +let Hls: any = null; + +// 扩展 HTMLVideoElement 类型以支持 hls 属性 +declare global { + interface HTMLVideoElement { + hls?: any; + } +} + +// 搜索结果类型 +interface SearchResult { + id: string; + title: string; + poster: string; + episodes?: number; + source: string; + source_name: string; +} + +export default function PlayPage() { + const searchParams = useSearchParams(); + const artRef = useRef(null); + const artPlayerRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 使用 useState 保存视频详情 + const [detail, setDetail] = useState(null); + + // 轻量级界面状态,仅用于显示 + const [videoTitle, setVideoTitle] = useState(''); + 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 [showTopBar, setShowTopBar] = useState(true); + const topBarTimeoutRef = useRef(null); + const [showShortcutHint, setShowShortcutHint] = useState(false); + const [shortcutText, setShortcutText] = useState(''); + const [shortcutDirection, setShortcutDirection] = useState(''); + const shortcutHintTimeoutRef = useRef(null); + + // 换源相关状态 + const [searchResults, setSearchResults] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(null); + const hasSearchedRef = useRef(false); + + // 视频播放地址 + const [videoUrl, setVideoUrl] = useState(''); + + // 总集数:从 detail 中获取,保证随 detail 更新而变化 + const totalEpisodes = detail?.episodes?.length || 0; + + // 根据 detail 和集数索引更新视频地址(仅当地址真正变化时) + const updateVideoUrl = ( + detailData: VideoDetail | null, + episodeIndex: number + ) => { + const newUrl = detailData?.episodes[episodeIndex] || ''; + if (newUrl != videoUrl) { + setVideoUrl(newUrl); + } + }; + + // 当集数索引变化时自动更新视频地址 + 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(() => { + // 动态加载 Artplayer 和 Hls.js + const loadPlayers = async () => { + try { + const [ArtplayerModule, HlsModule] = await Promise.all([ + import('artplayer'), + import('hls.js'), + ]); + Artplayer = ArtplayerModule.default; + Hls = HlsModule.default; + } catch (err) { + console.error('Failed to load players:', err); + setError('播放器加载失败'); + setLoading(false); + } + }; + + loadPlayers(); + }, []); + + useEffect(() => { + if (!currentSource || !currentId) { + setError('缺少必要参数'); + setLoading(false); + return; + } + + const fetchDetail = async () => { + try { + const response = await fetch( + `/api/detail?source=${currentSource}&id=${currentId}` + ); + if (!response.ok) { + throw new Error('获取视频详情失败'); + } + const data = await response.json(); + + // 更新状态保存详情 + setVideoTitle(data.videoInfo.title); + setVideoCover(data.videoInfo.cover); + setDetail(data); + if (currentEpisodeIndex >= data.episodes.length) { + setCurrentEpisodeIndex(0); + } + + // 清理URL参数(移除index参数) + if (searchParams.has('index')) { + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('index'); + window.history.replaceState({}, '', newUrl.toString()); + } + } catch (err) { + setError(err instanceof Error ? err.message : '获取视频详情失败'); + } finally { + setLoading(false); + } + }; + + fetchDetail(); + }, [currentSource]); + + // 播放器创建/切换逻辑,只依赖视频URL和集数索引 + useEffect(() => { + if ( + !Artplayer || + !Hls || + !videoUrl || + loading || + currentEpisodeIndex === null || + !artRef.current + ) + return; + + // 确保选集索引有效 + if ( + !detail || + !detail.episodes || + currentEpisodeIndex >= detail.episodes.length || + currentEpisodeIndex < 0 + ) { + setError(`选集索引无效,当前共 ${totalEpisodes} 集`); + return; + } + + if (!videoUrl) { + setError('视频地址无效'); + return; + } + + // 检测是否为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; + console.log(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 { + // 创建新的播放器实例 + artPlayerRef.current = new Artplayer({ + container: artRef.current, + url: videoUrl, + title: `${videoTitle} - 第${currentEpisodeIndex + 1}集`, + poster: videoCover, + volume: 0.7, + isLive: false, + muted: false, + autoplay: true, + pip: false, + autoSize: false, + autoMini: false, + screenshot: false, + setting: true, + loop: false, + flip: false, + playbackRate: true, + aspectRatio: false, + fullscreen: true, + fullscreenWeb: false, + subtitleOffset: false, + miniProgressBar: false, + mutex: true, + backdrop: true, + playsInline: true, + autoPlayback: false, + airplay: true, + theme: '#23ade5', + lang: 'zh-cn', + hotkey: false, + moreVideoAttr: { + crossOrigin: 'anonymous', + }, + // HLS 支持配置 + customType: { + m3u8: function (video: HTMLVideoElement, url: string) { + if (!Hls) { + console.error('HLS.js 未加载'); + return; + } + + if (Hls.isSupported()) { + if (video.hls) { + video.hls.destroy(); + } + const hls = new Hls({ + debug: false, + enableWorker: true, + lowLatencyMode: true, + backBufferLength: 90, + }); + + hls.loadSource(url); + hls.attachMedia(video); + video.hls = hls; + + 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; + } + } + }); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + // Safari 原生支持 HLS + video.src = url; + } else { + console.error('此浏览器不支持 HLS'); + } + }, + }, + icons: { + loading: + '', + }, + // 控制栏配置 + controls: [ + { + position: 'left', + index: 10, + html: '', + tooltip: '后退10秒', + click: function () { + if (artPlayerRef.current) { + artPlayerRef.current.backward = 10; + } + }, + }, + { + position: 'left', + index: 12, + html: '', + tooltip: '前进10秒', + click: function () { + if (artPlayerRef.current) { + artPlayerRef.current.forward = 10; + } + }, + }, + { + position: 'left', + index: 13, + html: '', + tooltip: '播放下一集', + click: function () { + handleNextEpisode(); + }, + }, + { + position: 'right', + html: '选集', + tooltip: '选择集数', + click: function () { + setShowEpisodePanel(true); + }, + }, + { + position: 'right', + html: '换源', + tooltip: '更换视频源', + click: function () { + handleSourcePanelOpen(); + }, + }, + ], + }); + + // 监听播放器事件 + artPlayerRef.current.on('ready', () => { + console.log('播放器准备就绪'); + setError(null); + }); + + artPlayerRef.current.on('error', (err: any) => { + console.error('播放器错误:', err); + setError('视频播放失败'); + }); + + // 监听视频播放结束事件,自动播放下一集 + artPlayerRef.current.on('video:ended', () => { + if ( + detail && + detail.episodes && + currentEpisodeIndex < detail.episodes.length - 1 + ) { + setTimeout(() => { + setCurrentEpisodeIndex(currentEpisodeIndex + 1); + }, 1000); + } + }); + } catch (err) { + console.error('创建播放器失败:', err); + setError('播放器初始化失败'); + } + }, [videoUrl]); + + // 清理定时器 + useEffect(() => { + return () => { + if (topBarTimeoutRef.current) { + clearTimeout(topBarTimeoutRef.current); + } + if (shortcutHintTimeoutRef.current) { + clearTimeout(shortcutHintTimeoutRef.current); + } + }; + }, []); + + // 当视频标题变化时重置搜索状态 + useEffect(() => { + if (videoTitle) { + hasSearchedRef.current = false; + setSearchResults([]); + setSearchError(null); + } + }, [videoTitle]); + + // 添加键盘事件监听器 + useEffect(() => { + document.addEventListener('keydown', handleKeyboardShortcuts); + return () => { + document.removeEventListener('keydown', handleKeyboardShortcuts); + }; + }, [currentEpisodeIndex, detail, artPlayerRef.current]); + + // 处理选集切换 + const handleEpisodeChange = (episodeIndex: number) => { + if (episodeIndex >= 0 && episodeIndex < totalEpisodes) { + setCurrentEpisodeIndex(episodeIndex); + setShowEpisodePanel(false); + } + }; + + // 处理下一集 + const handleNextEpisode = () => { + if ( + detail && + detail.episodes && + currentEpisodeIndex < detail.episodes.length - 1 + ) { + setCurrentEpisodeIndex(currentEpisodeIndex + 1); + } + }; + + // 处理鼠标移动,显示顶栏并重置隐藏定时器 + const handleMouseMove = () => { + setShowTopBar(true); + if (topBarTimeoutRef.current) { + clearTimeout(topBarTimeoutRef.current); + } + // 仅当视频正在播放时,才在 3 秒后隐藏顶栏 + if ( + artPlayerRef.current && + artPlayerRef.current.video && + !artPlayerRef.current.video.paused + ) { + topBarTimeoutRef.current = setTimeout(() => { + setShowTopBar(false); + }, 3000); + } + }; + + // 处理点击事件,显示顶栏并重置隐藏定时器 + const handleClick = () => { + setShowTopBar(true); + if (topBarTimeoutRef.current) { + clearTimeout(topBarTimeoutRef.current); + } + // 仅当视频正在播放时,才在 3 秒后隐藏顶栏 + if ( + artPlayerRef.current && + artPlayerRef.current.video && + !artPlayerRef.current.video.paused + ) { + topBarTimeoutRef.current = setTimeout(() => { + setShowTopBar(false); + }, 3000); + } + }; + + // 处理返回按钮点击 + const handleBack = () => { + window.location.href = `/detail?source=${currentSource}&id=${currentId}`; + }; + + // 处理上一集 + const handlePreviousEpisode = () => { + if (detail && currentEpisodeIndex > 0) { + setCurrentEpisodeIndex(currentEpisodeIndex - 1); + } + }; + + // 搜索视频源 + const handleSearch = async (query: string) => { + if (!query.trim()) { + setSearchResults([]); + return; + } + + setSearchLoading(true); + setSearchError(null); + + try { + const response = await fetch( + `/api/search?q=${encodeURIComponent(query)}` + ); + 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() + ); + + // 如果没有完全匹配,选择第一个结果 + const selectedResult = exactMatch || results[0]; + processedResults.push(selectedResult); + }); + + setSearchResults(processedResults); + } catch (err) { + setSearchError(err instanceof Error ? err.message : '搜索失败'); + setSearchResults([]); + } finally { + setSearchLoading(false); + } + }; + + // 处理换源 - 使用 startTransition 批量更新状态 + const handleSourceChange = async (newSource: string, newId: string) => { + try { + // 显示换源加载状态 + setSourceChanging(true); + setError(null); + + // 获取新源的详情 + const response = await fetch( + `/api/detail?source=${newSource}&id=${newId}` + ); + if (!response.ok) { + throw new Error('获取新源详情失败'); + } + const newDetail = await response.json(); + + // 尝试跳转到当前正在播放的集数 + let targetIndex = currentEpisodeIndex; + + // 如果当前集数超出新源的范围,则跳转到第一集 + if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) { + targetIndex = 0; + } + + // 更新URL参数(不刷新页面) + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('source', newSource); + newUrl.searchParams.set('id', newId); + window.history.replaceState({}, '', newUrl.toString()); + + // 关闭换源面板 + setShowSourcePanel(false); + + setVideoTitle(newDetail.videoInfo.title); + setVideoCover(newDetail.videoInfo.cover); + setCurrentSource(newSource); + setCurrentId(newId); + setDetail(newDetail); + setCurrentEpisodeIndex(targetIndex); + } catch (err) { + setError(err instanceof Error ? err.message : '换源失败'); + } finally { + setSourceChanging(false); + } + }; + + // 处理播放源面板展开 + const handleSourcePanelOpen = () => { + setShowSourcePanel(true); + // 只在第一次展开时搜索 + if (videoTitle && !hasSearchedRef.current) { + handleSearch(videoTitle); + hasSearchedRef.current = true; + } + }; + + // 显示快捷键提示 + const displayShortcutHint = (text: string, direction: string) => { + setShortcutText(text); + setShortcutDirection(direction); + setShowShortcutHint(true); + + // 清除之前的超时 + if (shortcutHintTimeoutRef.current) { + clearTimeout(shortcutHintTimeoutRef.current); + } + + // 2秒后隐藏 + shortcutHintTimeoutRef.current = setTimeout(() => { + setShowShortcutHint(false); + }, 2000); + }; + + // 处理全局快捷键 + const handleKeyboardShortcuts = (e: KeyboardEvent) => { + // 忽略输入框中的按键事件 + if ( + (e.target as HTMLElement).tagName === 'INPUT' || + (e.target as HTMLElement).tagName === 'TEXTAREA' + ) + return; + + // Alt + 左箭头 = 上一集 + if (e.altKey && e.key === 'ArrowLeft') { + if (detail && currentEpisodeIndex > 0) { + handlePreviousEpisode(); + displayShortcutHint('上一集', 'left'); + e.preventDefault(); + } + } + + // Alt + 右箭头 = 下一集 + if (e.altKey && e.key === 'ArrowRight') { + if (detail && currentEpisodeIndex < detail.episodes.length - 1) { + handleNextEpisode(); + displayShortcutHint('下一集', 'right'); + e.preventDefault(); + } + } + + // 左箭头 = 快退 + if (!e.altKey && e.key === 'ArrowLeft') { + if ( + artPlayerRef.current && + artPlayerRef.current.video && + artPlayerRef.current.video.currentTime > 5 + ) { + artPlayerRef.current.video.currentTime -= 10; + displayShortcutHint('快退', 'left'); + e.preventDefault(); + } + } + + // 右箭头 = 快进 + if (!e.altKey && e.key === 'ArrowRight') { + if ( + artPlayerRef.current && + artPlayerRef.current.video && + artPlayerRef.current.video.currentTime < + artPlayerRef.current.video.duration - 5 + ) { + artPlayerRef.current.video.currentTime += 10; + displayShortcutHint('快进', 'right'); + e.preventDefault(); + } + } + + // 上箭头 = 音量+ + if (e.key === 'ArrowUp') { + if ( + artPlayerRef.current && + artPlayerRef.current.video && + artPlayerRef.current.video.volume < 1 + ) { + artPlayerRef.current.video.volume += 0.1; + displayShortcutHint( + `音量 ${Math.round(artPlayerRef.current.video.volume * 100)}`, + 'up' + ); + e.preventDefault(); + } + } + + // 下箭头 = 音量- + if (e.key === 'ArrowDown') { + if ( + artPlayerRef.current && + artPlayerRef.current.video && + artPlayerRef.current.video.volume > 0 + ) { + artPlayerRef.current.video.volume -= 0.1; + displayShortcutHint( + `音量 ${Math.round(artPlayerRef.current.video.volume * 100)}`, + 'down' + ); + e.preventDefault(); + } + } + + // 空格 = 播放/暂停 + if (e.key === ' ') { + if (artPlayerRef.current) { + artPlayerRef.current.toggle(); + e.preventDefault(); + } + } + + // f 键 = 切换全屏 + if (e.key === 'f' || e.key === 'F') { + if (artPlayerRef.current) { + artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen; + e.preventDefault(); + } + } + }; + + if (loading) { + return ( +
+
+
+
加载中...
+
+
+ ); + } + + if (error) { + return ( +
+
+
+ 播放失败 +
+
{error}
+ +
+
+ ); + } + + if (!detail) { + return ( +
+
+
未找到视频
+ +
+
+ ); + } + + return ( +
+ {/* 换源加载遮罩 */} + {sourceChanging && ( +
+
+
+
换源中...
+
+
+ )} + + {/* 播放器容器 */} +
+
+ + {/* 顶栏 */} +
+
+ {/* 返回按钮 */} + + + {/* 中央标题 */} +
+ {/* 标题行与数据源徽章 */} +
+
+ {videoTitle} +
+ {detail?.videoInfo?.source_name && ( + + {detail.videoInfo.source_name} + + )} +
+ + {totalEpisodes > 1 && ( +
+ 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集 +
+ )} +
+ + {/* 右侧占位,保持标题居中 */} +
+
+
+
+ + {/* 快捷键提示 */} +
+
+ + {shortcutDirection === 'left' && ( + + )} + {shortcutDirection === 'right' && ( + + )} + {shortcutDirection === 'up' && ( + + )} + {shortcutDirection === 'down' && ( + + )} + + {shortcutText} +
+
+ + {/* 选集侧拉面板 */} + {totalEpisodes > 1 && ( + <> + {/* 遮罩层 */} + {showEpisodePanel && ( +
setShowEpisodePanel(false)} + /> + )} + + {/* 侧拉面板 */} +
+
+
+

选集列表

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

播放源

+ +
+ + {/* 搜索结果 */} +
+ {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.episodes && ( +
+ + {result.episodes} + +
+ )} + + {isCurrentSource && ( +
+
+ 当前播放 +
+
+ )} +
+ + {/* 视频信息 */} +
+

+ {result.title} +

+
+
{result.source_name}
+
+
+
+ ); + })} +
+ )} +
+
+
+ + )} +
+ ); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index ebfc2ee..aa58c25 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -104,7 +104,7 @@ export default function SearchPage() {
{searchResults.map((item) => (
- +
))} {searchResults.length === 0 && ( diff --git a/src/components/CollectionCard.tsx b/src/components/CollectionCard.tsx new file mode 100644 index 0000000..0d3f4a2 --- /dev/null +++ b/src/components/CollectionCard.tsx @@ -0,0 +1,40 @@ +import { LucideIcon } from 'lucide-react'; +import Link from 'next/link'; + +interface CollectionCardProps { + title: string; + icon: LucideIcon; + href: string; +} + +export default function CollectionCard({ + title, + icon: Icon, + href, +}: CollectionCardProps) { + return ( + +
+ {/* 长方形容器 - 调整宽高比和背景色 */} +
+ {/* 图标容器 */} +
+ +
+ + {/* Hover 蒙版效果 - 参考 DemoCard */} +
+
+ + {/* 标题 - absolute 定位,类似 DemoCard */} +
+
+

+ {title} +

+
+
+
+ + ); +} diff --git a/src/components/ScrollableRow.tsx b/src/components/ScrollableRow.tsx index 900c0b6..447d9be 100644 --- a/src/components/ScrollableRow.tsx +++ b/src/components/ScrollableRow.tsx @@ -3,9 +3,13 @@ import { useEffect, useRef, useState } from 'react'; interface ScrollableRowProps { children: React.ReactNode; + scrollDistance?: number; } -export default function ScrollableRow({ children }: ScrollableRowProps) { +export default function ScrollableRow({ + children, + scrollDistance = 1000, +}: ScrollableRowProps) { const containerRef = useRef(null); const [showLeftScroll, setShowLeftScroll] = useState(false); const [showRightScroll, setShowRightScroll] = useState(false); @@ -70,13 +74,19 @@ export default function ScrollableRow({ children }: ScrollableRowProps) { const handleScrollRightClick = () => { if (containerRef.current) { - containerRef.current.scrollBy({ left: 1000, behavior: 'smooth' }); + containerRef.current.scrollBy({ + left: scrollDistance, + behavior: 'smooth', + }); } }; const handleScrollLeftClick = () => { if (containerRef.current) { - containerRef.current.scrollBy({ left: -1000, behavior: 'smooth' }); + containerRef.current.scrollBy({ + left: -scrollDistance, + behavior: 'smooth', + }); } }; diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 3db633d..4b37a3b 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,6 +1,7 @@ import { Heart } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import React, { useState } from 'react'; interface VideoCardProps { @@ -11,6 +12,7 @@ interface VideoCardProps { episodes?: number; source_name: string; progress?: number; + from?: string; } function CheckCircleCustom() { @@ -73,11 +75,15 @@ export default function VideoCard({ source, source_name, progress, + from, }: VideoCardProps) { const [playHover, setPlayHover] = useState(false); + const router = useRouter(); return ( - +
{/* 海报图片 - 2:3 比例 */}
@@ -87,12 +93,17 @@ export default function VideoCard({
setPlayHover(true)} - onMouseLeave={() => setPlayHover(false)} className={`transition-all duration-200 ${ playHover ? 'scale-110' : '' }`} style={{ cursor: 'pointer' }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + router.push(`/play?source=${source}&id=${id}`); + }} + onMouseEnter={() => setPlayHover(true)} + onMouseLeave={() => setPlayHover(false)} >
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c04eb75..6779df0 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,15 @@ -import { Film, Folder, Home, Menu, Search, Star, Tv } from 'lucide-react'; +import { + Film, + Home, + Menu, + MessageCircleHeart, + MountainSnow, + Search, + Star, + Swords, + Tv, + VenetianMask, +} from 'lucide-react'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { @@ -6,6 +17,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useState, } from 'react'; @@ -36,15 +48,49 @@ interface SidebarProps { activePath?: string; } +// 在浏览器环境下通过全局变量缓存折叠状态,避免组件重新挂载时出现初始值闪烁 +declare global { + interface Window { + __sidebarCollapsed?: boolean; + } +} + const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const [isCollapsed, setIsCollapsed] = useState(() => { - if (typeof window === 'undefined') return false; - const saved = localStorage.getItem('sidebarCollapsed'); - return saved !== null ? JSON.parse(saved) : false; + // 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁 + const [isCollapsed, setIsCollapsed] = useState(() => { + if ( + typeof window !== 'undefined' && + typeof window.__sidebarCollapsed === 'boolean' + ) { + return window.__sidebarCollapsed; + } + return false; // 默认展开 }); + + // 首次挂载时读取 localStorage,以便刷新后仍保持上次的折叠状态 + useLayoutEffect(() => { + const saved = localStorage.getItem('sidebarCollapsed'); + if (saved !== null) { + const val = JSON.parse(saved); + setIsCollapsed(val); + window.__sidebarCollapsed = val; + } + }, []); + + // 当折叠状态变化时,同步到 data 属性,供首屏 CSS 使用 + useLayoutEffect(() => { + if (typeof document !== 'undefined') { + if (isCollapsed) { + document.documentElement.dataset.sidebarCollapsed = 'true'; + } else { + delete document.documentElement.dataset.sidebarCollapsed; + } + } + }, [isCollapsed]); + const [active, setActive] = useState(activePath); useEffect(() => { @@ -66,6 +112,9 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { const newState = !isCollapsed; setIsCollapsed(newState); localStorage.setItem('sidebarCollapsed', JSON.stringify(newState)); + if (typeof window !== 'undefined') { + window.__sidebarCollapsed = newState; + } onToggle?.(newState); }, [isCollapsed, onToggle]); @@ -93,16 +142,21 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { label: '豆瓣 Top250', href: '/douban?type=movie&tag=top250&title=豆瓣 Top250', }, - { icon: Folder, label: '美剧', href: '/douban?type=tv&tag=美剧' }, - { icon: Folder, label: '韩剧', href: '/douban?type=tv&tag=韩剧' }, - { icon: Folder, label: '日剧', href: '/douban?type=tv&tag=日剧' }, - { icon: Folder, label: '日漫', href: '/douban?type=tv&tag=日本动画' }, + { icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' }, + { + icon: MessageCircleHeart, + label: '韩剧', + href: '/douban?type=tv&tag=韩剧', + }, + { icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' }, + { icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' }, ]; return (