From 9208b134b619338ae2543ca9d06ec06469b89f4e Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 10:48:12 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20collapsible=20inline=20video=20?= =?UTF-8?q?player=20to=20TechniquePage=20with=20chapt=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/pages/TechniquePage.tsx" - "frontend/src/App.css" GSD-Task: S02/T02 --- .gsd/milestones/M024/slices/S02/S02-PLAN.md | 2 +- .../M024/slices/S02/tasks/T01-VERIFY.json | 16 +++ .../M024/slices/S02/tasks/T02-SUMMARY.md | 77 +++++++++++ frontend/src/App.css | 98 ++++++++++++++ frontend/src/pages/TechniquePage.tsx | 121 +++++++++++++++++- 5 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 .gsd/milestones/M024/slices/S02/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M024/slices/S02/tasks/T02-SUMMARY.md diff --git a/.gsd/milestones/M024/slices/S02/S02-PLAN.md b/.gsd/milestones/M024/slices/S02/S02-PLAN.md index 75cf035..0889ef5 100644 --- a/.gsd/milestones/M024/slices/S02/S02-PLAN.md +++ b/.gsd/milestones/M024/slices/S02/S02-PLAN.md @@ -39,7 +39,7 @@ - Estimate: 45m - Files: frontend/src/components/ChapterMarkers.tsx, frontend/src/components/PlayerControls.tsx, frontend/src/App.css - Verify: cd frontend && npm run build 2>&1 | tail -5 -- [ ] **T02: Add collapsible inline player with pins to TechniquePage** — Add a collapsible inline video player to TechniquePage that shows the source video with key moment pins on its timeline. Wire key moment bibliography clicks to seek the inline player instead of navigating to WatchPage. +- [x] **T02: Added collapsible inline video player to TechniquePage with chapter pin markers, bibliography seek wiring, and multi-source-video selector** — Add a collapsible inline video player to TechniquePage that shows the source video with key moment pins on its timeline. Wire key moment bibliography clicks to seek the inline player instead of navigating to WatchPage. ## Steps diff --git a/.gsd/milestones/M024/slices/S02/tasks/T01-VERIFY.json b/.gsd/milestones/M024/slices/S02/tasks/T01-VERIFY.json new file mode 100644 index 0000000..0f71727 --- /dev/null +++ b/.gsd/milestones/M024/slices/S02/tasks/T01-VERIFY.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M024/S02/T01", + "timestamp": 1775299485141, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 10, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M024/slices/S02/tasks/T02-SUMMARY.md b/.gsd/milestones/M024/slices/S02/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..df40b6f --- /dev/null +++ b/.gsd/milestones/M024/slices/S02/tasks/T02-SUMMARY.md @@ -0,0 +1,77 @@ +--- +id: T02 +parent: S02 +milestone: M024 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/pages/TechniquePage.tsx", "frontend/src/App.css"] +key_decisions: ["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: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "npm run build passes with zero errors (3.5s build time, only expected chunk size warning for hls.js)." +completed_at: 2026-04-04T10:48:08.136Z +blocker_discovered: false +--- + +# T02: Added collapsible inline video player to TechniquePage with chapter pin markers, bibliography seek wiring, and multi-source-video selector + +> Added collapsible inline video player to TechniquePage with chapter pin markers, bibliography seek wiring, and multi-source-video selector + +## What Happened +--- +id: T02 +parent: S02 +milestone: M024 +key_files: + - frontend/src/pages/TechniquePage.tsx + - frontend/src/App.css +key_decisions: + - 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 +duration: "" +verification_result: passed +completed_at: 2026-04-04T10:48:08.136Z +blocker_discovered: false +--- + +# T02: Added collapsible inline video player to TechniquePage with chapter pin markers, bibliography seek wiring, and multi-source-video selector + +**Added collapsible inline video player to TechniquePage with chapter pin markers, bibliography seek wiring, and multi-source-video selector** + +## What Happened + +Added a collapsible inline player section to TechniquePage 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 passed to PlayerControls which renders the T01-upgraded pin markers on the seek bar. Bibliography time links conditionally render as seek buttons when the inline player is active for the same video. Multi-source-video selector dropdown switches between source videos. Collapse animation uses CSS grid-template-rows 0fr/1fr pattern. + +## Verification + +npm run build passes with zero errors (3.5s build time, only expected chunk size warning for hls.js). + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3500ms | + + +## Deviations + +None. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/pages/TechniquePage.tsx` +- `frontend/src/App.css` + + +## Deviations +None. + +## Known Issues +None. diff --git a/frontend/src/App.css b/frontend/src/App.css index 24a5f0e..4f2ffb7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1815,6 +1815,104 @@ a.app-footer__repo:hover { line-height: 1.6; } +/* ── Inline player (collapsible) ─────────────────────────────────────────── */ + +.technique-player { + margin-bottom: 1.5rem; + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + background: var(--color-surface-raised, var(--color-bg-primary)); +} + +.technique-player__toggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem 0.875rem; + border: none; + background: transparent; + color: var(--color-text-primary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: background 0.15s; +} + +.technique-player__toggle:hover { + background: var(--color-surface-hover, rgba(255, 255, 255, 0.05)); +} + +.technique-player__toggle-icon { + font-size: 0.7rem; + opacity: 0.7; + width: 1em; + flex-shrink: 0; +} + +.technique-player__collapse { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease; +} + +.technique-player__collapse--open { + grid-template-rows: 1fr; +} + +.technique-player__inner { + overflow: hidden; + min-height: 0; +} + +.technique-player__video-select { + display: block; + margin: 0 0.875rem 0.5rem; + padding: 0.35rem 0.5rem; + border-radius: 4px; + border: 1px solid var(--color-border); + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-size: 0.8rem; +} + +.technique-player__video { + max-height: 400px; + width: 100%; + position: relative; +} + +.technique-player__video .video-player { + max-height: 400px; +} + +.technique-player__video .video-player__video { + max-height: 400px; + width: 100%; + object-fit: contain; +} + +/* Seek button in bibliography */ +.technique-source__time--seek { + border: none; + background: none; + padding: 0; + font: inherit; + cursor: pointer; +} + +@media (max-width: 768px) { + .technique-player__video { + max-height: 260px; + } + .technique-player__video .video-player, + .technique-player__video .video-player__video { + max-height: 260px; + } +} + .technique-prose { margin-bottom: 2rem; } diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx index afd3117..9317bb5 100644 --- a/frontend/src/pages/TechniquePage.tsx +++ b/frontend/src/pages/TechniquePage.tsx @@ -6,7 +6,7 @@ * Right column: ToC (sticky, scrolls with viewer). */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { fetchTechnique, @@ -17,12 +17,17 @@ import { type TechniquePageVersionDetail, type BodySectionV2, } from "../api"; +import { fetchChapters, type Chapter } from "../api/videos"; +import { BASE } from "../api/client"; import ReportIssueModal from "../components/ReportIssueModal"; import CopyLinkButton from "../components/CopyLinkButton"; import CreatorAvatar from "../components/CreatorAvatar"; +import VideoPlayer from "../components/VideoPlayer"; +import PlayerControls from "../components/PlayerControls"; import TableOfContents, { slugify } from "../components/TableOfContents"; import { parseCitations } from "../utils/citations"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import { useMediaSync } from "../hooks/useMediaSync"; function formatTime(seconds: number): string { const m = Math.floor(seconds / 60); @@ -94,6 +99,14 @@ export default function TechniquePage() { useState(null); const [versionLoading, setVersionLoading] = useState(false); + // Inline player + const [playerOpen, setPlayerOpen] = useState(false); + const [chapters, setChapters] = useState([]); + const [activeVideoId, setActiveVideoId] = useState(null); + const mediaSync = useMediaSync(); + const playerRef = useRef(null); + const playerContainerRef = useRef(null); + // Load technique + version list useEffect(() => { if (!slug) return; @@ -169,6 +182,46 @@ export default function TechniquePage() { }; }, [slug, selectedVersion]); + // Derive initial video ID from technique data + useEffect(() => { + if (!technique) return; + const firstVideoId = + technique.key_moments[0]?.source_video_id || + technique.source_videos[0]?.id || + null; + setActiveVideoId(firstVideoId); + }, [technique]); + + // Fetch chapters when activeVideoId changes and player is open + useEffect(() => { + if (!activeVideoId || !playerOpen) { + setChapters([]); + return; + } + let cancelled = false; + void (async () => { + try { + const res = await fetchChapters(activeVideoId); + if (!cancelled) setChapters(res.chapters); + } catch { + if (!cancelled) setChapters([]); + } + })(); + return () => { cancelled = true; }; + }, [activeVideoId, playerOpen]); + + // Seek inline player and scroll into view + const seekInlinePlayer = useCallback((time: number) => { + mediaSync.seekTo(time); + playerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, [mediaSync]); + + // Build unique source videos list for the selector + const sourceVideos = useMemo(() => { + if (!technique) return []; + return technique.source_videos ?? []; + }, [technique]); + // --- Scroll-spy: activeId for ToC --- const [activeId, setActiveId] = useState(""); const titleBarRef = useRef(null); @@ -447,6 +500,50 @@ export default function TechniquePage() { )} + {/* Inline video player — collapsible */} + {activeVideoId && ( +
+ +
+
+ {sourceVideos.length > 1 && ( + + )} +
+ +
+ +
+
+
+ )} + {/* Study guide prose — body_sections */} {displaySections && (Array.isArray(displaySections) ? displaySections.length > 0 : Object.keys(displaySections).length > 0) && ( @@ -513,12 +610,22 @@ export default function TechniquePage() { {km.video_filename} )} {km.source_video_id ? ( - - {formatTime(km.start_time)}–{formatTime(km.end_time)} - + playerOpen && km.source_video_id === activeVideoId ? ( + + ) : ( + + {formatTime(km.start_time)}–{formatTime(km.end_time)} + + ) ) : ( {formatTime(km.start_time)}–{formatTime(km.end_time)}