远程遥控完成

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
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
const { listTVRemoteDevices } = require('@/lib/tv-remote-hub');
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
return NextResponse.json({
devices: listTVRemoteDevices(authInfo.username),
});
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import type { TVRemoteKeyCommand } from '@/lib/tv-remote-types';
const { sendTVRemoteCommand } = require('@/lib/tv-remote-hub');
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const body = await request.json().catch(() => null) as {
deviceId?: string;
command?: TVRemoteKeyCommand;
} | null;
if (!body?.deviceId || !body.command?.key) {
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
}
const result = sendTVRemoteCommand(
authInfo.username,
body.deviceId,
'tv-remote:key',
body.command
);
return NextResponse.json(result, { status: result.success ? 200 : 404 });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import type { TVRemoteTextCommand } from '@/lib/tv-remote-types';
const { sendTVRemoteCommand } = require('@/lib/tv-remote-hub');
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const body = await request.json().catch(() => null) as {
deviceId?: string;
command?: TVRemoteTextCommand;
} | null;
if (!body?.deviceId || !body.command?.mode) {
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
}
const result = sendTVRemoteCommand(
authInfo.username,
body.deviceId,
'tv-remote:text',
body.command
);
return NextResponse.json(result, { status: result.success ? 200 : 404 });
}
+8 -1
View File
@@ -1,9 +1,16 @@
import { ReactNode } from 'react';
import TVRemoteReceiver from '@/components/tv/TVRemoteReceiver';
export const metadata = {
title: 'TV - MoonTV Plus',
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
return (
<>
{children}
<TVRemoteReceiver />
</>
);
}
+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 />
+123
View File
@@ -0,0 +1,123 @@
'use client';
import type {
TVRemoteKey,
TVRemoteKeyCommand,
TVRemoteTextCommand,
} from './tv-remote-types';
const keyConfigs: Record<
TVRemoteKey,
{ key: string; code: string; keyCode: number }
> = {
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 },
playPause: { key: 'Enter', code: 'Enter', keyCode: 13 },
pageUp: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
pageDown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
digit: { key: '0', code: 'Digit0', keyCode: 48 },
};
function dispatchKeyboardEvent(type: 'keydown' | 'keyup', cfg: {
key: string;
code: string;
keyCode: number;
}, repeat = false) {
const event = new KeyboardEvent(type, {
key: cfg.key,
code: cfg.code,
repeat,
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'keyCode', { get: () => cfg.keyCode });
Object.defineProperty(event, 'which', { get: () => cfg.keyCode });
document.activeElement?.dispatchEvent(event);
window.dispatchEvent(event);
document.dispatchEvent(event);
}
export function fireTVRemoteKey(command: TVRemoteKey | TVRemoteKeyCommand, repeat = false) {
const normalized =
typeof command === 'string'
? { key: command, repeat }
: command;
let cfg = keyConfigs[normalized.key];
if (normalized.key === 'digit') {
const digit = /^[0-9]$/.test(normalized.digit || '')
? normalized.digit || '0'
: '0';
cfg = {
key: digit,
code: `Digit${digit}`,
keyCode: 48 + Number(digit),
};
}
dispatchKeyboardEvent('keydown', cfg, Boolean(normalized.repeat));
dispatchKeyboardEvent('keyup', cfg, Boolean(normalized.repeat));
}
function setNativeValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
const prototype = Object.getPrototypeOf(element);
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
descriptor?.set?.call(element, value);
}
function getTextTarget() {
const active = document.activeElement;
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
return active;
}
return document.querySelector<HTMLInputElement | HTMLTextAreaElement>(
'input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly])'
);
}
export function applyTVRemoteText(command: TVRemoteTextCommand) {
const target = getTextTarget();
if (!target) return false;
target.focus({ preventScroll: true });
const start = target.selectionStart ?? target.value.length;
const end = target.selectionEnd ?? target.value.length;
const text = command.text || '';
let next = target.value;
let nextCaret = start;
if (command.mode === 'replace') {
next = text;
nextCaret = next.length;
} else if (command.mode === 'append') {
next = `${target.value.slice(0, start)}${text}${target.value.slice(end)}`;
nextCaret = start + text.length;
} else if (command.mode === 'backspace') {
if (start !== end) {
next = `${target.value.slice(0, start)}${target.value.slice(end)}`;
nextCaret = start;
} else if (start > 0) {
next = `${target.value.slice(0, start - 1)}${target.value.slice(end)}`;
nextCaret = start - 1;
}
} else if (command.mode === 'clear') {
next = '';
nextCaret = 0;
}
setNativeValue(target, next);
target.setSelectionRange(nextCaret, nextCaret);
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
+122
View File
@@ -0,0 +1,122 @@
const HUB_KEY = '__moonTvRemoteHub';
function getGlobalHub() {
if (!globalThis[HUB_KEY]) {
globalThis[HUB_KEY] = {
io: null,
devices: new Map(),
socketToDevice: new Map(),
};
}
return globalThis[HUB_KEY];
}
function attachTVRemoteIO(io) {
const hub = getGlobalHub();
hub.io = io;
}
function registerTVRemoteDevice(socketId, username, data) {
const hub = getGlobalHub();
const deviceId = String(data?.deviceId || '').slice(0, 128);
if (!deviceId) return { success: false, error: '缺少设备 ID' };
const device = {
deviceId,
socketId,
username,
deviceName: String(data?.deviceName || 'Web TV').slice(0, 80),
currentPath: String(data?.currentPath || '/tv').slice(0, 240),
title: String(data?.title || '').slice(0, 120),
lastActiveAt: Date.now(),
};
hub.devices.set(deviceId, device);
hub.socketToDevice.set(socketId, deviceId);
return { success: true };
}
function updateTVRemoteDevice(socketId, username, data) {
const hub = getGlobalHub();
const deviceId = String(data?.deviceId || hub.socketToDevice.get(socketId) || '');
const device = hub.devices.get(deviceId);
if (!device || device.socketId !== socketId || device.username !== username) {
return false;
}
device.currentPath = String(data?.currentPath || device.currentPath).slice(0, 240);
device.title = String(data?.title || device.title || '').slice(0, 120);
device.lastActiveAt = Date.now();
hub.devices.set(deviceId, device);
return true;
}
function removeTVRemoteSocket(socketId) {
const hub = getGlobalHub();
const deviceId = hub.socketToDevice.get(socketId);
if (!deviceId) return;
const device = hub.devices.get(deviceId);
if (device?.socketId === socketId) {
hub.devices.delete(deviceId);
}
hub.socketToDevice.delete(socketId);
}
function listTVRemoteDevices(username) {
const hub = getGlobalHub();
const now = Date.now();
return Array.from(hub.devices.values())
.filter((device) => device.username === username && now - device.lastActiveAt < 45_000)
.map(({ deviceId, deviceName, currentPath, title, lastActiveAt }) => ({
deviceId,
deviceName,
currentPath,
title,
lastActiveAt,
}))
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
function sendTVRemoteCommand(username, deviceId, eventName, command) {
const hub = getGlobalHub();
const device = hub.devices.get(String(deviceId || ''));
if (!hub.io) return { success: false, error: '遥控服务未启动' };
if (!device || device.username !== username) {
return { success: false, error: '电视端不在线' };
}
device.lastActiveAt = Date.now();
hub.devices.set(device.deviceId, device);
hub.io.to(device.socketId).emit(eventName, command);
return { success: true };
}
function cleanupTVRemoteDevices() {
const hub = getGlobalHub();
const now = Date.now();
for (const [deviceId, device] of hub.devices.entries()) {
if (now - device.lastActiveAt > 90_000) {
hub.devices.delete(deviceId);
hub.socketToDevice.delete(device.socketId);
}
}
}
function clearTVRemoteHub() {
const hub = getGlobalHub();
hub.io = null;
hub.devices.clear();
hub.socketToDevice.clear();
}
module.exports = {
attachTVRemoteIO,
cleanupTVRemoteDevices,
clearTVRemoteHub,
listTVRemoteDevices,
registerTVRemoteDevice,
removeTVRemoteSocket,
sendTVRemoteCommand,
updateTVRemoteDevice,
};
+34
View File
@@ -0,0 +1,34 @@
export type TVRemoteKey =
| 'up'
| 'down'
| 'left'
| 'right'
| 'ok'
| 'back'
| 'menu'
| 'home'
| 'playPause'
| 'pageUp'
| 'pageDown'
| 'digit';
export type TVRemoteTextMode = 'replace' | 'append' | 'backspace' | 'clear';
export interface TVRemoteDevice {
deviceId: string;
deviceName: string;
currentPath: string;
title?: string;
lastActiveAt: number;
}
export interface TVRemoteKeyCommand {
key: TVRemoteKey;
repeat?: boolean;
digit?: string;
}
export interface TVRemoteTextCommand {
mode: TVRemoteTextMode;
text?: string;
}