9660cdc21c
Deploy / deploy (push) Successful in 3m23s
Worker 1.7.0 (preview.highQuality, default on) now, after the AE render: - encodes a High quality MP4 from the delivery MOV (full-res H.264 High, CRF 14 capped at 100 Mbps) -> Version.hqFileUrl - uploads the MOV itself -> Version.highResKey/Filename/Size Both best-effort (the preview never fails over them), with a lease keep-alive during the long encode/uploads; the export timeline records what was attached. Player: HQ switch when a High quality source exists; keeps frame, play state, speed and zoom, remembered per browser, falls back to Standard if the file won't load. ProRes itself stays download-only (browser support). Downloads: the client portal's High Res button serves the version's ProRes (client-code filename), falling back to the shot's manual file; staff get a ProRes button on the review page. Housekeeping (lib/render-pipeline/high-res.ts): HQ MP4 + ProRes are kept while a version is latest, client-shared, approved or younger than preview.highResRetentionDays (14); otherwise deleted from storage. Runs per task after finalize and hourly from the reaper; deleting a version or a reslate removes its files. The Standard MP4 is never touched. Also: ProRes/Handles top-bar buttons go icon-only below lg so they don't overlap the version selector. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
593 lines
21 KiB
TypeScript
593 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useRef,
|
|
useEffect,
|
|
useState,
|
|
useCallback,
|
|
useMemo,
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
} from "react";
|
|
import { useReviewStore } from "@/hooks/use-review-player";
|
|
import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas";
|
|
import { AnnotationTools } from "@/components/annotations/AnnotationTools";
|
|
import { AspectOverlay } from "./AspectOverlay";
|
|
import { FrameTimeline } from "./FrameTimeline";
|
|
import { PlaybackControls } from "./PlaybackControls";
|
|
import { HandleOverlay } from "./HandleOverlay";
|
|
import { useZoomPan } from "@/hooks/use-zoom-pan";
|
|
import { Minus, Plus } from "lucide-react";
|
|
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import { computeEditRange, frameRegion, type VersionHandles } from "@/lib/handles";
|
|
import type { CommentWithReplies } from "@/types";
|
|
|
|
export interface ReviewPlayerRef {
|
|
seekToFrame: (frame: number) => void;
|
|
play: () => void;
|
|
pause: () => void;
|
|
}
|
|
|
|
interface ReviewPlayerProps {
|
|
videoUrl: string;
|
|
versionId: string;
|
|
fps?: number;
|
|
comments?: CommentWithReplies[];
|
|
annotations?: unknown[];
|
|
className?: string;
|
|
onAddComment?: (frameNumber: number, timestamp: number) => void;
|
|
onAnnotationSaved?: (frameNumber: number) => void;
|
|
/** Override the default server action used to persist annotations (e.g. for client-token routes) */
|
|
saveAnnotationFn?: (data: { versionId: string; frameNumber: number; drawingData: unknown; color: string }) => Promise<void>;
|
|
/** Override the default server action used to create the companion comment */
|
|
addCommentFn?: (data: { versionId: string; frameNumber: number; timestamp: number; text: string }) => Promise<void>;
|
|
/** The version's handle metadata — frames past the cut get flagged. */
|
|
handles?: VersionHandles | null;
|
|
/** Start with playback kept to the edit (the client portal's default). */
|
|
defaultEditRangeOnly?: boolean;
|
|
/** High quality source (full res, high bitrate); enables the quality switch. */
|
|
hqVideoUrl?: string | null;
|
|
}
|
|
|
|
export type PlaybackQuality = "standard" | "high";
|
|
const QUALITY_KEY = "review:quality";
|
|
|
|
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
|
function ReviewPlayer(
|
|
{
|
|
videoUrl,
|
|
versionId,
|
|
fps = 24,
|
|
comments = [],
|
|
annotations,
|
|
className,
|
|
onAddComment,
|
|
onAnnotationSaved,
|
|
saveAnnotationFn,
|
|
addCommentFn,
|
|
handles,
|
|
defaultEditRangeOnly = false,
|
|
hqVideoUrl,
|
|
},
|
|
ref
|
|
) {
|
|
// Stabilise the annotations reference so AnnotationCanvas's sync effect
|
|
// only fires when the actual content changes, not on every parent re-render.
|
|
const stableAnnotations = useMemo(() => annotations ?? [], [annotations]);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const stageRef = useRef<HTMLDivElement>(null);
|
|
const zoomLayerRef = useRef<HTMLDivElement>(null);
|
|
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const openedAtCut = useRef(false);
|
|
|
|
// ── Quality ──────────────────────────────────────────────────────────────
|
|
// Standard is the review MP4; High is the full-res, high-bitrate encode of
|
|
// the delivery MOV. The choice is a per-browser preference, read after
|
|
// mount so the server render and the first client render agree.
|
|
const [quality, setQuality] = useState<PlaybackQuality>("standard");
|
|
const [switchingQuality, setSwitchingQuality] = useState(false);
|
|
// Where to pick up once the other source has loaded.
|
|
const resumeRef = useRef<{ time: number; playing: boolean } | null>(null);
|
|
useEffect(() => {
|
|
try {
|
|
if (localStorage.getItem(QUALITY_KEY) === "high") setQuality("high");
|
|
} catch {
|
|
// storage blocked — Standard it is
|
|
}
|
|
}, []);
|
|
const activeSrc = quality === "high" && hqVideoUrl ? hqVideoUrl : videoUrl;
|
|
|
|
const changeQuality = useCallback(
|
|
(next: PlaybackQuality) => {
|
|
if (next === quality) return;
|
|
const video = videoRef.current;
|
|
if (video && video.readyState > 0 && hqVideoUrl) {
|
|
resumeRef.current = { time: video.currentTime, playing: !video.paused };
|
|
setSwitchingQuality(true);
|
|
}
|
|
setQuality(next);
|
|
try {
|
|
localStorage.setItem(QUALITY_KEY, next);
|
|
} catch {
|
|
// preference just isn't remembered
|
|
}
|
|
},
|
|
[quality, hqVideoUrl]
|
|
);
|
|
const [isReversing, setIsReversing] = useState(false);
|
|
|
|
const {
|
|
isPlaying,
|
|
currentFrame,
|
|
isAnnotating,
|
|
showAnnotations,
|
|
aspectPresetId,
|
|
blankingMode,
|
|
setPlaying,
|
|
setCurrentFrame,
|
|
setCurrentTime,
|
|
setDuration,
|
|
setFps,
|
|
setTotalFrames,
|
|
totalFrames,
|
|
editRange,
|
|
editRangeOnly,
|
|
setEditRange,
|
|
setEditRangeOnly,
|
|
} = useReviewStore();
|
|
|
|
// ── Handles ──────────────────────────────────────────────────────────────
|
|
// Where the cut sits in this media. Recomputed from primitives so a parent
|
|
// re-render with a fresh `handles` object does not churn the store.
|
|
const handleHead = handles?.handleHead ?? null;
|
|
const handleTail = handles?.handleTail ?? null;
|
|
const pictureStartFrame = handles?.pictureStartFrame ?? null;
|
|
const handleFrameCount = handles?.frameCount ?? null;
|
|
useEffect(() => {
|
|
setEditRange(
|
|
computeEditRange(
|
|
{ handleHead, handleTail, pictureStartFrame, frameCount: handleFrameCount },
|
|
totalFrames
|
|
)
|
|
);
|
|
}, [handleHead, handleTail, pictureStartFrame, handleFrameCount, totalFrames, setEditRange]);
|
|
useEffect(() => () => setEditRange(null), [setEditRange]);
|
|
|
|
useEffect(() => {
|
|
setEditRangeOnly(defaultEditRangeOnly);
|
|
}, [defaultEditRangeOnly, setEditRangeOnly]);
|
|
|
|
// Read inside event handlers / intervals without re-binding them.
|
|
const keepToEdit = useRef<{ range: typeof editRange; on: boolean }>({ range: null, on: false });
|
|
keepToEdit.current = { range: editRange, on: editRangeOnly && !!editRange };
|
|
|
|
const zoom = useZoomPan(stageRef, zoomLayerRef, {
|
|
gesturesEnabled: !isAnnotating,
|
|
});
|
|
const { reset: resetZoom, zoomBy } = zoom;
|
|
|
|
// Initialize player config from props
|
|
useEffect(() => {
|
|
setFps(fps);
|
|
}, [fps, setFps]);
|
|
|
|
// Reset playback state when the source changes so the timeline and
|
|
// timecode don't briefly show the previous clip's values while the new
|
|
// video is loading its metadata.
|
|
useEffect(() => {
|
|
setCurrentFrame(0);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setTotalFrames(0);
|
|
resetZoom();
|
|
openedAtCut.current = false;
|
|
resumeRef.current = null;
|
|
setSwitchingQuality(false);
|
|
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]);
|
|
|
|
// ── Playback state sync ──────────────────────────────────────────────────
|
|
const handleTimeUpdate = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const frame = timeToFrame(video.currentTime, fps);
|
|
setCurrentFrame(frame);
|
|
setCurrentTime(video.currentTime);
|
|
}, [fps, setCurrentFrame, setCurrentTime]);
|
|
|
|
const handleLoadedMetadata = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const frames = durationToFrameCount(video.duration, fps);
|
|
setDuration(video.duration);
|
|
setTotalFrames(frames);
|
|
// A new source starts at 1x; keep the speed the reviewer chose.
|
|
video.playbackRate = useReviewStore.getState().playbackRate;
|
|
// Quality switch: carry on from the same frame, playing or not.
|
|
const resume = resumeRef.current;
|
|
resumeRef.current = null;
|
|
if (resume) {
|
|
video.currentTime = resume.time;
|
|
if (resume.playing) video.play().catch(() => {});
|
|
}
|
|
}, [fps, setDuration, setTotalFrames]);
|
|
|
|
// High quality failed to load (pruned, or a network hiccup): fall back to
|
|
// Standard rather than leave a black player. Not remembered as a choice.
|
|
const handleVideoError = useCallback(() => {
|
|
if (quality !== "high" || !hqVideoUrl) return;
|
|
setQuality("standard");
|
|
}, [quality, hqVideoUrl]);
|
|
|
|
/**
|
|
* Play — when keeping to the edit, from the cut in-point if the playhead
|
|
* is outside the cut (slate, a handle, or parked on the last cut frame).
|
|
*/
|
|
const startPlayback = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const { range, on } = keepToEdit.current;
|
|
if (on && range) {
|
|
const frame = timeToFrame(video.currentTime, fps);
|
|
if (frame < range.cutIn || frame >= range.tailStart - 1) {
|
|
video.currentTime = frameToTime(range.cutIn, fps);
|
|
}
|
|
}
|
|
// A pause() or power-saving pause before playback starts rejects this;
|
|
// that's expected, not an error.
|
|
video.play().catch(() => {});
|
|
}, [fps]);
|
|
|
|
// Keeping to the edit: stop on the last frame of the cut rather than
|
|
// playing into the tail handle. Checked every animation frame — the
|
|
// video's own timeupdate only fires ~4x a second.
|
|
useEffect(() => {
|
|
if (!isPlaying || !editRangeOnly || !editRange) return;
|
|
const lastCutFrame = editRange.tailStart - 1;
|
|
let raf = 0;
|
|
const tick = () => {
|
|
const video = videoRef.current;
|
|
if (!video || video.paused) return;
|
|
if (timeToFrame(video.currentTime, fps) >= editRange.tailStart) {
|
|
video.pause();
|
|
video.currentTime = frameToTime(lastCutFrame, fps);
|
|
return;
|
|
}
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
raf = requestAnimationFrame(tick);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [isPlaying, editRangeOnly, editRange, fps]);
|
|
|
|
// Keeping to the edit: open on the first frame of the cut rather than on
|
|
// the slate. Once per video, so it never moves the playhead afterwards.
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video || openedAtCut.current || !editRangeOnly || !editRange) return;
|
|
openedAtCut.current = true;
|
|
if (video.paused && timeToFrame(video.currentTime, fps) < editRange.cutIn) {
|
|
video.currentTime = frameToTime(editRange.cutIn, fps);
|
|
}
|
|
}, [editRange, editRangeOnly, fps]);
|
|
|
|
const handlePlay = useCallback(() => setPlaying(true), [setPlaying]);
|
|
const handlePause = useCallback(() => setPlaying(false), [setPlaying]);
|
|
|
|
// ── Exposed ref API ──────────────────────────────────────────────────────
|
|
useImperativeHandle(ref, () => ({
|
|
seekToFrame: (frame: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.currentTime = frameToTime(frame, fps);
|
|
if (!video.paused) video.pause();
|
|
},
|
|
play: () => startPlayback(),
|
|
pause: () => videoRef.current?.pause(),
|
|
}));
|
|
|
|
// ── Frame step functions ─────────────────────────────────────────────────
|
|
const stepFrame = useCallback(
|
|
(delta: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.pause();
|
|
stopReverse();
|
|
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta / fps));
|
|
video.currentTime = newTime;
|
|
},
|
|
[fps]
|
|
);
|
|
|
|
const stopReverse = useCallback(() => {
|
|
if (reverseIntervalRef.current) {
|
|
clearInterval(reverseIntervalRef.current);
|
|
reverseIntervalRef.current = null;
|
|
}
|
|
setIsReversing(false);
|
|
}, []);
|
|
|
|
const startReverse = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.pause();
|
|
setIsReversing(true);
|
|
reverseIntervalRef.current = setInterval(() => {
|
|
// Keeping to the edit, reverse stops at the cut in-point.
|
|
const { range, on } = keepToEdit.current;
|
|
const floor = on && range ? frameToTime(range.cutIn, fps) : 0;
|
|
if (!videoRef.current || videoRef.current.currentTime <= floor) {
|
|
stopReverse();
|
|
return;
|
|
}
|
|
videoRef.current.currentTime = Math.max(floor, videoRef.current.currentTime - 1 / fps);
|
|
}, 1000 / fps);
|
|
}, [fps, stopReverse]);
|
|
|
|
const togglePlayback = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
stopReverse();
|
|
if (video.paused) {
|
|
startPlayback();
|
|
} else {
|
|
video.pause();
|
|
}
|
|
}, [stopReverse, startPlayback]);
|
|
|
|
// ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (
|
|
target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable
|
|
)
|
|
return;
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case "j":
|
|
e.preventDefault();
|
|
if (isReversing) {
|
|
stopReverse();
|
|
} else {
|
|
startReverse();
|
|
}
|
|
break;
|
|
case "k":
|
|
e.preventDefault();
|
|
if (isReversing) {
|
|
stopReverse();
|
|
} else {
|
|
togglePlayback();
|
|
}
|
|
break;
|
|
case "l":
|
|
e.preventDefault();
|
|
stopReverse();
|
|
if (videoRef.current?.paused) startPlayback();
|
|
break;
|
|
case "arrowleft":
|
|
e.preventDefault();
|
|
stepFrame(-1);
|
|
break;
|
|
case "arrowright":
|
|
e.preventDefault();
|
|
stepFrame(1);
|
|
break;
|
|
case " ":
|
|
e.preventDefault();
|
|
stopReverse();
|
|
togglePlayback();
|
|
break;
|
|
case "f":
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
break;
|
|
case "=":
|
|
case "+":
|
|
e.preventDefault();
|
|
zoomBy(1.25);
|
|
break;
|
|
case "-":
|
|
case "_":
|
|
e.preventDefault();
|
|
zoomBy(0.8);
|
|
break;
|
|
case "0":
|
|
e.preventDefault();
|
|
resetZoom();
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, startPlayback, zoomBy, resetZoom]);
|
|
|
|
// Stop a running reverse only on unmount. This used to live in the
|
|
// cleanup above, which re-runs whenever isReversing changes — so starting
|
|
// reverse immediately stopped it, and J never did anything.
|
|
useEffect(() => stopReverse, [stopReverse]);
|
|
|
|
// ── Fullscreen ───────────────────────────────────────────────────────────
|
|
const toggleFullscreen = () => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
if (!document.fullscreenElement) {
|
|
el.requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
};
|
|
|
|
const handleSeek = useCallback(
|
|
(frame: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
stopReverse();
|
|
video.currentTime = frameToTime(frame, fps);
|
|
video.pause();
|
|
},
|
|
[fps, stopReverse]
|
|
);
|
|
|
|
const handleAddComment = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.pause();
|
|
onAddComment?.(currentFrame, video.currentTime);
|
|
}, [currentFrame, onAddComment]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"review-player-container flex flex-col bg-black select-none h-full",
|
|
className
|
|
)}
|
|
>
|
|
{/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating).
|
|
Wheel / pinch zooms, drag pans when zoomed; a drag never counts as a tap. */}
|
|
<div
|
|
ref={stageRef}
|
|
className="relative flex-1 min-h-0 overflow-hidden"
|
|
style={zoom.stageStyle}
|
|
{...zoom.stageHandlers}
|
|
onClick={
|
|
!isAnnotating
|
|
? () => {
|
|
if (!zoom.consumeDrag()) togglePlayback();
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{/* Zoomed as one layer so blanking and annotations stay pinned to the
|
|
picture — annotation coords are normalised from the canvas's
|
|
transformed bounding rect, so drawing while zoomed lands correctly. */}
|
|
<div
|
|
ref={zoomLayerRef}
|
|
className="absolute inset-0 origin-top-left will-change-transform"
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
src={activeSrc}
|
|
className="block w-full h-full object-contain"
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onLoadedMetadata={handleLoadedMetadata}
|
|
onPlay={handlePlay}
|
|
onPause={handlePause}
|
|
onCanPlay={() => setSwitchingQuality(false)}
|
|
onError={handleVideoError}
|
|
preload="metadata"
|
|
playsInline
|
|
/>
|
|
|
|
{/* Delivery blanking — under the annotation canvas, so a reviewer
|
|
can still draw over a blanked area if they need to */}
|
|
<AspectOverlay
|
|
presetId={aspectPresetId}
|
|
mode={blankingMode}
|
|
videoRef={videoRef}
|
|
/>
|
|
|
|
{/* Annotation canvas overlay */}
|
|
<AnnotationCanvas
|
|
versionId={versionId}
|
|
videoRef={videoRef}
|
|
frameNumber={currentFrame}
|
|
fps={fps}
|
|
isAnnotating={isAnnotating && !isPlaying}
|
|
showAnnotations={showAnnotations}
|
|
existingAnnotations={stableAnnotations}
|
|
onAnnotationSaved={onAnnotationSaved}
|
|
saveAnnotationFn={saveAnnotationFn as any}
|
|
addCommentFn={addCommentFn}
|
|
/>
|
|
</div>
|
|
|
|
{/* Handle flag — drawn over the player, never into the media */}
|
|
<HandleOverlay region={frameRegion(editRange, currentFrame)} />
|
|
|
|
{/* Zoom badge — only while zoomed */}
|
|
{zoom.zoomed && (
|
|
<div
|
|
className="absolute top-3 right-3 flex items-center gap-0.5 rounded bg-black/70 p-0.5 text-xs text-white"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1.5 hover:bg-white/15"
|
|
onClick={() => zoomBy(0.8)}
|
|
aria-label="Zoom out"
|
|
>
|
|
<Minus className="h-3.5 w-3.5" />
|
|
</button>
|
|
<span className="w-10 text-center font-mono tabular-nums">
|
|
{Math.round(zoom.scale * 100)}%
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1.5 hover:bg-white/15"
|
|
onClick={() => zoomBy(1.25)}
|
|
aria-label="Zoom in"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded px-2 py-1 hover:bg-white/15"
|
|
onClick={resetZoom}
|
|
>
|
|
Fit
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{switchingQuality && (
|
|
<div className="pointer-events-none absolute bottom-3 left-3 rounded bg-black/70 px-2 py-1 text-xs text-white">
|
|
Loading {quality === "high" ? "high" : "standard"} quality…
|
|
</div>
|
|
)}
|
|
|
|
{/* JKL hint overlay — shown briefly when reversing */}
|
|
{isReversing && (
|
|
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
|
|
◄◄ REVERSE
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Annotation Tools (visible when annotation mode is active) */}
|
|
<AnnotationTools />
|
|
|
|
{/* Frame Timeline */}
|
|
<FrameTimeline
|
|
fps={fps}
|
|
comments={comments}
|
|
annotations={annotations as { frameNumber: number }[]}
|
|
videoRef={videoRef}
|
|
onSeek={handleSeek}
|
|
/>
|
|
|
|
{/* Playback Controls */}
|
|
<PlaybackControls
|
|
videoRef={videoRef}
|
|
fps={fps}
|
|
isReversing={isReversing}
|
|
onStepBackward={() => stepFrame(-1)}
|
|
onStepForward={() => stepFrame(1)}
|
|
onReverse={isReversing ? stopReverse : startReverse}
|
|
onTogglePlay={togglePlayback}
|
|
onToggleFullscreen={toggleFullscreen}
|
|
onAddComment={handleAddComment}
|
|
quality={hqVideoUrl ? quality : null}
|
|
onQualityChange={changeQuality}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
);
|