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.
This commit is contained in:
Maxim Isaev
2026-06-05 18:48:13 +03:00
parent 1d65a8d339
commit 0a09d29b06
11 changed files with 218 additions and 36 deletions
+104 -8
View File
@@ -9,7 +9,11 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder; use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine}; use super::engine::{audio_http_client, AudioEngine};
use super::helpers::MASTER_HEADROOM; use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, resolve_playback_format_hint,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource; use super::sources::PriorityBoostSource;
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
@@ -90,19 +94,44 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
} }
} }
/// Format hint inferred from a Subsonic stream URL. The frontend always passes /// `format=` query param on Subsonic stream URLs (transcode targets).
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> { pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?') url.split('?')
.nth(1)? .nth(1)?
.split('&') .split('&')
.find_map(|kv| { .find_map(|kv| {
let (k, v) = kv.split_once('=')?; let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None } if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
}) })
} }
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
#[tauri::command] #[tauri::command]
pub async fn audio_preview_play( pub async fn audio_preview_play(
id: String, id: String,
@@ -110,6 +139,7 @@ pub async fn audio_preview_play(
start_sec: f64, start_sec: f64,
duration_sec: f64, duration_sec: f64,
volume: f32, volume: f32,
format_suffix: Option<String>,
app: AppHandle, app: AppHandle,
state: State<'_, AudioEngine>, state: State<'_, AudioEngine>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -147,13 +177,24 @@ pub async fn audio_preview_play(
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent()) .user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build() .build()
.unwrap_or_else(|_| audio_http_client(&state)); .unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http let response = preview_http
.get(&url) .get(&url)
.send() .send()
.await .await
.map_err(|e| format!("preview: connection failed: {e}"))? .map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status() .error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))? .map_err(|e| format!("preview: HTTP {e}"))?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes() .bytes()
.await .await
.map_err(|e| format!("preview: read body: {e}"))? .map_err(|e| format!("preview: read body: {e}"))?
@@ -165,7 +206,13 @@ pub async fn audio_preview_play(
} }
// ── Decode ─────────────────────────────────────────────────────────────── // ── Decode ───────────────────────────────────────────────────────────────
let hint = preview_format_hint_from_url(&url); let hint = resolve_preview_format_hint(
&url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix.as_deref(),
&bytes,
);
let bytes_for_blocking = bytes; let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone(); let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || { let decoder = tokio::task::spawn_blocking(move || {
@@ -271,6 +318,55 @@ pub async fn audio_preview_play(
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command] #[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) { pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true); preview_stop_inner(&app, &state, true);
+2 -2
View File
@@ -8,7 +8,7 @@ import type { Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { useSelectionStore } from '../../store/selectionStore'; import { useSelectionStore } from '../../store/selectionStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { usePreviewStore } from '../../store/previewStore'; import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import StarRating from '../StarRating'; import StarRating from '../StarRating';
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers'; import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
import { formatLongDuration } from '../../utils/format/formatDuration'; 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' : ''}`} className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { onClick={e => {
e.stopPropagation(); 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')} data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={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 { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore'; import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration'; import { formatTrackTime } from '../../utils/format/formatDuration';
@@ -84,7 +84,7 @@ export default function ArtistDetailTopTracks({
<button <button
type="button" type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`} 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')} data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={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 { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore'; import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useSelectionStore } from '../../store/selectionStore'; import { useSelectionStore } from '../../store/selectionStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { useDragDrop } from '../../contexts/DragDropContext'; import { useDragDrop } from '../../contexts/DragDropContext';
@@ -122,7 +122,7 @@ export default function FavoritesSongsTracklist({
L.playTrack(L.visibleTracks[index], L.visibleTracks); L.playTrack(L.visibleTracks[index], L.visibleTracks);
}, },
startPreview: (song) => usePreviewStore.getState().startPreview( startPreview: (song) => usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, previewInputFromSong(song),
'favorites', 'favorites',
), ),
rate: (songId, r) => latest.current.handleRate(songId, r), rate: (songId, r) => latest.current.handleRate(songId, r),
+7 -2
View File
@@ -62,7 +62,12 @@ export function PlayerTrackInfo({
const previewCoverRef = useAlbumCoverRef( const previewCoverRef = useAlbumCoverRef(
showPreviewMeta ? coverArtId : null, showPreviewMeta ? coverArtId : null,
showPreviewMeta ? coverArtId : null, showPreviewMeta ? coverArtId : null,
undefined,
showPreviewMeta
? { clusterSeedServerId: previewingTrack?.clusterBrowseServerId, libraryResolve: false }
: undefined,
); );
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
const layoutItems = usePlayerBarLayoutStore(s => s.items); const layoutItems = usePlayerBarLayoutStore(s => s.items);
const isLayoutVisible = (id: PlayerBarLayoutItemId) => const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false; layoutItems.find(i => i.id === id)?.visible !== false;
@@ -88,10 +93,10 @@ export function PlayerTrackInfo({
<Cast size={20} /> <Cast size={20} />
</div> </div>
) )
) : !isRadio && (showPreviewMeta ? coverArtId : playbackCoverRef) ? ( ) : !isRadio && activeCoverRef ? (
<CoverArtImage <CoverArtImage
className="player-album-art" className="player-album-art"
coverRef={showPreviewMeta ? previewCoverRef! : playbackCoverRef!} coverRef={activeCoverRef}
displayCssPx={128} displayCssPx={128}
surface="sparse" surface="sparse"
ensurePriority="high" ensurePriority="high"
@@ -13,7 +13,7 @@ import type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes'; import type { Track } from '../../store/playerStoreTypes';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore'; import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { useDragDrop } from '../../contexts/DragDropContext'; import { useDragDrop } from '../../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
@@ -149,7 +149,7 @@ export default function PlaylistTracklist({
L.playTrack(L.displayedTracks[index], L.displayedTracks); L.playTrack(L.displayedTracks[index], L.displayedTracks);
}, },
startPreview: (song) => usePreviewStore.getState().startPreview( startPreview: (song) => usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, previewInputFromSong(song),
'playlists', 'playlists',
), ),
toggleStar: (song, e) => latest.current.handleToggleStar(song, e), 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 { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react';
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes'; import type { Track } from '../../store/playerStoreTypes';
import { usePreviewStore } from '../../store/previewStore'; import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
import { useDragDrop } from '../../contexts/DragDropContext'; import { useDragDrop } from '../../contexts/DragDropContext';
import { formatRandomMixDuration } from '../../utils/componentHelpers/randomMixHelpers'; import { formatRandomMixDuration } from '../../utils/componentHelpers/randomMixHelpers';
@@ -110,7 +110,7 @@ export default function RandomMixTrackRow({
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
usePreviewStore.getState().startPreview( usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, previewInputFromSong(song),
'randomMix', 'randomMix',
); );
}} }}
+13 -1
View File
@@ -13,7 +13,7 @@ import { useCoverArt } from './useCoverArt';
import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types'; import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types';
export type CoverArtImageProps = { export type CoverArtImageProps = {
coverRef: CoverArtRef; coverRef: CoverArtRef | null | undefined;
displayCssPx: number; displayCssPx: number;
surface?: CoverSurfaceKind; surface?: CoverSurfaceKind;
fullRes?: boolean; fullRes?: boolean;
@@ -39,6 +39,18 @@ export function CoverArtImage({
onError: restOnError, onError: restOnError,
...rest ...rest
}: CoverArtImageProps) { }: 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 pinnedHigh = ensurePriorityProp === 'high';
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>( const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
ensurePriorityProp ?? 'middle', ensurePriorityProp ?? 'middle',
+2 -8
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { usePreviewStore } from '../store/previewStore'; import { previewInputFromSong, usePreviewStore } from '../store/previewStore';
import type { SubsonicSong } from '../api/subsonicTypes'; import type { SubsonicSong } from '../api/subsonicTypes';
export function usePlaylistPreview(): { export function usePlaylistPreview(): {
@@ -9,13 +9,7 @@ export function usePlaylistPreview(): {
// handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors // handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors
// engine events so we just dispatch here and read `previewingId` for UI. // engine events so we just dispatch here and read `previewingId` for UI.
const startPreview = useCallback((song: SubsonicSong) => { const startPreview = useCallback((song: SubsonicSong) => {
usePreviewStore.getState().startPreview({ usePreviewStore.getState().startPreview(previewInputFromSong(song), 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
id: song.id,
title: song.title,
artist: song.artist,
coverArt: song.coverArt,
duration: song.duration,
}, 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
}, []); }, []);
// Cancel any in-flight preview when the user navigates away. // Cancel any in-flight preview when the user navigates away.
+34 -2
View File
@@ -6,10 +6,19 @@
* *
* Drives the store through its public action surface with the real * Drives the store through its public action surface with the real
* Zustand instance, stubs the Tauri commands via `onInvoke`, and uses * Zustand instance, stubs the Tauri commands via `onInvoke`, and uses
* `vi.mock('@/api/subsonic')` because `startPreview` calls `buildStreamUrl`. * `vi.mock` for stream URL resolution — `startPreview` uses `buildPreviewStreamUrl`.
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
const { resolvePlaybackUrlMock } = vi.hoisted(() => ({
resolvePlaybackUrlMock: vi.fn((trackId: string, serverId?: string) =>
`https://mock/stream/${serverId || 'default'}/${trackId}`),
}));
vi.mock('../utils/playback/resolvePlaybackUrl', () => ({
resolvePlaybackUrl: resolvePlaybackUrlMock,
}));
vi.mock('@/api/subsonic', () => ({ vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined), savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })), getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
@@ -164,6 +173,7 @@ describe('previewStore — startPreview', () => {
resetAuthStore(); resetAuthStore();
resetOrbitStore(); resetOrbitStore();
resetPlayerStore(); resetPlayerStore();
resolvePlaybackUrlMock.mockClear();
onInvoke('audio_preview_play', () => undefined); onInvoke('audio_preview_play', () => undefined);
onInvoke('audio_preview_stop', () => undefined); onInvoke('audio_preview_stop', () => undefined);
onInvoke('audio_preview_set_volume', () => undefined); onInvoke('audio_preview_set_volume', () => undefined);
@@ -200,6 +210,28 @@ describe('previewStore — startPreview', () => {
expect(state.duration).toBe(30); expect(state.duration).toBe(30);
}); });
it('resolves stream URL on clusterBrowseServerId member, not active server', async () => {
useAuthStore.setState({
servers: [
{ id: 'a', name: 'A', url: 'http://a.test', username: 'u', password: 'p' },
{ id: 'b', name: 'B', url: 'http://b.test', username: 'u', password: 'p' },
],
activeServerId: 'a',
});
await usePreviewStore.getState().startPreview({
...song('track-x'),
clusterBrowseServerId: 'b',
}, 'albums');
expect(resolvePlaybackUrlMock).toHaveBeenCalledWith('track-x', 'b');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
expect(call?.[1]).toMatchObject({
url: 'https://mock/stream/b/track-x',
});
expect(usePreviewStore.getState().previewingTrack?.clusterBrowseServerId).toBe('b');
});
it('starts at 0 when the track is too short to need a mid-track seek', async () => { it('starts at 0 when the track is too short to need a mid-track seek', async () => {
// duration <= previewDuration * 1.5 → start at 0. // duration <= previewDuration * 1.5 → start at 0.
await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions'); await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions');
@@ -277,7 +309,7 @@ describe('previewStore — startPreview', () => {
throw new Error('engine offline'); 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. // Only rolls back when the rolled-back id is still the optimistic one.
const state = usePreviewStore.getState(); const state = usePreviewStore.getState();
+48 -5
View File
@@ -1,10 +1,12 @@
import { buildStreamUrl } from '../api/subsonicStreamUrl'; import type { SubsonicSong } from '../api/subsonicTypes';
import type { TrackPreviewLocation } from './authStoreTypes'; import type { TrackPreviewLocation } from './authStoreTypes';
import { create } from 'zustand'; import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { isOrbitPlaybackSyncActive } from '../utils/orbit'; import { isOrbitPlaybackSyncActive } from '../utils/orbit';
import { resolveStreamServerIdForTrack } from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
/** Minimal track info needed to surface the preview in the player bar UI. */ /** Minimal track info needed to surface the preview in the player bar UI. */
export interface PreviewingTrack { export interface PreviewingTrack {
@@ -12,6 +14,32 @@ export interface PreviewingTrack {
title: string; title: string;
artist: string; artist: string;
coverArt?: string; coverArt?: string;
clusterBrowseServerId?: string;
}
export interface PreviewSongInput {
id: string;
title: string;
artist: string;
coverArt?: string;
duration?: number;
suffix?: string;
clusterBrowseServerId?: string;
}
/** Map a browse/playlist song row into preview input (keeps cluster member id). */
export function previewInputFromSong(
song: Pick<SubsonicSong, 'id' | 'title' | 'artist' | 'coverArt' | 'duration' | 'suffix' | 'clusterBrowseServerId'>,
): PreviewSongInput {
return {
id: song.id,
title: song.title,
artist: song.artist,
coverArt: song.coverArt,
duration: song.duration,
suffix: song.suffix,
clusterBrowseServerId: song.clusterBrowseServerId,
};
} }
interface PreviewState { interface PreviewState {
@@ -33,7 +61,7 @@ interface PreviewState {
*/ */
audioStarted: boolean; 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>; stopPreview: () => Promise<void>;
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */ /** Internal — called from the TauriEventBridge on `audio:preview-start`. */
@@ -65,6 +93,14 @@ export function computePreviewVolume(): number {
return Math.max(0, Math.min(1, volume)); return Math.max(0, Math.min(1, volume));
} }
/** Cluster-aware stream URL — same member resolution as main playback. */
export function buildPreviewStreamUrl(song: Pick<PreviewSongInput, 'id' | 'clusterBrowseServerId'>): string {
const streamServerId = resolveStreamServerIdForTrack(
song.clusterBrowseServerId ? { clusterBrowseServerId: song.clusterBrowseServerId } : null,
);
return resolvePlaybackUrl(song.id, streamServerId);
}
export const usePreviewStore = create<PreviewState>((set, get) => ({ export const usePreviewStore = create<PreviewState>((set, get) => ({
previewingId: null, previewingId: null,
previewingTrack: null, previewingTrack: null,
@@ -92,7 +128,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
const previewDuration = auth.trackPreviewDurationSec; const previewDuration = auth.trackPreviewDurationSec;
const startRatio = auth.trackPreviewStartRatio; const startRatio = auth.trackPreviewStartRatio;
const url = buildStreamUrl(song.id); const url = buildPreviewStreamUrl(song);
const trackDuration = Math.max(song.duration ?? 0, 0); const trackDuration = Math.max(song.duration ?? 0, 0);
const startSec = trackDuration > previewDuration * 1.5 const startSec = trackDuration > previewDuration * 1.5
? trackDuration * startRatio ? trackDuration * startRatio
@@ -100,7 +136,13 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
set({ set({
previewingId: song.id, 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,
clusterBrowseServerId: song.clusterBrowseServerId,
},
elapsed: 0, elapsed: 0,
duration: previewDuration, duration: previewDuration,
audioStarted: false, audioStarted: false,
@@ -113,13 +155,14 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
startSec, startSec,
durationSec: previewDuration, durationSec: previewDuration,
volume: computePreviewVolume(), volume: computePreviewVolume(),
formatSuffix: song.suffix ?? null,
}); });
} catch (e) { } catch (e) {
// Roll back optimistic state on failure. // Roll back optimistic state on failure.
if (get().previewingId === song.id) { if (get().previewingId === song.id) {
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false }); set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
} }
throw e; console.error('Preview playback failed', e);
} }
}, },