feat: add video room feature with API, database model, and UI integration

This commit is contained in:
shiyu
2026-05-16 10:51:23 +08:00
parent d5a24c69e1
commit 3de2615cd0
19 changed files with 690 additions and 38 deletions

View File

@@ -73,5 +73,6 @@ async function request<T = any>(url: string, options: RequestOptions = {}): Prom
export { vfsApi, type VfsEntry, type DirListing } from './vfs';
export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeMeta, type AdapterUsage } from './adapters';
export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share';
export { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from './videoRooms';
export { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads';
export default request;

35
web/src/api/videoRooms.ts Normal file
View File

@@ -0,0 +1,35 @@
import request, { API_BASE_URL } from './client';
export interface VideoRoomState {
current_time: number;
paused: boolean;
updated_at?: string | null;
}
export interface VideoRoomInfo {
id: number;
token: string;
name: string;
path: string;
created_at: string;
state: VideoRoomState;
}
export interface VideoRoomCreatePayload {
name: string;
path: string;
}
export const videoRoomsApi = {
create: (payload: VideoRoomCreatePayload) => request<VideoRoomInfo>('/video-rooms', { method: 'POST', json: payload }),
get: (token: string) => request<VideoRoomInfo>(`/video-rooms/${token}`),
streamUrl: (token: string) => `${API_BASE_URL}/video-rooms/${token}/stream`,
wsUrl: (token: string) => {
const base = API_BASE_URL.startsWith('http')
? API_BASE_URL
: `${window.location.origin}${API_BASE_URL}`;
const url = new URL(`${base}/video-rooms/${token}/ws`);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.href;
},
};

View File

@@ -242,6 +242,15 @@
"File": "File",
"Image": "Image",
"Video": "Video",
"Create Video Room": "Create Video Room",
"Video room created": "Video room created",
"Share this room link with friends": "Share this room link with friends",
"Video Room Link": "Video Room Link",
"Video Room Name": "Video Room Name",
"Video room load failed": "Failed to load video room",
"Video room not found": "Video room not found",
"Room synced": "Room synced",
"Room disconnected": "Room disconnected",
"Audio": "Audio",
"PDF": "PDF",
"Word": "Word",

View File

@@ -261,6 +261,15 @@
"File": "文件",
"Image": "图片",
"Video": "视频",
"Create Video Room": "创建视频房",
"Video room created": "视频房已创建",
"Share this room link with friends": "把这个房间链接分享给好友",
"Video Room Link": "视频房链接",
"Video Room Name": "视频房名称",
"Video room load failed": "加载视频房失败",
"Video room not found": "视频房不存在",
"Room synced": "房间同步中",
"Room disconnected": "房间已断开",
"Audio": "音频",
"PDF": "PDF",
"Word": "Word 文档",

View File

@@ -23,6 +23,7 @@ import { ProcessorModal } from './components/Modals/ProcessorModal';
import UploadModal from './components/Modals/UploadModal';
import { ShareModal } from './components/Modals/ShareModal';
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
import { VideoRoomModal } from './components/Modals/VideoRoomModal';
import { FileDetailModal } from './components/FileDetailModal';
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
import { SearchResultsView } from './components/SearchResultsView';
@@ -58,6 +59,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
const [videoRoomEntry, setVideoRoomEntry] = useState<VfsEntry | null>(null);
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
@@ -453,6 +455,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
open={!!directLinkEntry}
onCancel={() => setDirectLinkEntry(null)}
/>
<VideoRoomModal
entry={videoRoomEntry}
path={entryBasePath}
open={!!videoRoomEntry}
onCancel={() => setVideoRoomEntry(null)}
/>
<ProcessorModal
entry={processorHook.processorModal.entry}
visible={processorHook.processorModal.visible}
@@ -495,6 +503,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onCreateFile={() => setCreatingFile(true)}
onCreateDir={() => setCreatingDir(true)}
onShare={doShare}
onCreateVideoRoom={setVideoRoomEntry}
onGetDirectLink={doGetDirectLink}
onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}

View File

@@ -8,7 +8,8 @@ import { useI18n } from '../../../i18n';
import {
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined,
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined,
PlayCircleOutlined
} from '@ant-design/icons';
interface ContextMenuProps {
@@ -32,6 +33,7 @@ interface ContextMenuProps {
onCreateFile: () => void;
onCreateDir: () => void;
onShare: (entries: VfsEntry[]) => void;
onCreateVideoRoom: (entry: VfsEntry) => void;
onGetDirectLink: (entry: VfsEntry) => void;
onMove: (entries: VfsEntry[]) => void;
onCopy: (entries: VfsEntry[]) => void;
@@ -39,6 +41,8 @@ interface ContextMenuProps {
type MenuItem = Required<MenuProps>['items'][number];
const isVideoFile = (name: string) => /\.(mp4|webm|ogg|m4v|mov|mkv|avi|flv)$/i.test(name);
interface ActionMenuItem {
key: string;
label: React.ReactNode;
@@ -138,6 +142,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
icon: <ShareAltOutlined />,
onClick: () => actions.onShare(targetEntries),
},
{
key: 'videoRoom',
label: t('Create Video Room'),
icon: <PlayCircleOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir || !isVideoFile(targetEntries[0].name),
onClick: () => actions.onCreateVideoRoom(targetEntries[0]),
},
{
key: 'directLink',
label: t('Get Direct Link'),

View File

@@ -0,0 +1,95 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { Button, Form, Input, message, Modal, Typography } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../../api/client';
import { videoRoomsApi, type VideoRoomInfo } from '../../../../api/videoRooms';
import { useSystemStatus } from '../../../../contexts/SystemContext';
import { useI18n } from '../../../../i18n';
interface VideoRoomModalProps {
entry: VfsEntry | null;
path: string;
open: boolean;
onCancel: () => void;
}
export const VideoRoomModal = memo(function VideoRoomModal({ entry, path, open, onCancel }: VideoRoomModalProps) {
const [form] = Form.useForm();
const systemStatus = useSystemStatus();
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const [createdRoom, setCreatedRoom] = useState<VideoRoomInfo | null>(null);
const defaultName = entry?.name || '';
const roomUrl = useMemo(() => {
if (!createdRoom) return '';
const baseUrl = systemStatus?.app_domain || window.location.origin;
return new URL(`/room/${createdRoom.token}`, baseUrl).href;
}, [createdRoom, systemStatus?.app_domain]);
useEffect(() => {
if (!open) return;
setCreatedRoom(null);
form.setFieldsValue({ name: defaultName });
}, [defaultName, form, open]);
const handleCreate = async () => {
if (!entry) return;
try {
const values = await form.validateFields();
setLoading(true);
const base = path === '/' ? '' : path;
const fullPath = `${base}/${entry.name}`.replace(/\/{2,}/g, '/');
const room = await videoRoomsApi.create({
name: values.name || entry.name,
path: fullPath,
});
setCreatedRoom(room);
message.success(t('Video room created'));
} catch (e: any) {
message.error(e.message || t('Create failed'));
} finally {
setLoading(false);
}
};
const handleCopy = () => {
if (!roomUrl) return;
navigator.clipboard.writeText(roomUrl);
message.success(t('Copied to clipboard'));
};
return (
<Modal
title={createdRoom ? t('Video room created') : t('Create Video Room')}
open={open}
onCancel={onCancel}
onOk={createdRoom ? onCancel : handleCreate}
okText={createdRoom ? t('Done') : t('Create')}
confirmLoading={loading}
destroyOnHidden
>
{createdRoom ? (
<div>
<Typography.Paragraph>{t('Share this room link with friends')}</Typography.Paragraph>
<Form layout="vertical">
<Form.Item label={t('Video Room Link')}>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={roomUrl} style={{ flex: 1 }} />
<Button icon={<CopyOutlined />} onClick={handleCopy}>
{t('Copy')}
</Button>
</div>
</Form.Item>
</Form>
</div>
) : (
<Form form={form} layout="vertical" initialValues={{ name: defaultName }}>
<Form.Item name="name" label={t('Video Room Name')} rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
)}
</Modal>
);
});

View File

@@ -0,0 +1,180 @@
import { memo, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { Button, Empty, Spin, Typography, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import Artplayer from 'artplayer';
import { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from '../api/videoRooms';
import { useI18n } from '../i18n';
const { Title, Text } = Typography;
const SYNC_THRESHOLD = 1.2;
const VideoRoomPage = memo(function VideoRoomPage() {
const { token } = useParams<{ token: string }>();
const { t } = useI18n();
const artRef = useRef<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const applyingRemoteRef = useRef(false);
const sendTimerRef = useRef<number | null>(null);
const [room, setRoom] = useState<VideoRoomInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [connected, setConnected] = useState(false);
useEffect(() => {
let mounted = true;
if (!token) return;
videoRoomsApi.get(token)
.then((data) => {
if (!mounted) return;
setRoom(data);
})
.catch((e: any) => {
if (!mounted) return;
setError(e.message || t('Video room load failed'));
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, [t, token]);
useEffect(() => {
if (!token || !room || !artRef.current) return;
const sendState = () => {
const art = artInstance.current;
const ws = wsRef.current;
if (!art || !ws || ws.readyState !== WebSocket.OPEN || applyingRemoteRef.current) return;
const video = art.video;
const payload = {
type: 'state',
current_time: video.currentTime || 0,
paused: video.paused,
};
ws.send(JSON.stringify(payload));
};
const sendStateSoon = () => {
if (sendTimerRef.current !== null) {
window.clearTimeout(sendTimerRef.current);
}
sendTimerRef.current = window.setTimeout(() => {
sendTimerRef.current = null;
sendState();
}, 120);
};
const applyState = (state: VideoRoomState) => {
const art = artInstance.current;
if (!art) return;
const video = art.video;
const targetTime = Math.max(0, Number(state.current_time) || 0);
applyingRemoteRef.current = true;
if (Math.abs((video.currentTime || 0) - targetTime) > SYNC_THRESHOLD) {
video.currentTime = targetTime;
}
if (state.paused && !video.paused) {
void video.pause();
}
if (!state.paused && video.paused) {
void video.play().catch(() => undefined);
}
window.setTimeout(() => {
applyingRemoteRef.current = false;
}, 250);
};
const art = new Artplayer({
container: artRef.current,
url: videoRoomsApi.streamUrl(token),
autoplay: false,
fullscreen: true,
fullscreenWeb: true,
pip: true,
setting: true,
playbackRate: true,
});
artInstance.current = art;
art.on('ready', () => applyState(room.state));
art.on('play', sendStateSoon);
art.on('pause', sendStateSoon);
art.on('seek', sendStateSoon);
const ws = new WebSocket(videoRoomsApi.wsUrl(token));
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onerror = () => setConnected(false);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === 'state' && data.state) {
applyState(data.state);
}
} catch {
void 0;
}
};
return () => {
if (sendTimerRef.current !== null) {
window.clearTimeout(sendTimerRef.current);
sendTimerRef.current = null;
}
ws.close();
art.destroy();
wsRef.current = null;
artInstance.current = null;
};
}, [room, token]);
const handleCopy = () => {
navigator.clipboard.writeText(window.location.href);
message.success(t('Copied to clipboard'));
};
if (loading) {
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
}
if (error || !room) {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error || t('Video room not found')} /></div>;
}
return (
<div style={{ minHeight: '100vh', background: '#111', color: '#fff', padding: 24 }}>
<div style={{ maxWidth: 1120, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center', marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<Title level={3} style={{ color: '#fff', margin: 0 }}>{room.name}</Title>
<Text style={{ color: connected ? '#7dd3fc' : '#fca5a5' }}>
{connected ? t('Room synced') : t('Room disconnected')}
</Text>
</div>
<Button icon={<CopyOutlined />} onClick={handleCopy}>
{t('Copy Link')}
</Button>
</div>
<div
ref={artRef}
style={{
width: '100%',
height: 'min(70vh, 680px)',
minHeight: 360,
background: '#000',
}}
/>
</div>
</div>
);
});
export default VideoRoomPage;

View File

@@ -5,6 +5,7 @@ import LoginPage from '../pages/LoginPage.tsx';
import RegisterPage from '../pages/RegisterPage.tsx';
import SetupPage from '../pages/SetupPage.tsx';
import PublicSharePage from '../pages/PublicSharePage';
import VideoRoomPage from '../pages/VideoRoomPage';
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
import ResetPasswordPage from '../pages/ResetPasswordPage';
import { useAuth } from '../contexts/AuthContext';
@@ -16,6 +17,7 @@ export const routes: RouteObject[] = [
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/share/:token', element: <PublicSharePage /> },
{ path: '/room/:token', element: <VideoRoomPage /> },
{ 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('/room/') && !isPublic) {
return <Navigate to="/login" replace />;
}
return children;