tv完善弹幕以及各种焦点

This commit is contained in:
mtvpls
2026-05-30 01:01:37 +08:00
parent a880b65f35
commit e7558874bc
5 changed files with 375 additions and 48 deletions
+290 -27
View File
@@ -1,12 +1,12 @@
'use client'; 'use client';
import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react'; import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, SlidersHorizontal, X, Volume2, VolumeX } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Suspense, type Dispatch, type FocusEvent, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { deleteFavorite, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client'; import { deleteFavorite, deletePlayRecord, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
import { convertDanmakuFormat, getDanmakuById, getEpisodes, initDanmakuModule, searchAnime } from '@/lib/danmaku/api'; import { convertDanmakuFormat, getDanmakuById, getEpisodes, initDanmakuModule, loadDanmakuDisplayState, saveDanmakuDisplayState, searchAnime } from '@/lib/danmaku/api';
import TVNativeVideo from '@/components/tv/player/TVNativeVideo'; 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';
@@ -14,11 +14,114 @@ import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
const TV_DANMAKU_LANES = 8; const TV_DANMAKU_LANES = 8;
const TV_DANMAKU_SPAWN_GRACE = 0.6; const TV_DANMAKU_SPAWN_GRACE = 0.6;
const TV_DANMAKU_SEEK_WINDOW = 8;
const TV_DANMAKU_MAX_ITEMS = 3000;
const TV_DANMAKU_SETTINGS_KEY = 'tv_danmaku_settings';
type TVDanmakuSettings = {
fontSize: number;
displayArea: number;
opacity: number;
};
const DEFAULT_TV_DANMAKU_SETTINGS: TVDanmakuSettings = {
fontSize: 30,
displayArea: 42,
opacity: 0.75,
};
function loadTVDanmakuSettings(): TVDanmakuSettings {
if (typeof window === 'undefined') return DEFAULT_TV_DANMAKU_SETTINGS;
try {
const saved = localStorage.getItem(TV_DANMAKU_SETTINGS_KEY);
if (!saved) return DEFAULT_TV_DANMAKU_SETTINGS;
const parsed = JSON.parse(saved) as Partial<TVDanmakuSettings>;
return {
fontSize: typeof parsed.fontSize === 'number' ? parsed.fontSize : DEFAULT_TV_DANMAKU_SETTINGS.fontSize,
displayArea: typeof parsed.displayArea === 'number' ? parsed.displayArea : DEFAULT_TV_DANMAKU_SETTINGS.displayArea,
opacity: typeof parsed.opacity === 'number' ? parsed.opacity : DEFAULT_TV_DANMAKU_SETTINGS.opacity,
};
} catch {
return DEFAULT_TV_DANMAKU_SETTINGS;
}
}
function getTVDanmakuDuration(text: string) { function getTVDanmakuDuration(text: string) {
return Math.max(6, 12 - Math.min(6, text.length / 6)); return Math.max(6, 12 - Math.min(6, text.length / 6));
} }
function blurTVPlayerControl() {
const active = document.activeElement;
if (active instanceof HTMLElement && active.closest('[data-tv-player-control]')) {
active.blur();
}
}
function scrollFocusedControlIntoView(event: FocusEvent<HTMLElement>) {
const target = event.target;
if (target instanceof HTMLElement) {
target.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
}
}
function updateTVDanmakuSetting(
field: keyof TVDanmakuSettings,
direction: -1 | 1,
setDanmakuSettings: Dispatch<SetStateAction<TVDanmakuSettings>>
) {
setDanmakuSettings((prev) => {
if (field === 'opacity') {
const next = Math.max(0.25, Math.min(1, prev.opacity + direction * 0.05));
return { ...prev, opacity: Math.round(next * 100) / 100 };
}
if (field === 'displayArea') {
const next = Math.max(24, Math.min(72, prev.displayArea + direction * 2));
return { ...prev, displayArea: next };
}
const next = Math.max(20, Math.min(46, prev.fontSize + direction * 1));
return { ...prev, fontSize: next };
});
}
function getDanmakuSettingField(target: HTMLElement | null): keyof TVDanmakuSettings | null {
if (!(target instanceof HTMLInputElement) || target.type !== 'range') return null;
const field = target.dataset.tvDanmakuField;
if (field === 'fontSize' || field === 'displayArea' || field === 'opacity') return field;
return null;
}
function getFocusableElementsInScope(scope: HTMLElement) {
return Array.from(
scope.querySelectorAll<HTMLElement>([
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(','))
).filter((element) => !element.closest('[data-tv-no-focus="true"]'));
}
function moveFocusWithinScope(scope: HTMLElement, direction: 'up' | 'down') {
const elements = getFocusableElementsInScope(scope);
if (elements.length === 0) return;
const active = document.activeElement;
const index = active instanceof HTMLElement ? elements.indexOf(active) : -1;
if (index === -1) {
elements[0].focus({ preventScroll: true });
return;
}
const nextIndex = direction === 'down'
? Math.min(elements.length - 1, index + 1)
: Math.max(0, index - 1);
elements[nextIndex]?.focus({ preventScroll: true });
}
function TVPlayClient() { function TVPlayClient() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -32,6 +135,7 @@ function TVPlayClient() {
const [showPanel, setShowPanel] = useState(true); const [showPanel, setShowPanel] = useState(true);
const [showEpisodes, setShowEpisodes] = useState(false); const [showEpisodes, setShowEpisodes] = useState(false);
const [showDetail, setShowDetail] = useState(false); const [showDetail, setShowDetail] = useState(false);
const [showDanmakuSettings, setShowDanmakuSettings] = useState(false);
const [toggleCommand, setToggleCommand] = useState(0); const [toggleCommand, setToggleCommand] = useState(0);
const [retryNonce, setRetryNonce] = useState(0); const [retryNonce, setRetryNonce] = useState(0);
const [startTime, setStartTime] = useState(0); const [startTime, setStartTime] = useState(0);
@@ -52,10 +156,13 @@ function TVPlayClient() {
}); });
const [danmakuEnabled, setDanmakuEnabled] = useState(() => { const [danmakuEnabled, setDanmakuEnabled] = useState(() => {
if (typeof window === 'undefined') return true; if (typeof window === 'undefined') return true;
const saved = localStorage.getItem('tv_danmaku_enabled'); const saved = loadDanmakuDisplayState();
return saved === null ? true : saved === 'true'; if (saved !== null) return saved;
const legacySaved = localStorage.getItem('tv_danmaku_enabled');
return legacySaved === null ? true : legacySaved === '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 [danmakuSettings, setDanmakuSettings] = useState<TVDanmakuSettings>(() => loadTVDanmakuSettings());
const [activeDanmakuItems, setActiveDanmakuItems] = useState<Array<{ const [activeDanmakuItems, setActiveDanmakuItems] = useState<Array<{
id: string; id: string;
text: string; text: string;
@@ -73,12 +180,14 @@ function TVPlayClient() {
const timeRef = useRef({ current: 0, duration: 0 }); const timeRef = useRef({ current: 0, duration: 0 });
const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>({}); const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>({});
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null); const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
const danmakuFontSizeInputRef = useRef<HTMLInputElement | null>(null);
const digitTimerRef = useRef<number | null>(null); const digitTimerRef = useRef<number | null>(null);
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 spawnedDanmakuRef = useRef<Set<string>>(new Set());
const lastDanmakuTimeRef = useRef(0); const lastDanmakuTimeRef = useRef(0);
const suppressPlayRecordSaveKeyRef = useRef<string | null>(null);
const skippedIntroRef = useRef(''); const skippedIntroRef = useRef('');
const skippedOutroRef = useRef(''); const skippedOutroRef = useRef('');
const lastSavedRef = useRef<{ const lastSavedRef = useRef<{
@@ -160,9 +269,16 @@ function TVPlayClient() {
}, [adFilterEnabled]); }, [adFilterEnabled]);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem('tv_danmaku_enabled', String(danmakuEnabled)); if (typeof window === 'undefined') return;
saveDanmakuDisplayState(danmakuEnabled);
localStorage.setItem('tv_danmaku_enabled', String(danmakuEnabled));
}, [danmakuEnabled]); }, [danmakuEnabled]);
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(TV_DANMAKU_SETTINGS_KEY, JSON.stringify(danmakuSettings));
}, [danmakuSettings]);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem('tv_playback_rate', String(playbackRate)); if (typeof window !== 'undefined') localStorage.setItem('tv_playback_rate', String(playbackRate));
}, [playbackRate]); }, [playbackRate]);
@@ -189,7 +305,8 @@ function TVPlayClient() {
searchKeyword: title || detail.title, searchKeyword: title || detail.title,
}); });
if (!alive) return; if (!alive) return;
setDanmakuItems(convertDanmakuFormat(comments).slice(0, 250)); lastDanmakuTimeRef.current = Math.max(0, timeRef.current.current - TV_DANMAKU_SEEK_WINDOW - 1);
setDanmakuItems(convertDanmakuFormat(comments).slice(0, TV_DANMAKU_MAX_ITEMS));
} catch { } catch {
if (alive) setDanmakuItems([]); if (alive) setDanmakuItems([]);
} }
@@ -209,9 +326,10 @@ function TVPlayClient() {
const current = time.current; const current = time.current;
const previous = lastDanmakuTimeRef.current; const previous = lastDanmakuTimeRef.current;
const jumped = current < previous - 1 || current - previous > 2; 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 spawnWindow = jumped ? TV_DANMAKU_SEEK_WINDOW : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2);
const spawned = spawnedDanmakuRef.current; const spawned = spawnedDanmakuRef.current;
if (jumped) spawned.clear();
const nextItems = danmakuItems const nextItems = danmakuItems
.map((item, index) => { .map((item, index) => {
const id = `${index}-${item.time}-${item.text}`; const id = `${index}-${item.time}-${item.text}`;
@@ -225,8 +343,8 @@ function TVPlayClient() {
}; };
}) })
.filter((item) => { .filter((item) => {
const lateBy = current - item.time; const delta = jumped ? Math.abs(item.time - current) : current - item.time;
return lateBy >= 0 && lateBy <= spawnWindow && !spawned.has(item.id); return delta >= 0 && delta <= spawnWindow && !spawned.has(item.id);
}) })
.slice(0, TV_DANMAKU_LANES); .slice(0, TV_DANMAKU_LANES);
@@ -285,6 +403,9 @@ function TVPlayClient() {
useEffect(() => { useEffect(() => {
if (!detail) return; if (!detail) return;
const saveProgress = () => { const saveProgress = () => {
const storageKey = generateStorageKey(detail.source, detail.id);
if (suppressPlayRecordSaveKeyRef.current === storageKey) return;
const playTime = Math.floor(timeRef.current.current || 0); const playTime = Math.floor(timeRef.current.current || 0);
const totalTime = Math.floor(timeRef.current.duration || 0); const totalTime = Math.floor(timeRef.current.duration || 0);
@@ -392,6 +513,8 @@ function TVPlayClient() {
idleTimerRef.current = window.setTimeout(() => { idleTimerRef.current = window.setTimeout(() => {
setShowPanel(false); setShowPanel(false);
setShowEpisodes(false); setShowEpisodes(false);
setShowDanmakuSettings(false);
blurTVPlayerControl();
}, 10000); }, 10000);
}, []); }, []);
@@ -432,18 +555,24 @@ function TVPlayClient() {
}, [muted, videoUrl, volume]); }, [muted, videoUrl, volume]);
useEffect(() => { useEffect(() => {
if (showPanel || showEpisodes) revealPanel(); if (showPanel || showEpisodes || showDanmakuSettings) revealPanel();
return () => { return () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current); if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current);
}; };
}, [revealPanel, showEpisodes, showPanel]); }, [revealPanel, showDanmakuSettings, showEpisodes, showPanel]);
const switchSource = async (item: SearchResult) => { const switchSource = async (item: SearchResult) => {
if (!detail) return;
if (detail.source === item.source && detail.id === item.id) return;
revealPanel(); revealPanel();
setShowEpisodes(false); setShowEpisodes(false);
setLoading(true); setLoading(true);
setIsBuffering(false); setIsBuffering(false);
const currentPlayTime = Math.floor(timeRef.current.current || 0); const currentPlayTime = Math.floor(timeRef.current.current || 0);
const oldSource = detail.source;
const oldId = detail.id;
const oldStorageKey = generateStorageKey(oldSource, oldId);
suppressPlayRecordSaveKeyRef.current = oldStorageKey;
try { try {
let next = item; let next = item;
if (!item.episodes?.length) { if (!item.episodes?.length) {
@@ -455,7 +584,11 @@ function TVPlayClient() {
setEpisodeIndex(targetIndex); setEpisodeIndex(targetIndex);
setStartTime(currentPlayTime > 1 ? currentPlayTime : 0); setStartTime(currentPlayTime > 1 ? currentPlayTime : 0);
setEpisodePage(Math.floor(targetIndex / 30)); setEpisodePage(Math.floor(targetIndex / 30));
if (!(next.source === oldSource && next.id === oldId)) {
await deletePlayRecord(oldSource, oldId);
}
} catch (err) { } catch (err) {
suppressPlayRecordSaveKeyRef.current = null;
setError(err instanceof Error ? err.message : '切换播放源失败'); setError(err instanceof Error ? err.message : '切换播放源失败');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -468,6 +601,12 @@ function TVPlayClient() {
} }
}, [showDetail]); }, [showDetail]);
useEffect(() => {
if (showDanmakuSettings) {
window.requestAnimationFrame(() => danmakuFontSizeInputRef.current?.focus({ preventScroll: true }));
}
}, [showDanmakuSettings]);
useEffect(() => { useEffect(() => {
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (showDetail && event.key === 'Escape') { if (showDetail && event.key === 'Escape') {
@@ -477,12 +616,22 @@ function TVPlayClient() {
return; return;
} }
if (showDanmakuSettings && event.key === 'Escape') {
event.preventDefault();
setShowDanmakuSettings(false);
blurTVPlayerControl();
revealPanel();
return;
}
const isMenuKey = event.key === 'ContextMenu' || event.key === 'Menu' || event.keyCode === 93; const isMenuKey = event.key === 'ContextMenu' || event.key === 'Menu' || event.keyCode === 93;
if (isMenuKey) { if (isMenuKey) {
event.preventDefault(); event.preventDefault();
if (showPanel || showEpisodes) { if (showPanel || showEpisodes || showDanmakuSettings) {
setShowPanel(false); setShowPanel(false);
setShowEpisodes(false); setShowEpisodes(false);
setShowDanmakuSettings(false);
blurTVPlayerControl();
} else { } else {
revealPanel(); revealPanel();
} }
@@ -505,13 +654,18 @@ function TVPlayClient() {
const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]')); const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]'));
if (!showPanel && !showEpisodes) { if (!showPanel && !showEpisodes) {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation();
blurTVPlayerControl();
setToggleCommand((value) => value + 1); setToggleCommand((value) => value + 1);
return; return;
} }
if (!isControlFocused) { if (!isControlFocused) {
event.preventDefault(); event.preventDefault();
if (showPanel || showEpisodes) revealPanel(); event.stopImmediatePropagation();
setToggleCommand((value) => value + 1);
return;
} }
return;
} }
if (!showPanel && !showEpisodes && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { if (!showPanel && !showEpisodes && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
@@ -533,8 +687,16 @@ function TVPlayClient() {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
if (showDetail) setShowDetail(false); if (showDetail) setShowDetail(false);
else if (showEpisodes) setShowEpisodes(false); else if (showDanmakuSettings) {
else if (showPanel) setShowPanel(false); setShowDanmakuSettings(false);
blurTVPlayerControl();
} else if (showEpisodes) {
setShowEpisodes(false);
blurTVPlayerControl();
} else if (showPanel) {
setShowPanel(false);
blurTVPlayerControl();
}
else router.back(); else router.back();
} }
if (event.key === 'PageUp') switchEpisode(episodeIndex - 1); if (event.key === 'PageUp') switchEpisode(episodeIndex - 1);
@@ -542,7 +704,7 @@ function TVPlayClient() {
}; };
window.addEventListener('keydown', onKey, true); window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true);
}, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDetail, showEpisodes, showPanel, volume]); }, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDanmakuSettings, showDetail, showEpisodes, showPanel, volume]);
useEffect(() => { useEffect(() => {
if (!showEpisodes) return; if (!showEpisodes) return;
@@ -591,12 +753,15 @@ function TVPlayClient() {
} }
return ( return (
<main data-tv-player-root className='fixed inset-0 overflow-hidden bg-black text-white'> <main data-tv-player-root data-tv-controls-open={showPanel || showEpisodes || showDetail || showDanmakuSettings ? 'true' : 'false'} 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} onBufferingChange={setIsBuffering} 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>
)} )}
{activeDanmakuItems.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 overflow-hidden'
style={{ height: `${danmakuSettings.displayArea}vh` }}
>
{activeDanmakuItems.map((item) => ( {activeDanmakuItems.map((item) => (
<div <div
key={item.id} key={item.id}
@@ -607,6 +772,8 @@ function TVPlayClient() {
style={{ style={{
top: `${item.lane * 12}%`, top: `${item.lane * 12}%`,
color: item.color || '#fff', color: item.color || '#fff',
fontSize: `${danmakuSettings.fontSize}px`,
opacity: danmakuSettings.opacity,
animation: `tv-danmaku ${item.duration}s linear forwards`, animation: `tv-danmaku ${item.duration}s linear forwards`,
animationPlayState: isPlaying && !isBuffering ? 'running' : 'paused', animationPlayState: isPlaying && !isBuffering ? 'running' : 'paused',
}} }}
@@ -651,8 +818,8 @@ function TVPlayClient() {
</div> </div>
<div data-tv-player-control className={`absolute bottom-8 left-8 right-8 transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'pointer-events-none opacity-0'}`}> <div data-tv-player-control className={`absolute bottom-8 left-8 right-8 transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'pointer-events-none opacity-0'}`}>
<div className='flex items-center justify-between gap-5 rounded-[28px] bg-black/75 p-4 backdrop-blur'> <div className='rounded-[28px] bg-black/75 p-4 backdrop-blur'>
<div className='flex items-center gap-3'> <div onFocusCapture={scrollFocusedControlIntoView} className='flex items-center gap-3 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&_button]:shrink-0 [&_button]:whitespace-nowrap'>
<button onClick={() => switchEpisode(episodeIndex - 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SkipBack className='h-6 w-6' /></button> <button onClick={() => switchEpisode(episodeIndex - 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SkipBack className='h-6 w-6' /></button>
<button onClick={() => setToggleCommand((value) => value + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none'> <button onClick={() => setToggleCommand((value) => value + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none'>
{isPlaying ? <Pause className='h-6 w-6' /> : <Play className='h-6 w-6 fill-current' />} {isPlaying ? <Pause className='h-6 w-6' /> : <Play className='h-6 w-6 fill-current' />}
@@ -660,21 +827,19 @@ function TVPlayClient() {
</button> </button>
<button onClick={() => switchEpisode(episodeIndex + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SkipForward className='h-6 w-6' /></button> <button onClick={() => switchEpisode(episodeIndex + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SkipForward className='h-6 w-6' /></button>
<button onClick={() => setShowEpisodes((v) => !v)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><ListVideo className='h-6 w-6' /></button> <button onClick={() => setShowEpisodes((v) => !v)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><ListVideo className='h-6 w-6' /></button>
<button onClick={() => { setShowEpisodes(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><Layers className='h-6 w-6' /></button>
<button onClick={toggleFavorite} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${favorited ? 'bg-rose-600' : 'bg-white/10'}`}><Heart className={`h-6 w-6 ${favorited ? 'fill-current' : ''}`} /></button> <button onClick={toggleFavorite} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${favorited ? 'bg-rose-600' : 'bg-white/10'}`}><Heart className={`h-6 w-6 ${favorited ? 'fill-current' : ''}`} /></button>
<button onClick={() => { setShowDetail(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><Info className='h-6 w-6' /></button> <button onClick={() => { setShowDetail(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><Info className='h-6 w-6' /></button>
<button onClick={() => setDanmakuEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${danmakuEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><MessageCircle className='h-6 w-6' /></button> <button onClick={() => setDanmakuEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${danmakuEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><MessageCircle className='h-6 w-6' /></button>
<button onClick={() => { setShowEpisodes(false); setShowDanmakuSettings(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SlidersHorizontal className='h-6 w-6' /></button>
<button onClick={() => setAdFilterEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${adFilterEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><ShieldOff className='h-6 w-6' />广</button> <button onClick={() => setAdFilterEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${adFilterEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><ShieldOff className='h-6 w-6' />广</button>
<button onClick={cyclePlaybackRate} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'>{playbackRate}x</button> <button onClick={cyclePlaybackRate} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'>{playbackRate}x</button>
</div>
<div className='flex items-center gap-3 text-xl font-bold text-slate-200'>
<button onClick={toggleMute} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none' title='静音'>{muted ? <VolumeX className='h-6 w-6' /> : <Volume2 className='h-6 w-6' />}</button> <button onClick={toggleMute} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none' title='静音'>{muted ? <VolumeX className='h-6 w-6' /> : <Volume2 className='h-6 w-6' />}</button>
<button onClick={toggleFullscreen} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none'><Maximize className='h-6 w-6' /></button> <button onClick={toggleFullscreen} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none'><Maximize className='h-6 w-6' /></button>
<span>{formatTVTime(time.current)} / {formatTVTime(time.duration)}</span> <span className='shrink-0 whitespace-nowrap px-2 text-xl font-bold text-slate-200'>{formatTVTime(time.current)} / {formatTVTime(time.duration)}</span>
</div> </div>
</div> </div>
<div className='mt-4 rounded-3xl bg-black/70 p-4 backdrop-blur'> <div className='mt-4 rounded-3xl bg-black/70 p-4 backdrop-blur'>
<input aria-label='播放进度' data-tv-player-control type='range' min='0' max={Math.max(1, time.duration || 1)} step='1' value={Math.min(time.current, time.duration || time.current)} onChange={(e) => seekTo(Number(e.target.value))} className='tv-focusable h-3 w-full cursor-pointer accent-rose-600' /> <input aria-label='播放进度' data-tv-no-focus='true' tabIndex={-1} type='range' min='0' max={Math.max(1, time.duration || 1)} step='1' value={Math.min(time.current, time.duration || time.current)} onChange={(e) => seekTo(Number(e.target.value))} className='h-3 w-full cursor-pointer accent-rose-600' />
<div className='mt-2 flex items-center justify-between text-lg font-bold text-slate-200'> <div className='mt-2 flex items-center justify-between text-lg font-bold text-slate-200'>
<span>{formatTVTime(time.current)}</span> <span>{formatTVTime(time.current)}</span>
<span>{time.duration ? `${Math.max(0, Math.round((time.current / time.duration) * 100))}%` : '0%'}</span> <span>{time.duration ? `${Math.max(0, Math.round((time.current / time.duration) * 100))}%` : '0%'}</span>
@@ -706,6 +871,104 @@ function TVPlayClient() {
</div> </div>
</aside> </aside>
)} )}
{showDanmakuSettings && (
<div className='absolute inset-0 z-40 flex items-center justify-center bg-black/60 p-10 backdrop-blur-sm'>
<section
data-tv-player-control
data-tv-focus-scope='active'
data-tv-danmaku-settings
className='w-[720px] max-w-[92vw] rounded-[34px] border border-white/10 bg-slate-950/95 p-7 text-white shadow-2xl shadow-black/80'
onKeyDownCapture={(event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.closest('[data-tv-danmaku-settings]')) return;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
if (target instanceof HTMLInputElement && target.type === 'range') {
const field = getDanmakuSettingField(target);
if (!field) return;
event.preventDefault();
event.stopPropagation();
updateTVDanmakuSetting(field, event.key === 'ArrowRight' ? 1 : -1, setDanmakuSettings);
revealPanel();
}
return;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
event.stopPropagation();
moveFocusWithinScope(event.currentTarget as HTMLElement, event.key === 'ArrowDown' ? 'down' : 'up');
revealPanel();
}
}}
>
<div className='mb-7 flex items-center justify-between gap-5'>
<h2 className='flex items-center gap-3 text-3xl font-black'><SlidersHorizontal className='h-8 w-8 text-rose-500' /></h2>
<button onClick={() => { setShowDanmakuSettings(false); blurTVPlayerControl(); }} data-tv-player-control className='tv-focusable flex shrink-0 cursor-pointer items-center gap-2 whitespace-nowrap rounded-2xl bg-white/10 px-5 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><X className='h-7 w-7' /></button>
</div>
<div className='space-y-7'>
<label className='block'>
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
<span></span>
<span className='text-rose-200'>{danmakuSettings.fontSize}px</span>
</div>
<input
ref={danmakuFontSizeInputRef}
aria-label='弹幕字号'
data-tv-danmaku-field='fontSize'
data-tv-player-control
type='range'
min='20'
max='46'
step='1'
value={danmakuSettings.fontSize}
onChange={(event) => setDanmakuSettings((prev) => ({ ...prev, fontSize: Number(event.target.value) }))}
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
/>
</label>
<label className='block'>
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
<span></span>
<span className='text-rose-200'>{danmakuSettings.displayArea}%</span>
</div>
<input
aria-label='弹幕显示区域'
data-tv-danmaku-field='displayArea'
data-tv-player-control
type='range'
min='24'
max='72'
step='2'
value={danmakuSettings.displayArea}
onChange={(event) => setDanmakuSettings((prev) => ({ ...prev, displayArea: Number(event.target.value) }))}
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
/>
</label>
<label className='block'>
<div className='mb-3 flex items-center justify-between text-xl font-bold'>
<span></span>
<span className='text-rose-200'>{Math.round(danmakuSettings.opacity * 100)}%</span>
</div>
<input
aria-label='弹幕不透明度'
data-tv-danmaku-field='opacity'
data-tv-player-control
type='range'
min='25'
max='100'
step='5'
value={Math.round(danmakuSettings.opacity * 100)}
onChange={(event) => setDanmakuSettings((prev) => ({ ...prev, opacity: Number(event.target.value) / 100 }))}
className='tv-focusable h-3 w-full cursor-pointer accent-rose-600 outline-none'
/>
</label>
</div>
</section>
</div>
)}
{showDetail && ( {showDetail && (
<div className='absolute inset-0 z-40 flex items-center justify-center bg-black/72 p-10 backdrop-blur-sm'> <div className='absolute inset-0 z-40 flex items-center justify-center bg-black/72 p-10 backdrop-blur-sm'>
<section data-tv-player-control className='max-h-[82vh] w-[980px] max-w-[92vw] overflow-y-auto rounded-[42px] border border-white/10 bg-slate-950/95 p-8 text-white shadow-2xl shadow-black/80'> <section data-tv-player-control className='max-h-[82vh] w-[980px] max-w-[92vw] overflow-y-auto rounded-[42px] border border-white/10 bg-slate-950/95 p-8 text-white shadow-2xl shadow-black/80'>
+33
View File
@@ -30,8 +30,17 @@ function isVisible(element: HTMLElement) {
} }
function getFocusableElements() { function getFocusableElements() {
const scope = document.querySelector<HTMLElement>('[data-tv-focus-scope="active"]');
if (scope) {
return Array.from(scope.querySelectorAll<HTMLElement>(focusableSelector))
.filter((element) => !element.closest('[data-tv-remote]'))
.filter((element) => !element.closest('[data-tv-no-focus="true"]'))
.filter(isVisible);
}
return Array.from(document.querySelectorAll<HTMLElement>(focusableSelector)) return Array.from(document.querySelectorAll<HTMLElement>(focusableSelector))
.filter((element) => !element.closest('[data-tv-remote]')) .filter((element) => !element.closest('[data-tv-remote]'))
.filter((element) => !element.closest('[data-tv-no-focus="true"]'))
.filter(isVisible); .filter(isVisible);
} }
@@ -232,6 +241,25 @@ export default function TVVirtualRemote() {
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') { if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const active = document.activeElement;
if (active instanceof HTMLElement && active.closest('[data-tv-danmaku-settings]')) {
return;
}
if (
active instanceof HTMLInputElement &&
active.type === 'range' &&
active.closest('[data-tv-danmaku-settings]')
) {
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
return;
}
}
if (active instanceof HTMLInputElement && active.type === 'range' && active.closest('[data-tv-no-focus="true"]')) {
return;
}
event.preventDefault(); event.preventDefault();
const direction = event.key.replace('Arrow', '').toLowerCase() as 'up' | 'down' | 'left' | 'right'; const direction = event.key.replace('Arrow', '').toLowerCase() as 'up' | 'down' | 'left' | 'right';
moveSpatialFocus(direction, lastFocusedRef.current); moveSpatialFocus(direction, lastFocusedRef.current);
@@ -239,6 +267,11 @@ export default function TVVirtualRemote() {
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
const playerRoot = document.querySelector<HTMLElement>('[data-tv-player-root]');
if (playerRoot?.dataset.tvControlsOpen === 'false') {
return;
}
const active = document.activeElement; const active = document.activeElement;
if (active instanceof HTMLElement && !active.closest('input, textarea, select') && !active.closest('[data-tv-remote]')) { if (active instanceof HTMLElement && !active.closest('input, textarea, select') && !active.closest('[data-tv-remote]')) {
event.preventDefault(); event.preventDefault();
+9 -5
View File
@@ -235,14 +235,18 @@ export default function TVNativeVideo({
attach(); attach();
let seekedInitialTime = false; let triedInitialSeek = false;
const seekToInitialTime = () => { const seekToInitialTime = () => {
if (live || seekedInitialTime || !startTime || startTime <= 1) return; if (live || triedInitialSeek || !startTime || startTime <= 1) return;
if ((videoEl.currentTime || 0) > 1) {
triedInitialSeek = true;
return;
}
const duration = videoEl.duration || 0; const duration = videoEl.duration || 0;
const safeTime = duration > 30 ? Math.min(startTime, Math.max(0, duration - 8)) : startTime; const safeTime = duration > 30 ? Math.min(startTime, Math.max(0, duration - 8)) : startTime;
try { try {
videoEl.currentTime = safeTime; videoEl.currentTime = safeTime;
seekedInitialTime = true; triedInitialSeek = true;
} catch { } catch {
// ignore unsupported seek state // ignore unsupported seek state
} }
@@ -289,7 +293,7 @@ export default function TVNativeVideo({
videoEl.addEventListener('waiting', onBuffering); videoEl.addEventListener('waiting', onBuffering);
videoEl.addEventListener('stalled', onBuffering); videoEl.addEventListener('stalled', onBuffering);
videoEl.addEventListener('seeking', onBuffering); videoEl.addEventListener('seeking', onBuffering);
videoEl.addEventListener('seeked', onLoaded); videoEl.addEventListener('seeked', clearBuffering);
videoEl.addEventListener('error', onError); videoEl.addEventListener('error', onError);
videoEl.addEventListener('timeupdate', onTimeUpdate); videoEl.addEventListener('timeupdate', onTimeUpdate);
@@ -305,7 +309,7 @@ export default function TVNativeVideo({
videoEl.removeEventListener('waiting', onBuffering); videoEl.removeEventListener('waiting', onBuffering);
videoEl.removeEventListener('stalled', onBuffering); videoEl.removeEventListener('stalled', onBuffering);
videoEl.removeEventListener('seeking', onBuffering); videoEl.removeEventListener('seeking', onBuffering);
videoEl.removeEventListener('seeked', onLoaded); videoEl.removeEventListener('seeked', clearBuffering);
videoEl.removeEventListener('error', onError); videoEl.removeEventListener('error', onError);
videoEl.removeEventListener('timeupdate', onTimeUpdate); videoEl.removeEventListener('timeupdate', onTimeUpdate);
clearBuffering(); clearBuffering();
+18 -13
View File
@@ -18,6 +18,13 @@ export function createEmptyFeatureAccessMap(): FeatureAccessMap {
}, {} as FeatureAccessMap); }, {} as FeatureAccessMap);
} }
function createFullFeatureAccessMap(): FeatureAccessMap {
return ALL_FEATURE_PERMISSION_KEYS.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as FeatureAccessMap);
}
function isPrivilegedRole(role?: string) { function isPrivilegedRole(role?: string) {
return role === 'owner' || role === 'admin'; return role === 'owner' || role === 'admin';
} }
@@ -25,10 +32,7 @@ function isPrivilegedRole(role?: string) {
async function getUserFeatureAccessMap(username: string): Promise<FeatureAccessMap> { async function getUserFeatureAccessMap(username: string): Promise<FeatureAccessMap> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return ALL_FEATURE_PERMISSION_KEYS.reduce((acc, key) => { return createFullFeatureAccessMap();
acc[key] = true;
return acc;
}, {} as FeatureAccessMap);
} }
const userInfo = await db.getUserInfoV2(username); const userInfo = await db.getUserInfoV2(username);
@@ -37,10 +41,7 @@ async function getUserFeatureAccessMap(username: string): Promise<FeatureAccessM
} }
if (username === process.env.USERNAME || isPrivilegedRole(userInfo.role)) { if (username === process.env.USERNAME || isPrivilegedRole(userInfo.role)) {
return ALL_FEATURE_PERMISSION_KEYS.reduce((acc, key) => { return createFullFeatureAccessMap();
acc[key] = true;
return acc;
}, {} as FeatureAccessMap);
} }
const config = await getConfig(); const config = await getConfig();
@@ -48,10 +49,7 @@ async function getUserFeatureAccessMap(username: string): Promise<FeatureAccessM
// 兼容旧用户:未分配用户组时,默认拥有全部功能权限 // 兼容旧用户:未分配用户组时,默认拥有全部功能权限
if (tags.length === 0) { if (tags.length === 0) {
return ALL_FEATURE_PERMISSION_KEYS.reduce((acc, key) => { return createFullFeatureAccessMap();
acc[key] = true;
return acc;
}, {} as FeatureAccessMap);
} }
const allowedPermissions = new Set<FeaturePermissionKey>(); const allowedPermissions = new Set<FeaturePermissionKey>();
@@ -71,7 +69,14 @@ async function getUserFeatureAccessMap(username: string): Promise<FeatureAccessM
export async function getUserFeatureAccess(username?: string | null): Promise<FeatureAccessMap> { export async function getUserFeatureAccess(username?: string | null): Promise<FeatureAccessMap> {
if (!username) return createEmptyFeatureAccessMap(); if (!username) return createEmptyFeatureAccessMap();
return getUserFeatureAccessMap(username); try {
return await getUserFeatureAccessMap(username);
} catch (error) {
console.error('[Permissions] Failed to load feature access:', error);
return username === process.env.USERNAME
? createFullFeatureAccessMap()
: createEmptyFeatureAccessMap();
}
} }
export async function hasFeaturePermission( export async function hasFeaturePermission(
+25 -3
View File
@@ -5,10 +5,27 @@ import { Redis } from '@upstash/redis';
import { UpstashRedisAdapter } from './redis-adapter'; import { UpstashRedisAdapter } from './redis-adapter';
import { BaseRedisStorage } from './redis-base.db'; import { BaseRedisStorage } from './redis-base.db';
const DEFAULT_UPSTASH_TIMEOUT_MS =
process.env.NODE_ENV === 'development' ? 2500 : 8000;
const UPSTASH_TIMEOUT_MS = Math.max(
1000,
Number(process.env.UPSTASH_TIMEOUT_MS || DEFAULT_UPSTASH_TIMEOUT_MS)
);
const DEFAULT_UPSTASH_RETRIES =
process.env.NODE_ENV === 'development' ? 1 : 3;
const UPSTASH_MAX_RETRIES = Math.max(
1,
Number(process.env.UPSTASH_MAX_RETRIES || DEFAULT_UPSTASH_RETRIES)
);
function createUpstashAbortSignal() {
return AbortSignal.timeout(UPSTASH_TIMEOUT_MS);
}
// 添加Upstash Redis操作重试包装器 // 添加Upstash Redis操作重试包装器
async function withRetry<T>( async function withRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
maxRetries = 3 maxRetries = UPSTASH_MAX_RETRIES
): Promise<T> { ): Promise<T> {
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
@@ -19,6 +36,10 @@ async function withRetry<T>(
err.message?.includes('Connection') || err.message?.includes('Connection') ||
err.message?.includes('ECONNREFUSED') || err.message?.includes('ECONNREFUSED') ||
err.message?.includes('ENOTFOUND') || err.message?.includes('ENOTFOUND') ||
err.name === 'AbortError' ||
err.name === 'TimeoutError' ||
err.message?.includes('Aborted') ||
err.message?.includes('Timeout') ||
err.code === 'ECONNRESET' || err.code === 'ECONNRESET' ||
err.code === 'EPIPE' || err.code === 'EPIPE' ||
err.name === 'UpstashError'; err.name === 'UpstashError';
@@ -68,11 +89,12 @@ function getUpstashRedisClient(): Redis {
client = new Redis({ client = new Redis({
url: upstashUrl, url: upstashUrl,
token: upstashToken, token: upstashToken,
signal: createUpstashAbortSignal,
// 可选配置 // 可选配置
retry: { retry: {
retries: 3, retries: UPSTASH_MAX_RETRIES,
backoff: (retryCount: number) => backoff: (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 30000), Math.min(500 * Math.pow(2, retryCount), 5000),
}, },
}); });