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 a04b62cd..0e2a5000 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'; @@ -468,6 +469,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 f29f33ff..fef0f0f1 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/LiveSearch.tsx b/src/components/LiveSearch.tsx index c69e992b..b33b2604 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -157,9 +157,14 @@ export default function LiveSearch() {
{t('search.artists')}
{results.artists.map(a => { const i = idx++; + const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id; return ( - + + + +
+

{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/SongRow.tsx b/src/components/SongRow.tsx new file mode 100644 index 00000000..36873eae --- /dev/null +++ b/src/components/SongRow.tsx @@ -0,0 +1,130 @@ +import React, { memo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Play, ListPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { SubsonicSong } from '../api/subsonic'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { enqueueAndPlay } from '../utils/playSong'; +import { useDragDrop } from '../contexts/DragDropContext'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; + +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 Props { + song: SubsonicSong; +} + +function SongRow({ song }: Props) { + const navigate = useNavigate(); + const enqueue = usePlayerStore(s => s.enqueue); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id); + const psyDrag = useDragDrop(); + const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); + + // In an orbit session both buttons collapse into the orbit-suggest / host-enqueue + // path so we don't ship a queue replacement to every guest. + const handlePlay = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueueAndPlay(song); + }; + + const handleEnqueue = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueue([songToTrack(song)]); + }; + + return ( +
{ + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, song, 'song'); + }} + onMouseDown={(e) => { + if (e.button !== 0) return; + const sx = e.clientX, sy = e.clientY; + const track = songToTrack(song); + 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', track }), label: song.title }, + me.clientX, me.clientY, + ); + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }} + > +
+ + +
+
{song.title}
+
+ { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }} + title={song.artist} + >{song.artist} +
+
+ {song.albumId ? ( + { e.stopPropagation(); navigate(`/album/${song.albumId}`); }} + title={song.album} + >{song.album} + ) : {song.album}} +
+
+ {song.genre ?? '—'} +
+
{fmtDuration(song.duration)}
+
+ ); +} + +/** Column header with the same grid as . Optional — pages can render it above the list. */ +export function SongListHeader() { + const { t } = useTranslation(); + return ( +
+
+
{t('albumDetail.trackTitle')}
+
{t('albumDetail.trackArtist')}
+
{t('albumDetail.trackAlbum')}
+
{t('randomMix.trackGenre')}
+
{t('albumDetail.trackDuration')}
+
+ ); +} + +export default memo(SongRow); diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx new file mode 100644 index 00000000..fb155c59 --- /dev/null +++ b/src/components/VirtualSongList.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Search as SearchIcon, X } 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 SongRow, { SongListHeader } from './SongRow'; + +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); + } +} + +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 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 a0fed288..1e026994 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', @@ -40,6 +41,7 @@ export const deTranslation = { mostPlayed: 'Meistgehört', recentlyPlayed: 'Kürzlich gespielt', discover: 'Entdecken', + discoverSongs: 'Titel entdecken', loadMore: 'Mehr laden', discoverMore: 'Mehr entdecken', discoverArtists: 'Künstler entdecken', @@ -378,6 +380,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 a92fe3f3..bf61a1f1 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', @@ -42,6 +43,7 @@ export const enTranslation = { mostPlayed: 'Most Played', recentlyPlayed: 'Recently Played', discover: 'Discover', + discoverSongs: 'Discover Songs', loadMore: 'Load More', discoverMore: 'Discover More', discoverArtists: 'Discover Artists', @@ -380,6 +382,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 85ea3c5d..3da001dd 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', @@ -41,6 +42,7 @@ export const esTranslation = { mostPlayed: 'Más Reproducidos', recentlyPlayed: 'Reproducidos Recientemente', discover: 'Descubrir', + discoverSongs: 'Descubrir canciones', loadMore: 'Cargar Más', discoverMore: 'Descubrir Más', discoverArtists: 'Descubrir Artistas', @@ -380,6 +382,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 8ef75103..77f4624d 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', @@ -40,6 +41,7 @@ export const frTranslation = { mostPlayed: 'Les plus écoutés', recentlyPlayed: 'Récemment écoutés', discover: 'Découvrir', + discoverSongs: 'Découvrir des titres', loadMore: 'Charger plus', discoverMore: 'Découvrir plus', discoverArtists: 'Découvrir des artistes', @@ -378,6 +380,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 99d02690..d8434943 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', @@ -40,6 +41,7 @@ export const nbTranslation = { mostPlayed: 'Mest spilt', recentlyPlayed: 'Nylig spilt', discover: 'Oppdag', + discoverSongs: 'Oppdag spor', loadMore: 'Last inn flere', discoverMore: 'Oppdag flere', discoverArtists: 'Oppdag artister', @@ -378,6 +380,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 ca5653e3..1cfb04ea 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', @@ -40,6 +41,7 @@ export const nlTranslation = { mostPlayed: 'Meest gespeeld', recentlyPlayed: 'Recent afgespeeld', discover: 'Ontdekken', + discoverSongs: 'Nummers ontdekken', loadMore: 'Meer laden', discoverMore: 'Meer ontdekken', discoverArtists: 'Artiesten ontdekken', @@ -377,6 +379,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 ffe6df07..22a1e60a 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: 'Популярное', @@ -42,6 +43,7 @@ export const ruTranslation = { mostPlayed: 'Популярное', recentlyPlayed: 'Недавно проиграно', discover: 'Обзор', + discoverSongs: 'Открыть треки', loadMore: 'Ещё', discoverMore: 'Смотреть ещё', discoverArtists: 'Исполнители', @@ -402,6 +404,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 204a4aa5..780c2307 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: '网络电台', @@ -40,6 +41,7 @@ export const zhTranslation = { mostPlayed: '最常播放', recentlyPlayed: '最近播放', discover: '发现', + discoverSongs: '发现曲目', loadMore: '加载更多', discoverMore: '发现更多', discoverArtists: '发现艺术家', @@ -376,6 +378,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/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index fea83236..27b84ce6 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -1,19 +1,16 @@ -import React, { useEffect, useState } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { Play, SlidersVertical } from 'lucide-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SlidersVertical } from 'lucide-react'; import { - search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, + search, searchSongsPaged, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong, } from '../api/subsonic'; -import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; +import SongRow, { SongListHeader } from '../components/SongRow'; import CustomSelect from '../components/CustomSelect'; -import { useDragDrop } from '../contexts/DragDropContext'; -import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useAuthStore } from '../store/authStore'; -import { useShallow } from 'zustand/react/shallow'; type ResultType = 'all' | 'artists' | 'albums' | 'songs'; @@ -35,25 +32,6 @@ export default function AdvancedSearch() { const { t } = useTranslation(); const [params] = useSearchParams(); const qFromUrl = params.get('q') ?? ''; - const navigate = useNavigate(); - const psyDrag = useDragDrop(); - - const { playTrack, openContextMenu } = usePlayerStore( - useShallow(s => ({ - playTrack: s.playTrack, - openContextMenu: s.openContextMenu, - })) - ); - - const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); - - const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - - useEffect(() => { - if (!contextMenuOpen) setContextMenuSongId(null); - }, [contextMenuOpen]); - const [query, setQuery] = useState(params.get('q') ?? ''); const [genre, setGenre] = useState(''); const [yearFrom, setYearFrom] = useState(''); @@ -69,10 +47,35 @@ export default function AdvancedSearch() { const [genreNote, setGenreNote] = useState(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + // Pagination — only the free-text-query branch uses search3 with offset + const SONGS_INITIAL = 100; + const SONGS_PAGE_SIZE = 50; + const [activeSearch, setActiveSearch] = useState(null); + const [songsServerOffset, setSongsServerOffset] = useState(0); + const [songsHasMore, setSongsHasMore] = useState(false); + const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); + const songsSentinelRef = useRef(null); + + const applySongFilters = ( + list: SubsonicSong[], + g: string, + from: number | null, + to: number | null, + ): SubsonicSong[] => { + let r = list; + if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); + if (from !== null) r = r.filter(s => !s.year || s.year >= from); + if (to !== null) r = r.filter(s => !s.year || s.year <= to); + return r; + }; + const runSearch = async (opts: SearchOpts) => { setLoading(true); setHasSearched(true); setGenreNote(false); + setActiveSearch(opts); + setSongsServerOffset(0); + setSongsHasMore(false); const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts; const from = yf ? parseInt(yf) : null; const to = yt ? parseInt(yt) : null; @@ -83,23 +86,25 @@ export default function AdvancedSearch() { try { if (q.trim()) { - const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: 100 }); + const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL }); artists = r.artists; albums = r.albums; - songs = r.songs; + songs = applySongFilters(r.songs, g, from, to); if (g) { albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase()); - songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); } if (from !== null) { albums = albums.filter(a => !a.year || a.year >= from); - songs = songs.filter(s => !s.year || s.year >= from); } if (to !== null) { albums = albums.filter(a => !a.year || a.year <= to); - songs = songs.filter(s => !s.year || s.year <= to); } + + // Only the free-text branch supports server-side pagination via search3 offset. + // If the server returned a full page, more probably exist. + setSongsServerOffset(r.songs.length); + setSongsHasMore(r.songs.length === SONGS_INITIAL); } else if (g) { const [albumRes, songRes] = await Promise.all([ rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50), @@ -134,6 +139,39 @@ export default function AdvancedSearch() { if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' }); }, [musicLibraryFilterVersion, qFromUrl]); + const loadMoreSongs = useCallback(async () => { + if (loadingMoreSongs || !songsHasMore) return; + if (!activeSearch || !activeSearch.query.trim()) return; + setLoadingMoreSongs(true); + try { + const q = activeSearch.query.trim(); + const g = activeSearch.genre; + const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null; + const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null; + const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset); + const filtered = applySongFilters(page, g, from, to); + setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev); + setSongsServerOffset(o => o + page.length); + // No more pages when the server returned a non-full page (regardless of how many survived filtering). + if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); + } catch { + setSongsHasMore(false); + } finally { + setLoadingMoreSongs(false); + } + }, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset]); + + // IntersectionObserver on the bottom sentinel — fires loadMoreSongs as it nears the viewport. + useEffect(() => { + const el = songsSentinelRef.current; + if (!el) return; + const obs = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) loadMoreSongs(); + }, { rootMargin: '600px' }); + obs.observe(el); + return () => obs.disconnect(); + }, [loadMoreSongs]); + const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); runSearch({ query, genre, yearFrom, yearTo, resultType }); @@ -282,90 +320,22 @@ export default function AdvancedSearch() { {results && results.songs.length > 0 && (

- {t('search.songs')} ({results.songs.length}) + {t('search.songs')} {genreNote && ( — {t('search.advancedGenreNote')} )}

-
-
- - {t('randomMix.trackTitle')} - {t('randomMix.trackArtist')} - {t('randomMix.trackAlbum')} - {t('randomMix.trackGenre')} - {t('randomMix.trackDuration')} + + {results.songs.map(song => ( + + ))} + {songsHasMore && ( +
+ {loadingMoreSongs &&
}
- {results.songs.map(song => { - const track = songToTrack(song); - return ( -
orbitActive ? addTrackToOrbit(song.id) : playTrack(track, results.songs.map(songToTrack))} - role="row" - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, track, 'song'); - }} - onMouseDown={e => { - if (e.button !== 0) return; - e.preventDefault(); - 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', track }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - -
- {song.title} -
-
- song.artistId && navigate(`/artist/${song.artistId}`)} - > - {song.artist} - -
-
- navigate(`/album/${song.albumId}`)} - > - {song.album} - -
-
- {song.genre ?? '—'} -
- - {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} - -
- ); - })} -
+ )}
)}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 86a72937..7511f10f 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import Hero from '../components/Hero'; import AlbumRow from '../components/AlbumRow'; -import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic'; +import SongRail from '../components/SongRail'; +import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { NavLink, useNavigate } from 'react-router-dom'; import { ChevronRight } from 'lucide-react'; @@ -13,6 +14,7 @@ import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../uti const HOME_RANDOM_FETCH = 100; const HOME_HERO_COUNT = 8; const HOME_DISCOVER_SLICE = 20; +const HOME_DISCOVER_SONGS_SIZE = 18; export default function Home() { const homeSections = useHomeStore(s => s.sections); @@ -30,6 +32,7 @@ export default function Home() { const [mostPlayed, setMostPlayed] = useState([]); const [recentlyPlayed, setRecentlyPlayed] = useState([]); const [randomArtists, setRandomArtists] = useState([]); + const [discoverSongs, setDiscoverSongs] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { @@ -41,13 +44,16 @@ export default function Home() { const albumMix = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE; - const [s, n, rRaw, f, rp, artists] = await Promise.all([ + const [s, n, rRaw, f, rp, artists, songs] = await Promise.all([ getAlbumList('starred', 12).catch(() => []), getAlbumList('newest', 12).catch(() => []), getAlbumList('random', randomSize).catch(() => []), getAlbumList('frequent', 12).catch(() => []), getAlbumList('recent', 12).catch(() => []), isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve([]), + isVisible('discoverSongs') + ? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[]) + : Promise.resolve([]), ]); if (cancelled) return; const r = await filterAlbumsByMixRatings(rRaw, mixCfg); @@ -57,6 +63,7 @@ export default function Home() { setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE)); setMostPlayed(f); setRecentlyPlayed(rp); + setDiscoverSongs(songs); const shuffled = [...artists]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -128,6 +135,12 @@ export default function Home() { moreText={t('home.discoverMore')} /> )} + {isVisible('discoverSongs') && discoverSongs.length > 0 && ( + + )} {isVisible('discoverArtists') && randomArtists.length > 0 && (
diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 91b6afa7..8229ff4f 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,20 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Play, Search } from 'lucide-react'; -import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic'; -import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { Search } from 'lucide-react'; +import { search, searchSongsPaged, SearchResults as ISearchResults } from '../api/subsonic'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; +import SongRow, { SongListHeader } from '../components/SongRow'; import { useTranslation } from 'react-i18next'; -import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; -import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; -import { useThemeStore } from '../store/themeStore'; -import { useShallow } from 'zustand/react/shallow'; -function formatDuration(s: number) { - return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; -} +const SONGS_INITIAL = 50; +const SONGS_PAGE_SIZE = 50; export default function SearchResults() { const { t } = useTranslation(); @@ -22,43 +17,53 @@ export default function SearchResults() { const query = params.get('q') ?? ''; const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); + const [songsServerOffset, setSongsServerOffset] = useState(0); + const [songsHasMore, setSongsHasMore] = useState(false); + const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); + const songsSentinelRef = useRef(null); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); - const showBitrate = useThemeStore(s => s.showBitrate); - const psyDrag = useDragDrop(); - - const { playTrack, enqueue, openContextMenu, currentTrack } = usePlayerStore( - useShallow(s => ({ - playTrack: s.playTrack, - enqueue: s.enqueue, - openContextMenu: s.openContextMenu, - currentTrack: s.currentTrack, - })) - ); - - const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - - useEffect(() => { - if (!contextMenuOpen) setContextMenuSongId(null); - }, [contextMenuOpen]); useEffect(() => { + setSongsServerOffset(0); + setSongsHasMore(false); if (!query.trim()) { setResults(null); return; } setLoading(true); - search(query, { artistCount: 20, albumCount: 20, songCount: 50 }) - .then(r => setResults(r)) + search(query, { artistCount: 20, albumCount: 20, songCount: SONGS_INITIAL }) + .then(r => { + setResults(r); + setSongsServerOffset(r.songs.length); + setSongsHasMore(r.songs.length === SONGS_INITIAL); + }) .finally(() => setLoading(false)); }, [query, musicLibraryFilterVersion]); + const loadMoreSongs = useCallback(async () => { + if (loadingMoreSongs || !songsHasMore || !query.trim()) return; + setLoadingMoreSongs(true); + try { + const page = await searchSongsPaged(query.trim(), SONGS_PAGE_SIZE, songsServerOffset); + setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev); + setSongsServerOffset(o => o + page.length); + if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); + } catch { + setSongsHasMore(false); + } finally { + setLoadingMoreSongs(false); + } + }, [loadingMoreSongs, songsHasMore, query, songsServerOffset]); + + useEffect(() => { + const el = songsSentinelRef.current; + if (!el) return; + const obs = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) loadMoreSongs(); + }, { rootMargin: '600px' }); + obs.observe(el); + return () => obs.disconnect(); + }, [loadMoreSongs]); + const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); - const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); - - const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { - if (orbitActive) { addTrackToOrbit(song.id); return; } - playTrack(songToTrack(song), list.map(songToTrack)); - }; - return (
@@ -87,69 +92,17 @@ export default function SearchResults() { )} {results.songs.length > 0 && ( -
-
-

{t('search.songs')}

-
-
-
-
-
{t('albumDetail.trackTitle')}
-
{t('albumDetail.trackArtist')}
-
{t('search.album')}
-
{t('albumDetail.trackFormat')}
-
{t('albumDetail.trackDuration')}
+
+

{t('search.songs')}

+ + {results.songs.map(song => ( + + ))} + {songsHasMore && ( +
+ {loadingMoreSongs &&
}
- {results.songs.map(song => ( -
playSong(song, results.songs)} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); - }} - role="row" - onMouseDown={e => { - if (e.button !== 0) return; - e.preventDefault(); - const sx = e.clientX, sy = e.clientY; - const track = songToTrack(song); - 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', track }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - -
- {song.title} -
-
{song.artist}
-
{song.album}
- - {[song.suffix?.toUpperCase(), showBitrate && song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')} - - - {formatDuration(song.duration)} - -
- ))} -
+ )}
)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e1d2a713..ec9d705f 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -4220,6 +4220,7 @@ function HomeCustomizer() { hero: t('home.hero'), recent: t('home.recent'), discover: t('home.discover'), + discoverSongs: t('home.discoverSongs'), discoverArtists: t('home.discoverArtists'), recentlyPlayed: t('home.recentlyPlayed'), starred: t('home.starred'), 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/homeStore.ts b/src/store/homeStore.ts index 676750c9..62573cb7 100644 --- a/src/store/homeStore.ts +++ b/src/store/homeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed'; +export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverSongs' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed'; export interface HomeSectionConfig { id: HomeSectionId; @@ -12,6 +12,7 @@ export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [ { id: 'hero', visible: true }, { id: 'recent', visible: true }, { id: 'discover', visible: true }, + { id: 'discoverSongs', visible: true }, { id: 'discoverArtists', visible: true }, { id: 'recentlyPlayed', visible: true }, { id: 'starred', visible: true }, @@ -33,6 +34,19 @@ export const useHomeStore = create()( })), reset: () => set({ sections: DEFAULT_HOME_SECTIONS }), }), - { name: 'psysonic_home' } + { + name: 'psysonic_home', + onRehydrateStorage: () => (state) => { + // Append any sections introduced after the user first persisted their order, + // so new defaults show up without forcing a manual Reset. + if (!state) return; + const safe = (state.sections ?? []).filter( + (s): s is HomeSectionConfig => s != null && typeof s.id === 'string', + ); + const known = new Set(safe.map(s => s.id)); + const missing = DEFAULT_HOME_SECTIONS.filter(s => !known.has(s.id)); + state.sections = missing.length > 0 ? [...safe, ...missing] : safe; + }, + } ) ); 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..435d0a65 --- /dev/null +++ b/src/styles/tracks.css @@ -0,0 +1,525 @@ +/* ───────────────────────────────────────────────────────────────── + 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); +} + +/* ─ Shared SongRow (used by Tracks Hub, SearchResults, AdvancedSearch) ─ */ + +.song-list-row { + display: grid; + grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr) 110px 56px; + gap: var(--space-3); + align-items: center; + height: 52px; + padding: 0 var(--space-3); + font-size: 13px; + cursor: default; + transition: background var(--transition-fast); + border-bottom: 1px solid var(--border-subtle); +} + +.song-list-row:hover { + background: var(--bg-hover); +} + +.song-list-row.is-current { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.song-list-row.is-current .song-list-row-title { + color: var(--accent); +} + +.song-list-row--header { + height: 36px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + cursor: default; + background: transparent; + border-bottom: 1px solid var(--border-subtle); +} + +.song-list-row--header:hover { + background: transparent; +} + +.song-list-row-cell { + min-width: 0; +} + +.song-list-row-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.song-list-row-title { + font-weight: 500; + color: var(--text-primary); +} + +.song-list-row-genre { + color: var(--text-muted); + font-size: 12px; +} + +.song-list-row-duration { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 12px; +} + +.song-list-row-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); +} + +.song-list-row-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.song-list-row-btn--play { + color: var(--accent); +} + +.song-list-row-btn--play:hover { + background: var(--accent); + color: var(--ctp-crust, #11111b); +} + +.song-list-row.is-current .song-list-row-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; + } + + .song-list-row { + grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 56px; + } + + .song-list-row-cell:nth-child(4), /* album */ + .song-list-row-cell:nth-child(5) { /* genre */ + 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); +}