437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
'use client';
|
||
|
||
import {
|
||
BadgeCheck,
|
||
Clock3,
|
||
Copy,
|
||
Loader2,
|
||
LogOut,
|
||
Menu,
|
||
QrCode,
|
||
ShieldCheck,
|
||
SlidersHorizontal,
|
||
User,
|
||
Wifi,
|
||
Volume2,
|
||
} from 'lucide-react';
|
||
import { useRouter } from 'next/navigation';
|
||
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';
|
||
|
||
const LOCAL_REMOTE_URL_KEY = 'moontv_local_remote_url';
|
||
|
||
type MoonTVLocalRemoteBridge = {
|
||
getRemoteUrl?: () => string;
|
||
showHint?: () => void;
|
||
};
|
||
|
||
declare global {
|
||
interface Window {
|
||
MoonTVLocalRemote?: MoonTVLocalRemoteBridge;
|
||
__MOONTV_LOCAL_REMOTE_URL?: string;
|
||
}
|
||
}
|
||
|
||
type AuthInfo = {
|
||
username?: string;
|
||
role?: 'owner' | 'admin' | 'user';
|
||
timestamp?: number;
|
||
refreshExpires?: number;
|
||
};
|
||
|
||
function getRoleText(role?: AuthInfo['role']) {
|
||
switch (role) {
|
||
case 'owner':
|
||
return '站长';
|
||
case 'admin':
|
||
return '管理员';
|
||
case 'user':
|
||
return '用户';
|
||
default:
|
||
return '访客';
|
||
}
|
||
}
|
||
|
||
function formatDateTime(value?: number) {
|
||
if (!value) return '当前会话';
|
||
try {
|
||
return new Intl.DateTimeFormat('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}).format(new Date(value));
|
||
} catch {
|
||
return '当前会话';
|
||
}
|
||
}
|
||
|
||
export default function TVMePage() {
|
||
const router = useRouter();
|
||
const [ready, setReady] = useState(false);
|
||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||
const [loggingOut, setLoggingOut] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [localRemoteUrl, setLocalRemoteUrl] = useState('');
|
||
const [nativeHintAvailable, setNativeHintAvailable] = useState(false);
|
||
const [copyStatus, setCopyStatus] = 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);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const readLocalRemoteUrl = () => {
|
||
const bridgeUrl = window.MoonTVLocalRemote?.getRemoteUrl?.() || '';
|
||
setNativeHintAvailable(Boolean(window.MoonTVLocalRemote?.showHint));
|
||
setLocalRemoteUrl(
|
||
bridgeUrl ||
|
||
window.__MOONTV_LOCAL_REMOTE_URL ||
|
||
localStorage.getItem(LOCAL_REMOTE_URL_KEY) ||
|
||
''
|
||
);
|
||
};
|
||
|
||
const onLocalRemoteInfo = (event: Event) => {
|
||
const detail = (event as CustomEvent<{ url?: string }>).detail;
|
||
setLocalRemoteUrl(detail?.url || '');
|
||
};
|
||
|
||
readLocalRemoteUrl();
|
||
window.addEventListener('moontv:local-remote-info', onLocalRemoteInfo);
|
||
const timer = window.setInterval(readLocalRemoteUrl, 1500);
|
||
|
||
return () => {
|
||
window.removeEventListener('moontv:local-remote-info', onLocalRemoteInfo);
|
||
window.clearInterval(timer);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (ready && !authInfo) {
|
||
router.replace('/tv/login?redirect=/tv/me');
|
||
}
|
||
}, [authInfo, ready, router]);
|
||
|
||
const username = authInfo?.username || 'default';
|
||
const roleText = getRoleText(authInfo?.role || 'user');
|
||
const avatarText = useMemo(
|
||
() => username.trim().charAt(0).toUpperCase() || 'D',
|
||
[username]
|
||
);
|
||
|
||
const handleLogout = async () => {
|
||
setLoggingOut(true);
|
||
setError('');
|
||
try {
|
||
await fetch('/api/logout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
} catch {
|
||
setError('登出请求失败,已清理本地登录状态。');
|
||
} finally {
|
||
clearAuthCookie();
|
||
router.replace('/tv/login');
|
||
}
|
||
};
|
||
|
||
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 });
|
||
});
|
||
};
|
||
|
||
|
||
const showLocalRemoteHint = () => {
|
||
window.MoonTVLocalRemote?.showHint?.();
|
||
};
|
||
|
||
const copyLocalRemoteUrl = async () => {
|
||
if (!localRemoteUrl) return;
|
||
try {
|
||
await navigator.clipboard?.writeText(localRemoteUrl);
|
||
setCopyStatus('已复制');
|
||
} catch {
|
||
setCopyStatus('请手动输入电视上的地址');
|
||
}
|
||
window.setTimeout(() => setCopyStatus(''), 2200);
|
||
};
|
||
|
||
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>
|
||
</section>
|
||
</TVLayout>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<TVLayout>
|
||
<section className='mx-auto max-w-6xl overflow-hidden rounded-[42px] border border-white/10 bg-slate-950/75 shadow-2xl shadow-black/60 backdrop-blur-xl'>
|
||
<div className='relative p-10 md:p-12'>
|
||
<div className='pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_18%_10%,rgba(244,63,94,0.25),transparent_34%),radial-gradient(circle_at_85%_0%,rgba(67,56,202,0.28),transparent_30%)]' />
|
||
<div className='relative grid gap-10 lg:grid-cols-[1fr_340px]'>
|
||
<div>
|
||
<div className='inline-flex items-center gap-3 rounded-full border border-emerald-400/30 bg-emerald-500/15 px-5 py-2 text-xl font-bold text-emerald-200'>
|
||
<BadgeCheck className='h-6 w-6' />
|
||
已登录电视端
|
||
</div>
|
||
<div className='mt-8 flex items-center gap-7'>
|
||
<div className='flex h-28 w-28 shrink-0 items-center justify-center rounded-[32px] bg-gradient-to-br from-rose-500 to-indigo-600 text-6xl font-black text-white shadow-2xl shadow-rose-950/50'>
|
||
{avatarText}
|
||
</div>
|
||
<div>
|
||
<h1 className='text-6xl font-black tracking-tight text-white md:text-7xl'>
|
||
{username}
|
||
</h1>
|
||
<p className='mt-3 text-2xl text-slate-300'>
|
||
欢迎回来,继续享受大屏观影。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='mt-10 grid gap-4 md:grid-cols-2'>
|
||
<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'>
|
||
<ShieldCheck className='h-6 w-6 text-rose-300' />
|
||
账号角色
|
||
</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'>
|
||
<Clock3 className='h-6 w-6 text-indigo-300' />
|
||
登录时间
|
||
</div>
|
||
<div className='mt-4 text-3xl font-black text-white'>
|
||
{formatDateTime(authInfo.timestamp)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside className='flex flex-col justify-between rounded-[34px] border border-white/10 bg-black/35 p-7'>
|
||
<div>
|
||
<div className='flex h-16 w-16 items-center justify-center rounded-3xl bg-white/10'>
|
||
<User className='h-9 w-9 text-rose-300' />
|
||
</div>
|
||
<h2 className='mt-6 text-4xl font-black'>我的账号</h2>
|
||
<p className='mt-4 text-xl leading-relaxed text-slate-300'>
|
||
当前设备已绑定该账号。登出后会清除电视端会话,并返回扫码登录页。
|
||
</p>
|
||
</div>
|
||
|
||
<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'
|
||
>
|
||
{error}
|
||
</p>
|
||
)}
|
||
<button
|
||
type='button'
|
||
onClick={handleLogout}
|
||
disabled={loggingOut}
|
||
className='tv-focusable flex w-full cursor-pointer items-center justify-center gap-3 rounded-3xl bg-rose-600 px-7 py-5 text-3xl font-black text-white outline-none transition duration-200 hover:bg-rose-500 focus:ring-4 focus:ring-rose-300 disabled:cursor-not-allowed disabled:opacity-70'
|
||
>
|
||
{loggingOut ? (
|
||
<Loader2 className='h-8 w-8 animate-spin' />
|
||
) : (
|
||
<LogOut className='h-8 w-8' />
|
||
)}
|
||
{loggingOut ? '正在登出' : '登出'}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<div className='relative mt-10 overflow-hidden rounded-[34px] border border-indigo-300/20 bg-indigo-950/35 p-7'>
|
||
<div className='pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_8%_0%,rgba(99,102,241,0.35),transparent_34%),radial-gradient(circle_at_95%_10%,rgba(16,185,129,0.22),transparent_28%)]' />
|
||
<div className='relative flex flex-col gap-7 lg:flex-row lg:items-start lg:justify-between'>
|
||
<div className='max-w-2xl'>
|
||
<div className='inline-flex items-center gap-3 rounded-full border border-emerald-300/25 bg-emerald-400/10 px-4 py-2 text-lg font-black text-emerald-200'>
|
||
<Wifi className='h-6 w-6' />
|
||
局域网直连
|
||
</div>
|
||
<h2 className='mt-5 flex items-center gap-3 text-4xl font-black tracking-tight text-white'>
|
||
<QrCode className='h-10 w-10 text-indigo-200' />
|
||
手机扫码遥控
|
||
</h2>
|
||
<p className='mt-4 text-xl leading-relaxed text-slate-300'>
|
||
在同一 Wi‑Fi 下用手机打开遥控地址,不经过服务器,方向键和播放控制更低延迟。
|
||
</p>
|
||
|
||
{localRemoteUrl ? (
|
||
<div className='mt-6 rounded-3xl border border-white/10 bg-black/30 p-5'>
|
||
<div className='text-base font-bold text-indigo-200'>遥控地址</div>
|
||
<div className='mt-2 break-all font-mono text-lg font-black text-white'>
|
||
{localRemoteUrl}
|
||
</div>
|
||
<div className='mt-5 flex flex-wrap gap-3'>
|
||
{nativeHintAvailable && (
|
||
<button
|
||
type='button'
|
||
onClick={showLocalRemoteHint}
|
||
className='tv-focusable inline-flex cursor-pointer items-center justify-center gap-2 rounded-2xl bg-indigo-600 px-5 py-4 text-lg font-black text-white outline-none transition hover:bg-indigo-500 focus:ring-4 focus:ring-indigo-300'
|
||
>
|
||
<QrCode className='h-6 w-6' />
|
||
在电视上显示
|
||
</button>
|
||
)}
|
||
<button
|
||
type='button'
|
||
onClick={copyLocalRemoteUrl}
|
||
className='tv-focusable inline-flex cursor-pointer items-center justify-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-lg font-black text-white outline-none transition hover:bg-white/15 focus:ring-4 focus:ring-indigo-300'
|
||
>
|
||
<Copy className='h-6 w-6' />
|
||
复制地址
|
||
</button>
|
||
</div>
|
||
{copyStatus && (
|
||
<div className='mt-3 text-base font-bold text-emerald-200'>
|
||
{copyStatus}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className='mt-6 rounded-3xl border border-amber-300/20 bg-amber-400/10 p-5 text-lg leading-relaxed text-amber-100'>
|
||
当前页面未检测到 APK 内置局域网遥控服务。请使用新版 APK 打开电视端,或查看电视左上角的临时遥控地址。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{localRemoteUrl && (
|
||
<div className='shrink-0 rounded-[32px] border border-white/15 bg-white p-4 shadow-2xl shadow-black/40'>
|
||
<img
|
||
src={`/api/auth/qr/image?data=${encodeURIComponent(localRemoteUrl)}`}
|
||
alt='局域网遥控地址二维码'
|
||
className='h-64 w-64 rounded-2xl'
|
||
/>
|
||
<div className='mt-3 text-center text-base font-black text-slate-950'>
|
||
手机扫码打开遥控器
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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>
|
||
</section>
|
||
</TVLayout>
|
||
);
|
||
}
|