From 3dccceb06678cc20d51068db21a5aea1dbd0e279 Mon Sep 17 00:00:00 2001
From: Mihai M <695577+mihaimetal@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:00:28 +0300
Subject: [PATCH] feat(playback): add selectable playback speed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Expose discrete rates (0.1×–2×) on the player chrome, persist the
choice, and drive HTMLAudioElement.playbackRate. Floating player and
Discord progress timestamps follow the selected rate.
---
.../layout/floating-player-sync.tsx | 8 +++
src/components/layout/player-bar-bottom.tsx | 2 +
src/components/layout/player-bar.tsx | 72 ++++++++++++++++++-
src/lib/audio-engine.ts | 24 +++++--
src/lib/store/playback.test.ts | 23 ++++++
src/lib/store/playback.ts | 40 +++++++++--
6 files changed, 158 insertions(+), 11 deletions(-)
diff --git a/src/components/layout/floating-player-sync.tsx b/src/components/layout/floating-player-sync.tsx
index 66fa742..9edc9db 100644
--- a/src/components/layout/floating-player-sync.tsx
+++ b/src/components/layout/floating-player-sync.tsx
@@ -37,6 +37,7 @@ type TransportSnapshot = Pick<
| "playing"
| "volume"
| "muted"
+ | "playbackRate"
| "position"
| "duration"
>;
@@ -57,6 +58,7 @@ function buildTransportSnapshot(s: PlaybackState): TransportSnapshot {
playing: s.playing,
volume: s.volume,
muted: s.muted,
+ playbackRate: s.playbackRate,
position: s.position,
duration: s.duration,
};
@@ -90,6 +92,7 @@ function transportChanged(prev: PlaybackState, curr: PlaybackState): boolean {
prev.playing !== curr.playing ||
prev.volume !== curr.volume ||
prev.muted !== curr.muted ||
+ prev.playbackRate !== curr.playbackRate ||
prev.position !== curr.position ||
prev.duration !== curr.duration
);
@@ -102,6 +105,7 @@ type PlaybackAction =
| { type: "seek"; seconds: number }
| { type: "setVolume"; volume: number }
| { type: "toggleMute" }
+ | { type: "setPlaybackRate"; rate: number }
| { type: "setShuffle"; on: boolean }
| { type: "cycleRepeat" }
| { type: "goTo"; index: number }
@@ -197,6 +201,10 @@ export function FloatingPlayerSync() {
case "toggleMute":
store.toggleMute();
break;
+ case "setPlaybackRate":
+ // Rate is validated inside the store (PLAYBACK_RATES allow-list).
+ store.setPlaybackRate(a.rate as never);
+ break;
case "setShuffle":
store.setShuffle(a.on);
break;
diff --git a/src/components/layout/player-bar-bottom.tsx b/src/components/layout/player-bar-bottom.tsx
index 6281d60..c0bb43d 100644
--- a/src/components/layout/player-bar-bottom.tsx
+++ b/src/components/layout/player-bar-bottom.tsx
@@ -34,6 +34,7 @@ import {
} from "@/components/layout/lyrics-view";
import {
ProgressSlider,
+ SpeedControl,
VolumeControl,
formatTime,
repeatLabel,
@@ -235,6 +236,7 @@ export function PlayerBarBottom() {
{track ? : null}
+
diff --git a/src/components/layout/player-bar.tsx b/src/components/layout/player-bar.tsx
index 6c52e79..9dbeadf 100644
--- a/src/components/layout/player-bar.tsx
+++ b/src/components/layout/player-bar.tsx
@@ -32,6 +32,14 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Slider } from "@/components/ui/slider";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { ArtworkOutline } from "@/components/shared/artwork-outline";
import { Thumbnail } from "@/components/shared/thumbnail";
import { LikeDislikeButtons } from "@/components/shared/like-buttons";
@@ -40,7 +48,15 @@ import { PlayerMoreMenu } from "@/components/layout/player-more-menu";
import { PlayerCoverMenu } from "@/components/layout/player-cover-menu";
import { cn } from "@/lib/utils";
import { usePlayerCoverDrag } from "@/lib/player-drag";
-import { usePlaybackStore, currentTrack } from "@/lib/store/playback";
+import {
+ usePlaybackStore,
+ currentTrack,
+ PLAYBACK_RATES,
+ formatPlaybackRate,
+ type PlaybackRate,
+ type QueueTrack,
+ type RepeatMode,
+} from "@/lib/store/playback";
import { useScrubStore } from "@/lib/store/scrub";
import {
useTrackSourceStore,
@@ -48,7 +64,6 @@ import {
} from "@/lib/store/track-source";
import { findAlternateVideoId } from "@/lib/innertube/alternate-source";
import { lookupITunesCover, cacheCoverToDisk } from "@/lib/cover-art";
-import type { QueueTrack, RepeatMode } from "@/lib/store/playback";
/**
* Look up a 3000×3000 studio cover from iTunes for the now-playing
@@ -275,6 +290,58 @@ export function ProgressSlider({
);
}
+/**
+ * Playback-speed picker. Shows the current rate as a compact label
+ * ("1×", "0.5×", …) and opens a radio menu of the discrete steps from
+ * `PLAYBACK_RATES`. Non-1× rates light up brand-colored so a slowed or
+ * sped track is obvious at a glance.
+ */
+export function SpeedControl() {
+ const playbackRate = usePlaybackStore((s) => s.playbackRate);
+ const setPlaybackRate = usePlaybackStore((s) => s.setPlaybackRate);
+ const active = playbackRate !== 1;
+
+ return (
+
+
+
+
+
+
+
+ Playback speed
+
+
+ Speed
+ {
+ const n = Number(v);
+ if (Number.isFinite(n)) setPlaybackRate(n as PlaybackRate);
+ }}
+ >
+ {PLAYBACK_RATES.map((rate) => (
+
+ {formatPlaybackRate(rate)}
+
+ ))}
+
+
+
+ );
+}
+
export function VolumeControl({
direction = "horizontal",
compact = false,
@@ -709,6 +776,7 @@ export function PlayerBar({
open={queueOpen}
onToggle={() => setQueueOpen((v) => !v)}
/>
+
diff --git a/src/lib/audio-engine.ts b/src/lib/audio-engine.ts
index bb817fe..4e3b79d 100644
--- a/src/lib/audio-engine.ts
+++ b/src/lib/audio-engine.ts
@@ -322,6 +322,16 @@ export function useAudioEngine() {
el.muted = muted;
}, [volume, muted]);
+ // Playback speed follow store. HTMLAudioElement keeps the last rate
+ // across src swaps, but we still re-apply on every change so a
+ // rehydrated rate (or floating-window action) lands immediately.
+ const playbackRate = usePlaybackStore((s) => s.playbackRate);
+ useEffect(() => {
+ const el = audioRef.current;
+ if (!el) return;
+ el.playbackRate = playbackRate;
+ }, [playbackRate]);
+
// Handle seek requests.
const pendingSeek = usePlaybackStore((s) => s.pendingSeek);
useEffect(() => {
@@ -595,7 +605,9 @@ export function useAudioEngine() {
if (!playing) return;
const id = window.setInterval(push, 2000);
return () => window.clearInterval(id);
- }, [track, playing, duration, shuffle, repeat, liked]);
+ // playbackRate is included so a speed change re-pushes OS metadata while
+ // playing (souvlaki does not re-read the element rate on its own).
+ }, [track, playing, duration, shuffle, repeat, liked, playbackRate]);
// Discord Rich Presence mirrors the same metadata, but pushed only on
// track / play-state / duration change — never the 2s position refresh
@@ -616,12 +628,14 @@ export function useAudioEngine() {
const dur = Number.isFinite(s.duration) ? s.duration : 0;
// Timestamps (hence the progress bar) only while actually playing: Discord
// can't freeze a bar, so paused shows none rather than a wrong one. Unix
- // milliseconds, per Discord's Activity spec.
+ // milliseconds, per Discord's Activity spec. Scale by playbackRate so the
+ // bar tracks wall-clock remaining time at 0.5× / 2× etc.
let startMs: number | null = null;
let endMs: number | null = null;
if (s.playing && dur > 0) {
- startMs = Math.round(Date.now() - s.position * 1000);
- endMs = Math.round(startMs + dur * 1000);
+ const rate = Math.max(0.01, s.playbackRate);
+ startMs = Math.round(Date.now() - (s.position / rate) * 1000);
+ endMs = Math.round(startMs + (dur / rate) * 1000);
}
void invoke("discord_update", {
title: t.title,
@@ -631,7 +645,7 @@ export function useAudioEngine() {
startMs,
endMs,
}).catch(() => {});
- }, [track, playing, duration, discordRp]);
+ }, [track, playing, duration, playbackRate, discordRp]);
}
function buildArtistLabel(track: QueueTrack): string {
diff --git a/src/lib/store/playback.test.ts b/src/lib/store/playback.test.ts
index 3a92f73..463d1ad 100644
--- a/src/lib/store/playback.test.ts
+++ b/src/lib/store/playback.test.ts
@@ -33,6 +33,29 @@ function setup(partial: Partial
): void {
});
}
+describe("playback setPlaybackRate()", () => {
+ beforeEach(() => setup({}));
+
+ it("accepts discrete rates and rejects unknowns", () => {
+ const store = usePlaybackStore.getState();
+ store.setPlaybackRate(0.5);
+ expect(usePlaybackStore.getState().playbackRate).toBe(0.5);
+ store.setPlaybackRate(1.25);
+ expect(usePlaybackStore.getState().playbackRate).toBe(1.25);
+ store.setPlaybackRate(1.5);
+ expect(usePlaybackStore.getState().playbackRate).toBe(1.5);
+ store.setPlaybackRate(2);
+ expect(usePlaybackStore.getState().playbackRate).toBe(2);
+ // Unknown rates are ignored so a stale floater event can't set 1.1×.
+ store.setPlaybackRate(1.1 as never);
+ expect(usePlaybackStore.getState().playbackRate).toBe(2);
+ store.setPlaybackRate(0.1);
+ expect(usePlaybackStore.getState().playbackRate).toBe(0.1);
+ store.setPlaybackRate(1);
+ expect(usePlaybackStore.getState().playbackRate).toBe(1);
+ });
+});
+
describe("playback next()", () => {
beforeEach(() => setup({}));
diff --git a/src/lib/store/playback.ts b/src/lib/store/playback.ts
index 546e476..241052e 100644
--- a/src/lib/store/playback.ts
+++ b/src/lib/store/playback.ts
@@ -24,6 +24,22 @@ export type RepeatMode = "off" | "all" | "one";
export type LoadStatus = "idle" | "loading" | "ready" | "error";
+/** Discrete rates exposed in the player UI. Maps 1:1 to HTMLAudioElement.playbackRate. */
+export const PLAYBACK_RATES = [0.1, 0.25, 0.5, 1, 1.25, 1.5, 2] as const;
+export type PlaybackRate = (typeof PLAYBACK_RATES)[number];
+
+export function isPlaybackRate(v: number): v is PlaybackRate {
+ return (PLAYBACK_RATES as readonly number[]).includes(v);
+}
+
+export function formatPlaybackRate(rate: number): string {
+ // Keep the label short in the transport chrome ("1×", "0.25×").
+ if (rate === 1) return "1×";
+ // Trim trailing zeros so 0.50 → 0.5, but keep 0.25 intact.
+ const s = Number.isInteger(rate) ? String(rate) : String(rate);
+ return `${s}×`;
+}
+
export type PlaybackState = {
// Queue
queue: QueueTrack[];
@@ -42,6 +58,8 @@ export type PlaybackState = {
/** 0..1 — store as fraction; UI can scale. */
volume: number;
muted: boolean;
+ /** HTMLAudioElement.playbackRate — discrete steps from PLAYBACK_RATES. */
+ playbackRate: PlaybackRate;
/** Current playhead, seconds. */
position: number;
/** Real duration (from audio element, once loaded). */
@@ -88,9 +106,10 @@ export type PlaybackState = {
seek: (seconds: number) => void;
clearPendingSeek: () => void;
- // Actions — volume
+ // Actions — volume / speed
setVolume: (volume: number) => void;
toggleMute: () => void;
+ setPlaybackRate: (rate: PlaybackRate) => void;
setShuffle: (on: boolean) => void;
cycleRepeat: () => void;
};
@@ -179,6 +198,7 @@ const playbackStateCreator: StateCreator = (set, get) => ({
playing: false,
volume: 0.8,
muted: false,
+ playbackRate: 1,
position: 0,
duration: 0,
pendingSeek: undefined,
@@ -441,6 +461,10 @@ const playbackStateCreator: StateCreator = (set, get) => ({
setVolume: (volume) =>
set({ volume: Math.max(0, Math.min(1, volume)), muted: false }),
toggleMute: () => set((s) => ({ muted: !s.muted })),
+ setPlaybackRate: (rate) => {
+ if (!isPlaybackRate(rate)) return;
+ set({ playbackRate: rate });
+ },
setShuffle: (on) => {
set((s) => {
if (!on) return { shuffle: false };
@@ -484,6 +508,7 @@ export const usePlaybackStore = isFloatingPlayerWindow()
queueContinuation: s.queueContinuation,
volume: s.volume,
muted: s.muted,
+ playbackRate: s.playbackRate,
}),
onRehydrateStorage: () => (state) => {
if (!state) return;
@@ -494,6 +519,8 @@ export const usePlaybackStore = isFloatingPlayerWindow()
state.streamUrl = undefined;
state.pendingSeek = undefined;
state.error = undefined;
+ // Guard against corrupted / future-unknown rates from storage.
+ if (!isPlaybackRate(state.playbackRate)) state.playbackRate = 1;
},
}),
);
@@ -520,9 +547,9 @@ export function currentTrack(state: PlaybackState): QueueTrack | undefined {
* `FloatingPlayerSyncReceiver` writes those fields directly via
* `setState` when state events arrive.
*
- * Some actions (`seek`, `setVolume`, `toggleMute`) also do an
- * optimistic local update so the corresponding slider/icon doesn't
- * jump back for the round-trip.
+ * Some actions (`seek`, `setVolume`, `toggleMute`, `setPlaybackRate`)
+ * also do an optimistic local update so the corresponding control
+ * doesn't jump back for the round-trip.
*
* Call this from the floating window's entrypoint module before any
* component reads from the store. Guarded so an accidental call from
@@ -552,6 +579,11 @@ export function initFloatingPlaybackBridge(): void {
usePlaybackStore.setState((s) => ({ muted: !s.muted }));
sendAction({ type: "toggleMute" });
},
+ setPlaybackRate: (rate) => {
+ if (!isPlaybackRate(rate)) return;
+ usePlaybackStore.setState({ playbackRate: rate });
+ sendAction({ type: "setPlaybackRate", rate });
+ },
setShuffle: (on) => sendAction({ type: "setShuffle", on }),
cycleRepeat: () => sendAction({ type: "cycleRepeat" }),
goTo: (index) => sendAction({ type: "goTo", index }),