mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(songs): unified SongRow + paginated song results in search pages (#303)
Extracts the song-list row into a single shared <SongRow> component used by Tracks Hub Browse, /search and /search/advanced. All three now share the same five-column layout (Play+Enqueue · Title · Artist · Album · Genre · Duration), the same enqueueAndPlay click behaviour, and the same right-click context menu / drag handler. The header row is rendered separately via <SongListHeader> (kept outside the virtualizer scroll container in the Tracks Hub so it doesn't scroll away). Both SearchResults and AdvancedSearch now infinite-scroll their song results via an IntersectionObserver sentinel near the bottom of the list (rootMargin 600 px). Pagination uses search3's songOffset; the free-text branch in AdvancedSearch keeps applying genre/year filters client-side per loaded page. Initial fetches stay at 50 (SearchResults) and 100 (AdvancedSearch) songs; subsequent pages are 50 each. Cleanup: - removed the redundant `(N)` count in the AdvancedSearch songs heading - dropped the unused `useNavigate` + `psyDrag` + per-page contextMenuSongId state in both search pages — SongRow handles those internally - renamed the row-internal CSS classes from `.virtual-song-*` to `.song-list-row-*` so they read as shared, and switched the mobile grid breakpoint accordingly Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
3c0a42e298
commit
3673d826b1
@@ -1,12 +1,10 @@
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Search as SearchIcon, X, ListPlus } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Search as SearchIcon, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { SubsonicSong, searchSongsPaged } from '../api/subsonic';
|
||||
import { ndListSongs } from '../api/navidromeBrowse';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { enqueueAndPlay } from '../utils/playSong';
|
||||
import SongRow, { SongListHeader } from './SongRow';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
@@ -29,72 +27,6 @@ async function fetchSongPage(query: string, offset: number): Promise<SubsonicSon
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDuration(s: number): string {
|
||||
if (!s || !isFinite(s)) return '–';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
song: SubsonicSong;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const SongListRow = memo(function SongListRow({ song, isCurrent }: RowProps) {
|
||||
const navigate = useNavigate();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`virtual-song-row${isCurrent ? ' is-current' : ''}`}
|
||||
onDoubleClick={() => enqueueAndPlay(song)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, song, 'song');
|
||||
}}
|
||||
>
|
||||
<div className="virtual-song-cell virtual-song-cell-actions-left">
|
||||
<button
|
||||
className="virtual-song-action-btn virtual-song-action-btn--play"
|
||||
onClick={(e) => { e.stopPropagation(); enqueueAndPlay(song); }}
|
||||
aria-label="Play"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="virtual-song-action-btn"
|
||||
onClick={(e) => { e.stopPropagation(); enqueue([songToTrack(song)]); }}
|
||||
aria-label="Enqueue"
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="virtual-song-cell virtual-song-cell-title">
|
||||
<span className="virtual-song-title truncate">{song.title}</span>
|
||||
<span
|
||||
className={`virtual-song-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (!song.artistId) return;
|
||||
e.stopPropagation();
|
||||
navigate(`/artist/${song.artistId}`);
|
||||
}}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
<div className="virtual-song-cell virtual-song-cell-album truncate">
|
||||
{song.albumId ? (
|
||||
<span
|
||||
className="track-artist-link"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/album/${song.albumId}`); }}
|
||||
>{song.album}</span>
|
||||
) : <span>{song.album}</span>}
|
||||
</div>
|
||||
<div className="virtual-song-cell virtual-song-cell-duration">{fmtDuration(song.duration)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
emptyBrowseText?: string;
|
||||
@@ -110,8 +42,6 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [browseUnsupported, setBrowseUnsupported] = useState(false);
|
||||
|
||||
const currentTrackId = usePlayerStore(s => s.currentTrack?.id ?? null);
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement>(null);
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
@@ -250,8 +180,10 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
{emptyBrowseText ?? t('tracks.browseUnsupported')}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollParentRef} className="virtual-song-list-scroll">
|
||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||
<>
|
||||
<SongListHeader />
|
||||
<div ref={scrollParentRef} className="virtual-song-list-scroll">
|
||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const song = songs[vi.index];
|
||||
if (!song) return null;
|
||||
@@ -267,21 +199,21 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<SongListRow
|
||||
<SongRow
|
||||
song={song}
|
||||
isCurrent={currentTrackId === song.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="virtual-song-list-loading">
|
||||
<div className="spinner" style={{ width: 18, height: 18 }} />
|
||||
<span>{t('common.loadingMore')}</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user