tv完善弹幕以及各种焦点
This commit is contained in:
+290
-27
@@ -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'>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user