Files
LunaTV/src/app/play/page.tsx

1353 lines
44 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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>
);
}