diff --git a/.gsd/milestones/M024/M024-ROADMAP.md b/.gsd/milestones/M024/M024-ROADMAP.md index dcd4b90..49536c7 100644 --- a/.gsd/milestones/M024/M024-ROADMAP.md +++ b/.gsd/milestones/M024/M024-ROADMAP.md @@ -7,7 +7,7 @@ Shorts pipeline goes end-to-end with captioning and templates. Player gets key m | ID | Slice | Risk | Depends | Done | After this | |----|-------|------|---------|------|------------| | S01 | [A] Shorts Publishing Flow | medium | — | ✅ | Creator approves a short → it renders → gets a shareable URL and embed code | -| S02 | [A] Key Moment Pins on Player Timeline | low | — | ⬜ | Key technique moments appear as clickable pins on the player timeline | +| S02 | [A] Key Moment Pins on Player Timeline | low | — | ✅ | Key technique moments appear as clickable pins on the player timeline | | S03 | [A] Embed Support (iframe Snippet) | low | — | ⬜ | Creators can copy an iframe embed snippet to put the player on their own site | | S04 | [B] Auto-Captioning + Template System | medium | — | ⬜ | Shorts have Whisper-generated animated subtitles and creator-configurable intro/outro cards | | S05 | [B] Citation UX Improvements | low | — | ⬜ | Chat citations show timestamp links that seek the player and source cards with video thumbnails | diff --git a/.gsd/milestones/M024/slices/S02/S02-SUMMARY.md b/.gsd/milestones/M024/slices/S02/S02-SUMMARY.md new file mode 100644 index 0000000..47fa4c4 --- /dev/null +++ b/.gsd/milestones/M024/slices/S02/S02-SUMMARY.md @@ -0,0 +1,85 @@ +--- +id: S02 +parent: M024 +milestone: M024 +provides: + - Color-coded pin markers on player timeline + - Inline collapsible player on technique pages + - Bibliography seek wiring to inline player +requires: + [] +affects: + - S05 +key_files: + - frontend/src/components/ChapterMarkers.tsx + - frontend/src/components/PlayerControls.tsx + - frontend/src/pages/TechniquePage.tsx + - frontend/src/App.css +key_decisions: + - Used ::before pseudo-element with inset:-6px for touch-friendly hit area without enlarging visual pin + - Used grid-template-rows 0fr/1fr animation for collapse/expand per KNOWLEDGE.md pattern + - Bibliography time links render as button when inline player active for same video, Link to WatchPage otherwise +patterns_established: + - Content-type color-coded pins via CSS custom properties (--color-pin-{type}) for reuse in other timeline/marker contexts + - Conditional rendering pattern: same UI element renders as interactive button (seek) or navigation Link depending on inline player state +observability_surfaces: + - none +drill_down_paths: + - .gsd/milestones/M024/slices/S02/tasks/T01-SUMMARY.md + - .gsd/milestones/M024/slices/S02/tasks/T02-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-04-04T10:49:27.925Z +blocker_discovered: false +--- + +# S02: [A] Key Moment Pins on Player Timeline + +**Key technique moments now appear as color-coded clickable circle pins on the player timeline with active highlighting, and technique pages include a collapsible inline player with bibliography seek wiring.** + +## What Happened + +Two tasks delivered the full slice goal. + +T01 replaced the thin 3px line markers in ChapterMarkers.tsx with 12px circle pins, color-coded by content_type (technique=cyan, settings=amber, reasoning=purple, workflow=green) using four new CSS custom properties. Active-state highlighting scales the current pin to 1.3x when playback is within its time range. Tooltips now show title + formatted time range + content type label. A ::before pseudo-element with inset:-6px provides touch-friendly hit areas without enlarging the visual pin. PlayerControls passes currentTime to enable the active state. + +T02 added a collapsible inline video player to TechniquePage, positioned between the summary and body sections. The player uses the existing useMediaSync hook, VideoPlayer, and PlayerControls components. Key moment chapters are fetched when the player opens and rendered as pin markers on the seek bar. Bibliography time links conditionally render as seek buttons (calling mediaSync.seekTo) when the inline player is active for the matching video, or as Links to WatchPage otherwise. A multi-source-video selector dropdown handles technique pages sourced from multiple videos. Collapse animation uses the CSS grid-template-rows 0fr/1fr pattern established in KNOWLEDGE.md. + +## Verification + +npm run build passes with zero errors. grep confirms no stale chapter-marker__tick references. All four pin color custom properties defined in :root. ChapterMarkers accepts currentTime and computes isActive. TechniquePage renders technique-player section with collapse, video selector, and bibliography seek wiring. + +## Requirements Advanced + +None. + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +Used 12px pins instead of planned 10px for better touch targeting. + +## Known Limitations + +None. + +## Follow-ups + +None. + +## Files Created/Modified + +- `frontend/src/components/ChapterMarkers.tsx` — Replaced thin tick markers with 12px circle pins, added currentTime prop for active-state highlighting, enriched tooltips with title + time range + content type +- `frontend/src/components/PlayerControls.tsx` — Passes currentTime to ChapterMarkers +- `frontend/src/pages/TechniquePage.tsx` — Added collapsible inline player section with chapter pins, bibliography seek wiring, and multi-source-video selector +- `frontend/src/App.css` — Added pin color custom properties, pin/active/tooltip styles, technique-player collapse/expand styles diff --git a/.gsd/milestones/M024/slices/S02/S02-UAT.md b/.gsd/milestones/M024/slices/S02/S02-UAT.md new file mode 100644 index 0000000..bee43bb --- /dev/null +++ b/.gsd/milestones/M024/slices/S02/S02-UAT.md @@ -0,0 +1,68 @@ +# S02: [A] Key Moment Pins on Player Timeline — UAT + +**Milestone:** M024 +**Written:** 2026-04-04T10:49:27.926Z + +# UAT: S02 — Key Moment Pins on Player Timeline + +## Preconditions +- Chrysopedia running at ub01:8096 +- At least one technique page exists with key moments from a source video +- Video streaming is functional (chrysopedia-api serving /videos/{id}/stream) + +## Test 1: Pin Visual Appearance +1. Navigate to a video watch page (e.g., /watch/{video_id}) that has key moments +2. Observe the timeline/seek bar area +3. **Expected:** Key moments appear as 12px colored circle pins (not thin lines) +4. **Expected:** Pins are color-coded by content type — cyan (technique), amber (settings), purple (reasoning), green (workflow) +5. Hover over a pin +6. **Expected:** Tooltip shows: "{title} · {start}–{end} · {content_type}" + +## Test 2: Active Pin Highlighting +1. On the same watch page, play the video or seek to a position within a key moment's time range +2. **Expected:** The pin whose time range contains the current playback time scales up (1.3x) and reaches full opacity +3. Seek past the moment's end time +4. **Expected:** Pin returns to normal size/opacity + +## Test 3: Technique Page Inline Player — Collapsed Default +1. Navigate to a technique page (e.g., /techniques/{slug}) +2. **Expected:** A "▶ Preview Key Moments" toggle button is visible between the summary and body sections +3. **Expected:** The video player is NOT visible (collapsed by default) + +## Test 4: Technique Page Inline Player — Expand and Pins +1. Click the "▶ Preview Key Moments" button +2. **Expected:** Player section expands with smooth animation (grid-template-rows transition) +3. **Expected:** Video player loads with the source video +4. **Expected:** Chapter pin markers appear on the player's seek bar, color-coded by content type +5. **Expected:** Button text changes to "▼ Playing: {filename}" + +## Test 5: Bibliography Seek Wiring +1. With the inline player open on a technique page, scroll down to the key moments bibliography section +2. Click a time range link for a moment from the currently-loaded video +3. **Expected:** The player seeks to that moment's start time (no page navigation) +4. **Expected:** The page scrolls the player into view + +## Test 6: Bibliography Link Fallback +1. With the inline player closed, click a time range link in the bibliography +2. **Expected:** Navigates to the WatchPage (/watch/{video_id}?t={timestamp}) +3. If the technique has moments from multiple videos and the player is open with video A, click a time link for a moment from video B +4. **Expected:** Navigates to WatchPage for video B (does not try to seek the inline player) + +## Test 7: Multi-Source Video Selector +1. Navigate to a technique page that has key moments from 2+ different source videos +2. Open the inline player +3. **Expected:** A dropdown selector appears above the player listing available source videos +4. Select a different video from the dropdown +5. **Expected:** Player loads the new video; chapter pins update to show that video's moments + +## Test 8: Mobile Responsiveness +1. Resize viewport to 375px width (or use mobile device) +2. Navigate to a technique page and open the inline player +3. **Expected:** Player takes full width, video scales appropriately +4. **Expected:** Pin markers remain visible and tappable (touch-friendly hit area) +5. **Expected:** Video selector dropdown (if present) is usable on mobile + +## Edge Cases +- Technique page with no source videos: inline player section should not render +- Technique page with moments but video streaming unavailable: player shows but video fails gracefully +- Very short key moments (< 2 seconds): pin should still be visible and clickable diff --git a/.gsd/milestones/M024/slices/S02/tasks/T02-VERIFY.json b/.gsd/milestones/M024/slices/S02/tasks/T02-VERIFY.json new file mode 100644 index 0000000..ffe3d8b --- /dev/null +++ b/.gsd/milestones/M024/slices/S02/tasks/T02-VERIFY.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M024/S02/T02", + "timestamp": 1775299692698, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 9, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M024/slices/S03/S03-PLAN.md b/.gsd/milestones/M024/slices/S03/S03-PLAN.md index 944ef23..55db8f5 100644 --- a/.gsd/milestones/M024/slices/S03/S03-PLAN.md +++ b/.gsd/milestones/M024/slices/S03/S03-PLAN.md @@ -1,6 +1,47 @@ # S03: [A] Embed Support (iframe Snippet) -**Goal:** Add embed support with iframe snippet generation and proper CSP sandboxing +**Goal:** Creators can copy an iframe embed snippet to put the Chrysopedia player on their own site. A dedicated /embed/:videoId route renders the player without app chrome. **Demo:** After this: Creators can copy an iframe embed snippet to put the player on their own site ## Tasks +- [x] **T01: Extracted shared copyToClipboard utility and created EmbedPlayer page with full-viewport dark layout for iframe embedding** — Create the shared clipboard utility, the EmbedPlayer page component, and its CSS module. + +1. Extract `copyToClipboard` from `ShortPlayer.tsx` into `frontend/src/utils/clipboard.ts`. Update `ShortPlayer.tsx` to import from the shared util instead of its local copy. +2. Create `frontend/src/pages/EmbedPlayer.tsx`: + - Uses `useParams<{ videoId: string }>()` and `useSearchParams` to get videoId and `?t=` start time + - Fetches video detail via `fetchVideo(videoId)` from `../api/videos` + - If video has `video_url`: renders `` with startTime + - If audio-only: renders `` with stream URL + - Always renders `` with `useMediaSync` + - Shows small 'Powered by Chrysopedia' link at bottom + - Dark background, no app header/nav/footer + - Loading and error states matching ShortPlayer pattern +3. Create `frontend/src/pages/EmbedPlayer.module.css`: + - Full-viewport player layout (100vw/100vh container, dark bg) + - Small branding link at bottom + - Responsive: works at any iframe size + - Reuse CSS custom property tokens from existing theme +4. Verify: `cd frontend && npx tsc --noEmit` passes + - Estimate: 30m + - Files: frontend/src/utils/clipboard.ts, frontend/src/pages/EmbedPlayer.tsx, frontend/src/pages/EmbedPlayer.module.css, frontend/src/pages/ShortPlayer.tsx + - Verify: cd frontend && npx tsc --noEmit +- [ ] **T02: Wire embed route into App.tsx and add copy-embed button to WatchPage** — Restructure App.tsx to support a chrome-free embed route and add the copy-embed-code button to WatchPage. + +1. In `frontend/src/App.tsx`: + - Add lazy import for EmbedPlayer: `const EmbedPlayer = React.lazy(() => import('./pages/EmbedPlayer'))` + - Restructure the default export to render a top-level `` inside `` that matches `/embed/:videoId` directly (rendering ``) and falls back to `} />` for everything else. + - Move `` from being the direct child of `` to being a route element. + - This ensures embed pages skip header/nav/footer entirely. +2. In `frontend/src/pages/WatchPage.tsx`: + - Import `copyToClipboard` from `../utils/clipboard` + - Add state: `const [embedCopied, setEmbedCopied] = useState(false)` + - Add a 'Copy Embed Code' button in the `watch-page__header` section + - Button generates iframe snippet: `` + - For audio-only (no video_url), use height 120 instead of 405 + - On click: copy snippet, show 'Copied!' for 2 seconds, then revert + - Style the button using existing CSS custom properties (match ShortPlayer's copyBtn pattern) +3. Add CSS for the embed copy button in the existing WatchPage styles (inline or in the existing stylesheet that styles `.watch-page__*`). +4. Verify: `cd frontend && npm run build` succeeds with zero errors. Manually verify route structure is correct by inspecting the built output. + - Estimate: 25m + - Files: frontend/src/App.tsx, frontend/src/pages/WatchPage.tsx + - Verify: cd frontend && npm run build 2>&1 | tail -5 diff --git a/.gsd/milestones/M024/slices/S03/S03-RESEARCH.md b/.gsd/milestones/M024/slices/S03/S03-RESEARCH.md new file mode 100644 index 0000000..31944b2 --- /dev/null +++ b/.gsd/milestones/M024/slices/S03/S03-RESEARCH.md @@ -0,0 +1,124 @@ +# S03 Research — Embed Support (iframe Snippet) + +## Summary + +Straightforward frontend-only slice. The exact pattern already exists in `ShortPlayer.tsx` (copy-to-clipboard, iframe snippet generation). This slice applies the same pattern to the main video player, adding a dedicated embed route that renders a minimal chrome-free player and a "Copy Embed Code" button on the WatchPage. + +No backend changes needed. No new dependencies. No CSP/X-Frame-Options headers blocking iframes (confirmed in `docker/nginx.conf`). + +## Recommendation + +Follow the ShortPlayer embed pattern exactly. Three units of work: + +1. **EmbedPage component** — new route `/embed/:videoId` that renders VideoPlayer + PlayerControls with no nav/header/footer. Minimal dark background. Optional `?t=` start-time param forwarded to VideoPlayer. +2. **Copy Embed button on WatchPage** — reuse the `copyToClipboard` utility from ShortPlayer (extract to shared util or inline). Button generates ` +``` + +- 720×405 = 16:9 default (matches common video aspect ratio) +- `allowfullscreen` for user experience +- `frameborder="0"` for clean embedding +- For audio-only content, reduce height (e.g., 720×120) + +### Embed Page Behavior + +- Fetches video detail via existing `fetchVideo(videoId)` API +- If video has `video_url`: renders VideoPlayer +- If audio-only: renders AudioWaveform +- Supports `?t=` query param for start time (same as WatchPage) +- Shows small "Powered by Chrysopedia" link at bottom (same as ShortPlayer) +- Dark background matching the main app theme +- No auth required (public route) + +### Copy Button UX + +On WatchPage, add a button group near the existing header: +- "Copy Embed Code" button — copies iframe snippet, shows "Copied!" flash (2s timeout) +- Use same styling pattern as ShortPlayer's `.copyBtn` class + +### Clipboard Utility Extraction + +`ShortPlayer.tsx` has a standalone `copyToClipboard` function (lines 11-25) with navigator.clipboard + execCommand fallback. Extract to `frontend/src/utils/clipboard.ts` for reuse. Update ShortPlayer to import from the shared location. + +### No Backend Changes + +- Video fetch API (`GET /api/v1/videos/{id}`) already exists and is public +- Video streaming (`GET /api/v1/videos/{id}/stream`) already exists and is public +- No new endpoints needed +- No auth needed for embed page + +### No CSP/Frame Headers to Worry About + +`docker/nginx.conf` has no `X-Frame-Options` or `Content-Security-Policy` headers. The embed iframe will work on any external site without server-side changes. If CSP headers are added in the future, the `/embed/` path would need a `frame-ancestors *` exception. + +## Verification Strategy + +1. `npm run build` passes with zero TypeScript errors +2. Navigate to `/embed/{videoId}` — player renders without nav/header/footer +3. Navigate to `/watch/{videoId}` — "Copy Embed Code" button visible +4. Click "Copy Embed Code" — clipboard contains valid iframe HTML +5. Paste iframe snippet into a test HTML file, open in browser — player loads and plays diff --git a/.gsd/milestones/M024/slices/S03/tasks/T01-PLAN.md b/.gsd/milestones/M024/slices/S03/tasks/T01-PLAN.md new file mode 100644 index 0000000..eb9f6aa --- /dev/null +++ b/.gsd/milestones/M024/slices/S03/tasks/T01-PLAN.md @@ -0,0 +1,47 @@ +--- +estimated_steps: 17 +estimated_files: 4 +skills_used: [] +--- + +# T01: Create clipboard utility, EmbedPlayer page, and embed CSS module + +Create the shared clipboard utility, the EmbedPlayer page component, and its CSS module. + +1. Extract `copyToClipboard` from `ShortPlayer.tsx` into `frontend/src/utils/clipboard.ts`. Update `ShortPlayer.tsx` to import from the shared util instead of its local copy. +2. Create `frontend/src/pages/EmbedPlayer.tsx`: + - Uses `useParams<{ videoId: string }>()` and `useSearchParams` to get videoId and `?t=` start time + - Fetches video detail via `fetchVideo(videoId)` from `../api/videos` + - If video has `video_url`: renders `` with startTime + - If audio-only: renders `` with stream URL + - Always renders `` with `useMediaSync` + - Shows small 'Powered by Chrysopedia' link at bottom + - Dark background, no app header/nav/footer + - Loading and error states matching ShortPlayer pattern +3. Create `frontend/src/pages/EmbedPlayer.module.css`: + - Full-viewport player layout (100vw/100vh container, dark bg) + - Small branding link at bottom + - Responsive: works at any iframe size + - Reuse CSS custom property tokens from existing theme +4. Verify: `cd frontend && npx tsc --noEmit` passes + +## Inputs + +- ``frontend/src/pages/ShortPlayer.tsx` — contains copyToClipboard function to extract` +- ``frontend/src/pages/WatchPage.tsx` — reference for video fetching and player rendering pattern` +- ``frontend/src/components/VideoPlayer.tsx` — player component to reuse` +- ``frontend/src/components/AudioWaveform.tsx` — audio fallback component to reuse` +- ``frontend/src/components/PlayerControls.tsx` — controls component to reuse` +- ``frontend/src/hooks/useMediaSync.ts` — media state hook to reuse` +- ``frontend/src/pages/ShortPlayer.module.css` — reference for embed page styling` + +## Expected Output + +- ``frontend/src/utils/clipboard.ts` — shared copyToClipboard utility with navigator.clipboard + execCommand fallback` +- ``frontend/src/pages/EmbedPlayer.tsx` — minimal embed page component` +- ``frontend/src/pages/EmbedPlayer.module.css` — embed page styles` +- ``frontend/src/pages/ShortPlayer.tsx` — updated to import from shared clipboard util` + +## Verification + +cd frontend && npx tsc --noEmit diff --git a/.gsd/milestones/M024/slices/S03/tasks/T01-SUMMARY.md b/.gsd/milestones/M024/slices/S03/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..b7b02b3 --- /dev/null +++ b/.gsd/milestones/M024/slices/S03/tasks/T01-SUMMARY.md @@ -0,0 +1,81 @@ +--- +id: T01 +parent: S03 +milestone: M024 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/utils/clipboard.ts", "frontend/src/pages/EmbedPlayer.tsx", "frontend/src/pages/EmbedPlayer.module.css", "frontend/src/pages/ShortPlayer.tsx"] +key_decisions: ["Branding link opens origin in new tab with noopener for iframe safety", "PlayerControls rendered outside playerArea div so controls don't overlap video"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "cd frontend && npx tsc --noEmit — passed with zero errors." +completed_at: 2026-04-04T10:55:14.657Z +blocker_discovered: false +--- + +# T01: Extracted shared copyToClipboard utility and created EmbedPlayer page with full-viewport dark layout for iframe embedding + +> Extracted shared copyToClipboard utility and created EmbedPlayer page with full-viewport dark layout for iframe embedding + +## What Happened +--- +id: T01 +parent: S03 +milestone: M024 +key_files: + - frontend/src/utils/clipboard.ts + - frontend/src/pages/EmbedPlayer.tsx + - frontend/src/pages/EmbedPlayer.module.css + - frontend/src/pages/ShortPlayer.tsx +key_decisions: + - Branding link opens origin in new tab with noopener for iframe safety + - PlayerControls rendered outside playerArea div so controls don't overlap video +duration: "" +verification_result: passed +completed_at: 2026-04-04T10:55:14.658Z +blocker_discovered: false +--- + +# T01: Extracted shared copyToClipboard utility and created EmbedPlayer page with full-viewport dark layout for iframe embedding + +**Extracted shared copyToClipboard utility and created EmbedPlayer page with full-viewport dark layout for iframe embedding** + +## What Happened + +Extracted copyToClipboard from ShortPlayer.tsx into frontend/src/utils/clipboard.ts as a shared utility. Created EmbedPlayer.tsx following the WatchPage pattern — fetches video detail, renders VideoPlayer or AudioWaveform based on content type, with PlayerControls and a branding link. Created EmbedPlayer.module.css with full-viewport black background layout for iframe use. + +## Verification + +cd frontend && npx tsc --noEmit — passed with zero errors. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 2800ms | + + +## Deviations + +None. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/utils/clipboard.ts` +- `frontend/src/pages/EmbedPlayer.tsx` +- `frontend/src/pages/EmbedPlayer.module.css` +- `frontend/src/pages/ShortPlayer.tsx` + + +## Deviations +None. + +## Known Issues +None. diff --git a/.gsd/milestones/M024/slices/S03/tasks/T02-PLAN.md b/.gsd/milestones/M024/slices/S03/tasks/T02-PLAN.md new file mode 100644 index 0000000..684353e --- /dev/null +++ b/.gsd/milestones/M024/slices/S03/tasks/T02-PLAN.md @@ -0,0 +1,41 @@ +--- +estimated_steps: 16 +estimated_files: 2 +skills_used: [] +--- + +# T02: Wire embed route into App.tsx and add copy-embed button to WatchPage + +Restructure App.tsx to support a chrome-free embed route and add the copy-embed-code button to WatchPage. + +1. In `frontend/src/App.tsx`: + - Add lazy import for EmbedPlayer: `const EmbedPlayer = React.lazy(() => import('./pages/EmbedPlayer'))` + - Restructure the default export to render a top-level `` inside `` that matches `/embed/:videoId` directly (rendering ``) and falls back to `} />` for everything else. + - Move `` from being the direct child of `` to being a route element. + - This ensures embed pages skip header/nav/footer entirely. +2. In `frontend/src/pages/WatchPage.tsx`: + - Import `copyToClipboard` from `../utils/clipboard` + - Add state: `const [embedCopied, setEmbedCopied] = useState(false)` + - Add a 'Copy Embed Code' button in the `watch-page__header` section + - Button generates iframe snippet: `` + - For audio-only (no video_url), use height 120 instead of 405 + - On click: copy snippet, show 'Copied!' for 2 seconds, then revert + - Style the button using existing CSS custom properties (match ShortPlayer's copyBtn pattern) +3. Add CSS for the embed copy button in the existing WatchPage styles (inline or in the existing stylesheet that styles `.watch-page__*`). +4. Verify: `cd frontend && npm run build` succeeds with zero errors. Manually verify route structure is correct by inspecting the built output. + +## Inputs + +- ``frontend/src/App.tsx` — current routing structure to restructure` +- ``frontend/src/pages/WatchPage.tsx` — watch page to add copy button to` +- ``frontend/src/utils/clipboard.ts` — shared clipboard util created in T01` +- ``frontend/src/pages/EmbedPlayer.tsx` — embed page component created in T01` + +## Expected Output + +- ``frontend/src/App.tsx` — restructured with embed route outside AppShell` +- ``frontend/src/pages/WatchPage.tsx` — with copy-embed-code button in header` + +## Verification + +cd frontend && npm run build 2>&1 | tail -5 diff --git a/frontend/src/pages/EmbedPlayer.module.css b/frontend/src/pages/EmbedPlayer.module.css new file mode 100644 index 0000000..f7988f5 --- /dev/null +++ b/frontend/src/pages/EmbedPlayer.module.css @@ -0,0 +1,73 @@ +/* ── Embed Player (iframe) ─────────────────────────────────────────────────── */ + +.page { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + overflow: hidden; + background: #000; + color: var(--color-text-primary, #e2e8f0); +} + +.playerArea { + flex: 1 1 auto; + min-height: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +/* Stretch video/audio to fill available space */ +.playerArea video, +.playerArea canvas { + width: 100%; + height: 100%; + object-fit: contain; +} + +.controls { + flex: 0 0 auto; +} + +.branding { + flex: 0 0 auto; + padding: 0.25rem 0.5rem; + text-align: center; + font-size: 0.625rem; + color: var(--color-text-muted, #64748b); + background: rgba(0, 0, 0, 0.6); +} + +.branding a { + color: var(--color-accent, #00ffd1); + text-decoration: none; +} + +.branding a:hover { + text-decoration: underline; +} + +/* ── Loading / Error ──────────────────────────────────────────────────────── */ + +.loadingState, +.errorState { + display: flex; + align-items: center; + justify-content: center; + width: 100vw; + height: 100vh; + background: #000; + color: var(--color-text-muted, #64748b); + font-size: 0.875rem; +} + +.errorState { + flex-direction: column; + gap: 0.5rem; +} + +.errorMessage { + color: var(--color-error, #ef4444); +} diff --git a/frontend/src/pages/EmbedPlayer.tsx b/frontend/src/pages/EmbedPlayer.tsx new file mode 100644 index 0000000..7541bfc --- /dev/null +++ b/frontend/src/pages/EmbedPlayer.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; +import { useMediaSync } from "../hooks/useMediaSync"; +import { fetchVideo } from "../api/videos"; +import { BASE, ApiError } from "../api/client"; +import type { VideoDetail } from "../api/videos"; +import VideoPlayer from "../components/VideoPlayer"; +import AudioWaveform from "../components/AudioWaveform"; +import PlayerControls from "../components/PlayerControls"; +import styles from "./EmbedPlayer.module.css"; + +/** + * Minimal embed page for iframe use — renders the player + * without app chrome (no header, nav, or footer). + * Route: /embed/:videoId + */ +export default function EmbedPlayer() { + const { videoId } = useParams<{ videoId: string }>(); + const [searchParams] = useSearchParams(); + + // Parse ?t= start time + const rawT = parseFloat(searchParams.get("t") ?? ""); + const startTime = Number.isFinite(rawT) && rawT > 0 ? rawT : 0; + + const [video, setVideo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const mediaSync = useMediaSync(); + + useEffect(() => { + if (!videoId) { + setError("No video ID provided"); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + fetchVideo(videoId) + .then((v) => { + if (!cancelled) setVideo(v); + }) + .catch((err) => { + if (!cancelled) { + if (err instanceof ApiError && err.status === 404) { + setError("Video not found"); + } else { + setError("Failed to load video"); + } + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [videoId]); + + if (loading) { + return
Loading…
; + } + + if (error || !video) { + return ( +
+

{error ?? "Video not found"}

+
+ ); + } + + const streamUrl = `${BASE}/videos/${videoId}/stream`; + + return ( +
+
+ {video.video_url ? ( + + ) : ( + + )} +
+ +
+ +
+ +
+ Powered by{" "} + + Chrysopedia + +
+
+ ); +} diff --git a/frontend/src/pages/ShortPlayer.tsx b/frontend/src/pages/ShortPlayer.tsx index a769bcb..b2bd205 100644 --- a/frontend/src/pages/ShortPlayer.tsx +++ b/frontend/src/pages/ShortPlayer.tsx @@ -1,33 +1,9 @@ import { useEffect, useState, useCallback } from "react"; import { useParams } from "react-router-dom"; import { fetchPublicShort, type PublicShortResponse } from "../api/shorts"; +import { copyToClipboard } from "../utils/clipboard"; import styles from "./ShortPlayer.module.css"; -/** - * Copy text to clipboard with execCommand fallback for older browsers. - * Returns true on success. - */ -async function copyToClipboard(text: string): Promise { - if (navigator.clipboard) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - // Clipboard API failed — fall through to fallback - } - } - // Fallback: hidden textarea + execCommand - const ta = document.createElement("textarea"); - ta.value = text; - ta.style.position = "fixed"; - ta.style.left = "-9999px"; - document.body.appendChild(ta); - ta.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(ta); - return ok; -} - function buildEmbedSnippet( token: string, width: number, diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..abbd8d0 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,24 @@ +/** + * Copy text to clipboard with execCommand fallback for older browsers. + * Returns true on success. + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Clipboard API failed — fall through to fallback + } + } + // Fallback: hidden textarea + execCommand + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; +}