远程遥控完成

This commit is contained in:
mtvpls
2026-05-30 22:34:07 +08:00
parent 30554fcd77
commit 3a030cce5a
13 changed files with 1036 additions and 88 deletions
+17 -4
View File
@@ -49,6 +49,7 @@ import { FavoritesPanel } from './FavoritesPanel';
import { NotificationPanel } from './NotificationPanel';
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
import { PersonalCenterPanel } from './PersonalCenterPanel';
import TVRemotePanel from './tv/TVRemotePanel';
import { useVersionCheck } from './VersionCheckProvider';
import { VersionPanel } from './VersionPanel';
@@ -76,6 +77,7 @@ export const UserMenu: React.FC = () => {
const [isReportOpen, setIsReportOpen] = useState(false);
const [isDownloadManagementOpen, setIsDownloadManagementOpen] =
useState(false);
const [isTVRemoteOpen, setIsTVRemoteOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [displayStorageType, setDisplayStorageType] =
@@ -118,7 +120,8 @@ export const UserMenu: React.FC = () => {
isEcoAppsOpen ||
isReportOpen ||
isDownloadManagementOpen ||
isTvQrScannerOpen
isTvQrScannerOpen ||
isTVRemoteOpen
) {
const body = document.body;
const html = document.documentElement;
@@ -149,6 +152,7 @@ export const UserMenu: React.FC = () => {
isReportOpen,
isDownloadManagementOpen,
isTvQrScannerOpen,
isTVRemoteOpen,
]);
// 设置相关状态
@@ -4305,11 +4309,14 @@ export const UserMenu: React.FC = () => {
</button>
<button
type='button'
disabled
className='inline-flex w-full cursor-not-allowed items-center justify-center gap-2 rounded-xl border border-dashed border-slate-300 bg-white px-4 py-3 text-sm font-bold text-slate-400 dark:border-white/10 dark:bg-slate-900/70 dark:text-slate-500'
onClick={() => {
setIsSubscribeOpen(false);
setIsTVRemoteOpen(true);
}}
className='inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white transition hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500/70 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200'
>
<Sliders className='h-4 w-4' />
</button>
</div>
@@ -4866,6 +4873,12 @@ export const UserMenu: React.FC = () => {
getDeviceIcon={getDeviceIcon}
/>
<TVRemotePanel
isOpen={isTVRemoteOpen}
mounted={mounted}
onClose={() => setIsTVRemoteOpen(false)}
/>
{/* 使用 Portal 将生态应用面板渲染到 document.body */}
{isEcoAppsOpen && mounted && createPortal(ecoAppsPanel, document.body)}
+7 -1
View File
@@ -19,6 +19,10 @@ const WATCH_ROOM_SCREEN_PATH = '/watch-room/screen';
const WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY = 'watch_room_no_connect_timestamp';
const WATCH_ROOM_NO_CONNECT_TTL_MS = 10 * 60 * 1000;
function isTVPagePath(pathname: string | null) {
return pathname === '/tv' || Boolean(pathname?.startsWith('/tv/'));
}
interface WatchRoomContextType {
socket: WatchRoomSocket | null;
isConnected: boolean;
@@ -143,6 +147,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
const noConnect = window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1';
const lastActiveAt = Number(window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY) || 0);
const isScreenPage = pathname === WATCH_ROOM_SCREEN_PATH;
const isTVPage = isTVPagePath(pathname);
const isExpired = !lastActiveAt || Date.now() - lastActiveAt > WATCH_ROOM_NO_CONNECT_TTL_MS;
if (noConnect && isExpired) {
@@ -150,7 +155,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
window.localStorage.removeItem(WATCH_ROOM_NO_CONNECT_TIMESTAMP_KEY);
}
setShouldDisableWatchRoomConnection(!isScreenPage && noConnect && !isExpired);
setShouldDisableWatchRoomConnection(isTVPage || (!isScreenPage && noConnect && !isExpired));
};
refreshWatchRoomConnectionState();
@@ -215,6 +220,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
}
if (shouldDisableWatchRoomConnection) {
watchRoom.disconnect();
setConfig({
enabled: false,
serverType: 'internal',
+381
View File
@@ -0,0 +1,381 @@
'use client';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
CornerDownLeft,
Home,
Keyboard,
Loader2,
Menu,
Monitor,
Power,
RefreshCw,
RotateCcw,
Send,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type {
TVRemoteDevice,
TVRemoteKey,
TVRemoteTextMode,
} from '@/lib/tv-remote-types';
type TVRemotePanelProps = {
isOpen: boolean;
mounted: boolean;
onClose: () => void;
};
type SocketResponse<T = unknown> = {
success: boolean;
error?: string;
} & T;
function RemoteButton({
label,
onPress,
repeatable = false,
className = '',
children,
}: {
label: string;
onPress: (repeat?: boolean) => void;
repeatable?: boolean;
className?: string;
children: React.ReactNode;
}) {
const delayRef = useRef<number | null>(null);
const intervalRef = useRef<number | null>(null);
const clearRepeat = () => {
if (delayRef.current) window.clearTimeout(delayRef.current);
if (intervalRef.current) window.clearInterval(intervalRef.current);
delayRef.current = null;
intervalRef.current = null;
};
useEffect(() => clearRepeat, []);
return (
<button
type='button'
aria-label={label}
title={label}
onClick={() => {
if (!repeatable) onPress(false);
}}
onPointerDown={(event) => {
event.preventDefault();
if (!repeatable) return;
onPress(false);
clearRepeat();
delayRef.current = window.setTimeout(() => {
intervalRef.current = window.setInterval(() => onPress(true), 130);
}, 360);
}}
onPointerUp={clearRepeat}
onPointerCancel={clearRepeat}
onPointerLeave={clearRepeat}
className={`flex cursor-pointer items-center justify-center rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-sm transition hover:border-rose-300 hover:bg-rose-50 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/16 ${className}`}
>
{children}
</button>
);
}
export default function TVRemotePanel({
isOpen,
mounted,
onClose,
}: TVRemotePanelProps) {
const [devices, setDevices] = useState<TVRemoteDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState('');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState('');
const [text, setText] = useState('');
const selectedDevice =
devices.find((device) => device.deviceId === selectedDeviceId) || null;
const loadDevices = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/tv-remote/devices', {
cache: 'no-store',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || '无法获取电视端列表');
}
const nextDevices = Array.isArray(data.devices) ? data.devices : [];
setDevices(nextDevices);
setSelectedDeviceId((current) => {
if (nextDevices.some((device: TVRemoteDevice) => device.deviceId === current)) {
return current;
}
return nextDevices[0]?.deviceId || '';
});
setStatus(nextDevices.length ? '' : '没有在线的 Web 电视端');
} catch (error) {
setDevices([]);
setSelectedDeviceId('');
setStatus(error instanceof Error ? error.message : '无法获取电视端列表');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!isOpen) return;
void loadDevices();
return () => {
setDevices([]);
setSelectedDeviceId('');
setStatus('');
setLoading(false);
};
}, [isOpen, loadDevices]);
const sendKey = async (key: TVRemoteKey, repeat = false, digit?: string) => {
if (!selectedDeviceId) return;
try {
const response = await fetch('/api/tv-remote/key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deviceId: selectedDeviceId,
command: { key, repeat, digit },
}),
});
const data: SocketResponse = await response.json().catch(() => ({
success: false,
error: '发送失败',
}));
if (!response.ok || !data.success) {
setStatus(data.error || '发送失败');
}
} catch {
setStatus('发送失败');
}
};
const sendText = async (mode: TVRemoteTextMode, value = text) => {
if (!selectedDeviceId) return;
try {
const response = await fetch('/api/tv-remote/text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deviceId: selectedDeviceId,
command: { mode, text: value },
}),
});
const data: SocketResponse = await response.json().catch(() => ({
success: false,
error: '文本发送失败',
}));
if (!response.ok || !data.success) {
setStatus(data.error || '文本发送失败');
} else if (mode === 'append' || mode === 'replace') {
setStatus('文本已发送到电视端输入框');
}
} catch {
setStatus('文本发送失败');
}
};
if (!isOpen || !mounted) return null;
return createPortal(
<>
<div
className='fixed inset-0 z-[1000] bg-black/60 backdrop-blur-sm'
onClick={onClose}
onTouchMove={(e) => e.preventDefault()}
onWheel={(e) => e.preventDefault()}
style={{ touchAction: 'none' }}
/>
<div className='fixed inset-x-3 top-1/2 z-[1001] mx-auto max-h-[94vh] max-w-md -translate-y-1/2 overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl shadow-black/30 dark:border-white/10 dark:bg-slate-950'>
<div
className='max-h-[94vh] overflow-y-auto p-5'
data-panel-content
onTouchMove={(e) => e.stopPropagation()}
style={{ touchAction: 'auto' }}
>
<div className='mb-4 flex items-start justify-between gap-4'>
<div>
<div className='inline-flex items-center gap-2 rounded-full bg-rose-500/10 px-3 py-1 text-xs font-black text-rose-600 dark:text-rose-300'>
<Power className='h-4 w-4' />
TV REMOTE
</div>
<h3 className='mt-3 text-2xl font-black text-slate-950 dark:text-white'>
</h3>
</div>
<button
type='button'
onClick={onClose}
className='flex h-10 w-10 cursor-pointer items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:hover:bg-white/10 dark:hover:text-white'
aria-label='关闭遥控器'
>
<X className='h-5 w-5' />
</button>
</div>
<div className='mb-4 rounded-2xl border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/[0.04]'>
<div className='mb-2 flex items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-sm font-black text-slate-700 dark:text-slate-200'>
<Monitor className='h-4 w-4' />
线
</div>
<button
type='button'
onClick={() => loadDevices()}
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-slate-500 transition hover:bg-white hover:text-slate-950 dark:hover:bg-white/10 dark:hover:text-white'
aria-label='刷新电视端列表'
>
{loading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<RefreshCw className='h-4 w-4' />
)}
</button>
</div>
{devices.length > 0 ? (
<div className='grid gap-2'>
{devices.map((device) => (
<button
key={device.deviceId}
type='button'
onClick={() => setSelectedDeviceId(device.deviceId)}
className={`cursor-pointer rounded-xl border px-3 py-2 text-left transition ${
selectedDeviceId === device.deviceId
? 'border-rose-400 bg-rose-50 text-rose-950 dark:border-rose-400 dark:bg-rose-500/15 dark:text-rose-100'
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300 dark:border-white/10 dark:bg-slate-900/70 dark:text-slate-300'
}`}
>
<div className='truncate text-sm font-black'>
{device.deviceName}
</div>
<div className='mt-0.5 truncate text-xs opacity-70'>
{device.currentPath}
</div>
</button>
))}
</div>
) : (
<div className='rounded-xl border border-dashed border-slate-300 bg-white px-3 py-4 text-center text-sm font-semibold text-slate-500 dark:border-white/10 dark:bg-slate-900/70 dark:text-slate-400'>
{loading ? '正在查找在线电视端...' : '没有在线的 Web 电视端'}
</div>
)}
</div>
<div className='grid grid-cols-3 gap-3'>
<RemoteButton label='返回' onPress={() => sendKey('back')} className='h-14'>
<RotateCcw className='h-6 w-6' />
</RemoteButton>
<RemoteButton label='主页' onPress={() => sendKey('home')} className='h-14'>
<Home className='h-6 w-6' />
</RemoteButton>
<RemoteButton label='菜单' onPress={() => sendKey('menu')} className='h-14'>
<Menu className='h-6 w-6' />
</RemoteButton>
<div />
<RemoteButton label='上' onPress={(repeat) => sendKey('up', repeat)} repeatable className='h-16'>
<ChevronUp className='h-9 w-9' />
</RemoteButton>
<div />
<RemoteButton label='左' onPress={(repeat) => sendKey('left', repeat)} repeatable className='h-16'>
<ChevronLeft className='h-9 w-9' />
</RemoteButton>
<RemoteButton label='确认' onPress={() => sendKey('ok')} className='h-16 rounded-full bg-slate-950 text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200'>
<CornerDownLeft className='h-8 w-8' />
</RemoteButton>
<RemoteButton label='右' onPress={(repeat) => sendKey('right', repeat)} repeatable className='h-16'>
<ChevronRight className='h-9 w-9' />
</RemoteButton>
<div />
<RemoteButton label='下' onPress={(repeat) => sendKey('down', repeat)} repeatable className='h-16'>
<ChevronDown className='h-9 w-9' />
</RemoteButton>
<div />
</div>
<div className='mt-4 grid grid-cols-5 gap-2'>
{Array.from({ length: 10 }, (_, index) => String((index + 1) % 10)).map((digit) => (
<button
key={digit}
type='button'
onClick={() => sendKey('digit', false, digit)}
className='h-11 cursor-pointer rounded-xl border border-slate-200 bg-white text-lg font-black text-slate-900 transition hover:border-rose-300 hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/16'
>
{digit}
</button>
))}
</div>
<div className='mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-white/[0.04]'>
<div className='mb-2 flex items-center gap-2 text-sm font-black text-slate-700 dark:text-slate-200'>
<Keyboard className='h-4 w-4' />
</div>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={3}
placeholder='输入后发送到电视端当前输入框'
className='w-full resize-none rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-rose-400 focus:ring-2 focus:ring-rose-400/20 dark:border-white/10 dark:bg-slate-900 dark:text-white'
/>
<div className='mt-2 grid grid-cols-4 gap-2'>
<button
type='button'
onClick={() => sendText('replace')}
className='col-span-2 inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-rose-600 px-3 py-2 text-sm font-black text-white transition hover:bg-rose-700'
>
<Send className='h-4 w-4' />
</button>
<button
type='button'
onClick={() => sendText('backspace', '')}
className='cursor-pointer rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:bg-slate-900 dark:text-slate-200'
>
退
</button>
<button
type='button'
onClick={() => {
setText('');
sendText('clear', '');
}}
className='cursor-pointer rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:bg-slate-900 dark:text-slate-200'
>
</button>
</div>
</div>
{(status || selectedDevice) && (
<p className='mt-3 rounded-2xl bg-slate-100 px-3 py-2 text-center text-xs font-semibold text-slate-600 dark:bg-white/10 dark:text-slate-300'>
{status || `正在控制:${selectedDevice?.deviceName}`}
</p>
)}
</div>
</div>
</>,
document.body
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useEffect } from 'react';
import { type Socket,io } from 'socket.io-client';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import {
applyTVRemoteText,
fireTVRemoteKey,
} from '@/lib/tv-remote-core';
import type {
TVRemoteKeyCommand,
TVRemoteTextCommand,
} from '@/lib/tv-remote-types';
const DEVICE_ID_KEY = 'moontv_tv_remote_device_id';
type TVRemoteReceiverSingleton = {
socket: Socket | null;
refCount: number;
disconnectTimer: number | null;
};
const receiverState: TVRemoteReceiverSingleton = {
socket: null,
refCount: 0,
disconnectTimer: null,
};
function getDeviceId() {
let id = localStorage.getItem(DEVICE_ID_KEY);
if (!id) {
id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `tv-${Date.now()}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem(DEVICE_ID_KEY, id);
}
return id;
}
function getDeviceName() {
const ua = navigator.userAgent;
if (/Android/i.test(ua)) return 'Android TV Web';
if (/Windows/i.test(ua)) return 'Windows TV Web';
if (/Macintosh|Mac OS/i.test(ua)) return 'Mac TV Web';
return 'Web TV';
}
export default function TVRemoteReceiver() {
useEffect(() => {
const auth = getAuthInfoFromBrowserCookie();
if (!auth?.username) return;
receiverState.refCount += 1;
if (receiverState.disconnectTimer) {
window.clearTimeout(receiverState.disconnectTimer);
receiverState.disconnectTimer = null;
}
if (!receiverState.socket) {
receiverState.socket = io({
path: '/socket.io',
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
}
const socket = receiverState.socket;
const register = () => {
socket.timeout(5000).emit(
'tv-remote:register-tv',
{
deviceId: getDeviceId(),
deviceName: getDeviceName(),
currentPath: window.location.pathname,
title: document.title,
},
(error: Error | null, response?: { success: boolean; error?: string }) => {
if (error || !response?.success) {
// eslint-disable-next-line no-console
console.warn('[TVRemote] TV registration failed:', error || response?.error);
}
}
);
};
const updateState = () => {
socket.emit('tv-remote:tv-state', {
deviceId: getDeviceId(),
currentPath: window.location.pathname,
title: document.title,
});
};
socket.on('connect', register);
socket.on('tv-remote:key', (command: TVRemoteKeyCommand) => {
fireTVRemoteKey(command);
});
socket.on('tv-remote:text', (command: TVRemoteTextCommand) => {
applyTVRemoteText(command);
});
const interval = window.setInterval(updateState, 10000);
const onVisibilityChange = () => {
if (!document.hidden) updateState();
};
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('focus', updateState);
return () => {
window.clearInterval(interval);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('focus', updateState);
socket.off('connect', register);
socket.off('tv-remote:key');
socket.off('tv-remote:text');
receiverState.refCount = Math.max(0, receiverState.refCount - 1);
if (receiverState.refCount === 0) {
receiverState.disconnectTimer = window.setTimeout(() => {
if (receiverState.refCount > 0) return;
receiverState.socket?.disconnect();
receiverState.socket = null;
receiverState.disconnectTimer = null;
}, 1000);
}
};
}, []);
return null;
}
+9 -46
View File
@@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { fireTVRemoteKey } from '@/lib/tv-remote-core';
const focusableSelector = [
'a[href]',
@@ -190,44 +191,6 @@ function activateFocused() {
}
}
const keys = {
up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
ok: { key: 'Enter', code: 'Enter', keyCode: 13 },
back: { key: 'Escape', code: 'Escape', keyCode: 27 },
menu: { key: 'ContextMenu', code: 'ContextMenu', keyCode: 93 },
home: { key: 'Home', code: 'Home', keyCode: 36 },
};
function fireRemoteKey(name: keyof typeof keys, repeat = false) {
const cfg = keys[name];
const eventInit: KeyboardEventInit = {
key: cfg.key,
code: cfg.code,
repeat,
bubbles: true,
cancelable: true,
};
const down = new KeyboardEvent('keydown', eventInit);
const up = new KeyboardEvent('keyup', eventInit);
Object.defineProperty(down, 'keyCode', { get: () => cfg.keyCode });
Object.defineProperty(down, 'which', { get: () => cfg.keyCode });
Object.defineProperty(up, 'keyCode', { get: () => cfg.keyCode });
Object.defineProperty(up, 'which', { get: () => cfg.keyCode });
document.activeElement?.dispatchEvent(down);
window.dispatchEvent(down);
document.dispatchEvent(down);
document.activeElement?.dispatchEvent(up);
window.dispatchEvent(up);
document.dispatchEvent(up);
}
function RemoteButton({
label,
onClick,
@@ -384,34 +347,34 @@ export default function TVVirtualRemote() {
</div>
<div className='grid grid-cols-3 gap-3'>
<RemoteButton label='返回' onClick={() => fireRemoteKey('back')} className='h-14'>
<RemoteButton label='返回' onClick={() => fireTVRemoteKey('back')} className='h-14'>
<RotateCcw className='h-6 w-6' />
</RemoteButton>
<RemoteButton label='主页' onClick={() => fireRemoteKey('home')} className='h-14'>
<RemoteButton label='主页' onClick={() => fireTVRemoteKey('home')} className='h-14'>
<Home className='h-6 w-6' />
</RemoteButton>
<RemoteButton label='菜单' onClick={() => fireRemoteKey('menu')} className='h-14'>
<RemoteButton label='菜单' onClick={() => fireTVRemoteKey('menu')} className='h-14'>
<Menu className='h-6 w-6' />
</RemoteButton>
<div />
<RemoteButton label='上' onClick={() => fireRemoteKey('up')} onRepeat={() => fireRemoteKey('up', true)} repeatable className='h-16'>
<RemoteButton label='上' onClick={() => fireTVRemoteKey('up')} onRepeat={() => fireTVRemoteKey('up', true)} repeatable className='h-16'>
<ChevronUp className='h-9 w-9' />
</RemoteButton>
<div />
<RemoteButton label='左' onClick={() => fireRemoteKey('left')} onRepeat={() => fireRemoteKey('left', true)} repeatable className='h-16'>
<RemoteButton label='左' onClick={() => fireTVRemoteKey('left')} onRepeat={() => fireTVRemoteKey('left', true)} repeatable className='h-16'>
<ChevronLeft className='h-9 w-9' />
</RemoteButton>
<RemoteButton label='确认' onClick={() => fireRemoteKey('ok')} className='h-16 rounded-full bg-white text-black hover:bg-slate-200'>
<RemoteButton label='确认' onClick={() => fireTVRemoteKey('ok')} className='h-16 rounded-full bg-white text-black hover:bg-slate-200'>
<CornerDownLeft className='h-8 w-8' />
</RemoteButton>
<RemoteButton label='右' onClick={() => fireRemoteKey('right')} onRepeat={() => fireRemoteKey('right', true)} repeatable className='h-16'>
<RemoteButton label='右' onClick={() => fireTVRemoteKey('right')} onRepeat={() => fireTVRemoteKey('right', true)} repeatable className='h-16'>
<ChevronRight className='h-9 w-9' />
</RemoteButton>
<div />
<RemoteButton label='下' onClick={() => fireRemoteKey('down')} onRepeat={() => fireRemoteKey('down', true)} repeatable className='h-16'>
<RemoteButton label='下' onClick={() => fireTVRemoteKey('down')} onRepeat={() => fireTVRemoteKey('down', true)} repeatable className='h-16'>
<ChevronDown className='h-9 w-9' />
</RemoteButton>
<div />