perf: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and na…
- "frontend/src/hooks/useMediaSync.ts" - "frontend/src/components/VideoPlayer.tsx" - "frontend/src/components/PlayerControls.tsx" - "frontend/src/App.css" - "frontend/package.json" GSD-Task: S01/T02
This commit is contained in:
parent
87cb667848
commit
8069e9e2a3
10 changed files with 771 additions and 4 deletions
|
|
@ -51,7 +51,7 @@ Both return 404 for non-existent video IDs.
|
|||
- Estimate: 45m
|
||||
- Files: backend/routers/videos.py, backend/schemas.py, backend/tests/test_video_detail.py
|
||||
- Verify: cd backend && python -m pytest tests/test_video_detail.py -v
|
||||
- [ ] **T02: Build VideoPlayer component with HLS, custom controls, and media sync hook** — Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript.
|
||||
- [x] **T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts** — Build the core video player infrastructure: hls.js integration, custom playback controls, and the useMediaSync hook that shares playback state between player and transcript.
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
24
.gsd/milestones/M020/slices/S01/tasks/T01-VERIFY.json
Normal file
24
.gsd/milestones/M020/slices/S01/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M020/S01/T01",
|
||||
"timestamp": 1775259763553,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd backend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 8,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "python -m pytest tests/test_video_detail.py -v",
|
||||
"exitCode": 4,
|
||||
"durationMs": 242,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
83
.gsd/milestones/M020/slices/S01/tasks/T02-SUMMARY.md
Normal file
83
.gsd/milestones/M020/slices/S01/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S01
|
||||
milestone: M020
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["frontend/src/hooks/useMediaSync.ts", "frontend/src/components/VideoPlayer.tsx", "frontend/src/components/PlayerControls.tsx", "frontend/src/App.css", "frontend/package.json"]
|
||||
key_decisions: ["Cast videoRef to RefObject<HTMLVideoElement> at JSX site to satisfy React 18 strict ref typing"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "npx tsc --noEmit passes with zero errors. npm run build succeeds producing production bundle. hls.js is dynamically imported (lazy-loaded), not in main bundle."
|
||||
completed_at: 2026-04-03T23:45:50.331Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts
|
||||
|
||||
> Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S01
|
||||
milestone: M020
|
||||
key_files:
|
||||
- frontend/src/hooks/useMediaSync.ts
|
||||
- frontend/src/components/VideoPlayer.tsx
|
||||
- frontend/src/components/PlayerControls.tsx
|
||||
- frontend/src/App.css
|
||||
- frontend/package.json
|
||||
key_decisions:
|
||||
- Cast videoRef to RefObject<HTMLVideoElement> at JSX site to satisfy React 18 strict ref typing
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-03T23:45:50.331Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts
|
||||
|
||||
**Built useMediaSync hook, VideoPlayer with HLS lazy-loading and native fallback, and PlayerControls with speed/volume/seek/fullscreen/keyboard shortcuts**
|
||||
|
||||
## What Happened
|
||||
|
||||
Installed hls.js. Created useMediaSync hook managing shared playback state via video element event listeners. Created VideoPlayer with three source paths (HLS via lazy-loaded hls.js, Safari native HLS, direct mp4), null-src placeholder, and fatal error state. Created PlayerControls with play/pause, seek bar, time display, 6-option speed selector (0.5–2x), volume + mute, fullscreen, and keyboard shortcuts. Added ~180 lines of themed CSS with responsive stacking. Fixed React 18 ref type mismatch for video element.
|
||||
|
||||
## Verification
|
||||
|
||||
npx tsc --noEmit passes with zero errors. npm run build succeeds producing production bundle. hls.js is dynamically imported (lazy-loaded), not in main bundle.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 2800ms |
|
||||
| 2 | `cd frontend && npm run build` | 0 | ✅ pass | 3600ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Added RefObject cast at JSX ref site for React 18 strict ref typing compatibility.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/hooks/useMediaSync.ts`
|
||||
- `frontend/src/components/VideoPlayer.tsx`
|
||||
- `frontend/src/components/PlayerControls.tsx`
|
||||
- `frontend/src/App.css`
|
||||
- `frontend/package.json`
|
||||
|
||||
|
||||
## Deviations
|
||||
Added RefObject cast at JSX ref site for React 18 strict ref typing compatibility.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "chrysopedia-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "chrysopedia-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
|
|
@ -1463,6 +1464,12 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
|
|
|
|||
|
|
@ -5860,3 +5860,202 @@ a.app-footer__about:hover,
|
|||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Video Player ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-player__video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-player__unavailable {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.video-player__unavailable-content {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.video-player__unavailable-content svg {
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.video-player__unavailable-content p {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.video-player__unavailable-content span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Player Controls ───────────────────────────────────────────────────────── */
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-bg-header);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.player-controls__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.player-controls__btn:hover {
|
||||
background: var(--color-bg-surface-hover);
|
||||
}
|
||||
|
||||
.player-controls__time {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
/* Range input shared styles */
|
||||
.player-controls__seek,
|
||||
.player-controls__volume {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-accent) 0%,
|
||||
var(--color-accent) var(--progress, 0%),
|
||||
var(--color-border) var(--progress, 0%),
|
||||
var(--color-border) 100%
|
||||
);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.player-controls__seek {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-controls__seek:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.player-controls__volume {
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Thumb styling */
|
||||
.player-controls__seek::-webkit-slider-thumb,
|
||||
.player-controls__volume::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.player-controls__seek::-moz-range-thumb,
|
||||
.player-controls__volume::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Speed button group */
|
||||
.player-controls__speed {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.player-controls__speed-btn {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.player-controls__speed-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.player-controls__speed-btn--active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Responsive: player controls ───────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.player-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.player-controls__seek {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.player-controls__speed {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.player-controls__speed-btn {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
|
||||
.player-controls__volume {
|
||||
width: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
195
frontend/src/components/PlayerControls.tsx
Normal file
195
frontend/src/components/PlayerControls.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useCallback, useEffect } from "react";
|
||||
import type { MediaSyncState } from "../hooks/useMediaSync";
|
||||
|
||||
interface PlayerControlsProps {
|
||||
mediaSync: MediaSyncState;
|
||||
/** Ref to the container element for fullscreen requests */
|
||||
containerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function PlayerControls({ mediaSync, containerRef }: PlayerControlsProps) {
|
||||
const {
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
volume,
|
||||
isMuted,
|
||||
seekTo,
|
||||
setPlaybackRate,
|
||||
togglePlay,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
} = mediaSync;
|
||||
|
||||
const seekDisabled = !duration || duration === 0;
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
seekTo(parseFloat(e.target.value));
|
||||
},
|
||||
[seekTo],
|
||||
);
|
||||
|
||||
const handleVolume = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
},
|
||||
[setVolume],
|
||||
);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
const el = containerRef?.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
}, [containerRef]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't intercept when typing in inputs
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
seekTo(Math.max(0, currentTime - 5));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
seekTo(Math.min(duration, currentTime + 5));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setVolume(Math.min(1, volume + 0.1));
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setVolume(Math.max(0, volume - 0.1));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [togglePlay, seekTo, setVolume, currentTime, duration, volume]);
|
||||
|
||||
// Seek bar progress as CSS variable for styling
|
||||
const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const volumeProgress = (isMuted ? 0 : volume) * 100;
|
||||
|
||||
return (
|
||||
<div className="player-controls" role="toolbar" aria-label="Video playback controls">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
className="player-controls__btn"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
title={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Time display */}
|
||||
<span className="player-controls__time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
{/* Seek bar */}
|
||||
<input
|
||||
type="range"
|
||||
className="player-controls__seek"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
disabled={seekDisabled}
|
||||
aria-label="Seek"
|
||||
style={{ "--progress": `${seekProgress}%` } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* Speed selector */}
|
||||
<div className="player-controls__speed" role="group" aria-label="Playback speed">
|
||||
{SPEED_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
className={`player-controls__speed-btn${playbackRate === rate ? " player-controls__speed-btn--active" : ""}`}
|
||||
onClick={() => setPlaybackRate(rate)}
|
||||
aria-pressed={playbackRate === rate}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<button
|
||||
className="player-controls__btn"
|
||||
onClick={toggleMute}
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
title={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 12A4.5 4.5 0 0014 8.14v1.44l2.45 2.45c.03-.17.05-.34.05-.53zm2.5 0c0 .94-.2 1.82-.54 2.64l1.52 1.52A8.91 8.91 0 0021 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 003.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0014 8.14v7.72c1.48-.73 2.5-2.25 2.5-3.86zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
className="player-controls__volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolume}
|
||||
aria-label="Volume"
|
||||
style={{ "--progress": `${volumeProgress}%` } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
className="player-controls__btn"
|
||||
onClick={handleFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/VideoPlayer.tsx
Normal file
138
frontend/src/components/VideoPlayer.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MediaSyncState } from "../hooks/useMediaSync";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string | null;
|
||||
startTime?: number;
|
||||
mediaSync: MediaSyncState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video player with HLS support (lazy-loaded via dynamic import),
|
||||
* Safari native HLS fallback, and direct .mp4 playback.
|
||||
* Shows a placeholder when src is null.
|
||||
*/
|
||||
export default function VideoPlayer({ src, startTime, mediaSync }: VideoPlayerProps) {
|
||||
const { videoRef } = mediaSync;
|
||||
const hlsRef = useRef<import("hls.js").default | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const startTimeSeeked = useRef(false);
|
||||
|
||||
// Clamp startTime: NaN / negative → 0
|
||||
const safeStart =
|
||||
startTime != null && Number.isFinite(startTime) && startTime > 0
|
||||
? startTime
|
||||
: 0;
|
||||
|
||||
// Seek to startTime once after metadata loads
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el || safeStart === 0) return;
|
||||
startTimeSeeked.current = false;
|
||||
|
||||
const onLoaded = () => {
|
||||
if (!startTimeSeeked.current) {
|
||||
el.currentTime = safeStart;
|
||||
startTimeSeeked.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (el.readyState >= 1) {
|
||||
onLoaded();
|
||||
} else {
|
||||
el.addEventListener("loadedmetadata", onLoaded, { once: true });
|
||||
return () => el.removeEventListener("loadedmetadata", onLoaded);
|
||||
}
|
||||
}, [videoRef, safeStart]);
|
||||
|
||||
// HLS / native source attachment
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el || !src) return;
|
||||
|
||||
setError(null);
|
||||
let destroyed = false;
|
||||
|
||||
const isHlsUrl = src.endsWith(".m3u8");
|
||||
|
||||
// Check native HLS support (Safari)
|
||||
const canPlayHlsNatively = el.canPlayType("application/vnd.apple.mpegurl") !== "";
|
||||
|
||||
if (isHlsUrl && !canPlayHlsNatively) {
|
||||
// Lazy-load hls.js
|
||||
void import("hls.js").then(({ default: Hls }) => {
|
||||
if (destroyed) return;
|
||||
if (!Hls.isSupported()) {
|
||||
setError("HLS playback is not supported in this browser.");
|
||||
return;
|
||||
}
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
startPosition: safeStart > 0 ? safeStart : -1,
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
console.error("[VideoPlayer] Fatal HLS error:", data.type, data.details);
|
||||
setError(`Playback error: ${data.details}`);
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(el);
|
||||
});
|
||||
} else {
|
||||
// Native HLS (Safari) or plain .mp4
|
||||
el.src = src;
|
||||
}
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [src, videoRef, safeStart]);
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="video-player video-player__unavailable">
|
||||
<div className="video-player__unavailable-content">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" />
|
||||
<path d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12z" />
|
||||
<line x1="4" y1="4" x2="20" y2="20" strokeLinecap="round" />
|
||||
</svg>
|
||||
<p>Video not available</p>
|
||||
<span>The source video for this content has not been linked yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="video-player video-player__unavailable">
|
||||
<div className="video-player__unavailable-content">
|
||||
<p>Playback Error</p>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="video-player">
|
||||
<video
|
||||
ref={videoRef as React.RefObject<HTMLVideoElement>}
|
||||
className="video-player__video"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/hooks/useMediaSync.ts
Normal file
120
frontend/src/hooks/useMediaSync.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface MediaSyncState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
seekTo: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
togglePlay: () => void;
|
||||
setVolume: (v: number) => void;
|
||||
toggleMute: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared playback state hook. Owns the <video> ref and exposes
|
||||
* current time, duration, play state, rate, volume, and control actions.
|
||||
* Attach `videoRef` to a <video> element — the hook listens for
|
||||
* timeupdate, play, pause, ratechange, volumechange, and loadedmetadata.
|
||||
*/
|
||||
export function useMediaSync(): MediaSyncState {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playbackRate, setPlaybackRateState] = useState(1);
|
||||
const [volume, setVolumeState] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onTimeUpdate = () => setCurrentTime(el.currentTime);
|
||||
const onDurationChange = () => setDuration(el.duration || 0);
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onRateChange = () => setPlaybackRateState(el.playbackRate);
|
||||
const onVolumeChange = () => {
|
||||
setVolumeState(el.volume);
|
||||
setIsMuted(el.muted);
|
||||
};
|
||||
const onLoadedMetadata = () => {
|
||||
setDuration(el.duration || 0);
|
||||
setCurrentTime(el.currentTime);
|
||||
};
|
||||
|
||||
el.addEventListener("timeupdate", onTimeUpdate);
|
||||
el.addEventListener("durationchange", onDurationChange);
|
||||
el.addEventListener("play", onPlay);
|
||||
el.addEventListener("pause", onPause);
|
||||
el.addEventListener("ratechange", onRateChange);
|
||||
el.addEventListener("volumechange", onVolumeChange);
|
||||
el.addEventListener("loadedmetadata", onLoadedMetadata);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("timeupdate", onTimeUpdate);
|
||||
el.removeEventListener("durationchange", onDurationChange);
|
||||
el.removeEventListener("play", onPlay);
|
||||
el.removeEventListener("pause", onPause);
|
||||
el.removeEventListener("ratechange", onRateChange);
|
||||
el.removeEventListener("volumechange", onVolumeChange);
|
||||
el.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const seekTo = useCallback((time: number) => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const safe = Number.isFinite(time) && time >= 0 ? time : 0;
|
||||
el.currentTime = Math.min(safe, el.duration || 0);
|
||||
}, []);
|
||||
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const clamped = Math.max(0.5, Math.min(2, rate));
|
||||
el.playbackRate = clamped;
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
if (el.paused) {
|
||||
void el.play();
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((v: number) => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
el.volume = Math.max(0, Math.min(1, v));
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
el.muted = !el.muted;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
volume,
|
||||
isMuted,
|
||||
videoRef,
|
||||
seekTo,
|
||||
setPlaybackRate,
|
||||
togglePlay,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
};
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
||||
Loading…
Add table
Reference in a new issue