diff --git a/src/locales/de.ts b/src/locales/de.ts
index 9752cc7e..668ef193 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -853,6 +853,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 62bd57e2..b1ae5aba 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -855,6 +855,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 ad1e2e40..4f268ec7 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -856,6 +856,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 79f997d3..4e91d049 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -851,6 +851,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 079c19db..ff1e0339 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -850,6 +850,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 dfe68021..b7b30311 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -850,6 +850,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 b97d3427..98276d67 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -901,6 +901,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 9711380c..c2216305 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -846,6 +846,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 08bff3c0..b106d991 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.
@@ -804,6 +835,8 @@ export const usePlayerStore = create()(
(set, get) => ({
currentTrack: null,
currentRadio: null,
+ currentPlaybackSource: null,
+ enginePreloadedTrackId: null,
queue: [],
queueIndex: 0,
isPlaying: false,
@@ -915,7 +948,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 ────────────────────────────────────────────────────────────
@@ -946,6 +987,7 @@ export const usePlayerStore = create()(
set({
currentRadio: station,
currentTrack: null,
+ currentPlaybackSource: null,
queue: [],
queueIndex: 0,
isPlaying: true,
@@ -986,6 +1028,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({
@@ -999,9 +1060,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
@@ -1015,7 +1077,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;
@@ -1030,16 +1091,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);