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
+91
View File
@@ -0,0 +1,91 @@
import React, { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import SongCard from './SongCard';
interface Props {
title: string;
songs: SubsonicSong[];
/** Called when user clicks the reroll button (visible only if provided). */
onReroll?: () => void | Promise<void>;
/** Loading state — disables reroll, optional shimmer */
loading?: boolean;
/** Empty-state copy when songs is empty AND not loading. */
emptyText?: string;
}
export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [songs]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
// Hide rail entirely if empty and no empty-state copy
if (songs.length === 0 && !loading && !emptyText) return null;
return (
<section className="song-row-section">
<div className="song-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="song-row-nav">
{onReroll && (
<button
className="nav-btn song-row-reroll"
onClick={() => onReroll()}
disabled={loading}
aria-label="Reroll"
data-tooltip="Reroll"
data-tooltip-pos="top"
>
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
</button>
)}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="song-grid-wrapper">
{songs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p>
) : (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{songs.map(s => (
<SongCard key={s.id} song={s} />
))}
</div>
)}
</div>
</section>
);
}