Merge branch 'main' into exp/orbit (pre-PR sync)

Conflicts resolved in:
- src/pages/SearchResults.tsx
- src/pages/AdvancedSearch.tsx

Both pages were rewritten on main (PR #303) to use the shared
<SongRow> component with click-to-enqueueAndPlay semantics. Orbit's
playSong helper that branched on orbit-active is no longer needed
at the page level — instead, orbit awareness moved INTO SongRow and
SongCard themselves: in an active orbit session both buttons collapse
into addTrackToOrbit (suggest for guests, host-enqueue for the host)
so we don't ship a queue replacement to every guest.

Also kept main's IntersectionObserver-based pagination on both pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-25 16:34:38 +02:00
28 changed files with 1760 additions and 215 deletions
+28
View File
@@ -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<serde_json::Value, String> {
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::<serde_json::Value>().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,
+2
View File
@@ -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() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/tracks" element={<Tracks />} />
<Route path="/random" element={<RandomLanding />} />
<Route path="/random/albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
+97
View File
@@ -0,0 +1,97 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { ndLogin } from './navidromeAdmin';
import type { SubsonicSong } from './subsonic';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
async function getToken(force = false): Promise<string> {
const { getActiveServer, getBaseUrl } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
if (!server || !baseUrl) throw new Error('No active server configured');
if (!force && cachedToken?.serverUrl === baseUrl) return cachedToken.token;
const result = await ndLogin(baseUrl, server.username, server.password);
cachedToken = { serverUrl: baseUrl, token: result.token };
return result.token;
}
function asString(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback);
}
function asNumber(v: unknown): number | undefined {
return typeof v === 'number' && isFinite(v) ? v : undefined;
}
function mapNdSong(o: Record<string, unknown>): SubsonicSong {
// Navidrome's REST shape differs from Subsonic — flatten into the SubsonicSong contract.
const id = asString(o.id ?? o.mediaFileId);
const albumId = asString(o.albumId);
return {
id,
title: asString(o.title),
artist: asString(o.artist),
album: asString(o.album),
albumId,
artistId: asString(o.artistId) || undefined,
duration: asNumber(o.duration) !== undefined ? Math.round(asNumber(o.duration)!) : 0,
track: asNumber(o.trackNumber),
discNumber: asNumber(o.discNumber),
// Navidrome usually exposes coverArtId; many builds also accept the song id directly.
coverArt: asString(o.coverArtId) || albumId || id || undefined,
year: asNumber(o.year),
userRating: asNumber(o.rating),
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
genre: typeof o.genre === 'string' ? o.genre : undefined,
bitRate: asNumber(o.bitRate),
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
size: asNumber(o.size),
samplingRate: asNumber(o.sampleRate),
bitDepth: asNumber(o.bitDepth),
};
}
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count';
/**
* Fetch a sorted, paginated slice of all songs via Navidrome's native REST API.
* Returns mapped SubsonicSong objects. Throws on auth failure or non-Navidrome.
*/
export async function ndListSongs(
start: number,
end: number,
sort: NdSongSort = 'title',
order: 'ASC' | 'DESC' = 'ASC',
): Promise<SubsonicSong[]> {
const baseUrl = useAuthStore.getState().getBaseUrl();
if (!baseUrl) throw new Error('No server configured');
const callOnce = async (token: string): Promise<unknown> =>
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
let token = await getToken();
let raw: unknown;
try {
raw = await callOnce(token);
} catch (err) {
const msg = String(err);
// Token rejected → re-auth once and retry
if (msg.includes('401') || msg.includes('403')) {
token = await getToken(true);
raw = await callOnce(token);
} else {
throw err;
}
}
if (!Array.isArray(raw)) return [];
return raw.map(s => mapNdSong(s as Record<string, unknown>));
}
/** Drop the cached token — call when the active server changes. */
export function ndClearTokenCache(): void {
cachedToken = null;
}
+17
View File
@@ -926,6 +926,23 @@ export async function search(query: string, options?: { albumCount?: number; art
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
/**
* Song-only paginated search3. Tolerates empty query Navidrome returns all songs
* ordered by title in that case; strict Subsonic implementations may return nothing.
* Caller handles empty results gracefully (Tracks page falls back to its random pool).
*/
export async function searchSongsPaged(query: string, songCount: number, songOffset: number): Promise<SubsonicSong[]> {
const data = await api<{ searchResult3: { song?: SubsonicSong[] } }>('search3.view', {
query,
artistCount: 0,
albumCount: 0,
songCount,
songOffset,
...libraryFilterParams(),
});
return data.searchResult3?.song ?? [];
}
export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating });
}
+12 -2
View File
@@ -157,9 +157,14 @@ export default function LiveSearch() {
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
}}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Users size={14} /></div>
<span>{a.name}</span>
@@ -174,9 +179,14 @@ export default function LiveSearch() {
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<CachedImage
+126
View File
@@ -0,0 +1,126 @@
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';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
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 { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
const handlePlay = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueueAndPlay(song);
};
const handleEnqueue = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueue([songToTrack(song)]);
};
const handleClick = handlePlay;
const handleArtistClick = (e: React.MouseEvent) => {
if (!song.artistId) return;
e.stopPropagation();
navigate(`/artist/${song.artistId}`);
};
return (
<div
className="song-card card"
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${song.title} ${song.artist}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, song, 'song');
}}
onMouseDown={e => {
if (e.button !== 0) return;
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'song', id: song.id, name: song.title }), label: song.title, coverUrl: coverUrl || undefined },
me.clientX, me.clientY,
);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="song-card-cover">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverArtCacheKey(song.coverArt!, 200)}
alt={`${song.album} Cover`}
loading="lazy"
/>
) : (
<div className="song-card-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
)}
<div className="song-card-play-overlay">
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handlePlay(); }}
aria-label={t('tracks.playSong')}
data-tooltip={t('tracks.playSong')}
data-tooltip-pos="top"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handleEnqueue(); }}
aria-label={t('tracks.enqueueSong')}
data-tooltip={t('tracks.enqueueSong')}
data-tooltip-pos="top"
>
<ListPlus size={14} />
</button>
</div>
</div>
<div className="song-card-info">
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
<p
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={handleArtistClick}
title={song.artist}
>{song.artist}</p>
</div>
</div>
);
}
export default memo(SongCard);
+91
View File
@@ -0,0 +1,91 @@
import React, { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import SongCard from './SongCard';
interface Props {
title: string;
songs: SubsonicSong[];
/** Called when user clicks the reroll button (visible only if provided). */
onReroll?: () => void | Promise<void>;
/** Loading state — disables reroll, optional shimmer */
loading?: boolean;
/** Empty-state copy when songs is empty AND not loading. */
emptyText?: string;
}
export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [songs]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
// Hide rail entirely if empty and no empty-state copy
if (songs.length === 0 && !loading && !emptyText) return null;
return (
<section className="song-row-section">
<div className="song-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="song-row-nav">
{onReroll && (
<button
className="nav-btn song-row-reroll"
onClick={() => onReroll()}
disabled={loading}
aria-label="Reroll"
data-tooltip="Reroll"
data-tooltip-pos="top"
>
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
</button>
)}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="song-grid-wrapper">
{songs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p>
) : (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{songs.map(s => (
<SongCard key={s.id} song={s} />
))}
</div>
)}
</div>
</section>
);
}
+130
View File
@@ -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 (
<div
className={`song-list-row${isCurrent ? ' is-current' : ''}`}
onDoubleClick={handlePlay}
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 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);
}}
>
<div className="song-list-row-cell song-list-row-actions">
<button
className="song-list-row-btn song-list-row-btn--play"
onClick={(e) => { e.stopPropagation(); handlePlay(); }}
aria-label="Play"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-list-row-btn"
onClick={(e) => { e.stopPropagation(); handleEnqueue(); }}
aria-label="Enqueue"
>
<ListPlus size={14} />
</button>
</div>
<div className="song-list-row-cell song-list-row-title truncate" title={song.title}>{song.title}</div>
<div className="song-list-row-cell truncate">
<span
className={song.artistId ? 'track-artist-link' : ''}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
title={song.artist}
>{song.artist}</span>
</div>
<div className="song-list-row-cell truncate">
{song.albumId ? (
<span
className="track-artist-link"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigate(`/album/${song.albumId}`); }}
title={song.album}
>{song.album}</span>
) : <span title={song.album}>{song.album}</span>}
</div>
<div className="song-list-row-cell song-list-row-genre truncate" title={song.genre ?? ''}>
{song.genre ?? '—'}
</div>
<div className="song-list-row-cell song-list-row-duration">{fmtDuration(song.duration)}</div>
</div>
);
}
/** Column header with the same grid as <SongRow>. Optional — pages can render it above the list. */
export function SongListHeader() {
const { t } = useTranslation();
return (
<div className="song-list-row song-list-row--header" role="row">
<div className="song-list-row-cell song-list-row-actions" />
<div className="song-list-row-cell">{t('albumDetail.trackTitle')}</div>
<div className="song-list-row-cell">{t('albumDetail.trackArtist')}</div>
<div className="song-list-row-cell">{t('albumDetail.trackAlbum')}</div>
<div className="song-list-row-cell">{t('randomMix.trackGenre')}</div>
<div className="song-list-row-cell song-list-row-duration">{t('albumDetail.trackDuration')}</div>
</div>
);
}
export default memo(SongRow);
+220
View File
@@ -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<SubsonicSong[]> {
if (query !== '') {
return searchSongsPaged(query, PAGE_SIZE, offset);
}
try {
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
} catch {
return searchSongsPaged('', PAGE_SIZE, offset);
}
}
interface Props {
title?: string;
emptyBrowseText?: string;
}
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [browseUnsupported, setBrowseUnsupported] = useState(false);
const scrollParentRef = useRef<HTMLDivElement>(null);
const requestSeqRef = useRef(0);
// Debounce query
useEffect(() => {
const h = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(h);
}, [query]);
// Reset + first-page fetch on query change. One effect, no dep cascade,
// and a `cancelled` flag so a fast typist doesn't see results from stale queries.
useEffect(() => {
let cancelled = false;
setSongs([]);
setOffset(0);
setHasMore(true);
setBrowseUnsupported(false);
if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0;
const seq = ++requestSeqRef.current;
setLoading(true);
(async () => {
try {
const page = await fetchSongPage(debouncedQuery, 0);
if (cancelled || seq !== requestSeqRef.current) return;
if (page.length === 0) {
setHasMore(false);
if (debouncedQuery === '') setBrowseUnsupported(true);
} else {
setSongs(page);
setOffset(page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
if (!cancelled) setHasMore(false);
} finally {
if (!cancelled && seq === requestSeqRef.current) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [debouncedQuery]);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const seq = ++requestSeqRef.current;
try {
const page = await fetchSongPage(debouncedQuery, offset);
if (seq !== requestSeqRef.current) return;
if (page.length === 0) {
setHasMore(false);
} else {
setSongs(prev => {
const seen = new Set(prev.map(s => s.id));
const merged = [...prev];
for (const s of page) if (!seen.has(s.id)) merged.push(s);
return merged;
});
setOffset(o => o + page.length);
if (page.length < PAGE_SIZE) setHasMore(false);
}
} catch {
setHasMore(false);
} finally {
if (seq === requestSeqRef.current) setLoading(false);
}
}, [loading, hasMore, debouncedQuery, offset]);
// Scroll-based prefetch — uses ref so a stale loadMore can't loop
const loadMoreRef = useRef(loadMore);
useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]);
useEffect(() => {
const el = scrollParentRef.current;
if (!el) return;
let rafId = 0;
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - PREFETCH_PX) {
loadMoreRef.current();
}
});
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
const virtualizer = useVirtualizer({
count: songs.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const totalSize = virtualizer.getTotalSize();
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
return (
<section className="virtual-song-list-section">
{title && <h2 className="section-title virtual-song-list-title">{title}</h2>}
<div className="virtual-song-list-toolbar">
<div className="virtual-song-list-search">
<SearchIcon size={16} className="virtual-song-list-search-icon" />
<input
type="text"
className="input virtual-song-list-search-input"
placeholder={t('tracks.searchPlaceholder')}
value={query}
onChange={e => setQuery(e.target.value)}
/>
{query && (
<button
className="virtual-song-list-search-clear"
onClick={() => setQuery('')}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
)}
</div>
<div className="virtual-song-list-meta">
{songs.length > 0 && (
<span>{t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''}</span>
)}
</div>
</div>
{showEmptyBrowse ? (
<div className="virtual-song-list-empty">
{emptyBrowseText ?? t('tracks.browseUnsupported')}
</div>
) : (
<>
<SongListHeader />
<div ref={scrollParentRef} className="virtual-song-list-scroll">
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualizer.getVirtualItems().map(vi => {
const song = songs[vi.index];
if (!song) return null;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${vi.start}px)`,
}}
>
<SongRow
song={song}
/>
</div>
);
})}
</div>
{loading && (
<div className="virtual-song-list-loading">
<div className="spinner" style={{ width: 18, height: 18 }} />
<span>{t('common.loadingMore')}</span>
</div>
)}
</div>
</>
)}
</section>
);
}
+2
View File
@@ -3,6 +3,7 @@ import {
Disc3, Users, Music4, Radio, Heart, BarChart3,
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
AudioLines,
} from 'lucide-react';
export interface NavItemMeta {
@@ -17,6 +18,7 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' },
newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' },
allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' },
tracks: { icon: AudioLines, labelKey: 'sidebar.tracks', to: '/tracks', section: 'library' },
randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' },
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' },
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
+16
View File
@@ -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…',
+16
View File
@@ -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…',
+16
View File
@@ -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…',
+16
View File
@@ -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…',
+16
View File
@@ -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…',
+16
View File
@@ -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…',
+18
View File
@@ -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: 'Поиск…',
+16
View File
@@ -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: '搜索…',
+1
View File
@@ -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.
+79 -109
View File
@@ -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<string | null>(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<SearchOpts | null>(null);
const [songsServerOffset, setSongsServerOffset] = useState(0);
const [songsHasMore, setSongsHasMore] = useState(false);
const [loadingMoreSongs, setLoadingMoreSongs] = useState(false);
const songsSentinelRef = useRef<HTMLDivElement>(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 && (
<section>
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>
{t('search.songs')} ({results.songs.length})
{t('search.songs')}
{genreNote && (
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text-muted)', marginLeft: '0.75rem' }}>
{t('search.advancedGenreNote')}
</span>
)}
</h2>
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}>
<span />
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
<SongListHeader />
{results.songs.map(song => (
<SongRow key={song.id} song={song} />
))}
{songsHasMore && (
<div ref={songsSentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
{loadingMoreSongs && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{results.songs.map(song => {
const track = songToTrack(song);
return (
<div
key={song.id}
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}
onDoubleClick={() => 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);
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={e => { e.stopPropagation(); if (orbitActive) { addTrackToOrbit(song.id); return; } playTrack(track, results.songs.map(songToTrack)); }}
>
<Play size={13} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>
{song.artist}
</span>
</div>
<div className="track-info">
<span
className="track-title"
style={{ fontSize: '0.85rem', color: 'var(--subtext0)', cursor: 'pointer' }}
onClick={() => navigate(`/album/${song.albumId}`)}
>
{song.album}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{song.genre ?? '—'}
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</span>
</div>
);
})}
</div>
)}
</section>
)}
</div>
+15 -2
View File
@@ -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<SubsonicAlbum[]>([]);
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]);
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<SubsonicArtist[]>([]),
isVisible('discoverSongs')
? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[])
: Promise.resolve<SubsonicSong[]>([]),
]);
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 && (
<SongRail
title={t('home.discoverSongs')}
songs={discoverSongs}
/>
)}
{isVisible('discoverArtists') && randomArtists.length > 0 && (
<section className="album-row-section">
<div className="album-row-header">
+53 -100
View File
@@ -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<ISearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [songsServerOffset, setSongsServerOffset] = useState(0);
const [songsHasMore, setSongsHasMore] = useState(false);
const [loadingMoreSongs, setLoadingMoreSongs] = useState(false);
const songsSentinelRef = useRef<HTMLDivElement>(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<string | null>(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 (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<div style={{ marginBottom: '-1.5rem' }}>
@@ -87,69 +92,17 @@ export default function SearchResults() {
)}
{results.songs.length > 0 && (
<section className="album-row-section">
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}>
<div />
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div>{t('search.album')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
<section>
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>{t('search.songs')}</h2>
<SongListHeader />
{results.songs.map(song => (
<SongRow key={song.id} song={song} />
))}
{songsHasMore && (
<div ref={songsSentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
{loadingMoreSongs && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{results.songs.map(song => (
<div
key={song.id}
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}
onDoubleClick={() => 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);
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={e => { e.stopPropagation(); playSong(song, results.songs); }}
>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title" title={song.title}>{song.title}</span>
</div>
<div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div>
<div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div>
<span className="track-codec" style={{ alignSelf: 'center' }}>
{[song.suffix?.toUpperCase(), showBitrate && song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
</span>
<span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</span>
</div>
))}
</div>
)}
</section>
)}
</>
+1
View File
@@ -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'),
+153
View File
@@ -0,0 +1,153 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
SubsonicSong,
getRandomSongs,
buildCoverArtUrl,
coverArtCacheKey,
} from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import CachedImage from '../components/CachedImage';
import SongRail from '../components/SongRail';
import VirtualSongList from '../components/VirtualSongList';
import { playSongNow } from '../utils/playSong';
const RANDOM_RAIL_SIZE = 18;
export default function Tracks() {
const { t } = useTranslation();
const navigate = useNavigate();
const activeServerId = useAuthStore(s => s.activeServerId);
const enqueue = usePlayerStore(s => s.enqueue);
const [hero, setHero] = useState<SubsonicSong | null>(null);
const [heroLoading, setHeroLoading] = useState(false);
const [random, setRandom] = useState<SubsonicSong[]>([]);
const [randomLoading, setRandomLoading] = useState(true);
const rerollHero = useCallback(async () => {
setHeroLoading(true);
try {
const picks = await getRandomSongs(1);
if (picks[0]) setHero(picks[0]);
} finally {
setHeroLoading(false);
}
}, []);
const rerollRandom = useCallback(async () => {
setRandomLoading(true);
try {
const r = await getRandomSongs(RANDOM_RAIL_SIZE);
setRandom(r);
} finally {
setRandomLoading(false);
}
}, []);
useEffect(() => {
if (!activeServerId) return;
rerollHero();
rerollRandom();
}, [activeServerId, rerollHero, rerollRandom]);
const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : '';
// Hide the hero song from the random rail if the server happens to return it in
// both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window).
const railSongs = useMemo(
() => (hero ? random.filter(s => s.id !== hero.id) : random),
[random, hero],
);
return (
<div className="content-body animate-fade-in tracks-page">
<header className="tracks-header">
<div className="tracks-header-text">
<h1 className="page-title">{t('tracks.title')}</h1>
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
</div>
</header>
{hero && (
<section className="tracks-hero">
<div className="tracks-hero-cover">
{heroCoverUrl ? (
<CachedImage
src={heroCoverUrl}
cacheKey={coverArtCacheKey(hero.coverArt!, 600)}
alt=""
/>
) : (
<div className="tracks-hero-cover-placeholder" />
)}
</div>
<div className="tracks-hero-content">
<span className="tracks-hero-eyebrow">
<Sparkles size={14} />
{t('tracks.heroEyebrow')}
</span>
<h2 className="tracks-hero-title" title={hero.title}>{hero.title}</h2>
<p className="tracks-hero-meta">
<span
className={hero.artistId ? 'track-artist-link' : ''}
style={{ cursor: hero.artistId ? 'pointer' : 'default' }}
onClick={() => hero.artistId && navigate(`/artist/${hero.artistId}`)}
>{hero.artist}</span>
{hero.album && (
<>
<span className="tracks-hero-meta-dot">·</span>
<span
className={hero.albumId ? 'track-artist-link' : ''}
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
onClick={() => hero.albumId && navigate(`/album/${hero.albumId}`)}
>{hero.album}</span>
</>
)}
</p>
<div className="tracks-hero-actions">
<button
className="btn btn-primary"
onClick={() => playSongNow(hero)}
>
<Play size={16} fill="currentColor" /> {t('tracks.playSong')}
</button>
<button
className="btn"
onClick={() => enqueue([songToTrack(hero)])}
>
<ListPlus size={16} /> {t('tracks.enqueueSong')}
</button>
<button
className="btn btn-ghost"
onClick={rerollHero}
disabled={heroLoading}
aria-label={t('tracks.heroReroll')}
data-tooltip={t('tracks.heroReroll')}
data-tooltip-pos="top"
>
<RefreshCw size={16} className={heroLoading ? 'is-spinning' : ''} />
</button>
</div>
</div>
</section>
)}
<SongRail
title={t('tracks.railRandom')}
songs={railSongs}
loading={randomLoading}
onReroll={rerollRandom}
/>
<VirtualSongList
title={t('tracks.browseTitle')}
emptyBrowseText={t('tracks.browseUnsupported')}
/>
</div>
);
}
+16 -2
View File
@@ -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<HomeStore>()(
})),
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;
},
}
)
);
+1
View File
@@ -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 },
+525
View File
@@ -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;
}
}
+61
View File
@@ -0,0 +1,61 @@
import { usePlayerStore, songToTrack } from '../store/playerStore';
import type { SubsonicSong } from '../api/subsonic';
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
return new Promise(resolve => {
const steps = 16;
const stepMs = durationMs / steps;
let step = 0;
const id = setInterval(() => {
step++;
setVolume(Math.max(0, from * (1 - step / steps)));
if (step >= steps) {
clearInterval(id);
resolve();
}
}, stepMs);
});
}
/**
* Play a single song. When `queue` is provided, surrounds the chosen song with that queue
* so Next/Prev work pass the rail / pool the click came from. Mirrors playAlbum's fade-out.
*/
export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): Promise<void> {
const track = songToTrack(song);
const tracks = queue && queue.length > 0
? queue.map(songToTrack)
: [track];
const store = usePlayerStore.getState();
const { isPlaying, volume } = store;
if (isPlaying) {
await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume });
}
usePlayerStore.getState().playTrack(track, tracks);
}
/**
* Append the song to the existing queue (if not already there) and immediately jump to it.
* Existing queue stays intact different from playSongNow which replaces the queue.
*/
export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
const track = songToTrack(song);
const store = usePlayerStore.getState();
const { isPlaying, volume, queue } = store;
if (isPlaying) {
await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume });
}
if (!queue.some(t => t.id === track.id)) {
usePlayerStore.getState().enqueue([track]);
}
// playTrack with no queue arg uses the current state.queue, finds the track by id,
// and sets queueIndex accordingly.
usePlayerStore.getState().playTrack(track);
}