refactor(search): co-locate tracks song-browse cluster (SongRow/PagedSongList/SongBrowseSection/TracksPageChrome/useSongBrowseList) into features/search

This commit is contained in:
Psychotoxical
2026-06-30 18:41:31 +02:00
parent 5c3a135040
commit afa8999d37
8 changed files with 17 additions and 17 deletions
@@ -0,0 +1,53 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import React, { useRef } from 'react';
import SongRow, { SongListHeader } from '@/features/search/components/SongRow';
import { useInpageScrollSentinel } from '@/hooks/useInpageScrollSentinel';
import InpageScrollSentinel from '@/ui/InpageScrollSentinel';
interface Props {
songs: SubsonicSong[];
/** More pages available — renders the load-more sentinel. */
hasMore: boolean;
/** A page fetch is in flight — shows the sentinel spinner. */
loadingMore: boolean;
/** Fetch the next page. Called as the sentinel nears the viewport. */
onLoadMore: () => void;
/** Show a BPM column (Advanced Search when the BPM filter is active). */
showBpm?: boolean;
}
/**
* Shared song-list view: sticky column header + plain `SongRow`s in the page
* flow, with an `IntersectionObserver` sentinel for pagination. Used by the
* Tracks browse list, Search results, and Advanced Search so the three share
* one chrome + paging path (no transform-positioned rows, so the sticky header
* is never painted over — issue #841).
*/
export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore, showBpm }: Props) {
const onLoadMoreRef = useRef(onLoadMore);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
onLoadMoreRef.current = onLoadMore;
const bindSentinel = useInpageScrollSentinel({
active: hasMore,
onIntersect: () => onLoadMoreRef.current(),
rootMargin: '600px',
});
return (
<>
<SongListHeader showBpm={showBpm} />
{songs.map(song => (
<SongRow key={song.id} song={song} showBpm={showBpm} />
))}
{hasMore && (
<InpageScrollSentinel
bindSentinel={bindSentinel}
loading={loadingMore}
style={{ padding: '1rem', height: 'auto', margin: 0 }}
/>
)}
</>
);
}
@@ -0,0 +1,59 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import React from 'react';
import { useTranslation } from 'react-i18next';
import PagedSongList from '@/features/search/components/PagedSongList';
interface Props {
title?: string;
emptyBrowseText?: string;
searchActive?: boolean;
songs: SubsonicSong[];
hasMore: boolean;
loading: boolean;
browseUnsupported: boolean;
onLoadMore: () => void;
}
/** Tracks hub toolbar + paginated song list (search lives in the header live search field). */
export default function SongBrowseSection({
title,
emptyBrowseText,
searchActive = false,
songs,
hasMore,
loading,
browseUnsupported,
onLoadMore,
}: Props) {
const { t } = useTranslation();
const showEmptyBrowse =
!loading && songs.length === 0 && !searchActive && (browseUnsupported || !hasMore);
return (
<section className="virtual-song-list-section">
{title && !searchActive && (
<h2 className="section-title virtual-song-list-title">{title}</h2>
)}
<div className="virtual-song-list-toolbar">
<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>
);
}
+158
View File
@@ -0,0 +1,158 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import React, { memo } from 'react';
import { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { Play, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { enqueueAndPlay } from '@/features/playback/utils/playback/playSong';
import { useDragDrop } from '@/lib/dnd/DragDropContext';
import { useOrbitSongRowBehavior } from '@/features/orbit';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
import { tooltipAttrs } from '@/ui/tooltipAttrs';
interface Props {
song: SubsonicSong;
showBpm?: boolean;
}
function SongRow({ song, showBpm }: Props) {
const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
const { t } = useTranslation();
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)]);
};
const artistRefs = resolveTrackArtistRefs(song);
const bpmTooltip =
song.localBpmSource === 'analysis'
? t('search.bpmSourceAnalysis')
: song.localBpmSource === 'tag'
? t('search.bpmSourceTag')
: undefined;
return (
<div
className={`song-list-row${isCurrent ? ' is-current' : ''}${showBpm ? ' song-list-row--with-bpm' : ''}`}
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(); }}
{...tooltipAttrs(t('common.play'))}
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-list-row-btn"
onClick={(e) => { e.stopPropagation(); handleEnqueue(); }}
{...tooltipAttrs(t('common.addToQueue'))}
>
<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" title={song.artist}>
{artistRefs.map((a, i) => (
<React.Fragment key={a.id ?? a.name ?? i}>
{i > 0 && <span className="track-artist-sep">&nbsp;·&nbsp;</span>}
<span
className={a.id ? 'track-artist-link' : ''}
style={{ cursor: a.id ? 'pointer' : 'default' }}
onClick={(e) => { if (a.id) { e.stopPropagation(); navigateToArtist(a.id!); } }}
>{a.name ?? song.artist}</span>
</React.Fragment>
))}
</div>
<div className="song-list-row-cell truncate">
{song.albumId ? (
<span
className="track-artist-link"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigateToAlbum(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>
{showBpm && (
<div
className="song-list-row-cell song-list-row-bpm"
data-tooltip={bpmTooltip}
>
{song.bpm != null && song.bpm > 0 ? song.bpm : '—'}
</div>
)}
<div className="song-list-row-cell song-list-row-duration">{formatTrackTime(song.duration, '')}</div>
</div>
);
}
/** Column header with the same grid as <SongRow>. Optional — pages can render it above the list. */
export function SongListHeader({ showBpm }: { showBpm?: boolean } = {}) {
const { t } = useTranslation();
return (
<div
className={`song-list-row song-list-row--header${showBpm ? ' song-list-row--with-bpm' : ''}`}
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>
{showBpm && (
<div className="song-list-row-cell song-list-row-bpm">{t('albumDetail.trackBpm')}</div>
)}
<div className="song-list-row-cell song-list-row-duration">{t('albumDetail.trackDuration')}</div>
</div>
);
}
export default memo(SongRow);
@@ -0,0 +1,214 @@
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/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 '@/features/playback/store/playerStore';
import { SongRail } from '@/features/home';
import { playSongNow } from '@/features/playback/utils/playback/playSong';
import { ndListSongs, ndInvalidateSongsCache } from '@/lib/api/navidromeBrowse';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { OpenArtistRefInline } from '@/features/artist';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
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,
hideDiscoveryChrome = false,
}: {
/** Fires once when hero + rails finish their initial load (or fail). */
onLayoutReady?: () => void;
/** When true, skip hero and song rails (active scoped search). */
hideDiscoveryChrome?: boolean;
}) {
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 || hideDiscoveryChrome) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
rerollHero();
rerollRandom();
reloadRated();
}, [activeServerId, hideDiscoveryChrome, rerollHero, rerollRandom, reloadRated]);
useEffect(() => {
if (!onLayoutReady || layoutReadyNotifiedRef.current) return;
if (hideDiscoveryChrome || !activeServerId) {
layoutReadyNotifiedRef.current = true;
onLayoutReady();
return;
}
if (heroLoading || randomLoading || ratedLoading) return;
layoutReadyNotifiedRef.current = true;
onLayoutReady();
}, [activeServerId, hideDiscoveryChrome, onLayoutReady, heroLoading, randomLoading, ratedLoading]);
const railSongs = useMemo(
() => (hero ? random.filter(s => s.id !== hero.id) : random),
[random, hero],
);
const heroArtistRefs = hero ? resolveTrackArtistRefs(hero) : [];
return (
<>
{!perfFlags.disableMainstageStickyHeader && (
<header className="tracks-header">
<div className="tracks-header-text">
<h1 className="page-title">{t('tracks.title')}</h1>
{!hideDiscoveryChrome && (
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
)}
</div>
</header>
)}
{!perfFlags.disableMainstageHero && !hideDiscoveryChrome && 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">
<OpenArtistRefInline
refs={heroArtistRefs}
fallbackName={hero.artist}
onGoArtist={id => navigateToArtist(id)}
as="none"
linkTag="span"
linkClassName="track-artist-link"
separatorClassName="track-artist-sep"
/>
{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 compact-action-bar">
<button className="btn btn-primary" onClick={() => playSongNow(hero)} aria-label={t('tracks.playSong')} data-tooltip={t('tracks.playSong')}>
<Play size={16} fill="currentColor" /> <span className="compact-btn-label">{t('tracks.playSong')}</span>
</button>
<button className="btn btn-surface" onClick={() => enqueue([songToTrack(hero)])} aria-label={t('tracks.enqueueSong')} data-tooltip={t('tracks.enqueueSong')}>
<ListPlus size={16} /> <span className="compact-btn-label">{t('tracks.enqueueSong')}</span>
</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 && !hideDiscoveryChrome && 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 && !hideDiscoveryChrome && (
<SongRail
title={t('tracks.railRandom')}
songs={railSongs}
loading={randomLoading}
onReroll={rerollRandom}
windowArtworkByViewport={TRACKS_SONG_RAIL_WINDOWING}
initialArtworkBudget={TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
/>
)}
</>
);
}
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { useSongBrowseList } from '@/features/search/hooks/useSongBrowseList';
import SongBrowseSection from '@/features/search/components/SongBrowseSection';
interface Props {
title?: string;
emptyBrowseText?: string;
}
/** @deprecated Use SongBrowseSection via SearchBrowsePage (`/tracks`). */
export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const [searchQuery] = useState('');
const browse = useSongBrowseList({ enabled: true, searchQuery });
return (
<SongBrowseSection
title={title}
emptyBrowseText={emptyBrowseText}
songs={browse.songs}
hasMore={browse.hasMore}
loading={browse.loading}
browseUnsupported={browse.browseUnsupported}
onLoadMore={() => { void browse.loadMore(); }}
/>
);
}
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useSongBrowseList } from '@/features/search/hooks/useSongBrowseList';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
vi.mock('@/lib/api/subsonicSearch', () => ({
searchSongsPaged: vi.fn(async () => []),
}));
vi.mock('@/lib/api/navidromeBrowse', () => ({
ndListSongs: vi.fn(async () => []),
}));
vi.mock('@/lib/library/advancedSearchLocal', () => ({
runLocalSongBrowse: vi.fn(async () => []),
}));
// Only the reload-token hook was stubbed pre-move (its own module); mock that
// submodule directly so the barrel re-exports the stub while the real
// `useOfflineBrowseContext` (a different submodule) stays live.
vi.mock('@/features/offline/hooks/useOfflineBrowseReloadToken', () => ({
useOfflineBrowseReloadToken: () => undefined,
}));
vi.mock('@/lib/library/browseTextSearch', () => ({
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
browseRaceCountsSongs: vi.fn(),
loadMoreLocalBrowseSongs: vi.fn(async () => []),
raceBrowseWithLocalFallback: vi.fn(async () => null),
runLocalBrowseSongPage: vi.fn(async () => []),
runNetworkBrowseSongPage: vi.fn(async () => [{ id: 'fresh' } as SubsonicSong]),
}));
const stashedSong = { id: 'stashed', title: 'Stashed', artist: 'A', duration: 180 } as SubsonicSong;
describe('useSongBrowseList restore hold', () => {
beforeEach(() => {
useAuthStore.setState({ activeServerId: 'srv-1' });
useLibraryIndexStore.setState({ masterEnabled: true });
});
it('keeps stashed songs after fetchSongPage identity changes until query edits', async () => {
const { result, rerender } = renderHook(
({ searchQuery }) => useSongBrowseList({
enabled: true,
searchQuery,
initialRestore: {
query: 'jazz',
songs: [stashedSong],
offset: 1,
hasMore: false,
localSearchMode: true,
browseUnsupported: false,
hasSearched: true,
},
}),
{ initialProps: { searchQuery: 'jazz' } },
);
expect(result.current.songs).toEqual([stashedSong]);
rerender({ searchQuery: 'jazz' });
await waitFor(() => {
expect(result.current.songs).toEqual([stashedSong]);
}, { timeout: 500 });
rerender({ searchQuery: 'jazzx' });
await waitFor(() => {
expect(result.current.songs[0]?.id).toBe('fresh');
}, { timeout: 500 });
});
});
@@ -0,0 +1,239 @@
import { searchSongsPaged } from '@/lib/api/subsonicSearch';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ndListSongs } from '@/lib/api/navidromeBrowse';
import { runLocalSongBrowse } from '@/lib/library/advancedSearchLocal';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsSongs,
loadMoreLocalBrowseSongs,
raceBrowseWithLocalFallback,
runLocalBrowseSongPage,
runNetworkBrowseSongPage,
} from '@/lib/library/browseTextSearch';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { useOfflineBrowseContext } from '@/features/offline';
import { useOfflineBrowseReloadToken } from '@/features/offline';
import {
fetchOfflineLocalBrowsableSongPage,
offlineLocalBrowseEnabled,
searchOfflineLocalBrowsableSongs,
} from '@/features/offline';
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);
}
}
export type SongBrowseListRestore = {
query: string;
songs: SubsonicSong[];
offset: number;
hasMore: boolean;
localSearchMode: boolean;
browseUnsupported: boolean;
hasSearched: boolean;
};
type UseSongBrowseListArgs = {
enabled: boolean;
/** Header scoped browse query (wide title/artist/album search). */
searchQuery: string;
initialRestore?: SongBrowseListRestore | null;
};
/** Tracks hub song browse — all-library paging or filtered text search. */
export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) {
const serverId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const [debouncedQuery, setDebouncedQuery] = useState(
() => initialRestore?.query.trim() ?? searchQuery.trim(),
);
const [songs, setSongs] = useState<SubsonicSong[]>(() => initialRestore?.songs ?? []);
const [offset, setOffset] = useState(() => initialRestore?.offset ?? 0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(() => initialRestore?.hasMore ?? true);
const [browseUnsupported, setBrowseUnsupported] = useState(
() => initialRestore?.browseUnsupported ?? false,
);
const [hasSearched, setHasSearched] = useState(() => initialRestore?.hasSearched ?? false);
const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(initialRestore?.localSearchMode ?? false);
/** Keep stashed songs until the user edits the scoped query (survives fetchSongPage identity changes). */
const holdRestoredListRef = useRef(initialRestore != null);
const heldRestoredQueryRef = useRef(initialRestore?.query.trim() ?? '');
const restoreQueryHoldRef = useRef(
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
);
useEffect(() => {
if (!enabled) return;
const incoming = searchQuery.trim();
if (incoming !== '') {
restoreQueryHoldRef.current = null;
}
const effectiveQuery = incoming || restoreQueryHoldRef.current || '';
const debounceMs = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => setDebouncedQuery(effectiveQuery), debounceMs);
return () => window.clearTimeout(timer);
}, [searchQuery, indexEnabled, enabled]);
const fetchSongPage = useCallback(
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
if (offlineBrowseActive && serverId && offlineLocalBrowseEnabled(serverId)) {
localSearchModeRef.current = true;
if (q === '') {
const page = await fetchOfflineLocalBrowsableSongPage(serverId, pageOffset, PAGE_SIZE);
return page?.songs ?? [];
}
return (await searchOfflineLocalBrowsableSongs(serverId, q, pageOffset, PAGE_SIZE)) ?? [];
}
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)) ?? [];
},
// musicLibraryFilterVersion is an intentional re-create trigger: the page
// loaders read the active genre/library filter state internally, so the
// callback must refresh when that version bumps even though it is unused here.
// eslint-disable-next-line react-hooks/exhaustive-deps
[indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, serverId],
);
useEffect(() => {
if (!enabled) return;
if (holdRestoredListRef.current) {
const expected = heldRestoredQueryRef.current;
if (searchQuery.trim() !== expected || debouncedQuery !== expected) {
holdRestoredListRef.current = false;
} else {
return;
}
}
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);
}
setHasSearched(true);
} catch {
if (!isStale()) setHasMore(false);
} finally {
if (!isStale()) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [debouncedQuery, searchQuery, fetchSongPage, enabled, musicLibraryFilterVersion, offlineBrowseReloadTs]);
const loadMore = useCallback(async () => {
if (!enabled || 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);
}
}, [enabled, loading, hasMore, debouncedQuery, offset, fetchSongPage]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
return {
songs,
offset,
loading,
hasMore,
browseUnsupported,
hasSearched,
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
localSearchMode: localSearchModeRef.current,
loadMore,
};
}
@@ -8,7 +8,7 @@ import { SlidersVertical, Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { AlbumRow } from '@/features/album';
import { ArtistRow } from '@/features/artist';
import PagedSongList from '@/components/PagedSongList';
import PagedSongList from '@/features/search/components/PagedSongList';
import CustomSelect from '@/ui/CustomSelect';
import StarFilterButton from '@/ui/StarFilterButton';
import { tooltipAttrs } from '@/ui/tooltipAttrs';
@@ -58,9 +58,9 @@ import {
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { MOOD_GROUP_IDS } from '@/config/moodGroups';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { useSongBrowseList, type SongBrowseListRestore } from '@/hooks/useSongBrowseList';
import TracksPageChrome from '@/components/tracks/TracksPageChrome';
import SongBrowseSection from '@/components/tracks/SongBrowseSection';
import { useSongBrowseList, type SongBrowseListRestore } from '@/features/search/hooks/useSongBrowseList';
import TracksPageChrome from '@/features/search/components/TracksPageChrome';
import SongBrowseSection from '@/features/search/components/SongBrowseSection';
import {
useLiveSearchScopeStore,
useScopedBrowseSearchQuery,