mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: library browse navigation — restore filters, scroll, and search on back (#936)
* feat(albums): restore scroll position when returning from album detail Save in-page scroll and grid depth when opening an album from All Albums, then on browser back restore filters, preload enough rows, and apply scroll before revealing the grid to avoid a visible jump from the top. * feat(albums): smart back navigation and restore browse session on return Remember the originating route when opening album detail, restore All Albums filters/scroll on back (including explicit returnTo navigation), hide the grid until scroll is applied, and fix filters being cleared after albumBrowseRestore state is stripped from the location. * feat(search): restore Advanced Search session when returning from album Stash filters and results when leaving /search/advanced for album detail, then restore them on back navigation (POP or returnTo with advancedSearchRestore). * feat(search): restore Advanced Search album row scroll on return from album Save horizontal scrollLeft when opening an album from Advanced Search and reapply it via AlbumRow on return; keep main viewport at top. Add snapshot helpers and session stash fields; extend AlbumRow with restoreScrollLeft. * feat(search): restore Advanced Search session scroll and artist return path Save filters, main scroll, and album-row scroll when leaving to album or artist; restore without flash via hidden-until-ready. Add useNavigateToArtist, restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change. * feat(search): speed up Advanced Search back restore and year-only queries Reveal the page right after sync scroll instead of blocking on full viewport and album-row restore. Retry local index without the ready gate during sync; use open-ended byYear params on network fallback, matching All Albums browse. * feat(search): restore Advanced Search artist row scroll on back Save leave snapshot when opening artist from ArtistCardLocal, persist artistRowScrollLeft in session stash, and keep row restore targets in refs so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop. * feat(nav): route mouse back on album/artist detail like UI back Trap history popstate when returnTo is set and call navigateAlbumDetailBack so browser/mouse back restores browse/search session the same way as the header button. * feat(artists): restore browse filters and scroll on back from artist detail Persist Artists page filters, view settings, and vertical scroll when opening an artist and returning via UI or mouse back, matching All Albums behavior. * feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter Serve /search and /search/advanced from one page with shared session restore and scroll snapshot. Reset live search overlay state when navigating to full search so the dropdown does not linger or reopen. * feat(tracks): unify with search session and restore scroll on back Route /tracks through AdvancedSearch with shared leave snapshot, song browse stash, and main-viewport scroll restore when returning from album or artist detail. Wait for hero/rails layout before applying scroll. * refactor(search): rename AdvancedSearch page to SearchBrowsePage The shared route shell serves /search, /search/advanced, and /tracks; rename the page component and refresh stale file references in comments. * feat(albums): restore New Releases and Random Albums on back from detail Unify album grid leave-restore with surface-scoped session stash, live scroll snapshot sync, and in-page scroll for Random Albums. Keep the same random batch when returning from album detail; Refresh fetches anew and scrolls up. * docs: add CHANGELOG and credits for PR #936
This commit is contained in:
@@ -3,6 +3,7 @@ import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -66,6 +67,7 @@ function AlbumCard({
|
||||
onLongPress: () => playAlbumShuffled(album.id),
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -86,7 +88,7 @@ function AlbumCard({
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
navigate(linkQuery ? `/album/${album.id}?${linkQuery}` : `/album/${album.id}`);
|
||||
navigateToAlbum(album.id, { search: linkQuery });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { useCoverLightboxSrc } from '../cover/lightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAlbumDetailBack } from '../hooks/useAlbumDetailBack';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import StarRating from './StarRating';
|
||||
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
||||
@@ -118,6 +119,7 @@ export default function AlbumHeader({
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
|
||||
@@ -160,7 +162,7 @@ export default function AlbumHeader({
|
||||
)}
|
||||
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||
<button className="btn btn-ghost album-detail-back" onClick={goBack}>
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import React, { useRef, useState, useEffect, useMemo } from 'react';
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect, useMemo } from 'react';
|
||||
import AlbumCard from './AlbumCard';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
@@ -14,6 +14,12 @@ interface Props {
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
/** Restored horizontal scroll (e.g. Advanced Search session return). */
|
||||
restoreScrollLeft?: number;
|
||||
/** Parent stashes horizontal scroll when leaving the page. */
|
||||
onScrollLeftSnapshot?: (scrollLeft: number) => void;
|
||||
/** Fired once when `restoreScrollLeft` has been applied (or skipped). */
|
||||
onScrollRestoreComplete?: () => void;
|
||||
showRating?: boolean;
|
||||
/** Optional content rendered in the row header, left of the scroll-nav. */
|
||||
headerExtra?: React.ReactNode;
|
||||
@@ -44,6 +50,9 @@ export default function AlbumRow({
|
||||
initialArtworkBudget = 8,
|
||||
albumLinkQuery,
|
||||
libraryResolve = false,
|
||||
restoreScrollLeft,
|
||||
onScrollLeftSnapshot,
|
||||
onScrollRestoreComplete,
|
||||
}: Props) {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
||||
@@ -57,6 +66,8 @@ export default function AlbumRow({
|
||||
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
const scrollRestoreTargetRef = useRef(restoreScrollLeft);
|
||||
const scrollRestoreDoneRef = useRef(false);
|
||||
const uniqueAlbums = useMemo(() => dedupeById(albums), [albums]);
|
||||
|
||||
const recomputeArtworkBudget = () => {
|
||||
@@ -86,6 +97,8 @@ export default function AlbumRow({
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
}
|
||||
|
||||
onScrollLeftSnapshot?.(scrollLeft);
|
||||
|
||||
// Auto-load trigger (native horizontal scroll still works when rail buttons are perf-disabled)
|
||||
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
|
||||
triggerLoadMore();
|
||||
@@ -125,6 +138,74 @@ export default function AlbumRow({
|
||||
setArtworkBudget(initialArtworkBudget);
|
||||
}, [initialArtworkBudget, rowArtworkResetKey]);
|
||||
|
||||
const notifyRestoreCompletePendingRef = useRef(false);
|
||||
const [restoreCompleteTick, setRestoreCompleteTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreScrollLeft == null || restoreScrollLeft <= 0) return;
|
||||
scrollRestoreTargetRef.current = restoreScrollLeft;
|
||||
scrollRestoreDoneRef.current = false;
|
||||
notifyRestoreCompletePendingRef.current = false;
|
||||
}, [restoreScrollLeft]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRestoreDoneRef.current) return;
|
||||
const target = scrollRestoreTargetRef.current;
|
||||
if (target == null || target <= 0) {
|
||||
scrollRestoreDoneRef.current = true;
|
||||
onScrollRestoreComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const finish = () => {
|
||||
scrollRestoreDoneRef.current = true;
|
||||
if (windowArtworkByViewport) {
|
||||
notifyRestoreCompletePendingRef.current = true;
|
||||
setRestoreCompleteTick(t => t + 1);
|
||||
return;
|
||||
}
|
||||
onScrollRestoreComplete?.();
|
||||
};
|
||||
|
||||
const attempt = () => {
|
||||
if (cancelled || scrollRestoreDoneRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
if (++attempts < 12) requestAnimationFrame(attempt);
|
||||
else finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
const desired = Math.min(Math.max(0, target), maxScroll);
|
||||
el.scrollLeft = desired;
|
||||
if (windowArtworkByViewport) recomputeArtworkBudget();
|
||||
handleScroll();
|
||||
|
||||
const stuck = Math.abs(el.scrollLeft - desired) <= 1;
|
||||
const layoutStillGrowing = desired > el.scrollLeft + 1 && maxScroll < target;
|
||||
if ((!stuck || layoutStillGrowing) && ++attempts < 12) {
|
||||
requestAnimationFrame(attempt);
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
};
|
||||
|
||||
attempt();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rowArtworkResetKey, windowArtworkByViewport, initialArtworkBudget, uniqueAlbums.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!notifyRestoreCompletePendingRef.current) return;
|
||||
notifyRestoreCompletePendingRef.current = false;
|
||||
onScrollRestoreComplete?.();
|
||||
}, [artworkBudget, restoreCompleteTick, onScrollRestoreComplete]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
const amount = scrollRef.current.clientWidth * 0.75;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -17,12 +17,14 @@ interface Props {
|
||||
|
||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
|
||||
const href = linkQuery ? `/artist/${artist.id}?${linkQuery}` : `/artist/${artist.id}`;
|
||||
|
||||
return (
|
||||
<div className="artist-card" onClick={() => navigate(href)}>
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverRef ? (
|
||||
<CoverArtImage
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect } from 'react';
|
||||
import ArtistCardLocal from './ArtistCardLocal';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -12,23 +12,79 @@ interface Props {
|
||||
artistLinkQuery?: string;
|
||||
/** Search results: use API coverArt ids only. */
|
||||
libraryResolve?: boolean;
|
||||
/** Restored horizontal scroll (e.g. Advanced Search session return). */
|
||||
restoreScrollLeft?: number;
|
||||
/** Parent stashes horizontal scroll when leaving the page. */
|
||||
onScrollLeftSnapshot?: (scrollLeft: number) => void;
|
||||
}
|
||||
|
||||
export default function ArtistRow({
|
||||
title, artists, moreLink, moreText, artistLinkQuery, libraryResolve = false,
|
||||
restoreScrollLeft,
|
||||
onScrollLeftSnapshot,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const scrollRestoreTargetRef = useRef(restoreScrollLeft);
|
||||
const scrollRestoreDoneRef = useRef(false);
|
||||
const rowResetKey = artists[0]?.id ?? '';
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
onScrollLeftSnapshot?.(scrollLeft);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreScrollLeft == null || restoreScrollLeft <= 0) return;
|
||||
scrollRestoreTargetRef.current = restoreScrollLeft;
|
||||
scrollRestoreDoneRef.current = false;
|
||||
}, [restoreScrollLeft]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRestoreDoneRef.current) return;
|
||||
const target = scrollRestoreTargetRef.current;
|
||||
if (target == null || target <= 0) {
|
||||
scrollRestoreDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const attempt = () => {
|
||||
if (cancelled || scrollRestoreDoneRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
if (++attempts < 12) requestAnimationFrame(attempt);
|
||||
else scrollRestoreDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
const desired = Math.min(Math.max(0, target), maxScroll);
|
||||
el.scrollLeft = desired;
|
||||
handleScroll();
|
||||
|
||||
const stuck = Math.abs(el.scrollLeft - desired) <= 1;
|
||||
const layoutStillGrowing = desired > el.scrollLeft + 1 && maxScroll < target;
|
||||
if ((!stuck || layoutStillGrowing) && ++attempts < 12) {
|
||||
requestAnimationFrame(attempt);
|
||||
return;
|
||||
}
|
||||
scrollRestoreDoneRef.current = true;
|
||||
};
|
||||
|
||||
attempt();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rowResetKey, artists.length]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
@@ -70,7 +70,7 @@ interface HeroProps {
|
||||
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
@@ -293,7 +293,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
className="hero"
|
||||
role="banner"
|
||||
aria-label={t('hero.eyebrow')}
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
onClick={() => navigateToAlbum(album.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../utils/library/libraryDevLog';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -108,6 +109,7 @@ export default function LiveSearch() {
|
||||
const localReadyRef = useRef(false);
|
||||
const liveSearchGenRef = useRef(0);
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
|
||||
@@ -161,6 +163,20 @@ export default function LiveSearch() {
|
||||
setSearchSource(null);
|
||||
}, []);
|
||||
|
||||
/** Leave live search for a full-page route — cancel in-flight queries and reset overlay state. */
|
||||
const leaveLiveSearchFor = useCallback((path: string) => {
|
||||
liveSearchGenRef.current += 1;
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
setSearchSource(null);
|
||||
setActiveIndex(-1);
|
||||
setLoading(false);
|
||||
setIsFocused(false);
|
||||
inputRef.current?.blur();
|
||||
navigate(path);
|
||||
}, [navigate]);
|
||||
|
||||
const share = useShareSearch(query, closeSearch);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -459,7 +475,7 @@ export default function LiveSearch() {
|
||||
},
|
||||
] : results ? [
|
||||
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
|
||||
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
|
||||
...(results.songs.map(s => ({ id: s.id, action: () => {
|
||||
const track = songToTrack(s);
|
||||
enqueue([track]);
|
||||
@@ -486,7 +502,10 @@ export default function LiveSearch() {
|
||||
return;
|
||||
}
|
||||
if (!open || !flatItems.length) {
|
||||
if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||
if (e.key === 'Enter' && query.trim()) {
|
||||
e.preventDefault();
|
||||
leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
@@ -502,7 +521,9 @@ export default function LiveSearch() {
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); }
|
||||
else if (query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); }
|
||||
else if (query.trim()) {
|
||||
leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false); setActiveIndex(-1);
|
||||
}
|
||||
@@ -574,7 +595,10 @@ export default function LiveSearch() {
|
||||
// remain active long enough for this button click to fire.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => navigate(query.trim() ? `/search/advanced?q=${encodeURIComponent(query.trim())}` : '/search/advanced')}
|
||||
onClick={() => {
|
||||
const q = query.trim();
|
||||
leaveLiveSearchFor(q ? `/search/advanced?q=${encodeURIComponent(q)}` : '/search/advanced');
|
||||
}}
|
||||
data-tooltip={t('search.advanced')}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('search.advanced')}
|
||||
@@ -678,7 +702,7 @@ export default function LiveSearch() {
|
||||
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
|
||||
return (
|
||||
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
|
||||
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
|
||||
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, a, 'album');
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigatePathWithAlbumReturnTo } from '../utils/navigation/albumDetailNavigation';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -89,6 +90,7 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
|
||||
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -130,7 +132,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||
};
|
||||
|
||||
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
|
||||
const goTo = (path: string) => {
|
||||
commit(query);
|
||||
navigatePathWithAlbumReturnTo(navigate, location, path);
|
||||
onClose();
|
||||
};
|
||||
const goCategory = (path: string) => { navigate(path); onClose(); };
|
||||
const enqueueSong = (song: SearchResults['songs'][number]) => {
|
||||
commit(query);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { EntitySharePayloadV1 } from '../utils/share/shareLink';
|
||||
@@ -38,6 +38,7 @@ type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
||||
|
||||
export default function PasteClipboardHandler() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
@@ -171,7 +172,7 @@ export default function PasteClipboardHandler() {
|
||||
return;
|
||||
}
|
||||
busy.current = true;
|
||||
void applySharePastePayload(share, navigate, t).finally(() => {
|
||||
void applySharePastePayload(share, navigate, t, location).finally(() => {
|
||||
busy.current = false;
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -16,7 +17,8 @@ interface Props {
|
||||
}
|
||||
|
||||
function SongRow({ song, showBpm }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const { t } = useTranslation();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
@@ -94,7 +96,7 @@ function SongRow({ song, showBpm }: Props) {
|
||||
<span
|
||||
className={song.artistId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigateToArtist(song.artistId); } }}
|
||||
title={song.artist}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
@@ -103,7 +105,7 @@ function SongRow({ song, showBpm }: Props) {
|
||||
<span
|
||||
className="track-artist-link"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/album/${song.albumId}`); }}
|
||||
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!); }}
|
||||
title={song.album}
|
||||
>{song.album}</span>
|
||||
) : <span title={song.album}>{song.album}</span>}
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
onChange: (next: boolean) => void;
|
||||
/** 'default' = icon + label, regular padding (Albums toolbar).
|
||||
* 'compact' = icon-only, 0.5rem padding (Artists view-mode buttons).
|
||||
* 'small' = icon + label, 4px/14px padding + 12px text (AdvancedSearch tabs). */
|
||||
* 'small' = icon + label, 4px/14px padding + 12px text (SearchBrowsePage tabs). */
|
||||
size?: 'default' | 'compact' | 'small';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,215 +1,27 @@
|
||||
import { searchSongsPaged } from '../api/subsonicSearch';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Search as SearchIcon, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ndListSongs } from '../api/navidromeBrowse';
|
||||
import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal';
|
||||
import {
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||
BROWSE_TEXT_DEBOUNCE_RACE_MS,
|
||||
browseRaceCountsSongs,
|
||||
loadMoreLocalBrowseSongs,
|
||||
raceBrowseWithLocalFallback,
|
||||
runLocalBrowseSongPage,
|
||||
runNetworkBrowseSongPage,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import PagedSongList from './PagedSongList';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
async function fetchBrowseAllPage(
|
||||
serverId: string | null | undefined,
|
||||
offset: number,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const local = await runLocalSongBrowse(serverId, offset, PAGE_SIZE);
|
||||
if (local) return local;
|
||||
try {
|
||||
return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC');
|
||||
} catch {
|
||||
return searchSongsPaged('', PAGE_SIZE, offset);
|
||||
}
|
||||
}
|
||||
import React from 'react';
|
||||
import { useSongBrowseList } from '../hooks/useSongBrowseList';
|
||||
import SongBrowseSection from './tracks/SongBrowseSection';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
emptyBrowseText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse-all-tracks list. Renders through the shared `PagedSongList` in the page
|
||||
* flow (sticky header + plain rows + sentinel paging), so it matches the Search
|
||||
* pages and the column header can't be painted over while scrolling (issue #841).
|
||||
*/
|
||||
/** @deprecated Use SongBrowseSection via SearchBrowsePage (`/tracks`). */
|
||||
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
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 requestSeqRef = useRef(0);
|
||||
const localSearchModeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
|
||||
const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), debounceMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [query, indexEnabled]);
|
||||
|
||||
const fetchSongPage = useCallback(
|
||||
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
|
||||
if (q === '') {
|
||||
return fetchBrowseAllPage(serverId, pageOffset);
|
||||
}
|
||||
|
||||
if (pageOffset === 0 && indexEnabled && serverId) {
|
||||
const winner = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseSongPage(serverId, q, 0, PAGE_SIZE),
|
||||
() => runNetworkBrowseSongPage(q, 0, PAGE_SIZE),
|
||||
{
|
||||
surface: 'tracks_browse',
|
||||
query: q,
|
||||
indexEnabled,
|
||||
counts: browseRaceCountsSongs,
|
||||
},
|
||||
);
|
||||
if (isStale()) return [];
|
||||
if (winner) {
|
||||
localSearchModeRef.current = winner.source === 'local';
|
||||
return winner.result ?? [];
|
||||
}
|
||||
localSearchModeRef.current = false;
|
||||
return (await runNetworkBrowseSongPage(q, 0, PAGE_SIZE)) ?? [];
|
||||
}
|
||||
|
||||
if (localSearchModeRef.current && serverId) {
|
||||
try {
|
||||
return await loadMoreLocalBrowseSongs(serverId, q, pageOffset, PAGE_SIZE);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
|
||||
},
|
||||
[indexEnabled, serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSongs([]);
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
setBrowseUnsupported(false);
|
||||
localSearchModeRef.current = false;
|
||||
|
||||
const seq = ++requestSeqRef.current;
|
||||
const isStale = () => cancelled || seq !== requestSeqRef.current;
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await fetchSongPage(debouncedQuery, 0, isStale);
|
||||
if (isStale()) 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 (!isStale()) setHasMore(false);
|
||||
} finally {
|
||||
if (!isStale()) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, fetchSongPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loading || !hasMore) return;
|
||||
setLoading(true);
|
||||
const seq = ++requestSeqRef.current;
|
||||
const isStale = () => seq !== requestSeqRef.current;
|
||||
try {
|
||||
const page = await fetchSongPage(debouncedQuery, offset, isStale);
|
||||
if (isStale()) 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 (!isStale()) setLoading(false);
|
||||
}
|
||||
}, [loading, hasMore, debouncedQuery, offset, fetchSongPage]);
|
||||
|
||||
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
|
||||
const browse = useSongBrowseList({ enabled: true });
|
||||
|
||||
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>
|
||||
) : (
|
||||
<PagedSongList
|
||||
songs={songs}
|
||||
hasMore={hasMore}
|
||||
loadingMore={loading}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<SongBrowseSection
|
||||
title={title}
|
||||
emptyBrowseText={emptyBrowseText}
|
||||
query={browse.query}
|
||||
onQueryChange={browse.setQuery}
|
||||
songs={browse.songs}
|
||||
hasMore={browse.hasMore}
|
||||
loading={browse.loading}
|
||||
browseUnsupported={browse.browseUnsupported}
|
||||
onLoadMore={() => { void browse.loadMore(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAlbumDetailBack } from '../../hooks/useAlbumDetailBack';
|
||||
import {
|
||||
ArrowLeft, Camera, Check, ExternalLink, HardDriveDownload, Heart,
|
||||
Loader2, Play, Radio, Share2, Shuffle, Users,
|
||||
@@ -50,7 +50,7 @@ export default function ArtistDetailHero({
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
@@ -67,7 +67,7 @@ export default function ArtistDetailHero({
|
||||
<>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={() => goBack()}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
@@ -19,7 +18,7 @@ interface TileProps {
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
@@ -34,7 +33,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
|
||||
if (rest.selectionMode) {
|
||||
rest.toggleSelect(artist.id);
|
||||
} else {
|
||||
rest.navigate(`/artist/${artist.id}`);
|
||||
rest.onOpenArtist(artist.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
@@ -79,7 +78,7 @@ interface Props {
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
@@ -98,7 +97,7 @@ export function ArtistsGridView({
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
navigate,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
}: Props) {
|
||||
@@ -108,7 +107,7 @@ export function ArtistsGridView({
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
navigate,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
@@ -14,7 +13,7 @@ interface RowProps {
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
@@ -26,7 +25,7 @@ function ArtistListRow({
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
navigate,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
}: RowProps) {
|
||||
@@ -38,7 +37,7 @@ function ArtistListRow({
|
||||
if (selectionMode) {
|
||||
toggleSelect(artist.id);
|
||||
} else {
|
||||
navigate(`/artist/${artist.id}`);
|
||||
onOpenArtist(artist.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
@@ -79,7 +78,7 @@ interface Props {
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
@@ -110,13 +109,13 @@ export function ArtistsListView({
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
navigate,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
}: Props) {
|
||||
const rowCommonProps = {
|
||||
selectionMode, selectedIds, selectedArtists, showArtistImages,
|
||||
toggleSelect, navigate, openContextMenu, t,
|
||||
toggleSelect, onOpenArtist, openContextMenu, t,
|
||||
};
|
||||
|
||||
if (!virtualized) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { queueSongStar } from '../../store/pendingStarSync';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||
@@ -30,6 +31,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -101,7 +103,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
@@ -244,7 +246,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import { Search as SearchIcon, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PagedSongList from '../PagedSongList';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
emptyBrowseText?: string;
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
songs: SubsonicSong[];
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
browseUnsupported: boolean;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
/** Tracks hub toolbar + paginated song list (shared chrome with Search song results). */
|
||||
export default function SongBrowseSection({
|
||||
title,
|
||||
emptyBrowseText,
|
||||
query,
|
||||
onQueryChange,
|
||||
songs,
|
||||
hasMore,
|
||||
loading,
|
||||
browseUnsupported,
|
||||
onLoadMore,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showEmptyBrowse = !loading && songs.length === 0 && query.trim() === '' && (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 => onQueryChange(e.target.value)}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="virtual-song-list-search-clear"
|
||||
onClick={() => onQueryChange('')}
|
||||
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>
|
||||
) : (
|
||||
<PagedSongList
|
||||
songs={songs}
|
||||
hasMore={hasMore}
|
||||
loadingMore={loading}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||
import { getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import SongRail from '../SongRail';
|
||||
import { playSongNow } from '../../utils/playback/playSong';
|
||||
import { ndListSongs, ndInvalidateSongsCache } from '../../api/navidromeBrowse';
|
||||
import { usePerfProbeFlags } from '../../utils/perf/perfFlags';
|
||||
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||
|
||||
const RANDOM_RAIL_SIZE = 18;
|
||||
const RATED_RAIL_FETCH = 60;
|
||||
const RATED_RAIL_DISPLAY = 30;
|
||||
const RATED_RAIL_CACHE_MS = 60_000;
|
||||
const TRACKS_SONG_RAIL_WINDOWING = true;
|
||||
const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 14;
|
||||
|
||||
/** Tracks hub hero + song rails (above the browse-all list). */
|
||||
export default function TracksPageChrome({
|
||||
onLayoutReady,
|
||||
}: {
|
||||
/** Fires once when hero + rails finish their initial load (or fail). */
|
||||
onLayoutReady?: () => void;
|
||||
}) {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
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 [rated, setRated] = useState<SubsonicSong[]>([]);
|
||||
const [ratedLoading, setRatedLoading] = useState(true);
|
||||
const [ratedSupported, setRatedSupported] = useState(true);
|
||||
const layoutReadyNotifiedRef = useRef(false);
|
||||
|
||||
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 {
|
||||
setRandom(await getRandomSongs(RANDOM_RAIL_SIZE));
|
||||
} finally {
|
||||
setRandomLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadRated = useCallback(async () => {
|
||||
setRatedLoading(true);
|
||||
try {
|
||||
const songs = await ndListSongs(0, RATED_RAIL_FETCH, 'rating', 'DESC', RATED_RAIL_CACHE_MS);
|
||||
const filtered = songs.filter(s => (s.userRating ?? 0) > 0).slice(0, RATED_RAIL_DISPLAY);
|
||||
setRated(filtered);
|
||||
setRatedSupported(true);
|
||||
} catch {
|
||||
setRated([]);
|
||||
setRatedSupported(false);
|
||||
} finally {
|
||||
setRatedLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId) return;
|
||||
rerollHero();
|
||||
rerollRandom();
|
||||
reloadRated();
|
||||
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onLayoutReady || layoutReadyNotifiedRef.current) return;
|
||||
if (!activeServerId) {
|
||||
layoutReadyNotifiedRef.current = true;
|
||||
onLayoutReady();
|
||||
return;
|
||||
}
|
||||
if (heroLoading || randomLoading || ratedLoading) return;
|
||||
layoutReadyNotifiedRef.current = true;
|
||||
onLayoutReady();
|
||||
}, [activeServerId, onLayoutReady, heroLoading, randomLoading, ratedLoading]);
|
||||
|
||||
const railSongs = useMemo(
|
||||
() => (hero ? random.filter(s => s.id !== hero.id) : random),
|
||||
[random, hero],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!perfFlags.disableMainstageStickyHeader && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageHero && hero && (
|
||||
<section className="tracks-hero">
|
||||
<div className="tracks-hero-cover">
|
||||
{hero.albumId && hero.coverArt ? (
|
||||
<AlbumCoverArtImage
|
||||
albumId={hero.albumId}
|
||||
coverArt={hero.coverArt}
|
||||
displayCssPx={600}
|
||||
surface="sparse"
|
||||
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 && navigateToArtist(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 && navigateToAlbum(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 btn-surface" onClick={() => enqueue([songToTrack(hero)])}>
|
||||
<ListPlus size={16} /> {t('tracks.enqueueSong')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
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>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && (
|
||||
<SongRail
|
||||
title={t('tracks.railHighlyRated')}
|
||||
songs={rated}
|
||||
loading={ratedLoading}
|
||||
onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }}
|
||||
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
|
||||
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!perfFlags.disableMainstageRails && (
|
||||
<SongRail
|
||||
title={t('tracks.railRandom')}
|
||||
songs={railSongs}
|
||||
loading={randomLoading}
|
||||
onReroll={rerollRandom}
|
||||
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
|
||||
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user