Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,19 @@ Contributor recordings stay in memory as a `Blob` while the user moves between
the player and editor; closing the tab discards an unsubmitted take. On
submission, the frontend uploads the recording and trim/mirror metadata to the
backend. The backend validates and stores the recording and enforces the
submission cooldown. Admins prepare reference and demo videos in the browser
before upload: they can remove audio and trim the clip, with processing performed
submission cooldown. After submission, the recording uploads in the background:
one upload can run while one waits, and the contributor can continue browsing.
Failed uploads remain in the queue until retried or discarded. The queue is
memory-only, so the browser warns before a pending upload is lost; switching
accounts clears that account's queue.

Reference videos are downloaded into an in-memory cache for five minutes after
download completes, and the next video in the category is prefetched. Individual
videos larger than 100 MB bypass the cache, and cached entries are limited to
250 MB total; videos still playing are not revoked mid-playback.

Admins prepare reference and demo videos in the browser before upload: they can
remove audio and trim the clip, with processing performed
locally by ffmpeg.wasm when changes are applied. The browser also reports the
video's displayed dimensions and duration; the backend stores these values for
responsive playback and display. No video-processing worker is required on the
Expand All @@ -90,7 +101,7 @@ src/
main.jsx Browser entry point and provider setup
components/ Shared layouts, controls, navigation, and route guards
config/ UI configuration
context/ Auth, library, recording, notification, and theme state
context/ Auth, library, recording, upload queue, notification, and theme state
hooks/ Reusable hooks, including camera/MediaRecorder handling
pages/ Public and contributor pages
pages/admin/ Admin workspace pages
Expand Down
28 changes: 22 additions & 6 deletions frontend/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ management library.
| `AuthProvider` | Loads the current session from the API and exposes login, signup, email verification, logout, and connection updates. |
| `LibraryProvider` | Loads categories, visible videos, and the current user's submissions; provides derived category, contribution, and cooldown state plus admin mutations. |
| `RecordingProvider` | Holds unsubmitted recording blobs and trim/mirror edits in memory, keyed by video ID. These drafts are discarded on submit, replacement, user change, or tab close. |
| `UploadQueueProvider` | Holds up to two submitted recordings in an in-memory queue (one uploading and one waiting). Failed uploads keep their slot for retry or discard; the queue is cleared when the signed-in user changes. |
| `NotificationProvider` | Stores per-user notification items and read state in browser `localStorage`; notifications do not sync across devices. |
| `ThemeProvider` | Follows the OS theme until a user selects one, then persists the selection locally. |

Expand Down Expand Up @@ -88,9 +89,24 @@ interface hint; the backend independently enforces the cooldown.
`useRecorder` captures camera and microphone media through browser media APIs.
The `RecordingProvider` keeps the current take available while a contributor
moves between `/watch` and `/watch/edit`. The editor applies trim and mirror
settings as submission metadata, then uploads the recording blob and metadata
through the API service. The backend stores the uploaded recording and metadata;
it does not physically trim or transcode contributor recordings.
settings as submission metadata, then hands the recording blob and metadata to
`UploadQueueProvider`. Uploads run one at a time through an XMLHttpRequest so
the UI can report byte progress and abort on account changes. The editor returns
to the player immediately; a page-level toaster reports waiting, active, failed,
and completed uploads. Failed uploads retain their queue slot until retried or
discarded. Queue data is memory-only, and a `beforeunload` prompt warns if the
tab is closed or reloaded while work remains. The backend stores contributor
recordings and metadata; it does not physically trim or transcode them.

### Reference-video cache

`useCachedVideo` loads reference videos into an in-memory Blob cache and falls
back to the normal media URL if the download fails or exceeds the per-video
100 MB cap. Entries expire five minutes after download and the cache evicts
oldest entries to stay within a 250 MB total budget. A player holds its entry
while it is in use so an expired or evicted object URL is not revoked during
playback. The player also prefetches the next video in its category after the
current video's load attempt finishes.

### Admin video preparation and responsive playback

Expand Down Expand Up @@ -164,10 +180,10 @@ frontend/
seo/ # SEO metadata and helpers
styles/ # Theme tokens and feature styles
utils/ # Shared browser and media utilities
test/
RecorderPanel.test.jsx # Recorder workflow component tests
docs/
ARCHITECTURE.md # This guide
architecture.md # This guide
test/
RecorderPanel.test.jsx # Recorder workflow component tests
scripts/
generateSeoAssets.js # Sitemap, robots, and prerender route generation
runReactSnap.js # Static route prerendering
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.4",
"version": "1.0.5",
"type": "module",
"engines": {
"node": ">=20.19.0"
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/BasePlayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ResponsiveVideo from './ResponsiveVideo'
const SPEEDS = [0.5, 0.75, 1]

/** Base video player: play/pause, replay, seek, speed and fullscreen. The parent owns the <video> ref. */
export default function BasePlayer({ video, videoRef, onAspect }) {
export default function BasePlayer({ video, videoRef, onAspect, src, loading, progress }) {
const frame = useRef(null)
const [playing, setPlaying] = useState(false)
const [time, setTime] = useState(0)
Expand Down Expand Up @@ -45,7 +45,7 @@ export default function BasePlayer({ video, videoRef, onAspect }) {
<div className="base-player-stage">
<ResponsiveVideo
videoRef={videoRef}
src={video.videoUrl}
src={src}
poster={video.poster}
aspect={aspectOf(video)}
onAspect={onAspect}
Expand All @@ -63,7 +63,9 @@ export default function BasePlayer({ video, videoRef, onAspect }) {
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration)}
onError={() => setFailed(true)}
>
{failed ? (
{loading && !failed ? (
<p className="base-player-loading" role="status">Loading video{Number.isFinite(progress) && progress > 0 ? ` ${Math.round(progress * 100)}%` : '…'}</p>
) : failed ? (
<p className="base-player-error" role="alert">This video could not be loaded. Try again later or pick another video.</p>
) : (
!playing && <button type="button" className="base-player-big" onClick={toggle} aria-label="Play video">▶</button>
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/components/UploadToaster.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Button } from './ui'

const percent = (fraction) => `${Math.round((fraction || 0) * 100)}%`

/**
* Upload status in the corner of every page. Uploads that are waiting or running cannot be closed:
* they stay until they finish. A failed one offers Retry or Discard, and a finished one fades after a few seconds.
*/
export default function UploadToaster({ items, toasts, onRetry, onDiscard, onDismiss }) {
if (!items.length && !toasts.length) return null
return (
<div className="toaster" role="region" aria-label="Recording uploads" aria-live="polite">
{items.map((item) => (
<div key={item.id} className={`toast ${item.status === 'failed' ? 'is-error' : ''}`}>
{item.status === 'failed' ? (
<>
<p className="toast-title">Upload failed</p>
<p className="toast-body">“{item.title}”: {item.error}</p>
<div className="toast-actions">
<Button onClick={() => onRetry(item.id)}>Try again</Button>
<Button variant="outline" onClick={() => onDiscard(item.id)}>Discard recording</Button>
</div>
</>
) : (
<>
<p className="toast-title">{item.status === 'uploading' ? `Uploading ${percent(item.progress)}` : 'Waiting to upload'}</p>
<p className="toast-body">“{item.title}”{item.status === 'waiting' ? ' starts when the previous upload finishes.' : ' — you can keep browsing.'}</p>
{item.status === 'uploading' && <progress className="toast-progress" max="1" value={item.progress} aria-label={`Uploading ${item.title}`} />}
</>
)}
</div>
))}
{toasts.map((toast) => (
<div key={toast.id} className="toast is-success">
<p className="toast-title">✓ {toast.title}</p>
<p className="toast-body">“{toast.body}” is saved. Thank you for contributing.</p>
<button type="button" className="toast-close" onClick={() => onDismiss(toast.id)} aria-label="Dismiss">×</button>
</div>
))}
</div>
)
}
27 changes: 14 additions & 13 deletions frontend/src/context/LibraryProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { api } from '../services/api'
import { paths } from '../routes/appRoutes'
import { useAuth } from './AuthContext'
import { LibraryContext } from './LibraryContext'
import { useUploadQueue } from './UploadQueueContext'

const EMPTY = { ready: false, ownerId: null, categories: [], videos: [], submissions: [] }
const SUBMISSION_COOLDOWN_MS = Number(import.meta.env.VITE_SUBMISSION_COOLDOWN_MS || 30_000)
Expand All @@ -30,6 +31,7 @@ function isLibraryRoute(pathname) {

export default function LibraryProvider({ children }) {
const { user } = useAuth()
const { lastSubmission } = useUploadQueue()
const location = useLocation()
const [data, setData] = useState(EMPTY)
const [loadError, setLoadError] = useState(null)
Expand All @@ -45,6 +47,18 @@ export default function LibraryProvider({ children }) {
setLoadError(null)
}, [user])

// A background upload just finished: fold it into the library so counts, badges and the
// record-again cooldown update without a page change.
const seenSubmission = useRef(lastSubmission)
useEffect(() => {
if (!lastSubmission || lastSubmission === seenSubmission.current) return
seenSubmission.current = lastSubmission
setData((current) => (current.ready
? { ...current, submissions: [lastSubmission, ...current.submissions.filter((item) => item.id !== lastSubmission.id)] }
: current))
void refresh().catch(() => {})
}, [lastSubmission, refresh])

useEffect(() => {
if (!user || !shouldLoad || (data.ready && data.ownerId === user.id)) return undefined

Expand Down Expand Up @@ -107,19 +121,6 @@ export default function LibraryProvider({ children }) {
totalSubmissionCount: mySubmissions.length,
cooldownMs: SUBMISSION_COOLDOWN_MS,

async submitRecording(video, recording) {
const submission = await api.submissions.create({
videoId: video.id, recording: recording.blob, trimStart: recording.edit.start, trimEnd: recording.edit.end,
mirrored: recording.edit.mirrored, duration: recording.duration, size: recording.blob.size, mimeType: recording.mimeType,
})
void refresh().catch(() => {
setData((current) => ({
...current,
submissions: [submission, ...current.submissions.filter((item) => item.id !== submission.id)],
}))
})
return submission
},
async addCategory(values) { const created = await api.categories.create(values); await refresh(); return created },
async editCategory(id, patch) { await api.categories.update(id, patch); await refresh() },
async deleteCategory(id) { await api.categories.remove(id); await refresh() },
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/context/UploadQueueContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createContext, useContext } from 'react'

export const UploadQueueContext = createContext(null)

export function useUploadQueue() {
const value = useContext(UploadQueueContext)
if (!value) throw new Error('useUploadQueue must be used inside <UploadQueueProvider>')
return value
}
122 changes: 122 additions & 0 deletions frontend/src/context/UploadQueueProvider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import UploadToaster from '../components/UploadToaster'
import { api } from '../services/api'
import { watchUrl } from '../routes/appRoutes'
import { useAuth } from './AuthContext'
import { useNotifications } from './NotificationContext'
import { UploadQueueContext } from './UploadQueueContext'

/** One recording uploading and one waiting in the browser. Failed ones still hold a slot until retried or discarded. */
export const MAX_QUEUED = 2
const SUCCESS_TOAST_MS = 7000

let nextId = 0
const newId = (prefix) => `${prefix}_${Date.now()}_${nextId++}`

/**
* Uploads submitted recordings in the background, strictly one at a time, so a contributor can
* keep browsing, watching and recording while earlier takes are still going up.
*
* Mounted above LibraryProvider on purpose: that provider swaps its children for a loading screen
* while the library loads, which would otherwise unmount an upload in progress.
* Recordings wait in memory only; leaving the tab with uploads pending shows the browser's warning.
*/
export default function UploadQueueProvider({ children }) {
const { user } = useAuth()
const { notify } = useNotifications()
const userId = user?.id
const [items, setItems] = useState([]) // { id, videoId, title, status: waiting|uploading|failed, progress, error, recording }
const [toasts, setToasts] = useState([]) // finished uploads, shown briefly
const [lastSubmission, setLastSubmission] = useState(null)
const running = useRef(null) // { id, controller }
const notifyRef = useRef(notify)
useEffect(() => { notifyRef.current = notify }, [notify])

const patch = useCallback((id, changes) => {
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...changes } : item)))
}, [])

const addToast = useCallback((toast) => {
const id = newId('toast')
setToasts((current) => [...current, { id, ...toast }])
setTimeout(() => setToasts((current) => current.filter((item) => item.id !== id)), SUCCESS_TOAST_MS)
}, [])

const run = useCallback(async (item) => {
const controller = new AbortController()
running.current = { id: item.id, controller }
const { recording } = item
try {
const submission = await api.submissions.upload({
videoId: item.videoId, recording: recording.blob, trimStart: recording.edit.start, trimEnd: recording.edit.end,
mirrored: recording.edit.mirrored, duration: recording.duration, signal: controller.signal,
onProgress: (progress) => patch(item.id, { progress }),
})
setItems((current) => current.filter((entry) => entry.id !== item.id))
setLastSubmission(submission)
addToast({ title: 'Recording submitted', body: item.title })
notifyRef.current({ type: 'submission', title: 'Recording submitted', body: item.title, href: watchUrl(item.videoId) }).catch(() => {})
} catch (error) {
if (error?.name === 'AbortError') return
const message = error?.message || 'The upload failed.'
patch(item.id, { status: 'failed', error: message })
notifyRef.current({ type: 'submission', title: 'Recording not uploaded', body: `${item.title}: ${message}`, href: watchUrl(item.videoId) }).catch(() => {})
} finally {
if (running.current?.id === item.id) running.current = null
}
}, [addToast, patch])

// The queue's heartbeat: whenever nothing is uploading, start the oldest waiting recording.
useEffect(() => {
if (running.current) return
const next = items.find((item) => item.status === 'waiting')
if (!next) return
// eslint-disable-next-line react-hooks/set-state-in-effect -- advancing the queue is a state machine step; `running` guards against starting twice
patch(next.id, { status: 'uploading', progress: 0 })
void run(next)
}, [items, patch, run])

// A different person (or nobody) on this tab: drop their queue and stop the upload.
useEffect(() => () => {
running.current?.controller.abort()
running.current = null
setItems([])
setToasts([])
}, [userId])

// Pending recordings only exist in this tab's memory, so warn before it is closed or reloaded.
const pending = items.length > 0
useEffect(() => {
if (!pending) return undefined
const warn = (event) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [pending])

const enqueue = useCallback((video, recording) => {
if (!userId) return { ok: false, reason: 'Log in to submit recordings.' }
if (items.some((item) => item.videoId === video.id)) return { ok: false, reason: 'This video is already in the upload queue.' }
if (items.length >= MAX_QUEUED) return { ok: false, reason: 'Two recordings are already queued. Submit this one when an upload finishes.' }
setItems((current) => [...current, { id: newId('upload'), videoId: video.id, title: video.title, status: 'waiting', progress: 0, error: '', recording }])
return { ok: true }
}, [items, userId])

const retry = useCallback((id) => patch(id, { status: 'waiting', progress: 0, error: '' }), [patch])
const discard = useCallback((id) => {
if (running.current?.id === id) running.current.controller.abort()
setItems((current) => current.filter((item) => item.id !== id))
}, [])
const dismissToast = useCallback((id) => setToasts((current) => current.filter((item) => item.id !== id)), [])

const value = useMemo(() => ({
items, lastSubmission, isFull: items.length >= MAX_QUEUED, enqueue, retry, discard,
itemFor: (videoId) => items.find((item) => item.videoId === videoId) || null,
}), [items, lastSubmission, enqueue, retry, discard])

return (
<UploadQueueContext.Provider value={value}>
{children}
{userId && <UploadToaster items={items} toasts={toasts} onRetry={retry} onDiscard={discard} onDismiss={dismissToast} />}
</UploadQueueContext.Provider>
)
}
Loading
Loading