mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Improve video review readiness and controls (#5161)
   --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -22,7 +22,7 @@ import {
|
||||
getDmHuddleMemberPubkeys,
|
||||
hasOtherDmParticipant,
|
||||
} from "@/features/channels/lib/dmHuddleMembers";
|
||||
import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext";
|
||||
import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext";
|
||||
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
|
||||
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
|
||||
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
|
||||
@@ -464,7 +464,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
const activeVideoReviewCommentSender = activeChannel?.archivedAt
|
||||
? undefined
|
||||
: onSendVideoReviewComment;
|
||||
const threadVideoReviewContextsByMessageId = React.useMemo(() => {
|
||||
const threadVideoReviewPresentation = React.useMemo(() => {
|
||||
const messagesById = new Map(
|
||||
messages.map((message) => [message.id, message]),
|
||||
);
|
||||
@@ -475,7 +475,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
messagesById.set(message.id, message);
|
||||
}
|
||||
|
||||
return buildVideoReviewContextsByMessageId({
|
||||
return buildVideoReviewPresentationByMessageId({
|
||||
channelId: activeChannel?.id ?? null,
|
||||
channelName: activeChannel?.name,
|
||||
channelType: activeChannel?.channelType ?? null,
|
||||
@@ -883,9 +883,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
scrollTargetHighlights={!layoutScrollTargetId}
|
||||
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
|
||||
threadHead={threadHeadMessage}
|
||||
videoReviewContextsByMessageId={
|
||||
threadVideoReviewContextsByMessageId
|
||||
}
|
||||
videoReviewPresentation={threadVideoReviewPresentation}
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadRepliesPending={threadMessagesPending}
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewCommentsForRoot,
|
||||
buildVideoReviewCommentRootIdsByMessageId,
|
||||
buildVideoReviewContextForMessage,
|
||||
buildVideoReviewContextsByMessageId,
|
||||
hasVideoAttachment,
|
||||
@@ -159,6 +160,57 @@ test("buildVideoReviewCommentsForRoot returns descendants for one root", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("buildVideoReviewCommentRootIdsByMessageId targets the nearest video ancestor", () => {
|
||||
const root = message({ id: "root", body: "Review request" });
|
||||
const firstVideo = message({
|
||||
id: "first-video",
|
||||
body: "",
|
||||
parentId: root.id,
|
||||
rootId: root.id,
|
||||
});
|
||||
const firstComment = message({
|
||||
id: "first-comment",
|
||||
body: "[00:01] tighten this",
|
||||
parentId: firstVideo.id,
|
||||
rootId: root.id,
|
||||
});
|
||||
const nestedVideo = message({
|
||||
id: "nested-video",
|
||||
body: "",
|
||||
parentId: firstComment.id,
|
||||
rootId: root.id,
|
||||
});
|
||||
const nestedComment = message({
|
||||
id: "nested-comment",
|
||||
body: "[00:02] check this frame",
|
||||
parentId: nestedVideo.id,
|
||||
rootId: root.id,
|
||||
});
|
||||
const plainReply = message({
|
||||
id: "plain-reply",
|
||||
body: "No video ancestor",
|
||||
parentId: root.id,
|
||||
rootId: root.id,
|
||||
});
|
||||
|
||||
const rootIds = buildVideoReviewCommentRootIdsByMessageId([
|
||||
root,
|
||||
firstVideo,
|
||||
firstComment,
|
||||
nestedVideo,
|
||||
nestedComment,
|
||||
plainReply,
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
[...rootIds.entries()],
|
||||
[
|
||||
[firstComment.id, firstVideo.id],
|
||||
[nestedComment.id, nestedVideo.id],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildVideoReviewContextForMessage posts against the source video", async () => {
|
||||
const video = message({
|
||||
id: "video",
|
||||
|
||||
@@ -93,6 +93,33 @@ export function buildVideoReviewCommentsForRoot(
|
||||
return comments;
|
||||
}
|
||||
|
||||
export function buildVideoReviewCommentRootIdsByMessageId(
|
||||
messages: TimelineMessage[],
|
||||
): ReadonlyMap<string, string> {
|
||||
const messageById = new Map(messages.map((message) => [message.id, message]));
|
||||
const videoMessageIds = new Set(
|
||||
messages.filter(hasVideoAttachment).map((message) => message.id),
|
||||
);
|
||||
const rootIdsByMessageId = new Map<string, string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (videoMessageIds.has(message.id)) continue;
|
||||
|
||||
let ancestorId = message.parentId ?? null;
|
||||
const visited = new Set<string>();
|
||||
while (ancestorId && !visited.has(ancestorId)) {
|
||||
if (videoMessageIds.has(ancestorId)) {
|
||||
rootIdsByMessageId.set(message.id, ancestorId);
|
||||
break;
|
||||
}
|
||||
visited.add(ancestorId);
|
||||
ancestorId = messageById.get(ancestorId)?.parentId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return rootIdsByMessageId;
|
||||
}
|
||||
|
||||
export function buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
@@ -193,3 +220,18 @@ export function buildVideoReviewContextsByMessageId({
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
export function buildVideoReviewPresentationByMessageId(
|
||||
args: Parameters<typeof buildVideoReviewContextsByMessageId>[0],
|
||||
) {
|
||||
return {
|
||||
commentRootIdsByMessageId: buildVideoReviewCommentRootIdsByMessageId(
|
||||
args.messages,
|
||||
),
|
||||
contextsByMessageId: buildVideoReviewContextsByMessageId(args),
|
||||
};
|
||||
}
|
||||
|
||||
export type VideoReviewPresentation = ReturnType<
|
||||
typeof buildVideoReviewPresentationByMessageId
|
||||
>;
|
||||
|
||||
@@ -40,6 +40,9 @@ import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedB
|
||||
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
|
||||
import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation";
|
||||
import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode";
|
||||
import { VideoReviewTimecodeButton } from "@/shared/ui/VideoReviewTimecodeButton";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
import { MessageAgentOwner } from "./MessageAgentOwner";
|
||||
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
|
||||
@@ -95,6 +98,7 @@ export const MessageRow = React.memo(
|
||||
profiles,
|
||||
searchQuery,
|
||||
showDepthGuides = true,
|
||||
videoReviewCommentRootId,
|
||||
videoReviewContext,
|
||||
}: {
|
||||
channelId?: string | null;
|
||||
@@ -143,6 +147,7 @@ export const MessageRow = React.memo(
|
||||
profiles?: UserProfileLookup;
|
||||
searchQuery?: string;
|
||||
showDepthGuides?: boolean;
|
||||
videoReviewCommentRootId?: string;
|
||||
videoReviewContext?: VideoReviewContext;
|
||||
}) {
|
||||
// Keep the transient send state with its timestamp rather than collapsing
|
||||
@@ -244,6 +249,7 @@ export const MessageRow = React.memo(
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
|
||||
|
||||
const { nonDmChannelNames: channelNames } = useChannelNavigation();
|
||||
const openVideoReviewAt = useOpenVideoReviewAt();
|
||||
|
||||
const indentRem = getThreadReplyIndentRem(message.depth);
|
||||
const descendantGuideOffsetRem = connectDescendants
|
||||
@@ -340,22 +346,24 @@ export const MessageRow = React.memo(
|
||||
message={message}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
{
|
||||
const waveMessage = parseWaveMessageContent(message.body);
|
||||
if (waveMessage) {
|
||||
return (
|
||||
<WaveMessageAttachment
|
||||
channelId={channelId}
|
||||
fallbackText={waveMessage.fallbackText}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
const waveMessage = parseWaveMessageContent(message.body);
|
||||
if (waveMessage) {
|
||||
return (
|
||||
<WaveMessageAttachment
|
||||
channelId={channelId}
|
||||
fallbackText={waveMessage.fallbackText}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const reviewRootEventId = videoReviewCommentRootId;
|
||||
const reviewTimecode = reviewRootEventId
|
||||
? parseVideoReviewTimecode(message.body)
|
||||
: null;
|
||||
const markdown = (
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
className={cn(
|
||||
@@ -371,7 +379,7 @@ export const MessageRow = React.memo(
|
||||
message,
|
||||
isKnownAgentPubkey,
|
||||
)}
|
||||
content={message.body}
|
||||
content={reviewTimecode?.text ?? message.body}
|
||||
customEmoji={customEmoji}
|
||||
imetaByUrl={imetaByUrl}
|
||||
agentMentionPubkeysByName={agentMentionPubkeysByName}
|
||||
@@ -382,6 +390,24 @@ export const MessageRow = React.memo(
|
||||
videoReviewContext={videoReviewContext}
|
||||
/>
|
||||
);
|
||||
if (!reviewRootEventId || !reviewTimecode || !openVideoReviewAt) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
<VideoReviewTimecodeButton
|
||||
surface="message"
|
||||
timecode={reviewTimecode.timecode}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openVideoReviewAt(reviewRootEventId, reviewTimecode.seconds);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">{markdown}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -893,6 +919,7 @@ export const MessageRow = React.memo(
|
||||
prev.playEntrance === next.playEntrance &&
|
||||
prev.profiles === next.profiles &&
|
||||
prev.searchQuery === next.searchQuery &&
|
||||
prev.videoReviewCommentRootId === next.videoReviewCommentRootId &&
|
||||
prev.videoReviewContext === next.videoReviewContext,
|
||||
);
|
||||
|
||||
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { VideoReviewPresentation } from "@/features/messages/lib/videoReviewContext";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
|
||||
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
|
||||
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
||||
import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
|
||||
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
|
||||
@@ -38,7 +40,6 @@ import {
|
||||
} from "@/features/messages/lib/messageThreadPanelLayout";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
|
||||
import { ComposerActivityAccessory } from "./ComposerActivityAccessory";
|
||||
import { ComposerDockBackdrop } from "./ComposerDockBackdrop";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
@@ -111,7 +112,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
threadUnreadCount?: number;
|
||||
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
|
||||
threadTypingPubkeys: string[];
|
||||
videoReviewContextsByMessageId?: ReadonlyMap<string, VideoReviewContext>;
|
||||
videoReviewPresentation?: VideoReviewPresentation;
|
||||
activityAccessoryContent?: React.ReactNode;
|
||||
activityAccessoryVisible: boolean;
|
||||
widthPx: number;
|
||||
@@ -225,7 +226,7 @@ export function MessageThreadPanel({
|
||||
scrollTargetId,
|
||||
scrollTargetHighlights = true,
|
||||
threadHead,
|
||||
videoReviewContextsByMessageId,
|
||||
videoReviewPresentation,
|
||||
threadReplies,
|
||||
threadRepliesPending = false,
|
||||
threadUnreadCount,
|
||||
@@ -617,7 +618,10 @@ export function MessageThreadPanel({
|
||||
}
|
||||
profiles={profiles}
|
||||
showDepthGuides={shouldShowThreadBranchGuides}
|
||||
videoReviewContext={videoReviewContextsByMessageId?.get(
|
||||
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
|
||||
threadHead.id,
|
||||
)}
|
||||
videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
|
||||
threadHead.id,
|
||||
)}
|
||||
/>
|
||||
@@ -776,7 +780,10 @@ export function MessageThreadPanel({
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
showDepthGuides={shouldShowThreadBranchGuides}
|
||||
videoReviewContext={videoReviewContextsByMessageId?.get(
|
||||
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
|
||||
entry.message.id,
|
||||
)}
|
||||
videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
|
||||
entry.message.id,
|
||||
)}
|
||||
/>
|
||||
@@ -955,24 +962,26 @@ export function MessageThreadPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<AuxiliaryPanel
|
||||
className="relative"
|
||||
// The focus drawer animates itself; a second slide here would compound.
|
||||
enterMotion={!isFocusMode}
|
||||
footer={threadFooter}
|
||||
header={
|
||||
isHuddleTranscript ? undefined : (
|
||||
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
|
||||
)
|
||||
}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
layout={layout}
|
||||
onClose={onClose}
|
||||
testId="message-thread-panel"
|
||||
transparentChrome={transparentChrome}
|
||||
widthPx={widthPx}
|
||||
>
|
||||
{threadScrollRegion}
|
||||
</AuxiliaryPanel>
|
||||
<VideoReviewNavigationProvider>
|
||||
<AuxiliaryPanel
|
||||
className="relative"
|
||||
// The focus drawer animates itself; a second slide here would compound.
|
||||
enterMotion={!isFocusMode}
|
||||
footer={threadFooter}
|
||||
header={
|
||||
isHuddleTranscript ? undefined : (
|
||||
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
|
||||
)
|
||||
}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
layout={layout}
|
||||
onClose={onClose}
|
||||
testId="message-thread-panel"
|
||||
transparentChrome={transparentChrome}
|
||||
widthPx={widthPx}
|
||||
>
|
||||
{threadScrollRegion}
|
||||
</AuxiliaryPanel>
|
||||
</VideoReviewNavigationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ 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,
|
||||
@@ -109,16 +116,10 @@ type TimecodedComment = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const TIMECODE_RE =
|
||||
/^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
|
||||
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];
|
||||
const TIMECODE_ACCENT_CLASS =
|
||||
"bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.15)] text-[hsl(var(--buzz-video-review-accent-foreground,var(--buzz-video-review-accent,var(--primary))))]";
|
||||
const TIMECODE_ACCENT_HOVER_CLASS =
|
||||
"hover:bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.3)]";
|
||||
|
||||
/**
|
||||
* Frosted-glass backing layer for floating media controls. The parent must
|
||||
@@ -188,40 +189,11 @@ function isPlaybackSpeedOption(speed: number): boolean {
|
||||
return PLAYBACK_SPEEDS.some((option) => option === speed);
|
||||
}
|
||||
|
||||
function parseTimecode(value: string): number | null {
|
||||
const parts = value.split(":").map((part) => Number(part));
|
||||
if (parts.some((part) => !Number.isFinite(part) || part < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimecodedComment(comment: VideoReviewComment): TimecodedComment {
|
||||
const match = comment.body.match(TIMECODE_RE);
|
||||
if (!match) {
|
||||
return {
|
||||
comment,
|
||||
seconds: null,
|
||||
timecode: null,
|
||||
text: comment.body.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
comment,
|
||||
seconds: parseTimecode(match[1]),
|
||||
timecode: match[1],
|
||||
text: comment.body.slice(match[0].length).trim(),
|
||||
};
|
||||
const parsed = parseVideoReviewTimecode(comment.body);
|
||||
return parsed
|
||||
? { comment, ...parsed }
|
||||
: { comment, seconds: null, text: comment.body.trim(), timecode: null };
|
||||
}
|
||||
|
||||
function sortTimecodedComments(
|
||||
@@ -928,20 +900,32 @@ export function VideoPlayer({
|
||||
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;
|
||||
video?.pause();
|
||||
const startTime =
|
||||
video && Number.isFinite(video.currentTime)
|
||||
? video.currentTime
|
||||
: currentTime;
|
||||
setPendingSeekSeconds(startTime);
|
||||
setReviewCurrentTime(startTime);
|
||||
setReviewOpen(true);
|
||||
openReviewAt(startTime);
|
||||
},
|
||||
[currentTime, setReviewCurrentTime, setReviewOpen],
|
||||
[currentTime, openReviewAt],
|
||||
);
|
||||
|
||||
const handleReviewOpenChange = React.useCallback(
|
||||
@@ -989,7 +973,12 @@ export function VideoPlayer({
|
||||
maxHeight: 256,
|
||||
width: inlineSurfaceWidth,
|
||||
};
|
||||
const showControls = started && !hasError;
|
||||
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 (
|
||||
<>
|
||||
@@ -1015,7 +1004,7 @@ export function VideoPlayer({
|
||||
poster={poster}
|
||||
preload="metadata"
|
||||
src={src}
|
||||
onClick={showControls ? handleTogglePlay : undefined}
|
||||
onClick={started ? handleTogglePlay : undefined}
|
||||
onDurationChange={(event) =>
|
||||
handleMediaDuration(event.currentTarget.duration)
|
||||
}
|
||||
@@ -1072,16 +1061,29 @@ export function VideoPlayer({
|
||||
}}
|
||||
onWaiting={() => setIsBuffering(true)}
|
||||
/>
|
||||
{!started && !hasError ? (
|
||||
{!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"
|
||||
aria-label="Play video"
|
||||
className="group absolute inset-0 flex cursor-pointer items-center justify-center"
|
||||
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">
|
||||
<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" />
|
||||
<Play className="h-6 w-6 fill-white text-white" />
|
||||
{isPlaying ? (
|
||||
<Pause className="h-6 w-6 fill-white text-white" />
|
||||
) : (
|
||||
<Play className="h-6 w-6 fill-white text-white" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
@@ -1109,33 +1111,22 @@ export function VideoPlayer({
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{/* Slide (not fade) the pill out: animating opacity on an ancestor
|
||||
of a backdrop-filter flattens the glass into a plain fill
|
||||
mid-transition, which reads as a flicker. The video container's
|
||||
overflow-hidden clips the slid-out pill. */}
|
||||
{showControls ? (
|
||||
{!hasError ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-1.5 bottom-1.5 z-10 transition-transform duration-300 ease-out",
|
||||
isPlaying &&
|
||||
"pointer-events-none translate-y-[150%] focus-within:pointer-events-auto focus-within:translate-y-0 group-hover/video:pointer-events-auto group-hover/video:translate-y-0",
|
||||
"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",
|
||||
)}
|
||||
data-testid="video-inline-controls"
|
||||
>
|
||||
<div className="relative isolate flex items-center gap-1 rounded-[10px] px-1.5 py-1">
|
||||
<GlassSurface />
|
||||
<button
|
||||
aria-label={isPlaying ? "Pause video" : "Play video"}
|
||||
className="flex h-7 w-7 shrink-0 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={handleTogglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="pointer-events-none h-4 w-4 fill-white" />
|
||||
) : (
|
||||
<Play className="pointer-events-none h-4 w-4 fill-white" />
|
||||
)}
|
||||
</button>
|
||||
<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"
|
||||
@@ -1260,6 +1251,7 @@ function VideoReviewDialog({
|
||||
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;
|
||||
@@ -1364,6 +1356,7 @@ function VideoReviewDialog({
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setIsComposerMounted(false);
|
||||
setHasVisibleFrame(false);
|
||||
return;
|
||||
}
|
||||
// Two frames: one for the dialog to paint, one for the browser to
|
||||
@@ -1715,7 +1708,7 @@ function VideoReviewDialog({
|
||||
className="h-full w-full min-h-0 object-contain"
|
||||
playsInline
|
||||
poster={poster}
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
src={src}
|
||||
onClick={togglePlay}
|
||||
onDurationChange={(event) =>
|
||||
@@ -1740,6 +1733,7 @@ function VideoReviewDialog({
|
||||
syncCurrentTime(pendingSeekSeconds);
|
||||
}
|
||||
}}
|
||||
onLoadedData={() => setHasVisibleFrame(true)}
|
||||
onPause={(event) => {
|
||||
syncCurrentTime(event.currentTarget.currentTime);
|
||||
setIsPlaying(false);
|
||||
@@ -1748,7 +1742,15 @@ function VideoReviewDialog({
|
||||
syncCurrentTime(event.currentTarget.currentTime);
|
||||
setIsPlaying(true);
|
||||
}}
|
||||
onSeeked={reviewSeek.handleSeeked}
|
||||
onSeeked={(event) => {
|
||||
reviewSeek.handleSeeked();
|
||||
if (
|
||||
event.currentTarget.readyState >=
|
||||
HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
) {
|
||||
setHasVisibleFrame(true);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(event) => {
|
||||
syncCurrentTime(event.currentTarget.currentTime);
|
||||
}}
|
||||
@@ -1757,6 +1759,10 @@ function VideoReviewDialog({
|
||||
setMuted(event.currentTarget.muted);
|
||||
}}
|
||||
/>
|
||||
<VideoReviewPosterPreview
|
||||
poster={poster}
|
||||
visible={!hasVisibleFrame}
|
||||
/>
|
||||
</div>
|
||||
<div className="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">
|
||||
@@ -1903,12 +1909,12 @@ function VideoReviewDialog({
|
||||
|
||||
{showCommentsPanel ? (
|
||||
<aside
|
||||
className="min-h-0 shrink-0 overflow-hidden transition-[width] duration-200 ease-out"
|
||||
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-background">
|
||||
<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" />
|
||||
@@ -1960,7 +1966,7 @@ function VideoReviewDialog({
|
||||
className={cn(
|
||||
"rounded-md px-2 py-1 font-mono text-xs font-semibold transition-colors",
|
||||
!replyTarget && postAtCurrentFrame
|
||||
? TIMECODE_ACCENT_CLASS
|
||||
? VIDEO_REVIEW_TIMECODE_ACCENT_CLASS
|
||||
: "bg-muted text-muted-foreground/70",
|
||||
)}
|
||||
data-testid="video-review-composer-timecode"
|
||||
@@ -2124,19 +2130,10 @@ function VideoReviewCommentBody({
|
||||
const text = item.text || item.comment.body;
|
||||
const timecodeButton =
|
||||
item.seconds !== null && item.timecode ? (
|
||||
<button
|
||||
aria-label={`Jump to ${item.timecode}`}
|
||||
className={cn(
|
||||
"inline-flex h-5 shrink-0 items-center rounded px-1.5 align-middle font-mono text-2xs font-semibold outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
TIMECODE_ACCENT_CLASS,
|
||||
TIMECODE_ACCENT_HOVER_CLASS,
|
||||
)}
|
||||
data-testid="video-review-comment-timecode"
|
||||
type="button"
|
||||
<VideoReviewTimecodeButton
|
||||
timecode={item.timecode}
|
||||
onClick={() => onSeek(item.seconds ?? 0)}
|
||||
>
|
||||
{item.timecode}
|
||||
</button>
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
|
||||
type OpenVideoReview = (seconds: number) => void;
|
||||
|
||||
type VideoReviewNavigationValue = {
|
||||
open: (rootEventId: string, seconds: number) => void;
|
||||
register: (
|
||||
rootEventId: string,
|
||||
attachmentKey: string,
|
||||
handler: OpenVideoReview,
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
const VideoReviewNavigationContext =
|
||||
React.createContext<VideoReviewNavigationValue | null>(null);
|
||||
|
||||
export function VideoReviewNavigationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Review comments are scoped to their message, not an individual attachment.
|
||||
// Keep attachment registration order so a multi-video message always opens
|
||||
// its first video instead of whichever player happened to update last.
|
||||
const handlersRef = React.useRef(
|
||||
new Map<string, Map<string, Set<OpenVideoReview>>>(),
|
||||
);
|
||||
const value = React.useMemo<VideoReviewNavigationValue>(
|
||||
() => ({
|
||||
open(rootEventId, seconds) {
|
||||
handlersRef.current
|
||||
.get(rootEventId)
|
||||
?.values()
|
||||
.next()
|
||||
.value?.values()
|
||||
.next()
|
||||
.value?.(seconds);
|
||||
},
|
||||
register(rootEventId, attachmentKey, handler) {
|
||||
let rootHandlers = handlersRef.current.get(rootEventId);
|
||||
if (!rootHandlers) {
|
||||
rootHandlers = new Map();
|
||||
handlersRef.current.set(rootEventId, rootHandlers);
|
||||
}
|
||||
let attachmentHandlers = rootHandlers.get(attachmentKey);
|
||||
if (!attachmentHandlers) {
|
||||
attachmentHandlers = new Set();
|
||||
rootHandlers.set(attachmentKey, attachmentHandlers);
|
||||
}
|
||||
attachmentHandlers.add(handler);
|
||||
return () => {
|
||||
const registeredHandlers = handlersRef.current.get(rootEventId);
|
||||
const registeredAttachmentHandlers =
|
||||
registeredHandlers?.get(attachmentKey);
|
||||
registeredAttachmentHandlers?.delete(handler);
|
||||
if (registeredAttachmentHandlers?.size === 0) {
|
||||
registeredHandlers?.delete(attachmentKey);
|
||||
}
|
||||
if (registeredHandlers?.size === 0) {
|
||||
handlersRef.current.delete(rootEventId);
|
||||
}
|
||||
};
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<VideoReviewNavigationContext.Provider value={value}>
|
||||
{children}
|
||||
</VideoReviewNavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOpenVideoReviewAt():
|
||||
| VideoReviewNavigationValue["open"]
|
||||
| null {
|
||||
return React.useContext(VideoReviewNavigationContext)?.open ?? null;
|
||||
}
|
||||
|
||||
export function useRegisterVideoReview(
|
||||
reviewContext: { rootEventId?: string } | undefined,
|
||||
attachmentKey: string,
|
||||
handler: OpenVideoReview,
|
||||
): void {
|
||||
const navigation = React.useContext(VideoReviewNavigationContext);
|
||||
const rootEventId = reviewContext?.rootEventId;
|
||||
const handlerRef = React.useRef(handler);
|
||||
React.useLayoutEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
const registeredHandler = React.useCallback(
|
||||
(seconds: number) => handlerRef.current(seconds),
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!navigation || !rootEventId) return;
|
||||
return navigation.register(rootEventId, attachmentKey, registeredHandler);
|
||||
}, [attachmentKey, navigation, registeredHandler, rootEventId]);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function VideoReviewPosterPreview({
|
||||
poster,
|
||||
visible,
|
||||
}: {
|
||||
poster?: string;
|
||||
visible: boolean;
|
||||
}) {
|
||||
if (!poster || !visible) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
|
||||
data-testid="video-review-poster-preview"
|
||||
decoding="sync"
|
||||
draggable={false}
|
||||
fetchPriority="high"
|
||||
src={poster}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const TIMECODE_ACCENT_CLASS =
|
||||
"bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.15)] text-[hsl(var(--buzz-video-review-accent-foreground,var(--buzz-video-review-accent,var(--primary))))]";
|
||||
const TIMECODE_ACCENT_HOVER_CLASS =
|
||||
"hover:bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.3)]";
|
||||
const MESSAGE_TIMECODE_ACCENT_CLASS =
|
||||
"bg-primary/15 text-primary hover:bg-primary/30";
|
||||
|
||||
export function VideoReviewTimecodeButton({
|
||||
className,
|
||||
onClick,
|
||||
surface = "review",
|
||||
timecode,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
surface?: "message" | "review";
|
||||
timecode: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={`Jump to ${timecode}`}
|
||||
className={cn(
|
||||
"inline-flex h-5 shrink-0 items-center rounded px-1.5 align-middle font-mono text-2xs font-semibold outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
surface === "review"
|
||||
? [TIMECODE_ACCENT_CLASS, TIMECODE_ACCENT_HOVER_CLASS]
|
||||
: MESSAGE_TIMECODE_ACCENT_CLASS,
|
||||
className,
|
||||
)}
|
||||
data-testid="video-review-comment-timecode"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{timecode}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const VIDEO_REVIEW_TIMECODE_ACCENT_CLASS = TIMECODE_ACCENT_CLASS;
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseVideoReviewTimecode } from "./videoReviewTimecode.ts";
|
||||
|
||||
test("parseVideoReviewTimecode extracts supported leading timecodes", () => {
|
||||
assert.deepEqual(parseVideoReviewTimecode("[00:10.7] Tighten **this** cut"), {
|
||||
seconds: 10.7,
|
||||
text: "Tighten **this** cut",
|
||||
timecode: "00:10.7",
|
||||
});
|
||||
assert.deepEqual(parseVideoReviewTimecode("[1:02:03] Long-form note"), {
|
||||
seconds: 3723,
|
||||
text: "Long-form note",
|
||||
timecode: "1:02:03",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseVideoReviewTimecode ignores ordinary bracketed markdown", () => {
|
||||
assert.equal(parseVideoReviewTimecode("[docs](https://example.com)"), null);
|
||||
assert.equal(parseVideoReviewTimecode("Comment at [00:10]"), null);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export type VideoReviewTimecode = {
|
||||
seconds: number;
|
||||
text: string;
|
||||
timecode: string;
|
||||
};
|
||||
|
||||
const TIMECODE_RE =
|
||||
/^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
|
||||
|
||||
function parseTimecode(value: string): number | null {
|
||||
const parts = value.split(":").map((part) => Number(part));
|
||||
if (parts.some((part) => !Number.isFinite(part) || part < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseVideoReviewTimecode(
|
||||
content: string,
|
||||
): VideoReviewTimecode | null {
|
||||
const match = content.match(TIMECODE_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const seconds = parseTimecode(match[1]);
|
||||
if (seconds === null) return null;
|
||||
|
||||
return {
|
||||
seconds,
|
||||
text: content.slice(match[0].length).trim(),
|
||||
timecode: match[1],
|
||||
};
|
||||
}
|
||||
@@ -624,12 +624,20 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
// must not remount the review dialog or wipe an in-progress comment draft.
|
||||
await commentBox.click();
|
||||
await commentBox.fill("Second pass note");
|
||||
const commentEditor = await commentBox.elementHandle();
|
||||
if (!commentEditor) {
|
||||
throw new Error("Expected the review comment editor to be mounted.");
|
||||
}
|
||||
await emitMockMessage(page, "general", "Unrelated chatter mid-review");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "Unrelated chatter mid-review" }),
|
||||
).toHaveCount(1);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
expect(await commentEditor.evaluate((element) => element.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
await expect(commentBox).toHaveText("Second pass note");
|
||||
await expect(commentBox).toBeFocused();
|
||||
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
|
||||
@@ -746,6 +754,14 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
.click({ position: { x: 4, y: 4 } });
|
||||
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0);
|
||||
|
||||
// Re-open the channel so the thread summary is sourced from the persisted
|
||||
// mock history instead of depending on whether the background timeline row
|
||||
// stayed mounted while the modal handled live comment updates.
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const videoSummaryRow = page.locator(
|
||||
`[data-thread-head-id="${videoMessageId}"]`,
|
||||
);
|
||||
@@ -771,6 +787,103 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
).toContainText("Color pass looks right");
|
||||
});
|
||||
|
||||
test("inline video hover reveals a timeline without a second play control", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installVideoReviewHarness(page);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
|
||||
await emitMockMessage(page, "general", ``, {
|
||||
extraTags: [
|
||||
[
|
||||
"imeta",
|
||||
`url ${VIDEO_URL}`,
|
||||
"m video/mp4",
|
||||
`x ${VIDEO_SHA}`,
|
||||
"size 987654",
|
||||
"dim 160x80",
|
||||
"duration 12.5",
|
||||
`image ${POSTER_DATA_URL}`,
|
||||
"filename launch-demo.mp4",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const player = page.getByTestId("video-player").last();
|
||||
const video = player.locator("video");
|
||||
const surface = video.locator("..");
|
||||
const centerPlayback = player.getByTestId("video-inline-center-playback");
|
||||
const centerIcon = player.getByTestId("video-inline-center-icon");
|
||||
const controls = player.getByTestId("video-inline-controls");
|
||||
|
||||
await expect(centerPlayback).toHaveAttribute("aria-label", "Play video");
|
||||
await expect(
|
||||
controls.getByRole("button", { name: /^(?:Play|Pause) video$/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
player.getByRole("button", { name: "Open video review" }),
|
||||
).toBeVisible();
|
||||
await expect(player.getByTestId("video-inline-duration")).toHaveText("00:12");
|
||||
|
||||
await video.evaluate((element) => {
|
||||
element.currentTime = 6.25;
|
||||
element.dispatchEvent(new Event("timeupdate"));
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
player
|
||||
.getByTestId("video-inline-progress-fill")
|
||||
.evaluate((element) => element.style.width),
|
||||
)
|
||||
.toBe("50%");
|
||||
|
||||
const restingControlsBox = await controls.boundingBox();
|
||||
const restingIconTransform = await centerIcon.evaluate(
|
||||
(element) => window.getComputedStyle(element).transform,
|
||||
);
|
||||
expect(restingControlsBox).not.toBeNull();
|
||||
await expect(controls).toHaveCSS("opacity", "0");
|
||||
await surface.hover();
|
||||
await expect(controls).toHaveCSS("opacity", "1");
|
||||
const hoveredControlsBox = await controls.boundingBox();
|
||||
expect(hoveredControlsBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs((hoveredControlsBox?.y ?? 0) - (restingControlsBox?.y ?? 0)),
|
||||
).toBeLessThan(0.5);
|
||||
await expect
|
||||
.poll(() =>
|
||||
centerIcon.evaluate(
|
||||
(element) => window.getComputedStyle(element).transform,
|
||||
),
|
||||
)
|
||||
.toBe(restingIconTransform);
|
||||
|
||||
await centerPlayback.click();
|
||||
await expect
|
||||
.poll(() => video.evaluate((element) => element.paused))
|
||||
.toBe(false);
|
||||
await expect(centerPlayback).toHaveAttribute("aria-label", "Pause video");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(centerPlayback).toHaveCSS("opacity", "0");
|
||||
await surface.hover();
|
||||
await expect(centerPlayback).toHaveCSS("opacity", "1");
|
||||
await expect(centerPlayback).toHaveCSS("transition-property", "opacity");
|
||||
await expect(centerPlayback).toHaveCSS("transition-duration", "0.15s");
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await expect(centerPlayback).toHaveCSS("transition-property", "none");
|
||||
await expect(controls).toHaveCSS("transition-property", "none");
|
||||
|
||||
await centerPlayback.click();
|
||||
await expect
|
||||
.poll(() => video.evaluate((element) => element.paused))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("video replies in threads open the review comments view", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -791,6 +904,19 @@ test("video replies in threads open the review comments view", async ({
|
||||
"general",
|
||||
``,
|
||||
{
|
||||
extraTags: [
|
||||
[
|
||||
"imeta",
|
||||
`url ${VIDEO_URL}`,
|
||||
"m video/mp4",
|
||||
`x ${VIDEO_SHA}`,
|
||||
"size 987654",
|
||||
"dim 160x80",
|
||||
"duration 12.5",
|
||||
`image ${POSTER_DATA_URL}`,
|
||||
"filename launch-demo.mp4",
|
||||
],
|
||||
],
|
||||
parentEventId: root.id,
|
||||
},
|
||||
)) as { id: string };
|
||||
@@ -808,9 +934,73 @@ test("video replies in threads open the review comments view", async ({
|
||||
name: "Open video review",
|
||||
});
|
||||
await expect(reviewButton).toBeVisible();
|
||||
await reviewButton.click();
|
||||
const nestedVideoSummary = threadReplies.locator(
|
||||
`[data-thread-head-id="${videoReply.id}"]`,
|
||||
);
|
||||
await expect(nestedVideoSummary).toBeVisible();
|
||||
await expect(threadReplies.locator("video")).toHaveAttribute(
|
||||
"preload",
|
||||
"metadata",
|
||||
);
|
||||
await nestedVideoSummary.click();
|
||||
const outsideTimecode = threadReplies.getByRole("button", {
|
||||
name: "Jump to 00:01",
|
||||
});
|
||||
await expect(outsideTimecode).toBeVisible();
|
||||
await expect(outsideTimecode).toHaveAttribute(
|
||||
"data-testid",
|
||||
"video-review-comment-timecode",
|
||||
);
|
||||
const outsideTimecodeStyles = await outsideTimecode.evaluate((element) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
return {
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderRadius: styles.borderRadius,
|
||||
fontFamily: styles.fontFamily,
|
||||
height: styles.height,
|
||||
paddingLeft: styles.paddingLeft,
|
||||
paddingRight: styles.paddingRight,
|
||||
};
|
||||
});
|
||||
expect(outsideTimecodeStyles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
||||
await outsideTimecode.click();
|
||||
|
||||
const reviewDialog = page.getByTestId("video-review-dialog");
|
||||
const reviewVideo = reviewDialog.locator("video");
|
||||
const posterPreview = reviewDialog.getByTestId("video-review-poster-preview");
|
||||
await expect(posterPreview).toBeVisible();
|
||||
await expect(posterPreview).toHaveAttribute("src", POSTER_DATA_URL);
|
||||
await expect(
|
||||
reviewDialog.getByTestId("video-review-comments-panel"),
|
||||
).toHaveCSS("background-color", "oklch(0.145 0 0)");
|
||||
const modalTimecode = reviewDialog
|
||||
.getByRole("button", { name: "Jump to 00:01" })
|
||||
.first();
|
||||
await expect(modalTimecode).toBeVisible();
|
||||
const modalTimecodeStyles = await modalTimecode.evaluate((element) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
return {
|
||||
borderRadius: styles.borderRadius,
|
||||
fontFamily: styles.fontFamily,
|
||||
height: styles.height,
|
||||
paddingLeft: styles.paddingLeft,
|
||||
paddingRight: styles.paddingRight,
|
||||
};
|
||||
});
|
||||
expect(outsideTimecodeStyles).toMatchObject(modalTimecodeStyles);
|
||||
await expect(reviewVideo).toHaveAttribute("preload", "auto");
|
||||
await expect
|
||||
.poll(() =>
|
||||
reviewVideo.evaluate((video) => (video as HTMLVideoElement).paused),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
|
||||
)
|
||||
.toBe(1);
|
||||
await reviewVideo.dispatchEvent("loadeddata");
|
||||
await expect(posterPreview).toHaveCount(0);
|
||||
await expect(
|
||||
reviewDialog.getByTestId("video-review-comments-panel"),
|
||||
).toBeVisible();
|
||||
@@ -820,6 +1010,76 @@ test("video replies in threads open the review comments view", async ({
|
||||
);
|
||||
});
|
||||
|
||||
test("message timecodes deterministically open the first attached video", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installVideoReviewHarness(page);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
|
||||
const videoMessage = (await emitMockMessage(
|
||||
page,
|
||||
"general",
|
||||
`\n\n`,
|
||||
{
|
||||
extraTags: [
|
||||
[
|
||||
"imeta",
|
||||
`url ${VIDEO_URL}`,
|
||||
"m video/mp4",
|
||||
`x ${VIDEO_SHA}`,
|
||||
"dim 160x80",
|
||||
"duration 12.5",
|
||||
"filename first-cut.mp4",
|
||||
],
|
||||
[
|
||||
"imeta",
|
||||
`url ${PORTRAIT_VIDEO_URL}`,
|
||||
"m video/mp4",
|
||||
`x ${PORTRAIT_VIDEO_SHA}`,
|
||||
"dim 80x160",
|
||||
"duration 12.5",
|
||||
"filename second-cut.mp4",
|
||||
],
|
||||
],
|
||||
},
|
||||
)) as { id: string };
|
||||
await emitMockMessage(page, "general", "[00:01] Check this frame.", {
|
||||
parentEventId: videoMessage.id,
|
||||
});
|
||||
|
||||
const threadSummary = page.locator(
|
||||
`[data-thread-head-id="${videoMessage.id}"]`,
|
||||
);
|
||||
await expect(threadSummary).toBeVisible();
|
||||
await threadSummary.click();
|
||||
|
||||
const threadPanel = page.getByTestId("message-thread-panel");
|
||||
const threadHead = threadPanel.getByTestId("message-thread-head");
|
||||
const inlineVideos = threadHead.locator("video");
|
||||
await expect(inlineVideos).toHaveCount(2);
|
||||
const firstVideoSrc = await inlineVideos.nth(0).getAttribute("src");
|
||||
const secondVideoSrc = await inlineVideos.nth(1).getAttribute("src");
|
||||
expect(firstVideoSrc).toBeTruthy();
|
||||
expect(firstVideoSrc).not.toBe(secondVideoSrc);
|
||||
|
||||
await threadPanel
|
||||
.getByTestId("message-thread-replies")
|
||||
.getByRole("button", { name: "Jump to 00:01" })
|
||||
.click();
|
||||
|
||||
const reviewVideo = page.getByTestId("video-review-dialog").locator("video");
|
||||
await expect(reviewVideo).toHaveAttribute("src", firstVideoSrc ?? "");
|
||||
await expect
|
||||
.poll(() =>
|
||||
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
|
||||
)
|
||||
.toBe(1);
|
||||
});
|
||||
|
||||
test("narrow inline videos hide playback speed control", async ({ page }) => {
|
||||
await installVideoReviewHarness(page);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user