远程遥控完成

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
+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;
}