# S01: [A] Web Media Player MVP

**Goal:** Custom video player page with HLS playback, speed controls (0.5x–2x), and synchronized transcript sidebar, accessible from technique page key moment timestamps.
**Demo:** After this: Custom video player with HLS playback, speed controls (0.5x-2x), and synchronized transcript sidebar

## Tasks
- [x] **T01: Added GET /videos/{video_id} and GET /videos/{video_id}/transcript endpoints with creator info, ordered segments, and 5 integration tests** — Add two new endpoints to the existing videos router:

1. `GET /videos/{video_id}` — single video detail with eager-loaded creator (name, slug). Returns `SourceVideoDetail` schema (extends SourceVideoRead with creator_name, creator_slug fields).

2. `GET /videos/{video_id}/transcript` — returns all TranscriptSegments for a video, ordered by segment_index. Returns `TranscriptForPlayerResponse` with video_id and segments list.

Both return 404 for non-existent video IDs.

## Steps

1. Read `backend/schemas.py` to understand existing SourceVideoRead and TranscriptSegmentRead schemas.
2. Add new schemas to `backend/schemas.py`:
   - `SourceVideoDetail` — extends SourceVideoRead, adds `creator_name: str`, `creator_slug: str`, `video_url: str | None = None` (always None for now)
   - `TranscriptForPlayerResponse` — `video_id: uuid.UUID`, `segments: list[TranscriptSegmentRead]`, `total: int`
3. Read `backend/routers/videos.py` to understand the existing list endpoint pattern.
4. Add `GET /videos/{video_id}` endpoint to `backend/routers/videos.py`:
   - Query SourceVideo by ID with `selectinload(SourceVideo.creator)`
   - Return 404 if not found
   - Map to SourceVideoDetail with creator_name and creator_slug from the joined creator
5. Add `GET /videos/{video_id}/transcript` endpoint:
   - Query TranscriptSegments where source_video_id matches, ordered by segment_index
   - Return 404 if video doesn't exist
   - Return TranscriptForPlayerResponse
6. Write `backend/tests/test_video_detail.py` with tests:
   - test_get_video_detail_success — creates a video+creator, verifies response shape
   - test_get_video_detail_404 — random UUID returns 404
   - test_get_transcript_success — creates video + segments, verifies ordering and count
   - test_get_transcript_404 — random UUID returns 404
   - test_get_transcript_empty — video exists but has no segments, returns empty list
7. Run tests and fix any issues.

## Must-Haves

- [ ] GET /videos/{video_id} returns video detail with creator_name and creator_slug
- [ ] GET /videos/{video_id}/transcript returns segments ordered by segment_index
- [ ] Both endpoints return 404 for non-existent video IDs
- [ ] Tests pass

## Failure Modes

| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL | 500 with logged traceback | FastAPI default timeout | N/A (ORM-mapped) |
| video_id UUID parse | FastAPI returns 422 automatically | N/A | N/A |
  - 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
- [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

1. Install hls.js: `cd frontend && npm install hls.js`
2. Create `frontend/src/hooks/useMediaSync.ts`:
   - Custom hook managing shared playback state: `currentTime`, `duration`, `isPlaying`, `playbackRate`, `videoRef`
   - Listens to `timeupdate` (fires ~4Hz), `play`, `pause`, `ratechange`, `loadedmetadata` events on the video element
   - Exposes `seekTo(time: number)`, `setPlaybackRate(rate: number)`, `togglePlay()` actions
   - Returns `{ currentTime, duration, isPlaying, playbackRate, videoRef, seekTo, setPlaybackRate, togglePlay }`
3. Create `frontend/src/components/VideoPlayer.tsx`:
   - Accepts props: `src: string | null`, `startTime?: number`, `mediaSync: ReturnType<typeof useMediaSync>`
   - If `src` is null, render a styled "Video not available" placeholder with explanation text
   - If src ends in `.m3u8` or device doesn't support native HLS: lazy-load hls.js via dynamic `import('hls.js')`, call `hls.loadSource(src)`, `hls.attachMedia(videoEl)`
   - If native HLS supported (Safari): set `video.src = src` directly
   - For `.mp4` URLs: use native `<video src={src}>` directly
   - Handle HLS errors: on `Hls.Events.ERROR` with `data.fatal`, log to console.error and show error state
   - `useEffect` cleanup: call `hls.destroy()` on unmount
   - If `startTime` is provided, seek to it after `loadedmetadata`
4. Create `frontend/src/components/PlayerControls.tsx`:
   - Accepts `mediaSync` prop
   - Play/pause button (toggles icon based on isPlaying)
   - Seek bar: `<input type="range">` from 0 to duration, value = currentTime, onChange seeks
   - Current time / duration display in MM:SS format
   - Speed selector: dropdown/button group with options [0.5, 0.75, 1, 1.25, 1.5, 2], active state highlighted
   - Volume control: `<input type="range">` 0–1, mute toggle button
   - Fullscreen button: calls `videoContainerRef.requestFullscreen()`
   - Keyboard shortcuts: Space = play/pause, Left/Right arrows = ±5s seek, Up/Down = ±0.1 volume
5. Add player styles to `frontend/src/App.css`:
   - `.video-player` container with 16:9 aspect ratio
   - `.video-player__unavailable` placeholder styling (dark background, centered message)
   - `.player-controls` bar styling (dark background, flex layout)
   - `.player-controls__speed` button group with active state
   - `.player-controls__seek` range input custom styling (cyan accent)
   - `.player-controls__volume` range input
   - Responsive: controls stack or resize at mobile widths
6. Verify: `cd frontend && npx tsc --noEmit` passes with zero errors.

## Must-Haves

- [ ] hls.js added to package.json
- [ ] useMediaSync hook tracks currentTime, duration, isPlaying, playbackRate
- [ ] VideoPlayer renders HLS via hls.js with Safari native fallback
- [ ] VideoPlayer shows "Video not available" when src is null
- [ ] PlayerControls has play/pause, seek, speed (0.5–2x), volume, fullscreen
- [ ] hls.js is lazy-loaded via dynamic import (not in main bundle)
- [ ] Cleanup: hls.destroy() on unmount

## Negative Tests

- Malformed inputs: null src → "Video not available" state, NaN startTime → clamp to 0
- Error paths: HLS fatal error → error message in player area, non-HLS URL → native video fallback
- Boundary conditions: duration 0 → seek bar disabled, playbackRate at limits (0.5/2x) → buttons reflect state
  - Estimate: 2h
  - Files: frontend/src/hooks/useMediaSync.ts, frontend/src/components/VideoPlayer.tsx, frontend/src/components/PlayerControls.tsx, frontend/src/App.css, frontend/package.json
  - Verify: cd frontend && npx tsc --noEmit && npm run build
- [x] **T03: Built WatchPage with video player, synced transcript sidebar, lazy-loaded /watch/:videoId route, and clickable timestamp links on TechniquePage key moments** — Compose the full watch experience: TranscriptSidebar synced to playback, WatchPage layout, route in App.tsx, and clickable timestamp links on TechniquePage.

## Steps

1. Create `frontend/src/api/videos.ts`:
   - `fetchVideo(id: string)` → GET `/api/v1/videos/{id}` returning video detail (id, filename, creator_name, creator_slug, video_url, duration_seconds)
   - `fetchTranscript(videoId: string)` → GET `/api/v1/videos/{videoId}/transcript` returning `{ video_id, segments: [{start_time, end_time, text, segment_index}], total }`
   - Define TypeScript interfaces: `VideoDetail`, `TranscriptSegment`, `TranscriptResponse`
2. Create `frontend/src/components/TranscriptSidebar.tsx`:
   - Props: `segments: TranscriptSegment[]`, `currentTime: number`, `onSeek: (time: number) => void`
   - Renders scrollable list of segments, each showing timestamp (MM:SS) and text
   - Active segment determination: binary search on sorted segments array to find segment where `start_time <= currentTime < end_time` (O(log n) for 500+ segments)
   - Active segment gets `.transcript-segment--active` class with cyan left border highlight
   - Auto-scroll: `useEffect` watching activeSegmentIndex, calls `Element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })` on the active element. Use a ref map or data attribute to find the element.
   - Click handler: clicking a segment calls `onSeek(segment.start_time)`
   - Empty state: if segments array is empty, show "No transcript available"
3. Create `frontend/src/pages/WatchPage.tsx`:
   - Route: `/watch/:videoId` — read videoId from useParams, optional `t` from useSearchParams (parse as float, clamp to 0 if NaN/negative)
   - Fetch video detail and transcript via the API functions
   - Loading/error states
   - Layout: CSS grid — video player (main area) + transcript sidebar (right column on desktop, below on mobile)
   - Instantiate `useMediaSync`, pass to `VideoPlayer` (with `src=video.video_url`, `startTime=t`) and `PlayerControls`
   - Pass transcript segments and mediaSync.currentTime/seekTo to `TranscriptSidebar`
   - Page title: `useDocumentTitle` with video filename
   - Header: show video filename and link to creator detail page
4. Add route to `frontend/src/App.tsx`:
   - Lazy-load WatchPage: `const WatchPage = React.lazy(() => import('./pages/WatchPage'));`
   - Add route: `<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />`
   - Place it in the public routes section
5. Update `frontend/src/pages/TechniquePage.tsx`:
   - In the key moments list, wrap each timestamp span in a `<Link>` to `/watch/${km.source_video_id}?t=${km.start_time}`
   - Only link if `km.source_video_id` is truthy
   - Style the timestamp link with cyan color and underline on hover
6. Add styles to `frontend/src/App.css`:
   - `.watch-page` grid layout: `grid-template-columns: 1fr 22rem` on desktop, single column on mobile (768px breakpoint)
   - `.watch-page__header` with video title and creator link
   - `.transcript-sidebar` with max-height, overflow-y auto, scrollbar styling
   - `.transcript-segment` row styling with hover state
   - `.transcript-segment--active` with cyan left border and subtle background
   - `.transcript-segment__time` monospace font
   - Responsive: sidebar below player on mobile
7. Verify: `cd frontend && npx tsc --noEmit && npm run build` — zero errors, WatchPage chunk separate from main.

## Must-Haves

- [ ] TranscriptSidebar uses binary search for active segment (not linear scan)
- [ ] Active segment auto-scrolls into view with smooth behavior
- [ ] Click on segment seeks video to that timestamp
- [ ] WatchPage fetches video + transcript, composes player + sidebar
- [ ] `/watch/:videoId` route registered and lazy-loaded
- [ ] `?t=` query param sets initial seek position
- [ ] TechniquePage timestamps are clickable links to /watch/:videoId?t=X
- [ ] Responsive layout: sidebar beside player on desktop, below on mobile

## Negative Tests

- Malformed inputs: `?t=abc` → clamp to 0, `?t=-5` → clamp to 0
- Error paths: invalid videoId → 404 error state, transcript fetch fails → player still works without sidebar
- Boundary conditions: empty segments array → "No transcript available", video with 500+ segments → binary search keeps performance O(log n)
  - Estimate: 2h
  - Files: frontend/src/api/videos.ts, frontend/src/components/TranscriptSidebar.tsx, frontend/src/pages/WatchPage.tsx, frontend/src/App.tsx, frontend/src/pages/TechniquePage.tsx, frontend/src/App.css
  - Verify: cd frontend && npx tsc --noEmit && npm run build
