diff --git a/api-tests.http b/api-tests.http new file mode 100644 index 0000000..d3fa7f4 --- /dev/null +++ b/api-tests.http @@ -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 + + diff --git a/docker-compose.yml b/docker-compose.yml index 26c505d..f2e935f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/media-api/Dockerfile b/media-api/Dockerfile new file mode 100644 index 0000000..24b4314 --- /dev/null +++ b/media-api/Dockerfile @@ -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"] + + + + + diff --git a/media-api/main.py b/media-api/main.py new file mode 100644 index 0000000..bcffe2b --- /dev/null +++ b/media-api/main.py @@ -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) + + + diff --git a/media-api/requirements.txt b/media-api/requirements.txt new file mode 100644 index 0000000..83c94ed --- /dev/null +++ b/media-api/requirements.txt @@ -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 + + + + diff --git a/mediamtx.yml b/mediamtx.yml new file mode 100644 index 0000000..b591529 --- /dev/null +++ b/mediamtx.yml @@ -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 + + + + diff --git a/video-remote/package-lock.json b/video-remote/package-lock.json index 6c7674c..4af3420 100644 --- a/video-remote/package-lock.json +++ b/video-remote/package-lock.json @@ -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", diff --git a/video-remote/package.json b/video-remote/package.json index 7c1f391..ed3cf0a 100644 --- a/video-remote/package.json +++ b/video-remote/package.json @@ -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" } -} - - +} \ No newline at end of file diff --git a/video-remote/src/App.tsx b/video-remote/src/App.tsx index 0647f4a..abe69f5 100644 --- a/video-remote/src/App.tsx +++ b/video-remote/src/App.tsx @@ -7,10 +7,17 @@ const App: React.FC = () => { const [selected, setSelected] = useState(null) return ( -
-

Video Library

- setSelected(id)} /> - setSelected(null)} /> +
+
+
+

Video Library

+

Browse and manage your video recordings

+
+
+
+ setSelected(id)} /> + setSelected(null)} /> +
) } diff --git a/video-remote/src/components/FiltersBar.tsx b/video-remote/src/components/FiltersBar.tsx index 5e6d217..962f4a9 100644 --- a/video-remote/src/components/FiltersBar.tsx +++ b/video-remote/src/components/FiltersBar.tsx @@ -9,23 +9,74 @@ export const FiltersBar: React.FC = ({ 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 ( -
-
- - setCamera(e.target.value)} placeholder="camera1" className="w-full px-3 py-2 border rounded" /> +
+
+

Filters

+
-
- - setStart(e.target.value)} className="w-full px-3 py-2 border rounded" /> -
-
- - setEnd(e.target.value)} className="w-full px-3 py-2 border rounded" /> -
-
- - +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
) diff --git a/video-remote/src/components/Pagination.tsx b/video-remote/src/components/Pagination.tsx index abad21a..df0a06a 100644 --- a/video-remote/src/components/Pagination.tsx +++ b/video-remote/src/components/Pagination.tsx @@ -8,11 +8,89 @@ type Props = { export const Pagination: React.FC = ({ 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 ( -
- - Page {page} / {totalPages} - +
+
+ +
+ +
+ {startPage > 1 && ( + <> + + {startPage > 2 && ( + ... + )} + + )} + {pages.map((p) => ( + + ))} + {endPage < totalPages && ( + <> + {endPage < totalPages - 1 && ( + ... + )} + + + )} +
+ +
+ +
) } diff --git a/video-remote/src/components/VideoCard.tsx b/video-remote/src/components/VideoCard.tsx index 343b48b..000afd7 100644 --- a/video-remote/src/components/VideoCard.tsx +++ b/video-remote/src/components/VideoCard.tsx @@ -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 = ({ 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 (
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} > - +
+ +
+ {video.is_streamable ? ( + + Streamable + + ) : ( + + Processing + + )} +
+
-
{video.camera_name}
-
{video.filename}
+
+ + {video.camera_name} + + {formatFileSize(video.file_size_bytes)} +
+

+ {video.filename} +

+
+

{formatDate(video.created_at)}

+ {video.metadata?.duration_seconds && ( + + {Math.floor(video.metadata.duration_seconds / 60)}:{(video.metadata.duration_seconds % 60).toFixed(0).padStart(2, '0')} + + )} +
) diff --git a/video-remote/src/components/VideoList.tsx b/video-remote/src/components/VideoList.tsx index e4c2ce8..159b57c 100644 --- a/video-remote/src/components/VideoList.tsx +++ b/video-remote/src/components/VideoList.tsx @@ -31,18 +31,79 @@ export const VideoList: React.FC = ({ onSelect }) => { useEffect(() => { load(page) }, [page]) - if (loading) return
Loading videos…
- if (error) return
{error}
- return (
{ setFilters(f); setPage(1); load(1, f) }} /> -
- {videos.map(v => ( - onSelect(v.file_id) : undefined} /> - ))} -
- + + {loading && ( +
+
+
+
+
+

Loading videos...

+

Please wait while we fetch your recordings

+
+
+ )} + + {error && ( +
+
+
+ + + +
+
+

Error loading videos

+
+

{error}

+
+
+ +
+
+
+
+ )} + + {!loading && !error && videos.length === 0 && ( +
+
+ + + +

No videos found

+

+ {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."} +

+
+
+ )} + + {!loading && !error && videos.length > 0 && ( + <> +
+

+ Showing {videos.length} videos +

+
+
+ {videos.map(v => ( + onSelect(v.file_id) : undefined} /> + ))} +
+ + + )}
) } diff --git a/video-remote/src/components/VideoModal.tsx b/video-remote/src/components/VideoModal.tsx index fa6ae65..2a17e22 100644 --- a/video-remote/src/components/VideoModal.tsx +++ b/video-remote/src/components/VideoModal.tsx @@ -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 = ({ fileId, onClose }) => { if (!fileId) return null - const src = `${BASE}/videos/${fileId}/stream` + const src = `${BASE}/videos/stream?file_id=${encodeURIComponent(fileId)}` + return ( -
-
-
-
Video
- +
+
e.stopPropagation()} + > + {/* Close button - positioned absolutely in top right corner */} + + +
+

Video Player

+

Watch your recording

-
-
diff --git a/video-remote/src/components/VideoThumbnail.tsx b/video-remote/src/components/VideoThumbnail.tsx index 8a25378..ae44e24 100644 --- a/video-remote/src/components/VideoThumbnail.tsx +++ b/video-remote/src/components/VideoThumbnail.tsx @@ -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 = ({ 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 ( {alt} ) diff --git a/video-remote/src/index.css b/video-remote/src/index.css index a041a74..d98fc43 100644 --- a/video-remote/src/index.css +++ b/video-remote/src/index.css @@ -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; + } +} + diff --git a/video-remote/src/services/video.ts b/video-remote/src/services/video.ts index debf0a9..9230664 100644 --- a/video-remote/src/services/video.ts +++ b/video-remote/src/services/video.ts @@ -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(path: string): Promise { const res = await fetch(path, { headers: { 'Accept': 'application/json' } })