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:
parent
3c99084eb2
commit
9208b134b6
5 changed files with 306 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
.gsd/milestones/M024/slices/S02/tasks/T01-VERIFY.json
Normal file
16
.gsd/milestones/M024/slices/S02/tasks/T01-VERIFY.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
77
.gsd/milestones/M024/slices/S02/tasks/T02-SUMMARY.md
Normal file
77
.gsd/milestones/M024/slices/S02/tasks/T02-SUMMARY.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<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>
|
||||
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)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue