From 86d554a56fdc83b21971d30addee17ae6cf8d28a 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 --- frontend/src/App.css | 98 ++++++++++++++++++++++ frontend/src/pages/TechniquePage.tsx | 121 +++++++++++++++++++++++++-- 2 files changed, 212 insertions(+), 7 deletions(-) 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)}