feat(playback): global speed with three strategies (#852)

* feat(playback): global speed with three strategies

Add Settings → Audio and player-bar controls for global playback speed
(speed with auto pitch correction as default, varispeed, manual pitch shift).
Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough.

* fix(playback): align seekbar, seek, and progress on content timeline

Unify UI timebase across varispeed and preserve strategies: full-track
duration, speed-scaled progress for DSP paths, and content-timeline seeks
without varispeed scaling. Reset the sample counter after seek so clicks
land correctly; restart playback on strategy/enable changes instead of
fragile hot-switching.

* fix(ui): anchor playback speed popover like volume controls

Replace the centered EQ-style modal with a player-bar popover (outside
click, Escape, reposition on scroll). Show compact controls in the bar
and overflow menu; keep strategy hints and labels in Settings only.

* docs(release): CHANGELOG and credits for playback speed (PR #852)

* docs(changelog): add playback speed entry for PR #852

* fix(clippy): simplify raw_counter_samples branch for CI

Collapse duplicate if branches flagged by clippy::if-same-then-else.

* fix(ui): wheel on pitch slider adjusts pitch in speed popover

In compact player-bar controls, scroll over the pitch row changes pitch;
elsewhere in the panel changes speed. Stop propagation so overflow menu
wheel does not tweak volume.

* fix(playback): address PR #852 review and drop ineffective dynamic imports

Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample
counter on live preserve-path speed changes; use neutral rate atomics for
radio progress; static-import playerStore in playListenSession (move preview
volume sync to previewPlayerVolumeSync side-effect module).

* fix(i18n): translate playback-rate strategy labels in all locales

Replace leftover English Varispeed/Pitch strings in ru and other non-en
settings blocks so popover strategy buttons and hints read natively.

* fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo"

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
cucadmuh
2026-05-22 19:59:28 +03:00
committed by GitHub
parent d54eceaf3b
commit e8e41752a7
64 changed files with 2405 additions and 70 deletions
+3 -5
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import { onInvoke } from '../test/mocks/tauri';
import { useLibraryIndexStore } from './libraryIndexStore';
import { usePlayerStore } from './playerStore';
import { usePreviewStore } from './previewStore';
import {
_resetPlayListenSessionForTest,
playListenSessionFinalize,
@@ -31,14 +33,12 @@ const testTrack: Track = {
};
describe('playListenSession', () => {
beforeEach(async () => {
beforeEach(() => {
_resetPlayListenSessionForTest();
useLibraryIndexStore.setState({
masterEnabled: true,
syncExcludedByServer: {},
});
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
usePlayerStore.setState({
currentRadio: null,
isPlaying: true,
@@ -76,7 +76,6 @@ describe('playListenSession', () => {
});
it('picks up engine duration from progress ticks', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({
currentTrack: { ...testTrack, duration: 0 },
});
@@ -118,7 +117,6 @@ describe('playListenSession', () => {
});
it('skips radio playback', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({
currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' },
});
+4 -6
View File
@@ -1,4 +1,6 @@
import type { Track } from './playerStoreTypes';
import { usePlayerStore } from './playerStore';
import { usePreviewStore } from './previewStore';
import {
libraryRecordPlaySession,
type PlaySessionEndReason,
@@ -48,9 +50,7 @@ function noteDurationHint(durationSec?: number): void {
}
}
async function playerGateBlocks(): Promise<boolean> {
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
function playerGateBlocks(): boolean {
if (usePreviewStore.getState().previewingId) return true;
if (usePlayerStore.getState().currentRadio) return true;
return false;
@@ -59,7 +59,7 @@ async function playerGateBlocks(): Promise<boolean> {
async function recordingEnabledForServer(serverId: string): Promise<boolean> {
if (!serverId) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
if (await playerGateBlocks()) return false;
if (playerGateBlocks()) return false;
return libraryIsReady(serverId);
}
@@ -94,7 +94,6 @@ export async function playListenSessionOnProgress(
): Promise<void> {
if (!open?.recordingEnabled) return;
noteDurationHint(durationSecHint);
const { usePlayerStore } = await import('./playerStore');
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (track?.id === open.trackId) {
@@ -126,7 +125,6 @@ export async function playListenSessionFinalize(reason: PlaySessionEndReason): P
return;
}
const { usePlayerStore } = await import('./playerStore');
const track = usePlayerStore.getState().currentTrack;
const durationSecHint = resolveDurationSecHint(
track?.id === session.trackId ? track : null,
+99
View File
@@ -0,0 +1,99 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import {
clampPlaybackPitch,
clampPlaybackSpeed,
DEFAULT_PLAYBACK_STRATEGY,
effectivePlaybackPitch,
type PlaybackStrategy,
} from '../utils/audio/playbackRateHelpers';
import {
restartPlaybackForRateChange,
shouldRestartPlaybackForRateChange,
type PlaybackRateSnapshot,
} from '../utils/audio/playbackRateRestart';
import { isOrbitPlaybackSyncActive } from '../utils/orbit';
interface PlaybackRateState extends PlaybackRateSnapshot {
setEnabled: (v: boolean) => void;
setStrategy: (s: PlaybackStrategy) => void;
setSpeed: (speed: number) => void;
setPitchSemitones: (semitones: number) => void;
applyPresetSpeed: (speed: number) => void;
syncToRust: () => void;
}
function syncPlaybackRate(state: PlaybackRateSnapshot, prev?: PlaybackRateSnapshot) {
// Orbit sync assumes 1.0× wall-clock playback; suppress DSP without mutating prefs.
const effectiveEnabled = state.enabled && !isOrbitPlaybackSyncActive();
invoke('audio_set_playback_rate', {
enabled: effectiveEnabled,
strategy: state.strategy,
speed: state.speed,
pitchSemitones: effectivePlaybackPitch(state.strategy, state.pitchSemitones),
}).catch(() => {});
if (prev && shouldRestartPlaybackForRateChange(prev, state)) {
restartPlaybackForRateChange();
}
}
export const usePlaybackRateStore = create<PlaybackRateState>()(
persist(
(set, get) => ({
enabled: false,
strategy: DEFAULT_PLAYBACK_STRATEGY,
speed: 1.0,
pitchSemitones: 0,
setEnabled: (v) => {
const prev = get();
set({ enabled: v });
syncPlaybackRate(get(), prev);
},
setStrategy: (strategy) => {
const prev = get();
set({ strategy });
syncPlaybackRate(get(), prev);
},
setSpeed: (speed) => {
const prev = get();
const clamped = clampPlaybackSpeed(speed);
set({ speed: clamped });
syncPlaybackRate(get(), prev);
},
setPitchSemitones: (semitones) => {
const prev = get();
const clamped = clampPlaybackPitch(semitones);
set({ pitchSemitones: clamped });
syncPlaybackRate(get(), prev);
},
applyPresetSpeed: (speed) => {
const prev = get();
const clamped = clampPlaybackSpeed(speed);
set({ speed: clamped });
syncPlaybackRate(get(), prev);
},
syncToRust: () => {
syncPlaybackRate(get());
},
}),
{
name: 'psysonic-playback-rate',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({
enabled: s.enabled,
strategy: s.strategy,
speed: s.speed,
pitchSemitones: s.pitchSemitones,
}),
},
),
);
+2 -2
View File
@@ -9,10 +9,10 @@ describe('playerBarLayoutStore', () => {
usePlayerBarLayoutStore.getState().reset();
});
it('starts with all five items visible in declared order', () => {
it('starts with all six items visible in declared order', () => {
const items = usePlayerBarLayoutStore.getState().items;
expect(items.map(i => i.id)).toEqual([
'starRating', 'favorite', 'lastfmLove', 'equalizer', 'miniPlayer',
'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
]);
expect(items.every(i => i.visible)).toBe(true);
});
+2
View File
@@ -5,6 +5,7 @@ export type PlayerBarLayoutItemId =
| 'starRating'
| 'favorite'
| 'lastfmLove'
| 'playbackRate'
| 'equalizer'
| 'miniPlayer';
@@ -17,6 +18,7 @@ export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
{ id: 'starRating', visible: true },
{ id: 'favorite', visible: true },
{ id: 'lastfmLove', visible: true },
{ id: 'playbackRate', visible: true },
{ id: 'equalizer', visible: true },
{ id: 'miniPlayer', visible: true },
];
-1
View File
@@ -127,4 +127,3 @@ usePlayerStore.subscribe((state, prev) => {
buffered: state.buffered,
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Side-effect wiring: keep the Rust preview sink volume aligned with the main
* player slider while a track preview is active.
*/
import { invoke } from '@tauri-apps/api/core';
import { computePreviewVolume, usePreviewStore } from './previewStore';
import { usePlayerStore } from './playerStore';
usePlayerStore.subscribe((state, prev) => {
if (state.volume === prev.volume) return;
if (!usePreviewStore.getState().previewingId) return;
invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {});
});
+1
View File
@@ -30,6 +30,7 @@ import { usePreviewStore } from './previewStore';
import { useAuthStore } from './authStore';
import { useOrbitStore } from './orbitStore';
import { usePlayerStore } from './playerStore';
import './previewPlayerVolumeSync';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset';
+3 -17
View File
@@ -4,7 +4,7 @@ import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore';
import { useOrbitStore } from './orbitStore';
import { isOrbitPlaybackSyncActive } from '../utils/orbit';
/** Minimal track info needed to surface the preview in the player bar UI. */
export interface PreviewingTrack {
@@ -55,7 +55,7 @@ const PREVIEW_VOLUME_MATCH = true;
* preview-specific gain, so we pre-multiply here). Master headroom is added on
* the Rust side.
*/
function computePreviewVolume(): number {
export function computePreviewVolume(): number {
const auth = useAuthStore.getState();
let volume = usePlayerStore.getState().volume;
if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') {
@@ -82,10 +82,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
// preview started by a guest would yank the host's track out from
// under them. UI buttons are hidden via `[data-orbit-active]` CSS;
// this guards keyboard shortcuts / programmatic callers.
const orbit = useOrbitStore.getState();
const inOrbit = (orbit.role === 'host' || orbit.role === 'guest')
&& (orbit.phase === 'active' || orbit.phase === 'joining' || orbit.phase === 'starting');
if (inOrbit) return;
if (isOrbitPlaybackSyncActive()) return;
const current = get().previewingId;
if (current === song.id) {
@@ -158,14 +155,3 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
},
}));
// Keep the preview sink in sync with player volume slider movements while a
// preview is in flight. Without this the Rust preview Sink stays at whatever
// level was set at `audio_preview_play` — slider drags only ramp the main
// sink. Auth/normalization changes during preview are intentionally ignored
// (preview is short and the case is rare).
usePlayerStore.subscribe((state, prev) => {
if (state.volume === prev.volume) return;
if (!usePreviewStore.getState().previewingId) return;
invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {});
});