feat: Added collapsible inline video player to TechniquePage with chapt…

- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.css"

GSD-Task: S02/T02
This commit is contained in:
jlightner 2026-04-04 10:48:12 +00:00
parent 3c99084eb2
commit 9208b134b6
5 changed files with 306 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<TechniquePageVersionDetail | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
// Inline player
const [playerOpen, setPlayerOpen] = useState(false);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [activeVideoId, setActiveVideoId] = useState<string | null>(null);
const mediaSync = useMediaSync();
const playerRef = useRef<HTMLDivElement>(null);
const playerContainerRef = useRef<HTMLDivElement>(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<string>("");
const titleBarRef = useRef<HTMLDivElement>(null);
@ -447,6 +500,50 @@ export default function TechniquePage() {
</section>
)}
{/* Inline video player — collapsible */}
{activeVideoId && (
<section className="technique-player" ref={playerRef}>
<button
className="technique-player__toggle"
onClick={() => setPlayerOpen((prev) => !prev)}
aria-expanded={playerOpen}
>
<span className="technique-player__toggle-icon">{playerOpen ? "▼" : "▶"}</span>
{playerOpen
? `Playing: ${sourceVideos.find((v) => v.id === activeVideoId)?.filename ?? "Video"}`
: "Preview Key Moments"}
</button>
<div className={`technique-player__collapse ${playerOpen ? "technique-player__collapse--open" : ""}`}>
<div className="technique-player__inner">
{sourceVideos.length > 1 && (
<select
className="technique-player__video-select"
value={activeVideoId}
onChange={(e) => setActiveVideoId(e.target.value)}
>
{sourceVideos.map((v) => (
<option key={v.id} value={v.id}>
{v.filename}
</option>
))}
</select>
)}
<div className="technique-player__video" ref={playerContainerRef}>
<VideoPlayer
src={`${BASE}/videos/${activeVideoId}/stream`}
mediaSync={mediaSync}
/>
</div>
<PlayerControls
mediaSync={mediaSync}
containerRef={playerContainerRef}
chapters={chapters}
/>
</div>
</div>
</section>
)}
{/* 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() {
<span className="technique-source__file">{km.video_filename}</span>
)}
{km.source_video_id ? (
playerOpen && km.source_video_id === activeVideoId ? (
<button
type="button"
className="technique-source__time technique-source__time--link technique-source__time--seek"
onClick={() => seekInlinePlayer(km.start_time)}
>
{formatTime(km.start_time)}{formatTime(km.end_time)}
</button>
) : (
<Link
to={`/watch/${km.source_video_id}?t=${km.start_time}`}
className="technique-source__time technique-source__time--link"
>
{formatTime(km.start_time)}{formatTime(km.end_time)}
</Link>
)
) : (
<span className="technique-source__time">
{formatTime(km.start_time)}{formatTime(km.end_time)}