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
+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>
)}
</>
);
}