mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
18b4a982ef
* feat(queue): add ETA display, equalizer indicator and collapsible now playing
* deleted endsAt and showDuration strings, changed eta update to 30s
* feat(queue): ETA tooltip, persistent Now Playing collapse, EQ bar pause, remove redundant Play icon
* feat(queue): fold ETA into existing total/remaining toggle as third mode
The standalone ETA span next to the track counter is removed; instead the
clickable duration label in the queue header now rotates through three
modes per click: total → remaining → eta → total. Counter (N/M) stays
where it was.
ETA mode keeps the live-feel treatment from the original PR (accent
colour while playing, muted at 50% opacity when paused). The other two
modes use plain accent.
i18n: queue.etaTooltip removed (no longer a separate descriptive label),
queue.showEta added as the action tooltip ('Show estimated end time')
in all 8 locales — matches the showRemaining / showTotal pattern.
* docs(changelog): add #419 queue UX improvements entry
Adds the [1.45.0] / Added entry for this PR's queue panel refinements
(position counter, tri-state duration toggle including ETA, collapsible
Now Playing section, animated EQ indicator).
---------
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
127 lines
4.2 KiB
TypeScript
127 lines
4.2 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;
|
|
|
|
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,
|
|
|
|
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,
|
|
});
|
|
|
|
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 });
|
|
}
|
|
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 });
|
|
}
|
|
},
|
|
|
|
_onStart: (id) => {
|
|
if (get().previewingId !== 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 });
|
|
}
|
|
},
|
|
|
|
_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 });
|
|
},
|
|
}));
|