Files
Psychotoxical-psysonic/src/store/previewStore.ts
T
cucadmuh e8e41752a7 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>
2026-05-22 18:59:28 +02:00

158 lines
5.6 KiB
TypeScript

import { buildStreamUrl } from '../api/subsonicStreamUrl';
import type { TrackPreviewLocation } from './authStoreTypes';
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore';
import { isOrbitPlaybackSyncActive } from '../utils/orbit';
/** Minimal track info needed to surface the preview in the player bar UI. */
export interface PreviewingTrack {
id: string;
title: string;
artist: string;
coverArt?: string;
}
interface PreviewState {
/** Subsonic song id of the active preview, or null when nothing previews. */
previewingId: string | null;
/** Currently previewing track with the metadata the player-bar UI needs. */
previewingTrack: PreviewingTrack | null;
/** Seconds elapsed in the current preview window. */
elapsed: number;
/** Total preview window in seconds (echoes the duration_sec arg). */
duration: number;
/**
* True only after the engine has emitted `audio:preview-start` for the
* current `previewingId` — i.e. audio is actually playing. Drives the
* progress-ring animation so the ring doesn't run ahead of the speaker
* during the engine's download/decode/seek warmup. Reset to false on every
* `startPreview` call so a switch from track A to track B doesn't carry
* over A's animation state.
*/
audioStarted: boolean;
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
stopPreview: () => Promise<void>;
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
_onStart: (id: string) => void;
/** Internal — called from the TauriEventBridge on `audio:preview-progress`. */
_onProgress: (id: string, elapsed: number, duration: number) => void;
/** Internal — called from the TauriEventBridge on `audio:preview-end`. */
_onEnd: (id: string) => void;
}
const PREVIEW_VOLUME_MATCH = true;
/**
* Effective preview volume to send to the Rust engine.
*
* Mirrors the main sink's audible level: takes the player's slider value and,
* when loudness normalization is active, folds in the LUFS pre-analysis
* attenuation the engine applies to the main sink (the engine has no view of
* preview-specific gain, so we pre-multiply here). Master headroom is added on
* the Rust side.
*/
export function computePreviewVolume(): number {
const auth = useAuthStore.getState();
let volume = usePlayerStore.getState().volume;
if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') {
const preDbAtt = Math.min(0, auth.loudnessPreAnalysisAttenuationDb ?? -4.5);
volume = volume * Math.pow(10, preDbAtt / 20);
}
return Math.max(0, Math.min(1, volume));
}
export const usePreviewStore = create<PreviewState>((set, get) => ({
previewingId: null,
previewingTrack: null,
elapsed: 0,
duration: 30,
audioStarted: false,
startPreview: async (song, location) => {
const auth = useAuthStore.getState();
if (!auth.trackPreviewsEnabled) return;
if (!auth.trackPreviewLocations[location]) return;
// Block preview during any Orbit session — the preview path runs
// through the same Rust audio engine as the shared playback, and a
// 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.
if (isOrbitPlaybackSyncActive()) return;
const current = get().previewingId;
if (current === song.id) {
await get().stopPreview();
return;
}
const previewDuration = auth.trackPreviewDurationSec;
const startRatio = auth.trackPreviewStartRatio;
const url = buildStreamUrl(song.id);
const trackDuration = Math.max(song.duration ?? 0, 0);
const startSec = trackDuration > previewDuration * 1.5
? trackDuration * startRatio
: 0;
set({
previewingId: song.id,
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
elapsed: 0,
duration: previewDuration,
audioStarted: false,
});
try {
await invoke('audio_preview_play', {
id: song.id,
url,
startSec,
durationSec: previewDuration,
volume: computePreviewVolume(),
});
} catch (e) {
// Roll back optimistic state on failure.
if (get().previewingId === song.id) {
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
}
throw e;
}
},
stopPreview: async () => {
if (!get().previewingId) return;
try {
await invoke('audio_preview_stop');
} catch {
/* engine will emit preview-end anyway; clear locally as fallback */
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
}
},
_onStart: (id) => {
const current = get().previewingId;
if (current !== id) {
// Engine fired start for an id we didn't track locally — keep id but
// leave previewingTrack as-is (the caller's startPreview() set it).
set({ previewingId: id, elapsed: 0, audioStarted: true });
} else {
// Audio is now actually playing — unblock the progress-ring animation.
set({ audioStarted: true });
}
},
_onProgress: (id, elapsed, duration) => {
if (get().previewingId !== id) return;
set({ elapsed, duration });
},
_onEnd: (id) => {
if (get().previewingId !== id) return;
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
},
}));