mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add video playback speed control (#1164)
This commit is contained in:
@@ -22,6 +22,7 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
@@ -102,6 +103,9 @@ type TimecodedComment = {
|
||||
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];
|
||||
|
||||
/**
|
||||
* Frosted-glass backing layer for floating media controls. The parent must
|
||||
@@ -163,6 +167,14 @@ function formatCommentTimecode(seconds: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatPlaybackSpeed(speed: number): string {
|
||||
return `${speed}x`;
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -603,6 +615,82 @@ function VolumeControl({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -613,6 +701,7 @@ export function VideoPlayer({
|
||||
}: VideoPlayerProps) {
|
||||
const persistedReviewKey = reviewKey ?? src;
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||
const inlineSurfaceRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const reviewVideoRef = React.useRef<HTMLVideoElement>(null);
|
||||
const [started, setStarted] = React.useState(false);
|
||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||
@@ -625,6 +714,9 @@ export function VideoPlayer({
|
||||
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,
|
||||
);
|
||||
const [naturalAspectRatio, setNaturalAspectRatio] = React.useState<
|
||||
number | null
|
||||
>(null);
|
||||
@@ -637,6 +729,9 @@ export function VideoPlayer({
|
||||
const [pendingSeekSeconds, setPendingSeekSeconds] = React.useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [inlineRenderedWidth, setInlineRenderedWidth] = React.useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// The imeta duration seeds the timeline before metadata loads; metadata
|
||||
@@ -656,6 +751,7 @@ export function VideoPlayer({
|
||||
setIsPlaying(false);
|
||||
setIsBuffering(false);
|
||||
setHasError(false);
|
||||
setPlaybackSpeed(DEFAULT_PLAYBACK_SPEED);
|
||||
setReviewOpenState(isVideoReviewOpen(persistedReviewKey));
|
||||
setReviewCurrentTimeState(
|
||||
getReviewPlaybackPosition(persistedReviewKey) ?? 0,
|
||||
@@ -705,6 +801,53 @@ export function VideoPlayer({
|
||||
[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);
|
||||
|
||||
@@ -818,10 +961,14 @@ export function VideoPlayer({
|
||||
[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: getInlineSurfaceWidth(inlineAspectRatio),
|
||||
width: inlineSurfaceWidth,
|
||||
};
|
||||
const showControls = started && !hasError;
|
||||
|
||||
@@ -832,6 +979,7 @@ export function VideoPlayer({
|
||||
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}
|
||||
>
|
||||
@@ -865,6 +1013,7 @@ export function VideoPlayer({
|
||||
videoHeight,
|
||||
videoWidth,
|
||||
} = event.currentTarget;
|
||||
applyPlaybackSpeed(event.currentTarget);
|
||||
handleMediaDuration(mediaDuration);
|
||||
const savedSeconds =
|
||||
getInlinePlaybackPosition(persistedReviewKey);
|
||||
@@ -989,6 +1138,13 @@ export function VideoPlayer({
|
||||
>
|
||||
{formatTimecode(duration)}
|
||||
</span>
|
||||
{showInlineSpeedControl ? (
|
||||
<PlaybackSpeedControl
|
||||
playbackSpeed={playbackSpeed}
|
||||
testId="video-inline-speed"
|
||||
onPlaybackSpeedChange={handlePlaybackSpeedChange}
|
||||
/>
|
||||
) : null}
|
||||
<VolumeControl
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
@@ -1023,8 +1179,10 @@ export function VideoPlayer({
|
||||
onDurationChange={handleMediaDuration}
|
||||
onOpenChange={handleReviewOpenChange}
|
||||
onPendingSeekConsumed={handlePendingSeekConsumed}
|
||||
onPlaybackSpeedChange={handlePlaybackSpeedChange}
|
||||
open={reviewOpen}
|
||||
pendingSeekSeconds={pendingSeekSeconds}
|
||||
playbackSpeed={playbackSpeed}
|
||||
poster={poster}
|
||||
reviewContext={reviewContext}
|
||||
src={src}
|
||||
@@ -1043,8 +1201,10 @@ function VideoReviewDialog({
|
||||
onDurationChange,
|
||||
onOpenChange,
|
||||
onPendingSeekConsumed,
|
||||
onPlaybackSpeedChange,
|
||||
open,
|
||||
pendingSeekSeconds,
|
||||
playbackSpeed,
|
||||
poster,
|
||||
reviewContext,
|
||||
src,
|
||||
@@ -1058,8 +1218,10 @@ function VideoReviewDialog({
|
||||
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;
|
||||
@@ -1300,6 +1462,13 @@ function VideoReviewDialog({
|
||||
[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,
|
||||
@@ -1538,6 +1707,7 @@ function VideoReviewDialog({
|
||||
setIsPlaying(false);
|
||||
}}
|
||||
onLoadedMetadata={(event) => {
|
||||
event.currentTarget.playbackRate = playbackSpeed;
|
||||
onDurationChange(event.currentTarget.duration);
|
||||
const { videoHeight, videoWidth } = event.currentTarget;
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
@@ -1635,6 +1805,12 @@ function VideoReviewDialog({
|
||||
>
|
||||
{formatTimecode(visibleDuration)}
|
||||
</span>
|
||||
<PlaybackSpeedControl
|
||||
playbackSpeed={playbackSpeed}
|
||||
size="review"
|
||||
testId="video-review-speed"
|
||||
onPlaybackSpeedChange={onPlaybackSpeedChange}
|
||||
/>
|
||||
<VolumeControl
|
||||
expanded
|
||||
muted={muted}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const VIDEO_SHA = "b".repeat(64);
|
||||
const VIDEO_URL = `http://localhost:3000/media/${VIDEO_SHA}.mp4`;
|
||||
const PORTRAIT_VIDEO_SHA = "c".repeat(64);
|
||||
const PORTRAIT_VIDEO_URL = `http://localhost:3000/media/${PORTRAIT_VIDEO_SHA}.mp4`;
|
||||
const CONSTRAINED_LANDSCAPE_VIDEO_SHA = "d".repeat(64);
|
||||
const CONSTRAINED_LANDSCAPE_VIDEO_URL = `http://localhost:3000/media/${CONSTRAINED_LANDSCAPE_VIDEO_SHA}.mp4`;
|
||||
const POSTER_DATA_URL =
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNjAgODAiPjxyZWN0IHdpZHRoPSIxNjAiIGhlaWdodD0iODAiIGZpbGw9IiMyNjQ2NTMiLz48Y2lyY2xlIGN4PSI1NCIgY3k9IjQwIiByPSIyMiIgZmlsbD0iI2YyYzE0ZSIvPjxwYXRoIGQ9Ik05MiAyNGg0NHYzMkg5MnoiIGZpbGw9IiNmNzgxNTQiLz48L3N2Zz4=";
|
||||
|
||||
@@ -25,23 +29,29 @@ async function waitForMockLiveSubscription(page: Page, channelName: string) {
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
function emitMockMessage(page: Page, channelName: string, content: string) {
|
||||
function emitMockMessage(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
content: string,
|
||||
options: { extraTags?: string[][] } = {},
|
||||
) {
|
||||
return page.evaluate(
|
||||
({ channelName, content }) => {
|
||||
({ channelName, content, extraTags }) => {
|
||||
const emit = (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
extraTags?: string[][];
|
||||
}) => unknown;
|
||||
}
|
||||
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||||
if (!emit) {
|
||||
throw new Error("Mock message emitter is unavailable.");
|
||||
}
|
||||
emit({ channelName, content });
|
||||
emit({ channelName, content, extraTags });
|
||||
},
|
||||
{ channelName, content },
|
||||
{ channelName, content, extraTags: options.extraTags },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,6 +242,33 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
restoredInlinePlayer.getByRole("button", { name: "Pause video" }),
|
||||
).toBeVisible();
|
||||
|
||||
const inlineSpeedButton =
|
||||
restoredInlinePlayer.getByTestId("video-inline-speed");
|
||||
await expect(inlineSpeedButton).toHaveText("1x");
|
||||
await inlineSpeedButton.click();
|
||||
const inlineSpeedMenu = page.getByTestId("video-inline-speed-menu");
|
||||
await expect(inlineSpeedMenu.getByRole("button")).toHaveText([
|
||||
"2x",
|
||||
"1.75x",
|
||||
"1.5x",
|
||||
"1.25x",
|
||||
"1x",
|
||||
"0.75x",
|
||||
"0.5x",
|
||||
"0.25x",
|
||||
]);
|
||||
await inlineSpeedMenu
|
||||
.getByRole("button", { name: "1.5x", exact: true })
|
||||
.click();
|
||||
await expect(inlineSpeedButton).toHaveText("1.5x");
|
||||
await expect
|
||||
.poll(() =>
|
||||
restoredInlineVideo.evaluate(
|
||||
(video) => (video as HTMLVideoElement).playbackRate,
|
||||
),
|
||||
)
|
||||
.toBe(1.5);
|
||||
|
||||
// Inline volume controls.
|
||||
await inlinePlayer.getByRole("button", { name: "Mute" }).click();
|
||||
await expect
|
||||
@@ -272,6 +309,24 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
|
||||
const reviewVideo = reviewDialog.locator("video");
|
||||
await expect(reviewVideo).not.toHaveAttribute("controls", "");
|
||||
const reviewSpeedButton = reviewDialog.getByTestId("video-review-speed");
|
||||
await expect(reviewSpeedButton).toHaveText("1.5x");
|
||||
await expect
|
||||
.poll(() =>
|
||||
reviewVideo.evaluate((video) => (video as HTMLVideoElement).playbackRate),
|
||||
)
|
||||
.toBe(1.5);
|
||||
await reviewSpeedButton.click();
|
||||
await page
|
||||
.getByTestId("video-review-speed-menu")
|
||||
.getByRole("button", { name: "0.25x", exact: true })
|
||||
.click();
|
||||
await expect(reviewSpeedButton).toHaveText("0.25x");
|
||||
await expect
|
||||
.poll(() =>
|
||||
reviewVideo.evaluate((video) => (video as HTMLVideoElement).playbackRate),
|
||||
)
|
||||
.toBe(0.25);
|
||||
await reviewDialog.getByRole("button", { name: "Play review video" }).click();
|
||||
await expect(
|
||||
reviewDialog.getByRole("button", { name: "Pause review video" }),
|
||||
@@ -569,6 +624,16 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
.getByRole("button", { name: "Close video review" })
|
||||
.click();
|
||||
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0);
|
||||
await expect(
|
||||
restoredInlinePlayer.getByTestId("video-inline-speed"),
|
||||
).toHaveText("0.25x");
|
||||
await expect
|
||||
.poll(() =>
|
||||
restoredInlineVideo.evaluate(
|
||||
(video) => (video as HTMLVideoElement).playbackRate,
|
||||
),
|
||||
)
|
||||
.toBe(0.25);
|
||||
|
||||
await reviewButton.evaluate((button) =>
|
||||
(button as HTMLButtonElement).click(),
|
||||
@@ -603,3 +668,98 @@ test("video upload previews use poster frames and inline videos open review mode
|
||||
threadReviewDialog.getByTestId("video-review-comments"),
|
||||
).toContainText("Color pass looks right");
|
||||
});
|
||||
|
||||
test("narrow inline videos hide playback speed control", async ({ 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 ${PORTRAIT_VIDEO_URL}`,
|
||||
"m video/mp4",
|
||||
`x ${PORTRAIT_VIDEO_SHA}`,
|
||||
"size 987654",
|
||||
"dim 80x160",
|
||||
"duration 12.5",
|
||||
`image ${POSTER_DATA_URL}`,
|
||||
"filename portrait-demo.mp4",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const portraitPlayer = page.getByTestId("video-player").last();
|
||||
await expect(portraitPlayer).toBeVisible();
|
||||
const portraitBox = await portraitPlayer.boundingBox();
|
||||
expect(portraitBox?.width).toBeLessThan(220);
|
||||
|
||||
await portraitPlayer.getByRole("button", { name: "Play video" }).click();
|
||||
await expect(
|
||||
portraitPlayer.getByTestId("video-inline-controls"),
|
||||
).toBeVisible();
|
||||
await expect(portraitPlayer.getByTestId("video-inline-speed")).toHaveCount(0);
|
||||
|
||||
await portraitPlayer
|
||||
.getByRole("button", { name: "Open video review" })
|
||||
.click();
|
||||
const reviewDialog = page.getByTestId("video-review-dialog");
|
||||
await expect(reviewDialog).toBeVisible();
|
||||
await expect(reviewDialog.getByTestId("video-review-speed")).toHaveText("1x");
|
||||
});
|
||||
|
||||
test("constrained landscape inline videos measure rendered width before showing speed", async ({
|
||||
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 ${CONSTRAINED_LANDSCAPE_VIDEO_URL}`,
|
||||
"m video/mp4",
|
||||
`x ${CONSTRAINED_LANDSCAPE_VIDEO_SHA}`,
|
||||
"size 987654",
|
||||
"dim 160x80",
|
||||
"duration 12.5",
|
||||
`image ${POSTER_DATA_URL}`,
|
||||
"filename constrained-landscape-demo.mp4",
|
||||
],
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const landscapePlayer = page.getByTestId("video-player").last();
|
||||
await expect(landscapePlayer).toBeVisible();
|
||||
const fullWidthBox = await landscapePlayer.boundingBox();
|
||||
expect(fullWidthBox?.width).toBeGreaterThan(220);
|
||||
|
||||
await landscapePlayer.getByRole("button", { name: "Play video" }).click();
|
||||
await expect(
|
||||
landscapePlayer.getByTestId("video-inline-controls"),
|
||||
).toBeVisible();
|
||||
await expect(landscapePlayer.getByTestId("video-inline-speed")).toBeVisible();
|
||||
|
||||
await landscapePlayer.evaluate((element) => {
|
||||
(element as HTMLElement).style.width = "180px";
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await landscapePlayer.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
})
|
||||
.toBeLessThan(220);
|
||||
await expect(landscapePlayer.getByTestId("video-inline-speed")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user