web tv模式增加上下键可改为菜单模式
This commit is contained in:
+118
-4
@@ -5,13 +5,28 @@ import {
|
|||||||
Clock3,
|
Clock3,
|
||||||
Loader2,
|
Loader2,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Menu,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
User,
|
User,
|
||||||
|
Volume2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { 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';
|
import TVLayout from '@/components/tv/TVLayout';
|
||||||
|
|
||||||
@@ -56,10 +71,16 @@ export default function TVMePage() {
|
|||||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
const auth = getAuthInfoFromBrowserCookie();
|
const auth = getAuthInfoFromBrowserCookie();
|
||||||
setAuthInfo(auth);
|
setAuthInfo(auth);
|
||||||
|
setUpDownAction(loadTVPlayerUpDownAction());
|
||||||
setReady(true);
|
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) {
|
if (!ready || !authInfo) {
|
||||||
return (
|
return (
|
||||||
<TVLayout>
|
<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'>
|
<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' />
|
<Loader2 className='mx-auto h-16 w-16 animate-spin text-rose-400' />
|
||||||
<h1 className='mt-6 text-5xl font-black'>正在读取登录信息</h1>
|
<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>
|
</section>
|
||||||
</TVLayout>
|
</TVLayout>
|
||||||
);
|
);
|
||||||
@@ -135,7 +181,9 @@ export default function TVMePage() {
|
|||||||
<ShieldCheck className='h-6 w-6 text-rose-300' />
|
<ShieldCheck className='h-6 w-6 text-rose-300' />
|
||||||
账号角色
|
账号角色
|
||||||
</div>
|
</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>
|
||||||
<div className='rounded-[28px] border border-white/10 bg-white/[0.06] p-6'>
|
<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'>
|
<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'>
|
<div className='mt-10'>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -182,6 +233,69 @@ export default function TVMePage() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</TVLayout>
|
</TVLayout>
|
||||||
|
|||||||
+21
-14
@@ -17,16 +17,16 @@ import {
|
|||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
X,
|
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Suspense,
|
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type FocusEvent,
|
type FocusEvent,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -34,16 +34,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
deleteFavorite,
|
|
||||||
generateStorageKey,
|
|
||||||
getAllPlayRecords,
|
|
||||||
getSkipConfig,
|
|
||||||
isFavorited,
|
|
||||||
saveFavorite,
|
|
||||||
savePlayRecord,
|
|
||||||
} from '@/lib/db.client';
|
|
||||||
import { SearchResult } from '@/lib/types';
|
|
||||||
import {
|
import {
|
||||||
convertDanmakuFormat,
|
convertDanmakuFormat,
|
||||||
getDanmakuById,
|
getDanmakuById,
|
||||||
@@ -53,6 +43,17 @@ import {
|
|||||||
saveDanmakuDisplayState,
|
saveDanmakuDisplayState,
|
||||||
searchAnime,
|
searchAnime,
|
||||||
} from '@/lib/danmaku/api';
|
} 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 TVNativeVideo from '@/components/tv/player/TVNativeVideo';
|
||||||
import {
|
import {
|
||||||
@@ -241,6 +242,7 @@ function TVPlayClient() {
|
|||||||
const [muted, setMuted] = useState(initialVolumeState.muted);
|
const [muted, setMuted] = useState(initialVolumeState.muted);
|
||||||
const [volume, setVolume] = useState(initialVolumeState.volume);
|
const [volume, setVolume] = useState(initialVolumeState.volume);
|
||||||
const [showVolumeHint, setShowVolumeHint] = useState(false);
|
const [showVolumeHint, setShowVolumeHint] = useState(false);
|
||||||
|
const [upDownAction] = useState(loadTVPlayerUpDownAction);
|
||||||
const [seekHint, setSeekHint] = useState<{
|
const [seekHint, setSeekHint] = useState<{
|
||||||
current: number;
|
current: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
@@ -324,7 +326,7 @@ function TVPlayClient() {
|
|||||||
setSources(data.sources);
|
setSources(data.sources);
|
||||||
const maxIndex = Math.max(0, (data.detail.episodes?.length || 1) - 1);
|
const maxIndex = Math.max(0, (data.detail.episodes?.length || 1) - 1);
|
||||||
const explicitIndex = searchParams.has('index');
|
const explicitIndex = searchParams.has('index');
|
||||||
let safeIndex = Math.max(
|
const safeIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
initialIndex || data.detail.initialEpisodeIndex || 0,
|
initialIndex || data.detail.initialEpisodeIndex || 0,
|
||||||
@@ -922,7 +924,11 @@ function TVPlayClient() {
|
|||||||
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
|
(event.key === 'ArrowUp' || event.key === 'ArrowDown')
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,6 +964,7 @@ function TVPlayClient() {
|
|||||||
showDetail,
|
showDetail,
|
||||||
showEpisodes,
|
showEpisodes,
|
||||||
showPanel,
|
showPanel,
|
||||||
|
upDownAction,
|
||||||
volume,
|
volume,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user