暂存
This commit is contained in:
@@ -12,6 +12,13 @@ import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
|
|||||||
import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils';
|
import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils';
|
||||||
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
||||||
|
|
||||||
|
const TV_DANMAKU_LANES = 8;
|
||||||
|
const TV_DANMAKU_SPAWN_GRACE = 0.6;
|
||||||
|
|
||||||
|
function getTVDanmakuDuration(text: string) {
|
||||||
|
return Math.max(6, 12 - Math.min(6, text.length / 6));
|
||||||
|
}
|
||||||
|
|
||||||
function TVPlayClient() {
|
function TVPlayClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -32,6 +39,7 @@ function TVPlayClient() {
|
|||||||
const [episodePage, setEpisodePage] = useState(0);
|
const [episodePage, setEpisodePage] = useState(0);
|
||||||
const [playbackError, setPlaybackError] = useState(false);
|
const [playbackError, setPlaybackError] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(false);
|
||||||
const [favorited, setFavorited] = useState(false);
|
const [favorited, setFavorited] = useState(false);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
@@ -48,6 +56,14 @@ function TVPlayClient() {
|
|||||||
return saved === null ? true : saved === 'true';
|
return saved === null ? true : saved === 'true';
|
||||||
});
|
});
|
||||||
const [danmakuItems, setDanmakuItems] = useState<Array<{ text: string; time: number; color: string; mode: number }>>([]);
|
const [danmakuItems, setDanmakuItems] = useState<Array<{ text: string; time: number; color: string; mode: number }>>([]);
|
||||||
|
const [activeDanmakuItems, setActiveDanmakuItems] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
time: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
lane: number;
|
||||||
|
}>>([]);
|
||||||
const [playbackRate, setPlaybackRate] = useState(() => {
|
const [playbackRate, setPlaybackRate] = useState(() => {
|
||||||
if (typeof window === 'undefined') return 1;
|
if (typeof window === 'undefined') return 1;
|
||||||
return Number(localStorage.getItem('tv_playback_rate') || '1') || 1;
|
return Number(localStorage.getItem('tv_playback_rate') || '1') || 1;
|
||||||
@@ -61,6 +77,8 @@ function TVPlayClient() {
|
|||||||
const idleTimerRef = useRef<number | null>(null);
|
const idleTimerRef = useRef<number | null>(null);
|
||||||
const volumeHintTimerRef = useRef<number | null>(null);
|
const volumeHintTimerRef = useRef<number | null>(null);
|
||||||
const seekHintTimerRef = useRef<number | null>(null);
|
const seekHintTimerRef = useRef<number | null>(null);
|
||||||
|
const spawnedDanmakuRef = useRef<Set<string>>(new Set());
|
||||||
|
const lastDanmakuTimeRef = useRef(0);
|
||||||
const skippedIntroRef = useRef('');
|
const skippedIntroRef = useRef('');
|
||||||
const skippedOutroRef = useRef('');
|
const skippedOutroRef = useRef('');
|
||||||
const lastSavedRef = useRef<{
|
const lastSavedRef = useRef<{
|
||||||
@@ -153,6 +171,9 @@ function TVPlayClient() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
async function loadDanmaku() {
|
async function loadDanmaku() {
|
||||||
setDanmakuItems([]);
|
setDanmakuItems([]);
|
||||||
|
setActiveDanmakuItems([]);
|
||||||
|
spawnedDanmakuRef.current.clear();
|
||||||
|
lastDanmakuTimeRef.current = timeRef.current.current;
|
||||||
if (!danmakuEnabled || !detail?.title) return;
|
if (!danmakuEnabled || !detail?.title) return;
|
||||||
try {
|
try {
|
||||||
const search = await searchAnime(title || detail.title);
|
const search = await searchAnime(title || detail.title);
|
||||||
@@ -177,6 +198,51 @@ function TVPlayClient() {
|
|||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [danmakuEnabled, detail?.title, episodeIndex, title]);
|
}, [danmakuEnabled, detail?.title, episodeIndex, title]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!danmakuEnabled || danmakuItems.length === 0) {
|
||||||
|
setActiveDanmakuItems([]);
|
||||||
|
spawnedDanmakuRef.current.clear();
|
||||||
|
lastDanmakuTimeRef.current = time.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = time.current;
|
||||||
|
const previous = lastDanmakuTimeRef.current;
|
||||||
|
const jumped = current < previous - 1 || current - previous > 2;
|
||||||
|
const spawnWindow = jumped ? TV_DANMAKU_SPAWN_GRACE : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2);
|
||||||
|
|
||||||
|
const spawned = spawnedDanmakuRef.current;
|
||||||
|
const nextItems = danmakuItems
|
||||||
|
.map((item, index) => {
|
||||||
|
const id = `${index}-${item.time}-${item.text}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
text: item.text,
|
||||||
|
time: item.time,
|
||||||
|
color: item.color,
|
||||||
|
duration: getTVDanmakuDuration(item.text),
|
||||||
|
lane: index % TV_DANMAKU_LANES,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => {
|
||||||
|
const lateBy = current - item.time;
|
||||||
|
return lateBy >= 0 && lateBy <= spawnWindow && !spawned.has(item.id);
|
||||||
|
})
|
||||||
|
.slice(0, TV_DANMAKU_LANES);
|
||||||
|
|
||||||
|
if (nextItems.length > 0) {
|
||||||
|
nextItems.forEach((item) => spawned.add(item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveDanmakuItems((prev) => {
|
||||||
|
if (jumped) return nextItems;
|
||||||
|
if (nextItems.length === 0) return prev;
|
||||||
|
return [...prev, ...nextItems];
|
||||||
|
});
|
||||||
|
|
||||||
|
lastDanmakuTimeRef.current = current;
|
||||||
|
}, [danmakuEnabled, danmakuItems, time.current]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail?.source || !detail?.id) return;
|
if (!detail?.source || !detail?.id) return;
|
||||||
isFavorited(detail.source, detail.id).then(setFavorited).catch(() => undefined);
|
isFavorited(detail.source, detail.id).then(setFavorited).catch(() => undefined);
|
||||||
@@ -376,6 +442,8 @@ function TVPlayClient() {
|
|||||||
revealPanel();
|
revealPanel();
|
||||||
setShowEpisodes(false);
|
setShowEpisodes(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setIsBuffering(false);
|
||||||
|
const currentPlayTime = Math.floor(timeRef.current.current || 0);
|
||||||
try {
|
try {
|
||||||
let next = item;
|
let next = item;
|
||||||
if (!item.episodes?.length) {
|
if (!item.episodes?.length) {
|
||||||
@@ -385,7 +453,7 @@ function TVPlayClient() {
|
|||||||
const targetIndex = Math.max(0, Math.min(episodeIndex, Math.max(0, (next.episodes?.length || 1) - 1)));
|
const targetIndex = Math.max(0, Math.min(episodeIndex, Math.max(0, (next.episodes?.length || 1) - 1)));
|
||||||
setDetail(next);
|
setDetail(next);
|
||||||
setEpisodeIndex(targetIndex);
|
setEpisodeIndex(targetIndex);
|
||||||
setStartTime(0);
|
setStartTime(currentPlayTime > 1 ? currentPlayTime : 0);
|
||||||
setEpisodePage(Math.floor(targetIndex / 30));
|
setEpisodePage(Math.floor(targetIndex / 30));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '切换播放源失败');
|
setError(err instanceof Error ? err.message : '切换播放源失败');
|
||||||
@@ -524,19 +592,23 @@ function TVPlayClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main data-tv-player-root className='fixed inset-0 overflow-hidden bg-black text-white'>
|
<main data-tv-player-root className='fixed inset-0 overflow-hidden bg-black text-white'>
|
||||||
{videoUrl ? <TVNativeVideo key={`${videoUrl}-${retryNonce}`} url={videoUrl} poster={detail.poster} title={detail.title} onTime={onTime} command={toggleCommand} startTime={startTime} onError={() => setPlaybackError(true)} onPlayingChange={setIsPlaying} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : (
|
{videoUrl ? <TVNativeVideo key={`${videoUrl}-${retryNonce}`} url={videoUrl} poster={detail.poster} title={detail.title} onTime={onTime} command={toggleCommand} startTime={startTime} onError={() => setPlaybackError(true)} onPlayingChange={setIsPlaying} onBufferingChange={setIsBuffering} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : (
|
||||||
<div className='flex h-full w-full items-center justify-center text-3xl font-bold text-white'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />{resolving ? '正在解析播放地址...' : '准备播放...'}</div>
|
<div className='flex h-full w-full items-center justify-center text-3xl font-bold text-white'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />{resolving ? '正在解析播放地址...' : '准备播放...'}</div>
|
||||||
)}
|
)}
|
||||||
{danmakuEnabled && danmakuItems.length > 0 && (
|
{activeDanmakuItems.length > 0 && (
|
||||||
<div className='pointer-events-none absolute inset-x-0 top-12 z-10 h-[42vh] overflow-hidden'>
|
<div className='pointer-events-none absolute inset-x-0 top-12 z-10 h-[42vh] overflow-hidden'>
|
||||||
{danmakuItems.filter((item) => Math.abs(item.time - time.current) < 0.35).slice(0, 8).map((item, idx) => (
|
{activeDanmakuItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.time}-${idx}-${item.text}`}
|
key={item.id}
|
||||||
className='absolute whitespace-nowrap text-3xl font-black drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
|
className='absolute whitespace-nowrap text-3xl font-black drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
|
||||||
|
onAnimationEnd={() => {
|
||||||
|
setActiveDanmakuItems((prev) => prev.filter((active) => active.id !== item.id));
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
top: `${(idx % 8) * 12}%`,
|
top: `${item.lane * 12}%`,
|
||||||
color: item.color || '#fff',
|
color: item.color || '#fff',
|
||||||
animation: `tv-danmaku ${Math.max(6, 12 - Math.min(6, item.text.length / 6))}s linear forwards`,
|
animation: `tv-danmaku ${item.duration}s linear forwards`,
|
||||||
|
animationPlayState: isPlaying && !isBuffering ? 'running' : 'paused',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default function TVNativeVideo({
|
|||||||
onTime,
|
onTime,
|
||||||
onError: onPlaybackError,
|
onError: onPlaybackError,
|
||||||
onPlayingChange,
|
onPlayingChange,
|
||||||
|
onBufferingChange,
|
||||||
adFilterEnabled = false,
|
adFilterEnabled = false,
|
||||||
playbackRate = 1,
|
playbackRate = 1,
|
||||||
startTime = 0,
|
startTime = 0,
|
||||||
@@ -69,6 +70,7 @@ export default function TVNativeVideo({
|
|||||||
onTime?: (current: number, duration: number) => void;
|
onTime?: (current: number, duration: number) => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
onPlayingChange?: (playing: boolean) => void;
|
onPlayingChange?: (playing: boolean) => void;
|
||||||
|
onBufferingChange?: (buffering: boolean) => void;
|
||||||
adFilterEnabled?: boolean;
|
adFilterEnabled?: boolean;
|
||||||
playbackRate?: number;
|
playbackRate?: number;
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
@@ -79,7 +81,11 @@ export default function TVNativeVideo({
|
|||||||
const onTimeRef = useRef<typeof onTime>(onTime);
|
const onTimeRef = useRef<typeof onTime>(onTime);
|
||||||
const onPlaybackErrorRef = useRef<typeof onPlaybackError>(onPlaybackError);
|
const onPlaybackErrorRef = useRef<typeof onPlaybackError>(onPlaybackError);
|
||||||
const onPlayingChangeRef = useRef<typeof onPlayingChange>(onPlayingChange);
|
const onPlayingChangeRef = useRef<typeof onPlayingChange>(onPlayingChange);
|
||||||
|
const onBufferingChangeRef = useRef<typeof onBufferingChange>(onBufferingChange);
|
||||||
|
const bufferingTimerRef = useRef<number | null>(null);
|
||||||
|
const bufferingStateRef = useRef(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [buffering, setBuffering] = useState(false);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -95,6 +101,10 @@ export default function TVNativeVideo({
|
|||||||
onPlayingChangeRef.current = onPlayingChange;
|
onPlayingChangeRef.current = onPlayingChange;
|
||||||
}, [onPlayingChange]);
|
}, [onPlayingChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onBufferingChangeRef.current = onBufferingChange;
|
||||||
|
}, [onBufferingChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video || !url) return;
|
if (!video || !url) return;
|
||||||
@@ -102,6 +112,9 @@ export default function TVNativeVideo({
|
|||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
bufferingStateRef.current = false;
|
||||||
|
setBuffering(false);
|
||||||
|
onBufferingChangeRef.current?.(false);
|
||||||
setError('');
|
setError('');
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
|
|
||||||
@@ -124,6 +137,31 @@ export default function TVNativeVideo({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setBufferingState = (next: boolean) => {
|
||||||
|
if (bufferingStateRef.current === next) return;
|
||||||
|
bufferingStateRef.current = next;
|
||||||
|
setBuffering(next);
|
||||||
|
onBufferingChangeRef.current?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBuffering = () => {
|
||||||
|
if (bufferingTimerRef.current) {
|
||||||
|
window.clearTimeout(bufferingTimerRef.current);
|
||||||
|
bufferingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setBufferingState(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleBuffering = () => {
|
||||||
|
if (videoEl.paused || videoEl.ended || bufferingTimerRef.current) return;
|
||||||
|
bufferingTimerRef.current = window.setTimeout(() => {
|
||||||
|
bufferingTimerRef.current = null;
|
||||||
|
if (!videoEl.paused && !videoEl.ended && videoEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||||
|
setBufferingState(true);
|
||||||
|
}
|
||||||
|
}, 700);
|
||||||
|
};
|
||||||
|
|
||||||
async function attach() {
|
async function attach() {
|
||||||
cleanup();
|
cleanup();
|
||||||
const type = getSourceType(url);
|
const type = getSourceType(url);
|
||||||
@@ -212,27 +250,46 @@ export default function TVNativeVideo({
|
|||||||
const onLoaded = () => {
|
const onLoaded = () => {
|
||||||
seekToInitialTime();
|
seekToInitialTime();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
clearBuffering();
|
||||||
};
|
};
|
||||||
const onPlay = () => {
|
const onPlay = () => {
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
|
clearBuffering();
|
||||||
|
onPlayingChangeRef.current?.(true);
|
||||||
|
};
|
||||||
|
const onPlaying = () => {
|
||||||
|
setPlaying(true);
|
||||||
|
clearBuffering();
|
||||||
onPlayingChangeRef.current?.(true);
|
onPlayingChangeRef.current?.(true);
|
||||||
};
|
};
|
||||||
const onPause = () => {
|
const onPause = () => {
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
|
clearBuffering();
|
||||||
onPlayingChangeRef.current?.(false);
|
onPlayingChangeRef.current?.(false);
|
||||||
};
|
};
|
||||||
|
const onBuffering = () => scheduleBuffering();
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
clearBuffering();
|
||||||
setError('视频加载失败,请尝试切换线路或频道');
|
setError('视频加载失败,请尝试切换线路或频道');
|
||||||
onPlaybackErrorRef.current?.();
|
onPlaybackErrorRef.current?.();
|
||||||
};
|
};
|
||||||
const onTimeUpdate = () => onTimeRef.current?.(videoEl.currentTime || 0, videoEl.duration || 0);
|
const onTimeUpdate = () => {
|
||||||
|
clearBuffering();
|
||||||
|
onTimeRef.current?.(videoEl.currentTime || 0, videoEl.duration || 0);
|
||||||
|
};
|
||||||
|
|
||||||
videoEl.addEventListener('loadedmetadata', seekToInitialTime);
|
videoEl.addEventListener('loadedmetadata', seekToInitialTime);
|
||||||
videoEl.addEventListener('loadeddata', onLoaded);
|
videoEl.addEventListener('loadeddata', onLoaded);
|
||||||
videoEl.addEventListener('canplay', onLoaded);
|
videoEl.addEventListener('canplay', onLoaded);
|
||||||
|
videoEl.addEventListener('canplaythrough', onLoaded);
|
||||||
videoEl.addEventListener('play', onPlay);
|
videoEl.addEventListener('play', onPlay);
|
||||||
|
videoEl.addEventListener('playing', onPlaying);
|
||||||
videoEl.addEventListener('pause', onPause);
|
videoEl.addEventListener('pause', onPause);
|
||||||
|
videoEl.addEventListener('waiting', onBuffering);
|
||||||
|
videoEl.addEventListener('stalled', onBuffering);
|
||||||
|
videoEl.addEventListener('seeking', onBuffering);
|
||||||
|
videoEl.addEventListener('seeked', onLoaded);
|
||||||
videoEl.addEventListener('error', onError);
|
videoEl.addEventListener('error', onError);
|
||||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
|
||||||
@@ -241,10 +298,17 @@ export default function TVNativeVideo({
|
|||||||
videoEl.removeEventListener('loadedmetadata', seekToInitialTime);
|
videoEl.removeEventListener('loadedmetadata', seekToInitialTime);
|
||||||
videoEl.removeEventListener('loadeddata', onLoaded);
|
videoEl.removeEventListener('loadeddata', onLoaded);
|
||||||
videoEl.removeEventListener('canplay', onLoaded);
|
videoEl.removeEventListener('canplay', onLoaded);
|
||||||
|
videoEl.removeEventListener('canplaythrough', onLoaded);
|
||||||
videoEl.removeEventListener('play', onPlay);
|
videoEl.removeEventListener('play', onPlay);
|
||||||
|
videoEl.removeEventListener('playing', onPlaying);
|
||||||
videoEl.removeEventListener('pause', onPause);
|
videoEl.removeEventListener('pause', onPause);
|
||||||
|
videoEl.removeEventListener('waiting', onBuffering);
|
||||||
|
videoEl.removeEventListener('stalled', onBuffering);
|
||||||
|
videoEl.removeEventListener('seeking', onBuffering);
|
||||||
|
videoEl.removeEventListener('seeked', onLoaded);
|
||||||
videoEl.removeEventListener('error', onError);
|
videoEl.removeEventListener('error', onError);
|
||||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
|
clearBuffering();
|
||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
}, [url, live, startTime, adFilterEnabled, playbackRate]);
|
}, [url, live, startTime, adFilterEnabled, playbackRate]);
|
||||||
@@ -278,9 +342,12 @@ export default function TVNativeVideo({
|
|||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={title || 'TV 视频播放器'}
|
aria-label={title || 'TV 视频播放器'}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{(loading || buffering) && (
|
||||||
<div className='pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-2xl font-bold text-white'>
|
<div className='pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-white'>
|
||||||
<Loader2 className='mr-3 h-9 w-9 animate-spin text-rose-500' /> 正在缓冲...
|
<div className='flex items-center gap-3 rounded-2xl border border-white/12 bg-black/55 px-6 py-4 text-2xl font-bold shadow-2xl shadow-black/50 backdrop-blur-md'>
|
||||||
|
<Loader2 className='h-9 w-9 animate-spin text-rose-500' />
|
||||||
|
<span>{buffering ? '缓冲中' : '正在载入'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
Reference in New Issue
Block a user