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} 集
+
+ )}
+
+
+ {/* 右侧占位,保持标题居中 */}
+
+
+
+
+
+ {/* 快捷键提示 */}
+
+
+
+
{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.episodes && (
+
+
+ {result.episodes}
+
+
+ )}
+
+ {isCurrentSource && (
+
+ )}
+
+
+ {/* 视频信息 */}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
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 */}
+
+
+
+ );
+}
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 (