mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 17:24:41 +08:00
1701 lines
54 KiB
TypeScript
1701 lines
54 KiB
TypeScript
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
|
||
|
||
'use client';
|
||
|
||
import {
|
||
type MediaProviderAdapter,
|
||
AirPlayButton,
|
||
isHLSProvider,
|
||
MediaPlayer,
|
||
MediaProvider,
|
||
} from '@vidstack/react';
|
||
import { AirPlayIcon } from '@vidstack/react/icons';
|
||
import {
|
||
defaultLayoutIcons,
|
||
DefaultVideoLayout,
|
||
} from '@vidstack/react/player/layouts/default';
|
||
import Hls from 'hls.js';
|
||
import { Heart } from 'lucide-react';
|
||
import { useSearchParams } from 'next/navigation';
|
||
import { Suspense } from 'react';
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import React from 'react';
|
||
|
||
import 'vidstack/styles/defaults.css';
|
||
import '@vidstack/react/player/styles/default/theme.css';
|
||
import '@vidstack/react/player/styles/default/layouts/video.css';
|
||
|
||
import {
|
||
deletePlayRecord,
|
||
generateStorageKey,
|
||
getAllPlayRecords,
|
||
isFavorited,
|
||
savePlayRecord,
|
||
toggleFavorite,
|
||
} from '@/lib/db.client';
|
||
import { VideoDetail } from '@/lib/types';
|
||
|
||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||
declare global {
|
||
interface HTMLVideoElement {
|
||
hls?: any;
|
||
}
|
||
}
|
||
|
||
// 搜索结果类型
|
||
interface SearchResult {
|
||
id: string;
|
||
title: string;
|
||
poster: string;
|
||
episodes?: number;
|
||
source: string;
|
||
source_name: string;
|
||
}
|
||
|
||
function PlayPageClient() {
|
||
const searchParams = useSearchParams();
|
||
// @ts-ignore
|
||
const playerRef = useRef<any>(null);
|
||
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 使用 useState 保存视频详情
|
||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||
|
||
// 初始标题:如果 URL 中携带 title 参数,则优先使用
|
||
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
|
||
const [videoCover, setVideoCover] = useState('');
|
||
|
||
const [currentSource, setCurrentSource] = useState(
|
||
searchParams.get('source') || ''
|
||
);
|
||
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
||
const [sourceChanging, setSourceChanging] = useState(false);
|
||
const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引
|
||
|
||
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex);
|
||
const [showEpisodePanel, setShowEpisodePanel] = useState(false);
|
||
const [showSourcePanel, setShowSourcePanel] = useState(false);
|
||
const [showShortcutHint, setShowShortcutHint] = useState(false);
|
||
const [shortcutText, setShortcutText] = useState('');
|
||
const [shortcutDirection, setShortcutDirection] = useState('');
|
||
const shortcutHintTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// 换源相关状态
|
||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||
const [searchLoading, setSearchLoading] = useState(false);
|
||
const [searchError, setSearchError] = useState<string | null>(null);
|
||
const hasSearchedRef = useRef(false);
|
||
|
||
// 视频播放地址
|
||
const [videoUrl, setVideoUrl] = useState('');
|
||
|
||
// 播放进度保存相关
|
||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||
const lastSaveTimeRef = useRef<number>(0);
|
||
|
||
// 总集数:从 detail 中获取,保证随 detail 更新而变化
|
||
const totalEpisodes = detail?.episodes?.length || 0;
|
||
|
||
// 收藏状态
|
||
const [favorited, setFavorited] = useState(false);
|
||
// 是否显示旋转提示(5s 后自动隐藏)
|
||
const [showOrientationTip, setShowOrientationTip] = useState(false);
|
||
const orientationTipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
// 当前是否处于竖屏,用于控制"强制横屏"按钮显隐
|
||
const [isPortrait, setIsPortrait] = useState(
|
||
typeof window !== 'undefined'
|
||
? window.matchMedia('(orientation: portrait)').matches
|
||
: true
|
||
);
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
|
||
// 长按三倍速相关状态
|
||
const [isLongPressing, setIsLongPressing] = useState(false);
|
||
const [showSpeedTip, setShowSpeedTip] = useState(false);
|
||
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const originalPlaybackRateRef = useRef<number>(1);
|
||
const speedTipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||
const resumeTimeRef = useRef<number | null>(null);
|
||
|
||
const currentEpisodeIndexRef = useRef(currentEpisodeIndex);
|
||
const detailRef = useRef<VideoDetail | null>(detail);
|
||
|
||
const currentSourceRef = useRef(currentSource);
|
||
const currentIdRef = useRef(currentId);
|
||
const videoTitleRef = useRef(videoTitle);
|
||
|
||
// 同步最新值到 refs
|
||
useEffect(() => {
|
||
currentSourceRef.current = currentSource;
|
||
currentIdRef.current = currentId;
|
||
detailRef.current = detail;
|
||
currentEpisodeIndexRef.current = currentEpisodeIndex;
|
||
videoTitleRef.current = videoTitle;
|
||
}, [currentSource, currentId, detail, currentEpisodeIndex, videoTitle]);
|
||
|
||
// 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh
|
||
const setVH = useCallback(() => {
|
||
if (typeof window !== 'undefined') {
|
||
document.documentElement.style.setProperty(
|
||
'--vh',
|
||
`${window.innerHeight * 0.01}px`
|
||
);
|
||
}
|
||
}, []);
|
||
|
||
// 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh
|
||
useEffect(() => {
|
||
setVH();
|
||
window.addEventListener('resize', setVH);
|
||
window.addEventListener('orientationchange', setVH);
|
||
return () => {
|
||
window.removeEventListener('resize', setVH);
|
||
window.removeEventListener('orientationchange', setVH);
|
||
};
|
||
}, [setVH]);
|
||
|
||
// 根据 detail 和集数索引更新视频地址(仅当地址真正变化时)
|
||
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);
|
||
playerContainerRef.current?.focus();
|
||
}
|
||
};
|
||
|
||
// 当集数索引变化时自动更新视频地址
|
||
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(() => {
|
||
if (!currentSource || !currentId) {
|
||
setError('缺少必要参数');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const fetchDetail = async () => {
|
||
try {
|
||
const response = await fetch(
|
||
`/api/detail?source=${currentSource}&id=${currentId}`
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error('获取视频详情失败');
|
||
}
|
||
const data = await response.json();
|
||
|
||
// 更新状态保存详情
|
||
setVideoTitle(data.videoInfo.title || videoTitle);
|
||
setVideoCover(data.videoInfo.cover);
|
||
setDetail(data);
|
||
|
||
// 确保集数索引在有效范围内
|
||
if (currentEpisodeIndex >= data.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) {
|
||
setError(err instanceof Error ? err.message : '获取视频详情失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchDetail();
|
||
}, [currentSource]);
|
||
|
||
/* -------------------- 播放记录处理 -------------------- */
|
||
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 onCanPlay = () => {
|
||
console.log('播放器准备就绪');
|
||
setError(null);
|
||
|
||
// 若存在需要恢复的播放进度,则跳转
|
||
if (
|
||
playerRef.current &&
|
||
resumeTimeRef.current &&
|
||
resumeTimeRef.current > 0
|
||
) {
|
||
try {
|
||
playerRef.current.currentTime = resumeTimeRef.current;
|
||
} catch (err) {
|
||
console.warn('恢复播放进度失败:', err);
|
||
}
|
||
resumeTimeRef.current = null;
|
||
}
|
||
};
|
||
|
||
const onEnded = () => {
|
||
const d = detailRef.current;
|
||
const idx = currentEpisodeIndexRef.current;
|
||
if (d && d.episodes && idx < d.episodes.length - 1) {
|
||
setTimeout(() => {
|
||
setCurrentEpisodeIndex(idx + 1);
|
||
}, 1000);
|
||
}
|
||
};
|
||
|
||
const onTimeUpdate = () => {
|
||
const now = Date.now();
|
||
if (now - lastSaveTimeRef.current > 5000) {
|
||
saveCurrentPlayProgress();
|
||
lastSaveTimeRef.current = now;
|
||
}
|
||
};
|
||
|
||
const handlePlayerError = (e: any) => {
|
||
console.error('播放器错误:', e);
|
||
setError('视频播放失败');
|
||
};
|
||
|
||
// 页面卸载和隐藏时保存播放进度
|
||
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, playerRef.current]);
|
||
|
||
// 清理定时器
|
||
useEffect(() => {
|
||
return () => {
|
||
if (shortcutHintTimeoutRef.current) {
|
||
clearTimeout(shortcutHintTimeoutRef.current);
|
||
}
|
||
if (saveIntervalRef.current) {
|
||
clearInterval(saveIntervalRef.current);
|
||
}
|
||
if (longPressTimeoutRef.current) {
|
||
clearTimeout(longPressTimeoutRef.current);
|
||
}
|
||
if (speedTipTimeoutRef.current) {
|
||
clearTimeout(speedTipTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 当视频标题变化时重置搜索状态
|
||
useEffect(() => {
|
||
if (videoTitle) {
|
||
hasSearchedRef.current = false;
|
||
setSearchResults([]);
|
||
setSearchError(null);
|
||
}
|
||
}, [videoTitle]);
|
||
|
||
// 添加键盘事件监听器 (使用 refs 避免重复绑定)
|
||
useEffect(() => {
|
||
document.addEventListener('keydown', handleKeyboardShortcuts);
|
||
return () => {
|
||
document.removeEventListener('keydown', handleKeyboardShortcuts);
|
||
};
|
||
}, []);
|
||
|
||
// 处理选集切换
|
||
const handleEpisodeChange = (episodeIndex: number) => {
|
||
if (episodeIndex >= 0 && episodeIndex < totalEpisodes) {
|
||
// 在更换集数前保存当前播放进度
|
||
if (playerRef.current && !playerRef.current.paused) {
|
||
saveCurrentPlayProgress();
|
||
}
|
||
setCurrentEpisodeIndex(episodeIndex);
|
||
setShowEpisodePanel(false);
|
||
}
|
||
};
|
||
|
||
// 处理下一集
|
||
const handleNextEpisode = () => {
|
||
const d = detailRef.current;
|
||
const idx = currentEpisodeIndexRef.current;
|
||
if (d && d.episodes && idx < d.episodes.length - 1) {
|
||
if (playerRef.current && !playerRef.current.paused) {
|
||
saveCurrentPlayProgress();
|
||
}
|
||
setCurrentEpisodeIndex(idx + 1);
|
||
}
|
||
};
|
||
|
||
// 处理上一集
|
||
const handlePreviousEpisode = () => {
|
||
const idx = currentEpisodeIndexRef.current;
|
||
if (detailRef.current && idx > 0) {
|
||
if (playerRef.current && !playerRef.current.paused) {
|
||
saveCurrentPlayProgress();
|
||
}
|
||
setCurrentEpisodeIndex(idx - 1);
|
||
}
|
||
};
|
||
|
||
// 搜索视频源
|
||
const handleSearch = async (query: string) => {
|
||
if (!query.trim()) {
|
||
setSearchResults([]);
|
||
return;
|
||
}
|
||
|
||
setSearchLoading(true);
|
||
setSearchError(null);
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/search?q=${encodeURIComponent(query)}`
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error('搜索失败');
|
||
}
|
||
const data = await response.json();
|
||
|
||
// 处理搜索结果:每个数据源只展示一个,优先展示与title同名的结果
|
||
const processedResults: SearchResult[] = [];
|
||
const sourceMap = new Map<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 exactMatch = results.find(
|
||
(result) => result.title.toLowerCase() === videoTitle.toLowerCase()
|
||
);
|
||
|
||
// 如果没有完全匹配,选择第一个结果
|
||
const selectedResult = exactMatch || results[0];
|
||
processedResults.push(selectedResult);
|
||
});
|
||
|
||
setSearchResults(processedResults);
|
||
} catch (err) {
|
||
setSearchError(err instanceof Error ? err.message : '搜索失败');
|
||
setSearchResults([]);
|
||
} finally {
|
||
setSearchLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理换源
|
||
const handleSourceChange = async (
|
||
newSource: string,
|
||
newId: string,
|
||
newTitle: string
|
||
) => {
|
||
try {
|
||
// 显示换源加载状态
|
||
setSourceChanging(true);
|
||
setError(null);
|
||
|
||
// 清除前一个历史记录
|
||
if (currentSource && currentId) {
|
||
try {
|
||
await deletePlayRecord(currentSource, currentId);
|
||
console.log('已清除前一个播放记录');
|
||
} catch (err) {
|
||
console.error('清除播放记录失败:', err);
|
||
}
|
||
}
|
||
|
||
// 获取新源的详情
|
||
const response = await fetch(
|
||
`/api/detail?source=${newSource}&id=${newId}`
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error('获取新源详情失败');
|
||
}
|
||
const newDetail = await response.json();
|
||
|
||
// 尝试跳转到当前正在播放的集数
|
||
let targetIndex = currentEpisodeIndex;
|
||
|
||
// 如果当前集数超出新源的范围,则跳转到第一集
|
||
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
|
||
targetIndex = 0;
|
||
}
|
||
|
||
// 更新URL参数(不刷新页面)
|
||
const newUrl = new URL(window.location.href);
|
||
newUrl.searchParams.set('source', newSource);
|
||
newUrl.searchParams.set('id', newId);
|
||
window.history.replaceState({}, '', newUrl.toString());
|
||
|
||
// 关闭换源面板
|
||
setShowSourcePanel(false);
|
||
|
||
setVideoTitle(newDetail.videoInfo.title || newTitle);
|
||
setVideoCover(newDetail.videoInfo.cover);
|
||
setCurrentSource(newSource);
|
||
setCurrentId(newId);
|
||
setDetail(newDetail);
|
||
setCurrentEpisodeIndex(targetIndex);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '换源失败');
|
||
} finally {
|
||
setSourceChanging(false);
|
||
}
|
||
};
|
||
|
||
// 处理播放源面板展开
|
||
const handleSourcePanelOpen = () => {
|
||
setShowSourcePanel(true);
|
||
playerContainerRef.current?.focus();
|
||
// 只在第一次展开时搜索
|
||
if (videoTitle && !hasSearchedRef.current) {
|
||
handleSearch(videoTitle);
|
||
hasSearchedRef.current = true;
|
||
}
|
||
};
|
||
|
||
// 显示快捷键提示
|
||
const displayShortcutHint = (text: string, direction: string) => {
|
||
setShortcutText(text);
|
||
setShortcutDirection(direction);
|
||
setShowShortcutHint(true);
|
||
|
||
// 清除之前的超时
|
||
if (shortcutHintTimeoutRef.current) {
|
||
clearTimeout(shortcutHintTimeoutRef.current);
|
||
}
|
||
|
||
// 2秒后隐藏
|
||
shortcutHintTimeoutRef.current = setTimeout(() => {
|
||
setShowShortcutHint(false);
|
||
}, 2000);
|
||
};
|
||
|
||
// 处理全局快捷键
|
||
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
|
||
// 忽略输入框中的按键事件
|
||
if (
|
||
(e.target as HTMLElement).tagName === 'INPUT' ||
|
||
(e.target as HTMLElement).tagName === 'TEXTAREA'
|
||
)
|
||
return;
|
||
|
||
// Alt + 左箭头 = 上一集
|
||
if (e.altKey && e.key === 'ArrowLeft') {
|
||
if (detailRef.current && currentEpisodeIndexRef.current > 0) {
|
||
handlePreviousEpisode();
|
||
displayShortcutHint('上一集', 'left');
|
||
} else {
|
||
displayShortcutHint('已经是第一集了', 'error');
|
||
}
|
||
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();
|
||
displayShortcutHint('下一集', 'right');
|
||
} else {
|
||
displayShortcutHint('已经是最后一集了', 'error');
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
|
||
if (!playerRef.current) return;
|
||
const player = playerRef.current;
|
||
|
||
// 左箭头 = 快退
|
||
if (!e.altKey && e.key === 'ArrowLeft') {
|
||
if (player.currentTime > 5) {
|
||
player.currentTime -= 10;
|
||
displayShortcutHint('快退', 'left');
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
|
||
// 右箭头 = 快进
|
||
if (!e.altKey && e.key === 'ArrowRight') {
|
||
if (player.currentTime < player.duration - 5) {
|
||
player.currentTime += 10;
|
||
displayShortcutHint('快进', 'right');
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
|
||
// 上箭头 = 音量+
|
||
if (e.key === 'ArrowUp') {
|
||
if (player.volume < 1) {
|
||
player.volume += 0.1;
|
||
displayShortcutHint(`音量 ${Math.round(player.volume * 100)}`, 'up');
|
||
} else {
|
||
displayShortcutHint('音量 100', 'up');
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
|
||
// 下箭头 = 音量-
|
||
if (e.key === 'ArrowDown') {
|
||
if (player.volume > 0) {
|
||
player.volume -= 0.1;
|
||
displayShortcutHint(`音量 ${Math.round(player.volume * 100)}`, 'down');
|
||
} else {
|
||
displayShortcutHint('音量 0', 'down');
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
|
||
// 空格 = 播放/暂停
|
||
if (e.key === ' ') {
|
||
if (playerRef.current.paused) {
|
||
playerRef.current.play();
|
||
displayShortcutHint('播放', 'play');
|
||
} else {
|
||
playerRef.current.pause();
|
||
displayShortcutHint('暂停', 'pause');
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
|
||
// f 键 = 切换全屏
|
||
if (e.key === 'f' || e.key === 'F') {
|
||
if (player.state.fullscreen) {
|
||
player.exitFullscreen();
|
||
} else {
|
||
player.enterFullscreen();
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
// 保存播放进度的函数
|
||
const saveCurrentPlayProgress = async () => {
|
||
if (
|
||
!playerRef.current ||
|
||
!currentSourceRef.current ||
|
||
!currentIdRef.current ||
|
||
!videoTitleRef.current ||
|
||
!detailRef.current?.videoInfo?.source_name
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const player = playerRef.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?.videoInfo.source_name,
|
||
cover: videoCover,
|
||
index: currentEpisodeIndexRef.current + 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);
|
||
}
|
||
};
|
||
|
||
// 每当 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?.videoInfo.source_name || '',
|
||
cover: videoCover || '',
|
||
total_episodes: totalEpisodes || 1,
|
||
save_time: Date.now(),
|
||
});
|
||
setFavorited(newState);
|
||
} catch (err) {
|
||
console.error('切换收藏失败:', err);
|
||
}
|
||
};
|
||
|
||
// 监听屏幕方向变化:竖屏时显示提示蒙层
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
const mql = window.matchMedia('(orientation: portrait)');
|
||
|
||
const update = () => {
|
||
const portrait = mql.matches;
|
||
|
||
// 更新竖屏状态
|
||
setIsPortrait(portrait);
|
||
|
||
// 在进入竖屏时显示提示,5 秒后自动隐藏
|
||
if (portrait) {
|
||
setShowOrientationTip(true);
|
||
if (orientationTipTimeoutRef.current) {
|
||
clearTimeout(orientationTipTimeoutRef.current);
|
||
}
|
||
orientationTipTimeoutRef.current = setTimeout(() => {
|
||
setShowOrientationTip(false);
|
||
}, 5000);
|
||
} else {
|
||
setShowOrientationTip(false);
|
||
if (orientationTipTimeoutRef.current) {
|
||
clearTimeout(orientationTipTimeoutRef.current);
|
||
orientationTipTimeoutRef.current = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 初始执行一次
|
||
update();
|
||
|
||
if (mql.addEventListener) {
|
||
mql.addEventListener('change', update);
|
||
} else {
|
||
// Safari < 14
|
||
// @ts-ignore
|
||
mql.addListener(update);
|
||
}
|
||
|
||
return () => {
|
||
if (mql.removeEventListener) {
|
||
mql.removeEventListener('change', update);
|
||
} else {
|
||
// @ts-ignore
|
||
mql.removeListener(update);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 用户点击悬浮按钮 -> 请求全屏并锁定横屏
|
||
const handleForceLandscape = async () => {
|
||
try {
|
||
const el: any = document.documentElement;
|
||
if (el.requestFullscreen) {
|
||
await el.requestFullscreen();
|
||
} else if (el.webkitRequestFullscreen) {
|
||
el.webkitRequestFullscreen();
|
||
}
|
||
|
||
if (screen.orientation && (screen.orientation as any).lock) {
|
||
await (screen.orientation as any).lock('landscape');
|
||
}
|
||
} catch (err) {
|
||
console.warn('强制横屏失败:', err);
|
||
}
|
||
};
|
||
|
||
// 进入/退出全屏时锁定/解锁横屏(保持原有逻辑)
|
||
useEffect(() => {
|
||
if (typeof document === 'undefined') return;
|
||
|
||
const lockLandscape = async () => {
|
||
try {
|
||
// 某些浏览器需要在用户手势触发后才能调用
|
||
if (screen.orientation && (screen.orientation as any).lock) {
|
||
await (screen.orientation as any).lock('landscape');
|
||
}
|
||
} catch (err) {
|
||
console.warn('横屏锁定失败:', err);
|
||
}
|
||
};
|
||
|
||
const unlock = () => {
|
||
try {
|
||
if (screen.orientation && (screen.orientation as any).unlock) {
|
||
(screen.orientation as any).unlock();
|
||
}
|
||
} catch (_) {
|
||
// 忽略解锁屏幕方向失败的错误
|
||
}
|
||
};
|
||
|
||
const player = playerRef.current;
|
||
if (!player) return;
|
||
|
||
return player.subscribe(({ fullscreen }: any) => {
|
||
setIsFullscreen(fullscreen);
|
||
if (fullscreen) {
|
||
lockLandscape();
|
||
} else {
|
||
unlock();
|
||
// 强制重绘逻辑,解决退出全屏的黑屏/白边问题
|
||
const playerEl = playerRef.current?.el as HTMLElement | null;
|
||
if (playerEl) {
|
||
playerEl.style.display = 'none';
|
||
setTimeout(() => {
|
||
playerEl.style.display = '';
|
||
setVH();
|
||
}, 0);
|
||
}
|
||
}
|
||
});
|
||
}, [playerRef.current, setVH]);
|
||
|
||
useEffect(() => {
|
||
// 播放页挂载时,锁定页面滚动并消除 body 100vh 带来的额外空白
|
||
if (typeof document === 'undefined') return;
|
||
|
||
const { style: bodyStyle } = document.body;
|
||
const { style: htmlStyle } = document.documentElement;
|
||
|
||
// 记录原始样式,供卸载时恢复
|
||
const originalBodyMinH = bodyStyle.minHeight;
|
||
const originalBodyH = bodyStyle.height;
|
||
const originalBodyOverflow = bodyStyle.overflow;
|
||
const originalHtmlOverflow = htmlStyle.overflow;
|
||
|
||
bodyStyle.minHeight = '0';
|
||
bodyStyle.height = 'auto';
|
||
bodyStyle.overflow = 'hidden';
|
||
htmlStyle.overflow = 'hidden';
|
||
|
||
return () => {
|
||
bodyStyle.minHeight = originalBodyMinH;
|
||
bodyStyle.height = originalBodyH;
|
||
bodyStyle.overflow = originalBodyOverflow;
|
||
htmlStyle.overflow = originalHtmlOverflow;
|
||
};
|
||
}, []);
|
||
|
||
// 长按三倍速处理函数
|
||
const handleTouchStart = (e: TouchEvent) => {
|
||
// 防止在控制栏区域触发
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.closest('.vds-controls') ||
|
||
target.closest('.vds-context-menu') ||
|
||
target.closest('.vds-lazy-gesture')
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// 仅在播放时触发
|
||
if (!playerRef.current?.playing) {
|
||
return;
|
||
}
|
||
|
||
// 清除之前的定时器
|
||
if (longPressTimeoutRef.current) {
|
||
clearTimeout(longPressTimeoutRef.current);
|
||
}
|
||
|
||
// 设置长按检测定时器(500ms)
|
||
longPressTimeoutRef.current = setTimeout(() => {
|
||
if (playerRef.current) {
|
||
// 保存原始播放速度
|
||
originalPlaybackRateRef.current = playerRef.current.playbackRate;
|
||
|
||
// 设置三倍速
|
||
playerRef.current.playbackRate = 3;
|
||
|
||
// 更新状态
|
||
setIsLongPressing(true);
|
||
setShowSpeedTip(true);
|
||
|
||
// 触发震动反馈(如果支持)
|
||
if (navigator.vibrate) {
|
||
navigator.vibrate(50);
|
||
}
|
||
|
||
// 3秒后自动隐藏提示
|
||
if (speedTipTimeoutRef.current) {
|
||
clearTimeout(speedTipTimeoutRef.current);
|
||
}
|
||
speedTipTimeoutRef.current = setTimeout(() => {
|
||
setShowSpeedTip(false);
|
||
}, 3000);
|
||
}
|
||
}, 500);
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
// 清除长按检测定时器
|
||
if (longPressTimeoutRef.current) {
|
||
clearTimeout(longPressTimeoutRef.current);
|
||
longPressTimeoutRef.current = null;
|
||
}
|
||
|
||
// 如果正在长按,恢复原始播放速度
|
||
if (isLongPressing && playerRef.current) {
|
||
playerRef.current.playbackRate = originalPlaybackRateRef.current;
|
||
setIsLongPressing(false);
|
||
setShowSpeedTip(false);
|
||
|
||
// 清除提示定时器
|
||
if (speedTipTimeoutRef.current) {
|
||
clearTimeout(speedTipTimeoutRef.current);
|
||
speedTipTimeoutRef.current = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 添加触摸事件监听器
|
||
useEffect(() => {
|
||
const playerEl = playerRef.current?.el;
|
||
if (!playerEl) return;
|
||
|
||
const disableContextMenu = (e: Event) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
};
|
||
|
||
playerEl.addEventListener('touchstart', handleTouchStart, {
|
||
passive: true,
|
||
});
|
||
playerEl.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||
playerEl.addEventListener('touchcancel', handleTouchEnd, { passive: true });
|
||
playerEl.addEventListener('contextmenu', disableContextMenu);
|
||
|
||
return () => {
|
||
playerEl.removeEventListener('touchstart', handleTouchStart);
|
||
playerEl.removeEventListener('touchend', handleTouchEnd);
|
||
playerEl.removeEventListener('touchcancel', handleTouchEnd);
|
||
playerEl.removeEventListener('contextmenu', disableContextMenu);
|
||
};
|
||
}, [playerRef.current, isLongPressing]);
|
||
|
||
/* -------------------- 设置 meta theme-color 为纯黑 -------------------- */
|
||
useEffect(() => {
|
||
if (typeof document === 'undefined') return;
|
||
|
||
// 查找或创建 meta[name="theme-color"]
|
||
let metaTag = document.querySelector(
|
||
'meta[name="theme-color"]'
|
||
) as HTMLMetaElement | null;
|
||
|
||
if (!metaTag) {
|
||
metaTag = document.createElement('meta');
|
||
metaTag.setAttribute('name', 'theme-color');
|
||
document.head.appendChild(metaTag);
|
||
}
|
||
|
||
// 记录原始颜色,并设置为纯黑
|
||
metaTag.setAttribute('content', '#000000');
|
||
|
||
// 卸载时恢复
|
||
return () => {
|
||
metaTag?.setAttribute('content', '#e6f3fb');
|
||
};
|
||
}, []);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className='min-h-[100dvh] bg-black flex items-center justify-center overflow-hidden overscroll-contain'>
|
||
<div className='text-white text-center'>
|
||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4'></div>
|
||
<div className='text-lg'>加载中...</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className='min-h-[100dvh] bg-black flex items-center justify-center overflow-hidden overscroll-contain'>
|
||
<div className='text-white text-center'>
|
||
<div className='text-xl font-semibold mb-4 text-red-400'>
|
||
播放失败
|
||
</div>
|
||
<div className='text-base mb-6'>{error}</div>
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className='px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg transition-colors'
|
||
>
|
||
返回
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!detail) {
|
||
return (
|
||
<div className='min-h-[100dvh] bg-black flex items-center justify-center overflow-hidden overscroll-contain'>
|
||
<div className='text-white text-center'>
|
||
<div className='text-xl font-semibold mb-4'>未找到视频</div>
|
||
<button
|
||
onClick={() => window.history.back()}
|
||
className='px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg transition-colors'
|
||
>
|
||
返回
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const PlayerUITopbar = ({
|
||
videoTitle,
|
||
favorited,
|
||
totalEpisodes,
|
||
currentEpisodeIndex,
|
||
sourceName,
|
||
onToggleFavorite,
|
||
onOpenSourcePanel,
|
||
isFullscreen,
|
||
}: {
|
||
videoTitle: string;
|
||
favorited: boolean;
|
||
totalEpisodes: number;
|
||
currentEpisodeIndex: number;
|
||
sourceName: string;
|
||
onToggleFavorite: () => void;
|
||
onOpenSourcePanel: () => void;
|
||
isFullscreen: boolean;
|
||
}) => {
|
||
return (
|
||
<div
|
||
data-top-bar
|
||
className='absolute top-0 left-0 right-0 transition-opacity duration-300 z-10 opacity-0 pointer-events-none group-data-[controls]:opacity-100 group-data-[controls]:pointer-events-auto'
|
||
>
|
||
<div className='bg-black/60 backdrop-blur-sm px-0 sm:px-6 py-4 relative flex items-center sm:justify-center'>
|
||
{/* 返回按钮 */}
|
||
{!isFullscreen && (
|
||
<button
|
||
onClick={() => {
|
||
if (playerRef.current?.fullscreen) {
|
||
playerRef.current?.exitFullscreen();
|
||
}
|
||
window.history.back();
|
||
}}
|
||
className='absolute left-0 sm:left-6 text-white hover:text-gray-300 transition-colors p-2'
|
||
>
|
||
<svg
|
||
width='24'
|
||
height='24'
|
||
viewBox='0 0 24 24'
|
||
fill='none'
|
||
xmlns='http://www.w3.org/2000/svg'
|
||
>
|
||
<path
|
||
d='M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z'
|
||
fill='currentColor'
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* 中央标题及集数信息 */}
|
||
<div
|
||
className={`sm:text-center ${
|
||
!isFullscreen ? 'ml-10 sm:ml-0 text-left' : 'w-full text-center'
|
||
}`}
|
||
>
|
||
<div className='flex items-center justify-center gap-2 max-w-xs mx-auto'>
|
||
<span className='text-white font-semibold text-lg truncate'>
|
||
{videoTitle}
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onToggleFavorite();
|
||
}}
|
||
className='flex-shrink-0'
|
||
>
|
||
<FavoriteIcon filled={favorited} />
|
||
</button>
|
||
</div>
|
||
|
||
{totalEpisodes > 1 && (
|
||
<div className='text-gray-300 text-sm mt-0.5'>
|
||
第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 数据源徽章放置在右侧,不影响标题居中 */}
|
||
{sourceName && (
|
||
<span
|
||
className='absolute right-2 sm:right-6 text-gray-300 text-sm border border-gray-500/60 px-2 py-[1px] rounded cursor-pointer hover:bg-gray-600/30 transition-colors'
|
||
onClick={onOpenSourcePanel}
|
||
>
|
||
{sourceName}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const onProviderChange = (provider: MediaProviderAdapter | null) => {
|
||
class extendedHls extends Hls {
|
||
attachMedia(media: HTMLMediaElement): void {
|
||
super.attachMedia(media);
|
||
|
||
media.disableRemotePlayback = false;
|
||
media.autoplay = true;
|
||
}
|
||
}
|
||
if (isHLSProvider(provider)) {
|
||
provider.library = extendedHls;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={playerContainerRef}
|
||
tabIndex={-1}
|
||
className='bg-black fixed inset-0 overflow-hidden overscroll-contain'
|
||
style={{ height: 'calc(var(--vh, 1vh) * 100)' }}
|
||
>
|
||
{/* 竖屏提示蒙层 */}
|
||
{showOrientationTip && (
|
||
<div className='fixed bottom-16 left-1/2 -translate-x-1/2 z-[190] flex items-center px-4 py-2 rounded bg-black/70 text-white space-x-2 pointer-events-none backdrop-blur-sm'>
|
||
<svg
|
||
className='w-5 h-5'
|
||
fill='none'
|
||
stroke='currentColor'
|
||
viewBox='0 0 24 24'
|
||
>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth={2}
|
||
d='M12 4v16m8-8H4'
|
||
/>
|
||
</svg>
|
||
<span className='text-sm'>请横屏观看</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 强制横屏按钮:仅在移动端竖屏时显示 */}
|
||
{isPortrait && (
|
||
<button
|
||
onClick={handleForceLandscape}
|
||
className='fixed bottom-16 left-4 z-[85] w-10 h-10 rounded-full bg-gray-800 text-white flex items-center justify-center md:hidden'
|
||
>
|
||
<svg
|
||
className='w-6 h-6'
|
||
fill='none'
|
||
stroke='currentColor'
|
||
viewBox='0 0 24 24'
|
||
>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth={2}
|
||
d='M3 18v-6a3 3 0 013-3h12M21 6v6a3 3 0 01-3 3H6'
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* 换源加载遮罩 */}
|
||
{sourceChanging && (
|
||
<div className='fixed inset-0 bg-black/50 z-[200] flex items-center justify-center'>
|
||
<div className='text-white text-center'>
|
||
<div className='animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mx-auto mb-2'></div>
|
||
<div className='text-sm'>换源中...</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 播放器容器 */}
|
||
<MediaPlayer
|
||
ref={playerRef}
|
||
className='w-full h-full group'
|
||
src={videoUrl}
|
||
poster={videoCover}
|
||
playsInline
|
||
autoPlay
|
||
crossOrigin='anonymous'
|
||
controlsDelay={3000}
|
||
keyDisabled
|
||
onCanPlay={onCanPlay}
|
||
onEnded={onEnded}
|
||
onTimeUpdate={onTimeUpdate}
|
||
onPause={saveCurrentPlayProgress}
|
||
onError={handlePlayerError}
|
||
onProviderChange={onProviderChange}
|
||
>
|
||
<MediaProvider />
|
||
<PlayerUITopbar
|
||
videoTitle={videoTitle}
|
||
favorited={favorited}
|
||
totalEpisodes={totalEpisodes}
|
||
currentEpisodeIndex={currentEpisodeIndex}
|
||
sourceName={detail?.videoInfo.source_name || ''}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
onOpenSourcePanel={handleSourcePanelOpen}
|
||
isFullscreen={isFullscreen}
|
||
/>
|
||
<DefaultVideoLayout
|
||
icons={defaultLayoutIcons}
|
||
slots={{
|
||
googleCastButton: null,
|
||
pipButton: null,
|
||
settingsMenu: null,
|
||
muteButton: null, // 隐藏静音按钮
|
||
volumeSlider: null, // 隐藏音量条
|
||
airPlayButton: null, // 隐藏默认 AirPlay 按钮
|
||
beforeCurrentTime:
|
||
totalEpisodes > 1 ? (
|
||
// 下一集按钮放在时间显示前
|
||
<button
|
||
className='vds-button mr-2'
|
||
onClick={handleNextEpisode}
|
||
aria-label='Next Episode'
|
||
>
|
||
<svg
|
||
className='vds-icon'
|
||
viewBox='0 0 32 32'
|
||
xmlns='http://www.w3.org/2000/svg'
|
||
>
|
||
<path
|
||
d='M6 24l12-8L6 8v16zM22 8v16h3V8h-3z'
|
||
fill='currentColor'
|
||
/>
|
||
</svg>
|
||
</button>
|
||
) : null,
|
||
beforeFullscreenButton: (
|
||
<>
|
||
{totalEpisodes > 1 && (
|
||
<button
|
||
className='vds-button mr-2'
|
||
onClick={() => {
|
||
setShowEpisodePanel(true);
|
||
playerContainerRef.current?.focus();
|
||
}}
|
||
>
|
||
选集
|
||
</button>
|
||
)}
|
||
<PlaybackRateButton playerRef={playerRef} />
|
||
{/* 自定义 AirPlay 按钮 */}
|
||
<AirPlayButton className='vds-button'>
|
||
<AirPlayIcon className='vds-icon' />
|
||
</AirPlayButton>
|
||
</>
|
||
),
|
||
}}
|
||
/>
|
||
|
||
{/* 选集侧拉面板 */}
|
||
{totalEpisodes > 1 && (
|
||
<div data-episode-panel>
|
||
{/* 遮罩层 */}
|
||
{showEpisodePanel && (
|
||
<div
|
||
className='fixed inset-0 bg-black/50 z-[110]'
|
||
onClick={() => {
|
||
setShowEpisodePanel(false);
|
||
playerContainerRef.current?.focus();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 侧拉面板 */}
|
||
<div
|
||
className={`fixed top-0 right-0 h-full w-full mobile-landscape:w-1/2 md:w-80 bg-black/40 backdrop-blur-xl z-[110] transform transition-transform duration-300 ${
|
||
showEpisodePanel ? 'translate-x-0' : 'translate-x-full'
|
||
}`}
|
||
>
|
||
<div className='p-6 h-full flex flex-col'>
|
||
<div className='flex items-center justify-between mb-6'>
|
||
<h3 className='text-white text-xl font-semibold'>选集列表</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowEpisodePanel(false);
|
||
playerContainerRef.current?.focus();
|
||
}}
|
||
className='text-gray-400 hover:text-white transition-colors'
|
||
>
|
||
<svg
|
||
className='w-6 h-6'
|
||
fill='none'
|
||
stroke='currentColor'
|
||
viewBox='0 0 24 24'
|
||
>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth={2}
|
||
d='M6 18L18 6M6 6l12 12'
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className='text-gray-300 text-sm mb-4'>
|
||
当前: 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集
|
||
</div>
|
||
|
||
<div className='flex-1 overflow-y-auto'>
|
||
<div className='grid grid-cols-4 gap-3'>
|
||
{Array.from({ length: totalEpisodes }, (_, idx) => (
|
||
<button
|
||
key={idx}
|
||
onClick={() => handleEpisodeChange(idx)}
|
||
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||
idx === currentEpisodeIndex
|
||
? 'bg-green-500 text-white'
|
||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||
}`}
|
||
>
|
||
{idx + 1}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 换源侧拉面板 */}
|
||
<div data-source-panel>
|
||
{/* 遮罩层 */}
|
||
{showSourcePanel && (
|
||
<div
|
||
className='fixed inset-0 bg-black/50 z-[110]'
|
||
onClick={() => {
|
||
setShowSourcePanel(false);
|
||
playerContainerRef.current?.focus();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 侧拉面板 */}
|
||
<div
|
||
className={`fixed top-0 right-0 h-full w-full mobile-landscape:w-1/2 md:w-96 bg-black/40 backdrop-blur-xl z-[110] transform transition-transform duration-300 ${
|
||
showSourcePanel ? 'translate-x-0' : 'translate-x-full'
|
||
}`}
|
||
>
|
||
<div className='p-6 h-full flex flex-col'>
|
||
<div className='flex items-center justify-between mb-6'>
|
||
<h3 className='text-white text-xl font-semibold'>播放源</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowSourcePanel(false);
|
||
playerContainerRef.current?.focus();
|
||
}}
|
||
className='text-gray-400 hover:text-white transition-colors'
|
||
>
|
||
<svg
|
||
className='w-6 h-6'
|
||
fill='none'
|
||
stroke='currentColor'
|
||
viewBox='0 0 24 24'
|
||
>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth={2}
|
||
d='M6 18L18 6M6 6l12 12'
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 搜索结果 */}
|
||
<div className='flex-1 overflow-y-auto'>
|
||
{searchLoading && (
|
||
<div className='flex items-center justify-center py-8'>
|
||
<div className='animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500'></div>
|
||
<span className='text-gray-300 ml-3'>搜索中...</span>
|
||
</div>
|
||
)}
|
||
|
||
{searchError && (
|
||
<div className='text-red-400 text-center py-4'>
|
||
{searchError}
|
||
</div>
|
||
)}
|
||
|
||
{!searchLoading &&
|
||
!searchError &&
|
||
searchResults.length === 0 && (
|
||
<div className='text-gray-400 text-center py-8'>
|
||
未找到相关视频源
|
||
</div>
|
||
)}
|
||
|
||
{!searchLoading && !searchError && searchResults.length > 0 && (
|
||
<div className='grid grid-cols-2 gap-4'>
|
||
{[
|
||
...searchResults.filter(
|
||
(r) =>
|
||
r.source === currentSource &&
|
||
String(r.id) === String(currentId)
|
||
),
|
||
...searchResults.filter(
|
||
(r) =>
|
||
!(
|
||
r.source === currentSource &&
|
||
String(r.id) === String(currentId)
|
||
)
|
||
),
|
||
].map((result) => {
|
||
const isCurrentSource =
|
||
result.source === currentSource &&
|
||
String(result.id) === String(currentId);
|
||
return (
|
||
<div
|
||
key={`${result.source}-${result.id}`}
|
||
className={`rounded-lg transition-colors border-2 ${
|
||
isCurrentSource
|
||
? 'border-green-500 bg-green-500/20 cursor-not-allowed opacity-60'
|
||
: 'border-transparent bg-transparent hover:bg-gray-600/30 cursor-pointer'
|
||
}`}
|
||
onClick={() =>
|
||
!isCurrentSource &&
|
||
handleSourceChange(
|
||
result.source,
|
||
result.id,
|
||
result.title
|
||
)
|
||
}
|
||
>
|
||
{/* 视频封面 */}
|
||
<div className='aspect-[2/3] rounded-t-lg overflow-hidden flex items-center justify-center p-1 relative'>
|
||
<img
|
||
src={result.poster}
|
||
alt={result.title}
|
||
className='w-full h-full object-cover rounded'
|
||
referrerPolicy='no-referrer'
|
||
/>
|
||
|
||
{/* 集数圆形指示器 */}
|
||
{result.episodes && (
|
||
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
|
||
<span className='text-white text-xs font-bold'>
|
||
{result.episodes}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{isCurrentSource && (
|
||
<div className='absolute inset-0 flex items-center justify-center pointer-events-none'>
|
||
<div className='bg-green-500 text-white text-xs px-3 py-1 rounded shadow-lg'>
|
||
当前播放
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 视频信息 */}
|
||
<div className='p-2 bg-transparent text-center'>
|
||
<h4 className='text-white font-medium text-sm line-clamp-2 mb-2 leading-tight'>
|
||
{result.title}
|
||
</h4>
|
||
<div className='text-gray-400 text-xs space-y-1'>
|
||
<div className='inline-block border border-gray-500/60 rounded px-2 py-[1px]'>
|
||
{result.source_name}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 快捷键提示 */}
|
||
<div
|
||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 transition-opacity duration-300 ${
|
||
showShortcutHint ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||
}`}
|
||
>
|
||
<div className='bg-black/80 backdrop-blur-sm rounded p-4 flex items-center space-x-3'>
|
||
<svg
|
||
className='w-6 h-6 text-white'
|
||
fill='none'
|
||
stroke='currentColor'
|
||
viewBox='0 0 24 24'
|
||
>
|
||
{shortcutDirection === 'left' && (
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M15 19l-7-7 7-7'
|
||
></path>
|
||
)}
|
||
{shortcutDirection === 'right' && (
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M9 5l7 7-7 7'
|
||
></path>
|
||
)}
|
||
{shortcutDirection === 'up' && (
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M5 15l7-7 7 7'
|
||
></path>
|
||
)}
|
||
{shortcutDirection === 'down' && (
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M19 9l-7 7-7-7'
|
||
></path>
|
||
)}
|
||
{shortcutDirection === 'play' && (
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M8 5v14l11-7L8 5z'
|
||
></path>
|
||
)}
|
||
{shortcutDirection === 'pause' && (
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M6 6h4v12H6zm8 0h4v12h-4z'
|
||
></path>
|
||
)}
|
||
{shortcutDirection === 'error' && (
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M6 18L18 6M6 6l12 12'
|
||
></path>
|
||
)}
|
||
</svg>
|
||
<span className='text-white font-medium'>{shortcutText}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 三倍速提示 */}
|
||
<div
|
||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 transition-opacity duration-300 ${
|
||
showSpeedTip ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||
}`}
|
||
>
|
||
<div className='bg-black/60 backdrop-blur-sm rounded-lg p-4 flex items-center space-x-3'>
|
||
<svg
|
||
className='w-6 h-6 text-white animate-pulse'
|
||
fill='none'
|
||
stroke='currentColor'
|
||
viewBox='0 0 24 24'
|
||
>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth='2'
|
||
d='M13 10V3L4 14h7v7l9-11h-7z'
|
||
/>
|
||
</svg>
|
||
<span className='text-white font-bold text-lg'>3x 倍速</span>
|
||
</div>
|
||
</div>
|
||
</MediaPlayer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const PlaybackRateButton = ({
|
||
playerRef,
|
||
}: {
|
||
playerRef: React.RefObject<any>;
|
||
}) => {
|
||
const [rate, setRate] = useState(1);
|
||
const rates = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
|
||
|
||
useEffect(() => {
|
||
const player = playerRef.current;
|
||
if (!player) return;
|
||
// @ts-ignore
|
||
const unsubscribe = player.subscribe(({ playbackRate }) => {
|
||
setRate(playbackRate);
|
||
});
|
||
return unsubscribe;
|
||
}, [playerRef]);
|
||
|
||
const cycleRate = () => {
|
||
const player = playerRef.current;
|
||
if (!player) return;
|
||
const currentIndex = rates.indexOf(rate);
|
||
const nextIndex = (currentIndex + 1) % rates.length;
|
||
player.playbackRate = rates[nextIndex];
|
||
};
|
||
|
||
return (
|
||
<button className='vds-button mr-2' onClick={cycleRate}>
|
||
{rate === 1 ? '倍速' : `${rate.toFixed(2)}x`}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
|
||
if (filled) {
|
||
return (
|
||
<svg
|
||
className='h-5 w-5'
|
||
viewBox='0 0 24 24'
|
||
xmlns='http://www.w3.org/2000/svg'
|
||
>
|
||
<path
|
||
d='M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z'
|
||
fill='#ef4444' /* Tailwind red-500 */
|
||
stroke='#ef4444'
|
||
strokeWidth='2'
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
return <Heart className='h-5 w-5 stroke-[2] text-gray-300' />;
|
||
};
|
||
|
||
export default function PlayPage() {
|
||
return (
|
||
<Suspense>
|
||
<PlayPageClient />
|
||
</Suspense>
|
||
);
|
||
}
|