Files
MoonTVPlus/src/app/play/page.tsx
2025-12-02 10:16:23 +08:00

3118 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 artplayerPluginDanmuku from 'artplayer-plugin-danmuku';
import Hls from 'hls.js';
import { Heart } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
import { render as anime4kRender } from 'anime4k-webgpu';
import {
deleteFavorite,
deletePlayRecord,
deleteSkipConfig,
generateStorageKey,
getAllPlayRecords,
getSkipConfig,
isFavorited,
saveFavorite,
savePlayRecord,
saveSkipConfig,
subscribeToDataUpdates,
} from '@/lib/db.client';
import {
convertDanmakuFormat,
getDanmakuById,
getEpisodes,
loadDanmakuMemory,
loadDanmakuSettings,
saveDanmakuMemory,
saveDanmakuSettings,
searchAnime,
} from '@/lib/danmaku/api';
import type { DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
interface HTMLVideoElement {
hls?: any;
}
}
// Wake Lock API 类型声明
interface WakeLockSentinel {
released: boolean;
release(): Promise<void>;
addEventListener(type: 'release', listener: () => void): void;
removeEventListener(type: 'release', listener: () => void): void;
}
function PlayPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
// -----------------------------------------------------------------------------
// 状态变量State
// -----------------------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [loadingStage, setLoadingStage] = useState<
'searching' | 'preferring' | 'fetching' | 'ready'
>('searching');
const [loadingMessage, setLoadingMessage] = useState('正在搜索播放源...');
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<SearchResult | null>(null);
// 收藏状态
const [favorited, setFavorited] = useState(false);
// 跳过片头片尾配置
const [skipConfig, setSkipConfig] = useState<{
enable: boolean;
intro_time: number;
outro_time: number;
}>({
enable: false,
intro_time: 0,
outro_time: 0,
});
const skipConfigRef = useRef(skipConfig);
useEffect(() => {
skipConfigRef.current = skipConfig;
}, [
skipConfig,
skipConfig.enable,
skipConfig.intro_time,
skipConfig.outro_time,
]);
// 跳过检查的时间间隔控制
const lastSkipCheckRef = useRef(0);
// 去广告开关(从 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 blockAdEnabledRef = useRef(blockAdEnabled);
useEffect(() => {
blockAdEnabledRef.current = blockAdEnabled;
}, [blockAdEnabled]);
// Anime4K超分相关状态
const [webGPUSupported, setWebGPUSupported] = useState<boolean>(false);
const [anime4kEnabled, setAnime4kEnabled] = useState<boolean>(false);
const [anime4kMode, setAnime4kMode] = useState<string>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('anime4k_mode');
if (v !== null) return v;
}
return 'ModeA';
});
const [anime4kScale, setAnime4kScale] = useState<number>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('anime4k_scale');
if (v !== null) return parseFloat(v);
}
return 2.0;
});
const anime4kRef = useRef<any>(null);
const anime4kEnabledRef = useRef(anime4kEnabled);
const anime4kModeRef = useRef(anime4kMode);
const anime4kScaleRef = useRef(anime4kScale);
useEffect(() => {
anime4kEnabledRef.current = anime4kEnabled;
anime4kModeRef.current = anime4kMode;
anime4kScaleRef.current = anime4kScale;
}, [anime4kEnabled, anime4kMode, anime4kScale]);
// 检测WebGPU支持
useEffect(() => {
const checkWebGPUSupport = async () => {
if (typeof navigator === 'undefined' || !('gpu' in navigator)) {
setWebGPUSupported(false);
console.log('WebGPU不支持浏览器不支持WebGPU API');
return;
}
try {
const adapter = await (navigator as any).gpu.requestAdapter();
if (!adapter) {
setWebGPUSupported(false);
console.log('WebGPU不支持无法获取GPU适配器');
return;
}
setWebGPUSupported(true);
console.log('WebGPU支持检测✅ 支持');
} catch (err) {
setWebGPUSupported(false);
console.log('WebGPU不支持', err);
}
};
checkWebGPUSupport();
}, []);
// 弹幕相关状态
const [danmakuSettings, setDanmakuSettings] = useState<DanmakuSettings>(
loadDanmakuSettings()
);
const [currentDanmakuSelection, setCurrentDanmakuSelection] =
useState<DanmakuSelection | null>(null);
const [danmakuEpisodesList, setDanmakuEpisodesList] = useState<
Array<{ episodeId: number; episodeTitle: string }>
>([]);
const [danmakuLoading, setDanmakuLoading] = useState(false);
const [danmakuCount, setDanmakuCount] = useState(0);
const danmakuPluginRef = useRef<any>(null);
const danmakuSettingsRef = useRef(danmakuSettings);
useEffect(() => {
danmakuSettingsRef.current = danmakuSettings;
}, [danmakuSettings]);
// 视频基本信息
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
const [videoCover, setVideoCover] = useState('');
const [videoDoubanId, setVideoDoubanId] = useState(0);
// 当前源和ID
const [currentSource, setCurrentSource] = useState(
searchParams.get('source') || ''
);
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
// 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || '');
// 是否需要优选
const [needPrefer, setNeedPrefer] = useState(
searchParams.get('prefer') === 'true'
);
const needPreferRef = useRef(needPrefer);
useEffect(() => {
needPreferRef.current = needPrefer;
}, [needPrefer]);
// 集数相关
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0);
const currentSourceRef = useRef(currentSource);
const currentIdRef = useRef(currentId);
const videoTitleRef = useRef(videoTitle);
const videoYearRef = useRef(videoYear);
const detailRef = useRef<SearchResult | null>(detail);
const currentEpisodeIndexRef = useRef(currentEpisodeIndex);
// 同步最新值到 refs
useEffect(() => {
currentSourceRef.current = currentSource;
currentIdRef.current = currentId;
detailRef.current = detail;
currentEpisodeIndexRef.current = currentEpisodeIndex;
videoTitleRef.current = videoTitle;
videoYearRef.current = videoYear;
}, [
currentSource,
currentId,
detail,
currentEpisodeIndex,
videoTitle,
videoYear,
]);
// 监听剧集切换,自动加载对应的弹幕
useEffect(() => {
// 只有在有弹幕选择且有剧集列表时才自动切换
if (
currentDanmakuSelection &&
danmakuEpisodesList.length > 0 &&
currentEpisodeIndex < danmakuEpisodesList.length
) {
const episode = danmakuEpisodesList[currentEpisodeIndex];
if (episode && episode.episodeId !== currentDanmakuSelection.episodeId) {
// 自动加载新集数的弹幕
const newSelection: DanmakuSelection = {
animeId: currentDanmakuSelection.animeId,
episodeId: episode.episodeId,
animeTitle: currentDanmakuSelection.animeTitle,
episodeTitle: episode.episodeTitle,
};
setCurrentDanmakuSelection(newSelection);
loadDanmaku(episode.episodeId);
console.log(`自动切换弹幕到第 ${currentEpisodeIndex + 1}`);
}
}
}, [currentEpisodeIndex]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
// 总集数
const totalEpisodes = detail?.episodes?.length || 0;
// 用于记录是否需要在播放器 ready 后跳转到指定进度
const resumeTimeRef = useRef<number | null>(null);
// 上次使用的音量,默认 0.7
const lastVolumeRef = useRef<number>(0.7);
// 上次使用的播放速率,默认 1.0
const lastPlaybackRateRef = useRef<number>(1.0);
// 换源相关状态
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
const [sourceSearchLoading, setSourceSearchLoading] = useState(false);
const [sourceSearchError, setSourceSearchError] = useState<string | null>(
null
);
// 优选和测速开关
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 保存优选时的测速结果避免EpisodeSelector重复测速
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
Map<string, { quality: string; loadSpeed: string; pingTime: number }>
>(new Map());
// 折叠状态(仅在 lg 及以上屏幕有效)
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
useState(false);
// 换源加载状态
const [isVideoLoading, setIsVideoLoading] = useState(true);
const [videoLoadingStage, setVideoLoadingStage] = useState<
'initing' | 'sourceChanging'
>('initing');
// 播放进度保存相关
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastSaveTimeRef = useRef<number>(0);
const artPlayerRef = useRef<any>(null);
const artRef = useRef<HTMLDivElement | null>(null);
// Wake Lock 相关
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
// -----------------------------------------------------------------------------
// 工具函数Utils
// -----------------------------------------------------------------------------
// 播放源优选函数
const preferBestSource = async (
sources: SearchResult[]
): Promise<SearchResult> => {
if (sources.length === 1) return sources[0];
// 将播放源均分为两批,并发测速各批,避免一次性过多请求
const batchSize = Math.ceil(sources.length / 2);
const allResults: Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
} | null> = [];
for (let start = 0; start < sources.length; start += batchSize) {
const batchSources = sources.slice(start, start + batchSize);
const batchResults = await Promise.all(
batchSources.map(async (source) => {
try {
// 检查是否有第一集的播放地址
if (!source.episodes || source.episodes.length === 0) {
console.warn(`播放源 ${source.source_name} 没有可用的播放地址`);
return null;
}
const episodeUrl =
source.episodes.length > 1
? source.episodes[1]
: source.episodes[0];
const testResult = await getVideoResolutionFromM3u8(episodeUrl);
return {
source,
testResult,
};
} catch (error) {
return null;
}
})
);
allResults.push(...batchResults);
}
// 等待所有测速完成,包含成功和失败的结果
// 保存所有测速结果到 precomputedVideoInfo供 EpisodeSelector 使用(包含错误结果)
const newVideoInfoMap = new Map<
string,
{
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean;
}
>();
allResults.forEach((result, index) => {
const source = sources[index];
const sourceKey = `${source.source}-${source.id}`;
if (result) {
// 成功的结果
newVideoInfoMap.set(sourceKey, result.testResult);
}
});
// 过滤出成功的结果用于优选计算
const successfulResults = allResults.filter(Boolean) as Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
}>;
setPrecomputedVideoInfo(newVideoInfoMap);
if (successfulResults.length === 0) {
console.warn('所有播放源测速都失败,使用第一个播放源');
return sources[0];
}
// 找出所有有效速度的最大值,用于线性映射
const validSpeeds = successfulResults
.map((result) => {
const speedStr = result.testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 0;
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2];
return unit === 'MB/s' ? value * 1024 : value; // 统一转换为 KB/s
})
.filter((speed) => speed > 0);
const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; // 默认1MB/s作为基准
// 找出所有有效延迟的最小值和最大值,用于线性映射
const validPings = successfulResults
.map((result) => result.testResult.pingTime)
.filter((ping) => ping > 0);
const minPing = validPings.length > 0 ? Math.min(...validPings) : 50;
const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000;
// 计算每个结果的评分
const resultsWithScore = successfulResults.map((result) => ({
...result,
score: calculateSourceScore(
result.testResult,
maxSpeed,
minPing,
maxPing
),
}));
// 按综合评分排序,选择最佳播放源
resultsWithScore.sort((a, b) => b.score - a.score);
console.log('播放源评分排序结果:');
resultsWithScore.forEach((result, index) => {
console.log(
`${index + 1}. ${
result.source.source_name
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
result.testResult.loadSpeed
}, ${result.testResult.pingTime}ms)`
);
});
return resultsWithScore[0].source;
};
// 计算播放源综合评分
const calculateSourceScore = (
testResult: {
quality: string;
loadSpeed: string;
pingTime: number;
},
maxSpeed: number,
minPing: number,
maxPing: number
): number => {
let score = 0;
// 分辨率评分 (40% 权重)
const qualityScore = (() => {
switch (testResult.quality) {
case '4K':
return 100;
case '2K':
return 85;
case '1080p':
return 75;
case '720p':
return 60;
case '480p':
return 40;
case 'SD':
return 20;
default:
return 0;
}
})();
score += qualityScore * 0.4;
// 下载速度评分 (40% 权重) - 基于最大速度线性映射
const speedScore = (() => {
const speedStr = testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 30;
// 解析速度值
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 30;
const value = parseFloat(match[1]);
const unit = match[2];
const speedKBps = unit === 'MB/s' ? value * 1024 : value;
// 基于最大速度线性映射最高100分
const speedRatio = speedKBps / maxSpeed;
return Math.min(100, Math.max(0, speedRatio * 100));
})();
score += speedScore * 0.4;
// 网络延迟评分 (20% 权重) - 基于延迟范围线性映射
const pingScore = (() => {
const ping = testResult.pingTime;
if (ping <= 0) return 0; // 无效延迟给默认分
// 如果所有延迟都相同,给满分
if (maxPing === minPing) return 100;
// 线性映射:最低延迟=100分最高延迟=0分
const pingRatio = (maxPing - ping) / (maxPing - minPing);
return Math.min(100, Math.max(0, pingRatio * 100));
})();
score += pingScore * 0.2;
return Math.round(score * 100) / 100; // 保留两位小数
};
// 更新视频地址
const updateVideoUrl = (
detailData: SearchResult | 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');
}
};
// Wake Lock 相关函数
const requestWakeLock = async () => {
try {
if ('wakeLock' in navigator) {
wakeLockRef.current = await (navigator as any).wakeLock.request(
'screen'
);
console.log('Wake Lock 已启用');
}
} catch (err) {
console.warn('Wake Lock 请求失败:', err);
}
};
const releaseWakeLock = async () => {
try {
if (wakeLockRef.current) {
await wakeLockRef.current.release();
wakeLockRef.current = null;
console.log('Wake Lock 已释放');
}
} catch (err) {
console.warn('Wake Lock 释放失败:', err);
}
};
// 清理播放器资源的统一函数
const cleanupPlayer = () => {
if (artPlayerRef.current) {
try {
// 销毁 HLS 实例
if (artPlayerRef.current.video && artPlayerRef.current.video.hls) {
artPlayerRef.current.video.hls.destroy();
}
// 销毁 ArtPlayer 实例
artPlayerRef.current.destroy();
artPlayerRef.current = null;
console.log('播放器资源已清理');
} catch (err) {
console.warn('清理播放器资源时出错:', err);
artPlayerRef.current = null;
}
}
};
// 初始化Anime4K超分
const initAnime4K = async () => {
if (!artPlayerRef.current?.video) return;
try {
if (anime4kRef.current) {
anime4kRef.current.stop?.();
anime4kRef.current = null;
}
const video = artPlayerRef.current.video as HTMLVideoElement;
// 等待视频元数据加载完成
if (!video.videoWidth || !video.videoHeight) {
console.warn('视频尺寸未就绪等待loadedmetadata事件');
await new Promise<void>((resolve) => {
const handler = () => {
video.removeEventListener('loadedmetadata', handler);
resolve();
};
video.addEventListener('loadedmetadata', handler);
// 如果已经加载过了立即resolve
if (video.videoWidth && video.videoHeight) {
video.removeEventListener('loadedmetadata', handler);
resolve();
}
});
}
// 再次检查视频尺寸
if (!video.videoWidth || !video.videoHeight) {
throw new Error('无法获取视频尺寸');
}
const canvas = document.createElement('canvas');
const container = artPlayerRef.current.template.$video.parentElement;
// 使用用户选择的超分倍数
const scale = anime4kScaleRef.current;
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.objectFit = 'contain';
canvas.style.cursor = 'pointer';
// 在canvas上监听点击事件触发播放器的暂停/播放切换
const handleCanvasClick = (e: MouseEvent) => {
if (artPlayerRef.current) {
artPlayerRef.current.toggle();
}
};
canvas.addEventListener('click', handleCanvasClick);
// 在canvas上监听双击事件触发全屏切换
const handleCanvasDblClick = (e: MouseEvent) => {
if (artPlayerRef.current) {
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
}
};
canvas.addEventListener('dblclick', handleCanvasDblClick);
// 隐藏原始video元素
video.style.display = 'none';
// 插入canvas到容器
container.insertBefore(canvas, video);
// 动态导入对应的模式
let ModeClass: any;
const modeName = anime4kModeRef.current;
const modeModule = await import('anime4k-webgpu');
switch (modeName) {
case 'ModeA':
ModeClass = modeModule.ModeA;
break;
case 'ModeB':
ModeClass = modeModule.ModeB;
break;
case 'ModeC':
ModeClass = modeModule.ModeC;
break;
case 'ModeAA':
ModeClass = modeModule.ModeAA;
break;
case 'ModeBB':
ModeClass = modeModule.ModeBB;
break;
case 'ModeCA':
ModeClass = modeModule.ModeCA;
break;
default:
ModeClass = modeModule.ModeA;
}
// 使用anime4k-webgpu的render函数
const renderConfig: any = {
video,
canvas,
pipelineBuilder: (device: GPUDevice, inputTexture: GPUTexture) => {
const mode = new ModeClass({
device,
inputTexture,
nativeDimensions: {
width: video.videoWidth,
height: video.videoHeight,
},
targetDimensions: {
width: canvas.width,
height: canvas.height,
},
});
return [mode];
},
};
const controller = await anime4kRender(renderConfig);
anime4kRef.current = { controller, canvas, handleCanvasClick, handleCanvasDblClick };
console.log('Anime4K超分已启用模式:', anime4kModeRef.current, '倍数:', scale);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = `超分已启用 (${anime4kModeRef.current}, ${scale}x)`;
}
} catch (err) {
console.error('初始化Anime4K失败:', err);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '超分启用失败:' + (err instanceof Error ? err.message : '未知错误');
}
// 恢复video显示
if (artPlayerRef.current?.video) {
artPlayerRef.current.video.style.display = 'block';
}
}
};
// 清理Anime4K
const cleanupAnime4K = async () => {
if (anime4kRef.current) {
try {
// 停止渲染循环
anime4kRef.current.controller?.stop?.();
// 移除canvas事件监听器
if (anime4kRef.current.canvas) {
if (anime4kRef.current.handleCanvasClick) {
anime4kRef.current.canvas.removeEventListener('click', anime4kRef.current.handleCanvasClick);
}
if (anime4kRef.current.handleCanvasDblClick) {
anime4kRef.current.canvas.removeEventListener('dblclick', anime4kRef.current.handleCanvasDblClick);
}
}
// 移除canvas
if (anime4kRef.current.canvas && anime4kRef.current.canvas.parentNode) {
anime4kRef.current.canvas.parentNode.removeChild(anime4kRef.current.canvas);
}
anime4kRef.current = null;
// 恢复原始video显示
if (artPlayerRef.current?.video) {
artPlayerRef.current.video.style.display = 'block';
}
console.log('Anime4K已清理');
} catch (err) {
console.warn('清理Anime4K时出错:', err);
}
}
};
// 切换Anime4K状态
const toggleAnime4K = async (enabled: boolean) => {
try {
if (enabled) {
await initAnime4K();
} else {
await cleanupAnime4K();
}
setAnime4kEnabled(enabled);
localStorage.setItem('enable_anime4k', String(enabled));
} catch (err) {
console.error('切换超分状态失败:', err);
}
};
// 更改Anime4K模式
const changeAnime4KMode = async (mode: string) => {
try {
setAnime4kMode(mode);
localStorage.setItem('anime4k_mode', mode);
if (anime4kEnabledRef.current) {
await cleanupAnime4K();
await initAnime4K();
}
} catch (err) {
console.error('更改超分模式失败:', err);
}
};
// 更改Anime4K分辨率倍数
const changeAnime4KScale = async (scale: number) => {
try {
setAnime4kScale(scale);
localStorage.setItem('anime4k_scale', scale.toString());
if (anime4kEnabledRef.current) {
await cleanupAnime4K();
await initAnime4K();
}
} catch (err) {
console.error('更改超分倍数失败:', err);
}
};
function filterAdsFromM3U8(type: string, m3u8Content: string): string {
if (!m3u8Content) return '';
// 按行分割M3U8内容
const lines = m3u8Content.split('\n');
const filteredLines = [];
let nextdelete = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (nextdelete) {
nextdelete = false;
continue;
}
// 只过滤#EXT-X-DISCONTINUITY标识
if (!line.includes('#EXT-X-DISCONTINUITY')) {
if (
type == 'ruyi' &&
(line.includes('EXTINF:5.640000') ||
line.includes('EXTINF:2.960000') ||
line.includes('EXTINF:3.480000') ||
line.includes('EXTINF:4.000000') ||
line.includes('EXTINF:0.960000') ||
line.includes('EXTINF:10.000000') ||
line.includes('EXTINF:1.266667'))
) {
nextdelete = true;
continue;
}
filteredLines.push(line);
}
}
return filteredLines.join('\n');
}
// 跳过片头片尾配置相关函数
const handleSkipConfigChange = async (newConfig: {
enable: boolean;
intro_time: number;
outro_time: number;
}) => {
if (!currentSourceRef.current || !currentIdRef.current) return;
try {
setSkipConfig(newConfig);
if (!newConfig.enable && !newConfig.intro_time && !newConfig.outro_time) {
await deleteSkipConfig(currentSourceRef.current, currentIdRef.current);
artPlayerRef.current.setting.update({
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item: any) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
});
artPlayerRef.current.setting.update({
name: '设置片头',
html: '设置片头',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
});
artPlayerRef.current.setting.update({
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
const outroTime =
-(
artPlayerRef.current?.duration -
artPlayerRef.current?.currentTime
) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
},
});
} else {
await saveSkipConfig(
currentSourceRef.current,
currentIdRef.current,
newConfig
);
}
console.log('跳过片头片尾配置已保存:', newConfig);
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
}
};
const formatTime = (seconds: number): string => {
if (seconds === 0) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.round(seconds % 60);
if (hours === 0) {
// 不到一小时,格式为 00:00
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
} else {
// 超过一小时,格式为 00:00:00
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
};
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(
currentSourceRef.current,
response.data
);
}
return onSuccess(response, stats, context, null);
};
}
// 执行原始load方法
load(context, config, callbacks);
};
}
}
// 当集数索引变化时自动更新视频地址
useEffect(() => {
updateVideoUrl(detail, currentEpisodeIndex);
}, [detail, currentEpisodeIndex]);
// 进入页面时直接获取全部源信息
useEffect(() => {
const fetchSourceDetail = async (
source: string,
id: string
): Promise<SearchResult[]> => {
try {
const detailResponse = await fetch(
`/api/detail?source=${source}&id=${id}`
);
if (!detailResponse.ok) {
throw new Error('获取视频详情失败');
}
const detailData = (await detailResponse.json()) as SearchResult;
setAvailableSources([detailData]);
return [detailData];
} catch (err) {
console.error('获取视频详情失败:', err);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
// 根据搜索词获取全部源信息
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
);
if (!response.ok) {
throw new Error('搜索失败');
}
const data = await response.json();
// 处理搜索结果,根据规则过滤
const results = data.results.filter(
(result: SearchResult) =>
result.title.replaceAll(' ', '').toLowerCase() ===
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
(videoYearRef.current
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
(searchType
? (searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
: true)
);
setAvailableSources(results);
return results;
} catch (err) {
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
setAvailableSources([]);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const initAll = async () => {
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
setError('缺少必要参数');
setLoading(false);
return;
}
setLoading(true);
setLoadingStage(currentSource && currentId ? 'fetching' : 'searching');
setLoadingMessage(
currentSource && currentId
? '🎬 正在获取视频详情...'
: '🔍 正在搜索播放源...'
);
let sourcesInfo = await fetchSourcesData(searchTitle || videoTitle);
if (
currentSource &&
currentId &&
!sourcesInfo.some(
(source) => source.source === currentSource && source.id === currentId
)
) {
sourcesInfo = await fetchSourceDetail(currentSource, currentId);
}
if (sourcesInfo.length === 0) {
setError('未找到匹配结果');
setLoading(false);
return;
}
let detailData: SearchResult = sourcesInfo[0];
// 指定源和id且无需优选
if (currentSource && currentId && !needPreferRef.current) {
const target = sourcesInfo.find(
(source) => source.source === currentSource && source.id === currentId
);
if (target) {
detailData = target;
} else {
setError('未找到匹配结果');
setLoading(false);
return;
}
}
// 未指定源和 id 或需要优选,且开启优选开关
if (
(!currentSource || !currentId || needPreferRef.current) &&
optimizationEnabled
) {
setLoadingStage('preferring');
setLoadingMessage('⚡ 正在优选最佳播放源...');
detailData = await preferBestSource(sourcesInfo);
}
console.log(detailData.source, detailData.id);
setNeedPrefer(false);
setCurrentSource(detailData.source);
setCurrentId(detailData.id);
setVideoYear(detailData.year);
setVideoTitle(detailData.title || videoTitleRef.current);
setVideoCover(detailData.poster);
setVideoDoubanId(detailData.douban_id || 0);
setDetail(detailData);
if (currentEpisodeIndex >= detailData.episodes.length) {
setCurrentEpisodeIndex(0);
}
// 规范URL参数
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', detailData.source);
newUrl.searchParams.set('id', detailData.id);
newUrl.searchParams.set('year', detailData.year);
newUrl.searchParams.set('title', detailData.title);
newUrl.searchParams.delete('prefer');
window.history.replaceState({}, '', newUrl.toString());
setLoadingStage('ready');
setLoadingMessage('✨ 准备就绪,即将开始播放...');
// 加载播放记录
try {
const allRecords = await getAllPlayRecords();
const key = generateStorageKey(detailData.source, detailData.id);
const record = allRecords[key];
if (record) {
const targetIndex = record.index - 1;
const targetTime = record.play_time;
// 更新当前选集索引
if (targetIndex < detailData.episodes.length && targetIndex >= 0) {
setCurrentEpisodeIndex(targetIndex);
currentEpisodeIndexRef.current = targetIndex;
}
// 保存待恢复的播放进度,待播放器就绪后跳转
resumeTimeRef.current = targetTime;
}
} catch (err) {
console.error('读取播放记录失败:', err);
}
// 短暂延迟让用户看到完成状态
setTimeout(() => {
setLoading(false);
}, 1000);
};
initAll();
}, []);
// 跳过片头片尾配置处理
useEffect(() => {
// 仅在初次挂载时检查跳过片头片尾配置
const initSkipConfig = async () => {
if (!currentSource || !currentId) return;
try {
const config = await getSkipConfig(currentSource, currentId);
if (config) {
setSkipConfig(config);
}
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
}
};
initSkipConfig();
}, []);
// 处理换源
const handleSourceChange = async (
newSource: string,
newId: string,
newTitle: string
) => {
try {
// 显示换源加载状态
setVideoLoadingStage('sourceChanging');
setIsVideoLoading(true);
// 记录当前播放进度(仅在同一集数切换时恢复)
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
console.log('换源前当前播放时间:', currentPlayTime);
// 清除前一个历史记录
if (currentSourceRef.current && currentIdRef.current) {
try {
await deletePlayRecord(
currentSourceRef.current,
currentIdRef.current
);
console.log('已清除前一个播放记录');
} catch (err) {
console.error('清除播放记录失败:', err);
}
}
// 清除并设置下一个跳过片头片尾配置
if (currentSourceRef.current && currentIdRef.current) {
try {
await deleteSkipConfig(
currentSourceRef.current,
currentIdRef.current
);
await saveSkipConfig(newSource, newId, skipConfigRef.current);
} catch (err) {
console.error('清除跳过片头片尾配置失败:', err);
}
}
const newDetail = availableSources.find(
(source) => source.source === newSource && source.id === newId
);
if (!newDetail) {
setError('未找到匹配结果');
return;
}
// 尝试跳转到当前正在播放的集数
let targetIndex = currentEpisodeIndex;
// 如果当前集数超出新源的范围,则跳转到第一集
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
targetIndex = 0;
}
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
if (targetIndex !== currentEpisodeIndex) {
resumeTimeRef.current = 0;
} else if (
(!resumeTimeRef.current || resumeTimeRef.current === 0) &&
currentPlayTime > 1
) {
resumeTimeRef.current = currentPlayTime;
}
// 更新URL参数不刷新页面
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', newSource);
newUrl.searchParams.set('id', newId);
newUrl.searchParams.set('year', newDetail.year);
window.history.replaceState({}, '', newUrl.toString());
setVideoTitle(newDetail.title || newTitle);
setVideoYear(newDetail.year);
setVideoCover(newDetail.poster);
setVideoDoubanId(newDetail.douban_id || 0);
setCurrentSource(newSource);
setCurrentId(newId);
setDetail(newDetail);
setCurrentEpisodeIndex(targetIndex);
} catch (err) {
// 隐藏换源加载状态
setIsVideoLoading(false);
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 loadDanmaku = async (episodeId: number) => {
if (!danmakuPluginRef.current) {
console.warn('弹幕插件未初始化');
return;
}
setDanmakuLoading(true);
try {
// 先清空当前弹幕
danmakuPluginRef.current.config({
danmuku: [],
});
danmakuPluginRef.current.load();
// 获取弹幕数据
const comments = await getDanmakuById(episodeId);
if (comments.length === 0) {
console.warn('未获取到弹幕数据');
setDanmakuLoading(false);
return;
}
// 转换弹幕格式
const danmakuData = convertDanmakuFormat(comments);
// 加载弹幕到插件
danmakuPluginRef.current.config({
danmuku: danmakuData,
});
danmakuPluginRef.current.load();
setDanmakuCount(comments.length);
console.log(`弹幕加载成功,共 ${comments.length}`);
// 延迟一下让用户看到弹幕数量
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
console.error('加载弹幕失败:', error);
setDanmakuCount(0);
} finally {
setDanmakuLoading(false);
}
};
// 处理弹幕选择
const handleDanmakuSelect = async (selection: DanmakuSelection) => {
setCurrentDanmakuSelection(selection);
// 保存选择记忆
saveDanmakuMemory(
videoTitleRef.current,
selection.animeId,
selection.episodeId,
selection.animeTitle,
selection.episodeTitle
);
// 获取该动漫的所有剧集列表
try {
const episodesResult = await getEpisodes(selection.animeId);
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
}
} catch (error) {
console.error('获取弹幕剧集列表失败:', error);
}
// 加载弹幕
await loadDanmaku(selection.episodeId);
};
// 自动搜索并加载弹幕
const autoSearchDanmaku = async () => {
const title = videoTitleRef.current;
if (!title) {
console.warn('视频标题为空,无法自动搜索弹幕');
return;
}
// 检查是否有记忆
const memory = loadDanmakuMemory(title);
if (memory) {
console.log('使用记忆的弹幕动漫:', memory.animeTitle);
// 获取该动漫的所有剧集列表
try {
const episodesResult = await getEpisodes(memory.animeId);
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
// 根据当前集数选择对应的弹幕
const currentEp = currentEpisodeIndexRef.current;
const episode =
episodesResult.bangumi.episodes[
Math.min(currentEp, episodesResult.bangumi.episodes.length - 1)
];
if (episode) {
const selection: DanmakuSelection = {
animeId: memory.animeId,
episodeId: episode.episodeId,
animeTitle: memory.animeTitle,
episodeTitle: episode.episodeTitle,
};
setCurrentDanmakuSelection(selection);
// 更新选择记忆
saveDanmakuMemory(
title,
selection.animeId,
selection.episodeId,
selection.animeTitle,
selection.episodeTitle
);
await loadDanmaku(episode.episodeId);
return;
}
}
} catch (error) {
console.error('获取弹幕剧集列表失败:', error);
}
}
// 自动搜索弹幕
setDanmakuLoading(true);
try {
const searchResult = await searchAnime(title);
if (searchResult.success && searchResult.animes.length > 0) {
// 使用第一个搜索结果
const anime = searchResult.animes[0];
// 获取剧集列表
const episodesResult = await getEpisodes(anime.animeId);
if (
episodesResult.success &&
episodesResult.bangumi.episodes.length > 0
) {
// 保存剧集列表
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
// 根据当前集数选择对应的弹幕
const currentEp = currentEpisodeIndexRef.current;
const episode =
episodesResult.bangumi.episodes[
Math.min(currentEp, episodesResult.bangumi.episodes.length - 1)
];
if (episode) {
const selection: DanmakuSelection = {
animeId: anime.animeId,
episodeId: episode.episodeId,
animeTitle: anime.animeTitle,
episodeTitle: episode.episodeTitle,
};
setCurrentDanmakuSelection(selection);
// 保存选择记忆
saveDanmakuMemory(
title,
selection.animeId,
selection.episodeId,
selection.animeTitle,
selection.episodeTitle
);
// 加载弹幕
await loadDanmaku(episode.episodeId);
console.log('自动搜索弹幕成功:', selection);
}
} else {
console.warn('未找到剧集信息');
}
} else {
console.warn('未找到匹配的弹幕');
}
} catch (error) {
console.error('自动搜索弹幕失败:', error);
} finally {
setDanmakuLoading(false);
}
};
// ---------------------------------------------------------------------------
// 键盘快捷键
// ---------------------------------------------------------------------------
// 处理全局快捷键
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(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year,
cover: detailRef.current?.poster || '',
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
total_episodes: detailRef.current?.episodes.length || 1,
play_time: Math.floor(currentTime),
total_time: Math.floor(duration),
save_time: Date.now(),
search_title: searchTitle,
});
lastSaveTimeRef.current = Date.now();
console.log('播放进度已保存:', {
title: videoTitleRef.current,
episode: currentEpisodeIndexRef.current + 1,
year: detailRef.current?.year,
progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`,
});
} catch (err) {
console.error('保存播放进度失败:', err);
}
};
useEffect(() => {
// 页面即将卸载时保存播放进度和清理资源
const handleBeforeUnload = () => {
saveCurrentPlayProgress();
releaseWakeLock();
cleanupPlayer();
};
// 页面可见性变化时保存播放进度和释放 Wake Lock
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
saveCurrentPlayProgress();
releaseWakeLock();
} else if (document.visibilityState === 'visible') {
// 页面重新可见时,如果正在播放则重新请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
}
};
// 添加事件监听器
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);
}
};
}, []);
// ---------------------------------------------------------------------------
// 收藏相关
// ---------------------------------------------------------------------------
// 每当 source 或 id 变化时检查收藏状态
useEffect(() => {
if (!currentSource || !currentId) return;
(async () => {
try {
const fav = await isFavorited(currentSource, currentId);
setFavorited(fav);
} catch (err) {
console.error('检查收藏状态失败:', err);
}
})();
}, [currentSource, currentId]);
// 监听收藏数据更新事件
useEffect(() => {
if (!currentSource || !currentId) return;
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(favorites: Record<string, any>) => {
const key = generateStorageKey(currentSource, currentId);
const isFav = !!favorites[key];
setFavorited(isFav);
}
);
return unsubscribe;
}, [currentSource, currentId]);
// 切换收藏
const handleToggleFavorite = async () => {
if (
!videoTitleRef.current ||
!detailRef.current ||
!currentSourceRef.current ||
!currentIdRef.current
)
return;
try {
if (favorited) {
// 如果已收藏,删除收藏
await deleteFavorite(currentSourceRef.current, currentIdRef.current);
setFavorited(false);
} else {
// 如果未收藏,添加收藏
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year,
cover: detailRef.current?.poster || '',
total_episodes: detailRef.current?.episodes.length || 1,
save_time: Date.now(),
search_title: searchTitle,
});
setFavorited(true);
}
} 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) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
return;
}
// WebKit浏览器或首次创建销毁之前的播放器实例并创建新的
if (artPlayerRef.current) {
cleanupPlayer();
}
try {
// 创建新的播放器实例
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
Artplayer.USE_RAF = true;
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: true,
subtitleOffset: false,
miniProgressBar: false,
mutex: true,
playsInline: true,
autoPlayback: false,
airplay: true,
theme: '#22c55e',
lang: 'zh-cn',
hotkey: false,
fastForward: true,
autoOrientation: true,
lock: true,
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: blockAdEnabledRef.current
? 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;
}
}
});
},
},
// 弹幕插件
plugins: [
artplayerPluginDanmuku({
danmuku: [],
speed: danmakuSettingsRef.current.speed,
opacity: danmakuSettingsRef.current.opacity,
fontSize: danmakuSettingsRef.current.fontSize,
color: '#FFFFFF',
mode: 0,
margin: [danmakuSettingsRef.current.marginTop, danmakuSettingsRef.current.marginBottom],
antiOverlap: true,
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
emitter: false,
filter: (danmu: any) => {
// 应用过滤规则
if (danmakuSettingsRef.current.filterRules.length > 0) {
for (const rule of danmakuSettingsRef.current.filterRules) {
try {
if (new RegExp(rule).test(danmu.text)) {
return false;
}
} catch (e) {
console.error('弹幕过滤规则错误:', e);
}
}
}
return true;
},
}),
],
icons: {
loading:
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
},
settings: [
{
html: '去广告',
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 {
localStorage.setItem('enable_blockad', String(newVal));
if (artPlayerRef.current) {
resumeTimeRef.current = artPlayerRef.current.currentTime;
if (
artPlayerRef.current.video &&
artPlayerRef.current.video.hls
) {
artPlayerRef.current.video.hls.destroy();
}
artPlayerRef.current.destroy();
artPlayerRef.current = null;
}
setBlockAdEnabled(newVal);
} catch (_) {
// ignore
}
return newVal ? '当前开启' : '当前关闭';
},
},
// 弹幕开关
{
name: '弹幕开关',
html: '弹幕开关',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" fill="#ffffff"/><text x="12" y="13" font-size="8" text-anchor="middle" fill="#ffffff">弹</text></svg>',
switch: danmakuSettingsRef.current.enabled,
onSwitch: function (item: any) {
const newSettings = {
...danmakuSettingsRef.current,
enabled: !item.switch,
};
setDanmakuSettings(newSettings);
saveDanmakuSettings(newSettings);
// 切换弹幕显示/隐藏
if (danmakuPluginRef.current) {
if (newSettings.enabled) {
danmakuPluginRef.current.show();
} else {
danmakuPluginRef.current.hide();
}
}
return !item.switch;
},
},
// 弹幕不透明度
{
name: '弹幕不透明度',
html: '弹幕不透明度',
selector: [
{ html: '10%', value: '0.1' },
{ html: '25%', value: '0.25' },
{ html: '50%', value: '0.5' },
{ html: '75%', value: '0.75', default: true },
{ html: '100%', value: '1.0' },
],
onSelect: function (item: any) {
const opacity = parseFloat(item.value);
const newSettings = {
...danmakuSettingsRef.current,
opacity,
};
setDanmakuSettings(newSettings);
saveDanmakuSettings(newSettings);
// 更新弹幕插件配置
if (danmakuPluginRef.current) {
danmakuPluginRef.current.config({ opacity });
}
return item.html;
},
},
// 弹幕字体大小
{
name: '弹幕字体大小',
html: '弹幕字体大小',
selector: [
{ html: '小', value: '20' },
{ html: '中', value: '25', default: true },
{ html: '大', value: '30' },
{ html: '特大', value: '35' },
],
onSelect: function (item: any) {
const fontSize = parseInt(item.value);
const newSettings = {
...danmakuSettingsRef.current,
fontSize,
};
setDanmakuSettings(newSettings);
saveDanmakuSettings(newSettings);
// 更新弹幕插件配置
if (danmakuPluginRef.current) {
danmakuPluginRef.current.config({ fontSize });
}
return item.html;
},
},
// 弹幕速度
{
name: '弹幕速度',
html: '弹幕速度',
selector: [
{ html: '很慢', value: '3' },
{ html: '慢', value: '5', default: true },
{ html: '正常', value: '7' },
{ html: '快', value: '10' },
{ html: '很快', value: '15' },
],
onSelect: function (item: any) {
const speed = parseInt(item.value);
const newSettings = {
...danmakuSettingsRef.current,
speed,
};
setDanmakuSettings(newSettings);
saveDanmakuSettings(newSettings);
// 更新弹幕插件配置
if (danmakuPluginRef.current) {
danmakuPluginRef.current.config({ speed });
}
return item.html;
},
},
...(webGPUSupported ? [
{
name: 'Anime4K超分',
html: 'Anime4K超分',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 18c-4 0-7-3-7-7V9l7-3.5L19 9v4c0 4-3 7-7 7z" fill="#ffffff"/><path d="M10 12l2 2 4-4" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
switch: anime4kEnabledRef.current,
onSwitch: async function (item: any) {
const newVal = !item.switch;
await toggleAnime4K(newVal);
return newVal;
},
},
{
name: '超分模式',
html: '超分模式',
selector: [
{
html: 'ModeA (快速)',
value: 'ModeA',
default: anime4kModeRef.current === 'ModeA',
},
{
html: 'ModeB (平衡)',
value: 'ModeB',
default: anime4kModeRef.current === 'ModeB',
},
{
html: 'ModeC (质量)',
value: 'ModeC',
default: anime4kModeRef.current === 'ModeC',
},
{
html: 'ModeAA (增强快速)',
value: 'ModeAA',
default: anime4kModeRef.current === 'ModeAA',
},
{
html: 'ModeBB (增强平衡)',
value: 'ModeBB',
default: anime4kModeRef.current === 'ModeBB',
},
{
html: 'ModeCA (最高质量)',
value: 'ModeCA',
default: anime4kModeRef.current === 'ModeCA',
},
],
onSelect: async function (item: any) {
await changeAnime4KMode(item.value);
return item.html;
},
},
{
name: '超分倍数',
html: '超分倍数',
selector: [
{
html: '1.5x',
value: '1.5',
default: anime4kScaleRef.current === 1.5,
},
{
html: '2.0x',
value: '2.0',
default: anime4kScaleRef.current === 2.0,
},
{
html: '3.0x',
value: '3.0',
default: anime4kScaleRef.current === 3.0,
},
{
html: '4.0x',
value: '4.0',
default: anime4kScaleRef.current === 4.0,
},
],
onSelect: async function (item: any) {
await changeAnime4KScale(parseFloat(item.value));
return item.html;
},
}
] : []),
{
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
},
{
html: '删除跳过配置',
onClick: function () {
handleSkipConfigChange({
enable: false,
intro_time: 0,
outro_time: 0,
});
return '';
},
},
{
name: '设置片头',
html: '设置片头',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
},
{
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
const outroTime =
-(
artPlayerRef.current?.duration -
artPlayerRef.current?.currentTime
) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
},
},
],
// 控制栏配置
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', async () => {
setError(null);
// 保存弹幕插件引用
if (artPlayerRef.current?.plugins?.artplayerPluginDanmuku) {
danmakuPluginRef.current = artPlayerRef.current.plugins.artplayerPluginDanmuku;
// 根据设置显示或隐藏弹幕
if (danmakuSettingsRef.current.enabled) {
danmakuPluginRef.current.show();
} else {
danmakuPluginRef.current.hide();
}
// 自动搜索并加载弹幕
await autoSearchDanmaku();
}
// 播放器就绪后,如果正在播放则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
});
// 监听播放状态变化,控制 Wake Lock
artPlayerRef.current.on('play', () => {
requestWakeLock();
});
artPlayerRef.current.on('pause', () => {
releaseWakeLock();
saveCurrentPlayProgress();
});
artPlayerRef.current.on('video:ended', () => {
releaseWakeLock();
});
// 如果播放器初始化时已经在播放状态,则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
artPlayerRef.current.on('video:volumechange', () => {
lastVolumeRef.current = artPlayerRef.current.volume;
});
artPlayerRef.current.on('video:ratechange', () => {
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
});
// 监听视频可播放事件,这时恢复播放进度更可靠
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;
}
if (
Math.abs(
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
) > 0.01 &&
isWebkit
) {
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
}
artPlayerRef.current.notice.show = '';
}, 0);
// 隐藏换源加载状态
setIsVideoLoading(false);
});
// 监听视频时间更新事件,实现跳过片头片尾
artPlayerRef.current.on('video:timeupdate', () => {
if (!skipConfigRef.current.enable) return;
const currentTime = artPlayerRef.current.currentTime || 0;
const duration = artPlayerRef.current.duration || 0;
const now = Date.now();
// 限制跳过检查频率为1.5秒一次
if (now - lastSkipCheckRef.current < 1500) return;
lastSkipCheckRef.current = now;
// 跳过片头
if (
skipConfigRef.current.intro_time > 0 &&
currentTime < skipConfigRef.current.intro_time
) {
artPlayerRef.current.currentTime = skipConfigRef.current.intro_time;
artPlayerRef.current.notice.show = `已跳过片头 (${formatTime(
skipConfigRef.current.intro_time
)})`;
}
// 跳过片尾
if (
skipConfigRef.current.outro_time < 0 &&
duration > 0 &&
currentTime >
artPlayerRef.current.duration + skipConfigRef.current.outro_time
) {
if (
currentEpisodeIndexRef.current <
(detailRef.current?.episodes?.length || 1) - 1
) {
handleNextEpisode();
} else {
artPlayerRef.current.pause();
}
artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime(
skipConfigRef.current.outro_time
)})`;
}
});
artPlayerRef.current.on('error', (err: any) => {
console.error('播放器错误:', err);
if (artPlayerRef.current.currentTime > 0) {
return;
}
});
// 监听视频播放结束事件,自动播放下一集
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('video:timeupdate', () => {
const now = Date.now();
let interval = 5000;
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
interval = 20000;
}
if (now - lastSaveTimeRef.current > interval) {
saveCurrentPlayProgress();
lastSaveTimeRef.current = now;
}
});
artPlayerRef.current.on('pause', () => {
saveCurrentPlayProgress();
});
if (artPlayerRef.current?.video) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
} catch (err) {
console.error('创建播放器失败:', err);
setError('播放器初始化失败');
}
}, [Artplayer, Hls, videoUrl, loading, blockAdEnabled]);
// 当组件卸载时清理定时器、Wake Lock 和播放器资源
useEffect(() => {
return () => {
// 清理定时器
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
// 释放 Wake Lock
releaseWakeLock();
// 清理Anime4K
cleanupAnime4K();
// 销毁播放器实例
cleanupPlayer();
};
}, []);
if (loading) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>
{loadingStage === 'searching' && '🔍'}
{loadingStage === 'preferring' && '⚡'}
{loadingStage === 'fetching' && '🎬'}
{loadingStage === 'ready' && '✨'}
</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 进度指示器 */}
<div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'searching' || loadingStage === 'fetching'
? 'bg-green-500 scale-125'
: loadingStage === 'preferring' ||
loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'preferring'
? 'bg-green-500 scale-125'
: loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'ready'
? 'bg-green-500 scale-125'
: 'bg-gray-300'
}`}
></div>
</div>
{/* 进度条 */}
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
<div
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
style={{
width:
loadingStage === 'searching' ||
loadingStage === 'fetching'
? '33%'
: loadingStage === 'preferring'
? '66%'
: '100%',
}}
></div>
</div>
</div>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
{loadingMessage}
</p>
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
</div>
{/* 浮动错误粒子 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() =>
videoTitle
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
: router.back()
}
className='w-full px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl font-medium hover:from-green-600 hover:to-emerald-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
</button>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
>
🔄
</button>
</div>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout activePath='/play'>
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl: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'>
{` > ${
detail?.episodes_titles?.[currentEpisodeIndex] ||
`${currentEpisodeIndex + 1}`
}`}
</span>
)}
</h1>
</div>
{/* 第二行:播放器和选集 */}
<div className='space-y-2'>
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
<div className='hidden lg:flex justify-end'>
<button
onClick={() =>
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
title={
isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'
}
>
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M9 5l7 7-7 7'
/>
</svg>
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
</span>
{/* 精致的状态指示点 */}
<div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
isEpisodeSelectorCollapsed
? 'bg-orange-400 animate-pulse'
: 'bg-green-400'
}`}
></div>
</button>
</div>
<div
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'grid-cols-1'
: 'grid-cols-1 md:grid-cols-4'
}`}
>
{/* 播放器 */}
<div
className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
}`}
>
{/* 播放器容器 */}
<div className='relative w-full h-[300px] lg:flex-1 lg:min-h-0'>
<div
ref={artRef}
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg'
></div>
{/* 换源加载蒙层 */}
{isVideoLoading && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>🎬</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 换源消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-white animate-pulse'>
{videoLoadingStage === 'sourceChanging'
? '🔄 切换播放源...'
: '🔄 视频加载中...'}
</p>
</div>
</div>
</div>
)}
{/* 弹幕加载蒙层 */}
{danmakuLoading && (
<div className='absolute top-0 right-0 m-4 bg-black/80 backdrop-blur-sm rounded-lg px-4 py-2 z-[600] flex items-center gap-2 border border-green-500/30'>
{danmakuCount > 0 ? (
<>
<svg
className='w-4 h-4 text-green-500'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span className='text-sm font-medium text-green-400'>
{danmakuCount}
</span>
</>
) : (
<>
<div className='w-4 h-4 border-2 border-green-500 border-t-transparent rounded-full animate-spin'></div>
<span className='text-sm font-medium text-green-400'>
...
</span>
</>
)}
</div>
)}
</div>
{/* 第三方应用打开按钮 */}
{videoUrl && (
<div className='mt-3 px-2 lg:flex-shrink-0 flex justify-end'>
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-2 border border-gray-200/50 dark:border-gray-700/50 w-auto'>
<div className='flex flex-wrap gap-1.5 justify-end'>
{/* 下载按钮 */}
<button
onClick={(e) => {
e.preventDefault();
const isM3u8 = videoUrl.toLowerCase().includes('.m3u8') || videoUrl.toLowerCase().includes('/m3u8/');
if (isM3u8) {
// M3U8格式 - 复制链接并提示
navigator.clipboard.writeText(videoUrl).then(() => {
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '链接已复制!请使用 FFmpeg、N_m3u8DL-CLI 或 Downie 等工具下载';
}
}).catch(() => {
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '复制失败,请手动复制链接';
}
});
} else {
// 普通视频格式 - 直接下载
const a = document.createElement('a');
a.href = videoUrl;
a.download = `${videoTitle}_第${currentEpisodeIndex + 1}集.mp4`;
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '开始下载...';
}
}
}}
className='group relative flex items-center gap-1 px-2 py-1.5 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-green-400'
title='下载视频'
>
<svg
className='w-4 h-4 flex-shrink-0 text-white'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'
/>
</svg>
<span className='max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-white'>
</span>
</button>
{/* PotPlayer */}
<button
onClick={(e) => {
e.preventDefault();
window.open(`potplayer://${videoUrl}`, '_blank');
}}
className='group relative flex items-center gap-1 px-2 py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600'
title='PotPlayer'
>
<img
src='/players/potplayer.png'
alt='PotPlayer'
className='w-4 h-4 flex-shrink-0'
/>
<span className='max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
PotPlayer
</span>
</button>
{/* VLC */}
<button
onClick={(e) => {
e.preventDefault();
window.open(`vlc://${videoUrl}`, '_blank');
}}
className='group relative flex items-center gap-1 px-2 py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600'
title='VLC'
>
<img
src='/players/vlc.png'
alt='VLC'
className='w-4 h-4 flex-shrink-0'
/>
<span className='max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
VLC
</span>
</button>
{/* MPV */}
<button
onClick={(e) => {
e.preventDefault();
window.open(`mpv://${videoUrl}`, '_blank');
}}
className='group relative flex items-center gap-1 px-2 py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600'
title='MPV'
>
<img
src='/players/mpv.png'
alt='MPV'
className='w-4 h-4 flex-shrink-0'
/>
<span className='max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
MPV
</span>
</button>
{/* MX Player */}
<button
onClick={(e) => {
e.preventDefault();
window.open(
`intent://${videoUrl.replace(
/^https?:\/\//,
''
)}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
videoTitle
)};end`,
'_blank'
);
}}
className='group relative flex items-center gap-1 px-2 py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600'
title='MX Player'
>
<img
src='/players/mxplayer.png'
alt='MX Player'
className='w-4 h-4 flex-shrink-0'
/>
<span className='max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
MX Player
</span>
</button>
{/* nPlayer */}
<button
onClick={(e) => {
e.preventDefault();
window.open(`nplayer-${videoUrl}`, '_blank');
}}
className='group relative flex items-center gap-1 px-2 py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600'
title='nPlayer'
>
<img
src='/players/nplayer.png'
alt='nPlayer'
className='w-4 h-4 flex-shrink-0'
/>
<span className='max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
nPlayer
</span>
</button>
{/* IINA */}
<button
onClick={(e) => {
e.preventDefault();
window.open(
`iina://weblink?url=${encodeURIComponent(
videoUrl
)}`,
'_blank'
);
}}
className='group relative flex items-center gap-1 px-2 py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600'
title='IINA'
>
<img
src='/players/iina.png'
alt='IINA'
className='w-4 h-4 flex-shrink-0'
/>
<span className='max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
IINA
</span>
</button>
</div>
</div>
</div>
)}
</div>
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
<div
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`}
>
<EpisodeSelector
totalEpisodes={totalEpisodes}
episodes_titles={detail?.episodes_titles || []}
value={currentEpisodeIndex + 1}
onChange={handleEpisodeChange}
onSourceChange={handleSourceChange}
currentSource={currentSource}
currentId={currentId}
videoTitle={searchTitle || videoTitle}
availableSources={availableSources}
sourceSearchLoading={sourceSearchLoading}
sourceSearchError={sourceSearchError}
precomputedVideoInfo={precomputedVideoInfo}
onDanmakuSelect={handleDanmakuSelect}
currentDanmakuSelection={currentDanmakuSelection}
/>
</div>
</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='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? (
<>
<img
src={processImageUrl(videoCover)}
alt={videoTitle}
className='w-full h-full object-cover'
/>
{/* 豆瓣链接按钮 */}
{videoDoubanId !== 0 && (
<a
href={`https://movie.douban.com/subject/${videoDoubanId.toString()}`}
target='_blank'
rel='noopener noreferrer'
className='absolute top-3 left-3'
>
<div className='bg-green-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<svg
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
</svg>
</div>
</a>
)}
</>
) : (
<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>
);
}