Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/components/layout/floating-player-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type TransportSnapshot = Pick<
| "playing"
| "volume"
| "muted"
| "playbackRate"
| "position"
| "duration"
>;
Expand All @@ -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,
};
Expand Down Expand Up @@ -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
);
Expand All @@ -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 }
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/player-bar-bottom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "@/components/layout/lyrics-view";
import {
ProgressSlider,
SpeedControl,
VolumeControl,
formatTime,
repeatLabel,
Expand Down Expand Up @@ -235,6 +236,7 @@ export function PlayerBarBottom() {
{track ? <LikeDislikeButtons videoId={track.videoId} track={track} /> : null}
<LyricsPopover state={lyricsState} />
<QueuePopover />
<SpeedControl />
<VolumeControl direction="vertical" />
<PlayerMoreMenu track={track} />
</div>
Expand Down
72 changes: 70 additions & 2 deletions src/components/layout/player-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -40,15 +48,22 @@ 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,
type SourceKind,
} 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
Expand Down Expand Up @@ -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 (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={`Playback speed ${formatPlaybackRate(playbackRate)}`}
aria-pressed={active}
className={cn(
"min-w-9 px-1.5 text-xs font-semibold tabular-nums",
active && "text-brand",
)}
>
{formatPlaybackRate(playbackRate)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Playback speed</TooltipContent>
</Tooltip>
<DropdownMenuContent align="center" side="top" className="w-36">
<DropdownMenuLabel>Speed</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={String(playbackRate)}
onValueChange={(v) => {
const n = Number(v);
if (Number.isFinite(n)) setPlaybackRate(n as PlaybackRate);
}}
>
{PLAYBACK_RATES.map((rate) => (
<DropdownMenuRadioItem key={rate} value={String(rate)}>
{formatPlaybackRate(rate)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

export function VolumeControl({
direction = "horizontal",
compact = false,
Expand Down Expand Up @@ -709,6 +776,7 @@ export function PlayerBar({
open={queueOpen}
onToggle={() => setQueueOpen((v) => !v)}
/>
<SpeedControl />
<VolumeControl compact={compactControls} />
</div>
<div className="flex items-center gap-1">
Expand Down
24 changes: 19 additions & 5 deletions src/lib/audio-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -631,7 +645,7 @@ export function useAudioEngine() {
startMs,
endMs,
}).catch(() => {});
}, [track, playing, duration, discordRp]);
}, [track, playing, duration, playbackRate, discordRp]);
}

function buildArtistLabel(track: QueueTrack): string {
Expand Down
23 changes: 23 additions & 0 deletions src/lib/store/playback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ function setup(partial: Partial<PlaybackState>): 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({}));

Expand Down
40 changes: 36 additions & 4 deletions src/lib/store/playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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). */
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -179,6 +198,7 @@ const playbackStateCreator: StateCreator<PlaybackState> = (set, get) => ({
playing: false,
volume: 0.8,
muted: false,
playbackRate: 1,
position: 0,
duration: 0,
pendingSeek: undefined,
Expand Down Expand Up @@ -441,6 +461,10 @@ const playbackStateCreator: StateCreator<PlaybackState> = (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 };
Expand Down Expand Up @@ -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;
Expand All @@ -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;
},
}),
);
Expand All @@ -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
Expand Down Expand Up @@ -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 }),
Expand Down