Enhance video remote service and UI components

- Updated docker-compose.yml to include new media-api and mediamtx services for improved video handling.
- Modified package.json and package-lock.json to add TypeScript types for React and React DOM.
- Refactored video-related components (VideoCard, VideoList, VideoModal) for better user experience and responsiveness.
- Improved FiltersBar and Pagination components with enhanced styling and functionality.
- Added loading and error states in VideoList for better user feedback during data fetching.
- Enhanced CSS styles for a more polished look across the application.
This commit is contained in:
salirezav
2025-10-31 18:06:40 -04:00
parent 0b724fe59b
commit 00d4e5b275
17 changed files with 762 additions and 61 deletions

93
api-tests.http Normal file
View File

@@ -0,0 +1,93 @@
###############
# Environment #
###############
@host = exp-dash
@web_port = 8080
@api_port = 8000
@media_port = 8090
@rtsp_port = 8554
# Base URLs
@WEB = http://{{host}}:{{web_port}}
@API = http://{{host}}:{{api_port}}
@MEDIA = http://{{host}}:{{media_port}}
###########################
# Camera Management (API) #
###########################
### API health
GET {{API}}/health
### System status
GET {{API}}/system/status
### List cameras
GET {{API}}/cameras
### Get single camera status
GET {{API}}/cameras/camera1/status
### Start live preview streaming (MJPEG) for camera1 (served by camera-management-api)
POST {{API}}/cameras/camera1/start-stream
### Stop live preview streaming for camera1
POST {{API}}/cameras/camera1/stop-stream
### View live MJPEG stream in browser (open URL)
# {{API}}/cameras/camera1/stream
### getting a list of all videos
GET {{MEDIA}}/videos/?page=10&limit=1
########################
# Media API (media-api) #
########################
### Media API health
GET {{MEDIA}}/health
### List videos (page 1, 24 per page)
GET {{MEDIA}}/videos/?page=1&limit=24
### List videos filtered by camera name (example: camera1)
GET {{MEDIA}}/videos/?page=1&limit=24&camera_name=camera1
### Get a thumbnail for a specific file (replace FILE_ID from list)
# Tip: FILE_ID is URL-encoded relative path, e.g. camera1%2Fmy_video.mp4
@FILE_ID = camera2_auto_vibratory_conveyor_20251028_094749.mp4
GET {{MEDIA}}/videos/{{FILE_ID}}/thumbnail?width=320&height=180
### Stream a file (basic GET without Range header)
GET {{MEDIA}}/videos/{{FILE_ID}}/stream
### Stream a file with Range header (resume/seek)
GET {{MEDIA}}/videos/{{FILE_ID}}/stream
Range: bytes=0-1048575
#########################################
# RTSP / WebRTC via MediaMTX (mediamtx) #
#########################################
# Notes:
# - RTSP is not directly playable in browsers; use VLC/ffplay/NVR.
# - Example RTSP URL (for a path you configure or publish):
# rtsp://{{host}}:{{rtsp_port}}/vod
# - For on-demand publishing from a file, configure mediamtx.yml with runOnDemand ffmpeg.
# - Quick client test examples (Run in your terminal):
# ffplay -rtsp_transport tcp rtsp://{{host}}:{{rtsp_port}}/vod
# vlc rtsp://{{host}}:{{rtsp_port}}/vod
# If you enable WebRTC in MediaMTX, you can open its embedded player page (for testing):
# See MediaMTX docs; by default we exposed RTSP and WebRTC ports in compose.
###############################################
# Useful manual test values (copy/paste below) #
###############################################
# @name set-variables
# file_id examples (replace with values from /videos/):
# camera1%2Fcamera1_auto_blower_separator_20251030_160405.mp4
# camera2%2Ffoo_20251030_101010.mp4

View File

@@ -67,16 +67,40 @@ services:
image: node:20-alpine
working_dir: /app
environment:
- CHOKIDAR_USEPOLLING=true
- TZ=America/New_York
- VITE_MEDIA_API_URL=http://exp-dash:8090
- VITE_VISION_API_URL=http://exp-dash:8000
volumes:
- ./video-remote:/app
command: >
sh -lc "
npm install;
npm run build;
npx http-server dist -p 3001 --cors -c-1
npm run dev:watch
"
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "3001:3001"
media-api:
build:
context: ./media-api
dockerfile: Dockerfile
environment:
- MEDIA_VIDEOS_DIR=/mnt/nfs_share
- MEDIA_THUMBS_DIR=/mnt/nfs_share/.thumbnails
volumes:
- /mnt/nfs_share:/mnt/nfs_share
ports:
- "8090:8090"
mediamtx:
image: bluenviron/mediamtx:latest
volumes:
- ./mediamtx.yml:/mediamtx.yml:ro
- /mnt/nfs_share:/mnt/nfs_share:ro
ports:
- "8554:8554" # RTSP
- "8889:8889" # WebRTC HTTP API
- "8189:8189" # WebRTC UDP

25
media-api/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV MEDIA_VIDEOS_DIR=/mnt/videos \
MEDIA_THUMBS_DIR=/mnt/videos/.thumbnails \
MEDIA_PORT=8090
EXPOSE 8090
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]

168
media-api/main.py Normal file
View File

@@ -0,0 +1,168 @@
import os
import pathlib
import subprocess
import urllib.parse
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Response, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
MEDIA_DIR = pathlib.Path(os.getenv("MEDIA_VIDEOS_DIR", "/mnt/videos")).resolve()
THUMBS_DIR = pathlib.Path(os.getenv("MEDIA_THUMBS_DIR", MEDIA_DIR / ".thumbnails")).resolve()
app = FastAPI(title="Media API", version="0.1.0")
# CORS for dashboard at exp-dash:8080 (and localhost for convenience)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://exp-dash:8080", "http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
def list_video_files() -> List[pathlib.Path]:
exts = {".mp4", ".avi", ".mov", ".mkv"}
files: List[pathlib.Path] = []
for root, _, fnames in os.walk(MEDIA_DIR):
# skip thumbnails dir
if THUMBS_DIR.as_posix() in pathlib.Path(root).as_posix():
continue
for f in fnames:
p = pathlib.Path(root) / f
if p.suffix.lower() in exts:
files.append(p)
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
def file_id_from_path(p: pathlib.Path) -> str:
return urllib.parse.quote_plus(p.relative_to(MEDIA_DIR).as_posix())
def path_from_file_id(fid: str) -> pathlib.Path:
rel = urllib.parse.unquote_plus(fid)
p = (MEDIA_DIR / rel).resolve()
if not p.is_file() or MEDIA_DIR not in p.parents:
raise HTTPException(status_code=404, detail="Video not found")
return p
def ensure_thumbnail(p: pathlib.Path, width: int = 320, height: int = 180) -> pathlib.Path:
THUMBS_DIR.mkdir(parents=True, exist_ok=True)
thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg"
out = THUMBS_DIR / thumb_name
if out.exists():
return out
# generate from 1s offset or 0 if too short
cmd = [
"ffmpeg", "-y",
"-ss", "1",
"-i", str(p),
"-vframes", "1",
"-vf", f"scale='min({width},iw)':-2,scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2",
str(out)
]
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
return out
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/videos/")
def list_videos(camera_name: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None,
limit: int = 24, page: int = 1) -> dict:
files = list_video_files()
# basic filtering placeholder (camera_name can be inferred from path segments)
if camera_name:
files = [p for p in files if camera_name in p.as_posix()]
total = len(files)
start = max(0, (page - 1) * limit)
slice_ = files[start:start + limit]
items = []
for p in slice_:
stat = p.stat()
items.append({
"file_id": file_id_from_path(p),
"camera_name": p.parent.name,
"filename": p.name,
"file_size_bytes": stat.st_size,
"format": p.suffix.lstrip('.'),
"status": "completed",
"created_at": int(stat.st_mtime),
"is_streamable": True,
"needs_conversion": False,
})
total_pages = (total + limit - 1) // limit if limit else 1
return {
"videos": items,
"total_count": total,
"page": page,
"total_pages": total_pages,
}
@app.get("/videos/{file_id:path}/thumbnail")
def get_thumbnail(file_id: str, width: int = 320, height: int = 180):
p = path_from_file_id(file_id)
thumb = ensure_thumbnail(p, width, height)
return FileResponse(thumb, media_type="image/jpeg")
# Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded)
@app.get("/videos/thumbnail")
def get_thumbnail_q(file_id: str, width: int = 320, height: int = 180):
p = path_from_file_id(file_id)
thumb = ensure_thumbnail(p, width, height)
return FileResponse(thumb, media_type="image/jpeg")
def open_file_range(path: pathlib.Path, start: int, end: Optional[int]):
file_size = path.stat().st_size
if end is None or end >= file_size:
end = file_size - 1
length = end - start + 1
with open(path, 'rb') as f:
f.seek(start)
chunk = f.read(length)
return chunk, file_size, start, end
@app.get("/videos/{file_id:path}/stream")
def stream_file(request: Request, file_id: str):
p = path_from_file_id(file_id)
range_header = request.headers.get("range")
if not range_header:
return FileResponse(p, media_type="video/mp4")
# parse bytes=START-END
range_value = range_header.strip().lower().replace("bytes=", "")
start_str, _, end_str = range_value.partition("-")
try:
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else None
except ValueError:
raise HTTPException(status_code=400, detail="Bad Range header")
chunk, size, start, end = open_file_range(p, start, end)
headers = {
"Content-Range": f"bytes {start}-{end}/{size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Content-Type": "video/mp4",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Access-Control-Allow-Origin": "*",
}
return Response(content=chunk, status_code=206, headers=headers)
# Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded)
@app.get("/videos/stream")
def stream_file_q(request: Request, file_id: str):
return stream_file(request, file_id)

View File

@@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
pydantic==2.9.2
watchfiles==0.21.0

22
mediamtx.yml Normal file
View File

@@ -0,0 +1,22 @@
logLevel: info
rtsp: yes
rtmp: no
hls: yes
webrtc: yes
paths:
all:
# allow any path to be read; publishers can be added on-demand
readUser: any
sourceOnDemand: no
# Example on-demand publisher for a demo VOD (adjust file path):
# vod:
# readUser: any
# runOnDemand: |
# ffmpeg -re -stream_loop -1 -i /mnt/videos/sample.mp4 -c copy -f rtsp rtsp://localhost:8554/vod
# runOnDemandRestart: yes

View File

@@ -14,6 +14,8 @@
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.6.0",
"http-server": "^14.1.1",
"serve": "^14.2.3",
@@ -1460,6 +1462,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2012,6 +2034,13 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -6,8 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:watch": "vite build --watch",
"serve:dist": "serve -s dist -l 3001",
"preview": "vite preview --port 3001"
"preview": "vite preview --port 3001",
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3001 --cors -c-1"
},
"dependencies": {
"react": "^19.1.0",
@@ -15,14 +17,14 @@
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@vitejs/plugin-react": "^4.6.0",
"@tailwindcss/vite": "^4.1.11",
"tailwindcss": "^4.1.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.6.0",
"http-server": "^14.1.1",
"serve": "^14.2.3",
"vite": "^7.0.4",
"typescript": "~5.8.3"
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
}

View File

@@ -7,10 +7,17 @@ const App: React.FC = () => {
const [selected, setSelected] = useState<string | null>(null)
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Video Library</h1>
<VideoList onSelect={(id) => setSelected(id)} />
<VideoModal fileId={selected} onClose={() => setSelected(null)} />
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="px-6 py-5">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Video Library</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Browse and manage your video recordings</p>
</div>
</div>
<div className="px-6 py-6">
<VideoList onSelect={(id) => setSelected(id)} />
<VideoModal fileId={selected} onClose={() => setSelected(null)} />
</div>
</div>
)
}

View File

@@ -9,23 +9,74 @@ export const FiltersBar: React.FC<Props> = ({ onChange }) => {
const [start, setStart] = useState('')
const [end, setEnd] = useState('')
const handleApply = () => {
onChange({
camera_name: camera || undefined,
start_date: start || undefined,
end_date: end || undefined
})
}
const handleClear = () => {
setCamera('')
setStart('')
setEnd('')
onChange({})
}
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1">Camera</label>
<input value={camera} onChange={e=>setCamera(e.target.value)} placeholder="camera1" className="w-full px-3 py-2 border rounded" />
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
<button
onClick={handleClear}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Reset all
</button>
</div>
<div>
<label className="block text-sm mb-1">Start date</label>
<input type="date" value={start} onChange={e=>setStart(e.target.value)} className="w-full px-3 py-2 border rounded" />
</div>
<div>
<label className="block text-sm mb-1">End date</label>
<input type="date" value={end} onChange={e=>setEnd(e.target.value)} className="w-full px-3 py-2 border rounded" />
</div>
<div className="flex items-end gap-2">
<button className="px-3 py-2 border rounded" onClick={()=>onChange({ camera_name: camera || undefined, start_date: start || undefined, end_date: end || undefined })}>Apply</button>
<button className="px-3 py-2 border rounded" onClick={()=>{ setCamera(''); setStart(''); setEnd(''); onChange({}) }}>Clear</button>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Camera Name
</label>
<input
value={camera}
onChange={e => setCamera(e.target.value)}
placeholder="e.g., camera1"
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Start Date
</label>
<input
type="date"
value={start}
onChange={e => setStart(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
End Date
</label>
<input
type="date"
value={end}
onChange={e => setEnd(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
<div className="flex items-end">
<button
onClick={handleApply}
className="w-full px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Apply Filters
</button>
</div>
</div>
</div>
)

View File

@@ -8,11 +8,89 @@ type Props = {
export const Pagination: React.FC<Props> = ({ page, totalPages, onChange }) => {
if (totalPages <= 1) return null
const pages = []
const showPages = 5
let startPage = Math.max(1, page - Math.floor(showPages / 2))
let endPage = Math.min(totalPages, startPage + showPages - 1)
if (endPage - startPage < showPages - 1) {
startPage = Math.max(1, endPage - showPages + 1)
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i)
}
return (
<div className="flex items-center gap-2">
<button disabled={page<=1} onClick={()=>onChange(page-1)} className="px-3 py-1 border rounded disabled:opacity-50">Prev</button>
<span className="text-sm">Page {page} / {totalPages}</span>
<button disabled={page>=totalPages} onClick={()=>onChange(page+1)} className="px-3 py-1 border rounded disabled:opacity-50">Next</button>
<div className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm px-4 py-3">
<div className="flex items-center gap-2">
<button
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Previous
</button>
</div>
<div className="flex items-center gap-1">
{startPage > 1 && (
<>
<button
onClick={() => onChange(1)}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
>
1
</button>
{startPage > 2 && (
<span className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">...</span>
)}
</>
)}
{pages.map((p) => (
<button
key={p}
onClick={() => onChange(p)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
p === page
? 'bg-blue-600 text-white border border-blue-600'
: 'text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white'
}`}
>
{p}
</button>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && (
<span className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">...</span>
)}
<button
onClick={() => onChange(totalPages)}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
>
{totalPages}
</button>
</>
)}
</div>
<div className="flex items-center gap-2">
<button
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
<svg className="w-5 h-5 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)
}

View File

@@ -16,21 +16,75 @@ export type VideoFile = {
export type VideoCardProps = {
video: VideoFile
onClick?: (video: VideoFile) => void
onClick?: () => void
showMetadata?: boolean
className?: string
}
export const VideoCard: React.FC<VideoCardProps> = ({ video, onClick }) => {
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return dateString
}
}
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<div
className={`bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
onClick={onClick ? () => onClick(video) : undefined}
className={`group bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer hover:border-blue-500' : ''}`}
onClick={onClick}
>
<VideoThumbnail fileId={video.file_id} width={640} height={360} className="w-full h-auto" alt={video.filename} />
<div className="relative overflow-hidden bg-gray-100 dark:bg-gray-700">
<VideoThumbnail
fileId={video.file_id}
width={640}
height={360}
className="w-full h-auto transition-transform duration-300 group-hover:scale-105"
alt={video.filename}
/>
<div className="absolute top-2 right-2">
{video.is_streamable ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Streamable
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Processing
</span>
)}
</div>
</div>
<div className="p-4">
<div className="text-sm text-gray-500 mb-1">{video.camera_name}</div>
<div className="font-semibold text-gray-900 truncate" title={video.filename}>{video.filename}</div>
<div className="flex items-center justify-between mb-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{video.camera_name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{formatFileSize(video.file_size_bytes)}</span>
</div>
<h3 className="font-semibold text-gray-900 dark:text-white truncate mb-1" title={video.filename}>
{video.filename}
</h3>
<div className="flex items-center justify-between mt-2">
<p className="text-xs text-gray-500 dark:text-gray-400">{formatDate(video.created_at)}</p>
{video.metadata?.duration_seconds && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{Math.floor(video.metadata.duration_seconds / 60)}:{(video.metadata.duration_seconds % 60).toFixed(0).padStart(2, '0')}
</span>
)}
</div>
</div>
</div>
)

View File

@@ -31,18 +31,79 @@ export const VideoList: React.FC<Props> = ({ onSelect }) => {
useEffect(() => { load(page) }, [page])
if (loading) return <div className="p-6">Loading videos</div>
if (error) return <div className="p-6 text-red-600">{error}</div>
return (
<div className="space-y-6">
<FiltersBar onChange={(f) => { setFilters(f); setPage(1); load(1, f) }} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{videos.map(v => (
<VideoCard key={v.file_id} video={v} onClick={onSelect ? () => onSelect(v.file_id) : undefined} />
))}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
{loading && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-12">
<div className="flex flex-col items-center justify-center">
<div className="relative">
<div className="w-16 h-16 border-4 border-blue-200 dark:border-blue-900 border-t-blue-600 dark:border-t-blue-500 rounded-full animate-spin"></div>
</div>
<p className="mt-4 text-sm font-medium text-gray-700 dark:text-gray-300">Loading videos...</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Please wait while we fetch your recordings</p>
</div>
</div>
)}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg shadow-sm p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error loading videos</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={() => load(page, filters)}
className="text-sm font-medium text-red-800 dark:text-red-200 hover:text-red-900 dark:hover:text-red-100 underline"
>
Try again
</button>
</div>
</div>
</div>
</div>
)}
{!loading && !error && videos.length === 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-12">
<div className="flex flex-col items-center justify-center text-center">
<svg className="w-16 h-16 text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">No videos found</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm">
{Object.keys(filters).length > 0
? "Try adjusting your filters to see more results."
: "No videos have been recorded yet. Check back later or contact support if you believe this is an error."}
</p>
</div>
</div>
)}
{!loading && !error && videos.length > 0 && (
<>
<div className="mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Showing <span className="font-medium text-gray-900 dark:text-white">{videos.length}</span> videos
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{videos.map(v => (
<VideoCard key={v.file_id} video={v} onClick={onSelect ? () => onSelect(v.file_id) : undefined} />
))}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import React from 'react'
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
const BASE = (import.meta as any).env?.VITE_MEDIA_API_URL || (import.meta as any).env?.VITE_VISION_API_URL || '/api'
type Props = {
fileId: string | null
@@ -8,16 +8,41 @@ type Props = {
export const VideoModal: React.FC<Props> = ({ fileId, onClose }) => {
if (!fileId) return null
const src = `${BASE}/videos/${fileId}/stream`
const src = `${BASE}/videos/stream?file_id=${encodeURIComponent(fileId)}`
return (
<div className="fixed inset-0 z-[1000] bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-lg w-full max-w-3xl overflow-hidden">
<div className="flex items-center justify-between p-3 border-b">
<div className="font-semibold">Video</div>
<button onClick={onClose} className="px-2 py-1 border rounded">Close</button>
<div
className="fixed inset-0 z-[1000] bg-black/60 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl overflow-hidden transform transition-all relative"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{/* Close button - positioned absolutely in top right corner */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 inline-flex items-center justify-center w-10 h-10 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 shadow-md hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-all duration-200"
aria-label="Close modal"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="p-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Video Player</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Watch your recording</p>
</div>
<div className="p-3">
<video src={src} controls className="w-full h-auto" />
<div className="p-4 bg-black">
<div className="relative w-full" style={{ aspectRatio: '16/9', maxHeight: '70vh' }}>
<video
src={src}
controls
className="w-full h-full rounded-lg object-contain"
autoPlay
/>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import React from 'react'
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
const BASE = (import.meta as any).env?.VITE_MEDIA_API_URL || (import.meta as any).env?.VITE_VISION_API_URL || '/api'
type Props = {
fileId: string
@@ -12,10 +12,10 @@ type Props = {
export const VideoThumbnail: React.FC<Props> = ({ fileId, width = 320, height = 180, alt = '', className = '', onClick }) => {
const p = new URLSearchParams()
p.append('file_id', fileId) // supports raw or encoded
if (width) p.append('width', String(width))
if (height) p.append('height', String(height))
const qs = p.toString()
const src = `${BASE}/videos/${fileId}/thumbnail${qs ? `?${qs}` : ''}`
const src = `${BASE}/videos/thumbnail?${p.toString()}`
return (
<img src={src} alt={alt} width={width} height={height} className={className} onClick={onClick} />
)

View File

@@ -1,3 +1,56 @@
@import "tailwindcss";
/* TailAdmin-style enhancements */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar styling for webkit browsers */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Dark mode scrollbar */
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-track {
background: #1f2937;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
}

View File

@@ -2,7 +2,7 @@ type VideoListParams = { camera_name?: string; start_date?: string; end_date?: s
type VideoFile = { file_id: string; camera_name: string; filename: string; file_size_bytes: number; format: string; status: string; created_at: string; is_streamable: boolean; needs_conversion: boolean }
type VideoListResponse = { videos: VideoFile[]; total_count: number; page?: number; total_pages?: number }
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
const BASE = (import.meta as any).env?.VITE_MEDIA_API_URL || (import.meta as any).env?.VITE_VISION_API_URL || '/api'
async function get<T>(path: string): Promise<T> {
const res = await fetch(path, { headers: { 'Accept': 'application/json' } })