From e53c1b7b152b5d292377972e7d2b6f01dca9a555 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 13:06:43 +0100 Subject: [PATCH 01/13] Implement media upload features and enhance session management - Added MediaUpload component for handling video and photo uploads with progress tracking and cancellation. - Introduced SessionMediaSection for attaching media to sessions, including video and poster uploads. - Enhanced CreateSession route to support optional player photo uploads during session creation. - Updated SessionDetail to display video and media upload options. - Improved media handling in the API, allowing for video URLs, posters, and player photos to be updated in sessions. - Added support for new video MIME types and updated OpenAPI specifications accordingly. - Implemented responsive image handling for session posters using PosterThumb component. --- SUBMISSION.md | 2 +- apps/api/src/lib/media-policy.ts | 13 +- apps/api/src/openapi/spec.ts | 15 +++ apps/api/src/services/sessions.ts | 22 +++- apps/web/src/components/MediaUpload.tsx | 111 ++++++++++++++++++ apps/web/src/components/PosterThumb.tsx | 19 +++ .../src/components/SessionMediaSection.tsx | 46 ++++++++ apps/web/src/components/SessionVideo.tsx | 83 +++++++++++++ apps/web/src/lib/media-src.ts | 25 ++++ apps/web/src/lib/upload-media.ts | 56 +++++++++ apps/web/src/routes/CreateSession.tsx | 101 +++++++++++----- apps/web/src/routes/History.tsx | 11 +- apps/web/src/routes/SessionDetail.tsx | 19 +-- apps/web/vite.config.d.ts.map | 2 +- apps/web/vite.config.js | 18 ++- apps/web/vite.config.ts | 18 ++- docs/ROADMAP.md | 16 +-- docs/checklists/03-known-gaps.md | 16 +-- docs/checklists/04-enhancements.md | 16 +-- package.json | 1 + packages/shared/src/__tests__/schema.test.ts | 8 ++ packages/shared/src/schema.ts | 24 +++- scripts/transcode-local.mjs | 104 ++++++++++++++-- 23 files changed, 653 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/components/MediaUpload.tsx create mode 100644 apps/web/src/components/PosterThumb.tsx create mode 100644 apps/web/src/components/SessionMediaSection.tsx create mode 100644 apps/web/src/components/SessionVideo.tsx create mode 100644 apps/web/src/lib/upload-media.ts diff --git a/SUBMISSION.md b/SUBMISSION.md index 63c150f..09458b7 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -18,7 +18,7 @@ Frontend performance · efficient image/video handling · what changes at scale ## Notable extras -RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · session picker + end-session lifecycle · virtualised history with cursor pagination · score timeline on detail · light/dark theme · full docs hub + ADRs + diagrams. +RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · R2 media upload from the UI (progress + cancel) · offline FFmpeg transcode script · Media Capabilities video source pick · session picker + end-session lifecycle · virtualised history · full docs hub + ADRs + diagrams. ## Systematic checklists & roadmap diff --git a/apps/api/src/lib/media-policy.ts b/apps/api/src/lib/media-policy.ts index 912833d..71aacaf 100644 --- a/apps/api/src/lib/media-policy.ts +++ b/apps/api/src/lib/media-policy.ts @@ -1,6 +1,11 @@ /** Allowed MIME types and size limits for uploads. */ export const PHOTO_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif']); -export const VIDEO_MIMES = new Set(['video/mp4', 'video/webm']); +export const VIDEO_MIMES = new Set([ + 'video/mp4', + 'video/webm', + 'application/vnd.apple.mpegurl', + 'application/x-mpegURL', +]); export const MAX_PHOTO_BYTES = 5 * 1024 * 1024; export const MAX_VIDEO_BYTES = 100 * 1024 * 1024; @@ -14,7 +19,8 @@ export function classifyMedia(mime: string): MediaKind | null { } export function maxBytesFor(kind: MediaKind): number { - return kind === 'photo' ? MAX_PHOTO_BYTES : MAX_VIDEO_BYTES; + if (kind === 'photo') return MAX_PHOTO_BYTES; + return MAX_VIDEO_BYTES; } export function extensionFor(mime: string): string { @@ -31,6 +37,9 @@ export function extensionFor(mime: string): string { return 'mp4'; case 'video/webm': return 'webm'; + case 'application/vnd.apple.mpegurl': + case 'application/x-mpegURL': + return 'm3u8'; default: return 'bin'; } diff --git a/apps/api/src/openapi/spec.ts b/apps/api/src/openapi/spec.ts index e674a1e..71eca22 100644 --- a/apps/api/src/openapi/spec.ts +++ b/apps/api/src/openapi/spec.ts @@ -133,6 +133,21 @@ export const openApiDocument = { }, }, }, + videoUrl: { type: 'string', description: 'HTTPS URL or R2 key' }, + videoPoster: { type: 'string', description: 'HTTPS URL or R2 key' }, + hlsUrl: { type: 'string', description: 'HTTPS URL or R2 key' }, + playerPhotos: { + type: 'array', + maxItems: 12, + items: { + type: 'object', + required: ['playerId', 'photoUrl'], + properties: { + playerId: { type: 'string', format: 'uuid' }, + photoUrl: { type: 'string' }, + }, + }, + }, }, }, }, diff --git a/apps/api/src/services/sessions.ts b/apps/api/src/services/sessions.ts index 8f8f9ad..2413c83 100644 --- a/apps/api/src/services/sessions.ts +++ b/apps/api/src/services/sessions.ts @@ -143,7 +143,27 @@ export async function patchSession( }); } - if (input.scores?.length || input.status) { + for (const photo of input.playerPhotos ?? []) { + await tx + .update(players) + .set({ photoUrl: photo.photoUrl }) + .where(and(eq(players.id, photo.playerId), eq(players.sessionId, sessionId))); + } + + const mediaPatch: Partial = {}; + if (input.videoUrl !== undefined) mediaPatch.videoUrl = input.videoUrl; + if (input.videoPoster !== undefined) mediaPatch.videoPoster = input.videoPoster; + if (input.hlsUrl !== undefined) mediaPatch.hlsUrl = input.hlsUrl; + + const hasMediaPatch = Object.keys(mediaPatch).length > 0; + if (hasMediaPatch) { + await tx + .update(sessions) + .set({ ...mediaPatch, updatedAt: new Date() }) + .where(eq(sessions.id, sessionId)); + } + + if (input.scores?.length || input.status || input.playerPhotos?.length || hasMediaPatch) { await tx.update(sessions).set({ updatedAt: new Date() }).where(eq(sessions.id, sessionId)); } diff --git a/apps/web/src/components/MediaUpload.tsx b/apps/web/src/components/MediaUpload.tsx new file mode 100644 index 0000000..c8085af --- /dev/null +++ b/apps/web/src/components/MediaUpload.tsx @@ -0,0 +1,111 @@ +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { uploadMedia, type UploadProgress } from '@/lib/upload-media'; + +type MediaUploadProps = { + accept: string; + label: string; + hint?: string; + disabled?: boolean; + onUploaded: (result: { key: string; kind: 'photo' | 'video' }) => void | Promise; +}; + +/** File picker with upload progress bar and cancel (XHR abort). */ +export function MediaUpload({ accept, label, hint, disabled, onUploaded }: MediaUploadProps) { + const inputRef = useRef(null); + const abortRef = useRef(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function onFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + + setError(null); + setBusy(true); + setProgress({ loaded: 0, total: file.size, percent: 0 }); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const result = await uploadMedia(file, { + signal: controller.signal, + onProgress: setProgress, + }); + await onUploaded(result); + setProgress(null); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Upload failed'; + if (msg !== 'Upload cancelled') setError(msg); + setProgress(null); + } finally { + abortRef.current = null; + setBusy(false); + } + } + + function cancel() { + abortRef.current?.abort(); + } + + return ( +
+
+
+

{label}

+ {hint ?

{hint}

: null} +
+ +
+ + void onFileChange(e)} + /> + + {progress ? ( +
+
+
+
+
+ {progress.percent}% + +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/PosterThumb.tsx b/apps/web/src/components/PosterThumb.tsx new file mode 100644 index 0000000..7fe1b19 --- /dev/null +++ b/apps/web/src/components/PosterThumb.tsx @@ -0,0 +1,19 @@ +import { posterSrcSet } from '@/lib/media-src'; + +/** History row thumbnail with responsive srcset when derivable. */ +export function PosterThumb({ src, alt = '' }: { src: string; alt?: string }) { + const srcSet = posterSrcSet(src); + return ( + {alt} + ); +} diff --git a/apps/web/src/components/SessionMediaSection.tsx b/apps/web/src/components/SessionMediaSection.tsx new file mode 100644 index 0000000..edfb625 --- /dev/null +++ b/apps/web/src/components/SessionMediaSection.tsx @@ -0,0 +1,46 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { MediaUpload } from '@/components/MediaUpload'; +import { api } from '@/lib/api'; + +/** Upload game video / poster and attach to a session via PATCH. */ +export function SessionMediaSection({ sessionId, hasVideo }: { sessionId: string; hasVideo: boolean }) { + const qc = useQueryClient(); + const [notice, setNotice] = useState(null); + + async function attach(fields: { videoUrl?: string; videoPoster?: string; hlsUrl?: string }) { + setNotice(null); + await api.patchSession(sessionId, fields); + await qc.invalidateQueries({ queryKey: ['session', sessionId] }); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + await qc.invalidateQueries({ queryKey: ['sessions', 'history'] }); + setNotice('Media attached to this session.'); + } + + return ( +
+

+ Session media +

+ {!hasVideo ? ( +

No video yet — upload a clip or poster below.

+ ) : null} + + attach({ videoUrl: result.key })} + /> + + attach({ videoPoster: result.key })} + /> + + {notice ?

{notice}

: null} +
+ ); +} diff --git a/apps/web/src/components/SessionVideo.tsx b/apps/web/src/components/SessionVideo.tsx new file mode 100644 index 0000000..58e9f4a --- /dev/null +++ b/apps/web/src/components/SessionVideo.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; + +type VideoSource = { src: string; type: string }; + +function buildCandidates(videoUrl: string, hlsUrl?: string | null): VideoSource[] { + const out: VideoSource[] = []; + if (hlsUrl) out.push({ src: hlsUrl, type: 'application/vnd.apple.mpegurl' }); + const isWebm = videoUrl.includes('.webm'); + out.push({ src: videoUrl, type: isWebm ? 'video/webm' : 'video/mp4' }); + return out; +} + +async function rankByMediaCapabilities(sources: VideoSource[]): Promise { + if ( + !('mediaCapabilities' in navigator) || + typeof navigator.mediaCapabilities?.decodingInfo !== 'function' + ) { + return sources; + } + + const scored = await Promise.all( + sources.map(async (source) => { + try { + const info = await navigator.mediaCapabilities.decodingInfo({ + type: 'media-source', + video: { + contentType: source.type, + width: 1280, + height: 720, + bitrate: 2_500_000, + framerate: 30, + }, + }); + const score = (info.supported ? 2 : 0) + (info.smooth ? 1 : 0) + (info.powerEfficient ? 1 : 0); + return { source, score }; + } catch { + return { source, score: 1 }; + } + }), + ); + + return scored.sort((a, b) => b.score - a.score).map((row) => row.source); +} + +/** Video player that orders sources via Media Capabilities when available. */ +export function SessionVideo({ + videoUrl, + videoPoster, + hlsUrl, +}: { + videoUrl: string; + videoPoster?: string | null; + hlsUrl?: string | null; +}) { + const [sources, setSources] = useState(() => buildCandidates(videoUrl, hlsUrl)); + + useEffect(() => { + let cancelled = false; + const candidates = buildCandidates(videoUrl, hlsUrl); + void rankByMediaCapabilities(candidates).then((ranked) => { + if (!cancelled) setSources(ranked.length ? ranked : candidates); + }); + return () => { + cancelled = true; + }; + }, [videoUrl, hlsUrl]); + + return ( + + ); +} diff --git a/apps/web/src/lib/media-src.ts b/apps/web/src/lib/media-src.ts index 3e19102..6e0d1aa 100644 --- a/apps/web/src/lib/media-src.ts +++ b/apps/web/src/lib/media-src.ts @@ -8,3 +8,28 @@ export function avatarSrcSet(url: string): string | undefined { const large = u.toString(); return `${small} 40w, ${large} 80w`; } + +/** Poster/thumbnail srcset — ui-avatars density variants or Cloudflare-style width params. */ +export function posterSrcSet(url: string): string | undefined { + if (url.includes('ui-avatars.com')) { + const u = new URL(url); + u.searchParams.set('size', '80'); + const small = u.toString(); + u.searchParams.set('size', '160'); + const large = u.toString(); + return `${small} 80w, ${large} 160w`; + } + + try { + const u = new URL(url); + if (u.searchParams.has('w') || u.searchParams.has('width')) { + const base = u.toString(); + u.searchParams.set('w', '160'); + return `${base} 80w, ${u.toString()} 160w`; + } + } catch { + return undefined; + } + + return undefined; +} diff --git a/apps/web/src/lib/upload-media.ts b/apps/web/src/lib/upload-media.ts new file mode 100644 index 0000000..a53330d --- /dev/null +++ b/apps/web/src/lib/upload-media.ts @@ -0,0 +1,56 @@ +const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8787'; +const OWNER_HEADER = 'demo-key-a'; + +export type UploadResult = { key: string; kind: 'photo' | 'video'; url: string }; +export type UploadProgress = { loaded: number; total: number; percent: number }; + +/** Upload via XHR so we get progress events and can abort mid-flight. */ +export function uploadMedia( + file: File, + opts?: { onProgress?: (p: UploadProgress) => void; signal?: AbortSignal }, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${BASE}/media/upload`); + xhr.setRequestHeader('content-type', file.type); + xhr.setRequestHeader('x-oche-owner', OWNER_HEADER); + + const onAbort = () => xhr.abort(); + opts?.signal?.addEventListener('abort', onAbort, { once: true }); + + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable || !opts?.onProgress) return; + opts.onProgress({ + loaded: e.loaded, + total: e.total, + percent: Math.min(100, Math.round((e.loaded / e.total) * 100)), + }); + }; + + xhr.onload = () => { + opts?.signal?.removeEventListener('abort', onAbort); + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText) as UploadResult); + return; + } + try { + const body = JSON.parse(xhr.responseText) as { error: string }; + reject(new Error(body.error)); + } catch { + reject(new Error('Upload failed')); + } + }; + + xhr.onerror = () => { + opts?.signal?.removeEventListener('abort', onAbort); + reject(new Error('Network error during upload')); + }; + + xhr.onabort = () => { + opts?.signal?.removeEventListener('abort', onAbort); + reject(new Error('Upload cancelled')); + }; + + xhr.send(file); + }); +} diff --git a/apps/web/src/routes/CreateSession.tsx b/apps/web/src/routes/CreateSession.tsx index 5f361e0..39ff269 100644 --- a/apps/web/src/routes/CreateSession.tsx +++ b/apps/web/src/routes/CreateSession.tsx @@ -4,14 +4,17 @@ import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { api } from '@/lib/api'; +import { uploadMedia } from '@/lib/upload-media'; -/** Create a session + players (POST /sessions). */ +/** Create a session + players (POST /sessions), with optional player photo uploads. */ export function CreateSession() { const navigate = useNavigate(); const qc = useQueryClient(); const [title, setTitle] = useState(''); const [names, setNames] = useState(['', '']); + const [photos, setPhotos] = useState<(File | null)[]>([null, null]); const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); const create = useMutation({ mutationFn: (body: CreateSessionInput) => api.createSession(body), @@ -27,19 +30,28 @@ export function CreateSession() { setNames((prev) => prev.map((n, idx) => (idx === i ? value : n))); } + function setPhoto(i: number, file: File | null) { + setPhotos((prev) => prev.map((p, idx) => (idx === i ? file : p))); + } + function addPlayer() { - if (names.length < 12) setNames((prev) => [...prev, '']); + if (names.length < 12) { + setNames((prev) => [...prev, '']); + setPhotos((prev) => [...prev, null]); + } } function removePlayer(i: number) { if (names.length <= 1) return; setNames((prev) => prev.filter((_, idx) => idx !== i)); + setPhotos((prev) => prev.filter((_, idx) => idx !== i)); } - function submit(e: React.FormEvent) { + async function submit(e: React.FormEvent) { e.preventDefault(); setError(null); - const players = names.map((name) => name.trim()).filter(Boolean); + const trimmed = names.map((name) => name.trim()); + const players = trimmed.map((name, position) => ({ name, position })).filter((p) => p.name); if (!title.trim()) { setError('Title is required.'); return; @@ -48,21 +60,43 @@ export function CreateSession() { setError('Add at least one player.'); return; } - create.mutate({ - title: title.trim(), - status: 'active', - players: players.map((name, position) => ({ name, score: 0, position })), - }); + + setUploading(true); + try { + const withPhotos = await Promise.all( + players.map(async (p, index) => { + const file = photos[index]; + if (!file) { + return { name: p.name, score: 0, position: p.position }; + } + const uploaded = await uploadMedia(file); + return { name: p.name, score: 0, position: p.position, photoUrl: uploaded.key }; + }), + ); + + create.mutate({ + title: title.trim(), + status: 'active', + players: withPhotos, + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Photo upload failed.'); + } finally { + setUploading(false); + } } + const busy = create.isPending || uploading; + return (

New session

- Creates a session via POST /sessions. + Creates a session via POST /sessions. Optional player photos upload + to R2 first.

-
+ void submit(e)} className="mt-6 grid gap-4">