diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e4a39196..a6cc1202 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -522,6 +522,33 @@ async fn nd_delete_user( Ok(()) } +/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. +/// Available to any authenticated user (no admin required). Returns raw JSON array. +#[tauri::command] +async fn nd_list_songs( + server_url: String, + token: String, + sort: String, + order: String, + start: u32, + end: u32, +) -> Result { + let url = format!( + "{}/api/song?_sort={}&_order={}&_start={}&_end={}", + server_url, sort, order, start, end + ); + let resp = nd_retry(|| { + nd_http_client() + .get(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + /// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. #[tauri::command] async fn nd_list_libraries( @@ -3838,6 +3865,7 @@ pub fn run() { nd_update_user, nd_delete_user, nd_list_libraries, + nd_list_songs, nd_set_user_libraries, nd_list_playlists, nd_create_playlist, diff --git a/src/App.tsx b/src/App.tsx index 79e890fc..2cc9070d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> } /> diff --git a/src/api/navidromeBrowse.ts b/src/api/navidromeBrowse.ts new file mode 100644 index 00000000..371a8e21 --- /dev/null +++ b/src/api/navidromeBrowse.ts @@ -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 { + 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): 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 { + const baseUrl = useAuthStore.getState().getBaseUrl(); + if (!baseUrl) throw new Error('No server configured'); + + const callOnce = async (token: string): Promise => + invoke('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)); +} + +/** Drop the cached token — call when the active server changes. */ +export function ndClearTokenCache(): void { + cachedToken = null; +} diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 17456780..ca3f8d15 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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 { + 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 { await api('setRating.view', { id, rating }); } diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx new file mode 100644 index 00000000..73573ea2 --- /dev/null +++ b/src/components/SongCard.tsx @@ -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 ( +
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); + }} + > +
+ {coverUrl ? ( + + ) : ( +
+ + + + +
+ )} +
+ + +
+
+
+

{song.title}

+

{song.artist}

+
+
+ ); +} + +export default memo(SongCard); diff --git a/src/components/SongRail.tsx b/src/components/SongRail.tsx new file mode 100644 index 00000000..9dd25fbe --- /dev/null +++ b/src/components/SongRail.tsx @@ -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; + /** 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(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 ( +
+
+

{title}

+
+ {onReroll && ( + + )} + + +
+
+ +
+ {songs.length === 0 && emptyText ? ( +

{emptyText}

+ ) : ( +
+ {songs.map(s => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx new file mode 100644 index 00000000..e5174bd2 --- /dev/null +++ b/src/components/VirtualSongList.tsx @@ -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 { + 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 ( +
enqueueAndPlay(song)} + onContextMenu={(e) => { + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, song, 'song'); + }} + > +
+ + +
+
+ {song.title} + { + if (!song.artistId) return; + e.stopPropagation(); + navigate(`/artist/${song.artistId}`); + }} + >{song.artist} +
+
+ {song.albumId ? ( + { e.stopPropagation(); navigate(`/album/${song.albumId}`); }} + >{song.album} + ) : {song.album}} +
+
{fmtDuration(song.duration)}
+
+ ); +}); + +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([]); + 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(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 ( +
+ {title &&

{title}

} +
+
+ + setQuery(e.target.value)} + /> + {query && ( + + )} +
+
+ {songs.length > 0 && ( + {t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''} + )} +
+
+ + {showEmptyBrowse ? ( +
+ {emptyBrowseText ?? t('tracks.browseUnsupported')} +
+ ) : ( +
+
+ {virtualizer.getVirtualItems().map(vi => { + const song = songs[vi.index]; + if (!song) return null; + return ( +
+ +
+ ); + })} +
+ {loading && ( +
+
+ {t('common.loadingMore')} +
+ )} +
+ )} +
+ ); +} diff --git a/src/config/navItems.ts b/src/config/navItems.ts index f85c40a6..c71540bb 100644 --- a/src/config/navItems.ts +++ b/src/config/navItems.ts @@ -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 = { 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' }, diff --git a/src/locales/de.ts b/src/locales/de.ts index 9aee350e..9a3f40de 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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…', diff --git a/src/locales/en.ts b/src/locales/en.ts index 87cf2fef..6fd07e7f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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…', diff --git a/src/locales/es.ts b/src/locales/es.ts index 30f0b473..2eaf3837 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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…', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index f672ec2f..00332085 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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…', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index acea7446..d840baec 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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…', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 8446652f..babf582c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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…', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 1450e035..48a35936 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'Поиск…', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index c0331564..fc259946 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '搜索…', diff --git a/src/main.tsx b/src/main.tsx index 656a4a30..4de98ddf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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. diff --git a/src/pages/Tracks.tsx b/src/pages/Tracks.tsx new file mode 100644 index 00000000..b15ac44e --- /dev/null +++ b/src/pages/Tracks.tsx @@ -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(null); + const [heroLoading, setHeroLoading] = useState(false); + + const [random, setRandom] = useState([]); + 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 ( +
+
+
+

{t('tracks.title')}

+

{t('tracks.subtitle')}

+
+
+ + {hero && ( +
+
+ {heroCoverUrl ? ( + + ) : ( +
+ )} +
+
+ + + {t('tracks.heroEyebrow')} + +

{hero.title}

+

+ hero.artistId && navigate(`/artist/${hero.artistId}`)} + >{hero.artist} + {hero.album && ( + <> + · + hero.albumId && navigate(`/album/${hero.albumId}`)} + >{hero.album} + + )} +

+
+ + + +
+
+
+ )} + + + + +
+ ); +} diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index 0ac5aeca..82eff057 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -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 }, diff --git a/src/styles/tracks.css b/src/styles/tracks.css new file mode 100644 index 00000000..edeb431b --- /dev/null +++ b/src/styles/tracks.css @@ -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; + } +} diff --git a/src/utils/playSong.ts b/src/utils/playSong.ts new file mode 100644 index 00000000..a1ecf2d1 --- /dev/null +++ b/src/utils/playSong.ts @@ -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 { + 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 { + 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 { + 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); +}