mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(preview): Symphonia format sniff and ranged stream startup (#1006)
* fix(preview): Symphonia format sniff and cluster member stream URLs Resolve preview container hints from HTTP headers, Subsonic suffix, and magic-byte sniff after Symphonia 0.6. Route preview streams through clusterBrowseServerId like main playback; guard CoverArtImage when the preview cover ref is still loading. * fix(preview): adapt Symphonia sniff branch for main without cluster routing Keep formatSuffix and cover-ref guards from the cluster work; use buildStreamUrl on the active server instead of clusterBrowseServerId. * fix(preview): use ranged HTTP so preview starts without full-file download Open preview via RangedHttpSource when the server supports byte ranges; fall back to buffered download otherwise. Gate in-memory probe with ProbeSeekGate so Symphonia 0.6 does not scan the entire file before audio. * docs: CHANGELOG and credits for preview fix PR #1006 * chore: drop PR #1006 from settings credits (minor fix) * fix(preview): allow clippy too_many_arguments on audio_preview_play formatSuffix pushed the Tauri command to 8 args; matches other IPC commands.
This commit is contained in:
@@ -200,6 +200,12 @@ describe('previewStore — startPreview', () => {
|
||||
expect(state.duration).toBe(30);
|
||||
});
|
||||
|
||||
it('passes Subsonic suffix as formatSuffix for Symphonia container hints', async () => {
|
||||
await usePreviewStore.getState().startPreview({ ...song('flac-1'), suffix: 'flac' }, 'albums');
|
||||
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
|
||||
expect(call?.[1]).toMatchObject({ formatSuffix: 'flac' });
|
||||
});
|
||||
|
||||
it('starts at 0 when the track is too short to need a mid-track seek', async () => {
|
||||
// duration <= previewDuration * 1.5 → start at 0.
|
||||
await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions');
|
||||
@@ -277,7 +283,7 @@ describe('previewStore — startPreview', () => {
|
||||
throw new Error('engine offline');
|
||||
});
|
||||
|
||||
await expect(usePreviewStore.getState().startPreview(song('song-2'), 'suggestions')).rejects.toThrow(/engine offline/);
|
||||
await usePreviewStore.getState().startPreview(song('song-2'), 'suggestions');
|
||||
|
||||
// Only rolls back when the rolled-back id is still the optimistic one.
|
||||
const state = usePreviewStore.getState();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildStreamUrl } from '../api/subsonicStreamUrl';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { TrackPreviewLocation } from './authStoreTypes';
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@@ -14,6 +15,29 @@ export interface PreviewingTrack {
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
export interface PreviewSongInput {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
coverArt?: string;
|
||||
duration?: number;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
/** Map a browse/playlist song row into preview input (keeps Subsonic suffix for format hints). */
|
||||
export function previewInputFromSong(
|
||||
song: Pick<SubsonicSong, 'id' | 'title' | 'artist' | 'coverArt' | 'duration' | 'suffix'>,
|
||||
): PreviewSongInput {
|
||||
return {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
coverArt: song.coverArt,
|
||||
duration: song.duration,
|
||||
suffix: song.suffix,
|
||||
};
|
||||
}
|
||||
|
||||
interface PreviewState {
|
||||
/** Subsonic song id of the active preview, or null when nothing previews. */
|
||||
previewingId: string | null;
|
||||
@@ -33,7 +57,7 @@ interface PreviewState {
|
||||
*/
|
||||
audioStarted: boolean;
|
||||
|
||||
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
|
||||
startPreview: (song: PreviewSongInput, location: TrackPreviewLocation) => Promise<void>;
|
||||
stopPreview: () => Promise<void>;
|
||||
|
||||
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
|
||||
@@ -100,7 +124,12 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
||||
|
||||
set({
|
||||
previewingId: song.id,
|
||||
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
|
||||
previewingTrack: {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
coverArt: song.coverArt,
|
||||
},
|
||||
elapsed: 0,
|
||||
duration: previewDuration,
|
||||
audioStarted: false,
|
||||
@@ -113,13 +142,14 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
||||
startSec,
|
||||
durationSec: previewDuration,
|
||||
volume: computePreviewVolume(),
|
||||
formatSuffix: song.suffix ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
// Roll back optimistic state on failure.
|
||||
if (get().previewingId === song.id) {
|
||||
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||
}
|
||||
throw e;
|
||||
console.error('Preview playback failed', e);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user