diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index fedfc28b..dad76b17 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -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, format_hint: Option<&str>) -> Result { - // Larger read-ahead buffer for the live radio SPSC consumer — reduces + fn new_streaming( + media: Box, + format_hint: Option<&str>, + source_tag: &str, + ) -> Result { + // 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())??; diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index af6d9380..0675319a 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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
{techLine}
; + if (!techLine && !playbackSource) return null; + return ( +
+ {playbackSource && ( + + {playbackSource === 'offline' && } + {playbackSource === 'hot' && } + {playbackSource === 'stream' && } + + )} + {techLine} +
+ ); })()}
diff --git a/src/locales/de.ts b/src/locales/de.ts index ee7e855c..e274cba3 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 968dccd7..2106c428 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/es.ts b/src/locales/es.ts index febb771d..b1470f12 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index ea8983a1..da5c5af2 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index a614cd2c..8040c2ef 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index d8c5ab85..d7fd2f00 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 33ae4ffb..15dfa8a1 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -906,6 +906,9 @@ export const ruTranslation = { rgTrack: 'Т {{db}} дБ', rgAlbum: 'А {{db}} дБ', rgPeak: 'Пик {{pk}}', + sourceOffline: 'Играет из офлайн-библиотеки', + sourceHot: 'Играет из кэша', + sourceStream: 'Играет из сетевого потока', }, statistics: { title: 'Статистика', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 8de63763..3ef52103 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -851,6 +851,9 @@ export const zhTranslation = { rgTrack: '曲目 {{db}} dB', rgAlbum: '专辑 {{db}} dB', rgPeak: '峰值 {{pk}}', + sourceOffline: '正在从离线库播放', + sourceHot: '正在从缓存播放', + sourceStream: '正在从网络流播放', }, statistics: { title: '统计', diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index db564b59..268331be 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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('audio:ended', () => handleAudioEnded()), listen('audio:error', ({ payload }) => handleAudioError(payload)), listen('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), + listen('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()( (set, get) => ({ currentTrack: null, currentRadio: null, + currentPlaybackSource: null, + enginePreloadedTrackId: null, queue: [], queueIndex: 0, isPlaying: false, @@ -934,7 +967,15 @@ export const usePlayerStore = create()( } 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()( set({ currentRadio: station, currentTrack: null, + currentPlaybackSource: null, queue: [], queueIndex: 0, isPlaying: true, @@ -1005,6 +1047,25 @@ export const usePlayerStore = create()( 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()( 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()( ); } 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()( 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(); diff --git a/src/styles/layout.css b/src/styles/layout.css index 7515f2f8..2fef2745 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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 { diff --git a/src/utils/resolvePlaybackUrl.ts b/src/utils/resolvePlaybackUrl.ts index 4f3be81b..bb36dbb5 100644 --- a/src/utils/resolvePlaybackUrl.ts +++ b/src/utils/resolvePlaybackUrl.ts @@ -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);