远程遥控完成
This commit is contained in:
@@ -3,6 +3,14 @@ const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
const { Server } = require('socket.io');
|
||||
const {
|
||||
attachTVRemoteIO,
|
||||
cleanupTVRemoteDevices,
|
||||
clearTVRemoteHub,
|
||||
registerTVRemoteDevice,
|
||||
removeTVRemoteSocket,
|
||||
updateTVRemoteDevice,
|
||||
} = require('./src/lib/tv-remote-hub.js');
|
||||
|
||||
function shouldInitSQLite() {
|
||||
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||
@@ -705,6 +713,95 @@ class WatchRoomServer {
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookieHeader(cookieHeader) {
|
||||
if (!cookieHeader) return {};
|
||||
return cookieHeader.split(';').reduce((acc, part) => {
|
||||
const index = part.indexOf('=');
|
||||
if (index <= 0) return acc;
|
||||
const key = part.slice(0, index).trim();
|
||||
const value = part.slice(index + 1).trim();
|
||||
if (key) acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function parseSocketAuth(socket) {
|
||||
const cookies = parseCookieHeader(socket.handshake.headers.cookie || '');
|
||||
const raw = cookies.auth || socket.handshake.auth?.token || '';
|
||||
if (!raw) return null;
|
||||
|
||||
let decoded = raw;
|
||||
try {
|
||||
decoded = decodeURIComponent(decoded);
|
||||
} catch {}
|
||||
|
||||
if (decoded.includes('%')) {
|
||||
try {
|
||||
decoded = decodeURIComponent(decoded);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TVRemoteServer {
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
this.cleanupInterval = null;
|
||||
attachTVRemoteIO(io);
|
||||
this.setupEventHandlers();
|
||||
this.startCleanupTimer();
|
||||
}
|
||||
|
||||
setupEventHandlers() {
|
||||
this.io.on('connection', (socket) => {
|
||||
socket.on('tv-remote:register-tv', (data, callback) => {
|
||||
const auth = parseSocketAuth(socket);
|
||||
if (!auth?.username) {
|
||||
callback?.({ success: false, error: '未登录' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = String(data?.deviceId || '').slice(0, 128);
|
||||
if (!deviceId) {
|
||||
callback?.({ success: false, error: '缺少设备 ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
callback?.(registerTVRemoteDevice(socket.id, auth.username, data));
|
||||
});
|
||||
|
||||
socket.on('tv-remote:tv-state', (data) => {
|
||||
const auth = parseSocketAuth(socket);
|
||||
if (!auth?.username) return;
|
||||
updateTVRemoteDevice(socket.id, auth.username, data);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
removeTVRemoteSocket(socket.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
startCleanupTimer() {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
cleanupTVRemoteDevices();
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
clearTVRemoteHub();
|
||||
}
|
||||
}
|
||||
|
||||
app.prepare().then(async () => {
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
try {
|
||||
@@ -722,20 +819,20 @@ app.prepare().then(async () => {
|
||||
console.log('[WatchRoom] Config:', watchRoomConfig);
|
||||
|
||||
let watchRoomServer = null;
|
||||
let tvRemoteServer = null;
|
||||
|
||||
const io = new Server(httpServer, {
|
||||
path: '/socket.io',
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
},
|
||||
});
|
||||
|
||||
tvRemoteServer = new TVRemoteServer(io);
|
||||
console.log('[TVRemote] Socket.IO remote server initialized');
|
||||
|
||||
// 只在启用观影室且使用内部服务器时初始化 Socket.IO
|
||||
if (watchRoomConfig.enabled && watchRoomConfig.serverType === 'internal') {
|
||||
console.log('[WatchRoom] Initializing Socket.IO server...');
|
||||
|
||||
// 初始化 Socket.IO
|
||||
const io = new Server(httpServer, {
|
||||
path: '/socket.io',
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
},
|
||||
});
|
||||
|
||||
// 初始化观影室服务器
|
||||
watchRoomServer = new WatchRoomServer(io);
|
||||
console.log('[WatchRoom] Socket.IO server initialized');
|
||||
@@ -754,31 +851,14 @@ app.prepare().then(async () => {
|
||||
})
|
||||
.listen(port, () => {
|
||||
console.log(`> Ready on http://${hostname}:${port}`);
|
||||
if (watchRoomConfig.enabled && watchRoomConfig.serverType === 'internal') {
|
||||
console.log(`> Socket.IO ready on ws://${hostname}:${port}`);
|
||||
}
|
||||
console.log(`> Socket.IO ready on ws://${hostname}:${port}`);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n[Server] Shutting down...');
|
||||
if (watchRoomServer) {
|
||||
watchRoomServer.destroy();
|
||||
}
|
||||
httpServer.close(() => {
|
||||
console.log('[Server] Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
const forceExit = (signal) => {
|
||||
console.log(`\n[Server] Received ${signal}, force exiting...`);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n[Server] Shutting down...');
|
||||
if (watchRoomServer) {
|
||||
watchRoomServer.destroy();
|
||||
}
|
||||
httpServer.close(() => {
|
||||
console.log('[Server] Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
process.on('SIGINT', () => forceExit('SIGINT'));
|
||||
process.on('SIGTERM', () => forceExit('SIGTERM'));
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user