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 fa7e4983c7
commit 86d554a56f
2 changed files with 212 additions and 7 deletions

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 ? (
<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)}