e.preventDefault()}
+ onWheel={(e) => e.preventDefault()}
+ style={{ touchAction: 'none' }}
+ />
+
+
e.stopPropagation()}
+ style={{ touchAction: 'auto' }}
+ >
+
+
+
+
+
+
+ 在线电视端
+
+
+
+
+ {devices.length > 0 ? (
+
+ {devices.map((device) => (
+
+ ))}
+
+ ) : (
+
+ {loading ? '正在查找在线电视端...' : '没有在线的 Web 电视端'}
+
+ )}
+
+
+
+
sendKey('back')} className='h-14'>
+
+
+
sendKey('home')} className='h-14'>
+
+
+
sendKey('menu')} className='h-14'>
+
+
+
+
+
sendKey('up', repeat)} repeatable className='h-16'>
+
+
+
+
+
sendKey('left', repeat)} repeatable className='h-16'>
+
+
+
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'>
+
+
+
sendKey('right', repeat)} repeatable className='h-16'>
+
+
+
+
+
sendKey('down', repeat)} repeatable className='h-16'>
+
+
+
+
+
+
+ {Array.from({ length: 10 }, (_, index) => String((index + 1) % 10)).map((digit) => (
+
+ ))}
+
+
+
+
+ {(status || selectedDevice) && (
+
+ {status || `正在控制:${selectedDevice?.deviceName}`}
+
+ )}
+
+
+ >,
+ document.body
+ );
+}
diff --git a/src/components/tv/TVRemoteReceiver.tsx b/src/components/tv/TVRemoteReceiver.tsx
new file mode 100644
index 0000000..0f31154
--- /dev/null
+++ b/src/components/tv/TVRemoteReceiver.tsx
@@ -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;
+}
diff --git a/src/components/tv/TVVirtualRemote.tsx b/src/components/tv/TVVirtualRemote.tsx
index b12d144..b037229 100644
--- a/src/components/tv/TVVirtualRemote.tsx
+++ b/src/components/tv/TVVirtualRemote.tsx
@@ -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() {