From e3aabd98b7e9ff0f64f7f20ac5903e2a2cf3e2a6 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:23:15 +0200 Subject: [PATCH 1/4] feat(tracks): add Tracks library hub page (closes #299) (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /tracks route with three sections: - Hero "Track of the moment" — random pick with play / enqueue / reroll - Random Pick rail — 18 song cards, rerollable; hero song deduped - Browse all tracks — virtualized list (@tanstack/react-virtual), paginated 50 at a time Browse uses Navidrome's native /api/song?_sort=title&_order=ASC for proper A-Z order (no Subsonic equivalent), with automatic fallback to search3 on non-Navidrome servers. Search input drives search3 with 300ms debounce. Bearer token cached module-level, re-auth on 401. Play button on rows + cards calls a new enqueueAndPlay() helper that appends to the existing queue (skip if duplicate) and jumps to the song — different from playSongNow which replaces the queue. Enqueue button stays opaque (no hover-only). i18n keys for sidebar.tracks + tracks.* namespace in all 8 locales. New AudioLines sidebar icon. Sidebar entry inserted between "All Albums" and "Build a Mix". Performance: cover thumbnails dropped (uniform layout instead), RAF-throttled scroll prefetch, hover transforms removed from cards (WebKitGTK compositing-friendly). Co-authored-by: Claude Opus 4.7 (1M context) --- src-tauri/src/lib.rs | 28 ++ src/App.tsx | 2 + src/api/navidromeBrowse.ts | 97 ++++++ src/api/subsonic.ts | 17 + src/components/SongCard.tsx | 114 ++++++ src/components/SongRail.tsx | 91 +++++ src/components/VirtualSongList.tsx | 288 ++++++++++++++++ src/config/navItems.ts | 2 + src/locales/de.ts | 15 + src/locales/en.ts | 15 + src/locales/es.ts | 15 + src/locales/fr.ts | 15 + src/locales/nb.ts | 15 + src/locales/nl.ts | 15 + src/locales/ru.ts | 17 + src/locales/zh.ts | 15 + src/main.tsx | 1 + src/pages/Tracks.tsx | 153 +++++++++ src/store/sidebarStore.ts | 1 + src/styles/tracks.css | 535 +++++++++++++++++++++++++++++ src/utils/playSong.ts | 61 ++++ 21 files changed, 1512 insertions(+) create mode 100644 src/api/navidromeBrowse.ts create mode 100644 src/components/SongCard.tsx create mode 100644 src/components/SongRail.tsx create mode 100644 src/components/VirtualSongList.tsx create mode 100644 src/pages/Tracks.tsx create mode 100644 src/styles/tracks.css create mode 100644 src/utils/playSong.ts 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); +} From 807e7b4520e9b1b8e1ab5071d6b9a6dbf07ec8b6 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:32:20 +0200 Subject: [PATCH 2/4] feat(home): add Discover Songs rail to Mainstage (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the SongRail component from the Tracks hub — 18 random songs fetched in parallel with the other Home queries. No reroll button. Click-to-play uses enqueueAndPlay so the existing queue stays intact. New homeStore section id 'discoverSongs' inserted after 'discover' in DEFAULT_HOME_SECTIONS. onRehydrateStorage now appends any newly introduced sections to a previously persisted layout — existing users get the rail without a manual Reset. Settings → Personalisation Home-Customizer surfaces it automatically via SECTION_LABELS. i18n key home.discoverSongs added to all 8 locales. Co-authored-by: Claude Opus 4.7 (1M context) --- src/locales/de.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/fr.ts | 1 + src/locales/nb.ts | 1 + src/locales/nl.ts | 1 + src/locales/ru.ts | 1 + src/locales/zh.ts | 1 + src/pages/Home.tsx | 17 +++++++++++++++-- src/pages/Settings.tsx | 1 + src/store/homeStore.ts | 18 ++++++++++++++++-- 11 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/locales/de.ts b/src/locales/de.ts index 9a3f40de..862a6b72 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -41,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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 6fd07e7f..21f42b40 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -43,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', diff --git a/src/locales/es.ts b/src/locales/es.ts index 2eaf3837..e9838e49 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -42,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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 00332085..e0b62d28 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -41,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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d840baec..c85fc0ea 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -41,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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index babf582c..0f21b74f 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -41,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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 48a35936..b2add156 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -43,6 +43,7 @@ export const ruTranslation = { mostPlayed: 'Популярное', recentlyPlayed: 'Недавно проиграно', discover: 'Обзор', + discoverSongs: 'Открыть треки', loadMore: 'Ещё', discoverMore: 'Смотреть ещё', discoverArtists: 'Исполнители', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index fc259946..bdde858e 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -41,6 +41,7 @@ export const zhTranslation = { mostPlayed: '最常播放', recentlyPlayed: '最近播放', discover: '发现', + discoverSongs: '发现曲目', loadMore: '加载更多', discoverMore: '发现更多', discoverArtists: '发现艺术家', 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/Settings.tsx b/src/pages/Settings.tsx index 8236b535..1ff824cf 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -4209,6 +4209,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/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; + }, + } ) ); From 3c0a42e2980349ea4d3f229fbd6f13dda4865781 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:50:54 +0200 Subject: [PATCH 3/4] fix(search): right-click context menu on artist + album rows (#302) Mirrors what PR #298 did for songs in the live-search dropdown: both artist and album rows now respond to right-click with the matching context menu (type 'artist' / 'album'), and pick up the .context-active highlight while their menu is open. Click-to-navigate behaviour is unchanged. Co-authored-by: Claude Opus 4.7 (1M context) --- src/components/LiveSearch.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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}
+
+ { 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 index e5174bd2..fb155c59 100644 --- a/src/components/VirtualSongList.tsx +++ b/src/components/VirtualSongList.tsx @@ -1,12 +1,10 @@ -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 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 { usePlayerStore, songToTrack } from '../store/playerStore'; -import { enqueueAndPlay } from '../utils/playSong'; +import SongRow, { SongListHeader } from './SongRow'; const PAGE_SIZE = 50; const SEARCH_DEBOUNCE_MS = 300; @@ -29,72 +27,6 @@ async function fetchSongPage(query: string, offset: number): Promise 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; @@ -110,8 +42,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { 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); @@ -250,8 +180,10 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { {emptyBrowseText ?? t('tracks.browseUnsupported')}
) : ( -
-
+ <> + +
+
{virtualizer.getVirtualItems().map(vi => { const song = songs[vi.index]; if (!song) return null; @@ -267,21 +199,21 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { transform: `translateY(${vi.start}px)`, }} > -
); })}
- {loading && ( -
-
- {t('common.loadingMore')} -
- )} -
+ {loading && ( +
+
+ {t('common.loadingMore')} +
+ )} +
+ )}
); diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 8ee5c83c..27b84ce6 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -1,18 +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 { useAuthStore } from '../store/authStore'; -import { useShallow } from 'zustand/react/shallow'; type ResultType = 'all' | 'artists' | 'albums' | 'songs'; @@ -34,23 +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 [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(''); @@ -66,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; @@ -80,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), @@ -131,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 }); @@ -279,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 ( -
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/SearchResults.tsx b/src/pages/SearchResults.tsx index e854edc6..8229ff4f 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,19 +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 { 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(); @@ -21,39 +17,52 @@ 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 hasResults = results && (results.artists.length || results.albums.length || results.songs.length); + 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]); - const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { - playTrack(songToTrack(song), list.map(songToTrack)); - }; + 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); return (
@@ -83,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/styles/tracks.css b/src/styles/tracks.css index edeb431b..435d0a65 100644 --- a/src/styles/tracks.css +++ b/src/styles/tracks.css @@ -392,88 +392,77 @@ color: var(--text-muted); } -/* ─ Row layout ─ */ +/* ─ Shared SongRow (used by Tracks Hub, SearchResults, AdvancedSearch) ─ */ -.virtual-song-row { +.song-list-row { display: grid; - grid-template-columns: 64px minmax(0, 1.6fr) minmax(0, 1.2fr) 56px; + 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); } -.virtual-song-row:hover { +.song-list-row:hover { background: var(--bg-hover); } -.virtual-song-row.is-current { +.song-list-row.is-current { background: color-mix(in srgb, var(--accent) 14%, transparent); } -.virtual-song-row.is-current .virtual-song-title { +.song-list-row.is-current .song-list-row-title { color: var(--accent); } -.virtual-song-cell { +.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; } -.virtual-song-cell-actions-left { +.song-list-row-actions { 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; +.song-list-row-title { 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; +.song-list-row-genre { color: var(--text-muted); - font-variant-numeric: tabular-nums; + font-size: 12px; +} + +.song-list-row-duration { text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 12px; } -.virtual-song-cell-actions { - display: flex; - align-items: center; - justify-content: center; -} - -.virtual-song-action-btn { +.song-list-row-btn { background: transparent; border: none; color: var(--text-muted); @@ -488,21 +477,21 @@ transition: background var(--transition-fast), color var(--transition-fast); } -.virtual-song-action-btn:hover { +.song-list-row-btn:hover { background: var(--bg-hover); color: var(--text-primary); } -.virtual-song-action-btn--play { +.song-list-row-btn--play { color: var(--accent); } -.virtual-song-action-btn--play:hover { +.song-list-row-btn--play:hover { background: var(--accent); color: var(--ctp-crust, #11111b); } -.virtual-song-row.is-current .virtual-song-action-btn--play { +.song-list-row.is-current .song-list-row-btn--play { color: var(--accent); } @@ -525,11 +514,12 @@ justify-content: center; } - .virtual-song-row { - grid-template-columns: 64px minmax(0, 1fr) 56px; + .song-list-row { + grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 56px; } - .virtual-song-cell-album { + .song-list-row-cell:nth-child(4), /* album */ + .song-list-row-cell:nth-child(5) { /* genre */ display: none; } }