feat: Extracted shared copyToClipboard utility and created EmbedPlayer…

- "frontend/src/utils/clipboard.ts"
- "frontend/src/pages/EmbedPlayer.tsx"
- "frontend/src/pages/EmbedPlayer.module.css"
- "frontend/src/pages/ShortPlayer.tsx"

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-04-04 10:55:21 +00:00
parent 9208b134b6
commit 8444fbdb12
13 changed files with 707 additions and 27 deletions

View file

@ -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 |

View file

@ -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

View file

@ -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

View file

@ -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"
}
]
}

View file

@ -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 `<VideoPlayer>` with startTime
- If audio-only: renders `<AudioWaveform>` with stream URL
- Always renders `<PlayerControls>` 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 `<Routes>` inside `<AuthProvider>` that matches `/embed/:videoId` directly (rendering `<Suspense><EmbedPlayer /></Suspense>`) and falls back to `<Route path='/*' element={<AppShell />} />` for everything else.
- Move `<AppShell>` from being the direct child of `<AuthProvider>` 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: `<iframe src="${window.location.origin}/embed/${videoId}" width="720" height="405" frameborder="0" allowfullscreen></iframe>`
- 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

View file

@ -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 `<iframe src="{origin}/embed/{videoId}" ...>` snippet.
3. **Route wiring + styles** — add route to App.tsx (outside AppShell to avoid nav/footer), add embed page CSS.
## Implementation Landscape
### Existing Components to Reuse
| Component | Location | Role |
|-----------|----------|------|
| `VideoPlayer` | `frontend/src/components/VideoPlayer.tsx` | HLS/native/mp4 player with startTime support |
| `PlayerControls` | `frontend/src/components/PlayerControls.tsx` | Play/pause, seek, volume controls |
| `AudioWaveform` | `frontend/src/components/AudioWaveform.tsx` | Audio-only fallback (embed should support both) |
| `useMediaSync` | `frontend/src/hooks/useMediaSync.ts` | Shared media state hook connecting player + controls |
| `copyToClipboard` | `frontend/src/pages/ShortPlayer.tsx` (lines 11-25) | Clipboard with execCommand fallback — extract to shared util |
| `CopyLinkButton` | `frontend/src/components/CopyLinkButton.tsx` | Similar pattern but copies URLs — can inform the embed button design |
### Key Files to Modify
| File | Change |
|------|--------|
| `frontend/src/App.tsx` | Add `/embed/:videoId` route **outside** `<AppShell>` so it skips nav/header/footer |
| `frontend/src/pages/WatchPage.tsx` | Add "Copy Embed Code" button in the header area |
### New Files
| File | Purpose |
|------|---------|
| `frontend/src/pages/EmbedPlayer.tsx` | Minimal embed page: fetches video detail, renders VideoPlayer/AudioWaveform + PlayerControls, dark bg, small Chrysopedia branding link |
| `frontend/src/pages/EmbedPlayer.module.css` | Embed-specific styles (full-viewport player, minimal padding) |
| `frontend/src/utils/clipboard.ts` | Extracted `copyToClipboard` utility (shared between ShortPlayer and WatchPage) |
### Architecture: Route Outside AppShell
The embed page must render **without** the app header, nav, and footer. The current App.tsx structure is:
```
<AuthProvider>
<AppShell> ← header + nav + footer
<Routes>
... all routes ...
</Routes>
</AppShell>
</AuthProvider>
```
The embed route needs to live outside AppShell. The cleanest approach: add a top-level `<Routes>` in `App()` that matches `/embed/*` and renders `EmbedPlayer` directly, with a fallback to `AppShell` for everything else. This is the same pattern used in many SPA embed implementations.
```tsx
export default function App() {
return (
<AuthProvider>
<Routes>
<Route path="/embed/:videoId" element={<Suspense fallback={<LoadingFallback />}><EmbedPlayer /></Suspense>} />
<Route path="/*" element={<AppShell />} />
</Routes>
</AuthProvider>
);
}
```
### Embed Snippet Format
Following the ShortPlayer precedent:
```html
<iframe src="https://chrysopedia.com/embed/{videoId}" width="720" height="405" frameborder="0" allowfullscreen></iframe>
```
- 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

View file

@ -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 `<VideoPlayer>` with startTime
- If audio-only: renders `<AudioWaveform>` with stream URL
- Always renders `<PlayerControls>` 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

View file

@ -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.

View file

@ -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 `<Routes>` inside `<AuthProvider>` that matches `/embed/:videoId` directly (rendering `<Suspense><EmbedPlayer /></Suspense>`) and falls back to `<Route path='/*' element={<AppShell />} />` for everything else.
- Move `<AppShell>` from being the direct child of `<AuthProvider>` 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: `<iframe src="${window.location.origin}/embed/${videoId}" width="720" height="405" frameborder="0" allowfullscreen></iframe>`
- 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

View file

@ -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);
}

View file

@ -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<VideoDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className={styles.loadingState}>Loading</div>;
}
if (error || !video) {
return (
<div className={styles.errorState}>
<p className={styles.errorMessage}>{error ?? "Video not found"}</p>
</div>
);
}
const streamUrl = `${BASE}/videos/${videoId}/stream`;
return (
<div className={styles.page}>
<div className={styles.playerArea}>
{video.video_url ? (
<VideoPlayer
src={video.video_url}
startTime={startTime}
mediaSync={mediaSync}
/>
) : (
<AudioWaveform src={streamUrl} mediaSync={mediaSync} />
)}
</div>
<div className={styles.controls}>
<PlayerControls mediaSync={mediaSync} />
</div>
<div className={styles.branding}>
Powered by{" "}
<a href={window.location.origin} target="_blank" rel="noopener noreferrer">
Chrysopedia
</a>
</div>
</div>
);
}

View file

@ -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<boolean> {
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,

View file

@ -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<boolean> {
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;
}