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