fix(library): browse-all-tracks shares the Search song-list view (#854)

* fix(library): browse-all-tracks shares the Search song-list view (#841)

"Browse all tracks" rendered virtualized, transform-positioned rows inside
its own scroll box, so the sticky column header got painted over while
scrolling. Extract the Search / Advanced-Search song-list chrome (sticky
header + plain SongRows + IntersectionObserver sentinel paging) into a
shared PagedSongList and route all three through it; Browse now flows in the
page like the Search pages, so the header can no longer be overlapped.

Trade-off: Browse-all drops its bespoke row virtualization to match the
Search pages (DOM grows with scroll as they do); paging is unchanged. Also
removes the duplicated sentinel/observer in SearchResults and AdvancedSearch.

# Conflicts:
#	src/components/VirtualSongList.tsx
#	src/pages/SearchResults.tsx

* docs(changelog): browse-all-tracks sticky header fix (#854)
This commit is contained in:
Frank Stellmacher
2026-05-22 19:53:22 +02:00
committed by GitHub
parent cc8e6cc811
commit cb4d331f99
6 changed files with 84 additions and 145 deletions
+8
View File
@@ -191,6 +191,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Browse all tracks — sticky header no longer overlapped
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#854](https://github.com/Psychotoxical/psysonic/pull/854)**
* Scrolling the full tracks list painted rows over the sticky column header. Browse now flows in the page like the search results, so the header stays put; it shares one list view with Search and Advanced Search.
## [1.46.0] - 2026-05-18 ## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
+50
View File
@@ -0,0 +1,50 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useEffect, useRef } from 'react';
import SongRow, { SongListHeader } from './SongRow';
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;
}
/**
* 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 }: Props) {
const sentinelRef = useRef<HTMLDivElement>(null);
// Re-observe whenever `onLoadMore` changes identity (callers rebuild it after
// each page), so a sentinel still in view keeps loading the next page.
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) onLoadMore();
}, { rootMargin: '600px' });
obs.observe(el);
return () => obs.disconnect();
}, [onLoadMore]);
return (
<>
<SongListHeader />
{songs.map(song => (
<SongRow key={song.id} song={song} />
))}
{hasMore && (
<div ref={sentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
);
}
+12 -83
View File
@@ -1,11 +1,8 @@
import { searchSongsPaged } from '../api/subsonicSearch'; import { searchSongsPaged } from '../api/subsonicSearch';
import type { SubsonicSong } from '../api/subsonicTypes'; import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRefElementClientHeight } from '../hooks/useResizeClientHeight';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
import { Search as SearchIcon, X } from 'lucide-react'; import { Search as SearchIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ndListSongs } from '../api/navidromeBrowse'; import { ndListSongs } from '../api/navidromeBrowse';
import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal'; import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal';
import { import {
@@ -19,11 +16,9 @@ import {
} from '../utils/library/browseTextSearch'; } from '../utils/library/browseTextSearch';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
import SongRow, { SongListHeader } from './SongRow'; import PagedSongList from './PagedSongList';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const ROW_HEIGHT = 52;
const PREFETCH_PX = 600;
async function fetchBrowseAllPage( async function fetchBrowseAllPage(
serverId: string | null | undefined, serverId: string | null | undefined,
@@ -43,6 +38,11 @@ interface Props {
emptyBrowseText?: 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).
*/
export default function VirtualSongList({ title, emptyBrowseText }: Props) { export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId); const serverId = useAuthStore(s => s.activeServerId);
@@ -55,9 +55,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [browseUnsupported, setBrowseUnsupported] = useState(false); const [browseUnsupported, setBrowseUnsupported] = useState(false);
const scrollParentRef = useRef<HTMLDivElement>(null);
const scrollParentHeight = useRefElementClientHeight(scrollParentRef);
const songListOverscan = Math.max(8, Math.ceil(scrollParentHeight / ROW_HEIGHT));
const requestSeqRef = useRef(0); const requestSeqRef = useRef(0);
const localSearchModeRef = useRef(false); const localSearchModeRef = useRef(false);
@@ -114,7 +111,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
setHasMore(true); setHasMore(true);
setBrowseUnsupported(false); setBrowseUnsupported(false);
localSearchModeRef.current = false; localSearchModeRef.current = false;
if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0;
const seq = ++requestSeqRef.current; const seq = ++requestSeqRef.current;
const isStale = () => cancelled || seq !== requestSeqRef.current; const isStale = () => cancelled || seq !== requestSeqRef.current;
@@ -170,45 +166,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
} }
}, [loading, hasMore, debouncedQuery, offset, fetchSongPage]); }, [loading, hasMore, debouncedQuery, offset, fetchSongPage]);
const loadMoreRef = useRef(loadMore);
useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]);
useEffect(() => {
const el = scrollParentRef.current;
if (!el) return;
let rafId = 0;
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - PREFETCH_PX) {
loadMoreRef.current();
}
});
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
const virtualWrapRef = useRef<HTMLDivElement>(null);
const scrollMargin = useVirtualizerScrollMargin(
virtualWrapRef,
() => scrollParentRef.current,
{ active: true, deps: [songs.length] },
);
const virtualizer = useVirtualizer({
count: songs.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: songListOverscan,
scrollMargin,
});
const totalSize = virtualizer.getTotalSize();
const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore); const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore);
return ( return (
@@ -246,40 +203,12 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
{emptyBrowseText ?? t('tracks.browseUnsupported')} {emptyBrowseText ?? t('tracks.browseUnsupported')}
</div> </div>
) : ( ) : (
<> <PagedSongList
<div ref={scrollParentRef} className="virtual-song-list-scroll"> songs={songs}
<SongListHeader /> hasMore={hasMore}
<div ref={virtualWrapRef} style={{ height: totalSize, width: '100%', position: 'relative' }}> loadingMore={loading}
{virtualizer.getVirtualItems().map(vi => { onLoadMore={loadMore}
const song = songs[vi.index]; />
if (!song) return null;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${vi.start - scrollMargin}px)`,
}}
>
<SongRow
song={song}
/>
</div>
);
})}
</div>
{loading && (
<div className="virtual-song-list-loading">
<div className="spinner" style={{ width: 18, height: 18 }} />
<span>{t('common.loadingMore')}</span>
</div>
)}
</div>
</>
)} )}
</section> </section>
); );
+7 -22
View File
@@ -8,7 +8,7 @@ import { SlidersVertical } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import SongRow, { SongListHeader } from '../components/SongRow'; import PagedSongList from '../components/PagedSongList';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import StarFilterButton from '../components/StarFilterButton'; import StarFilterButton from '../components/StarFilterButton';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -79,7 +79,6 @@ export default function AdvancedSearch() {
const [songsServerOffset, setSongsServerOffset] = useState(0); const [songsServerOffset, setSongsServerOffset] = useState(0);
const [songsHasMore, setSongsHasMore] = useState(false); const [songsHasMore, setSongsHasMore] = useState(false);
const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); const [loadingMoreSongs, setLoadingMoreSongs] = useState(false);
const songsSentinelRef = useRef<HTMLDivElement>(null);
const applySongFilters = ( const applySongFilters = (
list: SubsonicSong[], list: SubsonicSong[],
@@ -294,17 +293,6 @@ export default function AdvancedSearch() {
} }
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId]); }, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId]);
// IntersectionObserver on the bottom sentinel — fires loadMoreSongs as it nears the viewport.
useEffect(() => {
const el = songsSentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) loadMoreSongs();
}, { rootMargin: '600px' });
obs.observe(el);
return () => obs.disconnect();
}, [loadMoreSongs]);
const handleSubmit = (e?: React.FormEvent) => { const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
runSearch({ query, genre, yearFrom, yearTo, resultType }); runSearch({ query, genre, yearFrom, yearTo, resultType });
@@ -461,15 +449,12 @@ export default function AdvancedSearch() {
</span> </span>
)} )}
</h2> </h2>
<SongListHeader /> <PagedSongList
{filteredResults.songs.map(song => ( songs={filteredResults.songs}
<SongRow key={song.id} song={song} /> hasMore={songsHasMore}
))} loadingMore={loadingMoreSongs}
{songsHasMore && ( onLoadMore={loadMoreSongs}
<div ref={songsSentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}> />
{loadingMoreSongs && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</section> </section>
)} )}
</div> </div>
+7 -21
View File
@@ -5,7 +5,7 @@ import { Search } from 'lucide-react';
import type { SearchResults as ISearchResults } from '../api/subsonicTypes'; import type { SearchResults as ISearchResults } from '../api/subsonicTypes';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import SongRow, { SongListHeader } from '../components/SongRow'; import PagedSongList from '../components/PagedSongList';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
@@ -30,7 +30,6 @@ export default function SearchResults() {
const [songsHasMore, setSongsHasMore] = useState(false); const [songsHasMore, setSongsHasMore] = useState(false);
const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); const [loadingMoreSongs, setLoadingMoreSongs] = useState(false);
const [localMode, setLocalMode] = useState(false); const [localMode, setLocalMode] = useState(false);
const songsSentinelRef = useRef<HTMLDivElement>(null);
const searchRunRef = useRef(0); const searchRunRef = useRef(0);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId); const serverId = useAuthStore(s => s.activeServerId);
@@ -109,16 +108,6 @@ export default function SearchResults() {
} }
}, [loadingMoreSongs, songsHasMore, query, songsServerOffset, localMode, serverId]); }, [loadingMoreSongs, songsHasMore, query, songsServerOffset, localMode, serverId]);
useEffect(() => {
const el = songsSentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) loadMoreSongs();
}, { rootMargin: '600px' });
obs.observe(el);
return () => obs.disconnect();
}, [loadMoreSongs]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
return ( return (
@@ -151,15 +140,12 @@ export default function SearchResults() {
{results.songs.length > 0 && ( {results.songs.length > 0 && (
<section> <section>
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>{t('search.songs')}</h2> <h2 className="section-title" style={{ marginBottom: '0.75rem' }}>{t('search.songs')}</h2>
<SongListHeader /> <PagedSongList
{results.songs.map(song => ( songs={results.songs}
<SongRow key={song.id} song={song} /> hasMore={songsHasMore}
))} loadingMore={loadingMoreSongs}
{songsHasMore && ( onLoadMore={loadMoreSongs}
<div ref={songsSentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}> />
{loadingMoreSongs && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</section> </section>
)} )}
</> </>
-19
View File
@@ -77,22 +77,3 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.virtual-song-list-scroll {
max-height: 70vh;
min-height: 320px;
overflow-y: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-card);
}
.virtual-song-list-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: var(--space-3);
font-size: 12px;
color: var(--text-muted);
}