mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-25 18:20:07 +08:00
feat: add video room feature with API, database model, and UI integration
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
# -----------------
|
||||
# 上传(大文件分片)
|
||||
|
||||
9
domain/video_rooms/__init__.py
Normal file
9
domain/video_rooms/__init__.py
Normal 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
72
domain/video_rooms/api.py
Normal 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)
|
||||
82
domain/video_rooms/service.py
Normal file
82
domain/video_rooms/service.py
Normal 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
|
||||
39
domain/video_rooms/types.py
Normal file
39
domain/video_rooms/types.py
Normal 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
31
domain/video_rooms/ws.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
@@ -23,4 +23,5 @@ dependencies = [
|
||||
"telethon>=1.42.0",
|
||||
"tortoise-orm>=1.0.0",
|
||||
"uvicorn>=0.40.0",
|
||||
"websockets",
|
||||
]
|
||||
|
||||
29
uv.lock
generated
29
uv.lock
generated
@@ -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"
|
||||
|
||||
@@ -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