feat: switch watch sync from polling to websocket

This commit is contained in:
时雨
2026-05-15 20:49:17 +08:00
parent d5a24c69e1
commit f900bcf2ca
10 changed files with 508 additions and 1 deletions

View File

@@ -75,3 +75,5 @@ export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeM
export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share';
export { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads';
export default request;
export { videoRoomApi, type VideoRoomInfo, type VideoRoomState } from './videoRoom';

47
web/src/api/videoRoom.ts Normal file
View File

@@ -0,0 +1,47 @@
import request, { API_BASE_URL } from './client';
export interface VideoRoomInfo {
id: number;
name: string;
token: string;
path: string;
control_mode: 'host_only' | 'everyone';
created_at: string;
expires_at?: string | null;
}
export interface VideoPlaybackState {
position_ms: number;
is_paused: boolean;
playback_rate: number;
updated_at: string;
updated_by: string;
}
export interface VideoRoomState {
room: VideoRoomInfo;
playback: VideoPlaybackState;
}
export interface VideoRoomCreatePayload {
path: string;
name?: string;
expires_in_days?: number;
control_mode?: 'host_only' | 'everyone';
}
export const videoRoomApi = {
create: (payload: VideoRoomCreatePayload) => request<VideoRoomInfo>('/video-rooms', { method: 'POST', json: payload }),
getState: (token: string) => request<VideoRoomState>(`/watch/${token}`),
pushEvent: (token: string, payload: { type: 'play' | 'pause' | 'seek' | 'rate'; position_ms?: number; playback_rate?: number }, actorId?: string) =>
request<{ playback: VideoPlaybackState }>(`/watch/${token}/events`, {
method: 'POST',
json: payload,
headers: actorId ? { 'X-Watch-Actor': actorId } : undefined,
}),
streamUrl: (token: string, path: string) => `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`,
connectWs: (token: string, actorId: string) => {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
return new WebSocket(`${proto}://${window.location.host}/api/watch/${token}/ws?actor=${encodeURIComponent(actorId)}`);
},
};

View File

@@ -0,0 +1,147 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { Alert, Button, Card, Empty, Input, Space, Spin, Typography, message } from 'antd';
import { videoRoomApi, type VideoRoomState } from '../api/videoRoom';
const { Title, Text } = Typography;
export default function PublicWatchPage() {
const { token } = useParams();
const [data, setData] = useState<VideoRoomState | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const syncingRef = useRef(false);
const wsRef = useRef<WebSocket | null>(null);
const actorId = useMemo(() => {
const key = 'watch_actor_id';
const cached = localStorage.getItem(key);
if (cached) return cached;
const v = `guest:${Math.random().toString(36).slice(2, 10)}`;
localStorage.setItem(key, v);
return v;
}, []);
useEffect(() => {
if (!token) return;
const load = async () => {
try {
const res = await videoRoomApi.getState(token);
setData(res);
setErr('');
} catch (e: any) {
setErr(e.message || '加载视频间失败');
} finally {
setLoading(false);
}
};
void load();
}, [token]);
useEffect(() => {
if (!token) return;
let closedByCleanup = false;
let reconnectTimer: number | null = null;
const connect = () => {
const ws = videoRoomApi.connectWs(token, actorId);
wsRef.current = ws;
ws.onopen = () => setWsConnected(true);
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === 'snapshot' || msg.type === 'playback') {
setData((prev) => {
if (!prev) return prev;
return { ...prev, playback: msg.playback };
});
}
} catch {
void 0;
}
};
ws.onclose = () => {
setWsConnected(false);
if (!closedByCleanup) {
reconnectTimer = window.setTimeout(connect, 1500);
}
};
ws.onerror = () => {
setWsConnected(false);
};
};
connect();
return () => {
closedByCleanup = true;
setWsConnected(false);
if (reconnectTimer) window.clearTimeout(reconnectTimer);
wsRef.current?.close();
wsRef.current = null;
};
}, [token, actorId]);
useEffect(() => {
const video = videoRef.current;
const pb = data?.playback;
if (!video || !pb) return;
syncingRef.current = true;
const targetSec = (pb.position_ms || 0) / 1000;
if (Math.abs(video.currentTime - targetSec) > 1.2) video.currentTime = targetSec;
if (Math.abs(video.playbackRate - pb.playback_rate) > 0.01) video.playbackRate = pb.playback_rate;
if (pb.is_paused && !video.paused) video.pause();
if (!pb.is_paused && video.paused) void video.play().catch(() => void 0);
setTimeout(() => { syncingRef.current = false; }, 120);
}, [data?.playback?.updated_at]);
const sendEvent = (payload: { event: 'play' | 'pause' | 'seek' | 'rate'; position_ms?: number; playback_rate?: number }) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(payload));
};
if (loading) return <div style={{ padding: 40, textAlign: 'center' }}><Spin /></div>;
if (err || !data) return <div style={{ padding: 40 }}><Empty description={err || '房间不存在'} /></div>;
return (
<div style={{ maxWidth: 980, margin: '24px auto', padding: '0 16px' }}>
<Card>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Title level={4} style={{ margin: 0 }}>{data.room.name}</Title>
<Text type="secondary">{data.playback.is_paused ? '暂停' : '播放中'} | {data.playback.playback_rate}x</Text>
<Text type={wsConnected ? 'success' : 'warning'}>{wsConnected ? '实时同步已连接' : '实时同步断开,正在重连…'}</Text>
<Input readOnly value={`${window.location.origin}/watch/${data.room.token}`} addonBefore="分享链接" />
</Space>
</Card>
<Card style={{ marginTop: 16 }}>
<video
ref={videoRef}
src={videoRoomApi.streamUrl(data.room.token, data.room.path)}
style={{ width: '100%', background: '#000', borderRadius: 8 }}
controls
onPlay={() => { if (!syncingRef.current) sendEvent({ event: 'play' }); }}
onPause={() => { if (!syncingRef.current) sendEvent({ event: 'pause' }); }}
onSeeked={() => {
if (syncingRef.current) return;
const ms = Math.floor((videoRef.current?.currentTime || 0) * 1000);
sendEvent({ event: 'seek', position_ms: ms });
}}
onRateChange={() => {
if (syncingRef.current) return;
const rate = videoRef.current?.playbackRate || 1;
sendEvent({ event: 'rate', playback_rate: rate });
}}
/>
<Alert type="info" showIcon style={{ marginTop: 12 }} message="已改为 WebSocket 实时同步,不再使用定时轮询。" />
<Space style={{ marginTop: 12 }}>
<Button onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/watch/${data.room.token}`); message.success('已复制'); }}></Button>
</Space>
</Card>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import SetupPage from '../pages/SetupPage.tsx';
import PublicSharePage from '../pages/PublicSharePage';
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
import ResetPasswordPage from '../pages/ResetPasswordPage';
import PublicWatchPage from '../pages/PublicWatchPage';
import { useAuth } from '../contexts/AuthContext';
import type { JSX } from 'react';
@@ -16,6 +17,7 @@ export const routes: RouteObject[] = [
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/share/:token', element: <PublicSharePage /> },
{ path: '/watch/:token', element: <PublicWatchPage /> },
{ path: '/setup', element: <SetupPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/reset-password', element: <ResetPasswordPage /> },
@@ -26,7 +28,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
const location = useLocation();
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
const isPublic = publicPaths.some((p) => location.pathname.startsWith(p));
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) {
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !location.pathname.startsWith('/watch/') && !isPublic) {
return <Navigate to="/login" replace />;
}
return children;