mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Merge pull request #201 from cucadmuh/feat/playback-source-indicator
feat(queue): playback source badge + preload-aware source tracking
This commit is contained in:
+19
-11
@@ -1200,11 +1200,15 @@ impl SizedDecoder {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a decoder from any `MediaSource` (e.g. `RadioBuffer`).
|
||||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
|
||||
// Larger read-ahead buffer for the live radio SPSC consumer — reduces
|
||||
fn new_streaming(
|
||||
media: Box<dyn MediaSource>,
|
||||
format_hint: Option<&str>,
|
||||
source_tag: &str,
|
||||
) -> Result<Self, String> {
|
||||
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
|
||||
// read() call frequency into the ring buffer, easing I/O spikes.
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||||
let mut hint = Hint::new();
|
||||
@@ -1212,16 +1216,16 @@ impl SizedDecoder {
|
||||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.map_err(|e| format!("radio: format probe failed: {e}"))?;
|
||||
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
|
||||
|
||||
let track = probed.format.tracks().iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "radio: no audio track found".to_string())?;
|
||||
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
||||
let track_id = track.id;
|
||||
// Live streams have no known total frame count → total_duration = None.
|
||||
let total_duration = None;
|
||||
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("radio: codec init failed: {e}"))?;
|
||||
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
|
||||
let mut format = probed.format;
|
||||
|
||||
let mut errors = 0usize;
|
||||
@@ -1235,12 +1239,12 @@ impl SizedDecoder {
|
||||
Ok(d) => break d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
errors += 1;
|
||||
eprintln!("[psysonic] radio init: dropped corrupt frame #{errors}: {msg}");
|
||||
eprintln!("[psysonic] {source_tag} init: dropped corrupt frame #{errors}: {msg}");
|
||||
if errors >= MAX_CONSECUTIVE_DECODE_ERRORS {
|
||||
return Err("radio: too many consecutive decode errors".into());
|
||||
return Err(format!("{source_tag}: too many consecutive decode errors"));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("radio: decode error: {e}")),
|
||||
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
|
||||
}
|
||||
};
|
||||
let spec = decoded.spec().to_owned();
|
||||
@@ -2349,7 +2353,7 @@ pub async fn audio_play(
|
||||
),
|
||||
PlayInput::Streaming { reader, format_hint } => {
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref())
|
||||
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref(), "track-stream")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -3076,11 +3080,13 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||
let _ = app.emit("audio:preload-ready", url.clone());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -3102,7 +3108,9 @@ pub async fn audio_preload(
|
||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
};
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
let url_for_emit = url.clone();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
let _ = app.emit("audio:preload-ready", url_for_emit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3201,7 +3209,7 @@ pub async fn audio_play_radio(
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref())
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
@@ -251,6 +251,8 @@ export default function QueuePanel() {
|
||||
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
@@ -450,8 +452,29 @@ export default function QueuePanel() {
|
||||
].filter(Boolean) as string[];
|
||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||
const techLine = [...baseParts, ...rgParts].join(' · ');
|
||||
if (!techLine) return null;
|
||||
return <div className="queue-current-tech">{techLine}</div>;
|
||||
if (!techLine && !playbackSource) return null;
|
||||
return (
|
||||
<div className="queue-current-tech">
|
||||
{playbackSource && (
|
||||
<span
|
||||
className="queue-current-tech-source"
|
||||
data-tooltip={
|
||||
playbackSource === 'offline'
|
||||
? t('queue.sourceOffline')
|
||||
: playbackSource === 'hot'
|
||||
? t('queue.sourceHot')
|
||||
: t('queue.sourceStream')
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
|
||||
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
|
||||
</span>
|
||||
)}
|
||||
<span className="queue-current-tech-main">{techLine}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="queue-current-track-body">
|
||||
<div className="queue-current-cover">
|
||||
|
||||
@@ -858,6 +858,9 @@ export const deTranslation = {
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Peak {{pk}}',
|
||||
sourceOffline: 'Wiedergabe aus der Offline-Bibliothek',
|
||||
sourceHot: 'Wiedergabe aus dem Cache',
|
||||
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiken',
|
||||
|
||||
@@ -860,6 +860,9 @@ export const enTranslation = {
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Peak {{pk}}',
|
||||
sourceOffline: 'Playing from offline library',
|
||||
sourceHot: 'Playing from cache',
|
||||
sourceStream: 'Playing from network stream',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistics',
|
||||
|
||||
@@ -861,6 +861,9 @@ export const esTranslation = {
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Pico {{pk}}',
|
||||
sourceOffline: 'Reproducción desde la biblioteca sin conexión',
|
||||
sourceHot: 'Reproducción desde la caché',
|
||||
sourceStream: 'Reproducción desde la transmisión en red',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Estadísticas',
|
||||
|
||||
@@ -856,6 +856,9 @@ export const frTranslation = {
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Pic {{pk}}',
|
||||
sourceOffline: 'Lecture depuis la bibliothèque hors ligne',
|
||||
sourceHot: 'Lecture depuis le cache',
|
||||
sourceStream: 'Lecture depuis le flux réseau',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistiques',
|
||||
|
||||
@@ -855,6 +855,9 @@ export const nbTranslation = {
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Topp {{pk}}',
|
||||
sourceOffline: 'Spiller fra offlinebibliotek',
|
||||
sourceHot: 'Spiller fra cache',
|
||||
sourceStream: 'Spiller fra nettverksstrøm',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistikk',
|
||||
|
||||
@@ -855,6 +855,9 @@ export const nlTranslation = {
|
||||
rgTrack: 'T {{db}} dB',
|
||||
rgAlbum: 'A {{db}} dB',
|
||||
rgPeak: 'Piek {{pk}}',
|
||||
sourceOffline: 'Afspelen vanuit offlinebibliotheek',
|
||||
sourceHot: 'Afspelen vanuit cache',
|
||||
sourceStream: 'Afspelen vanuit netwerkstream',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Statistieken',
|
||||
|
||||
@@ -906,6 +906,9 @@ export const ruTranslation = {
|
||||
rgTrack: 'Т {{db}} дБ',
|
||||
rgAlbum: 'А {{db}} дБ',
|
||||
rgPeak: 'Пик {{pk}}',
|
||||
sourceOffline: 'Играет из офлайн-библиотеки',
|
||||
sourceHot: 'Играет из кэша',
|
||||
sourceStream: 'Играет из сетевого потока',
|
||||
},
|
||||
statistics: {
|
||||
title: 'Статистика',
|
||||
|
||||
@@ -851,6 +851,9 @@ export const zhTranslation = {
|
||||
rgTrack: '曲目 {{db}} dB',
|
||||
rgAlbum: '专辑 {{db}} dB',
|
||||
rgPeak: '峰值 {{pk}}',
|
||||
sourceOffline: '正在从离线库播放',
|
||||
sourceHot: '正在从缓存播放',
|
||||
sourceStream: '正在从网络流播放',
|
||||
},
|
||||
statistics: {
|
||||
title: '统计',
|
||||
|
||||
+81
-13
@@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
@@ -113,6 +113,13 @@ async function buildInfiniteQueueCandidates(
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
currentRadio: InternetRadioStation | null;
|
||||
/** Latches the source used to start the currently playing track. */
|
||||
currentPlaybackSource: PlaybackSourceKind | null;
|
||||
/**
|
||||
* Subsonic track id for which `audio_preload` finished into the engine RAM slot (see `audio:preload-ready`).
|
||||
* Cleared after a successful `audio_play` consumed that preload, or when starting another track.
|
||||
*/
|
||||
enginePreloadedTrackId: string | null;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
@@ -465,6 +472,16 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
||||
bytePreloadingId = nextTrack.id;
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-request]', {
|
||||
nextTrackId: nextTrack.id,
|
||||
nextUrl,
|
||||
shouldBytePreload,
|
||||
shouldBytePreloadForGaplessBackup,
|
||||
remaining,
|
||||
gaplessEnabled,
|
||||
});
|
||||
}
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -627,6 +644,20 @@ export function initAudioListeners(): () => void {
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
listen<string>('audio:preload-ready', ({ payload }) => {
|
||||
const tid = streamUrlTrackId(payload);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-ready]', {
|
||||
payload,
|
||||
parsedTrackId: tid,
|
||||
prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId,
|
||||
});
|
||||
}
|
||||
if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid });
|
||||
else if (import.meta.env.DEV) {
|
||||
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Sync Last.fm loved tracks cache on startup.
|
||||
@@ -823,6 +854,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
(set, get) => ({
|
||||
currentTrack: null,
|
||||
currentRadio: null,
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
@@ -934,7 +967,15 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
|
||||
set({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
currentRadio: null,
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
});
|
||||
},
|
||||
|
||||
// ── playRadio ────────────────────────────────────────────────────────────
|
||||
@@ -965,6 +1006,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
set({
|
||||
currentRadio: station,
|
||||
currentTrack: null,
|
||||
currentPlaybackSource: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: true,
|
||||
@@ -1005,6 +1047,25 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
|
||||
const authState = useAuthStore.getState();
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||
const keepPreloadHint = preloadedTrackId === track.id;
|
||||
const playbackSourceHint = getPlaybackSourceKind(
|
||||
track.id,
|
||||
authState.activeServerId ?? '',
|
||||
keepPreloadHint ? track.id : null,
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][playTrack-source]', {
|
||||
trackId: track.id,
|
||||
resolvedUrl: url,
|
||||
preloadedTrackId,
|
||||
keepPreloadHint,
|
||||
playbackSourceHint,
|
||||
});
|
||||
}
|
||||
|
||||
// Set state immediately so the UI updates before the download completes.
|
||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||
set({
|
||||
@@ -1018,9 +1079,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
||||
});
|
||||
|
||||
const authState = useAuthStore.getState();
|
||||
if (
|
||||
prevTrack
|
||||
&& prevTrack.id !== track.id
|
||||
@@ -1034,7 +1096,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
);
|
||||
}
|
||||
setDeferHotCachePrefetch(true);
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
@@ -1049,16 +1110,23 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
})
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
if (keepPreloadHint) {
|
||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
|
||||
@@ -1593,6 +1593,24 @@
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.queue-current-tech-source {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 0;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.queue-current-tech-main {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
|
||||
@@ -2,6 +2,57 @@ import { buildStreamUrl } from '../api/subsonic';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
|
||||
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
||||
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
||||
|
||||
/**
|
||||
* Subsonic `buildStreamUrl()` rotates `t`/`s` on every call; Rust matches by `id` (see `playback_identity`).
|
||||
*/
|
||||
export function streamUrlTrackId(url: string): string | null {
|
||||
if (!url.includes('stream.view')) return null;
|
||||
try {
|
||||
const fromUrl = new URL(url).searchParams.get('id');
|
||||
if (fromUrl) return fromUrl;
|
||||
} catch {
|
||||
// Fallback for non-standard/relative URLs: parse query manually.
|
||||
}
|
||||
const q = url.split('?')[1];
|
||||
if (!q) return null;
|
||||
for (const part of q.split('&')) {
|
||||
const [k, v = ''] = part.split('=');
|
||||
if (k === 'id') {
|
||||
try {
|
||||
return decodeURIComponent(v);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param enginePreloadedTrackId — song id for which `audio_preload` finished into the engine RAM slot
|
||||
* (parsed from `audio:preload-ready` payload URL).
|
||||
*/
|
||||
export function getPlaybackSourceKind(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
enginePreloadedTrackId: string | null = null,
|
||||
): PlaybackSourceKind {
|
||||
if (useOfflineStore.getState().getLocalUrl(trackId, serverId)) return 'offline';
|
||||
if (useHotCacheStore.getState().getLocalUrl(trackId, serverId)) return 'hot';
|
||||
const resolved = resolvePlaybackUrl(trackId, serverId);
|
||||
if (
|
||||
!resolved.startsWith('psysonic-local://')
|
||||
&& enginePreloadedTrackId
|
||||
&& trackId === enginePreloadedTrackId
|
||||
) {
|
||||
return 'hot';
|
||||
}
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
/** Offline library → hot playback cache → HTTP stream. */
|
||||
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
|
||||
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
|
||||
|
||||
Reference in New Issue
Block a user