From ddb44dba883f315d4da371df45acbf63f52276ab Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:12:34 -0700 Subject: [PATCH 1/2] feat(desktop): persist video playback speed preference Selecting a playback speed in a video player now persists as a device-level preference, so every later video starts at the chosen speed instead of resetting to 1x. Changing it again updates the stored preference. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../lib/videoPlaybackSpeedPreference.test.mjs | 66 ++++++++++ .../lib/videoPlaybackSpeedPreference.ts | 113 ++++++++++++++++++ desktop/src/shared/ui/VideoPlayer.tsx | 23 ++-- desktop/tests/e2e/video-attachment.spec.ts | 81 +++++++++++++ 4 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs create mode 100644 desktop/src/shared/lib/videoPlaybackSpeedPreference.ts diff --git a/desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs b/desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs new file mode 100644 index 00000000000..aa6c7322726 --- /dev/null +++ b/desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs @@ -0,0 +1,66 @@ +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("adopts a speed changed in another window", () => { + preference.setVideoPlaybackSpeed(1); + values.set("buzz.media.videoPlaybackSpeed", "0.5"); + windowListeners.get("storage")({ key: "buzz.media.videoPlaybackSpeed" }); + assert.equal(preference.getVideoPlaybackSpeed(), 0.5); +}); diff --git a/desktop/src/shared/lib/videoPlaybackSpeedPreference.ts b/desktop/src/shared/lib/videoPlaybackSpeedPreference.ts new file mode 100644 index 00000000000..b7b01c7418a --- /dev/null +++ b/desktop/src/shared/lib/videoPlaybackSpeedPreference.ts @@ -0,0 +1,113 @@ +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; + +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); +} + +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); + }; +} + +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, + ); +} diff --git a/desktop/src/shared/ui/VideoPlayer.tsx b/desktop/src/shared/ui/VideoPlayer.tsx index f94eb5cc5ce..67036050109 100644 --- a/desktop/src/shared/ui/VideoPlayer.tsx +++ b/desktop/src/shared/ui/VideoPlayer.tsx @@ -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); diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 1ce0b6d85a8..8580e86c4a5 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -1579,3 +1579,84 @@ 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"); + 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}`, + ], + ], + }); + const player = page.getByTestId("video-player").last(); + 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); +}); From 6961ee3078ef627102834116fc11100bb8eb3fc3 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:01:17 -0700 Subject: [PATCH 2/2] fix(desktop): harden video speed preference coverage Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../lib/videoPlaybackSpeedPreference.test.mjs | 13 ++++++- .../lib/videoPlaybackSpeedPreference.ts | 3 ++ desktop/tests/e2e/video-attachment.spec.ts | 37 +++++++++++-------- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs b/desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs index aa6c7322726..5ca72e44d32 100644 --- a/desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs +++ b/desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs @@ -58,9 +58,20 @@ test("notifies subscribers when the speed changes", () => { assert.equal(notifications, 1); }); -test("adopts a speed changed in another window", () => { +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(); }); diff --git a/desktop/src/shared/lib/videoPlaybackSpeedPreference.ts b/desktop/src/shared/lib/videoPlaybackSpeedPreference.ts index b7b01c7418a..bf584d7a6af 100644 --- a/desktop/src/shared/lib/videoPlaybackSpeedPreference.ts +++ b/desktop/src/shared/lib/videoPlaybackSpeedPreference.ts @@ -12,6 +12,7 @@ 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>(); @@ -23,6 +24,7 @@ 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 { @@ -75,6 +77,7 @@ export function subscribeToVideoPlaybackSpeed( }; } +/** Return the current device-level playback-speed preference. */ export function getVideoPlaybackSpeed(): number { listenForStorageChanges(); if (videoPlaybackSpeed === null) { diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 8580e86c4a5..2c86f5a9bda 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -1591,22 +1591,29 @@ test("playback speed persists across videos and reloads", async ({ page }) => { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - 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}`, + 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}`, + ], ], - ], - }); - const player = page.getByTestId("video-player").last(); + }, + )) 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;