mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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:
@@ -8,7 +8,7 @@ import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { useSelectionStore } from '../../store/selectionStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import StarRating from '../StarRating';
|
||||
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
|
||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||
@@ -116,7 +116,7 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
|
||||
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'albums');
|
||||
}}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
@@ -82,7 +82,7 @@ export default function ArtistDetailTopTracks({
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview(previewInputFromSong(song), 'artist'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import type { ColDef } from '../../utils/useTracklistColumns';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import { useSelectionStore } from '../../store/selectionStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
@@ -122,7 +122,7 @@ export default function FavoritesSongsTracklist({
|
||||
L.playTrack(L.visibleTracks[index], L.visibleTracks);
|
||||
},
|
||||
startPreview: (song) => usePreviewStore.getState().startPreview(
|
||||
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
|
||||
previewInputFromSong(song),
|
||||
'favorites',
|
||||
),
|
||||
rate: (songId, r) => latest.current.handleRate(songId, r),
|
||||
|
||||
@@ -60,7 +60,10 @@ export function PlayerTrackInfo({
|
||||
const previewCoverRef = useAlbumCoverRef(
|
||||
showPreviewMeta ? coverArtId : null,
|
||||
showPreviewMeta ? coverArtId : null,
|
||||
undefined,
|
||||
showPreviewMeta ? { libraryResolve: false } : undefined,
|
||||
);
|
||||
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
|
||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
@@ -86,10 +89,10 @@ export function PlayerTrackInfo({
|
||||
<Cast size={20} />
|
||||
</div>
|
||||
)
|
||||
) : !isRadio && (showPreviewMeta ? coverArtId : playbackCoverRef) ? (
|
||||
) : !isRadio && activeCoverRef ? (
|
||||
<CoverArtImage
|
||||
className="player-album-art"
|
||||
coverRef={showPreviewMeta ? previewCoverRef! : playbackCoverRef!}
|
||||
coverRef={activeCoverRef}
|
||||
displayCssPx={128}
|
||||
surface="sparse"
|
||||
ensurePriority="high"
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ColDef } from '../../utils/useTracklistColumns';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
@@ -149,7 +149,7 @@ export default function PlaylistTracklist({
|
||||
L.playTrack(L.displayedTracks[index], L.displayedTracks);
|
||||
},
|
||||
startPreview: (song) => usePreviewStore.getState().startPreview(
|
||||
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
|
||||
previewInputFromSong(song),
|
||||
'playlists',
|
||||
),
|
||||
toggleStar: (song, e) => latest.current.handleToggleStar(song, e),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { formatRandomMixDuration } from '../../utils/componentHelpers/randomMixHelpers';
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function RandomMixTrackRow({
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
usePreviewStore.getState().startPreview(
|
||||
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
|
||||
previewInputFromSong(song),
|
||||
'randomMix',
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCoverArt } from './useCoverArt';
|
||||
import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types';
|
||||
|
||||
export type CoverArtImageProps = {
|
||||
coverRef: CoverArtRef;
|
||||
coverRef: CoverArtRef | null | undefined;
|
||||
displayCssPx: number;
|
||||
surface?: CoverSurfaceKind;
|
||||
fullRes?: boolean;
|
||||
@@ -39,6 +39,18 @@ export function CoverArtImage({
|
||||
onError: restOnError,
|
||||
...rest
|
||||
}: CoverArtImageProps) {
|
||||
if (!coverRef) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
data-cover-provisional="true"
|
||||
role="img"
|
||||
aria-label={alt ?? ''}
|
||||
{...(rest as React.HTMLAttributes<HTMLDivElement>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pinnedHigh = ensurePriorityProp === 'high';
|
||||
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
|
||||
ensurePriorityProp ?? 'middle',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../store/previewStore';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
|
||||
export function usePlaylistPreview(): {
|
||||
@@ -9,13 +9,7 @@ export function usePlaylistPreview(): {
|
||||
// handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors
|
||||
// engine events so we just dispatch here and read `previewingId` for UI.
|
||||
const startPreview = useCallback((song: SubsonicSong) => {
|
||||
usePreviewStore.getState().startPreview({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
coverArt: song.coverArt,
|
||||
duration: song.duration,
|
||||
}, 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
|
||||
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
|
||||
}, []);
|
||||
|
||||
// Cancel any in-flight preview when the user navigates away.
|
||||
|
||||
@@ -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