mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
## Summary - render every agent/AI identity as a 30% squircle across desktop and mobile while keeping human avatars circular - propagate agent identity through message, thread, profile, reaction, member, DM, search, workflow, project, huddle, forum, pulse, and agent-management surfaces - preserve squircle geometry for fallbacks, focus/status treatments, add-agent controls, and overlapping avatar outlines (`calc(30% + 2px)` for the outer background) ### Related issue None found. This change was requested and visually reviewed in the originating Buzz thread. ### Testing - `just desktop-test` — 5,799 passed - `just mobile-test` — 2,008 passed - pre-push gates passed at `0d59d77b120dcb90aac2f918e422c11c9fa5353b`: desktop check, TypeScript typecheck, desktop full test suite, mobile format/analyze and full test suite, Rust tests, Tauri checks, and differential file-size gate - deterministic desktop visual sweep covered channel messages/thread summaries; thread, subthread, and sub-subthread depths; reactions and reactor popovers; hover/full profiles; added-to-channel activity; channel members/settings; agent library/team overlaps; agent creation; mention autocomplete; and DM header/sidebar/settings ### UI evidence The complete labeled visual matrix is available in the originating Buzz review thread. GitHub-hosted copies will be added in a follow-up PR comment using the repository screenshot script. --------- Signed-off-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz> Co-authored-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
2210 lines
77 KiB
TypeScript
2210 lines
77 KiB
TypeScript
import * as React from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
Maximize2,
|
|
MessageCircle,
|
|
PanelRight,
|
|
Pause,
|
|
Play,
|
|
SmilePlus,
|
|
Volume2,
|
|
VolumeX,
|
|
X,
|
|
} from "lucide-react";
|
|
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
|
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
|
import type { ChannelType } from "@/shared/api/types";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Checkbox } from "@/shared/ui/checkbox";
|
|
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
|
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
|
import { Spinner } from "./spinner";
|
|
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
|
|
import { useVideoContextMenu } from "./useVideoContextMenu";
|
|
import { useRegisterVideoReview } from "./VideoReviewNavigation";
|
|
import { VideoReviewPosterPreview } from "./VideoReviewPosterPreview";
|
|
import { parseVideoReviewTimecode } from "./videoReviewTimecode";
|
|
import {
|
|
VideoReviewTimecodeButton,
|
|
VIDEO_REVIEW_TIMECODE_ACCENT_CLASS,
|
|
} from "./VideoReviewTimecodeButton";
|
|
import {
|
|
getInlinePlaybackPosition,
|
|
getReviewPlaybackPosition,
|
|
isVideoReviewOpen,
|
|
saveInlinePlaybackPosition,
|
|
saveReviewPlaybackPosition,
|
|
setVideoReviewOpen,
|
|
} from "./videoPlayerState";
|
|
type VideoReviewReaction = {
|
|
emoji: string;
|
|
emojiUrl?: string;
|
|
count: number;
|
|
reactedByCurrentUser?: boolean;
|
|
users: Array<{
|
|
pubkey: string;
|
|
displayName: string;
|
|
avatarUrl: string | null;
|
|
}>;
|
|
};
|
|
export type VideoReviewComment = {
|
|
id: string;
|
|
author: string;
|
|
avatarUrl?: string | null;
|
|
isAgent?: boolean;
|
|
body: string;
|
|
createdAt: number;
|
|
time: string;
|
|
/** Parent event id — when it points at another comment, this is a reply. */
|
|
parentId?: string | null;
|
|
reactions?: VideoReviewReaction[];
|
|
};
|
|
export type VideoReviewContext = {
|
|
channelId?: string | null;
|
|
channelName?: string;
|
|
channelType?: ChannelType | null;
|
|
comments: VideoReviewComment[];
|
|
disabled?: boolean;
|
|
isSending?: boolean;
|
|
onSendComment?: (
|
|
content: string,
|
|
mentionPubkeys: string[],
|
|
mediaTags?: string[][],
|
|
/** Reply to this comment instead of the video message itself. */
|
|
parentEventId?: string,
|
|
) => Promise<void>;
|
|
onToggleCommentReaction?: (
|
|
comment: VideoReviewComment,
|
|
emoji: string,
|
|
remove: boolean,
|
|
) => Promise<void>;
|
|
profiles?: UserProfileLookup;
|
|
rootEventId?: string;
|
|
title?: string;
|
|
};
|
|
type VideoPlayerProps = {
|
|
src: string;
|
|
poster?: string;
|
|
aspectRatio?: number;
|
|
durationSeconds?: number;
|
|
reviewKey?: string;
|
|
reviewContext?: VideoReviewContext;
|
|
/**
|
|
* Original relay `/media/` URL for the right-click Download action, distinct
|
|
* from the possibly-proxied `src` the download command's SSRF check rejects.
|
|
* Omitted for non-relay sources, which hide the Download item.
|
|
*/
|
|
downloadUrl?: string;
|
|
/** imeta `filename`, used as the save-dialog name. */
|
|
filename?: string;
|
|
};
|
|
type TimecodedComment = {
|
|
comment: VideoReviewComment;
|
|
seconds: number | null;
|
|
timecode: string | null;
|
|
text: string;
|
|
};
|
|
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
|
|
const DEFAULT_PLAYBACK_SPEED = 1;
|
|
const INLINE_SPEED_CONTROL_MIN_WIDTH = 220;
|
|
const PLAYBACK_SPEEDS = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5, 0.25];
|
|
|
|
/**
|
|
* Frosted-glass backing layer for floating media controls. The parent must
|
|
* be `relative isolate` so the `-z-10` layer sits behind the control content
|
|
* but above the video underneath.
|
|
*/
|
|
function GlassSurface({ className }: { className?: string }) {
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"pointer-events-none absolute inset-0 -z-10 rounded-[inherit] border border-white/10 bg-black/35 backdrop-blur-xl backdrop-saturate-150",
|
|
className,
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function formatTimecode(
|
|
seconds: number,
|
|
options: { fractionalDigits?: number; trimZeroFraction?: boolean } = {},
|
|
): string {
|
|
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
|
const fractionalDigits = Math.max(0, options.fractionalDigits ?? 0);
|
|
const precisionFactor = 10 ** fractionalDigits;
|
|
const totalTicks =
|
|
fractionalDigits > 0
|
|
? Math.round(safeSeconds * precisionFactor)
|
|
: Math.floor(safeSeconds);
|
|
const totalSeconds =
|
|
fractionalDigits > 0
|
|
? Math.floor(totalTicks / precisionFactor)
|
|
: totalTicks;
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const remainingSeconds = totalSeconds % 60;
|
|
const paddedSeconds = String(remainingSeconds).padStart(2, "0");
|
|
const fraction = fractionalDigits > 0 ? totalTicks % precisionFactor : 0;
|
|
|
|
const baseTimecode =
|
|
hours > 0
|
|
? `${hours}:${String(minutes).padStart(2, "0")}:${paddedSeconds}`
|
|
: `${String(minutes).padStart(2, "0")}:${paddedSeconds}`;
|
|
|
|
if (
|
|
fractionalDigits <= 0 ||
|
|
(options.trimZeroFraction === true && fraction === 0)
|
|
) {
|
|
return baseTimecode;
|
|
}
|
|
|
|
return `${baseTimecode}.${String(fraction).padStart(fractionalDigits, "0")}`;
|
|
}
|
|
|
|
function formatCommentTimecode(seconds: number): string {
|
|
return formatTimecode(seconds, {
|
|
fractionalDigits: 1,
|
|
trimZeroFraction: true,
|
|
});
|
|
}
|
|
|
|
function formatPlaybackSpeed(speed: number): string {
|
|
return `${speed}x`;
|
|
}
|
|
|
|
function isPlaybackSpeedOption(speed: number): boolean {
|
|
return PLAYBACK_SPEEDS.some((option) => option === speed);
|
|
}
|
|
|
|
function parseTimecodedComment(comment: VideoReviewComment): TimecodedComment {
|
|
const parsed = parseVideoReviewTimecode(comment.body);
|
|
return parsed
|
|
? { comment, ...parsed }
|
|
: { comment, seconds: null, text: comment.body.trim(), timecode: null };
|
|
}
|
|
|
|
function sortTimecodedComments(
|
|
left: TimecodedComment,
|
|
right: TimecodedComment,
|
|
): number {
|
|
if (left.seconds !== null && right.seconds !== null) {
|
|
if (left.seconds !== right.seconds) return left.seconds - right.seconds;
|
|
return left.comment.createdAt - right.comment.createdAt;
|
|
}
|
|
|
|
if (left.seconds !== null) return -1;
|
|
if (right.seconds !== null) return 1;
|
|
return left.comment.createdAt - right.comment.createdAt;
|
|
}
|
|
|
|
function fileNameFromUrl(src: string): string {
|
|
const withoutQuery = src.split("?")[0];
|
|
const tail = withoutQuery.split("/").filter(Boolean).pop();
|
|
return tail ? decodeURIComponent(tail) : "Video";
|
|
}
|
|
|
|
function getInlineSurfaceWidth(aspectRatio: number): number {
|
|
return Math.round(Math.min(384, Math.max(160, aspectRatio * 180)));
|
|
}
|
|
|
|
/**
|
|
* Drives a `requestAnimationFrame` loop that mirrors `video.currentTime` into
|
|
* React state while playback is active, so progress bars move smoothly
|
|
* between the browser's coarse `timeupdate` events.
|
|
*/
|
|
function useSmoothPlaybackTime(
|
|
videoRef: React.RefObject<HTMLVideoElement | null>,
|
|
isActive: boolean,
|
|
onTime: (seconds: number) => void,
|
|
) {
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
|
|
let frameId = 0;
|
|
const tick = () => {
|
|
const video = videoRef.current;
|
|
if (video) {
|
|
onTime(video.currentTime);
|
|
}
|
|
frameId = window.requestAnimationFrame(tick);
|
|
};
|
|
|
|
frameId = window.requestAnimationFrame(tick);
|
|
return () => window.cancelAnimationFrame(frameId);
|
|
}, [isActive, onTime, videoRef]);
|
|
}
|
|
|
|
/**
|
|
* Serializes seeks against the browser's seek pipeline: while a seek is in
|
|
* flight only the latest requested position is remembered, and it is applied
|
|
* when `seeked` fires. Without this, a slow drag floods the element with
|
|
* seeks and the displayed frame freezes until the drag ends — with it, the
|
|
* video scrubs frame-by-frame under the pointer.
|
|
*/
|
|
function useThrottledVideoSeek(
|
|
videoRef: React.RefObject<HTMLVideoElement | null>,
|
|
) {
|
|
const isSeekingRef = React.useRef(false);
|
|
const queuedSeekRef = React.useRef<number | null>(null);
|
|
|
|
const requestSeek = React.useCallback(
|
|
(seconds: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
// `video.seeking` is the ground truth: if the element is not actually
|
|
// mid-seek, a stale in-flight flag (a seek interrupted by an error, or
|
|
// a skipped `seeked` event) must not wedge the scrubber.
|
|
if (isSeekingRef.current && video.seeking) {
|
|
queuedSeekRef.current = seconds;
|
|
return;
|
|
}
|
|
isSeekingRef.current = true;
|
|
video.currentTime = seconds;
|
|
},
|
|
[videoRef],
|
|
);
|
|
|
|
const handleSeeked = React.useCallback(() => {
|
|
isSeekingRef.current = false;
|
|
const queued = queuedSeekRef.current;
|
|
if (queued === null) return;
|
|
queuedSeekRef.current = null;
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
isSeekingRef.current = true;
|
|
video.currentTime = queued;
|
|
}, [videoRef]);
|
|
|
|
return { handleSeeked, requestSeek };
|
|
}
|
|
|
|
/**
|
|
* Ambient-light glow behind the review video: a small canvas continuously
|
|
* redraws downscaled video frames at ~15fps, then a heavy blur + saturation
|
|
* smears them into a soft colour wash (the same technique as
|
|
* blog.maximeheckel.com's media player glow).
|
|
*/
|
|
function VideoGlow({
|
|
videoRef,
|
|
}: {
|
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
}) {
|
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
const context = canvas?.getContext("2d");
|
|
if (!canvas || !context) return;
|
|
|
|
const frameInterval = 1000 / 15;
|
|
let frameId = 0;
|
|
let lastDrawTime = 0;
|
|
|
|
const draw = (timestamp: number) => {
|
|
const video = videoRef.current;
|
|
if (
|
|
video &&
|
|
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
|
timestamp - lastDrawTime >= frameInterval
|
|
) {
|
|
try {
|
|
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
} catch {
|
|
// Frame not decodable yet — skip and retry on a later frame.
|
|
}
|
|
lastDrawTime = timestamp;
|
|
}
|
|
frameId = window.requestAnimationFrame(draw);
|
|
};
|
|
|
|
frameId = window.requestAnimationFrame(draw);
|
|
return () => window.cancelAnimationFrame(frameId);
|
|
}, [videoRef]);
|
|
|
|
return (
|
|
<canvas
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-1/2 top-1/2 z-0 h-[95%] w-[95%] -translate-x-1/2 -translate-y-1/2 scale-125"
|
|
data-testid="video-review-glow"
|
|
height={144}
|
|
ref={canvasRef}
|
|
style={{
|
|
filter: "blur(80px) saturate(1.25)",
|
|
// Radial falloff so the blurred frame never shows a hard rectangular
|
|
// edge when the glow extends past the letterboxed video.
|
|
maskImage: "radial-gradient(closest-side, black 65%, transparent 100%)",
|
|
WebkitMaskImage:
|
|
"radial-gradient(closest-side, black 65%, transparent 100%)",
|
|
}}
|
|
tabIndex={-1}
|
|
width={256}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function VideoScrubber({
|
|
ariaLabel,
|
|
className,
|
|
currentTime,
|
|
duration,
|
|
markers,
|
|
onSeek,
|
|
showHoverPreview = false,
|
|
testIdPrefix,
|
|
}: {
|
|
ariaLabel: string;
|
|
className?: string;
|
|
currentTime: number;
|
|
duration: number;
|
|
markers?: React.ReactNode;
|
|
onSeek: (seconds: number) => void;
|
|
showHoverPreview?: boolean;
|
|
testIdPrefix: string;
|
|
}) {
|
|
const sliderRef = React.useRef<HTMLDivElement>(null);
|
|
const draggingRef = React.useRef(false);
|
|
const [hoverRatio, setHoverRatio] = React.useState<number | null>(null);
|
|
const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0;
|
|
const progressPercent = progress * 100;
|
|
|
|
const ratioFromClientX = React.useCallback(
|
|
(target: HTMLElement, clientX: number) => {
|
|
const rect = target.getBoundingClientRect();
|
|
if (rect.width <= 0) return null;
|
|
return Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const seekFromClientX = React.useCallback(
|
|
(target: HTMLElement, clientX: number) => {
|
|
if (duration <= 0) return;
|
|
const ratio = ratioFromClientX(target, clientX);
|
|
if (ratio === null) return;
|
|
onSeek(Math.round(ratio * duration * 1000) / 1000);
|
|
},
|
|
[duration, onSeek, ratioFromClientX],
|
|
);
|
|
|
|
const handlePointerDown = React.useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (duration <= 0) return;
|
|
if (
|
|
event.target instanceof Element &&
|
|
event.target.closest("[data-video-review-marker]")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
draggingRef.current = true;
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
sliderRef.current?.focus({ preventScroll: true });
|
|
seekFromClientX(event.currentTarget, event.clientX);
|
|
},
|
|
[duration, seekFromClientX],
|
|
);
|
|
|
|
const handlePointerMove = React.useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (showHoverPreview && event.pointerType === "mouse") {
|
|
setHoverRatio(ratioFromClientX(event.currentTarget, event.clientX));
|
|
}
|
|
if (!draggingRef.current) return;
|
|
seekFromClientX(event.currentTarget, event.clientX);
|
|
},
|
|
[ratioFromClientX, seekFromClientX, showHoverPreview],
|
|
);
|
|
|
|
const handlePointerEnd = React.useCallback(
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!draggingRef.current) return;
|
|
draggingRef.current = false;
|
|
seekFromClientX(event.currentTarget, event.clientX);
|
|
},
|
|
[seekFromClientX],
|
|
);
|
|
|
|
const handlePointerLeave = React.useCallback(() => {
|
|
setHoverRatio(null);
|
|
}, []);
|
|
|
|
const handleKeyDown = React.useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (duration <= 0) return;
|
|
|
|
const smallStep = event.shiftKey ? 10 : 1;
|
|
const largeStep = Math.max(5, duration / 10);
|
|
let nextSeconds: number | null = null;
|
|
|
|
switch (event.key) {
|
|
case "ArrowLeft":
|
|
case "ArrowDown":
|
|
nextSeconds = currentTime - smallStep;
|
|
break;
|
|
case "ArrowRight":
|
|
case "ArrowUp":
|
|
nextSeconds = currentTime + smallStep;
|
|
break;
|
|
case "PageDown":
|
|
nextSeconds = currentTime - largeStep;
|
|
break;
|
|
case "PageUp":
|
|
nextSeconds = currentTime + largeStep;
|
|
break;
|
|
case "Home":
|
|
nextSeconds = 0;
|
|
break;
|
|
case "End":
|
|
nextSeconds = duration;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (nextSeconds === null) return;
|
|
event.preventDefault();
|
|
onSeek(Math.min(Math.max(nextSeconds, 0), duration));
|
|
},
|
|
[currentTime, duration, onSeek],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn("relative touch-none select-none", className)}
|
|
onPointerCancel={handlePointerEnd}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerLeave={handlePointerLeave}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerEnd}
|
|
>
|
|
<div
|
|
aria-label={ariaLabel}
|
|
aria-valuemax={duration}
|
|
aria-valuemin={0}
|
|
aria-valuenow={duration > 0 ? Math.min(currentTime, duration) : 0}
|
|
aria-valuetext={`${formatTimecode(currentTime)} / ${formatTimecode(duration)}`}
|
|
className={cn(
|
|
"absolute inset-0 z-10 rounded-md outline-hidden focus-visible:ring-2 focus-visible:ring-white/60",
|
|
duration > 0 ? "cursor-pointer" : "cursor-default",
|
|
)}
|
|
data-testid={`${testIdPrefix}-timeline`}
|
|
ref={sliderRef}
|
|
role="slider"
|
|
tabIndex={duration > 0 ? 0 : -1}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
<div
|
|
className="pointer-events-none absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-white/[0.16]"
|
|
data-testid={`${testIdPrefix}-progress-track`}
|
|
>
|
|
<div
|
|
className="h-full rounded-full bg-white"
|
|
data-testid={`${testIdPrefix}-progress-fill`}
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
{duration > 0 ? (
|
|
<div
|
|
className="pointer-events-none absolute top-1/2 z-20 h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_0_0.5px_rgba(0,0,0,0.35)]"
|
|
data-testid={`${testIdPrefix}-progress-thumb`}
|
|
style={{ left: `${progressPercent}%` }}
|
|
/>
|
|
) : null}
|
|
{showHoverPreview && hoverRatio !== null && duration > 0 ? (
|
|
<div
|
|
className="pointer-events-none absolute top-1/2 z-30 -translate-x-1/2 -translate-y-1/2"
|
|
data-testid={`${testIdPrefix}-hover-cursor`}
|
|
style={{ left: `${hoverRatio * 100}%` }}
|
|
>
|
|
<div className="h-5 w-0.5 rounded-full bg-white shadow-[0_0_0_0.5px_rgba(0,0,0,0.25)]" />
|
|
<span className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-2xs font-medium tabular-nums text-white/90">
|
|
{formatTimecode(hoverRatio * duration)} / {formatTimecode(duration)}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{markers}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VolumeControl({
|
|
expanded = false,
|
|
muted,
|
|
onToggleMute,
|
|
onVolumeChange,
|
|
volume,
|
|
}: {
|
|
expanded?: boolean;
|
|
muted: boolean;
|
|
onToggleMute: () => void;
|
|
onVolumeChange: (volume: number) => void;
|
|
volume: number;
|
|
}) {
|
|
const isSilent = muted || volume <= 0;
|
|
const fillPercent = (muted ? 0 : volume) * 100;
|
|
return (
|
|
<div className="group/volume flex shrink-0 items-center">
|
|
<button
|
|
aria-label={isSilent ? "Unmute" : "Mute"}
|
|
className="flex h-7 w-7 items-center justify-center rounded-md text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary"
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleMute();
|
|
}}
|
|
>
|
|
{isSilent ? (
|
|
<VolumeX className="pointer-events-none h-4 w-4" />
|
|
) : (
|
|
<Volume2 className="pointer-events-none h-4 w-4" />
|
|
)}
|
|
</button>
|
|
<input
|
|
aria-label="Volume"
|
|
className={cn(
|
|
"video-volume-slider h-3 cursor-pointer transition-all duration-200",
|
|
expanded
|
|
? "ml-1 w-16"
|
|
: "pointer-events-none w-0 opacity-0 focus-visible:pointer-events-auto focus-visible:ml-1 focus-visible:w-16 focus-visible:opacity-100 group-hover/volume:pointer-events-auto group-hover/volume:ml-1 group-hover/volume:w-16 group-hover/volume:opacity-100",
|
|
)}
|
|
max={1}
|
|
min={0}
|
|
step={0.05}
|
|
style={
|
|
{
|
|
"--video-volume-fill": `${fillPercent}%`,
|
|
} as React.CSSProperties
|
|
}
|
|
type="range"
|
|
value={muted ? 0 : volume}
|
|
onChange={(event) => onVolumeChange(Number(event.target.value))}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlaybackSpeedControl({
|
|
playbackSpeed,
|
|
onPlaybackSpeedChange,
|
|
size = "inline",
|
|
testId,
|
|
}: {
|
|
playbackSpeed: number;
|
|
onPlaybackSpeedChange: (speed: number) => void;
|
|
size?: "inline" | "review";
|
|
testId: string;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const label = formatPlaybackSpeed(playbackSpeed);
|
|
const triggerSizeClass =
|
|
size === "review"
|
|
? "h-8 min-w-11 rounded-lg px-2 text-xs"
|
|
: "h-7 min-w-10 rounded-md px-1.5 text-2xs";
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-label={`Playback speed: ${label}`}
|
|
className={cn(
|
|
"flex shrink-0 items-center justify-center font-semibold tabular-nums text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-white/60",
|
|
triggerSizeClass,
|
|
open && "bg-white/15",
|
|
)}
|
|
data-testid={testId}
|
|
type="button"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
{label}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="end"
|
|
className="w-28 border-white/10 bg-black/85 p-1 text-white backdrop-blur-xl"
|
|
data-testid={`${testId}-menu`}
|
|
side="top"
|
|
sideOffset={8}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="px-2 pb-1 pt-1 text-2xs font-medium text-white/55">
|
|
Speed
|
|
</div>
|
|
<div className="grid gap-0.5">
|
|
{PLAYBACK_SPEEDS.map((speed) => {
|
|
const speedLabel = formatPlaybackSpeed(speed);
|
|
const selected = speed === playbackSpeed;
|
|
return (
|
|
<button
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
"flex h-8 w-full items-center justify-between rounded-lg px-2 text-left text-xs font-medium tabular-nums text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-white/60",
|
|
selected && "bg-white/15",
|
|
)}
|
|
key={speed}
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onPlaybackSpeedChange(speed);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<span>{speedLabel}</span>
|
|
{selected ? <Check className="h-3.5 w-3.5" /> : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
export function VideoPlayer({
|
|
src,
|
|
poster,
|
|
aspectRatio,
|
|
durationSeconds,
|
|
reviewKey,
|
|
reviewContext,
|
|
downloadUrl,
|
|
filename,
|
|
}: VideoPlayerProps) {
|
|
const persistedReviewKey = reviewKey ?? src;
|
|
const videoRef = React.useRef<HTMLVideoElement>(null);
|
|
const inlineSurfaceRef = React.useRef<HTMLDivElement | null>(null);
|
|
const reviewVideoRef = React.useRef<HTMLVideoElement>(null);
|
|
useSmoothCorners(inlineSurfaceRef);
|
|
const [started, setStarted] = React.useState(false);
|
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
const [isBuffering, setIsBuffering] = React.useState(false);
|
|
const [hasError, setHasError] = React.useState(false);
|
|
const [currentTime, setCurrentTimeState] = React.useState(
|
|
() => getInlinePlaybackPosition(persistedReviewKey) ?? 0,
|
|
);
|
|
const currentTimeRef = React.useRef(currentTime);
|
|
const [duration, setDuration] = React.useState(durationSeconds ?? 0);
|
|
const [volume, setVolume] = React.useState(1);
|
|
const [muted, setMuted] = React.useState(false);
|
|
const [playbackSpeed, setPlaybackSpeed] = React.useState(
|
|
DEFAULT_PLAYBACK_SPEED,
|
|
);
|
|
// Cache-seeded so a row evicted by the virtualized timeline remounts at the
|
|
// ratio learned on first metadata load, not the 16/9 fallback.
|
|
const [naturalAspectRatio, learnNaturalAspectRatio] =
|
|
useNaturalVideoAspectRatio(src);
|
|
const [reviewOpen, setReviewOpenState] = React.useState(() =>
|
|
isVideoReviewOpen(persistedReviewKey),
|
|
);
|
|
const [reviewCurrentTime, setReviewCurrentTimeState] = React.useState(
|
|
() => getReviewPlaybackPosition(persistedReviewKey) ?? 0,
|
|
);
|
|
const [pendingSeekSeconds, setPendingSeekSeconds] = React.useState<
|
|
number | null
|
|
>(null);
|
|
const [inlineRenderedWidth, setInlineRenderedWidth] = React.useState<
|
|
number | null
|
|
>(null);
|
|
|
|
const { onContextMenu: onSurfaceContextMenu, menu: videoContextMenu } =
|
|
useVideoContextMenu(src, downloadUrl, filename);
|
|
|
|
React.useEffect(() => {
|
|
// The imeta duration seeds the timeline before metadata loads; metadata
|
|
// (set via onLoadedMetadata below) stays authoritative once known.
|
|
if ((durationSeconds ?? 0) > 0) {
|
|
setDuration((current) =>
|
|
current > 0 ? current : (durationSeconds ?? 0),
|
|
);
|
|
}
|
|
}, [durationSeconds]);
|
|
|
|
React.useEffect(() => {
|
|
const savedCurrentTime = getInlinePlaybackPosition(persistedReviewKey) ?? 0;
|
|
currentTimeRef.current = savedCurrentTime;
|
|
setStarted(false);
|
|
setCurrentTimeState(savedCurrentTime);
|
|
setIsPlaying(false);
|
|
setIsBuffering(false);
|
|
setHasError(false);
|
|
setPlaybackSpeed(DEFAULT_PLAYBACK_SPEED);
|
|
setReviewOpenState(isVideoReviewOpen(persistedReviewKey));
|
|
setReviewCurrentTimeState(
|
|
getReviewPlaybackPosition(persistedReviewKey) ?? 0,
|
|
);
|
|
}, [persistedReviewKey]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
const video = videoRef.current;
|
|
if (!video || !Number.isFinite(video.currentTime)) {
|
|
return;
|
|
}
|
|
saveInlinePlaybackPosition(
|
|
persistedReviewKey,
|
|
Math.max(video.currentTime, currentTimeRef.current),
|
|
{ ignoreResetToZero: true },
|
|
);
|
|
};
|
|
}, [persistedReviewKey]);
|
|
|
|
const setCurrentTime = React.useCallback(
|
|
(seconds: number) => {
|
|
const nextSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
|
currentTimeRef.current = nextSeconds;
|
|
saveInlinePlaybackPosition(persistedReviewKey, nextSeconds);
|
|
setCurrentTimeState(nextSeconds);
|
|
},
|
|
[persistedReviewKey],
|
|
);
|
|
|
|
const setReviewCurrentTime = React.useCallback(
|
|
(seconds: number) => {
|
|
const nextSeconds = saveReviewPlaybackPosition(
|
|
persistedReviewKey,
|
|
seconds,
|
|
);
|
|
setReviewCurrentTimeState(nextSeconds);
|
|
},
|
|
[persistedReviewKey],
|
|
);
|
|
|
|
const setReviewOpen = React.useCallback(
|
|
(open: boolean) => {
|
|
setVideoReviewOpen(persistedReviewKey, open);
|
|
setReviewOpenState(open);
|
|
},
|
|
[persistedReviewKey],
|
|
);
|
|
|
|
const applyPlaybackSpeed = React.useCallback(
|
|
(video: HTMLVideoElement | null) => {
|
|
if (!video) return;
|
|
video.playbackRate = playbackSpeed;
|
|
},
|
|
[playbackSpeed],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
applyPlaybackSpeed(videoRef.current);
|
|
applyPlaybackSpeed(reviewVideoRef.current);
|
|
}, [applyPlaybackSpeed]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
const element = inlineSurfaceRef.current;
|
|
if (!element) {
|
|
setInlineRenderedWidth(null);
|
|
return;
|
|
}
|
|
|
|
const updateWidth = () => {
|
|
const width = element.getBoundingClientRect().width;
|
|
setInlineRenderedWidth((currentWidth) =>
|
|
currentWidth !== null && Math.abs(currentWidth - width) < 0.5
|
|
? currentWidth
|
|
: width,
|
|
);
|
|
};
|
|
|
|
updateWidth();
|
|
|
|
if (typeof ResizeObserver === "undefined") {
|
|
window.addEventListener("resize", updateWidth);
|
|
return () => window.removeEventListener("resize", updateWidth);
|
|
}
|
|
|
|
const observer = new ResizeObserver(updateWidth);
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
const handlePlaybackSpeedChange = React.useCallback((speed: number) => {
|
|
if (isPlaybackSpeedOption(speed)) {
|
|
setPlaybackSpeed(speed);
|
|
}
|
|
}, []);
|
|
|
|
useSmoothPlaybackTime(videoRef, isPlaying && !reviewOpen, setCurrentTime);
|
|
const inlineSeek = useThrottledVideoSeek(videoRef);
|
|
|
|
const handleMediaDuration = React.useCallback((nextDuration: number) => {
|
|
if (Number.isFinite(nextDuration) && nextDuration > 0) {
|
|
setDuration(nextDuration);
|
|
}
|
|
}, []);
|
|
|
|
const handleTogglePlay = React.useCallback(
|
|
(event?: React.SyntheticEvent) => {
|
|
event?.stopPropagation();
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
if (video.paused || video.ended) {
|
|
setStarted(true);
|
|
if (hasError) {
|
|
setHasError(false);
|
|
video.load();
|
|
}
|
|
void video.play().catch((error) => {
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
return;
|
|
}
|
|
setHasError(true);
|
|
setIsPlaying(false);
|
|
setIsBuffering(false);
|
|
});
|
|
} else {
|
|
video.pause();
|
|
}
|
|
},
|
|
[hasError],
|
|
);
|
|
|
|
const handleInlineSeek = React.useCallback(
|
|
(seconds: number) => {
|
|
const bounded = Math.min(
|
|
Math.max(Number.isFinite(seconds) ? seconds : 0, 0),
|
|
duration > 0 ? duration : Number.POSITIVE_INFINITY,
|
|
);
|
|
inlineSeek.requestSeek(bounded);
|
|
setCurrentTime(bounded);
|
|
},
|
|
[duration, inlineSeek, setCurrentTime],
|
|
);
|
|
|
|
const handleToggleMute = React.useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.muted = !video.muted;
|
|
}, []);
|
|
|
|
const handleVolumeChange = React.useCallback((value: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.volume = Math.min(Math.max(value, 0), 1);
|
|
video.muted = value <= 0;
|
|
}, []);
|
|
|
|
const openReviewAt = React.useCallback(
|
|
(seconds: number) => {
|
|
const video = videoRef.current;
|
|
video?.pause();
|
|
const safeSeconds = Number.isFinite(seconds) ? Math.max(seconds, 0) : 0;
|
|
const nextSeconds =
|
|
duration > 0 ? Math.min(safeSeconds, duration) : safeSeconds;
|
|
setPendingSeekSeconds(nextSeconds);
|
|
setReviewCurrentTime(nextSeconds);
|
|
setReviewOpen(true);
|
|
},
|
|
[duration, setReviewCurrentTime, setReviewOpen],
|
|
);
|
|
useRegisterVideoReview(reviewContext, persistedReviewKey, openReviewAt);
|
|
|
|
const handleOpenReview = React.useCallback(
|
|
(event?: React.SyntheticEvent) => {
|
|
event?.stopPropagation();
|
|
const video = videoRef.current;
|
|
const startTime =
|
|
video && Number.isFinite(video.currentTime)
|
|
? video.currentTime
|
|
: currentTime;
|
|
openReviewAt(startTime);
|
|
},
|
|
[currentTime, openReviewAt],
|
|
);
|
|
|
|
const handleReviewOpenChange = React.useCallback(
|
|
(open: boolean) => {
|
|
if (!open) {
|
|
// Hand the review position back to the inline player so playback
|
|
// resumes where the review left off.
|
|
const video = videoRef.current;
|
|
const reviewSeconds = getReviewPlaybackPosition(persistedReviewKey);
|
|
if (
|
|
video &&
|
|
reviewSeconds !== undefined &&
|
|
Number.isFinite(reviewSeconds)
|
|
) {
|
|
video.currentTime = reviewSeconds;
|
|
setCurrentTime(reviewSeconds);
|
|
if (reviewSeconds > 0) {
|
|
setStarted(true);
|
|
}
|
|
}
|
|
}
|
|
setReviewOpen(open);
|
|
},
|
|
[persistedReviewKey, setCurrentTime, setReviewOpen],
|
|
);
|
|
|
|
const handlePendingSeekConsumed = React.useCallback(() => {
|
|
setPendingSeekSeconds(null);
|
|
}, []);
|
|
|
|
const timecodedComments = React.useMemo(
|
|
() =>
|
|
(reviewContext?.comments ?? [])
|
|
.map(parseTimecodedComment)
|
|
.sort(sortTimecodedComments),
|
|
[reviewContext?.comments],
|
|
);
|
|
const inlineAspectRatio = aspectRatio ?? naturalAspectRatio ?? 16 / 9;
|
|
const inlineSurfaceWidth = getInlineSurfaceWidth(inlineAspectRatio);
|
|
const showInlineSpeedControl =
|
|
inlineRenderedWidth !== null &&
|
|
inlineRenderedWidth >= INLINE_SPEED_CONTROL_MIN_WIDTH;
|
|
const inlineSurfaceStyle: React.CSSProperties = {
|
|
aspectRatio: String(inlineAspectRatio),
|
|
maxHeight: 256,
|
|
width: inlineSurfaceWidth,
|
|
};
|
|
const hideInlineControls = !started || isPlaying;
|
|
const inlineControlsRevealClass = cn(
|
|
"transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
|
|
hideInlineControls &&
|
|
"opacity-0 group-focus-within/inline-controls:opacity-100 group-hover/video:opacity-100",
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="relative mt-3 inline-block max-w-full align-top"
|
|
data-testid="video-player"
|
|
>
|
|
<div
|
|
ref={inlineSurfaceRef}
|
|
className="group/video relative isolate max-w-full overflow-hidden rounded-2xl border border-border/70 bg-black"
|
|
style={inlineSurfaceStyle}
|
|
onContextMenuCapture={onSurfaceContextMenu}
|
|
>
|
|
{/* Cover, not contain: when the surface's max-height clamp breaks
|
|
the aspect match (tall videos), fill the tile and crop instead
|
|
of letterboxing — the review overlay still shows the full
|
|
frame. */}
|
|
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
|
|
<video
|
|
ref={videoRef}
|
|
className="h-full w-full object-cover"
|
|
playsInline
|
|
poster={poster}
|
|
preload="metadata"
|
|
src={src}
|
|
onClick={started ? handleTogglePlay : undefined}
|
|
onDurationChange={(event) =>
|
|
handleMediaDuration(event.currentTarget.duration)
|
|
}
|
|
onEnded={() => setIsPlaying(false)}
|
|
onError={() => {
|
|
if (started) {
|
|
setHasError(true);
|
|
setIsPlaying(false);
|
|
setIsBuffering(false);
|
|
}
|
|
}}
|
|
onLoadedMetadata={(event) => {
|
|
const {
|
|
duration: mediaDuration,
|
|
videoHeight,
|
|
videoWidth,
|
|
} = event.currentTarget;
|
|
applyPlaybackSpeed(event.currentTarget);
|
|
handleMediaDuration(mediaDuration);
|
|
const savedSeconds =
|
|
getInlinePlaybackPosition(persistedReviewKey);
|
|
if (
|
|
savedSeconds !== undefined &&
|
|
savedSeconds > 0 &&
|
|
Number.isFinite(savedSeconds)
|
|
) {
|
|
const restoredSeconds = Math.min(
|
|
savedSeconds,
|
|
Number.isFinite(mediaDuration) && mediaDuration > 0
|
|
? mediaDuration
|
|
: savedSeconds,
|
|
);
|
|
event.currentTarget.currentTime = restoredSeconds;
|
|
setCurrentTime(restoredSeconds);
|
|
}
|
|
learnNaturalAspectRatio(videoWidth, videoHeight);
|
|
}}
|
|
onPause={() => {
|
|
setIsPlaying(false);
|
|
setIsBuffering(false);
|
|
}}
|
|
onPlay={() => {
|
|
setStarted(true);
|
|
setIsPlaying(true);
|
|
}}
|
|
onPlaying={() => setIsBuffering(false)}
|
|
onSeeked={inlineSeek.handleSeeked}
|
|
onTimeUpdate={(event) =>
|
|
setCurrentTime(event.currentTarget.currentTime)
|
|
}
|
|
onVolumeChange={(event) => {
|
|
setVolume(event.currentTarget.volume);
|
|
setMuted(event.currentTarget.muted);
|
|
}}
|
|
onWaiting={() => setIsBuffering(true)}
|
|
/>
|
|
{!hasError && !isBuffering ? (
|
|
<button
|
|
aria-label={isPlaying ? "Pause video" : "Play video"}
|
|
className={cn(
|
|
"absolute inset-0 flex cursor-pointer items-center justify-center opacity-100 outline-hidden transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-2 focus-visible:ring-white/60 motion-reduce:transition-none",
|
|
started &&
|
|
isPlaying &&
|
|
"opacity-0 group-hover/video:opacity-100 focus-visible:opacity-100",
|
|
)}
|
|
data-testid="video-inline-center-playback"
|
|
type="button"
|
|
onClick={handleTogglePlay}
|
|
>
|
|
<span
|
|
className="relative isolate flex h-14 w-14 items-center justify-center rounded-full"
|
|
data-testid="video-inline-center-icon"
|
|
>
|
|
<GlassSurface className="rounded-full" />
|
|
{isPlaying ? (
|
|
<Pause className="h-6 w-6 fill-white text-white" />
|
|
) : (
|
|
<Play className="h-6 w-6 fill-white text-white" />
|
|
)}
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
{isBuffering && !hasError ? (
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
<span className="relative isolate flex h-14 w-14 items-center justify-center rounded-full">
|
|
<GlassSurface className="rounded-full" />
|
|
<Spinner className="text-white" size={28} />
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{hasError ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Retry loading video"
|
|
className="group absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-2"
|
|
onClick={handleTogglePlay}
|
|
>
|
|
<span className="relative isolate flex h-14 w-14 items-center justify-center rounded-full transition-transform duration-200 ease-out group-hover:scale-105">
|
|
<GlassSurface className="rounded-full" />
|
|
<AlertCircle className="h-6 w-6 text-white" />
|
|
</span>
|
|
<span className="rounded-md bg-black/50 px-2 py-1 text-xs text-white backdrop-blur-sm">
|
|
Failed to load — tap to retry
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
{!hasError ? (
|
|
<div
|
|
className={cn(
|
|
"group/inline-controls absolute inset-x-1.5 bottom-1.5 z-10 isolate rounded-[10px]",
|
|
hideInlineControls &&
|
|
"pointer-events-none focus-within:pointer-events-auto group-hover/video:pointer-events-auto",
|
|
)}
|
|
>
|
|
<GlassSurface className={inlineControlsRevealClass} />
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-1 px-1.5 py-1",
|
|
inlineControlsRevealClass,
|
|
)}
|
|
data-testid="video-inline-controls"
|
|
>
|
|
<span
|
|
className="shrink-0 text-2xs font-medium tabular-nums leading-none text-white"
|
|
data-testid="video-inline-time"
|
|
>
|
|
{formatTimecode(currentTime)}
|
|
</span>
|
|
<VideoScrubber
|
|
ariaLabel="Video progress"
|
|
className="group h-7 min-w-0 flex-1"
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
onSeek={handleInlineSeek}
|
|
testIdPrefix="video-inline"
|
|
/>
|
|
<span
|
|
className="shrink-0 text-2xs font-medium tabular-nums leading-none text-white/70"
|
|
data-testid="video-inline-duration"
|
|
>
|
|
{formatTimecode(duration)}
|
|
</span>
|
|
{showInlineSpeedControl ? (
|
|
<PlaybackSpeedControl
|
|
playbackSpeed={playbackSpeed}
|
|
testId="video-inline-speed"
|
|
onPlaybackSpeedChange={handlePlaybackSpeedChange}
|
|
/>
|
|
) : null}
|
|
<VolumeControl
|
|
muted={muted}
|
|
volume={volume}
|
|
onToggleMute={handleToggleMute}
|
|
onVolumeChange={handleVolumeChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{!hasError ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Open video review"
|
|
data-video-review-launcher=""
|
|
title="Open video review"
|
|
className="group absolute right-2 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-white shadow-sm outline-hidden focus-visible:ring-2 focus-visible:ring-white/60"
|
|
onClick={handleOpenReview}
|
|
>
|
|
<span className="relative isolate flex h-full w-full items-center justify-center rounded-full transition-transform duration-200 ease-out group-hover:scale-110">
|
|
<GlassSurface className="rounded-full" />
|
|
<Maximize2 className="pointer-events-none h-4 w-4" />
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{videoContextMenu}
|
|
<VideoReviewDialog
|
|
comments={timecodedComments}
|
|
currentTime={reviewCurrentTime}
|
|
duration={duration}
|
|
onCurrentTimeChange={setReviewCurrentTime}
|
|
onDurationChange={handleMediaDuration}
|
|
onOpenChange={handleReviewOpenChange}
|
|
onPendingSeekConsumed={handlePendingSeekConsumed}
|
|
onPlaybackSpeedChange={handlePlaybackSpeedChange}
|
|
open={reviewOpen}
|
|
pendingSeekSeconds={pendingSeekSeconds}
|
|
playbackSpeed={playbackSpeed}
|
|
poster={poster}
|
|
reviewContext={reviewContext}
|
|
src={src}
|
|
title={reviewContext?.title ?? fileNameFromUrl(src)}
|
|
videoRef={reviewVideoRef}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function VideoReviewDialog({
|
|
comments,
|
|
currentTime,
|
|
duration,
|
|
onCurrentTimeChange,
|
|
onDurationChange,
|
|
onOpenChange,
|
|
onPendingSeekConsumed,
|
|
onPlaybackSpeedChange,
|
|
open,
|
|
pendingSeekSeconds,
|
|
playbackSpeed,
|
|
poster,
|
|
reviewContext,
|
|
src,
|
|
title,
|
|
videoRef,
|
|
}: {
|
|
comments: TimecodedComment[];
|
|
currentTime: number;
|
|
duration: number;
|
|
onCurrentTimeChange: (seconds: number) => void;
|
|
onDurationChange: (seconds: number) => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
onPendingSeekConsumed: () => void;
|
|
onPlaybackSpeedChange: (speed: number) => void;
|
|
open: boolean;
|
|
pendingSeekSeconds: number | null;
|
|
playbackSpeed: number;
|
|
poster?: string;
|
|
reviewContext?: VideoReviewContext;
|
|
src: string;
|
|
title: string;
|
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
}) {
|
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
const [isPosting, setIsPosting] = React.useState(false);
|
|
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
|
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
|
const [isPanelOpen, setIsPanelOpen] = React.useState(true);
|
|
const [postAtCurrentFrame, setPostAtCurrentFrame] = React.useState(true);
|
|
const [isComposerMounted, setIsComposerMounted] = React.useState(false);
|
|
const [replyTarget, setReplyTarget] = React.useState<TimecodedComment | null>(
|
|
null,
|
|
);
|
|
const [volume, setVolume] = React.useState(1);
|
|
const [muted, setMuted] = React.useState(false);
|
|
const [mediaRatio, setMediaRatio] = React.useState<number | null>(null);
|
|
const [hasVisibleFrame, setHasVisibleFrame] = React.useState(false);
|
|
const [videoAreaSize, setVideoAreaSize] = React.useState<{
|
|
height: number;
|
|
width: number;
|
|
} | null>(null);
|
|
const videoAreaRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [optimisticComments, setOptimisticComments] = React.useState<
|
|
VideoReviewComment[]
|
|
>([]);
|
|
const currentTimeRef = React.useRef(currentTime);
|
|
const replyTargetRef = React.useRef(replyTarget);
|
|
const postAtCurrentFrameRef = React.useRef(postAtCurrentFrame);
|
|
const composerWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
|
currentTimeRef.current = currentTime;
|
|
replyTargetRef.current = replyTarget;
|
|
postAtCurrentFrameRef.current = postAtCurrentFrame;
|
|
|
|
const canComment =
|
|
Boolean(reviewContext?.onSendComment) && !reviewContext?.disabled;
|
|
const isPostingReviewItem = Boolean(reviewContext?.isSending) || isPosting;
|
|
const canPost = canComment && !isPostingReviewItem;
|
|
const postDisabledReason = !reviewContext?.onSendComment
|
|
? "Commenting is unavailable for this video."
|
|
: reviewContext.disabled
|
|
? "Wait for the video message to finish sending."
|
|
: isPostingReviewItem
|
|
? "Posting..."
|
|
: null;
|
|
const visibleDuration = duration > 0 ? duration : 0;
|
|
const displayComments = React.useMemo(() => {
|
|
const confirmedBodies = new Set(
|
|
comments.map((comment) => comment.comment.body),
|
|
);
|
|
const pendingComments = optimisticComments
|
|
.filter((comment) => !confirmedBodies.has(comment.body))
|
|
.map(parseTimecodedComment);
|
|
return [...comments, ...pendingComments].sort(sortTimecodedComments);
|
|
}, [comments, optimisticComments]);
|
|
const displayMarkerComments = React.useMemo(
|
|
() =>
|
|
displayComments.filter(
|
|
(comment) =>
|
|
comment.seconds !== null &&
|
|
visibleDuration > 0 &&
|
|
comment.seconds <= visibleDuration,
|
|
),
|
|
[displayComments, visibleDuration],
|
|
);
|
|
// Thread the flat comment list: replies (comments whose parent is another
|
|
// comment) nest one level under their top-level ancestor, frame.io-style.
|
|
const commentThreads = React.useMemo(() => {
|
|
const byId = new Map(
|
|
displayComments.map((item) => [item.comment.id, item]),
|
|
);
|
|
const topLevel = displayComments.filter(
|
|
(item) => !item.comment.parentId || !byId.has(item.comment.parentId),
|
|
);
|
|
const repliesByTopId = new Map<string, TimecodedComment[]>();
|
|
for (const item of displayComments) {
|
|
if (!item.comment.parentId || !byId.has(item.comment.parentId)) continue;
|
|
// Walk up to the top-level ancestor (deep reply chains flatten).
|
|
let ancestor = item;
|
|
const visited = new Set<string>([item.comment.id]);
|
|
while (
|
|
ancestor.comment.parentId &&
|
|
byId.has(ancestor.comment.parentId) &&
|
|
!visited.has(ancestor.comment.parentId)
|
|
) {
|
|
visited.add(ancestor.comment.parentId);
|
|
ancestor = byId.get(ancestor.comment.parentId) as TimecodedComment;
|
|
}
|
|
const replies = repliesByTopId.get(ancestor.comment.id) ?? [];
|
|
replies.push(item);
|
|
repliesByTopId.set(ancestor.comment.id, replies);
|
|
}
|
|
for (const replies of repliesByTopId.values()) {
|
|
replies.sort((a, b) => a.comment.createdAt - b.comment.createdAt);
|
|
}
|
|
return { repliesByTopId, topLevel };
|
|
}, [displayComments]);
|
|
// A read-only panel (no send capability) is still useful when comments
|
|
// exist; with neither, drop the column and let the video own the dialog.
|
|
const showCommentsPanel =
|
|
Boolean(reviewContext?.onSendComment) || displayComments.length > 0;
|
|
|
|
const syncCurrentTime = React.useCallback(
|
|
(seconds: number) => {
|
|
const nextSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
|
currentTimeRef.current = nextSeconds;
|
|
onCurrentTimeChange(nextSeconds);
|
|
},
|
|
[onCurrentTimeChange],
|
|
);
|
|
|
|
const boundSeconds = React.useCallback(
|
|
(seconds: number) => {
|
|
const nextSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
|
return duration > 0 ? Math.min(nextSeconds, duration) : nextSeconds;
|
|
},
|
|
[duration],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
setIsComposerMounted(false);
|
|
setHasVisibleFrame(false);
|
|
return;
|
|
}
|
|
// Two frames: one for the dialog to paint, one for the browser to
|
|
// composite, then mount the heavyweight Tiptap composer.
|
|
let secondFrameId = 0;
|
|
const firstFrameId = window.requestAnimationFrame(() => {
|
|
secondFrameId = window.requestAnimationFrame(() =>
|
|
setIsComposerMounted(true),
|
|
);
|
|
});
|
|
return () => {
|
|
window.cancelAnimationFrame(firstFrameId);
|
|
window.cancelAnimationFrame(secondFrameId);
|
|
};
|
|
}, [open]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
const element = videoAreaRef.current;
|
|
if (!element) return;
|
|
|
|
const update = () => {
|
|
const rect = element.getBoundingClientRect();
|
|
setVideoAreaSize({ height: rect.height, width: rect.width });
|
|
};
|
|
update();
|
|
const observer = new ResizeObserver(update);
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, [open]);
|
|
|
|
// Size the video wrapper to the frame itself (not the letterboxed area) so
|
|
// the corner radius, glow, and floating controls all hug the picture.
|
|
const fittedVideoStyle = React.useMemo<React.CSSProperties>(() => {
|
|
if (
|
|
!mediaRatio ||
|
|
!videoAreaSize ||
|
|
videoAreaSize.width <= 0 ||
|
|
videoAreaSize.height <= 0
|
|
) {
|
|
return { height: "100%", width: "100%" };
|
|
}
|
|
const width = Math.min(
|
|
videoAreaSize.width,
|
|
videoAreaSize.height * mediaRatio,
|
|
);
|
|
return { aspectRatio: String(mediaRatio), width };
|
|
}, [mediaRatio, videoAreaSize]);
|
|
|
|
const reviewSeek = useThrottledVideoSeek(videoRef);
|
|
|
|
const setVideoTime = React.useCallback(
|
|
(seconds: number) => {
|
|
const boundedSeconds = boundSeconds(seconds);
|
|
reviewSeek.requestSeek(boundedSeconds);
|
|
syncCurrentTime(boundedSeconds);
|
|
},
|
|
[boundSeconds, reviewSeek, syncCurrentTime],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
if (pendingSeekSeconds === null) return;
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.currentTime = pendingSeekSeconds;
|
|
syncCurrentTime(pendingSeekSeconds);
|
|
onPendingSeekConsumed();
|
|
}, [
|
|
onPendingSeekConsumed,
|
|
open,
|
|
pendingSeekSeconds,
|
|
syncCurrentTime,
|
|
videoRef,
|
|
]);
|
|
|
|
useSmoothPlaybackTime(videoRef, open && isPlaying, syncCurrentTime);
|
|
|
|
const togglePlay = React.useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
if (video.paused || video.ended) {
|
|
void video.play().catch(() => undefined);
|
|
} else {
|
|
video.pause();
|
|
}
|
|
}, [videoRef]);
|
|
|
|
const readAuthoringSeconds = React.useCallback(() => {
|
|
const video = videoRef.current;
|
|
return boundSeconds(video?.currentTime ?? currentTimeRef.current);
|
|
}, [boundSeconds, videoRef]);
|
|
|
|
const pauseForCommentAuthoring = React.useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (video && !video.paused) {
|
|
video.pause();
|
|
}
|
|
}, [videoRef]);
|
|
|
|
const handleToggleMute = React.useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.muted = !video.muted;
|
|
}, [videoRef]);
|
|
|
|
const handleVolumeChange = React.useCallback(
|
|
(value: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.volume = Math.min(Math.max(value, 0), 1);
|
|
video.muted = value <= 0;
|
|
},
|
|
[videoRef],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.playbackRate = playbackSpeed;
|
|
}, [open, playbackSpeed, videoRef]);
|
|
|
|
const postContent = React.useCallback(
|
|
async (
|
|
content: string,
|
|
options?: {
|
|
mediaTags?: string[][];
|
|
mentionPubkeys?: string[];
|
|
replyTo?: VideoReviewComment | null;
|
|
secondsOverride?: number;
|
|
/** Default true — quick reactions always stamp; the composer toggle
|
|
* can opt out. */
|
|
stampTimecode?: boolean;
|
|
},
|
|
) => {
|
|
const trimmed = content.trim();
|
|
if (!trimmed || !reviewContext?.onSendComment || !canPost) return;
|
|
|
|
const replyTo = options?.replyTo ?? null;
|
|
const authoredSeconds =
|
|
typeof options?.secondsOverride === "number" &&
|
|
Number.isFinite(options.secondsOverride)
|
|
? boundSeconds(options.secondsOverride)
|
|
: readAuthoringSeconds();
|
|
// Replies attach to a comment's existing moment, and the composer
|
|
// toggle lets plain comments skip the frame stamp entirely.
|
|
const stampTimecode = !replyTo && (options?.stampTimecode ?? true);
|
|
const body = stampTimecode
|
|
? `[${formatCommentTimecode(authoredSeconds)}] ${trimmed}`
|
|
: trimmed;
|
|
const optimisticComment: VideoReviewComment = {
|
|
id: `optimistic-video-review-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.slice(2)}`,
|
|
author: "You",
|
|
avatarUrl: null,
|
|
body,
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
parentId: replyTo?.id ?? null,
|
|
time: "now",
|
|
};
|
|
|
|
setOptimisticComments((current) => [...current, optimisticComment]);
|
|
setIsPosting(true);
|
|
setErrorMessage(null);
|
|
try {
|
|
await reviewContext.onSendComment(
|
|
body,
|
|
options?.mentionPubkeys ?? [],
|
|
options?.mediaTags,
|
|
replyTo?.id,
|
|
);
|
|
if (replyTo) {
|
|
setReplyTarget(null);
|
|
}
|
|
} catch (error) {
|
|
setOptimisticComments((current) =>
|
|
current.filter((comment) => comment.id !== optimisticComment.id),
|
|
);
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : "Failed to post comment.",
|
|
);
|
|
// Rethrow so the composer restores the draft it cleared on submit.
|
|
throw error;
|
|
} finally {
|
|
setIsPosting(false);
|
|
}
|
|
},
|
|
[boundSeconds, canPost, readAuthoringSeconds, reviewContext],
|
|
);
|
|
|
|
const handleComposerSend = React.useCallback(
|
|
async (
|
|
content: string,
|
|
mentionPubkeys: string[],
|
|
mediaTags?: string[][],
|
|
) => {
|
|
await postContent(content, {
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
replyTo: replyTargetRef.current?.comment ?? null,
|
|
stampTimecode: postAtCurrentFrameRef.current,
|
|
});
|
|
},
|
|
[postContent],
|
|
);
|
|
|
|
const focusComposerInput = React.useCallback(() => {
|
|
window.requestAnimationFrame(() => {
|
|
composerWrapperRef.current
|
|
?.querySelector<HTMLElement>("[data-testid='message-input']")
|
|
?.focus();
|
|
});
|
|
}, []);
|
|
|
|
const handleReplyPress = React.useCallback(
|
|
(item: TimecodedComment) => {
|
|
setReplyTarget(item);
|
|
focusComposerInput();
|
|
},
|
|
[focusComposerInput],
|
|
);
|
|
|
|
const handleCancelReply = React.useCallback(() => {
|
|
setReplyTarget(null);
|
|
}, []);
|
|
|
|
const handleReactionPress = React.useCallback(
|
|
(emoji: string) => {
|
|
void postContent(emoji, {
|
|
secondsOverride: readAuthoringSeconds(),
|
|
}).catch(() => undefined);
|
|
},
|
|
[postContent, readAuthoringSeconds],
|
|
);
|
|
|
|
const handleEmojiPickerSelect = React.useCallback(
|
|
(emoji: string) => {
|
|
void postContent(emoji)
|
|
.catch(() => undefined)
|
|
.finally(() => setIsEmojiPickerOpen(false));
|
|
},
|
|
[postContent],
|
|
);
|
|
|
|
const handleClose = React.useCallback(() => {
|
|
onOpenChange(false);
|
|
}, [onOpenChange]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== "Escape") return;
|
|
if (isEmojiPickerOpen) {
|
|
setIsEmojiPickerOpen(false);
|
|
} else {
|
|
onOpenChange(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [isEmojiPickerOpen, onOpenChange, open]);
|
|
|
|
if (!open) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
className={cn(
|
|
"dark video-review-theme fixed inset-0 z-50 flex min-h-0 min-w-0 items-center justify-center bg-black/75 p-4 text-foreground sm:p-8 lg:p-10",
|
|
MODAL_BACKDROP_BLUR_CLASS,
|
|
)}
|
|
>
|
|
<button
|
|
aria-label="Close video review"
|
|
className="absolute inset-0 cursor-default"
|
|
data-testid="video-review-backdrop"
|
|
type="button"
|
|
onClick={handleClose}
|
|
/>
|
|
<div
|
|
aria-describedby="video-review-description"
|
|
aria-labelledby="video-review-title"
|
|
aria-modal="true"
|
|
className="relative z-10 flex h-full max-h-[980px] min-h-0 w-full max-w-[1520px] min-w-0 flex-col overflow-hidden rounded-2xl border border-border bg-background text-foreground shadow-2xl outline-hidden"
|
|
data-testid="video-review-dialog"
|
|
role="dialog"
|
|
>
|
|
<h2 className="sr-only" id="video-review-title">
|
|
Video review
|
|
</h2>
|
|
<p className="sr-only" id="video-review-description">
|
|
Review video comments and timecoded replies.
|
|
</p>
|
|
|
|
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-border bg-background px-4">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-foreground">
|
|
{title}
|
|
</p>
|
|
</div>
|
|
{showCommentsPanel ? (
|
|
<Button
|
|
aria-label={isPanelOpen ? "Hide comments" : "Show comments"}
|
|
aria-pressed={isPanelOpen}
|
|
className={cn(
|
|
"h-8 w-8 rounded-lg border border-border bg-muted/40 text-foreground hover:bg-muted",
|
|
isPanelOpen && "bg-muted",
|
|
)}
|
|
data-testid="video-review-toggle-comments"
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setIsPanelOpen((open) => !open)}
|
|
>
|
|
<PanelRight className="h-4 w-4" />
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
aria-label="Close video review"
|
|
className="h-8 w-8 rounded-lg border border-border bg-muted/40 text-foreground hover:bg-muted"
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={handleClose}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 min-w-0 flex-1">
|
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-black">
|
|
<div
|
|
className="relative flex min-h-0 flex-1 items-center justify-center p-4 pb-3 lg:p-6 lg:pb-4"
|
|
ref={videoAreaRef}
|
|
>
|
|
<div
|
|
className="video-review-media-surface relative isolate flex max-h-full min-w-0 max-w-full items-center justify-center"
|
|
style={fittedVideoStyle}
|
|
>
|
|
<VideoGlow videoRef={videoRef} />
|
|
<div className="relative z-10 h-full w-full overflow-hidden rounded-lg">
|
|
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
|
|
<video
|
|
ref={videoRef}
|
|
className="h-full w-full min-h-0 object-contain"
|
|
playsInline
|
|
poster={poster}
|
|
preload="auto"
|
|
src={src}
|
|
onClick={togglePlay}
|
|
onDurationChange={(event) =>
|
|
onDurationChange(event.currentTarget.duration)
|
|
}
|
|
onEnded={(event) => {
|
|
syncCurrentTime(event.currentTarget.currentTime);
|
|
setIsPlaying(false);
|
|
}}
|
|
onLoadedMetadata={(event) => {
|
|
event.currentTarget.playbackRate = playbackSpeed;
|
|
onDurationChange(event.currentTarget.duration);
|
|
const { videoHeight, videoWidth } = event.currentTarget;
|
|
if (videoWidth > 0 && videoHeight > 0) {
|
|
setMediaRatio(videoWidth / videoHeight);
|
|
}
|
|
if (
|
|
pendingSeekSeconds !== null &&
|
|
pendingSeekSeconds > 0
|
|
) {
|
|
event.currentTarget.currentTime = pendingSeekSeconds;
|
|
syncCurrentTime(pendingSeekSeconds);
|
|
}
|
|
}}
|
|
onLoadedData={() => setHasVisibleFrame(true)}
|
|
onPause={(event) => {
|
|
syncCurrentTime(event.currentTarget.currentTime);
|
|
setIsPlaying(false);
|
|
}}
|
|
onPlay={(event) => {
|
|
syncCurrentTime(event.currentTarget.currentTime);
|
|
setIsPlaying(true);
|
|
}}
|
|
onSeeked={(event) => {
|
|
reviewSeek.handleSeeked();
|
|
if (
|
|
event.currentTarget.readyState >=
|
|
HTMLMediaElement.HAVE_CURRENT_DATA
|
|
) {
|
|
setHasVisibleFrame(true);
|
|
}
|
|
}}
|
|
onTimeUpdate={(event) => {
|
|
syncCurrentTime(event.currentTarget.currentTime);
|
|
}}
|
|
onVolumeChange={(event) => {
|
|
setVolume(event.currentTarget.volume);
|
|
setMuted(event.currentTarget.muted);
|
|
}}
|
|
/>
|
|
<VideoReviewPosterPreview
|
|
poster={poster}
|
|
visible={!hasVisibleFrame}
|
|
/>
|
|
</div>
|
|
<div className="video-review-controls absolute inset-x-2 bottom-2 z-20 sm:inset-x-4 sm:bottom-3">
|
|
<div className="relative isolate flex items-center gap-2 rounded-xl px-2 py-1.5">
|
|
<GlassSurface />
|
|
<button
|
|
aria-label={
|
|
isPlaying ? "Pause review video" : "Play review video"
|
|
}
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-white transition-colors hover:bg-white/15 outline-hidden focus-visible:ring-2 focus-visible:ring-white/60"
|
|
type="button"
|
|
onClick={togglePlay}
|
|
>
|
|
{isPlaying ? (
|
|
<Pause className="h-4 w-4 fill-white text-white" />
|
|
) : (
|
|
<Play className="h-4 w-4 fill-white text-white" />
|
|
)}
|
|
</button>
|
|
<span
|
|
className="shrink-0 text-xs font-medium tabular-nums text-white"
|
|
data-testid="video-review-time-current"
|
|
>
|
|
{formatTimecode(currentTime)}
|
|
</span>
|
|
<VideoScrubber
|
|
ariaLabel="Video timeline"
|
|
className="group h-8 min-w-0 flex-1"
|
|
currentTime={currentTime}
|
|
duration={visibleDuration}
|
|
onSeek={setVideoTime}
|
|
showHoverPreview
|
|
testIdPrefix="video-review"
|
|
markers={displayMarkerComments.map((item) => {
|
|
const seconds = item.seconds ?? 0;
|
|
const left = `${Math.min((seconds / visibleDuration) * 100, 100)}%`;
|
|
return (
|
|
<Tooltip key={item.comment.id}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
aria-label={`Seek to ${item.timecode}`}
|
|
className="absolute top-1/2 z-20 flex h-5 w-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/40 bg-black/60 shadow-md transition-transform hover:scale-110 outline-hidden focus-visible:ring-2 focus-visible:ring-white/60"
|
|
data-video-review-marker=""
|
|
style={{ left }}
|
|
type="button"
|
|
onClick={() => setVideoTime(seconds)}
|
|
>
|
|
<UserAvatar
|
|
avatarUrl={item.comment.avatarUrl ?? null}
|
|
className="h-4 w-4 shadow-none"
|
|
displayName={item.comment.author}
|
|
shape={
|
|
item.comment.isAgent ? "squircle" : "circle"
|
|
}
|
|
size="xs"
|
|
/>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{item.timecode} · {item.comment.author}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
/>
|
|
<span
|
|
className="shrink-0 text-xs font-medium tabular-nums text-white/70"
|
|
data-testid="video-review-time-duration"
|
|
>
|
|
{formatTimecode(visibleDuration)}
|
|
</span>
|
|
<PlaybackSpeedControl
|
|
playbackSpeed={playbackSpeed}
|
|
size="review"
|
|
testId="video-review-speed"
|
|
onPlaybackSpeedChange={onPlaybackSpeedChange}
|
|
/>
|
|
<VolumeControl
|
|
expanded
|
|
muted={muted}
|
|
volume={volume}
|
|
onToggleMute={handleToggleMute}
|
|
onVolumeChange={handleVolumeChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{reviewContext?.onSendComment ? (
|
|
<div className="relative z-10 flex shrink-0 items-center justify-center px-4 pb-4 pt-1">
|
|
<div
|
|
className="relative isolate flex flex-wrap items-center justify-center gap-1 rounded-full px-2 py-1 text-white"
|
|
data-testid="video-review-reaction-tray"
|
|
>
|
|
<GlassSurface />
|
|
{QUICK_REACTIONS.map((emoji, index) => (
|
|
<button
|
|
aria-label={`React ${emoji} at ${formatTimecode(currentTime)}`}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-lg transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-35"
|
|
data-testid={`video-review-reaction-${index}`}
|
|
disabled={!canPost}
|
|
key={emoji}
|
|
title={postDisabledReason ?? undefined}
|
|
type="button"
|
|
onClick={() => handleReactionPress(emoji)}
|
|
>
|
|
<span className="pointer-events-none">{emoji}</span>
|
|
</button>
|
|
))}
|
|
<span className="mx-1 h-6 w-px bg-white/15" />
|
|
<div className="relative">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
aria-label="More reactions"
|
|
aria-pressed={isEmojiPickerOpen}
|
|
className={cn(
|
|
"flex h-8 w-8 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-35",
|
|
isEmojiPickerOpen && "bg-white/15 text-white",
|
|
)}
|
|
disabled={!canPost}
|
|
title={postDisabledReason ?? "More reactions"}
|
|
type="button"
|
|
onClick={() => setIsEmojiPickerOpen((open) => !open)}
|
|
>
|
|
<SmilePlus className="pointer-events-none h-4 w-4" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>More reactions</TooltipContent>
|
|
</Tooltip>
|
|
{isEmojiPickerOpen ? (
|
|
<div
|
|
className="absolute bottom-full right-0 z-30 mb-2 overflow-hidden rounded-2xl shadow-2xl"
|
|
data-testid="video-review-emoji-picker"
|
|
>
|
|
<EmojiPicker
|
|
autoFocus
|
|
onSelect={handleEmojiPickerSelect}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
{showCommentsPanel ? (
|
|
<aside
|
|
className="relative z-10 min-h-0 shrink-0 overflow-hidden bg-neutral-950 transition-[width] duration-200 ease-out"
|
|
data-testid="video-review-comments-panel"
|
|
inert={!isPanelOpen || undefined}
|
|
style={{ width: isPanelOpen ? 380 : 0 }}
|
|
>
|
|
<div className="flex h-full w-[380px] min-h-0 flex-col border-l border-border bg-neutral-950">
|
|
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
|
<h3 className="text-sm font-semibold text-foreground">
|
|
Comments
|
|
</h3>
|
|
</div>
|
|
<span className="ml-auto rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
{displayComments.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className="min-h-0 flex-1 overflow-y-auto px-4 py-3"
|
|
data-testid="video-review-comments"
|
|
>
|
|
{commentThreads.topLevel.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{commentThreads.topLevel.map((item) => (
|
|
<VideoReviewCommentCard
|
|
canReply={canComment}
|
|
item={item}
|
|
key={item.comment.id}
|
|
onReply={handleReplyPress}
|
|
onSeek={setVideoTime}
|
|
onToggleReaction={
|
|
reviewContext?.onToggleCommentReaction
|
|
}
|
|
replies={
|
|
commentThreads.repliesByTopId.get(
|
|
item.comment.id,
|
|
) ?? []
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-center">
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
No comments yet
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-border">
|
|
<div className="flex items-center gap-2.5 px-4 pb-3 pt-4">
|
|
<span
|
|
className={cn(
|
|
"rounded-md px-2 py-1 font-mono text-xs font-semibold transition-colors",
|
|
!replyTarget && postAtCurrentFrame
|
|
? VIDEO_REVIEW_TIMECODE_ACCENT_CLASS
|
|
: "bg-muted text-muted-foreground/70",
|
|
)}
|
|
data-testid="video-review-composer-timecode"
|
|
>
|
|
{formatTimecode(currentTime)}
|
|
</span>
|
|
{replyTarget ? (
|
|
<span className="text-2xs text-muted-foreground">
|
|
Replying to {replyTarget.comment.author}
|
|
</span>
|
|
) : (
|
|
<label
|
|
className="flex cursor-pointer select-none items-center gap-1.5 text-2xs text-muted-foreground"
|
|
htmlFor="video-review-frame-toggle"
|
|
>
|
|
<Checkbox
|
|
checked={postAtCurrentFrame}
|
|
className="h-3.5 w-3.5"
|
|
data-testid="video-review-frame-toggle"
|
|
id="video-review-frame-toggle"
|
|
onCheckedChange={(checked) =>
|
|
setPostAtCurrentFrame(checked === true)
|
|
}
|
|
/>
|
|
Comment at current frame
|
|
</label>
|
|
)}
|
|
</div>
|
|
{canComment && !isComposerMounted ? (
|
|
<div aria-hidden="true" className="px-3 pb-3.5">
|
|
<div className="h-[88px] rounded-2xl border border-border/50 bg-muted/20" />
|
|
</div>
|
|
) : null}
|
|
{canComment && isComposerMounted ? (
|
|
<div
|
|
className="[&_footer>div[aria-hidden]]:hidden"
|
|
ref={composerWrapperRef}
|
|
onFocusCapture={pauseForCommentAuthoring}
|
|
>
|
|
<MessageComposer
|
|
channelId={reviewContext?.channelId ?? null}
|
|
channelName={
|
|
reviewContext?.channelName ?? "video-review"
|
|
}
|
|
channelType={reviewContext?.channelType ?? null}
|
|
containerClassName="px-3 pb-3.5"
|
|
disabled={!canComment}
|
|
draftKey={
|
|
reviewContext?.rootEventId
|
|
? `video-review:${reviewContext.rootEventId}`
|
|
: undefined
|
|
}
|
|
isSending={isPostingReviewItem}
|
|
onCancelReply={
|
|
replyTarget ? handleCancelReply : undefined
|
|
}
|
|
onSend={handleComposerSend}
|
|
placeholder={
|
|
replyTarget ? undefined : "Leave your comment..."
|
|
}
|
|
profiles={reviewContext?.profiles}
|
|
replyTarget={
|
|
replyTarget
|
|
? {
|
|
author: replyTarget.comment.author,
|
|
body:
|
|
replyTarget.text || replyTarget.comment.body,
|
|
id: replyTarget.comment.id,
|
|
}
|
|
: null
|
|
}
|
|
typingParentEventId={reviewContext?.rootEventId ?? null}
|
|
typingRootEventId={reviewContext?.rootEventId ?? null}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<p className="px-4 pb-4 pt-2 text-xs text-muted-foreground">
|
|
{postDisabledReason}
|
|
</p>
|
|
)}
|
|
{errorMessage ? (
|
|
<p className="px-4 pb-3 text-xs text-destructive">
|
|
{errorMessage}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
function VideoReviewCommentCard({
|
|
item,
|
|
canReply,
|
|
onReply,
|
|
onSeek,
|
|
onToggleReaction,
|
|
replies,
|
|
}: {
|
|
canReply: boolean;
|
|
item: TimecodedComment;
|
|
onReply: (item: TimecodedComment) => void;
|
|
onSeek: (seconds: number) => void;
|
|
onToggleReaction?: (
|
|
comment: VideoReviewComment,
|
|
emoji: string,
|
|
remove: boolean,
|
|
) => Promise<void>;
|
|
replies: TimecodedComment[];
|
|
}) {
|
|
return (
|
|
<article className="rounded-lg bg-muted/40 p-3 text-sm text-foreground">
|
|
<VideoReviewCommentBody
|
|
canReply={canReply}
|
|
item={item}
|
|
onReply={onReply}
|
|
onSeek={onSeek}
|
|
onToggleReaction={onToggleReaction}
|
|
/>
|
|
{replies.length > 0 ? (
|
|
<div className="mt-3 space-y-3 border-l border-border pl-3">
|
|
{replies.map((reply) => (
|
|
<VideoReviewCommentBody
|
|
canReply={canReply}
|
|
item={reply}
|
|
key={reply.comment.id}
|
|
onReply={onReply}
|
|
onSeek={onSeek}
|
|
onToggleReaction={onToggleReaction}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function VideoReviewCommentBody({
|
|
canReply,
|
|
item,
|
|
onReply,
|
|
onSeek,
|
|
onToggleReaction,
|
|
}: {
|
|
canReply: boolean;
|
|
item: TimecodedComment;
|
|
onReply: (item: TimecodedComment) => void;
|
|
onSeek: (seconds: number) => void;
|
|
onToggleReaction?: (
|
|
comment: VideoReviewComment,
|
|
emoji: string,
|
|
remove: boolean,
|
|
) => Promise<void>;
|
|
}) {
|
|
const reactions = item.comment.reactions ?? [];
|
|
const text = item.text || item.comment.body;
|
|
const timecodeButton =
|
|
item.seconds !== null && item.timecode ? (
|
|
<VideoReviewTimecodeButton
|
|
timecode={item.timecode}
|
|
onClick={() => onSeek(item.seconds ?? 0)}
|
|
/>
|
|
) : null;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<UserAvatar
|
|
avatarUrl={item.comment.avatarUrl ?? null}
|
|
className="h-6 w-6 shadow-none"
|
|
displayName={item.comment.author}
|
|
shape={item.comment.isAgent ? "squircle" : "circle"}
|
|
size="xs"
|
|
/>
|
|
<p className="truncate text-sm font-semibold text-foreground">
|
|
{item.comment.author}
|
|
</p>
|
|
<p className="shrink-0 text-xs text-muted-foreground">
|
|
{item.comment.time}
|
|
</p>
|
|
{reactions.some((reaction) => reaction.reactedByCurrentUser) ? (
|
|
<Check
|
|
className="ml-auto h-4 w-4 shrink-0 text-primary"
|
|
aria-hidden
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<p className="mt-1.5 leading-5">
|
|
{timecodeButton ? <>{timecodeButton} </> : null}
|
|
{text}
|
|
</p>
|
|
{reactions.length > 0 ? (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{reactions.map((reaction) => (
|
|
<button
|
|
aria-label={`Toggle ${reaction.emoji} reaction`}
|
|
aria-pressed={reaction.reactedByCurrentUser}
|
|
className={cn(
|
|
"flex h-7 items-center gap-1 rounded-full border px-2 text-xs transition-colors",
|
|
reaction.reactedByCurrentUser
|
|
? "border-primary/60 bg-primary/20 text-primary"
|
|
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
|
)}
|
|
disabled={!onToggleReaction}
|
|
key={reaction.emoji}
|
|
type="button"
|
|
onClick={() => {
|
|
void onToggleReaction?.(
|
|
item.comment,
|
|
reaction.emoji,
|
|
Boolean(reaction.reactedByCurrentUser),
|
|
);
|
|
}}
|
|
>
|
|
{reaction.emojiUrl ? (
|
|
<img alt="" className="h-4 w-4" src={reaction.emojiUrl} />
|
|
) : (
|
|
<span>{reaction.emoji}</span>
|
|
)}
|
|
<span>{reaction.count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{canReply ? (
|
|
<button
|
|
className="mt-1.5 text-xs font-medium text-muted-foreground outline-hidden transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
data-testid="video-review-comment-reply"
|
|
type="button"
|
|
onClick={() => onReply(item)}
|
|
>
|
|
Reply
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|