Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 116 additions & 0 deletions desktop/src/shared/lib/videoPlaybackSpeedPreference.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
23 changes: 10 additions & 13 deletions desktop/src/shared/ui/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] =
Expand Down Expand Up @@ -738,7 +738,6 @@ export function VideoPlayer({
setIsPlaying(false);
setIsBuffering(false);
setHasError(false);
setPlaybackSpeed(DEFAULT_PLAYBACK_SPEED);
setReviewOpenState(isVideoReviewOpen(persistedReviewKey));
setReviewCurrentTimeState(
getReviewPlaybackPosition(persistedReviewKey) ?? 0,
Expand Down Expand Up @@ -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);
Expand Down
88 changes: 88 additions & 0 deletions desktop/tests/e2e/video-attachment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading