Merge pull request #201 from cucadmuh/feat/playback-source-indicator

feat(queue): playback source badge + preload-aware source tracking
This commit is contained in:
Frank Stellmacher
2026-04-17 15:56:01 +02:00
committed by GitHub
13 changed files with 219 additions and 27 deletions
+19 -11
View File
@@ -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())??;
+26 -3
View File
@@ -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">
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -906,6 +906,9 @@ export const ruTranslation = {
rgTrack: 'Т {{db}} дБ',
rgAlbum: 'А {{db}} дБ',
rgPeak: 'Пик {{pk}}',
sourceOffline: 'Играет из офлайн-библиотеки',
sourceHot: 'Играет из кэша',
sourceStream: 'Играет из сетевого потока',
},
statistics: {
title: 'Статистика',
+3
View File
@@ -851,6 +851,9 @@ export const zhTranslation = {
rgTrack: '曲目 {{db}} dB',
rgAlbum: '专辑 {{db}} dB',
rgPeak: '峰值 {{pk}}',
sourceOffline: '正在从离线库播放',
sourceHot: '正在从缓存播放',
sourceStream: '正在从网络流播放',
},
statistics: {
title: '统计',
+81 -13
View File
@@ -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();
+18
View File
@@ -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 {
+51
View File
@@ -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);