mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(audio): rust track preview engine + inline play/preview buttons (#392)
* feat(audio): rust preview engine with secondary sink Adds a parallel rodio Sink on the existing OutputStream for 30s mid-track previews. Two new Tauri commands (audio_preview_play, audio_preview_stop) plus three events (audio:preview-start / -progress / -end). The main sink is paused with Sink::pause() and auto-resumed on preview end iff it was playing beforehand. * feat(playlists): migrate suggestion preview to rust audio engine Replaces the HTML5 <audio> path with the new rust preview engine. previewStore mirrors the engine's start/progress/end events so any tracklist row can render preview UI from a single source of truth. Spacebar redirects to stopPreview while a preview plays, hardware mediakeys are silently dropped (Q5), and tray clicks cancel the preview before forwarding the original action. * feat(albums): inline play + preview buttons in tracklist rows Track number stays static on hover instead of swapping to a play icon — the dedicated Play and Preview buttons in the title cell take over click-to-play and click-to-preview. Active+playing rows keep the eq-bars (also on hover), active+paused rows fall back to the static accent-coloured number. Pilot for the wider rollout to other tracklists. * feat(tracklists): roll out inline play + preview buttons Mirrors the AlbumTrackList pilot across the remaining track-row based lists: PlaylistDetail main tracks, Favorites, ArtistDetail top tracks, RandomMix (both genre-mix and filtered-songs lists). Track number stays static, the dedicated Play + Preview buttons in the title cell take over click-to-play and click-to-preview. * feat(settings): track preview toggle + configurable position and duration Adds an opt-out switch and two sliders to Settings → Audio: start position (0-90 % of track length, default 33 %) and preview duration (5-60 s, default 30 s). The progress-ring animation follows the duration via a CSS variable so the visual matches the engine's auto-stop. Disabling the feature hides every inline preview button via a single root-level data attribute, no per-row conditional rendering required. i18n keys added in all 8 locales. * fix(audio): cancel preview when main playback (re)starts audio_play, audio_play_radio, audio_resume and audio_stop did not know about the parallel preview sink, so clicking Play on a track that was currently being previewed left the preview running on top of the freshly started main playback. New helper clears the resume flag, bumps the preview generation, drops the sink and emits an 'interrupted' end event before any of those commands touches the main sink. * feat(settings): per-location track preview toggles Splits the single trackPreviewsEnabled toggle into a master + 6 per-location sub-toggles (suggestions, albums, playlists, favorites, artist, randomMix). Master remains the kill switch; sub-toggles are only honoured when master is on. Each tracklist container is marked with `data-preview-loc="<id>"` and hidden via scoped CSS when the matching root attribute is "off". startPreview now takes a location argument so the store can guard logic too. i18n added in all 8 locales. * fix(contextmenu): use ChevronsRight for Play Next to distinguish from preview
This commit is contained in:
committed by
GitHub
parent
ff47170736
commit
a14dba8167
@@ -49,6 +49,34 @@ function sanitizeLoudnessPreAnalysisFromStorage(v: unknown): number {
|
||||
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
|
||||
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
||||
|
||||
export type TrackPreviewLocation =
|
||||
| 'suggestions'
|
||||
| 'albums'
|
||||
| 'playlists'
|
||||
| 'favorites'
|
||||
| 'artist'
|
||||
| 'randomMix';
|
||||
|
||||
export type TrackPreviewLocations = Record<TrackPreviewLocation, boolean>;
|
||||
|
||||
export const TRACK_PREVIEW_LOCATIONS: readonly TrackPreviewLocation[] = [
|
||||
'suggestions',
|
||||
'albums',
|
||||
'playlists',
|
||||
'favorites',
|
||||
'artist',
|
||||
'randomMix',
|
||||
];
|
||||
|
||||
const DEFAULT_TRACK_PREVIEW_LOCATIONS: TrackPreviewLocations = {
|
||||
suggestions: true,
|
||||
albums: true,
|
||||
playlists: true,
|
||||
favorites: true,
|
||||
artist: true,
|
||||
randomMix: true,
|
||||
};
|
||||
|
||||
const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
|
||||
{ id: 'server', enabled: true },
|
||||
{ id: 'lrclib', enabled: true },
|
||||
@@ -89,6 +117,14 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
|
||||
trackPreviewsEnabled: boolean;
|
||||
/** Per-location toggles. Only honoured when `trackPreviewsEnabled` is true. */
|
||||
trackPreviewLocations: TrackPreviewLocations;
|
||||
/** Mid-track start position as a 0…1 ratio. Default 0.33 = 33%. */
|
||||
trackPreviewStartRatio: number;
|
||||
/** Preview window length in seconds. Default 30 s. */
|
||||
trackPreviewDurationSec: number;
|
||||
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
|
||||
preloadCustomSeconds: number;
|
||||
infiniteQueueEnabled: boolean;
|
||||
@@ -252,6 +288,10 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setTrackPreviewsEnabled: (v: boolean) => void;
|
||||
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
|
||||
setTrackPreviewStartRatio: (v: number) => void;
|
||||
setTrackPreviewDurationSec: (v: number) => void;
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
|
||||
setPreloadCustomSeconds: (v: number) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
@@ -367,6 +407,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
trackPreviewsEnabled: true,
|
||||
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
|
||||
trackPreviewStartRatio: 0.33,
|
||||
trackPreviewDurationSec: 30,
|
||||
preloadMode: 'balanced',
|
||||
preloadCustomSeconds: 30,
|
||||
infiniteQueueEnabled: false,
|
||||
@@ -521,6 +565,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setTrackPreviewsEnabled: (v) => set({ trackPreviewsEnabled: !!v }),
|
||||
setTrackPreviewLocation: (location, enabled) => set(state => ({
|
||||
trackPreviewLocations: { ...state.trackPreviewLocations, [location]: !!enabled },
|
||||
})),
|
||||
setTrackPreviewStartRatio: (v) => set({ trackPreviewStartRatio: Math.max(0, Math.min(0.9, v)) }),
|
||||
setTrackPreviewDurationSec: (v) => set({ trackPreviewDurationSec: Math.max(5, Math.min(120, Math.round(v))) }),
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
|
||||
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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';
|
||||
|
||||
interface PreviewState {
|
||||
/** Subsonic song id of the active preview, or null when nothing previews. */
|
||||
previewingId: string | 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; 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,
|
||||
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, 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, 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, elapsed: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
_onStart: (id) => {
|
||||
if (get().previewingId !== id) {
|
||||
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, elapsed: 0 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user