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