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:
morgmart
2026-09-04 13:10:17 -07:00
committed by GitHub
parent e09f715c9d
commit 5d107836c6
4 changed files with 291 additions and 13 deletions

View 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();
});

View 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,
);
}

View File

@@ -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);

View File

@@ -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",
`![video](${url})`,
{
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);
});