From 8069e9e2a33d900846f534b6378a7b559d43fdc3 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 23:46:03 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20Built=20useMediaSync=20hook,=20VideoPla?= =?UTF-8?q?yer=20with=20HLS=20lazy-loading=20and=20na=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/hooks/useMediaSync.ts" - "frontend/src/components/VideoPlayer.tsx" - "frontend/src/components/PlayerControls.tsx" - "frontend/src/App.css" - "frontend/package.json" GSD-Task: S01/T02 --- .gsd/milestones/M020/slices/S01/S01-PLAN.md | 2 +- .../M020/slices/S01/tasks/T01-VERIFY.json | 24 +++ .../M020/slices/S01/tasks/T02-SUMMARY.md | 83 ++++++++ frontend/package-lock.json | 11 +- frontend/package.json | 1 + frontend/src/App.css | 199 ++++++++++++++++++ frontend/src/components/PlayerControls.tsx | 195 +++++++++++++++++ frontend/src/components/VideoPlayer.tsx | 138 ++++++++++++ frontend/src/hooks/useMediaSync.ts | 120 +++++++++++ frontend/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 771 insertions(+), 4 deletions(-) create mode 100644 .gsd/milestones/M020/slices/S01/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M020/slices/S01/tasks/T02-SUMMARY.md create mode 100644 frontend/src/components/PlayerControls.tsx create mode 100644 frontend/src/components/VideoPlayer.tsx create mode 100644 frontend/src/hooks/useMediaSync.ts diff --git a/.gsd/milestones/M020/slices/S01/S01-PLAN.md b/.gsd/milestones/M020/slices/S01/S01-PLAN.md index 2bba271..0933594 100644 --- a/.gsd/milestones/M020/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M020/slices/S01/S01-PLAN.md @@ -51,7 +51,7 @@ Both return 404 for non-existent video IDs. - Estimate: 45m - Files: backend/routers/videos.py, backend/schemas.py, backend/tests/test_video_detail.py - Verify: cd backend && python -m pytest tests/test_video_detail.py -v -- [ ] **T02: Build VideoPlayer component with HLS, custom controls, and media sync hook** — Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript. +- [x] **T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts** — Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript. ## Steps diff --git a/.gsd/milestones/M020/slices/S01/tasks/T01-VERIFY.json b/.gsd/milestones/M020/slices/S01/tasks/T01-VERIFY.json new file mode 100644 index 0000000..1b89ee9 --- /dev/null +++ b/.gsd/milestones/M020/slices/S01/tasks/T01-VERIFY.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M020/S01/T01", + "timestamp": 1775259763553, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 8, + "verdict": "pass" + }, + { + "command": "python -m pytest tests/test_video_detail.py -v", + "exitCode": 4, + "durationMs": 242, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M020/slices/S01/tasks/T02-SUMMARY.md b/.gsd/milestones/M020/slices/S01/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..d570f82 --- /dev/null +++ b/.gsd/milestones/M020/slices/S01/tasks/T02-SUMMARY.md @@ -0,0 +1,83 @@ +--- +id: T02 +parent: S01 +milestone: M020 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/hooks/useMediaSync.ts", "frontend/src/components/VideoPlayer.tsx", "frontend/src/components/PlayerControls.tsx", "frontend/src/App.css", "frontend/package.json"] +key_decisions: ["Cast videoRef to RefObject at JSX site to satisfy React 18 strict ref typing"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "npx tsc --noEmit passes with zero errors. npm run build succeeds producing production bundle. hls.js is dynamically imported (lazy-loaded), not in main bundle." +completed_at: 2026-04-03T23:45:50.331Z +blocker_discovered: false +--- + +# T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts + +> Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts + +## What Happened +--- +id: T02 +parent: S01 +milestone: M020 +key_files: + - frontend/src/hooks/useMediaSync.ts + - frontend/src/components/VideoPlayer.tsx + - frontend/src/components/PlayerControls.tsx + - frontend/src/App.css + - frontend/package.json +key_decisions: + - Cast videoRef to RefObject at JSX site to satisfy React 18 strict ref typing +duration: "" +verification_result: passed +completed_at: 2026-04-03T23:45:50.331Z +blocker_discovered: false +--- + +# T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts + +**Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts** + +## What Happened + +Installed hls.js. Created useMediaSync hook managing shared playback state via video element event listeners. Created VideoPlayer with three source paths (HLS via lazy-loaded hls.js, Safari native HLS, direct mp4), null-src placeholder, and fatal error state. Created PlayerControls with play/pause, seek bar, time display, 6-option speed selector (0.5–2x), volume + mute, fullscreen, and keyboard shortcuts. Added ~180 lines of themed CSS with responsive stacking. Fixed React 18 ref type mismatch for video element. + +## Verification + +npx tsc --noEmit passes with zero errors. npm run build succeeds producing production bundle. hls.js is dynamically imported (lazy-loaded), not in main bundle. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 2800ms | +| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 3600ms | + + +## Deviations + +Added RefObject cast at JSX ref site for React 18 strict ref typing compatibility. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/hooks/useMediaSync.ts` +- `frontend/src/components/VideoPlayer.tsx` +- `frontend/src/components/PlayerControls.tsx` +- `frontend/src/App.css` +- `frontend/package.json` + + +## Deviations +Added RefObject cast at JSX ref site for React 18 strict ref typing compatibility. + +## Known Issues +None. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c3f9ab5..6a0dceb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { "name": "chrysopedia-web", - "version": "0.1.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chrysopedia-web", - "version": "0.1.0", + "version": "0.8.0", "dependencies": { + "hls.js": "^1.6.15", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0" @@ -1463,6 +1464,12 @@ "node": ">=6.9.0" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ab8bdd8..a08cf73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "hls.js": "^1.6.15", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0" diff --git a/frontend/src/App.css b/frontend/src/App.css index f571ae5..954f45d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5860,3 +5860,202 @@ a.app-footer__about:hover, margin-left: 0; } } + +/* ── Video Player ──────────────────────────────────────────────────────────── */ + +.video-player { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: #000; + border-radius: 8px; + overflow: hidden; +} + +.video-player__video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.video-player__unavailable { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); +} + +.video-player__unavailable-content { + text-align: center; + color: var(--color-text-muted); +} + +.video-player__unavailable-content svg { + margin-bottom: 0.75rem; + opacity: 0.5; +} + +.video-player__unavailable-content p { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-secondary); + margin: 0 0 0.25rem; +} + +.video-player__unavailable-content span { + font-size: 0.875rem; +} + +/* ── Player Controls ───────────────────────────────────────────────────────── */ + +.player-controls { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--color-bg-header); + border-radius: 0 0 8px 8px; +} + +.player-controls__btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-text-primary); + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + transition: background 0.15s; +} + +.player-controls__btn:hover { + background: var(--color-bg-surface-hover); +} + +.player-controls__time { + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); + white-space: nowrap; + min-width: 5.5rem; +} + +/* Range input shared styles */ +.player-controls__seek, +.player-controls__volume { + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + background: linear-gradient( + to right, + var(--color-accent) 0%, + var(--color-accent) var(--progress, 0%), + var(--color-border) var(--progress, 0%), + var(--color-border) 100% + ); + cursor: pointer; + outline: none; +} + +.player-controls__seek { + flex: 1; + min-width: 0; +} + +.player-controls__seek:disabled { + opacity: 0.4; + cursor: default; +} + +.player-controls__volume { + width: 5rem; + flex-shrink: 0; +} + +/* Thumb styling */ +.player-controls__seek::-webkit-slider-thumb, +.player-controls__volume::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-accent); + border: none; + cursor: pointer; +} + +.player-controls__seek::-moz-range-thumb, +.player-controls__volume::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-accent); + border: none; + cursor: pointer; +} + +/* Speed button group */ +.player-controls__speed { + display: flex; + gap: 2px; + margin: 0 0.25rem; +} + +.player-controls__speed-btn { + font-size: 0.6875rem; + font-weight: 500; + padding: 0.15rem 0.35rem; + border: 1px solid var(--color-border); + border-radius: 3px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: all 0.15s; + line-height: 1; +} + +.player-controls__speed-btn:hover { + border-color: var(--color-accent); + color: var(--color-text-primary); +} + +.player-controls__speed-btn--active { + background: var(--color-accent); + border-color: var(--color-accent); + color: #000; + font-weight: 600; +} + +/* ── Responsive: player controls ───────────────────────────────────────────── */ + +@media (max-width: 640px) { + .player-controls { + flex-wrap: wrap; + gap: 0.375rem; + padding: 0.375rem 0.5rem; + } + + .player-controls__seek { + order: -1; + width: 100%; + flex-basis: 100%; + } + + .player-controls__speed { + gap: 1px; + } + + .player-controls__speed-btn { + font-size: 0.625rem; + padding: 0.125rem 0.25rem; + } + + .player-controls__volume { + width: 3.5rem; + } +} diff --git a/frontend/src/components/PlayerControls.tsx b/frontend/src/components/PlayerControls.tsx new file mode 100644 index 0000000..5ef1b84 --- /dev/null +++ b/frontend/src/components/PlayerControls.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect } from "react"; +import type { MediaSyncState } from "../hooks/useMediaSync"; + +interface PlayerControlsProps { + mediaSync: MediaSyncState; + /** Ref to the container element for fullscreen requests */ + containerRef?: React.RefObject; +} + +const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export default function PlayerControls({ mediaSync, containerRef }: PlayerControlsProps) { + const { + currentTime, + duration, + isPlaying, + playbackRate, + volume, + isMuted, + seekTo, + setPlaybackRate, + togglePlay, + setVolume, + toggleMute, + } = mediaSync; + + const seekDisabled = !duration || duration === 0; + + const handleSeek = useCallback( + (e: React.ChangeEvent) => { + seekTo(parseFloat(e.target.value)); + }, + [seekTo], + ); + + const handleVolume = useCallback( + (e: React.ChangeEvent) => { + setVolume(parseFloat(e.target.value)); + }, + [setVolume], + ); + + const handleFullscreen = useCallback(() => { + const el = containerRef?.current; + if (!el) return; + if (document.fullscreenElement) { + void document.exitFullscreen(); + } else { + void el.requestFullscreen(); + } + }, [containerRef]); + + // Keyboard shortcuts + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + // Don't intercept when typing in inputs + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + + switch (e.key) { + case " ": + e.preventDefault(); + togglePlay(); + break; + case "ArrowLeft": + e.preventDefault(); + seekTo(Math.max(0, currentTime - 5)); + break; + case "ArrowRight": + e.preventDefault(); + seekTo(Math.min(duration, currentTime + 5)); + break; + case "ArrowUp": + e.preventDefault(); + setVolume(Math.min(1, volume + 0.1)); + break; + case "ArrowDown": + e.preventDefault(); + setVolume(Math.max(0, volume - 0.1)); + break; + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [togglePlay, seekTo, setVolume, currentTime, duration, volume]); + + // Seek bar progress as CSS variable for styling + const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0; + const volumeProgress = (isMuted ? 0 : volume) * 100; + + return ( +
+ {/* Play/Pause */} + + + {/* Time display */} + + {formatTime(currentTime)} / {formatTime(duration)} + + + {/* Seek bar */} + + + {/* Speed selector */} +
+ {SPEED_OPTIONS.map((rate) => ( + + ))} +
+ + {/* Volume */} + + + + + {/* Fullscreen */} + +
+ ); +} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..6a0486d --- /dev/null +++ b/frontend/src/components/VideoPlayer.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import type { MediaSyncState } from "../hooks/useMediaSync"; + +interface VideoPlayerProps { + src: string | null; + startTime?: number; + mediaSync: MediaSyncState; +} + +/** + * Video player with HLS support (lazy-loaded via dynamic import), + * Safari native HLS fallback, and direct .mp4 playback. + * Shows a placeholder when src is null. + */ +export default function VideoPlayer({ src, startTime, mediaSync }: VideoPlayerProps) { + const { videoRef } = mediaSync; + const hlsRef = useRef(null); + const [error, setError] = useState(null); + const startTimeSeeked = useRef(false); + + // Clamp startTime: NaN / negative → 0 + const safeStart = + startTime != null && Number.isFinite(startTime) && startTime > 0 + ? startTime + : 0; + + // Seek to startTime once after metadata loads + useEffect(() => { + const el = videoRef.current; + if (!el || safeStart === 0) return; + startTimeSeeked.current = false; + + const onLoaded = () => { + if (!startTimeSeeked.current) { + el.currentTime = safeStart; + startTimeSeeked.current = true; + } + }; + + if (el.readyState >= 1) { + onLoaded(); + } else { + el.addEventListener("loadedmetadata", onLoaded, { once: true }); + return () => el.removeEventListener("loadedmetadata", onLoaded); + } + }, [videoRef, safeStart]); + + // HLS / native source attachment + useEffect(() => { + const el = videoRef.current; + if (!el || !src) return; + + setError(null); + let destroyed = false; + + const isHlsUrl = src.endsWith(".m3u8"); + + // Check native HLS support (Safari) + const canPlayHlsNatively = el.canPlayType("application/vnd.apple.mpegurl") !== ""; + + if (isHlsUrl && !canPlayHlsNatively) { + // Lazy-load hls.js + void import("hls.js").then(({ default: Hls }) => { + if (destroyed) return; + if (!Hls.isSupported()) { + setError("HLS playback is not supported in this browser."); + return; + } + const hls = new Hls({ + enableWorker: true, + startPosition: safeStart > 0 ? safeStart : -1, + }); + hlsRef.current = hls; + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + console.error("[VideoPlayer] Fatal HLS error:", data.type, data.details); + setError(`Playback error: ${data.details}`); + hls.destroy(); + hlsRef.current = null; + } + }); + + hls.loadSource(src); + hls.attachMedia(el); + }); + } else { + // Native HLS (Safari) or plain .mp4 + el.src = src; + } + + return () => { + destroyed = true; + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + }; + }, [src, videoRef, safeStart]); + + if (!src) { + return ( +
+
+ + + + + +

Video not available

+ The source video for this content has not been linked yet. +
+
+ ); + } + + if (error) { + return ( +
+
+

Playback Error

+ {error} +
+
+ ); + } + + return ( +
+
+ ); +} diff --git a/frontend/src/hooks/useMediaSync.ts b/frontend/src/hooks/useMediaSync.ts new file mode 100644 index 0000000..cd1a1ee --- /dev/null +++ b/frontend/src/hooks/useMediaSync.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface MediaSyncState { + currentTime: number; + duration: number; + isPlaying: boolean; + playbackRate: number; + volume: number; + isMuted: boolean; + videoRef: React.RefObject; + seekTo: (time: number) => void; + setPlaybackRate: (rate: number) => void; + togglePlay: () => void; + setVolume: (v: number) => void; + toggleMute: () => void; +} + +/** + * Shared playback state hook. Owns the