mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(search): co-locate tracks song-browse cluster (SongRow/PagedSongList/SongBrowseSection/TracksPageChrome/useSongBrowseList) into features/search
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"> · </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,
|
||||
|
||||
Reference in New Issue
Block a user