web tv模式增加上下键可改为菜单模式

This commit is contained in:
mtvpls
2026-06-12 20:55:06 +08:00
parent 33aa0443e8
commit 98e114ca42
3 changed files with 172 additions and 18 deletions
+118 -4
View File
@@ -5,13 +5,28 @@ import {
Clock3,
Loader2,
LogOut,
Menu,
ShieldCheck,
SlidersHorizontal,
User,
Volume2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { clearAuthCookie, getAuthInfoFromBrowserCookie } from '@/lib/auth';
import {
type TVPlayerUpDownAction,
DEFAULT_TV_PLAYER_UP_DOWN_ACTION,
loadTVPlayerUpDownAction,
saveTVPlayerUpDownAction,
} from '@/lib/tv-preferences';
import TVLayout from '@/components/tv/TVLayout';
@@ -56,10 +71,16 @@ export default function TVMePage() {
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
const [error, setError] = useState('');
const [upDownAction, setUpDownAction] = useState<TVPlayerUpDownAction>(
DEFAULT_TV_PLAYER_UP_DOWN_ACTION
);
const wakeMenuButtonRef = useRef<HTMLButtonElement | null>(null);
const volumeButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const auth = getAuthInfoFromBrowserCookie();
setAuthInfo(auth);
setUpDownAction(loadTVPlayerUpDownAction());
setReady(true);
}, []);
@@ -92,13 +113,38 @@ export default function TVMePage() {
}
};
const handleUpDownActionChange = (action: TVPlayerUpDownAction) => {
setUpDownAction(action);
saveTVPlayerUpDownAction(action);
};
const handleUpDownActionKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
event.stopPropagation();
const nextAction: TVPlayerUpDownAction =
event.key === 'ArrowRight' ? 'volume' : 'wake-menu';
handleUpDownActionChange(nextAction);
window.requestAnimationFrame(() => {
const target =
nextAction === 'wake-menu'
? wakeMenuButtonRef.current
: volumeButtonRef.current;
target?.focus({ preventScroll: true });
});
};
if (!ready || !authInfo) {
return (
<TVLayout>
<section className='mx-auto max-w-5xl rounded-[42px] border border-white/10 bg-slate-950/75 p-12 text-center shadow-2xl shadow-black/60'>
<Loader2 className='mx-auto h-16 w-16 animate-spin text-rose-400' />
<h1 className='mt-6 text-5xl font-black'></h1>
<p className='mt-4 text-2xl text-slate-300'></p>
<p className='mt-4 text-2xl text-slate-300'>
</p>
</section>
</TVLayout>
);
@@ -135,7 +181,9 @@ export default function TVMePage() {
<ShieldCheck className='h-6 w-6 text-rose-300' />
</div>
<div className='mt-4 text-4xl font-black text-white'>{roleText}</div>
<div className='mt-4 text-4xl font-black text-white'>
{roleText}
</div>
</div>
<div className='rounded-[28px] border border-white/10 bg-white/[0.06] p-6'>
<div className='flex items-center gap-3 text-xl font-bold text-slate-300'>
@@ -162,7 +210,10 @@ export default function TVMePage() {
<div className='mt-10'>
{error && (
<p role='alert' className='mb-4 rounded-2xl border border-red-400/40 bg-red-950/50 p-4 text-xl text-red-100'>
<p
role='alert'
className='mb-4 rounded-2xl border border-red-400/40 bg-red-950/50 p-4 text-xl text-red-100'
>
{error}
</p>
)}
@@ -182,6 +233,69 @@ export default function TVMePage() {
</div>
</aside>
</div>
<div className='relative mt-10 rounded-[34px] border border-white/10 bg-black/35 p-7'>
<div className='flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between'>
<div className='max-w-2xl'>
<div className='flex items-center gap-3 text-3xl font-black text-white'>
<SlidersHorizontal className='h-9 w-9 text-rose-300' />
</div>
<p className='mt-3 text-xl leading-relaxed text-slate-300'>
</p>
</div>
<div className='w-full rounded-[28px] border border-white/10 bg-white/[0.06] p-6 lg:max-w-[560px]'>
<div className='flex items-start justify-between gap-5'>
<div>
<h3 className='text-2xl font-black text-white'>
</h3>
<p className='mt-2 text-lg leading-relaxed text-slate-300'>
/
</p>
</div>
<div className='shrink-0 rounded-2xl bg-slate-950/55 px-4 py-2 text-lg font-black text-rose-100'>
{upDownAction === 'wake-menu' ? '唤醒菜单' : '音量控制'}
</div>
</div>
<div
data-tv-preference-up-down-action
onKeyDownCapture={handleUpDownActionKeyDown}
className='mt-5 grid gap-3 sm:grid-cols-2'
>
<button
ref={wakeMenuButtonRef}
type='button'
onClick={() => handleUpDownActionChange('wake-menu')}
className={`tv-focusable flex cursor-pointer items-center justify-center gap-3 rounded-2xl px-5 py-4 text-xl font-black outline-none transition focus:ring-4 focus:ring-rose-300 ${
upDownAction === 'wake-menu'
? 'bg-rose-600 text-white shadow-lg shadow-rose-950/40'
: 'bg-white/10 text-slate-200 hover:bg-white/15'
}`}
>
<Menu className='h-6 w-6' />
</button>
<button
ref={volumeButtonRef}
type='button'
onClick={() => handleUpDownActionChange('volume')}
className={`tv-focusable flex cursor-pointer items-center justify-center gap-3 rounded-2xl px-5 py-4 text-xl font-black outline-none transition focus:ring-4 focus:ring-rose-300 ${
upDownAction === 'volume'
? 'bg-rose-600 text-white shadow-lg shadow-rose-950/40'
: 'bg-white/10 text-slate-200 hover:bg-white/15'
}`}
>
<Volume2 className='h-6 w-6' />
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</TVLayout>
+21 -14
View File
@@ -17,16 +17,16 @@ import {
SkipBack,
SkipForward,
SlidersHorizontal,
X,
Volume2,
VolumeX,
X,
} from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Suspense,
type Dispatch,
type FocusEvent,
type SetStateAction,
Suspense,
useCallback,
useEffect,
useMemo,
@@ -34,16 +34,6 @@ import {
useState,
} from 'react';
import {
deleteFavorite,
generateStorageKey,
getAllPlayRecords,
getSkipConfig,
isFavorited,
saveFavorite,
savePlayRecord,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import {
convertDanmakuFormat,
getDanmakuById,
@@ -53,6 +43,17 @@ import {
saveDanmakuDisplayState,
searchAnime,
} from '@/lib/danmaku/api';
import {
deleteFavorite,
generateStorageKey,
getAllPlayRecords,
getSkipConfig,
isFavorited,
saveFavorite,
savePlayRecord,
} from '@/lib/db.client';
import { loadTVPlayerUpDownAction } from '@/lib/tv-preferences';
import { SearchResult } from '@/lib/types';
import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
import {
@@ -241,6 +242,7 @@ function TVPlayClient() {
const [muted, setMuted] = useState(initialVolumeState.muted);
const [volume, setVolume] = useState(initialVolumeState.volume);
const [showVolumeHint, setShowVolumeHint] = useState(false);
const [upDownAction] = useState(loadTVPlayerUpDownAction);
const [seekHint, setSeekHint] = useState<{
current: number;
duration: number;
@@ -324,7 +326,7 @@ function TVPlayClient() {
setSources(data.sources);
const maxIndex = Math.max(0, (data.detail.episodes?.length || 1) - 1);
const explicitIndex = searchParams.has('index');
let safeIndex = Math.max(
const safeIndex = Math.max(
0,
Math.min(
initialIndex || data.detail.initialEpisodeIndex || 0,
@@ -922,7 +924,11 @@ function TVPlayClient() {
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
) {
event.preventDefault();
setVideoVolume(volume + (event.key === 'ArrowUp' ? 0.05 : -0.05));
if (upDownAction === 'volume') {
setVideoVolume(volume + (event.key === 'ArrowUp' ? 0.05 : -0.05));
} else {
revealPanel();
}
return;
}
@@ -958,6 +964,7 @@ function TVPlayClient() {
showDetail,
showEpisodes,
showPanel,
upDownAction,
volume,
]);
+33
View File
@@ -0,0 +1,33 @@
export type TVPlayerUpDownAction = 'wake-menu' | 'volume';
export const TV_PLAYER_UP_DOWN_ACTION_KEY = 'tv_player_up_down_action';
export const DEFAULT_TV_PLAYER_UP_DOWN_ACTION: TVPlayerUpDownAction =
'wake-menu';
export function normalizeTVPlayerUpDownAction(
value: unknown
): TVPlayerUpDownAction {
return value === 'volume' ? 'volume' : DEFAULT_TV_PLAYER_UP_DOWN_ACTION;
}
export function loadTVPlayerUpDownAction(): TVPlayerUpDownAction {
if (typeof window === 'undefined') return DEFAULT_TV_PLAYER_UP_DOWN_ACTION;
try {
return normalizeTVPlayerUpDownAction(
localStorage.getItem(TV_PLAYER_UP_DOWN_ACTION_KEY)
);
} catch {
return DEFAULT_TV_PLAYER_UP_DOWN_ACTION;
}
}
export function saveTVPlayerUpDownAction(action: TVPlayerUpDownAction): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(TV_PLAYER_UP_DOWN_ACTION_KEY, action);
} catch {
// ignore localStorage failures in private/limited modes
}
}