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
@@ -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.
|
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
|
## 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.
|
- **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.
|
* 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.
|
* 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(
|
export async function ndListSongs(
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
sort: NdSongSort = 'title',
|
sort: NdSongSort = 'title',
|
||||||
order: 'ASC' | 'DESC' = 'ASC',
|
order: 'ASC' | 'DESC' = 'ASC',
|
||||||
|
cacheMs?: number,
|
||||||
): Promise<SubsonicSong[]> {
|
): Promise<SubsonicSong[]> {
|
||||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||||
if (!baseUrl) throw new Error('No server configured');
|
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> =>
|
const callOnce = async (token: string): Promise<unknown> =>
|
||||||
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
|
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 [];
|
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 {
|
export function ndClearTokenCache(): void {
|
||||||
cachedToken = null;
|
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> {
|
export async function setRating(id: string, rating: number): Promise<void> {
|
||||||
await api('setRating.view', { id, rating });
|
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). */
|
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
@@ -118,6 +118,18 @@ function SongCard({ song }: SongCardProps) {
|
|||||||
onClick={handleArtistClick}
|
onClick={handleArtistClick}
|
||||||
title={song.artist}
|
title={song.artist}
|
||||||
>{song.artist}</p>
|
>{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export const deTranslation = {
|
|||||||
playSong: 'Abspielen',
|
playSong: 'Abspielen',
|
||||||
enqueueSong: 'Zur Warteschlange',
|
enqueueSong: 'Zur Warteschlange',
|
||||||
railRandom: 'Zufallsauswahl',
|
railRandom: 'Zufallsauswahl',
|
||||||
|
railHighlyRated: 'Hoch bewertet',
|
||||||
browseTitle: 'Alle Titel durchstöbern',
|
browseTitle: 'Alle Titel durchstöbern',
|
||||||
browseUnsupported: 'Dieser Server listet nicht die ganze Bibliothek auf einmal. Nutze die Suche oben, um bestimmte Titel zu finden.',
|
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…',
|
searchPlaceholder: 'Titel, Künstler oder Album suchen…',
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ export const enTranslation = {
|
|||||||
playSong: 'Play',
|
playSong: 'Play',
|
||||||
enqueueSong: 'Add to queue',
|
enqueueSong: 'Add to queue',
|
||||||
railRandom: 'Random Pick',
|
railRandom: 'Random Pick',
|
||||||
|
railHighlyRated: 'Highly Rated',
|
||||||
browseTitle: 'Browse all tracks',
|
browseTitle: 'Browse all tracks',
|
||||||
browseUnsupported: "This server doesn't list the whole library at once. Use the search above to find specific 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…',
|
searchPlaceholder: 'Find a track by title, artist or album…',
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ export const esTranslation = {
|
|||||||
playSong: 'Reproducir',
|
playSong: 'Reproducir',
|
||||||
enqueueSong: 'Añadir a la cola',
|
enqueueSong: 'Añadir a la cola',
|
||||||
railRandom: 'Selección aleatoria',
|
railRandom: 'Selección aleatoria',
|
||||||
|
railHighlyRated: 'Mejor valoradas',
|
||||||
browseTitle: 'Explorar todas las canciones',
|
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.',
|
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…',
|
searchPlaceholder: 'Busca una canción por título, artista o álbum…',
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export const frTranslation = {
|
|||||||
playSong: 'Lire',
|
playSong: 'Lire',
|
||||||
enqueueSong: 'Ajouter à la file',
|
enqueueSong: 'Ajouter à la file',
|
||||||
railRandom: 'Sélection aléatoire',
|
railRandom: 'Sélection aléatoire',
|
||||||
|
railHighlyRated: 'Mieux notés',
|
||||||
browseTitle: 'Parcourir tous les titres',
|
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.',
|
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…',
|
searchPlaceholder: 'Chercher un titre par titre, artiste ou album…',
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export const nbTranslation = {
|
|||||||
playSong: 'Spill av',
|
playSong: 'Spill av',
|
||||||
enqueueSong: 'Legg til i kø',
|
enqueueSong: 'Legg til i kø',
|
||||||
railRandom: 'Tilfeldig valg',
|
railRandom: 'Tilfeldig valg',
|
||||||
|
railHighlyRated: 'Høyt vurdert',
|
||||||
browseTitle: 'Bla gjennom alle spor',
|
browseTitle: 'Bla gjennom alle spor',
|
||||||
browseUnsupported: 'Denne tjeneren lister ikke hele biblioteket på én gang. Bruk søket ovenfor for å finne bestemte 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…',
|
searchPlaceholder: 'Finn et spor etter tittel, artist eller album…',
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ export const nlTranslation = {
|
|||||||
playSong: 'Afspelen',
|
playSong: 'Afspelen',
|
||||||
enqueueSong: 'Aan wachtrij toevoegen',
|
enqueueSong: 'Aan wachtrij toevoegen',
|
||||||
railRandom: 'Willekeurige selectie',
|
railRandom: 'Willekeurige selectie',
|
||||||
|
railHighlyRated: 'Hoog beoordeeld',
|
||||||
browseTitle: 'Alle nummers doorbladeren',
|
browseTitle: 'Alle nummers doorbladeren',
|
||||||
browseUnsupported: 'Deze server toont niet de hele bibliotheek in één keer. Gebruik de zoekbalk hierboven om specifieke nummers te vinden.',
|
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…',
|
searchPlaceholder: 'Zoek op titel, artiest of album…',
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ export const ruTranslation = {
|
|||||||
playSong: 'Воспроизвести',
|
playSong: 'Воспроизвести',
|
||||||
enqueueSong: 'В очередь',
|
enqueueSong: 'В очередь',
|
||||||
railRandom: 'Случайная подборка',
|
railRandom: 'Случайная подборка',
|
||||||
|
railHighlyRated: 'Высоко оценённые',
|
||||||
browseTitle: 'Просмотреть все треки',
|
browseTitle: 'Просмотреть все треки',
|
||||||
browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.',
|
browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.',
|
||||||
searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
|
searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ export const zhTranslation = {
|
|||||||
playSong: '播放',
|
playSong: '播放',
|
||||||
enqueueSong: '加入队列',
|
enqueueSong: '加入队列',
|
||||||
railRandom: '随机精选',
|
railRandom: '随机精选',
|
||||||
|
railHighlyRated: '高分推荐',
|
||||||
browseTitle: '浏览所有曲目',
|
browseTitle: '浏览所有曲目',
|
||||||
browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。',
|
browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。',
|
||||||
searchPlaceholder: '按标题、艺人或专辑搜索…',
|
searchPlaceholder: '按标题、艺人或专辑搜索…',
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ const CONTRIBUTORS = [
|
|||||||
'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)',
|
'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)',
|
'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)',
|
'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;
|
] as const;
|
||||||
|
|||||||
+41
-1
@@ -14,8 +14,17 @@ import CachedImage from '../components/CachedImage';
|
|||||||
import SongRail from '../components/SongRail';
|
import SongRail from '../components/SongRail';
|
||||||
import VirtualSongList from '../components/VirtualSongList';
|
import VirtualSongList from '../components/VirtualSongList';
|
||||||
import { playSongNow } from '../utils/playSong';
|
import { playSongNow } from '../utils/playSong';
|
||||||
|
import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse';
|
||||||
|
|
||||||
const RANDOM_RAIL_SIZE = 18;
|
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() {
|
export default function Tracks() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -29,6 +38,11 @@ export default function Tracks() {
|
|||||||
const [random, setRandom] = useState<SubsonicSong[]>([]);
|
const [random, setRandom] = useState<SubsonicSong[]>([]);
|
||||||
const [randomLoading, setRandomLoading] = useState(true);
|
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 () => {
|
const rerollHero = useCallback(async () => {
|
||||||
setHeroLoading(true);
|
setHeroLoading(true);
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeServerId) return;
|
if (!activeServerId) return;
|
||||||
rerollHero();
|
rerollHero();
|
||||||
rerollRandom();
|
rerollRandom();
|
||||||
}, [activeServerId, rerollHero, rerollRandom]);
|
reloadRated();
|
||||||
|
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
|
||||||
|
|
||||||
const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : '';
|
const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : '';
|
||||||
|
|
||||||
@@ -137,6 +168,15 @@ export default function Tracks() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ratedSupported && (ratedLoading || rated.length > 0) && (
|
||||||
|
<SongRail
|
||||||
|
title={t('tracks.railHighlyRated')}
|
||||||
|
songs={rated}
|
||||||
|
loading={ratedLoading}
|
||||||
|
onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SongRail
|
<SongRail
|
||||||
title={t('tracks.railRandom')}
|
title={t('tracks.railRandom')}
|
||||||
songs={railSongs}
|
songs={railSongs}
|
||||||
|
|||||||
@@ -294,6 +294,14 @@
|
|||||||
text-overflow: ellipsis;
|
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 ───────────────────────────────────────── */
|
||||||
|
|
||||||
.virtual-song-list-section {
|
.virtual-song-list-section {
|
||||||
|
|||||||
Reference in New Issue
Block a user