mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
committed by
GitHub
parent
98ff73d17a
commit
1799e90e04
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user