diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b93086..646da186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 > **🙏 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. diff --git a/src/components/PagedSongList.tsx b/src/components/PagedSongList.tsx new file mode 100644 index 00000000..939c226f --- /dev/null +++ b/src/components/PagedSongList.tsx @@ -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(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 ( + <> + + {songs.map(song => ( + + ))} + {hasMore && ( +
+ {loadingMore &&
} +
+ )} + + ); +} diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx index 13c7fbdd..43d6f248 100644 --- a/src/components/VirtualSongList.tsx +++ b/src/components/VirtualSongList.tsx @@ -1,11 +1,8 @@ import { searchSongsPaged } from '../api/subsonicSearch'; import type { SubsonicSong } from '../api/subsonicTypes'; 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 { useTranslation } from 'react-i18next'; -import { useVirtualizer } from '@tanstack/react-virtual'; import { ndListSongs } from '../api/navidromeBrowse'; import { runLocalSongBrowse } from '../utils/library/advancedSearchLocal'; import { @@ -19,11 +16,9 @@ import { } from '../utils/library/browseTextSearch'; import { useAuthStore } from '../store/authStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; -import SongRow, { SongListHeader } from './SongRow'; +import PagedSongList from './PagedSongList'; const PAGE_SIZE = 50; -const ROW_HEIGHT = 52; -const PREFETCH_PX = 600; async function fetchBrowseAllPage( serverId: string | null | undefined, @@ -43,6 +38,11 @@ interface Props { 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) { const { t } = useTranslation(); const serverId = useAuthStore(s => s.activeServerId); @@ -55,9 +55,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { const [hasMore, setHasMore] = useState(true); const [browseUnsupported, setBrowseUnsupported] = useState(false); - const scrollParentRef = useRef(null); - const scrollParentHeight = useRefElementClientHeight(scrollParentRef); - const songListOverscan = Math.max(8, Math.ceil(scrollParentHeight / ROW_HEIGHT)); const requestSeqRef = useRef(0); const localSearchModeRef = useRef(false); @@ -114,7 +111,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { setHasMore(true); setBrowseUnsupported(false); localSearchModeRef.current = false; - if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0; const 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]); - 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(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); return ( @@ -246,40 +203,12 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { {emptyBrowseText ?? t('tracks.browseUnsupported')}
) : ( - <> -
- -
- {virtualizer.getVirtualItems().map(vi => { - const song = songs[vi.index]; - if (!song) return null; - return ( -
- -
- ); - })} -
- {loading && ( -
-
- {t('common.loadingMore')} -
- )} -
- + )} ); diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 7fbc7462..b6fbf37a 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -8,7 +8,7 @@ import { SlidersVertical } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; -import SongRow, { SongListHeader } from '../components/SongRow'; +import PagedSongList from '../components/PagedSongList'; import CustomSelect from '../components/CustomSelect'; import StarFilterButton from '../components/StarFilterButton'; import { useAuthStore } from '../store/authStore'; @@ -79,7 +79,6 @@ export default function AdvancedSearch() { const [songsServerOffset, setSongsServerOffset] = useState(0); const [songsHasMore, setSongsHasMore] = useState(false); const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); - const songsSentinelRef = useRef(null); const applySongFilters = ( list: SubsonicSong[], @@ -294,17 +293,6 @@ export default function AdvancedSearch() { } }, [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) => { e?.preventDefault(); runSearch({ query, genre, yearFrom, yearTo, resultType }); @@ -461,15 +449,12 @@ export default function AdvancedSearch() { )} - - {filteredResults.songs.map(song => ( - - ))} - {songsHasMore && ( -
- {loadingMoreSongs &&
} -
- )} + )}
diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 06bf5bdb..dd5b1c5b 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -5,7 +5,7 @@ import { Search } from 'lucide-react'; import type { SearchResults as ISearchResults } from '../api/subsonicTypes'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; -import SongRow, { SongListHeader } from '../components/SongRow'; +import PagedSongList from '../components/PagedSongList'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; @@ -30,7 +30,6 @@ export default function SearchResults() { const [songsHasMore, setSongsHasMore] = useState(false); const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); const [localMode, setLocalMode] = useState(false); - const songsSentinelRef = useRef(null); const searchRunRef = useRef(0); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const serverId = useAuthStore(s => s.activeServerId); @@ -109,16 +108,6 @@ export default function SearchResults() { } }, [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); return ( @@ -151,15 +140,12 @@ export default function SearchResults() { {results.songs.length > 0 && (

{t('search.songs')}

- - {results.songs.map(song => ( - - ))} - {songsHasMore && ( -
- {loadingMoreSongs &&
} -
- )} +
)} diff --git a/src/styles/tracks/virtual-song-list.css b/src/styles/tracks/virtual-song-list.css index 5ed4fc01..5283fe85 100644 --- a/src/styles/tracks/virtual-song-list.css +++ b/src/styles/tracks/virtual-song-list.css @@ -77,22 +77,3 @@ 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); -} -