diff --git a/CHANGELOG.md b/CHANGELOG.md index deb8c7c1..0597a402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,6 +155,17 @@ The boolean **Reduce animations** toggle in **Settings → Appearance** is now a Existing users with `reducedAnimations: true` are migrated 1:1 to **Reduced** on first launch; everyone else lands on **Full**. The picker is in the same place as before. A contextual hint below the picker explains what the selected mode does. +### Tracks — Highly Rated Rail and Per-Card Star Display + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#443](https://github.com/Psychotoxical/psysonic/pull/443), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)** + +The Tracks page gets a new **Highly Rated** rail above Random Pick, surfacing your top-rated tracks (sorted by rating, descending). The rail auto-hides on non-Navidrome servers and on libraries with no rated tracks yet. The standard reroll button forces a fresh fetch. + +Every song card across the app whose rating is greater than zero now shows a small five-star row below the artist line, filled to the rating value. Read-only display — rating is still done via the row's context menu or the Now Playing star widget. + +Backed by an opt-in 60 s in-memory cache for `ndListSongs` (used only by the new rail; paginated browsing is unaffected). The cache is cleared automatically when you rate a track, switch server, or click the rail's reroll button. + + ## Fixed - **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible. diff --git a/src/api/navidromeBrowse.ts b/src/api/navidromeBrowse.ts index 371a8e21..34a60a3e 100644 --- a/src/api/navidromeBrowse.ts +++ b/src/api/navidromeBrowse.ts @@ -54,21 +54,45 @@ function mapNdSong(o: Record): SubsonicSong { }; } -export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count'; +export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count' | 'rating'; + +/** Optional opt-in cache for `ndListSongs` — keyed by call signature + active server. */ +type SongsCacheEntry = { data: SubsonicSong[]; expiresAt: number }; +const songsCache = new Map(); + +function songsCacheKey( + baseUrl: string, start: number, end: number, sort: string, order: string, +): string { + return `${baseUrl}|${start}-${end}|${sort}|${order}`; +} /** * Fetch a sorted, paginated slice of all songs via Navidrome's native REST API. * Returns mapped SubsonicSong objects. Throws on auth failure or non-Navidrome. + * + * `cacheMs` (> 0) opts in to a per-call-signature in-memory cache. Skip for + * paginated browsing — only useful for stable-list rails (e.g. Highly Rated) + * where a brief staleness window is acceptable in exchange for skipping the + * roundtrip on every page revisit. */ export async function ndListSongs( start: number, end: number, sort: NdSongSort = 'title', order: 'ASC' | 'DESC' = 'ASC', + cacheMs?: number, ): Promise { const baseUrl = useAuthStore.getState().getBaseUrl(); if (!baseUrl) throw new Error('No server configured'); + const cacheKey = (cacheMs && cacheMs > 0) + ? songsCacheKey(baseUrl, start, end, sort, order) + : null; + if (cacheKey) { + const hit = songsCache.get(cacheKey); + if (hit && hit.expiresAt > Date.now()) return hit.data; + } + const callOnce = async (token: string): Promise => invoke('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end }); @@ -88,10 +112,21 @@ export async function ndListSongs( } if (!Array.isArray(raw)) return []; - return raw.map(s => mapNdSong(s as Record)); + const data = raw.map(s => mapNdSong(s as Record)); + + if (cacheKey && cacheMs && cacheMs > 0) { + songsCache.set(cacheKey, { data, expiresAt: Date.now() + cacheMs }); + } + return data; } -/** Drop the cached token — call when the active server changes. */ +/** Drop the cached token AND the songs cache — call when the active server changes. */ export function ndClearTokenCache(): void { cachedToken = null; + songsCache.clear(); +} + +/** Drop the songs cache only (e.g. after a rating mutation). */ +export function ndInvalidateSongsCache(): void { + songsCache.clear(); } diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 81cef56c..c843102c 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -959,6 +959,11 @@ export async function searchSongsPaged(query: string, songCount: number, songOff export async function setRating(id: string, rating: number): Promise { await api('setRating.view', { id, rating }); + // Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become + // stale immediately. Lazy-import to keep the module dep direction + // subsonic ← navidromeBrowse and avoid pulling Tauri internals into shared + // type-only consumers. + void import('./navidromeBrowse').then(m => m.ndInvalidateSongsCache()).catch(() => {}); } /** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */ diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx index bfce2e4b..7b346f02 100644 --- a/src/components/SongCard.tsx +++ b/src/components/SongCard.tsx @@ -1,6 +1,6 @@ import React, { memo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Play, ListPlus } from 'lucide-react'; +import { Play, ListPlus, Star } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; @@ -118,6 +118,18 @@ function SongCard({ song }: SongCardProps) { onClick={handleArtistClick} title={song.artist} >{song.artist}

+ {(song.userRating ?? 0) > 0 && ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ )} ); diff --git a/src/locales/de.ts b/src/locales/de.ts index 94b1f9da..05cc4901 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -388,6 +388,7 @@ export const deTranslation = { playSong: 'Abspielen', enqueueSong: 'Zur Warteschlange', railRandom: 'Zufallsauswahl', + railHighlyRated: 'Hoch bewertet', browseTitle: 'Alle Titel durchstöbern', browseUnsupported: 'Dieser Server listet nicht die ganze Bibliothek auf einmal. Nutze die Suche oben, um bestimmte Titel zu finden.', searchPlaceholder: 'Titel, Künstler oder Album suchen…', diff --git a/src/locales/en.ts b/src/locales/en.ts index ddaf7bfd..01742c75 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -390,6 +390,7 @@ export const enTranslation = { playSong: 'Play', enqueueSong: 'Add to queue', railRandom: 'Random Pick', + railHighlyRated: 'Highly Rated', browseTitle: 'Browse all tracks', browseUnsupported: "This server doesn't list the whole library at once. Use the search above to find specific tracks.", searchPlaceholder: 'Find a track by title, artist or album…', diff --git a/src/locales/es.ts b/src/locales/es.ts index 126ff993..fad31d29 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -390,6 +390,7 @@ export const esTranslation = { playSong: 'Reproducir', enqueueSong: 'Añadir a la cola', railRandom: 'Selección aleatoria', + railHighlyRated: 'Mejor valoradas', browseTitle: 'Explorar todas las canciones', browseUnsupported: 'Este servidor no lista toda la biblioteca de una vez. Usa la búsqueda de arriba para encontrar canciones concretas.', searchPlaceholder: 'Busca una canción por título, artista o álbum…', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b4cbe0ac..c5dc98b8 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -388,6 +388,7 @@ export const frTranslation = { playSong: 'Lire', enqueueSong: 'Ajouter à la file', railRandom: 'Sélection aléatoire', + railHighlyRated: 'Mieux notés', browseTitle: 'Parcourir tous les titres', browseUnsupported: 'Ce serveur ne liste pas toute la bibliothèque d\'un coup. Utilisez la recherche ci-dessus pour trouver des titres précis.', searchPlaceholder: 'Chercher un titre par titre, artiste ou album…', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 09752b6d..721af647 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -388,6 +388,7 @@ export const nbTranslation = { playSong: 'Spill av', enqueueSong: 'Legg til i kø', railRandom: 'Tilfeldig valg', + railHighlyRated: 'Høyt vurdert', browseTitle: 'Bla gjennom alle spor', browseUnsupported: 'Denne tjeneren lister ikke hele biblioteket på én gang. Bruk søket ovenfor for å finne bestemte spor.', searchPlaceholder: 'Finn et spor etter tittel, artist eller album…', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index dc84996c..900e6bc5 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -387,6 +387,7 @@ export const nlTranslation = { playSong: 'Afspelen', enqueueSong: 'Aan wachtrij toevoegen', railRandom: 'Willekeurige selectie', + railHighlyRated: 'Hoog beoordeeld', browseTitle: 'Alle nummers doorbladeren', browseUnsupported: 'Deze server toont niet de hele bibliotheek in één keer. Gebruik de zoekbalk hierboven om specifieke nummers te vinden.', searchPlaceholder: 'Zoek op titel, artiest of album…', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index d66afd11..e8c6d2b7 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -412,6 +412,7 @@ export const ruTranslation = { playSong: 'Воспроизвести', enqueueSong: 'В очередь', railRandom: 'Случайная подборка', + railHighlyRated: 'Высоко оценённые', browseTitle: 'Просмотреть все треки', browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.', searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 1f0323f0..4f103ebd 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -386,6 +386,7 @@ export const zhTranslation = { playSong: '播放', enqueueSong: '加入队列', railRandom: '随机精选', + railHighlyRated: '高分推荐', browseTitle: '浏览所有曲目', browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。', searchPlaceholder: '按标题、艺人或专辑搜索…', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e254e587..5780feaa 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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; diff --git a/src/pages/Tracks.tsx b/src/pages/Tracks.tsx index b15ac44e..5da4fc83 100644 --- a/src/pages/Tracks.tsx +++ b/src/pages/Tracks.tsx @@ -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([]); const [randomLoading, setRandomLoading] = useState(true); + const [rated, setRated] = useState([]); + 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() { )} + {ratedSupported && (ratedLoading || rated.length > 0) && ( + { ndInvalidateSongsCache(); return reloadRated(); }} + /> + )} +