feat(tracks): Highly Rated rail + per-card star display (#443)

* feat(tracks): Highly Rated rail + per-card star display

Adds a new SongRail above the Random Pick on the Tracks page that
surfaces the user's highly-rated tracks (sorted by rating DESC).
Auto-hides on non-Navidrome servers and when the library has no
rated tracks yet. Reuses the existing SongRail layout, with the
standard reroll button forcing a cache bypass.

Per-card stars: any SongCard whose `userRating > 0` now shows a
small five-star row (filled to the rating value) below the artist
line — visible everywhere SongCard is used, not only in the new
rail. Read-only display; rating is still done via the row's
context menu or the Now Playing star widget.

Cache layer in `ndListSongs`: opt-in `cacheMs` parameter (skipped
by VirtualSongList; used only by the Highly Rated rail with a 60 s
TTL). Cleared on `setRating` mutation so a freshly-rated track
shows up on the next page revisit, and on server switch alongside
the existing token cache. The reroll button explicitly invalidates
before refetching, so a manual refresh always hits the network.

* docs(changelog): add #443 Tracks Highly Rated rail entry

* chore(credits): add #443 to Psychotoxical contributions
This commit is contained in:
Frank Stellmacher
2026-05-03 18:54:07 +02:00
committed by GitHub
parent 98ff73d17a
commit 1799e90e04
15 changed files with 125 additions and 5 deletions
+1
View File
@@ -350,6 +350,7 @@ const CONTRIBUTORS = [
'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)',
'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)',
'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
],
},
] as const;
+41 -1
View File
@@ -14,8 +14,17 @@ import CachedImage from '../components/CachedImage';
import SongRail from '../components/SongRail';
import VirtualSongList from '../components/VirtualSongList';
import { playSongNow } from '../utils/playSong';
import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse';
const RANDOM_RAIL_SIZE = 18;
/** Over-fetch buffer so the client-side `userRating > 0` filter still leaves
* enough cards for the rail. Server-side rating filter on Navidrome's REST
* is finicky and not yet wired through — revisit when verified. */
const RATED_RAIL_FETCH = 60;
const RATED_RAIL_DISPLAY = 30;
/** Stay-fresh window for the Highly Rated rail. Cleared on rating mutation, so
* the only staleness path is a reroll-button click after >60 s. */
const RATED_RAIL_CACHE_MS = 60_000;
export default function Tracks() {
const { t } = useTranslation();
@@ -29,6 +38,11 @@ export default function Tracks() {
const [random, setRandom] = useState<SubsonicSong[]>([]);
const [randomLoading, setRandomLoading] = useState(true);
const [rated, setRated] = useState<SubsonicSong[]>([]);
const [ratedLoading, setRatedLoading] = useState(true);
/** Hide the rail entirely on non-Navidrome servers (REST call throws) so we don't show an empty section. */
const [ratedSupported, setRatedSupported] = useState(true);
const rerollHero = useCallback(async () => {
setHeroLoading(true);
try {
@@ -49,11 +63,28 @@ export default function Tracks() {
}
}, []);
const reloadRated = useCallback(async () => {
setRatedLoading(true);
try {
const songs = await ndListSongs(0, RATED_RAIL_FETCH, 'rating', 'DESC', RATED_RAIL_CACHE_MS);
const filtered = songs.filter(s => (s.userRating ?? 0) > 0).slice(0, RATED_RAIL_DISPLAY);
setRated(filtered);
setRatedSupported(true);
} catch {
// Non-Navidrome server, or REST endpoint refused → silently hide the rail.
setRated([]);
setRatedSupported(false);
} finally {
setRatedLoading(false);
}
}, []);
useEffect(() => {
if (!activeServerId) return;
rerollHero();
rerollRandom();
}, [activeServerId, rerollHero, rerollRandom]);
reloadRated();
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : '';
@@ -137,6 +168,15 @@ export default function Tracks() {
</section>
)}
{ratedSupported && (ratedLoading || rated.length > 0) && (
<SongRail
title={t('tracks.railHighlyRated')}
songs={rated}
loading={ratedLoading}
onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }}
/>
)}
<SongRail
title={t('tracks.railRandom')}
songs={railSongs}