mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-24 12:24:46 +08:00
1353 lines
44 KiB
TypeScript
1353 lines
44 KiB
TypeScript
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
|
||
|
||
'use client';
|
||
|
||
import Artplayer from 'artplayer';
|
||
import Hls from 'hls.js';
|
||
import { Heart } from 'lucide-react';
|
||
import { useSearchParams } from 'next/navigation';
|
||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||
|
||
import {
|
||
deletePlayRecord,
|
||
generateStorageKey,
|
||
getAllPlayRecords,
|
||
isFavorited,
|
||
savePlayRecord,
|
||
toggleFavorite,
|
||
} from '@/lib/db.client';
|
||
import {
|
||
type VideoDetail,
|
||
fetchVideoDetail,
|
||
} from '@/lib/fetchVideoDetail.client';
|
||
import { SearchResult } from '@/lib/types';
|
||
|
||
import EpisodeSelector from '@/components/EpisodeSelector';
|
||
import PageLayout from '@/components/PageLayout';
|
||
|
||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||
declare global {
|
||
interface HTMLVideoElement {
|
||
hls?: any;
|
||
}
|
||
}
|
||
|
||
function PlayPageClient() {
|
||
const searchParams = useSearchParams();
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 状态变量(State)
|
||
// -----------------------------------------------------------------------------
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||
|
||
// 收藏状态
|
||
const [favorited, setFavorited] = useState(false);
|
||
|
||
// 去广告开关(从 localStorage 继承,默认 true)
|
||
const [blockAdEnabled, _setBlockAdEnabled] = useState<boolean>(() => {
|
||
if (typeof window !== 'undefined') {
|
||
const v = localStorage.getItem('enable_blockad');
|
||
if (v !== null) return v === 'true';
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// 视频基本信息
|
||
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<VideoDetail | null>(detail);
|
||
const currentEpisodeIndexRef = useRef(currentEpisodeIndex);
|
||
|
||
// 同步最新值到 refs
|
||
useEffect(() => {
|
||
currentSourceRef.current = currentSource;
|
||
currentIdRef.current = currentId;
|
||
detailRef.current = detail;
|
||
currentEpisodeIndexRef.current = currentEpisodeIndex;
|
||
videoTitleRef.current = videoTitle;
|
||
}, [currentSource, currentId, detail, currentEpisodeIndex, videoTitle]);
|
||
|
||
// 视频播放地址
|
||
const [videoUrl, setVideoUrl] = useState('');
|
||
|
||
// 总集数
|
||
const totalEpisodes = detail?.episodes?.length || 0;
|
||
|
||
// 长按三倍速相关状态
|
||
const [isLongPressing, setIsLongPressing] = useState(false);
|
||
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const originalPlaybackRateRef = useRef<number>(1);
|
||
|
||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||
const resumeTimeRef = useRef<number | null>(null);
|
||
// 上次使用的音量,默认 0.7
|
||
const lastVolumeRef = useRef<number>(0.7);
|
||
|
||
// 换源相关状态
|
||
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
|
||
const [sourceSearchLoading, setSourceSearchLoading] = useState(false);
|
||
const [sourceSearchError, setSourceSearchError] = useState<string | null>(
|
||
null
|
||
);
|
||
|
||
// 播放进度保存相关
|
||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||
const lastSaveTimeRef = useRef<number>(0);
|
||
const videoEventListenersRef = useRef<{
|
||
video: HTMLVideoElement;
|
||
listeners: Array<{ event: string; handler: EventListener }>;
|
||
} | null>(null);
|
||
|
||
const artPlayerRef = useRef<any>(null);
|
||
const artRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 工具函数(Utils)
|
||
// -----------------------------------------------------------------------------
|
||
// 更新视频地址
|
||
const updateVideoUrl = (
|
||
detailData: VideoDetail | null,
|
||
episodeIndex: number
|
||
) => {
|
||
if (
|
||
!detailData ||
|
||
!detailData.episodes ||
|
||
episodeIndex >= detailData.episodes.length
|
||
) {
|
||
setVideoUrl('');
|
||
return;
|
||
}
|
||
const newUrl = detailData?.episodes[episodeIndex] || '';
|
||
if (newUrl !== videoUrl) {
|
||
setVideoUrl(newUrl);
|
||
}
|
||
};
|
||
|
||
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
|
||
if (!video || !url) return;
|
||
const sources = Array.from(video.getElementsByTagName('source'));
|
||
const existed = sources.some((s) => s.src === url);
|
||
if (!existed) {
|
||
// 移除旧的 source,保持唯一
|
||
sources.forEach((s) => s.remove());
|
||
const sourceEl = document.createElement('source');
|
||
sourceEl.src = url;
|
||
video.appendChild(sourceEl);
|
||
}
|
||
|
||
// 始终允许远程播放(AirPlay / Cast)
|
||
video.disableRemotePlayback = false;
|
||
// 如果曾经有禁用属性,移除之
|
||
if (video.hasAttribute('disableRemotePlayback')) {
|
||
video.removeAttribute('disableRemotePlayback');
|
||
}
|
||
};
|
||
|
||
// 去广告相关函数
|
||
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);
|
||
}, [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(() => {
|
||
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();
|
||
};
|
||
|
||
fetchDetailAsync();
|
||
}, [currentSource, currentId]);
|
||
|
||
// 播放记录处理
|
||
useEffect(() => {
|
||
// 仅在初次挂载时检查播放记录
|
||
const initFromHistory = async () => {
|
||
if (!currentSource || !currentId) return;
|
||
|
||
try {
|
||
const allRecords = await getAllPlayRecords();
|
||
const key = generateStorageKey(currentSource, currentId);
|
||
const record = allRecords[key];
|
||
|
||
// URL 参数
|
||
const urlIndexParam = searchParams.get('index');
|
||
const urlPositionParam = searchParams.get('position');
|
||
|
||
// 当index参数存在时的处理逻辑
|
||
if (urlIndexParam) {
|
||
const urlIndex = parseInt(urlIndexParam, 10) - 1;
|
||
let targetTime = 0; // 默认从0开始
|
||
|
||
// 只有index参数和position参数都存在时才生效position
|
||
if (urlPositionParam) {
|
||
targetTime = parseInt(urlPositionParam, 10);
|
||
} else if (record && urlIndex === record.index - 1) {
|
||
// 如果有同集播放记录则跳转到播放记录处
|
||
targetTime = record.play_time;
|
||
}
|
||
// 否则从0开始(targetTime已经是0)
|
||
|
||
// 更新当前选集索引
|
||
if (urlIndex !== currentEpisodeIndex) {
|
||
setCurrentEpisodeIndex(urlIndex);
|
||
}
|
||
|
||
// 保存待恢复的播放进度,待播放器就绪后跳转
|
||
resumeTimeRef.current = targetTime;
|
||
} else if (record) {
|
||
// 没有index参数但有播放记录时,使用原有逻辑
|
||
const targetIndex = record.index - 1;
|
||
const targetTime = record.play_time;
|
||
|
||
// 更新当前选集索引
|
||
if (targetIndex !== currentEpisodeIndex) {
|
||
setCurrentEpisodeIndex(targetIndex);
|
||
}
|
||
|
||
// 保存待恢复的播放进度,待播放器就绪后跳转
|
||
resumeTimeRef.current = targetTime;
|
||
}
|
||
} catch (err) {
|
||
console.error('读取播放记录失败:', err);
|
||
}
|
||
};
|
||
|
||
initFromHistory();
|
||
}, []);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 换源搜索与切换
|
||
// ---------------------------------------------------------------------------
|
||
// 处理换源搜索
|
||
const handleSearchSources = async (
|
||
query: string
|
||
): Promise<SearchResult[]> => {
|
||
if (!query.trim()) {
|
||
setAvailableSources([]);
|
||
return [];
|
||
}
|
||
|
||
setSourceSearchLoading(true);
|
||
setSourceSearchError(null);
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error('搜索失败');
|
||
}
|
||
const data = await response.json();
|
||
|
||
// 处理搜索结果:每个数据源只展示一个,优先展示与title同名的结果
|
||
const processedResults: SearchResult[] = [];
|
||
const sourceMap = new Map<string, SearchResult[]>();
|
||
|
||
// 按数据源分组
|
||
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 exactMatchs = results.filter(
|
||
(result) =>
|
||
result.title.toLowerCase() === videoTitle.toLowerCase() &&
|
||
(videoYear
|
||
? result.year.toLowerCase() === videoYear.toLowerCase()
|
||
: true) &&
|
||
(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 (exactMatchs.length > 0) {
|
||
processedResults.push(...exactMatchs);
|
||
}
|
||
});
|
||
console.log('processedResults', processedResults);
|
||
|
||
setAvailableSources(processedResults);
|
||
return processedResults;
|
||
} catch (err) {
|
||
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
|
||
setAvailableSources([]);
|
||
return [];
|
||
} finally {
|
||
setSourceSearchLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理换源
|
||
const handleSourceChange = async (
|
||
newSource: string,
|
||
newId: string,
|
||
newTitle: string
|
||
) => {
|
||
try {
|
||
// 记录当前播放进度(仅在同一集数切换时恢复)
|
||
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
||
console.log('换源前当前播放时间:', currentPlayTime);
|
||
|
||
// 显示加载状态
|
||
setError(null);
|
||
|
||
// 清除前一个历史记录
|
||
if (currentSource && currentId) {
|
||
try {
|
||
await deletePlayRecord(currentSource, currentId);
|
||
console.log('已清除前一个播放记录');
|
||
} catch (err) {
|
||
console.error('清除播放记录失败:', err);
|
||
}
|
||
}
|
||
|
||
// 获取新源的详情
|
||
const newDetail = await fetchVideoDetail({
|
||
source: newSource,
|
||
id: newId,
|
||
fallbackTitle: newTitle.trim(),
|
||
fallbackYear: videoYear,
|
||
});
|
||
|
||
// 尝试跳转到当前正在播放的集数
|
||
let targetIndex = currentEpisodeIndex;
|
||
|
||
// 如果当前集数超出新源的范围,则跳转到第一集
|
||
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
|
||
targetIndex = 0;
|
||
}
|
||
|
||
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
|
||
if (targetIndex === currentEpisodeIndex && currentPlayTime > 1) {
|
||
resumeTimeRef.current = currentPlayTime;
|
||
} else {
|
||
// 否则从头开始播放,防止影响后续选集逻辑
|
||
resumeTimeRef.current = 0;
|
||
}
|
||
|
||
// 更新URL参数(不刷新页面)
|
||
const newUrl = new URL(window.location.href);
|
||
newUrl.searchParams.set('source', newSource);
|
||
newUrl.searchParams.set('id', newId);
|
||
window.history.replaceState({}, '', newUrl.toString());
|
||
|
||
setVideoTitle(newDetail.title || newTitle);
|
||
setVideoCover(newDetail.poster);
|
||
setCurrentSource(newSource);
|
||
setCurrentId(newId);
|
||
setDetail(newDetail);
|
||
setCurrentEpisodeIndex(targetIndex);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '换源失败');
|
||
}
|
||
};
|
||
|
||
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 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);
|
||
}
|
||
};
|
||
|
||
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) => {
|
||
// 忽略输入框中的按键事件
|
||
if (
|
||
(e.target as HTMLElement).tagName === 'INPUT' ||
|
||
(e.target as HTMLElement).tagName === 'TEXTAREA'
|
||
)
|
||
return;
|
||
|
||
// Alt + 左箭头 = 上一集
|
||
if (e.altKey && e.key === 'ArrowLeft') {
|
||
if (detailRef.current && currentEpisodeIndexRef.current > 0) {
|
||
handlePreviousEpisode();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
// Alt + 右箭头 = 下一集
|
||
if (e.altKey && e.key === 'ArrowRight') {
|
||
const d = detailRef.current;
|
||
const idx = currentEpisodeIndexRef.current;
|
||
if (d && idx < d.episodes.length - 1) {
|
||
handleNextEpisode();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
// 左箭头 = 快退
|
||
if (!e.altKey && e.key === 'ArrowLeft') {
|
||
if (artPlayerRef.current && artPlayerRef.current.currentTime > 5) {
|
||
artPlayerRef.current.currentTime -= 10;
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
// 右箭头 = 快进
|
||
if (!e.altKey && e.key === 'ArrowRight') {
|
||
if (
|
||
artPlayerRef.current &&
|
||
artPlayerRef.current.currentTime < artPlayerRef.current.duration - 5
|
||
) {
|
||
artPlayerRef.current.currentTime += 10;
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
// 上箭头 = 音量+
|
||
if (e.key === 'ArrowUp') {
|
||
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();
|
||
}
|
||
}
|
||
|
||
// 下箭头 = 音量-
|
||
if (e.key === 'ArrowDown') {
|
||
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();
|
||
}
|
||
}
|
||
|
||
// 空格 = 播放/暂停
|
||
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();
|
||
}
|
||
}
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 播放记录相关
|
||
// ---------------------------------------------------------------------------
|
||
// 保存播放进度
|
||
const saveCurrentPlayProgress = async () => {
|
||
if (
|
||
!artPlayerRef.current ||
|
||
!currentSourceRef.current ||
|
||
!currentIdRef.current ||
|
||
!videoTitleRef.current ||
|
||
!detailRef.current?.source_name
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const player = artPlayerRef.current;
|
||
const currentTime = player.currentTime || 0;
|
||
const duration = player.duration || 0;
|
||
|
||
// 如果播放时间太短(少于5秒)或者视频时长无效,不保存
|
||
if (currentTime < 1 || !duration) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await savePlayRecord(currentSource, currentId, {
|
||
title: videoTitleRef.current,
|
||
source_name: detailRef.current?.source_name || '',
|
||
year: videoYear || detailRef.current?.year || '',
|
||
cover: videoCover,
|
||
index: currentEpisodeIndex + 1, // 转换为1基索引
|
||
total_episodes: totalEpisodes,
|
||
play_time: Math.floor(currentTime),
|
||
total_time: Math.floor(duration),
|
||
save_time: Date.now(),
|
||
});
|
||
|
||
lastSaveTimeRef.current = Date.now();
|
||
console.log('播放进度已保存:', {
|
||
title: videoTitleRef.current,
|
||
episode: currentEpisodeIndexRef.current + 1,
|
||
progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`,
|
||
});
|
||
} catch (err) {
|
||
console.error('保存播放进度失败:', err);
|
||
}
|
||
};
|
||
|
||
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;
|
||
(async () => {
|
||
try {
|
||
const fav = await isFavorited(currentSource, currentId);
|
||
setFavorited(fav);
|
||
} catch (err) {
|
||
console.error('检查收藏状态失败:', err);
|
||
}
|
||
})();
|
||
}, [currentSource, currentId]);
|
||
|
||
// 切换收藏
|
||
const handleToggleFavorite = async () => {
|
||
if (!currentSource || !currentId) return;
|
||
|
||
try {
|
||
const newState = await toggleFavorite(currentSource, currentId, {
|
||
title: videoTitle,
|
||
source_name: detail?.source_name || '',
|
||
year: detail?.year || videoYear || '',
|
||
cover: videoCover || '',
|
||
total_episodes: totalEpisodes || 1,
|
||
save_time: Date.now(),
|
||
});
|
||
setFavorited(newState);
|
||
} catch (err) {
|
||
console.error('切换收藏失败:', err);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!Artplayer ||
|
||
!Hls ||
|
||
!videoUrl ||
|
||
loading ||
|
||
currentEpisodeIndex === null ||
|
||
!artRef.current
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// 确保选集索引有效
|
||
if (
|
||
!detail ||
|
||
!detail.episodes ||
|
||
currentEpisodeIndex >= detail.episodes.length ||
|
||
currentEpisodeIndex < 0
|
||
) {
|
||
setError(`选集索引无效,当前共 ${totalEpisodes} 集`);
|
||
return;
|
||
}
|
||
|
||
if (!videoUrl) {
|
||
setError('视频地址无效');
|
||
return;
|
||
}
|
||
console.log(videoUrl);
|
||
|
||
// 检测是否为WebKit浏览器
|
||
const isWebkit =
|
||
typeof window !== 'undefined' &&
|
||
typeof (window as any).webkitConvertPointFromNodeToPage === 'function';
|
||
|
||
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||
if (!isWebkit && artPlayerRef.current) {
|
||
artPlayerRef.current.switch = videoUrl;
|
||
artPlayerRef.current.title = `${videoTitle} - 第${
|
||
currentEpisodeIndex + 1
|
||
}集`;
|
||
artPlayerRef.current.poster = videoCover;
|
||
if (artPlayerRef.current?.video) {
|
||
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:
|
||
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||
},
|
||
settings: [
|
||
{
|
||
html: blockAdEnabled ? '关闭去广告' : '开启去广告',
|
||
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
|
||
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: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
|
||
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 (artPlayerRef.current) {
|
||
// 保存原始播放速度
|
||
originalPlaybackRateRef.current = artPlayerRef.current.playbackRate;
|
||
|
||
// 设置三倍速
|
||
artPlayerRef.current.playbackRate = 3;
|
||
|
||
// 更新状态
|
||
setIsLongPressing(true);
|
||
artPlayerRef.current.notice.show = '3x';
|
||
|
||
// 触发震动反馈(如果支持)
|
||
if (navigator.vibrate) {
|
||
navigator.vibrate(50);
|
||
}
|
||
}
|
||
}, 500);
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
// 清除长按检测定时器
|
||
if (longPressTimeoutRef.current) {
|
||
clearTimeout(longPressTimeoutRef.current);
|
||
longPressTimeoutRef.current = null;
|
||
}
|
||
|
||
// 如果正在长按,恢复原始播放速度
|
||
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 (
|
||
<PageLayout activePath='/play'>
|
||
<div className='flex items-center justify-center h-screen'>
|
||
<div className='text-center'>
|
||
<div className='animate-spin rounded-full h-32 w-32 border-b-2 border-green-500 mx-auto mb-4'></div>
|
||
<p className='text-lg text-gray-600 dark:text-gray-300'>
|
||
加载中...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<PageLayout activePath='/play'>
|
||
<div className='flex items-center justify-center h-screen'>
|
||
<div className='text-center'>
|
||
<div className='text-red-500 text-6xl mb-4'>⚠️</div>
|
||
<p className='text-lg text-red-600 dark:text-red-400 mb-4'>
|
||
{error}
|
||
</p>
|
||
<button
|
||
onClick={() =>
|
||
videoTitle
|
||
? (window.location.href = `/search?q=${encodeURIComponent(
|
||
videoTitle
|
||
)}`)
|
||
: window.history.back()
|
||
}
|
||
className='px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors'
|
||
>
|
||
{videoTitle ? '返回搜索' : '返回'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PageLayout activePath='/play'>
|
||
<div className='flex flex-col gap-6 py-4 px-5 lg:px-10 xl:px-20'>
|
||
{/* 第一行:影片标题 */}
|
||
<div className='py-1'>
|
||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||
{videoTitle || '影片标题'}
|
||
{totalEpisodes > 1 && (
|
||
<span className='text-gray-500 dark:text-gray-400'>
|
||
{` > 第 ${currentEpisodeIndex + 1} 集`}
|
||
</span>
|
||
)}
|
||
</h1>
|
||
</div>
|
||
{/* 第二行:播放器和选集 */}
|
||
<div className='grid grid-cols-1 md:grid-cols-4 gap-4 lg:h-[500px] xl:h-[650px]'>
|
||
{/* 播放器 */}
|
||
<div className='md:col-span-3 h-full'>
|
||
<div
|
||
ref={artRef}
|
||
className='bg-black w-full h-[300px] lg:h-full rounded-xl overflow-hidden border border-white/0 dark:border-white/30'
|
||
></div>
|
||
</div>
|
||
|
||
{/* 选集和换源 */}
|
||
<div className='md:col-span-1 h-[300px] lg:h-full md:overflow-hidden'>
|
||
<EpisodeSelector
|
||
totalEpisodes={totalEpisodes}
|
||
value={currentEpisodeIndex + 1}
|
||
onChange={handleEpisodeChange}
|
||
onSourceChange={handleSourceChange}
|
||
currentSource={currentSource}
|
||
currentId={currentId}
|
||
videoTitle={videoTitle}
|
||
availableSources={availableSources}
|
||
onSearchSources={handleSearchSources}
|
||
sourceSearchLoading={sourceSearchLoading}
|
||
sourceSearchError={sourceSearchError}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 详情展示 */}
|
||
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
|
||
{/* 文字区 */}
|
||
<div className='md:col-span-3'>
|
||
<div className='p-6 flex flex-col min-h-0'>
|
||
{/* 标题 */}
|
||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||
{videoTitle || '影片标题'}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleToggleFavorite();
|
||
}}
|
||
className='ml-3 flex-shrink-0 hover:opacity-80 transition-opacity'
|
||
>
|
||
<FavoriteIcon filled={favorited} />
|
||
</button>
|
||
</h1>
|
||
|
||
{/* 关键信息行 */}
|
||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||
{detail?.class && (
|
||
<span className='text-green-600 font-semibold'>
|
||
{detail.class}
|
||
</span>
|
||
)}
|
||
{(detail?.year || videoYear) && (
|
||
<span>{detail?.year || videoYear}</span>
|
||
)}
|
||
{detail?.source_name && (
|
||
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
|
||
{detail.source_name}
|
||
</span>
|
||
)}
|
||
{detail?.type_name && <span>{detail.type_name}</span>}
|
||
</div>
|
||
{/* 剧情简介 */}
|
||
{detail?.desc && (
|
||
<div
|
||
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
||
style={{ whiteSpace: 'pre-line' }}
|
||
>
|
||
{detail.desc}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 封面展示 */}
|
||
<div className='hidden md:block md:col-span-1 md:order-first'>
|
||
<div className='pl-0 py-4 pr-6'>
|
||
<div className='bg-gray-300 dark:bg-gray-700 aspect-[3/4] flex items-center justify-center rounded-xl overflow-hidden'>
|
||
{videoCover ? (
|
||
<img
|
||
src={videoCover}
|
||
alt={videoTitle}
|
||
className='w-full h-full object-cover'
|
||
/>
|
||
) : (
|
||
<span className='text-gray-600 dark:text-gray-400'>
|
||
封面图片
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
);
|
||
}
|
||
|
||
// FavoriteIcon 组件
|
||
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
|
||
if (filled) {
|
||
return (
|
||
<svg
|
||
className='h-7 w-7'
|
||
viewBox='0 0 24 24'
|
||
xmlns='http://www.w3.org/2000/svg'
|
||
>
|
||
<path
|
||
d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
|
||
fill='#ef4444' /* Tailwind red-500 */
|
||
stroke='#ef4444'
|
||
strokeWidth='2'
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
return (
|
||
<Heart className='h-7 w-7 stroke-[1] text-gray-600 dark:text-gray-300' />
|
||
);
|
||
};
|
||
|
||
export default function PlayPage() {
|
||
return (
|
||
<Suspense fallback={<div>Loading...</div>}>
|
||
<PlayPageClient />
|
||
</Suspense>
|
||
);
|
||
}
|