mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
e44e6dcdf4
The squash-merge of PR #419 was performed against an outdated PR base that predated several main-side refactors and features. The resulting squash inadvertently re-introduced files that had already been removed (`src-tauri/src/audio.rs` monolith, `app-icon.png`) and reverted main's content for ~20 files (`src-tauri/src/lib.rs` decompose, `src/App.tsx` animation-pause, `src/components/AlbumRow.tsx` headerExtra, etc). This commit: * Restores all collateral-damage files to their pre-#419 main state (5662dafe), including the audio module split, lib decompose, Cargo.toml `windows` dep, animation-pause-on-blur logic, and `reducedAnimations` toggle plumbing in WaveformSeek/Settings. * Keeps the genuine #419 work intact: tri-state duration toggle, position counter, persistent Now Playing collapse, animated EQ indicator (in QueuePanel.tsx, 8 locales, authStore, components.css). * Merges authStore.ts so both `reducedAnimations` (from #426) and `queueNowPlayingCollapsed` (from #419) coexist. * Adds the `.eq-bars.paused` CSS rule manually since components.css needed a5662dafebase + the single #419 addition. * Fixes a latent type error in OverlayScrollArea.tsx (`useRef<T>(null)` is `RefObject<T>` / read-only under current `@types/react`; widened to `useRef<T | null>(null)` so the existing `wrapRef.current = el` assignment compiles). Verified locally with `cargo check` and `npm run build` — both green.
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
import { create } from 'zustand';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { buildStreamUrl } from '../api/subsonic';
|
|
import { usePlayerStore } from './playerStore';
|
|
import { useAuthStore, type TrackPreviewLocation } from './authStore';
|
|
|
|
/** 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;
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
// Match the main player's effective volume so preview doesn't blast at
|
|
// unattenuated level. LUFS pre-analysis attenuation is folded into base
|
|
// volume by the audio engine for the main sink; we mirror by reading the
|
|
// player volume + applying the same headroom multiplier conceptually.
|
|
let volume = usePlayerStore.getState().volume;
|
|
if (PREVIEW_VOLUME_MATCH) {
|
|
if (auth.normalizationEngine === 'loudness') {
|
|
const preDbAtt = Math.min(0, auth.loudnessPreAnalysisAttenuationDb ?? -4.5);
|
|
volume = volume * Math.pow(10, preDbAtt / 20);
|
|
}
|
|
}
|
|
|
|
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: Math.max(0, Math.min(1, volume)),
|
|
});
|
|
} 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 });
|
|
},
|
|
}));
|