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
+114
View File
@@ -0,0 +1,114 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import CachedImage from './CachedImage';
import { enqueueAndPlay } from '../utils/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
interface SongCardProps {
song: SubsonicSong;
}
function SongCard({ song }: SongCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : '';
const psyDrag = useDragDrop();
const handleClick = () => enqueueAndPlay(song);
const handleArtistClick = (e: React.MouseEvent) => {
if (!song.artistId) return;
e.stopPropagation();
navigate(`/artist/${song.artistId}`);
};
return (
<div
className="song-card card"
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${song.title} ${song.artist}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, song, 'song');
}}
onMouseDown={e => {
if (e.button !== 0) return;
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'song', id: song.id, name: song.title }), label: song.title, coverUrl: coverUrl || undefined },
me.clientX, me.clientY,
);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="song-card-cover">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverArtCacheKey(song.coverArt!, 200)}
alt={`${song.album} Cover`}
loading="lazy"
/>
) : (
<div className="song-card-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
)}
<div className="song-card-play-overlay">
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); enqueueAndPlay(song); }}
aria-label={t('tracks.playSong')}
data-tooltip={t('tracks.playSong')}
data-tooltip-pos="top"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); enqueue([songToTrack(song)]); }}
aria-label={t('tracks.enqueueSong')}
data-tooltip={t('tracks.enqueueSong')}
data-tooltip-pos="top"
>
<ListPlus size={14} />
</button>
</div>
</div>
<div className="song-card-info">
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
<p
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={handleArtistClick}
title={song.artist}
>{song.artist}</p>
</div>
</div>
);
}
export default memo(SongCard);
+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>
);
}
+288
View File
@@ -0,0 +1,288 @@
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Search as SearchIcon, X, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SubsonicSong, searchSongsPaged } from '../api/subsonic';
import { ndListSongs } from '../api/navidromeBrowse';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { enqueueAndPlay } from '../utils/playSong';
const PAGE_SIZE = 50;
const SEARCH_DEBOUNCE_MS = 300;
const ROW_HEIGHT = 52;
const PREFETCH_PX = 600;
/**
* Empty query → Navidrome /api/song sorted by title (no Subsonic equivalent).
* Non-empty → Subsonic search3 (search isn't a browse).
* Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3.
*/
async function fetchSongPage(query: string, offset: number): Promise<SubsonicSong[]> {
if (query !== '') {
return searchSongsPaged(query, PAGE_SIZE, offset);
}
try {
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
} catch {
return searchSongsPaged('', PAGE_SIZE, offset);
}
}
function fmtDuration(s: number): string {
if (!s || !isFinite(s)) return '';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
interface RowProps {
song: SubsonicSong;
isCurrent: boolean;
}
const SongListRow = memo(function SongListRow({ song, isCurrent }: RowProps) {
const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
return (
<div
className={`virtual-song-row${isCurrent ? ' is-current' : ''}`}
onDoubleClick={() => enqueueAndPlay(song)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, song, 'song');
}}
>
<div className="virtual-song-cell virtual-song-cell-actions-left">
<button
className="virtual-song-action-btn virtual-song-action-btn--play"
onClick={(e) => { e.stopPropagation(); enqueueAndPlay(song); }}
aria-label="Play"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="virtual-song-action-btn"
onClick={(e) => { e.stopPropagation(); enqueue([songToTrack(song)]); }}
aria-label="Enqueue"
>
<ListPlus size={14} />
</button>
</div>
<div className="virtual-song-cell virtual-song-cell-title">
<span className="virtual-song-title truncate">{song.title}</span>
<span
className={`virtual-song-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
onClick={(e) => {
if (!song.artistId) return;
e.stopPropagation();
navigate(`/artist/${song.artistId}`);
}}
>{song.artist}</span>
</div>
<div className="virtual-song-cell virtual-song-cell-album truncate">
{song.albumId ? (
<span
className="track-artist-link"
onClick={(e) => { e.stopPropagation(); navigate(`/album/${song.albumId}`); }}
>{song.album}</span>
) : <span>{song.album}</span>}
</div>
<div className="virtual-song-cell virtual-song-cell-duration">{fmtDuration(song.duration)}</div>
</div>
);
});
interface Props {
title?: string;
emptyBrowseText?: string;
}
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [browseUnsupported, setBrowseUnsupported] = useState(false);
const currentTrackId = usePlayerStore(s => s.currentTrack?.id ?? null);
const scrollParentRef = useRef<HTMLDivElement>(null);
const requestSeqRef = useRef(0);
// Debounce query
useEffect(() => {
const h = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(h);
}, [query]);
// Reset + first-page fetch on query change. One effect, no dep cascade,
// and a `cancelled` flag so a fast typist doesn't see results from stale queries.
useEffect(() => {
let cancelled = false;
setSongs([]);
setOffset(0);
setHasMore(true);
setBrowseUnsupported(false);
if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0;
const seq = ++requestSeqRef.current;
setLoading(true);
(async () => {
try {
const page = await fetchSongPage(debouncedQuery, 0);
if (cancelled || seq !== requestSeqRef.current) return;
if (page.length === 0) {
setHasMore(false);
if (debouncedQuery === '') setBrowseUnsupported(true);
} else {
setSongs(page);
setOffset(page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
if (!cancelled) setHasMore(false);
} finally {
if (!cancelled && seq === requestSeqRef.current) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [debouncedQuery]);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const seq = ++requestSeqRef.current;
try {
const page = await fetchSongPage(debouncedQuery, offset);
if (seq !== requestSeqRef.current) return;
if (page.length === 0) {
setHasMore(false);
} else {
setSongs(prev => {
const seen = new Set(prev.map(s => s.id));
const merged = [...prev];
for (const s of page) if (!seen.has(s.id)) merged.push(s);
return merged;
});
setOffset(o => o + page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
setHasMore(false);
} finally {
if (seq === requestSeqRef.current) setLoading(false);
}
}, [loading, hasMore, debouncedQuery, offset]);
// Scroll-based prefetch — uses ref so a stale loadMore can't loop
const loadMoreRef = useRef(loadMore);
useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]);
useEffect(() => {
const el = scrollParentRef.current;
if (!el) return;
let rafId = 0;
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - PREFETCH_PX) {
loadMoreRef.current();
}
});
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
const virtualizer = useVirtualizer({
count: songs.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const totalSize = virtualizer.getTotalSize();
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
return (
<section className="virtual-song-list-section">
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
<div className="virtual-song-list-toolbar">
<div className="virtual-song-list-search">
<SearchIcon size={16} className="virtual-song-list-search-icon" />
<input
type="text"
className="input virtual-song-list-search-input"
placeholder={t('tracks.searchPlaceholder')}
value={query}
onChange={e => setQuery(e.target.value)}
/>
{query && (
<button
className="virtual-song-list-search-clear"
onClick={() => setQuery('')}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
)}
</div>
<div className="virtual-song-list-meta">
{songs.length > 0 && (
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
)}
</div>
</div>
{showEmptyBrowse ? (
<div className="virtual-song-list-empty">
{emptyBrowseText ?? t('tracks.browseUnsupported')}
</div>
) : (
<div ref={scrollParentRef} className="virtual-song-list-scroll">
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const song = songs[vi.index];
if (!song) return null;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${vi.start}px)`,
}}
>
<SongListRow
song={song}
isCurrent={currentTrackId === song.id}
/>
</div>
);
})}
</div>
{loading && (
<div className="virtual-song-list-loading">
<div className="spinner" style={{ width: 18, height: 18 }} />
<span>{t('common.loadingMore')}</span>
</div>
)}
</div>
)}
</section>
);
}