远程遥控完成
This commit is contained in:
@@ -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)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user