mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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
@@ -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.
|
||||
|
||||
@@ -54,21 +54,45 @@ function mapNdSong(o: Record<string, unknown>): 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<string, SongsCacheEntry>();
|
||||
|
||||
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<SubsonicSong[]> {
|
||||
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<unknown> =>
|
||||
invoke<unknown>('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<string, unknown>));
|
||||
const data = raw.map(s => mapNdSong(s as Record<string, unknown>));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -959,6 +959,11 @@ export async function searchSongsPaged(query: string, songCount: number, songOff
|
||||
|
||||
export async function setRating(id: string, rating: number): Promise<void> {
|
||||
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). */
|
||||
|
||||
@@ -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}</p>
|
||||
{(song.userRating ?? 0) > 0 && (
|
||||
<div className="song-card-rating" aria-label={`${song.userRating} stars`}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
size={11}
|
||||
fill={i < (song.userRating ?? 0) ? 'currentColor' : 'none'}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -412,6 +412,7 @@ export const ruTranslation = {
|
||||
playSong: 'Воспроизвести',
|
||||
enqueueSong: 'В очередь',
|
||||
railRandom: 'Случайная подборка',
|
||||
railHighlyRated: 'Высоко оценённые',
|
||||
browseTitle: 'Просмотреть все треки',
|
||||
browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.',
|
||||
searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
|
||||
|
||||
@@ -386,6 +386,7 @@ export const zhTranslation = {
|
||||
playSong: '播放',
|
||||
enqueueSong: '加入队列',
|
||||
railRandom: '随机精选',
|
||||
railHighlyRated: '高分推荐',
|
||||
browseTitle: '浏览所有曲目',
|
||||
browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。',
|
||||
searchPlaceholder: '按标题、艺人或专辑搜索…',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -294,6 +294,14 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-card-rating {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
color: var(--accent);
|
||||
margin-top: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ─── Virtual Song List ───────────────────────────────────────── */
|
||||
|
||||
.virtual-song-list-section {
|
||||
|
||||
Reference in New Issue
Block a user