mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-31 13:10:55 +08:00
feat: add video room feature with API, database model, and UI integration
This commit is contained in:
@@ -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
35
web/src/api/videoRooms.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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 文档",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
180
web/src/pages/VideoRoomPage.tsx
Normal file
180
web/src/pages/VideoRoomPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user