diff --git a/server.js b/server.js index ff21c5a..c0de442 100644 --- a/server.js +++ b/server.js @@ -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')); }); diff --git a/src/app/api/tv-remote/devices/route.ts b/src/app/api/tv-remote/devices/route.ts new file mode 100644 index 0000000..a260da6 --- /dev/null +++ b/src/app/api/tv-remote/devices/route.ts @@ -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), + }); +} diff --git a/src/app/api/tv-remote/key/route.ts b/src/app/api/tv-remote/key/route.ts new file mode 100644 index 0000000..4b4e11c --- /dev/null +++ b/src/app/api/tv-remote/key/route.ts @@ -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 }); +} diff --git a/src/app/api/tv-remote/text/route.ts b/src/app/api/tv-remote/text/route.ts new file mode 100644 index 0000000..97525da --- /dev/null +++ b/src/app/api/tv-remote/text/route.ts @@ -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 }); +} diff --git a/src/app/tv/layout.tsx b/src/app/tv/layout.tsx index 0c7d762..f362818 100644 --- a/src/app/tv/layout.tsx +++ b/src/app/tv/layout.tsx @@ -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} + + + ); } diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index e2063f3..2fd53d1 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -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(null); const [storageType, setStorageType] = useState('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 = () => { @@ -4866,6 +4873,12 @@ export const UserMenu: React.FC = () => { getDeviceIcon={getDeviceIcon} /> + setIsTVRemoteOpen(false)} + /> + {/* 使用 Portal 将生态应用面板渲染到 document.body */} {isEcoAppsOpen && mounted && createPortal(ecoAppsPanel, document.body)} diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx index 8352f45..c0f2e13 100644 --- a/src/components/WatchRoomProvider.tsx +++ b/src/components/WatchRoomProvider.tsx @@ -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', diff --git a/src/components/tv/TVRemotePanel.tsx b/src/components/tv/TVRemotePanel.tsx new file mode 100644 index 0000000..8c4c7c5 --- /dev/null +++ b/src/components/tv/TVRemotePanel.tsx @@ -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 = { + 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(null); + const intervalRef = useRef(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 ( + + ); +} + +export default function TVRemotePanel({ + isOpen, + mounted, + onClose, +}: TVRemotePanelProps) { + const [devices, setDevices] = useState([]); + 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( + <> +
e.preventDefault()} + onWheel={(e) => e.preventDefault()} + style={{ touchAction: 'none' }} + /> +
+
e.stopPropagation()} + style={{ touchAction: 'auto' }} + > +
+
+
+ + TV REMOTE +
+

+ 电视遥控器 +

+
+ +
+ +
+
+
+ + 在线电视端 +
+ +
+ + {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) => ( + + ))} +
+ +
+
+ + 文本输入 +
+