mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
Persist video playback speed preference (#7336)
## Summary - persist the selected desktop video playback speed as a device-level preference - apply the shared preference to inline and review video players, including after a reload - cover persistence, validation, cross-window updates, and the end-to-end playback flow ## Verification - `node --test desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs` - `cd desktop && pnpm exec tsc --noEmit` - `cd desktop && pnpm check:px-text` - `cd desktop && pnpm test` - `cd desktop && pnpm build:e2e && pnpm exec playwright test --project=smoke video-attachment.spec.ts` - pre-push: desktop check, file-size check, typecheck, and 6,416 desktop tests --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
77
desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs
Normal file
77
desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const values = new Map();
|
||||
const windowListeners = new Map();
|
||||
|
||||
globalThis.window = {
|
||||
addEventListener: (type, listener) => windowListeners.set(type, listener),
|
||||
};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
|
||||
values.set("buzz.media.videoPlaybackSpeed", "2");
|
||||
|
||||
const preference = await import("./videoPlaybackSpeedPreference.ts");
|
||||
|
||||
test("reads the persisted speed as the starting preference", () => {
|
||||
assert.equal(preference.getVideoPlaybackSpeed(), 2);
|
||||
});
|
||||
|
||||
test("defaults missing and unsupported speeds to 1x", () => {
|
||||
assert.equal(preference.parseVideoPlaybackSpeed(null), 1);
|
||||
assert.equal(preference.parseVideoPlaybackSpeed("3"), 1);
|
||||
assert.equal(preference.parseVideoPlaybackSpeed("fast"), 1);
|
||||
assert.equal(preference.parseVideoPlaybackSpeed("1.5"), 1.5);
|
||||
});
|
||||
|
||||
test("persists a newly selected speed for later players", () => {
|
||||
preference.setVideoPlaybackSpeed(1.5);
|
||||
assert.equal(preference.getVideoPlaybackSpeed(), 1.5);
|
||||
assert.equal(values.get(preference.VIDEO_PLAYBACK_SPEED_STORAGE_KEY), "1.5");
|
||||
});
|
||||
|
||||
test("ignores speeds the control cannot display", () => {
|
||||
preference.setVideoPlaybackSpeed(1.5);
|
||||
preference.setVideoPlaybackSpeed(3);
|
||||
assert.equal(preference.getVideoPlaybackSpeed(), 1.5);
|
||||
assert.equal(values.get(preference.VIDEO_PLAYBACK_SPEED_STORAGE_KEY), "1.5");
|
||||
});
|
||||
|
||||
test("notifies subscribers when the speed changes", () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = preference.subscribeToVideoPlaybackSpeed(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
preference.setVideoPlaybackSpeed(2);
|
||||
assert.equal(notifications, 1);
|
||||
|
||||
// Re-selecting the same speed is not a change.
|
||||
preference.setVideoPlaybackSpeed(2);
|
||||
assert.equal(notifications, 1);
|
||||
|
||||
unsubscribe();
|
||||
preference.setVideoPlaybackSpeed(1);
|
||||
assert.equal(notifications, 1);
|
||||
});
|
||||
|
||||
test("notifies mounted consumers when another window changes the speed", () => {
|
||||
preference.setVideoPlaybackSpeed(1);
|
||||
let notifications = 0;
|
||||
const unsubscribe = preference.subscribeToVideoPlaybackSpeed(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
values.set("buzz.media.videoPlaybackSpeed", "0.5");
|
||||
windowListeners.get("storage")({ key: "buzz.media.videoPlaybackSpeed" });
|
||||
assert.equal(preference.getVideoPlaybackSpeed(), 0.5);
|
||||
assert.equal(notifications, 1);
|
||||
|
||||
// A redundant storage event must not needlessly re-render every player.
|
||||
windowListeners.get("storage")({ key: "buzz.media.videoPlaybackSpeed" });
|
||||
assert.equal(notifications, 1);
|
||||
unsubscribe();
|
||||
});
|
||||
116
desktop/src/shared/lib/videoPlaybackSpeedPreference.ts
Normal file
116
desktop/src/shared/lib/videoPlaybackSpeedPreference.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Device-level video playback speed. Selecting a speed in any video player
|
||||
* persists it as the preference every later player starts from, so a viewer
|
||||
* who watches at 2x does not have to re-select it per video.
|
||||
*/
|
||||
export const VIDEO_PLAYBACK_SPEED_STORAGE_KEY = "buzz.media.videoPlaybackSpeed";
|
||||
|
||||
/** Selectable speeds, fastest first to match the control's menu order. */
|
||||
export const VIDEO_PLAYBACK_SPEEDS = [
|
||||
2, 1.75, 1.5, 1.25, 1, 0.75, 0.5, 0.25,
|
||||
] as const;
|
||||
|
||||
/** Playback speed used when no valid saved preference exists. */
|
||||
export const DEFAULT_VIDEO_PLAYBACK_SPEED = 1;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let videoPlaybackSpeed: number | null = null;
|
||||
let listeningForStorageChanges = false;
|
||||
|
||||
/** True for speeds the control can actually represent. */
|
||||
export function isVideoPlaybackSpeed(speed: number): boolean {
|
||||
return VIDEO_PLAYBACK_SPEEDS.some((option) => option === speed);
|
||||
}
|
||||
|
||||
/** Parse a stored value, falling back when it is missing or unsupported. */
|
||||
export function parseVideoPlaybackSpeed(
|
||||
value: string | null | undefined,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && isVideoPlaybackSpeed(parsed)
|
||||
? parsed
|
||||
: DEFAULT_VIDEO_PLAYBACK_SPEED;
|
||||
}
|
||||
|
||||
function readStoredVideoPlaybackSpeed(): number {
|
||||
try {
|
||||
return parseVideoPlaybackSpeed(
|
||||
globalThis.localStorage?.getItem(VIDEO_PLAYBACK_SPEED_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_VIDEO_PLAYBACK_SPEED;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function listenForStorageChanges(): void {
|
||||
if (listeningForStorageChanges || !globalThis.window?.addEventListener) {
|
||||
return;
|
||||
}
|
||||
globalThis.window.addEventListener("storage", (event) => {
|
||||
if (event.key === VIDEO_PLAYBACK_SPEED_STORAGE_KEY || event.key === null) {
|
||||
const nextSpeed = readStoredVideoPlaybackSpeed();
|
||||
if (nextSpeed === videoPlaybackSpeed) return;
|
||||
videoPlaybackSpeed = nextSpeed;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
listeningForStorageChanges = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to playback-speed changes, including changes made in another
|
||||
* window. Returns an unsubscribe function.
|
||||
*/
|
||||
export function subscribeToVideoPlaybackSpeed(
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
listeners.add(listener);
|
||||
listenForStorageChanges();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the current device-level playback-speed preference. */
|
||||
export function getVideoPlaybackSpeed(): number {
|
||||
listenForStorageChanges();
|
||||
if (videoPlaybackSpeed === null) {
|
||||
videoPlaybackSpeed = readStoredVideoPlaybackSpeed();
|
||||
}
|
||||
return videoPlaybackSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the viewer's chosen speed and apply it to every mounted player.
|
||||
* Unsupported values are ignored so a stale caller cannot strand the control
|
||||
* on a speed it cannot display.
|
||||
*/
|
||||
export function setVideoPlaybackSpeed(speed: number): void {
|
||||
if (!isVideoPlaybackSpeed(speed)) return;
|
||||
const changed = getVideoPlaybackSpeed() !== speed;
|
||||
videoPlaybackSpeed = speed;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(
|
||||
VIDEO_PLAYBACK_SPEED_STORAGE_KEY,
|
||||
String(speed),
|
||||
);
|
||||
} catch {
|
||||
// Persistence is best-effort; the live preference still applies.
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
/** Subscribe a player to the shared, persisted playback speed. */
|
||||
export function useVideoPlaybackSpeed(): number {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToVideoPlaybackSpeed,
|
||||
getVideoPlaybackSpeed,
|
||||
() => DEFAULT_VIDEO_PLAYBACK_SPEED,
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,11 @@ 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 {
|
||||
setVideoPlaybackSpeed,
|
||||
useVideoPlaybackSpeed,
|
||||
VIDEO_PLAYBACK_SPEEDS,
|
||||
} from "@/shared/lib/videoPlaybackSpeedPreference";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
|
||||
@@ -112,9 +117,8 @@ type TimecodedComment = {
|
||||
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];
|
||||
const PLAYBACK_SPEEDS = VIDEO_PLAYBACK_SPEEDS;
|
||||
|
||||
/**
|
||||
* Frosted-glass backing layer for floating media controls. The parent must
|
||||
@@ -180,10 +184,6 @@ 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
|
||||
@@ -697,9 +697,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,
|
||||
);
|
||||
// Device-level preference, not per-player state: a speed chosen here is the
|
||||
// speed every later video starts at.
|
||||
const playbackSpeed = useVideoPlaybackSpeed();
|
||||
// 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] =
|
||||
@@ -738,7 +738,6 @@ export function VideoPlayer({
|
||||
setIsPlaying(false);
|
||||
setIsBuffering(false);
|
||||
setHasError(false);
|
||||
setPlaybackSpeed(DEFAULT_PLAYBACK_SPEED);
|
||||
setReviewOpenState(isVideoReviewOpen(persistedReviewKey));
|
||||
setReviewCurrentTimeState(
|
||||
getReviewPlaybackPosition(persistedReviewKey) ?? 0,
|
||||
@@ -829,9 +828,7 @@ export function VideoPlayer({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const handlePlaybackSpeedChange = React.useCallback((speed: number) => {
|
||||
if (isPlaybackSpeedOption(speed)) {
|
||||
setPlaybackSpeed(speed);
|
||||
}
|
||||
setVideoPlaybackSpeed(speed);
|
||||
}, []);
|
||||
|
||||
useSmoothPlaybackTime(videoRef, isPlaying && !reviewOpen, setCurrentTime);
|
||||
|
||||
@@ -1579,3 +1579,91 @@ test("right-click menus expose distinct selectors for links, relay video, and of
|
||||
offRelayMenu.getByRole("button", { name: "Download video" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("playback speed persists across videos and reloads", async ({ page }) => {
|
||||
await installVideoReviewHarness(page);
|
||||
|
||||
const openGeneralWithVideo = async (
|
||||
url: string,
|
||||
sha: string,
|
||||
filename: string,
|
||||
) => {
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
const emitted = (await emitMockMessage(
|
||||
page,
|
||||
"general",
|
||||
``,
|
||||
{
|
||||
extraTags: [
|
||||
[
|
||||
"imeta",
|
||||
`url ${url}`,
|
||||
"m video/mp4",
|
||||
`x ${sha}`,
|
||||
"size 987654",
|
||||
"dim 160x80",
|
||||
"duration 12.5",
|
||||
`image ${POSTER_DATA_URL}`,
|
||||
`filename ${filename}`,
|
||||
],
|
||||
],
|
||||
},
|
||||
)) as { id: string };
|
||||
const player = page
|
||||
.locator(`[data-message-id="${emitted.id}"]`)
|
||||
.getByTestId("video-player");
|
||||
await expect(player).toBeVisible();
|
||||
await player.getByRole("button", { name: "Play video" }).click();
|
||||
return player;
|
||||
};
|
||||
|
||||
await page.goto("/");
|
||||
const firstPlayer = await openGeneralWithVideo(
|
||||
VIDEO_URL,
|
||||
VIDEO_SHA,
|
||||
"launch-demo.mp4",
|
||||
);
|
||||
const firstSpeedButton = firstPlayer.getByTestId("video-inline-speed");
|
||||
await expect(firstSpeedButton).toHaveText("1x");
|
||||
await firstSpeedButton.click();
|
||||
await page
|
||||
.getByTestId("video-inline-speed-menu")
|
||||
.getByRole("button", { name: "2x", exact: true })
|
||||
.click();
|
||||
await expect(firstSpeedButton).toHaveText("2x");
|
||||
|
||||
// A different video in the same session starts at the chosen speed.
|
||||
const secondPlayer = await openGeneralWithVideo(
|
||||
MENU_RELAY_VIDEO_URL,
|
||||
MENU_RELAY_VIDEO_SHA,
|
||||
"second-demo.mp4",
|
||||
);
|
||||
const secondVideo = secondPlayer.locator("video");
|
||||
await expect(secondPlayer.getByTestId("video-inline-speed")).toHaveText("2x");
|
||||
await expect
|
||||
.poll(() =>
|
||||
secondVideo.evaluate((video) => (video as HTMLVideoElement).playbackRate),
|
||||
)
|
||||
.toBe(2);
|
||||
|
||||
// And the preference survives an app restart.
|
||||
await page.reload();
|
||||
const reloadedPlayer = await openGeneralWithVideo(
|
||||
VIDEO_URL,
|
||||
VIDEO_SHA,
|
||||
"launch-demo.mp4",
|
||||
);
|
||||
const reloadedVideo = reloadedPlayer.locator("video");
|
||||
await expect(reloadedPlayer.getByTestId("video-inline-speed")).toHaveText(
|
||||
"2x",
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
reloadedVideo.evaluate(
|
||||
(video) => (video as HTMLVideoElement).playbackRate,
|
||||
),
|
||||
)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user