From 6cfafbe066c3651eff6cf4e756522ee70d170716 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sat, 16 May 2026 11:57:00 +0800 Subject: [PATCH] feat: add video room page with UI enhancements, state management, and localization support --- web/src/i18n/locales/en.json | 12 ++ web/src/i18n/locales/zh.json | 12 ++ web/src/pages/VideoRoomPage.css | 238 ++++++++++++++++++++++++++++++++ web/src/pages/VideoRoomPage.tsx | 162 ++++++++++++++++++---- 4 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 web/src/pages/VideoRoomPage.css diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d580b14..a12a83c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -251,6 +251,18 @@ "Video room not found": "Video room not found", "Room synced": "Room synced", "Room disconnected": "Room disconnected", + "Share room": "Share room", + "Now watching": "Now watching", + "Everyone follows the same playback": "Everyone follows the same playback", + "Waiting for sync connection": "Waiting for sync connection", + "Playback state is shared in this room": "Playback state is shared in this room", + "Room status": "Room status", + "Playback": "Playback", + "Playing": "Playing", + "Paused": "Paused", + "Current position": "Current position", + "Resync playback": "Resync playback", + "Room link": "Room link", "Audio": "Audio", "PDF": "PDF", "Word": "Word", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 51ced47..42618c5 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -270,6 +270,18 @@ "Video room not found": "视频房不存在", "Room synced": "房间同步中", "Room disconnected": "房间已断开", + "Share room": "分享房间", + "Now watching": "正在观看", + "Everyone follows the same playback": "所有人同步播放", + "Waiting for sync connection": "等待同步连接", + "Playback state is shared in this room": "房间内会同步播放、暂停和进度", + "Room status": "房间状态", + "Playback": "播放状态", + "Playing": "播放中", + "Paused": "已暂停", + "Current position": "当前进度", + "Resync playback": "重新同步", + "Room link": "房间链接", "Audio": "音频", "PDF": "PDF", "Word": "Word 文档", diff --git a/web/src/pages/VideoRoomPage.css b/web/src/pages/VideoRoomPage.css new file mode 100644 index 0000000..51532b1 --- /dev/null +++ b/web/src/pages/VideoRoomPage.css @@ -0,0 +1,238 @@ +.video-room-page { + min-height: 100vh; + padding: 24px; + background: #0b0f12; +} + +.video-room-page--center { + display: flex; + align-items: center; + justify-content: center; +} + +.video-room-page--center .ant-empty-description { + color: rgba(255, 255, 255, 0.72); +} + +.video-room-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + max-width: 1720px; + margin: 0 auto 16px; +} + +.video-room-header__left { + min-width: 0; +} + +.video-room-header__right { + display: flex; + flex: none; + align-items: center; + gap: 10px; +} + +.video-room-title-block { + min-width: 0; +} + +.video-room-title.ant-typography { + max-width: min(980px, 70vw); + margin: 0 0 4px; + overflow: hidden; + color: rgba(255, 255, 255, 0.92); + text-overflow: ellipsis; + white-space: nowrap; +} + +.video-room-file-name { + max-width: min(720px, 58vw); +} + +.video-room-status-tag.ant-tag { + display: inline-flex; + align-items: center; + height: 32px; + margin: 0; + padding: 0 12px; + border-radius: 8px; +} + +.video-room-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 18px; + max-width: 1720px; + margin: 0 auto; +} + +.video-room-main { + min-width: 0; +} + +.video-room-stage { + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: #000; +} + +.video-room-player { + width: 100%; + height: min(72vh, 760px); + min-height: 480px; + background: #000; +} + +.video-room-note { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px; + color: rgba(255, 255, 255, 0.45); + font-size: 13px; +} + +.video-room-note .anticon { + color: var(--ant-color-primary, #1677ff); +} + +.video-room-side { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.video-room-panel.ant-card { + border-color: rgba(255, 255, 255, 0.08); + background: #14191d; +} + +.video-room-panel .ant-card-head { + min-height: 48px; + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +.video-room-panel .ant-card-head-title { + color: rgba(255, 255, 255, 0.9); + font-weight: 600; +} + +.video-room-panel .ant-card-body { + padding: 16px; +} + +.video-room-dot { + display: block; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--ant-color-error, #ff4d4f); + box-shadow: 0 0 0 4px rgba(255, 77, 79, 0.14); +} + +.video-room-dot.is-connected { + background: var(--ant-color-success, #52c41a); + box-shadow: 0 0 0 4px rgba(82, 196, 26, 0.14); +} + +.video-room-status-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} + +.video-room-status-item { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} + +.video-room-status-item > .anticon { + flex: none; + color: var(--ant-color-primary, #1677ff); + font-size: 18px; +} + +.video-room-status-item div { + min-width: 0; +} + +.video-room-status-item span { + display: block; + margin-bottom: 2px; + color: rgba(255, 255, 255, 0.45); + font-size: 12px; +} + +.video-room-status-item strong { + display: block; + overflow: hidden; + color: rgba(255, 255, 255, 0.88); + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.video-room-primary-action.ant-btn { + height: 40px; +} + +.video-room-panel__text { + margin: 0 0 14px; + color: rgba(255, 255, 255, 0.55); + font-size: 14px; + line-height: 1.6; +} + +@media (max-width: 1180px) { + .video-room-shell { + grid-template-columns: 1fr; + } + + .video-room-side { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .video-room-page { + padding: 14px; + } + + .video-room-header { + align-items: stretch; + flex-direction: column; + } + + .video-room-header__right { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .video-room-title.ant-typography, + .video-room-file-name { + max-width: 100%; + } + + .video-room-player { + height: 56vh; + min-height: 280px; + } + + .video-room-side { + display: flex; + } +} diff --git a/web/src/pages/VideoRoomPage.tsx b/web/src/pages/VideoRoomPage.tsx index fc8efe9..852c41b 100644 --- a/web/src/pages/VideoRoomPage.tsx +++ b/web/src/pages/VideoRoomPage.tsx @@ -1,15 +1,40 @@ 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 { Button, Card, Empty, Space, Spin, Tag, Tooltip, Typography, message } from 'antd'; +import { + CheckCircleFilled, + ClockCircleOutlined, + CopyOutlined, + DisconnectOutlined, + FileTextOutlined, + LinkOutlined, + PlayCircleOutlined, + ReloadOutlined, +} from '@ant-design/icons'; import Artplayer from 'artplayer'; import { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from '../api/videoRooms'; import { useI18n } from '../i18n'; +import './VideoRoomPage.css'; -const { Title, Text } = Typography; +const { Text, Title } = Typography; const SYNC_THRESHOLD = 1.2; +function formatTime(seconds: number) { + const safeSeconds = Math.max(0, Math.floor(seconds || 0)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const secs = safeSeconds % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } + return `${minutes}:${String(secs).padStart(2, '0')}`; +} + +function getFileName(path: string) { + return path.split('/').filter(Boolean).pop() || path; +} + const VideoRoomPage = memo(function VideoRoomPage() { const { token } = useParams<{ token: string }>(); const { t } = useI18n(); @@ -19,6 +44,7 @@ const VideoRoomPage = memo(function VideoRoomPage() { const applyingRemoteRef = useRef(false); const sendTimerRef = useRef(null); const [room, setRoom] = useState(null); + const [liveState, setLiveState] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [connected, setConnected] = useState(false); @@ -31,6 +57,7 @@ const VideoRoomPage = memo(function VideoRoomPage() { .then((data) => { if (!mounted) return; setRoom(data); + setLiveState(data.state); }) .catch((e: any) => { if (!mounted) return; @@ -73,6 +100,7 @@ const VideoRoomPage = memo(function VideoRoomPage() { const applyState = (state: VideoRoomState) => { const art = artInstance.current; + setLiveState(state); if (!art) return; const video = art.video; const targetTime = Math.max(0, Number(state.current_time) || 0); @@ -141,38 +169,120 @@ const VideoRoomPage = memo(function VideoRoomPage() { message.success(t('Copied to clipboard')); }; + const handleResync = () => { + if (!liveState) return; + const art = artInstance.current; + if (!art) return; + art.video.currentTime = Math.max(0, Number(liveState.current_time) || 0); + if (liveState.paused) { + void art.video.pause(); + } else { + void art.video.play().catch(() => undefined); + } + }; + if (loading) { - return
; + return ( +
+ +
+ ); } if (error || !room) { - return
; + return ( +
+ +
+ ); } + const fileName = getFileName(room.path); + const state = liveState || room.state; + return ( -
-
-
-
- {room.name} - - {connected ? t('Room synced') : t('Room disconnected')} - +
+
+
+
+ {room.name} + + {fileName} + {formatTime(state.current_time)} +
-
-
-
+
+ : } + > + {connected ? t('Room synced') : t('Room disconnected')} + + + + +
+
+ +
+
+
+
+
+
+ + {t('Playback state is shared in this room')} +
+
+ + +
); });