feat(tracks): add Tracks library hub page (closes #299) (#300)

New /tracks route with three sections:

- Hero "Track of the moment" — random pick with play / enqueue / reroll
- Random Pick rail — 18 song cards, rerollable; hero song deduped
- Browse all tracks — virtualized list (@tanstack/react-virtual),
  paginated 50 at a time

Browse uses Navidrome's native /api/song?_sort=title&_order=ASC for
proper A-Z order (no Subsonic equivalent), with automatic fallback
to search3 on non-Navidrome servers. Search input drives search3
with 300ms debounce. Bearer token cached module-level, re-auth on 401.

Play button on rows + cards calls a new enqueueAndPlay() helper that
appends to the existing queue (skip if duplicate) and jumps to the
song — different from playSongNow which replaces the queue. Enqueue
button stays opaque (no hover-only).

i18n keys for sidebar.tracks + tracks.* namespace in all 8 locales.
New AudioLines sidebar icon. Sidebar entry inserted between
"All Albums" and "Build a Mix".

Performance: cover thumbnails dropped (uniform layout instead),
RAF-throttled scroll prefetch, hover transforms removed from cards
(WebKitGTK compositing-friendly).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-25 14:23:15 +02:00
committed by GitHub
parent 93b724fc65
commit e3aabd98b7
21 changed files with 1512 additions and 0 deletions
+15
View File
@@ -21,6 +21,7 @@ export const deTranslation = {
cancelDownload: 'Download abbrechen',
offlineLibrary: 'Offline-Bibliothek',
genres: 'Genres',
tracks: 'Titel',
playlists: 'Playlists',
mostPlayed: 'Meistgehört',
radio: 'Internetradio',
@@ -377,6 +378,20 @@ export const deTranslation = {
offlineQueuing: '{{count}} Album(s) für Offline einreihen…',
offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden',
},
tracks: {
title: 'Titel',
subtitle: 'Stöbern. Suchen. Entdecken.',
heroEyebrow: 'Titel des Moments',
heroReroll: 'Anderen wählen',
playSong: 'Abspielen',
enqueueSong: 'Zur Warteschlange',
railRandom: 'Zufallsauswahl',
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…',
count_one: '{{count}} Titel',
count_other: '{{count}} Titel',
},
artists: {
title: 'Künstler',
search: 'Suchen…',
+15
View File
@@ -22,6 +22,7 @@ export const enTranslation = {
cancelDownload: 'Cancel download',
offlineLibrary: 'Offline Library',
genres: 'Genres',
tracks: 'Tracks',
playlists: 'Playlists',
smartPlaylists: 'Smart Playlists',
mostPlayed: 'Most Played',
@@ -379,6 +380,20 @@ export const enTranslation = {
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
},
tracks: {
title: 'Tracks',
subtitle: 'Browse. Search. Discover.',
heroEyebrow: 'Track of the moment',
heroReroll: 'Pick another',
playSong: 'Play',
enqueueSong: 'Add to queue',
railRandom: 'Random Pick',
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…',
count_one: '{{count}} track',
count_other: '{{count}} tracks',
},
artists: {
title: 'Artists',
search: 'Search…',
+15
View File
@@ -22,6 +22,7 @@ export const esTranslation = {
cancelDownload: 'Cancelar descarga',
offlineLibrary: 'Biblioteca Offline',
genres: 'Géneros',
tracks: 'Canciones',
playlists: 'Listas de Reproducción',
mostPlayed: 'Más Reproducidos',
radio: 'Radio por Internet',
@@ -379,6 +380,20 @@ export const esTranslation = {
offlineFailed: 'Error al agregar {{name}} offline',
addToPlaylist: 'Agregar a Lista de Reproducción',
},
tracks: {
title: 'Canciones',
subtitle: 'Explorar. Buscar. Descubrir.',
heroEyebrow: 'Canción del momento',
heroReroll: 'Elegir otra',
playSong: 'Reproducir',
enqueueSong: 'Añadir a la cola',
railRandom: 'Selección aleatoria',
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…',
count_one: '{{count}} canción',
count_other: '{{count}} canciones',
},
artists: {
title: 'Artistas',
search: 'Buscar…',
+15
View File
@@ -21,6 +21,7 @@ export const frTranslation = {
cancelDownload: 'Annuler le téléchargement',
offlineLibrary: 'Bibliothèque hors ligne',
genres: 'Genres',
tracks: 'Titres',
playlists: 'Playlists',
mostPlayed: 'Les plus joués',
radio: 'Radio Internet',
@@ -377,6 +378,20 @@ export const frTranslation = {
offlineQueuing: 'Mise en file d\'attente de {{count}} album(s) hors ligne…',
offlineFailed: 'Échec de l\'ajout de {{name}} hors ligne',
},
tracks: {
title: 'Titres',
subtitle: 'Parcourir. Chercher. Découvrir.',
heroEyebrow: 'Titre du moment',
heroReroll: 'En choisir un autre',
playSong: 'Lire',
enqueueSong: 'Ajouter à la file',
railRandom: 'Sélection aléatoire',
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…',
count_one: '{{count}} titre',
count_other: '{{count}} titres',
},
artists: {
title: 'Artistes',
search: 'Rechercher…',
+15
View File
@@ -21,6 +21,7 @@ export const nbTranslation = {
cancelDownload: 'Avbryt nedlasting',
offlineLibrary: 'Frakoblet bibliotek',
genres: 'Sjangere',
tracks: 'Spor',
playlists: 'Spillelister',
mostPlayed: 'Mest spilt',
radio: 'Internettradio',
@@ -377,6 +378,20 @@ export const nbTranslation = {
offlineQueuing: 'Legger {{count}} album i kø for offline…',
offlineFailed: 'Kunne ikke legge til {{name}} offline',
},
tracks: {
title: 'Spor',
subtitle: 'Bla. Søk. Oppdag.',
heroEyebrow: 'Sporet akkurat nå',
heroReroll: 'Velg et annet',
playSong: 'Spill av',
enqueueSong: 'Legg til i kø',
railRandom: 'Tilfeldig valg',
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…',
count_one: '{{count}} spor',
count_other: '{{count}} spor',
},
artists: {
title: 'Artister',
search: 'Søk…',
+15
View File
@@ -21,6 +21,7 @@ export const nlTranslation = {
cancelDownload: 'Download annuleren',
offlineLibrary: 'Offline bibliotheek',
genres: 'Genres',
tracks: 'Nummers',
playlists: 'Playlists',
mostPlayed: 'Meest gespeeld',
radio: 'Internetradio',
@@ -376,6 +377,20 @@ export const nlTranslation = {
offlineQueuing: '{{count}} album(s) in wachtrij voor offline…',
offlineFailed: 'Toevoegen van {{name}} offline mislukt',
},
tracks: {
title: 'Nummers',
subtitle: 'Bladeren. Zoeken. Ontdekken.',
heroEyebrow: 'Nummer van het moment',
heroReroll: 'Een ander kiezen',
playSong: 'Afspelen',
enqueueSong: 'Aan wachtrij toevoegen',
railRandom: 'Willekeurige selectie',
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…',
count_one: '{{count}} nummer',
count_other: '{{count}} nummers',
},
artists: {
title: 'Artiesten',
search: 'Zoeken…',
+17
View File
@@ -22,6 +22,7 @@ export const ruTranslation = {
cancelDownload: 'Отменить загрузку',
offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры',
tracks: 'Треки',
playlists: 'Плейлисты',
smartPlaylists: 'Смарт-плейлисты',
mostPlayed: 'Популярное',
@@ -401,6 +402,22 @@ export const ruTranslation = {
offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…',
offlineFailed: 'Не удалось добавить {{name}} офлайн',
},
tracks: {
title: 'Треки',
subtitle: 'Листать. Искать. Открывать.',
heroEyebrow: 'Трек момента',
heroReroll: 'Выбрать другой',
playSong: 'Воспроизвести',
enqueueSong: 'В очередь',
railRandom: 'Случайная подборка',
browseTitle: 'Просмотреть все треки',
browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.',
searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
count_one: '{{count}} трек',
count_few: '{{count}} трека',
count_many: '{{count}} треков',
count_other: '{{count}} треков',
},
artists: {
title: 'Исполнители',
search: 'Поиск…',
+15
View File
@@ -21,6 +21,7 @@ export const zhTranslation = {
cancelDownload: '取消下载',
offlineLibrary: '离线音乐库',
genres: '流派',
tracks: '曲目',
playlists: '播放列表',
mostPlayed: '最常播放',
radio: '网络电台',
@@ -375,6 +376,20 @@ export const zhTranslation = {
offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…',
offlineFailed: '添加 {{name}} 离线失败',
},
tracks: {
title: '曲目',
subtitle: '浏览。搜索。发现。',
heroEyebrow: '此刻精选',
heroReroll: '换一首',
playSong: '播放',
enqueueSong: '加入队列',
railRandom: '随机精选',
browseTitle: '浏览所有曲目',
browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。',
searchPlaceholder: '按标题、艺人或专辑搜索…',
count_one: '{{count}} 首曲目',
count_other: '{{count}} 首曲目',
},
artists: {
title: '艺术家',
search: '搜索…',