Environment upgrade & hot-cache playback (#463)

* chore: upgrade dependencies and migrate playback to rodio 0.22

Bump npm and Rust crates; adapt symphonia decoding, ringbuf 0.5, lofty tags,
and discord-rich-presence usage. Use native rodio Player/MixerDeviceSink and
cpal device descriptions; drop the unused cpal patch. Align Vite 8 build
targets and chunking; remove redundant dynamic imports and fix hot-cache debug
logging imports.

* perf(build): lazy-load routes and restore default chunk warnings

Lazy-load all routed pages with React.lazy to shrink the main bundle; wrap root
Routes in Suspense for lazy Login. Drop chunkSizeWarningLimit override so Vite
uses the default 500 kB threshold.

* fix(windows): tray double-click without spurious menu; clean unused import

Disable tray menu on left mouse-up on Windows so a double-click to hide the
main window does not immediately reopen the context menu (tray-icon default
menu_on_left_click). Gate std::fs in app_api/core behind cfg(linux) for
/proc-only code so Windows builds stay warning-free.

* fix(sidebar): preserve new-releases read state under storage cap

When merging seen album ids, keep the current newest sample first so the
500-id localStorage limit does not truncate freshly marked reads and bring
back the unread badge.

* fix(audio): hot-cache replay, analysis no-op skips, playback source UI

Retain stream_completed_cache across audio_stop so end-of-queue replay can
use RAM promote or disk hot file instead of re-ranging HTTP.

Add cpu_seed_redundant_for_track gate before file/bytes seeds and local-file
spawn; emit analysis:waveform-updated only on Upserted. Ranged/legacy promote
checks generation after await before filling the slot.

Frontend: promote on same-track and cold resume; set currentPlaybackSource on
resume, queue undo restore, and gapless track switch so cache/stream icons stay
accurate. Import tauri::Manager for try_state in audio_play.

* fix(ts): narrow activeServerId for hot-cache promote calls

promoteCompletedStreamToHotCache expects a string; bind non-null server ids
in repeat-one, playTrack prev/same-track, and cold resume paths so tauri
production build (tsc) succeeds.

* fix(player): handle same-track hot-cache promote promise chain

Add .catch for promoteCompletedStreamToHotCache → runPlayTrackBody so sync
throws and unexpected rejections do not surface as unhandled in DevTools;
reset defer-hot-cache prefetch and isPlaying on failure.

* chore(nix): sync npmDepsHash with package-lock.json

* chore(release): finalize 1.46.0 CHANGELOG with PR #463 links

Document the release with full GitHub PR #463 on every subsection so
entries stay attributable if sections are reordered. Fix ContextMenu
lines where dynamic imports were accidentally merged onto one line.

* docs(contributors): credit cucadmuh for #463
This commit is contained in:
cucadmuh
2026-05-05 22:00:29 +03:00
committed by GitHub
parent 54e774ef24
commit 8d8c1aa8a3
29 changed files with 2381 additions and 2511 deletions
+46 -45
View File
@@ -15,38 +15,39 @@ import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
// Eager — main browsing flow, loaded on first paint
import Home from './pages/Home';
import Albums from './pages/Albums';
import Artists from './pages/Artists';
import ArtistDetail from './pages/ArtistDetail';
import NewReleases from './pages/NewReleases';
import Favorites from './pages/Favorites';
import RandomMix from './pages/RandomMix';
import RandomLanding from './pages/RandomLanding';
import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
import MostPlayed from './pages/MostPlayed';
import RandomAlbums from './pages/RandomAlbums';
import LuckyMixPage from './pages/LuckyMix';
import SearchResults from './pages/SearchResults';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import NowPlayingPage from './pages/NowPlaying';
// Lazy — visited rarely or on-demand. Each becomes its own chunk so the
// initial bundle stays smaller and these pages don't block first paint.
const Settings = lazy(() => import('./pages/Settings'));
const Statistics = lazy(() => import('./pages/Statistics'));
const Help = lazy(() => import('./pages/Help'));
const WhatsNew = lazy(() => import('./pages/WhatsNew'));
const DeviceSync = lazy(() => import('./pages/DeviceSync'));
// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
// the entry chunk; each page is fetched when its route is first visited.
const Home = lazy(() => import('./pages/Home'));
const Albums = lazy(() => import('./pages/Albums'));
const Artists = lazy(() => import('./pages/Artists'));
const ArtistDetail = lazy(() => import('./pages/ArtistDetail'));
const NewReleases = lazy(() => import('./pages/NewReleases'));
const Favorites = lazy(() => import('./pages/Favorites'));
const RandomMix = lazy(() => import('./pages/RandomMix'));
const RandomLanding = lazy(() => import('./pages/RandomLanding'));
const Login = lazy(() => import('./pages/Login'));
const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
const MostPlayed = lazy(() => import('./pages/MostPlayed'));
const RandomAlbums = lazy(() => import('./pages/RandomAlbums'));
const LuckyMixPage = lazy(() => import('./pages/LuckyMix'));
const SearchResults = lazy(() => import('./pages/SearchResults'));
const Playlists = lazy(() => import('./pages/Playlists'));
const PlaylistDetail = lazy(() => import('./pages/PlaylistDetail'));
const NowPlayingPage = lazy(() => import('./pages/NowPlaying'));
const Tracks = lazy(() => import('./pages/Tracks'));
const Settings = lazy(() => import('./pages/Settings'));
const Statistics = lazy(() => import('./pages/Statistics'));
const Help = lazy(() => import('./pages/Help'));
const WhatsNew = lazy(() => import('./pages/WhatsNew'));
const DeviceSync = lazy(() => import('./pages/DeviceSync'));
const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary'));
const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
const Tracks = lazy(() => import('./pages/Tracks'));
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
const Genres = lazy(() => import('./pages/Genres'));
const GenreDetail = lazy(() => import('./pages/GenreDetail'));
import MiniPlayer from './components/MiniPlayer';
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
import FullscreenPlayer from './components/FullscreenPlayer';
@@ -63,8 +64,6 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineBanner from './components/OfflineBanner';
import Genres from './pages/Genres';
import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import AppUpdater from './components/AppUpdater';
import TitleBar from './components/TitleBar';
@@ -1316,7 +1315,7 @@ export default function App() {
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
else if (e.key === 'psysonic_language' && e.newValue) {
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
i18n.changeLanguage(e.newValue);
}
};
window.addEventListener('storage', onStorage);
@@ -1402,19 +1401,21 @@ export default function App() {
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
<Suspense fallback={null}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
</Suspense>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
-12
View File
@@ -278,7 +278,6 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -342,7 +341,6 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
const handleCreateWithToast = async (songIds: string[]) => {
const { createPlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -409,7 +407,6 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
const { createPlaylist } = await import('../api/subsonic');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
const { usePlaylistStore } = await import('../store/playlistStore');
usePlaylistStore.getState().touchPlaylist(pl.id);
showToast(
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
@@ -522,7 +519,6 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -628,7 +624,6 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
const { createPlaylist } = await import('../api/subsonic');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
const { usePlaylistStore } = await import('../store/playlistStore');
usePlaylistStore.getState().touchPlaylist(pl.id);
showToast(
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
@@ -762,7 +757,6 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -782,7 +776,6 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
const handleAdd = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -910,7 +903,6 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -941,7 +933,6 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
const handleMerge = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -1673,7 +1664,6 @@ export default function ContextMenu() {
{playlistId && playlistSongIndex !== undefined && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
@@ -1931,7 +1921,6 @@ export default function ContextMenu() {
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../utils/toast');
const { deletePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { removeId } = usePlaylistStore.getState();
try {
await deletePlaylist(playlist.id);
@@ -2145,7 +2134,6 @@ export default function ContextMenu() {
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../utils/toast');
const { usePlaylistStore } = await import('../store/playlistStore');
const { deletePlaylist } = await import('../api/subsonic');
const { removeId } = usePlaylistStore.getState();
const deletedIds: string[] = [];
+28 -3
View File
@@ -39,6 +39,26 @@ const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v1';
const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80;
const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000;
const NEW_RELEASES_RESET_DELAY_MS = 5_000;
/** Max album ids persisted per server/scope; cap must not drop the latest "newest" batch when marking read. */
const NEW_RELEASES_SEEN_MAX_IDS = 500;
/** Merge previous seen IDs with the current `getAlbumList(newest)` sample: newest batch is kept in full first, then older seen until `maxIds` (localStorage budget). */
function mergeSeenNewReleaseIdsCap(prevSeen: string[], newestBatch: string[], maxIds: number): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const id of newestBatch) {
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
for (const id of prevSeen) {
if (out.length >= maxIds) break;
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
return out;
}
function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
@@ -197,7 +217,7 @@ export default function Sidebar({
);
const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => {
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, 500);
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, NEW_RELEASES_SEEN_MAX_IDS);
localStorage.setItem(key, JSON.stringify(normalized));
}, []);
@@ -238,11 +258,16 @@ export default function Sidebar({
}
if (markAsSeen) {
writeSeenNewReleaseIds([...seenIds, ...newestIds]);
// Prepend the live newest sample so a full `seenIds` list + slice(500)
// cannot silently discard freshly "read" albums (fixes badge coming back).
writeSeenNewReleaseIds(mergeSeenNewReleaseIdsCap(seenIds, newestIds, NEW_RELEASES_SEEN_MAX_IDS));
// Keep server-wide baseline in sync so scope fallback never resurrects
// already-viewed items after opening the New Releases page.
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
writeSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey, [...allScopeSeen, ...newestIds]);
writeSeenNewReleaseIdsByKey(
newReleasesSeenAllScopeStorageKey,
mergeSeenNewReleaseIdsCap(allScopeSeen, newestIds, NEW_RELEASES_SEEN_MAX_IDS),
);
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
+1
View File
@@ -206,6 +206,7 @@ const CONTRIBUTORS = [
'Queue: drag rows outside to remove with trash ghost (PR #420)',
'Tauri: modularize audio and lib command layers (PR #422)',
'Shortcuts: action registry, dynamic CLI help, 9 new input targets + F1 help binding (PR #435)',
'Environment upgrade & hot-cache playback — replay via RAM/disk on same-track and queue-end resume, playback-source icon stays correct after resume/undo/gapless, sidebar new-releases 500-id cap merge, Windows tray double-click fix, lazy-loaded routes, rodio 0.22 migration (PR #463)',
],
},
{
+7 -10
View File
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
import type { Track } from './playerStore';
import { emitAnalysisStorageChanged } from './analysisSync';
import { useAuthStore } from './authStore';
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
@@ -67,17 +68,13 @@ function evictionReasonForTier(tier: number): string {
return labels[tier] ?? `tier-${tier}`;
}
/** Settings → Logging → Debug, same as `emitNormalizationDebug` / lucky-mix. Dynamic `authStore` import avoids a static cycle (auth → player → hot-cache). */
/** Settings → Logging → Debug, same as `emitNormalizationDebug` / lucky-mix. */
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
void import('./authStore')
.then(({ useAuthStore }) => {
if (useAuthStore.getState().loggingMode !== 'debug') return;
return invoke('frontend_debug_log', {
scope: 'hot-cache',
message: JSON.stringify(payload),
});
})
.catch(() => {});
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'hot-cache',
message: JSON.stringify(payload),
}).catch(() => {});
}
export const useHotCacheStore = create<HotCacheState>()(
+259 -110
View File
@@ -478,6 +478,10 @@ function queueUndoRestoreAudioEngine(opts: {
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
recordEnginePlayUrl(track.id, url);
usePlayerStore.setState({
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, authState.activeServerId ?? '', url),
});
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
setDeferHotCachePrefetch(true);
invoke('audio_play', {
@@ -492,6 +496,7 @@ function queueUndoRestoreAudioEngine(opts: {
manual: false,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
if (playGeneration !== generation) return;
@@ -1187,6 +1192,31 @@ async function promoteCompletedStreamToHotCache(track: Track, serverId: string,
}
}
/**
* Tracks the **actual** `audio_play` URL family: `getPlaybackSourceKind()` can
* report `hot` for RAM preload or disk index while the engine still uses HTTP.
* Rebind-to-local seeks need this, not the UI hint alone.
*/
let lastOpenedWithHttpTrackId: string | null = null;
function recordEnginePlayUrl(trackId: string, url: string): void {
lastOpenedWithHttpTrackId = url.startsWith('psysonic-local://') ? null : trackId;
}
/** Matches `playTrack` / PlayerBar: stream vs hot-cache vs offline file from resolved `audio_play` URL. */
function playbackSourceHintForResolvedUrl(trackId: string, serverId: string, url: string): PlaybackSourceKind {
if (!url.startsWith('psysonic-local://')) return 'stream';
return useOfflineStore.getState().getLocalUrl(trackId, serverId) ? 'offline' : 'hot';
}
function shouldRebindPlaybackToHotCache(trackId: string, serverId: string): boolean {
if (!serverId) return false;
if (!lastOpenedWithHttpTrackId || !sameQueueTrackId(lastOpenedWithHttpTrackId, trackId)) {
return false;
}
return resolvePlaybackUrl(trackId, serverId).startsWith('psysonic-local://');
}
// Track ID that has already been sent to audio_chain_preload (gapless chain).
let gaplessPreloadingId: string | null = null;
// Track ID that has already been sent to audio_preload (byte pre-download).
@@ -1479,11 +1509,24 @@ function handleAudioEnded() {
buffered: 0,
});
setTimeout(() => {
if (repeatMode === 'one' && currentTrack) {
usePlayerStore.getState().playTrack(currentTrack, queue, false);
} else {
usePlayerStore.getState().next(false);
}
void (async () => {
if (repeatMode === 'one' && currentTrack) {
const authState = useAuthStore.getState();
const repeatPromoteSid = authState.activeServerId;
if (authState.hotCacheEnabled && repeatPromoteSid) {
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
await promoteCompletedStreamToHotCache(
currentTrack,
repeatPromoteSid,
authState.hotCacheDownloadDir || null,
);
}
usePlayerStore.getState().playTrack(currentTrack, queue, false);
} else {
usePlayerStore.getState().next(false);
}
})();
}, 150);
}
@@ -1519,6 +1562,10 @@ function handleAudioTrackSwitched(duration: number) {
if (!nextTrack) return;
const switchServerId = useAuthStore.getState().activeServerId ?? '';
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
@@ -1532,6 +1579,7 @@ function handleAudioTrackSwitched(duration: number) {
buffered: 0,
scrobbled: false,
lastfmLoved: false,
currentPlaybackSource: switchPlaybackSource,
});
emitNormalizationDebug('track-switched', {
trackId: nextTrack.id,
@@ -2436,110 +2484,168 @@ export const usePlayerStore = create<PlayerState>()(
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
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({
currentTrack: track,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
scrobbled: false,
lastfmLoved: false,
isPlaying: true, // optimistic — reverted on error
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
});
if (
prevTrack
&& prevTrack.id !== track.id
&& authState.hotCacheEnabled
&& authState.activeServerId
) {
void promoteCompletedStreamToHotCache(
prevTrack,
authState.activeServerId,
authState.hotCacheDownloadDir || null,
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
// `take()` once; a second replay would full HTTP-range again unless we flush
// RAM to hot disk first (promote was only run when switching to another track).
const needSameTrackHotPromote =
Boolean(
prevTrack
&& sameQueueTrackId(prevTrack.id, track.id)
&& authState.hotCacheEnabled
&& authState.activeServerId,
);
}
void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id);
setDeferHotCachePrefetch(true);
const playIdx = idx >= 0 ? idx : 0;
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
})
.then(() => {
if (playGeneration !== gen) return;
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);
const runPlayTrackBody = () => {
const authStateNow = useAuthStore.getState();
const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? '');
recordEnginePlayUrl(track.id, url);
const preloadedTrackId = get().enginePreloadedTrackId;
const keepPreloadHint = preloadedTrackId === track.id;
const playbackSourceHint = playbackSourceHintForResolvedUrl(
track.id,
authStateNow.activeServerId ?? '',
url,
);
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({
currentTrack: track,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
scrobbled: false,
lastfmLoved: false,
isPlaying: true, // optimistic — reverted on error
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
});
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
if (npEnabled) reportNowPlaying(track.id);
if (lfmKey) {
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
const cacheKey = `${track.title}::${track.artist}`;
usePlayerStore.setState(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, track.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = authStateNow.activeServerId;
if (prevPromoteSid) {
void promoteCompletedStreamToHotCache(
prevTrack,
prevPromoteSid,
authStateNow.hotCacheDownloadDir || null,
);
}
}
void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id);
setDeferHotCachePrefetch(true);
const playIdx = idx >= 0 ? idx : 0;
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authStateNow.replayGainPreGainDb,
fallbackDb: authStateNow.replayGainFallbackDb,
manual,
hiResEnabled: authStateNow.enableHiRes,
analysisTrackId: track.id,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
if (playGeneration !== gen) return;
if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null });
}
const durSeek = track.duration && track.duration > 0 ? track.duration : null;
const seekTo = initialTime;
const canSeekAfterPlay =
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
if (canSeekAfterPlay) {
void invoke('audio_seek', { seconds: seekTo })
.then(() => {
if (playGeneration !== gen) return;
setSeekTarget(seekTo);
if (seekFallbackVisualTarget?.trackId === track.id) {
seekFallbackVisualTarget = null;
}
})
.catch(() => {
if (seekFallbackVisualTarget?.trackId === track.id) {
seekFallbackVisualTarget = 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();
if (npEnabled) reportNowPlaying(track.id);
if (lfmKey) {
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
const cacheKey = `${track.title}::${track.artist}`;
usePlayerStore.setState(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(newQueue, track, initialTime);
touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? '');
};
const hotPromoteSid = authState.activeServerId;
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
track,
hotPromoteSid,
authState.hotCacheDownloadDir || null,
)
.then(() => {
if (playGeneration !== gen) return;
runPlayTrackBody();
})
.catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] same-track hot promote / play body failed:', err);
set({ isPlaying: false });
});
} else {
runPlayTrackBody();
}
syncQueueToServer(newQueue, track, initialTime);
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
},
reseedQueueForInstantMix: (track) => {
@@ -2666,13 +2772,28 @@ export const usePlayerStore = create<PlayerState>()(
set({ isPlaying: true });
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
} else {
// Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
// Engine has no loaded paused stream (app relaunch, or track ended and user
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
// `stream_completed_cache` from the prior play to hot disk before resolving URL.
const gen = ++playGeneration;
const vol = get().volume;
set({ isPlaying: true });
// Fetch fresh track data from server to get replay gain metadata
getSong(currentTrack.id).then(freshSong => {
void (async () => {
const authHot = useAuthStore.getState();
const resumePromoteSid = authHot.activeServerId;
if (authHot.hotCacheEnabled && resumePromoteSid) {
await promoteCompletedStreamToHotCache(
currentTrack,
resumePromoteSid,
authHot.hotCacheDownloadDir || null,
);
}
if (playGeneration !== gen) return;
// Fetch fresh track data from server to get replay gain metadata
getSong(currentTrack.id).then(freshSong => {
if (playGeneration !== gen) return;
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
// Update store with fresh track data if available
if (freshSong) set({ currentTrack: trackToPlay });
@@ -2685,6 +2806,8 @@ export const usePlayerStore = create<PlayerState>()(
const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
recordEnginePlayUrl(trackToPlay.id, coldUrl);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
@@ -2698,6 +2821,7 @@ export const usePlayerStore = create<PlayerState>()(
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: trackToPlay.id,
streamFormatSuffix: trackToPlay.suffix ?? null,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
@@ -2721,6 +2845,8 @@ export const usePlayerStore = create<PlayerState>()(
const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
recordEnginePlayUrl(currentTrack.id, coldUrl);
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
@@ -2734,6 +2860,7 @@ export const usePlayerStore = create<PlayerState>()(
manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: currentTrack.id,
streamFormatSuffix: currentTrack.suffix ?? null,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
@@ -2742,6 +2869,7 @@ export const usePlayerStore = create<PlayerState>()(
});
syncQueueToServer(queue, currentTrack, currentTime);
});
})();
}
},
@@ -2923,10 +3051,17 @@ export const usePlayerStore = create<PlayerState>()(
},
previous: () => {
const { queue, queueIndex } = get();
const { queue, queueIndex, currentTrack } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) {
// Restart current track from the beginning.
const authState = useAuthStore.getState();
const sid = authState.activeServerId ?? '';
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
seekFallbackVisualTarget = { trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() };
get().playTrack(currentTrack, queue, true);
return;
}
invoke('audio_seek', { seconds: 0 }).catch(console.error);
set({ progress: 0, currentTime: 0 });
return;
@@ -2947,6 +3082,20 @@ export const usePlayerStore = create<PlayerState>()(
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
const s0 = get();
if (!s0.currentTrack) return;
const authSeek = useAuthStore.getState();
const sidSeek = authSeek.activeServerId ?? '';
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
seekFallbackVisualTarget = {
trackId: s0.currentTrack.id,
seconds: time,
setAtMs: Date.now(),
};
clearSeekFallbackRetry();
s0.playTrack(s0.currentTrack, s0.queue, true);
return;
}
invoke('audio_seek', { seconds: time }).then(() => {
// Arm stale-progress guard only after backend acknowledged seek.
setSeekTarget(time);