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

@@ -11,6 +11,7 @@ from domain.plugins import api as plugins
from domain.processors import api as processors
from domain.share import api as share
from domain.tasks import api as tasks
from domain.video_rooms import api as video_rooms
from domain.ai import api as ai
from domain.agent import api as agent
from domain.virtual_fs import api as virtual_fs
@@ -32,6 +33,7 @@ def include_routers(app: FastAPI):
app.include_router(config.router)
app.include_router(processors.router)
app.include_router(tasks.router)
app.include_router(video_rooms.router)
app.include_router(share.router)
app.include_router(share.public_router)
app.include_router(backup.router)

View File

@@ -360,25 +360,14 @@ class QuarkAdapter:
if tr:
url = tr
dl_headers = self._download_headers()
# 预获取大小/是否支持范围
total_size: Optional[int] = None
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
try:
head_resp = await client.head(url, headers=dl_headers)
if head_resp.status_code == 200:
cl = head_resp.headers.get("Content-Length")
if cl and cl.isdigit():
total_size = int(cl)
except Exception:
pass
file_size = int(it.get("size") or 0)
mime, _ = mimetypes.guess_type(rel)
content_type = mime or "application/octet-stream"
# 解析 Range
start = 0
end: Optional[int] = None
end: Optional[int] = file_size - 1 if file_size > 0 else None
status_code = 200
if range_header and range_header.startswith("bytes="):
status_code = 206
@@ -388,35 +377,65 @@ class QuarkAdapter:
start = int(s)
if e.strip():
end = int(e)
elif file_size > 0:
end = file_size - 1
if file_size > 0:
if start >= file_size:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
if end is None or end >= file_size:
end = file_size - 1
if start > end:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
headers = dict(dl_headers)
if status_code == 206:
headers["Range"] = f"bytes={start}-" if end is None else f"bytes={start}-{end}"
if total_size is not None and end is None and status_code == 206:
end = total_size - 1
if end is not None and total_size is not None and end >= total_size:
end = total_size - 1
if total_size is not None and start >= total_size:
client = httpx.AsyncClient(timeout=None, follow_redirects=True)
req = client.build_request("GET", url, headers=headers)
resp = await client.send(req, stream=True)
if resp.status_code == 404:
await resp.aclose()
await client.aclose()
raise FileNotFoundError(rel)
if resp.status_code == 416:
await resp.aclose()
await client.aclose()
raise HTTPException(416, detail="Requested Range Not Satisfiable")
try:
resp.raise_for_status()
except Exception:
await resp.aclose()
await client.aclose()
raise
resp_headers: Dict[str, str] = {"Accept-Ranges": "bytes", "Content-Type": content_type}
if status_code == 206 and total_size is not None and end is not None:
resp_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}"
resp_headers["Content-Length"] = str(end - start + 1)
elif total_size is not None:
resp_headers["Content-Length"] = str(total_size)
resp_headers: Dict[str, str] = {
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
"Content-Type": resp.headers.get("Content-Type", content_type),
}
content_range = resp.headers.get("Content-Range")
content_length = resp.headers.get("Content-Length")
if content_range:
resp_headers["Content-Range"] = content_range
elif status_code == 206 and file_size > 0 and end is not None:
resp_headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
if content_length:
resp_headers["Content-Length"] = content_length
elif file_size > 0:
if status_code == 206 and end is not None:
resp_headers["Content-Length"] = str(end - start + 1)
elif resp.status_code == 200:
resp_headers["Content-Length"] = str(file_size)
async def iterator():
headers = dict(dl_headers)
if status_code == 206 and end is not None:
headers["Range"] = f"bytes={start}-{end}"
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code in (404, 416):
await resp.aclose()
raise HTTPException(resp.status_code, detail="Upstream not available")
async for chunk in resp.aiter_bytes():
if chunk:
yield chunk
try:
async for chunk in resp.aiter_bytes():
if chunk:
yield chunk
finally:
await resp.aclose()
await client.aclose()
return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type)
return StreamingResponse(iterator(), status_code=resp.status_code, headers=resp_headers, media_type=content_type)
# -----------------
# 上传(大文件分片)

View File

@@ -0,0 +1,9 @@
from .service import VideoRoomService
from .types import VideoRoomCreate, VideoRoomInfo, VideoRoomState
__all__ = [
"VideoRoomService",
"VideoRoomCreate",
"VideoRoomInfo",
"VideoRoomState",
]

72
domain/video_rooms/api.py Normal file
View File

@@ -0,0 +1,72 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
from api.response import success
from domain.audit import AuditAction, audit
from domain.auth import User, get_current_active_user
from domain.permission import require_path_permission
from domain.permission.types import PathAction
from models.database import UserAccount
from .service import VideoRoomService
from .types import VideoRoomCreate, VideoRoomInfo
from .ws import video_room_ws_manager
router = APIRouter(prefix="/api/video-rooms", tags=["Video Rooms"])
@router.post("", response_model=VideoRoomInfo)
@audit(action=AuditAction.SHARE, description="创建视频房", body_fields=["name", "path"])
@require_path_permission(PathAction.SHARE, "payload.path")
async def create_video_room(
request: Request,
payload: VideoRoomCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
user_account = await UserAccount.get(id=current_user.id)
room = await VideoRoomService.create_room(
user=user_account,
name=payload.name,
path=payload.path,
)
return VideoRoomInfo.from_orm(room, VideoRoomService.get_effective_state(room))
@router.get("/{token}", response_model=VideoRoomInfo)
@audit(action=AuditAction.SHARE, description="获取视频房信息")
async def get_video_room(request: Request, token: str):
room = await VideoRoomService.get_room_by_token(token)
return VideoRoomInfo.from_orm(room, VideoRoomService.get_effective_state(room))
@router.get("/{token}/stream")
@audit(action=AuditAction.DOWNLOAD, description="播放视频房文件")
async def stream_video_room(token: str, request: Request):
return await VideoRoomService.stream_room_file(token, request.headers.get("Range"))
@router.websocket("/{token}/ws")
async def video_room_ws(websocket: WebSocket, token: str):
room = await VideoRoomService.get_room_by_token(token)
await video_room_ws_manager.connect(token, websocket)
try:
state = VideoRoomService.get_effective_state(room)
await websocket.send_json({"type": "state", "state": state.model_dump()})
while True:
data = await websocket.receive_json()
if data.get("type") != "state":
continue
state = await VideoRoomService.update_state(
room,
current_time=float(data.get("current_time") or 0),
paused=bool(data.get("paused")),
)
await video_room_ws_manager.broadcast(
token,
{"type": "state", "state": state.model_dump()},
exclude=websocket,
)
except WebSocketDisconnect:
pass
finally:
video_room_ws_manager.disconnect(token, websocket)

View File

@@ -0,0 +1,82 @@
import secrets
from datetime import datetime, timezone
from urllib.parse import quote
from fastapi import HTTPException
from fastapi.responses import Response
from domain.virtual_fs import VirtualFSService
from models.database import UserAccount, VideoRoom
from .types import VideoRoomState
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".m4v", ".mov", ".mkv", ".avi", ".flv"}
class VideoRoomService:
@classmethod
def _is_video_path(cls, path: str) -> bool:
lower = path.lower()
return any(lower.endswith(ext) for ext in VIDEO_EXTENSIONS)
@classmethod
async def create_room(cls, user: UserAccount, name: str, path: str) -> VideoRoom:
if not path or path == "/" or ".." in path.split("/"):
raise HTTPException(status_code=400, detail="无效的视频路径")
if not cls._is_video_path(path):
raise HTTPException(status_code=400, detail="仅支持视频文件创建视频房")
stat = await VirtualFSService.stat_file(path)
if stat.get("is_dir"):
raise HTTPException(status_code=400, detail="目录不能创建视频房")
token = secrets.token_urlsafe(16)
return await VideoRoom.create(
token=token,
name=name or path.rsplit("/", 1)[-1],
path=path,
user=user,
state_updated_at=datetime.now(timezone.utc),
)
@classmethod
async def get_room_by_token(cls, token: str) -> VideoRoom:
room = await VideoRoom.get_or_none(token=token).prefetch_related("user")
if not room:
raise HTTPException(status_code=404, detail="视频房不存在")
return room
@classmethod
def get_effective_state(cls, room: VideoRoom) -> VideoRoomState:
current_time = float(room.current_time or 0)
updated_at = room.state_updated_at
if not room.paused and updated_at:
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
current_time += max(0, (datetime.now(timezone.utc) - updated_at).total_seconds())
return VideoRoomState(
current_time=max(0, current_time),
paused=bool(room.paused),
updated_at=updated_at.isoformat() if updated_at else None,
)
@classmethod
async def update_state(cls, room: VideoRoom, current_time: float, paused: bool) -> VideoRoomState:
now = datetime.now(timezone.utc)
room.current_time = max(0, float(current_time or 0))
room.paused = bool(paused)
room.state_updated_at = now
await room.save(update_fields=["current_time", "paused", "state_updated_at"])
return VideoRoomState(
current_time=room.current_time,
paused=room.paused,
updated_at=now.isoformat(),
)
@classmethod
async def stream_room_file(cls, token: str, range_header: str | None) -> Response:
room = await cls.get_room_by_token(token)
response = await VirtualFSService.stream_file(room.path, range_header)
filename = room.path.rsplit("/", 1)[-1]
response.headers["Content-Disposition"] = f"inline; filename*=UTF-8''{quote(filename)}"
return response

View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel
from models.database import VideoRoom
class VideoRoomCreate(BaseModel):
name: str
path: str
class VideoRoomState(BaseModel):
current_time: float
paused: bool
updated_at: str | None = None
class VideoRoomInfo(BaseModel):
id: int
token: str
name: str
path: str
created_at: str
state: VideoRoomState
@classmethod
def from_orm(cls, obj: VideoRoom, state: VideoRoomState | None = None):
return cls(
id=obj.id,
token=obj.token,
name=obj.name,
path=obj.path,
created_at=obj.created_at.isoformat(),
state=state
or VideoRoomState(
current_time=obj.current_time,
paused=obj.paused,
updated_at=obj.state_updated_at.isoformat() if obj.state_updated_at else None,
),
)

31
domain/video_rooms/ws.py Normal file
View File

@@ -0,0 +1,31 @@
from fastapi import WebSocket
class VideoRoomWebSocketManager:
def __init__(self):
self.rooms: dict[str, set[WebSocket]] = {}
async def connect(self, token: str, websocket: WebSocket):
await websocket.accept()
self.rooms.setdefault(token, set()).add(websocket)
def disconnect(self, token: str, websocket: WebSocket):
sockets = self.rooms.get(token)
if not sockets:
return
sockets.discard(websocket)
if not sockets:
self.rooms.pop(token, None)
async def broadcast(self, token: str, message: dict, exclude: WebSocket | None = None):
sockets = list(self.rooms.get(token, set()))
for socket in sockets:
if socket is exclude:
continue
try:
await socket.send_json(message)
except Exception:
self.disconnect(token, socket)
video_room_ws_manager = VideoRoomWebSocketManager()

View File

@@ -234,6 +234,23 @@ class ShareLink(Model):
table = "share_links"
class VideoRoom(Model):
id = fields.IntField(pk=True)
token = fields.CharField(max_length=100, unique=True, index=True)
name = fields.CharField(max_length=255)
path = fields.CharField(max_length=4096)
user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(
"models.UserAccount", related_name="video_rooms", on_delete=fields.CASCADE
)
current_time = fields.FloatField(default=0)
paused = fields.BooleanField(default=True)
state_updated_at = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "video_rooms"
class RecentFile(Model):
id = fields.IntField(pk=True)
user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(

View File

@@ -23,4 +23,5 @@ dependencies = [
"telethon>=1.42.0",
"tortoise-orm>=1.0.0",
"uvicorn>=0.40.0",
"websockets",
]

29
uv.lock generated
View File

@@ -459,6 +459,7 @@ dependencies = [
{ name = "telethon" },
{ name = "tortoise-orm" },
{ name = "uvicorn" },
{ name = "websockets" },
]
[package.metadata]
@@ -481,6 +482,7 @@ requires-dist = [
{ name = "telethon", specifier = ">=1.42.0" },
{ name = "tortoise-orm", specifier = ">=1.0.0" },
{ name = "uvicorn", specifier = ">=0.40.0" },
{ name = "websockets" },
]
[[package]]
@@ -1429,6 +1431,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "wrapt"
version = "1.17.3"

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;