mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
93b724fc65
commit
e3aabd98b7
@@ -46,6 +46,7 @@ const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
|
||||
const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
|
||||
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
|
||||
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
|
||||
const Tracks = lazy(() => import('./pages/Tracks'));
|
||||
import MiniPlayer from './components/MiniPlayer';
|
||||
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
@@ -445,6 +446,7 @@ function AppShell() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/tracks" element={<Tracks />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
import type { SubsonicSong } from './subsonic';
|
||||
|
||||
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
|
||||
let cachedToken: { serverUrl: string; token: string } | null = null;
|
||||
|
||||
async function getToken(force = false): Promise<string> {
|
||||
const { getActiveServer, getBaseUrl } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
if (!server || !baseUrl) throw new Error('No active server configured');
|
||||
if (!force && cachedToken?.serverUrl === baseUrl) return cachedToken.token;
|
||||
const result = await ndLogin(baseUrl, server.username, server.password);
|
||||
cachedToken = { serverUrl: baseUrl, token: result.token };
|
||||
return result.token;
|
||||
}
|
||||
|
||||
function asString(v: unknown, fallback = ''): string {
|
||||
return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback);
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function mapNdSong(o: Record<string, unknown>): SubsonicSong {
|
||||
// Navidrome's REST shape differs from Subsonic — flatten into the SubsonicSong contract.
|
||||
const id = asString(o.id ?? o.mediaFileId);
|
||||
const albumId = asString(o.albumId);
|
||||
return {
|
||||
id,
|
||||
title: asString(o.title),
|
||||
artist: asString(o.artist),
|
||||
album: asString(o.album),
|
||||
albumId,
|
||||
artistId: asString(o.artistId) || undefined,
|
||||
duration: asNumber(o.duration) !== undefined ? Math.round(asNumber(o.duration)!) : 0,
|
||||
track: asNumber(o.trackNumber),
|
||||
discNumber: asNumber(o.discNumber),
|
||||
// Navidrome usually exposes coverArtId; many builds also accept the song id directly.
|
||||
coverArt: asString(o.coverArtId) || albumId || id || undefined,
|
||||
year: asNumber(o.year),
|
||||
userRating: asNumber(o.rating),
|
||||
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
bitRate: asNumber(o.bitRate),
|
||||
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
|
||||
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
|
||||
size: asNumber(o.size),
|
||||
samplingRate: asNumber(o.sampleRate),
|
||||
bitDepth: asNumber(o.bitDepth),
|
||||
};
|
||||
}
|
||||
|
||||
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function ndListSongs(
|
||||
start: number,
|
||||
end: number,
|
||||
sort: NdSongSort = 'title',
|
||||
order: 'ASC' | 'DESC' = 'ASC',
|
||||
): Promise<SubsonicSong[]> {
|
||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
|
||||
const callOnce = async (token: string): Promise<unknown> =>
|
||||
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
|
||||
|
||||
let token = await getToken();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await callOnce(token);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
// Token rejected → re-auth once and retry
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
token = await getToken(true);
|
||||
raw = await callOnce(token);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(s => mapNdSong(s as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** Drop the cached token — call when the active server changes. */
|
||||
export function ndClearTokenCache(): void {
|
||||
cachedToken = null;
|
||||
}
|
||||
@@ -926,6 +926,23 @@ export async function search(query: string, options?: { albumCount?: number; art
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-only paginated search3. Tolerates empty query — Navidrome returns all songs
|
||||
* ordered by title in that case; strict Subsonic implementations may return nothing.
|
||||
* Caller handles empty results gracefully (Tracks page falls back to its random pool).
|
||||
*/
|
||||
export async function searchSongsPaged(query: string, songCount: number, songOffset: number): Promise<SubsonicSong[]> {
|
||||
const data = await api<{ searchResult3: { song?: SubsonicSong[] } }>('search3.view', {
|
||||
query,
|
||||
artistCount: 0,
|
||||
albumCount: 0,
|
||||
songCount,
|
||||
songOffset,
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
return data.searchResult3?.song ?? [];
|
||||
}
|
||||
|
||||
export async function setRating(id: string, rating: number): Promise<void> {
|
||||
await api('setRating.view', { id, rating });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Disc3, Users, Music4, Radio, Heart, BarChart3,
|
||||
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
|
||||
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
|
||||
AudioLines,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface NavItemMeta {
|
||||
@@ -17,6 +18,7 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
|
||||
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
|
||||
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
|
||||
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
|
||||
tracks: { icon: AudioLines, labelKey: 'sidebar.tracks', to: '/tracks', section: 'library' },
|
||||
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
|
||||
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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: 'Поиск…',
|
||||
|
||||
@@ -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: '搜索…',
|
||||
|
||||
@@ -7,6 +7,7 @@ import './i18n';
|
||||
import './styles/theme.css';
|
||||
import './styles/layout.css';
|
||||
import './styles/components.css';
|
||||
import './styles/tracks.css';
|
||||
|
||||
// Expose the Tauri window label synchronously so App() can pick its root
|
||||
// component (main app vs mini player) on first render without flicker.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
SubsonicSong,
|
||||
getRandomSongs,
|
||||
buildCoverArtUrl,
|
||||
coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import SongRail from '../components/SongRail';
|
||||
import VirtualSongList from '../components/VirtualSongList';
|
||||
import { playSongNow } from '../utils/playSong';
|
||||
|
||||
const RANDOM_RAIL_SIZE = 18;
|
||||
|
||||
export default function Tracks() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [hero, setHero] = useState<SubsonicSong | null>(null);
|
||||
const [heroLoading, setHeroLoading] = useState(false);
|
||||
|
||||
const [random, setRandom] = useState<SubsonicSong[]>([]);
|
||||
const [randomLoading, setRandomLoading] = useState(true);
|
||||
|
||||
const rerollHero = useCallback(async () => {
|
||||
setHeroLoading(true);
|
||||
try {
|
||||
const picks = await getRandomSongs(1);
|
||||
if (picks[0]) setHero(picks[0]);
|
||||
} finally {
|
||||
setHeroLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rerollRandom = useCallback(async () => {
|
||||
setRandomLoading(true);
|
||||
try {
|
||||
const r = await getRandomSongs(RANDOM_RAIL_SIZE);
|
||||
setRandom(r);
|
||||
} finally {
|
||||
setRandomLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId) return;
|
||||
rerollHero();
|
||||
rerollRandom();
|
||||
}, [activeServerId, rerollHero, rerollRandom]);
|
||||
|
||||
const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : '';
|
||||
|
||||
// Hide the hero song from the random rail if the server happens to return it in
|
||||
// both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window).
|
||||
const railSongs = useMemo(
|
||||
() => (hero ? random.filter(s => s.id !== hero.id) : random),
|
||||
[random, hero],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in tracks-page">
|
||||
<header className="tracks-header">
|
||||
<div className="tracks-header-text">
|
||||
<h1 className="page-title">{t('tracks.title')}</h1>
|
||||
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{hero && (
|
||||
<section className="tracks-hero">
|
||||
<div className="tracks-hero-cover">
|
||||
{heroCoverUrl ? (
|
||||
<CachedImage
|
||||
src={heroCoverUrl}
|
||||
cacheKey={coverArtCacheKey(hero.coverArt!, 600)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="tracks-hero-cover-placeholder" />
|
||||
)}
|
||||
</div>
|
||||
<div className="tracks-hero-content">
|
||||
<span className="tracks-hero-eyebrow">
|
||||
<Sparkles size={14} />
|
||||
{t('tracks.heroEyebrow')}
|
||||
</span>
|
||||
<h2 className="tracks-hero-title" title={hero.title}>{hero.title}</h2>
|
||||
<p className="tracks-hero-meta">
|
||||
<span
|
||||
className={hero.artistId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: hero.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => hero.artistId && navigate(`/artist/${hero.artistId}`)}
|
||||
>{hero.artist}</span>
|
||||
{hero.album && (
|
||||
<>
|
||||
<span className="tracks-hero-meta-dot">·</span>
|
||||
<span
|
||||
className={hero.albumId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => hero.albumId && navigate(`/album/${hero.albumId}`)}
|
||||
>{hero.album}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="tracks-hero-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => playSongNow(hero)}
|
||||
>
|
||||
<Play size={16} fill="currentColor" /> {t('tracks.playSong')}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => enqueue([songToTrack(hero)])}
|
||||
>
|
||||
<ListPlus size={16} /> {t('tracks.enqueueSong')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={rerollHero}
|
||||
disabled={heroLoading}
|
||||
aria-label={t('tracks.heroReroll')}
|
||||
data-tooltip={t('tracks.heroReroll')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<RefreshCw size={16} className={heroLoading ? 'is-spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<SongRail
|
||||
title={t('tracks.railRandom')}
|
||||
songs={railSongs}
|
||||
loading={randomLoading}
|
||||
onReroll={rerollRandom}
|
||||
/>
|
||||
|
||||
<VirtualSongList
|
||||
title={t('tracks.browseTitle')}
|
||||
emptyBrowseText={t('tracks.browseUnsupported')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'mainstage', visible: true },
|
||||
{ id: 'newReleases', visible: true },
|
||||
{ id: 'allAlbums', visible: true },
|
||||
{ id: 'tracks', visible: true },
|
||||
{ id: 'randomPicker', visible: true },
|
||||
{ id: 'randomMix', visible: true },
|
||||
{ id: 'randomAlbums', visible: true },
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Tracks Page — Hub view (rails on top + virtualized browse below)
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tracks-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6, 1.5rem);
|
||||
padding-bottom: var(--space-8, 2rem);
|
||||
}
|
||||
|
||||
.tracks-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.tracks-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Hero ────────────────────────────────────────────────────── */
|
||||
|
||||
.tracks-hero {
|
||||
display: flex;
|
||||
gap: var(--space-5, 1.25rem);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tracks-hero-cover {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
overflow: hidden;
|
||||
background: var(--bg-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.tracks-hero-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tracks-hero-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tracks-hero-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tracks-hero-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tracks-hero-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tracks-hero-meta {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tracks-hero-meta-dot {
|
||||
margin: 0 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tracks-hero-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tracks-hero-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ─── Spinner helper ──────────────────────────────────────────── */
|
||||
|
||||
@keyframes tracks-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.is-spinning {
|
||||
animation: tracks-spin 1s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* ─── Song Rail (mirrors album-row pattern) ───────────────────── */
|
||||
|
||||
.song-row-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.song-row-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.song-row-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.song-row-nav .nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.song-row-nav .nav-btn:hover:not(.disabled):not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.song-row-nav .nav-btn.disabled,
|
||||
.song-row-nav .nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.song-grid-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.song-grid {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.song-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song-row-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* ─── Song Card ───────────────────────────────────────────────── */
|
||||
|
||||
.song-card {
|
||||
position: relative;
|
||||
flex: 0 0 140px;
|
||||
width: 140px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px var(--border-subtle);
|
||||
transition: box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.song-card:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md);
|
||||
}
|
||||
|
||||
.song-card-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.song-card-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.song-card-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.song-card-play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.song-card:hover .song-card-play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.song-card-action-btn {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.song-card-action-btn:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.song-card-info {
|
||||
padding: var(--space-2) var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.song-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.song-card-artist {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ─── Virtual Song List ───────────────────────────────────────── */
|
||||
|
||||
.virtual-song-list-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.virtual-song-list-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.virtual-song-list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.virtual-song-list-search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.virtual-song-list-search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.virtual-song-list-search-input {
|
||||
width: 100%;
|
||||
padding-left: 34px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.virtual-song-list-search-clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.virtual-song-list-search-clear:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.virtual-song-list-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.virtual-song-list-empty {
|
||||
padding: var(--space-6, 1.5rem) var(--space-4);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.virtual-song-list-scroll {
|
||||
max-height: 70vh;
|
||||
min-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.virtual-song-list-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: var(--space-3);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─ Row layout ─ */
|
||||
|
||||
.virtual-song-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1.6fr) minmax(0, 1.2fr) 56px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
padding: 0 var(--space-3);
|
||||
cursor: default;
|
||||
transition: background var(--transition-fast);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.virtual-song-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.virtual-song-row.is-current {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.virtual-song-row.is-current .virtual-song-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.virtual-song-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.virtual-song-cell-actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.virtual-song-cell-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.virtual-song-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.virtual-song-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.virtual-song-cell-album {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.virtual-song-cell-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.virtual-song-cell-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.virtual-song-action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.virtual-song-action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.virtual-song-action-btn--play {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.virtual-song-action-btn--play:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust, #11111b);
|
||||
}
|
||||
|
||||
.virtual-song-row.is-current .virtual-song-action-btn--play {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Responsive ──────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.tracks-hero {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tracks-hero-cover {
|
||||
flex: 0 0 140px;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.tracks-hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.virtual-song-row {
|
||||
grid-template-columns: 64px minmax(0, 1fr) 56px;
|
||||
}
|
||||
|
||||
.virtual-song-cell-album {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
|
||||
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const steps = 16;
|
||||
const stepMs = durationMs / steps;
|
||||
let step = 0;
|
||||
const id = setInterval(() => {
|
||||
step++;
|
||||
setVolume(Math.max(0, from * (1 - step / steps)));
|
||||
if (step >= steps) {
|
||||
clearInterval(id);
|
||||
resolve();
|
||||
}
|
||||
}, stepMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a single song. When `queue` is provided, surrounds the chosen song with that queue
|
||||
* so Next/Prev work — pass the rail / pool the click came from. Mirrors playAlbum's fade-out.
|
||||
*/
|
||||
export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): Promise<void> {
|
||||
const track = songToTrack(song);
|
||||
const tracks = queue && queue.length > 0
|
||||
? queue.map(songToTrack)
|
||||
: [track];
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const { isPlaying, volume } = store;
|
||||
|
||||
if (isPlaying) {
|
||||
await fadeOut(store.setVolume, volume, 700);
|
||||
usePlayerStore.setState({ volume });
|
||||
}
|
||||
|
||||
usePlayerStore.getState().playTrack(track, tracks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the song to the existing queue (if not already there) and immediately jump to it.
|
||||
* Existing queue stays intact — different from playSongNow which replaces the queue.
|
||||
*/
|
||||
export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
|
||||
const track = songToTrack(song);
|
||||
const store = usePlayerStore.getState();
|
||||
const { isPlaying, volume, queue } = store;
|
||||
|
||||
if (isPlaying) {
|
||||
await fadeOut(store.setVolume, volume, 700);
|
||||
usePlayerStore.setState({ volume });
|
||||
}
|
||||
|
||||
if (!queue.some(t => t.id === track.id)) {
|
||||
usePlayerStore.getState().enqueue([track]);
|
||||
}
|
||||
// playTrack with no queue arg uses the current state.queue, finds the track by id,
|
||||
// and sets queueIndex accordingly.
|
||||
usePlayerStore.getState().playTrack(track);
|
||||
}
|
||||
Reference in New Issue
Block a user