fix(favorites): reflect player-bar song star in track lists (#1063)

* fix(favorites): reflect player-bar song star in track lists

Liking a song from the player bar / fullscreen / shortcuts wrote only to
the session `starredOverrides` map, which onStarSuccess deleted once the
server sync resolved. List views (AlbumDetail, Favorites, RandomMix,
playlists) seed their starred state from a one-shot fetch and reflect
later changes only by merging that override, so the row reverted the
instant the sync completed — the like never stuck in the list. Toggling
from a list row also updated the row's own local state, which is why that
direction already worked.

Keep the star override as the durable session source of truth (stop
deleting it on success); the in-memory Track / queue-cache patches stay.
The override is unpersisted and superseded by the next toggle, so it never
diverges from the server. Ratings keep their existing clear-on-success
behavior.
This commit is contained in:
‮Artem
2026-06-11 16:07:02 +03:00
committed by GitHub
parent 90452a8f8c
commit ea304357ca
4 changed files with 23 additions and 18 deletions
+9
View File
@@ -349,6 +349,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Favorites — player-bar star stays synced in track lists
**By [@artplan1](https://github.com/artplan1), PR [#1063](https://github.com/Psychotoxical/psysonic/pull/1063)**
* Liking a song from the **player bar**, fullscreen player, or global shortcuts now updates the star in album tracklists, playlists, Random Mix, and Favorites — the row no longer reverts the instant the server sync completes.
* List views seed starred state from a one-shot fetch and merge session `starredOverrides`; clearing those overrides on sync success had only patched `currentTrack` and the queue cache, so rows fell back to stale fetched values.
## [1.47.0] ## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u) > **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
+1
View File
@@ -259,6 +259,7 @@ const CONTRIBUTOR_ENTRIES = [
'Player: cap persisted queue to ±250-track window — fixes QuotaExceededError on large playlists (PR #756)', 'Player: cap persisted queue to ±250-track window — fixes QuotaExceededError on large playlists (PR #756)',
'Playlists: virtualized tracklist for large playlists — no UI freeze on 10k+ tracks (PR #755)', 'Playlists: virtualized tracklist for large playlists — no UI freeze on 10k+ tracks (PR #755)',
'Favorites: virtualized songs tracklist for large starred collections (PR #805)', 'Favorites: virtualized songs tracklist for large starred collections (PR #805)',
'Favorites: player-bar star toggle stays synced with album, playlist, and favorites tracklists (PR #1063)',
], ],
}, },
{ {
+3 -3
View File
@@ -47,7 +47,7 @@ describe('pendingStarSync', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('stars optimistically, then clears the override + patches the track on success', async () => { it('stars optimistically, then keeps the override + patches the track on success', async () => {
queueSongStar('t1', true); queueSongStar('t1', true);
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // optimistic, instant expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // optimistic, instant
@@ -55,7 +55,7 @@ describe('pendingStarSync', () => {
expect(starMock).toHaveBeenCalledWith('t1', 'song', undefined); expect(starMock).toHaveBeenCalledWith('t1', 'song', undefined);
const s = usePlayerStore.getState(); const s = usePlayerStore.getState();
expect('t1' in s.starredOverrides).toBe(false); // cleared on success expect(s.starredOverrides.t1).toBe(true); // kept on success so list views stay in sync
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
// Thin-state: the resolver cache entry is patched in place (not dropped) so // Thin-state: the resolver cache entry is patched in place (not dropped) so
// the visible queue row keeps its title and reflects the synced star — // the visible queue row keeps its title and reflects the synced star —
@@ -86,7 +86,7 @@ describe('pendingStarSync', () => {
queueSongStar('t1', false); // user toggled back off queueSongStar('t1', false); // user toggled back off
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
expect(unstarMock).toHaveBeenCalledWith('t1', 'song', undefined); expect(unstarMock).toHaveBeenCalledWith('t1', 'song', undefined);
expect('t1' in usePlayerStore.getState().starredOverrides).toBe(false); expect(usePlayerStore.getState().starredOverrides.t1).toBe(false); // kept as durable false
expect(usePlayerStore.getState().currentTrack?.starred).toBeFalsy(); expect(usePlayerStore.getState().currentTrack?.starred).toBeFalsy();
}); });
+8 -13
View File
@@ -6,15 +6,15 @@ import { patchCachedTrack } from '../utils/library/queueTrackResolver';
* F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18). * F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18).
* *
* The player-store override maps (`starredOverrides` / `userRatingOverrides`) * The player-store override maps (`starredOverrides` / `userRatingOverrides`)
* are *session-only outbound sync state*, not a permanent second source of * are *session-only* client truth that every list view merges over its
* truth: * one-shot-fetched state:
* *
* 1. Set the override optimistically (instant UI). * 1. Set the override optimistically (instant UI).
* 2. Retry the Subsonic API (`star` / `unstar` / `setRating`) with exponential * 2. Retry the Subsonic API (`star` / `unstar` / `setRating`) with exponential
* backoff; flush immediately on `online` / window focus. * backoff; flush immediately on `online` / window focus.
* 3. On success: clear the override and patch the in-memory `Track` * 3. On **star** success: KEEP the override — list views read it — and patch
* (`currentTrack` + `queue`) so the UI stays correct without the override. * the in-memory `Track`. F3 index patch-on-use runs in the API layer.
* The F3 index patch-on-use runs inside the API layer, unchanged. * (Ratings clear on success; see `onRatingSuccess`.)
* 4. On app restart before success: the pending change is lost — acceptable, * 4. On app restart before success: the pending change is lost — acceptable,
* overrides are not persisted. * overrides are not persisted.
* *
@@ -86,16 +86,11 @@ async function run(k: string): Promise<void> {
function onStarSuccess(id: string, starred: boolean): void { function onStarSuccess(id: string, starred: boolean): void {
const starredVal = starred ? new Date().toISOString() : undefined; const starredVal = starred ? new Date().toISOString() : undefined;
usePlayerStore.setState(s => { // Keep the override — list views merge it (step 3 atop this file).
if (!(id in s.starredOverrides)) return {}; usePlayerStore.setState(s => ({
const next = { ...s.starredOverrides };
delete next[id];
return {
starredOverrides: next,
currentTrack: currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack, s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack,
}; }));
});
// Thin-state: the queue's copy lives in the resolver cache. Patch it in place // Thin-state: the queue's copy lives in the resolver cache. Patch it in place
// to the synced value rather than dropping it — a dropped entry would blank the // to the synced value rather than dropping it — a dropped entry would blank the
// visible queue row to a "…" placeholder until the next window re-resolve. // visible queue row to a "…" placeholder until the next window re-resolve.