mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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
@@ -0,0 +1,116 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { enqueueAndPlay } from '../utils/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
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 Props {
|
||||
song: SubsonicSong;
|
||||
}
|
||||
|
||||
function SongRow({ song }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`song-list-row${isCurrent ? ' is-current' : ''}`}
|
||||
onDoubleClick={() => enqueueAndPlay(song)}
|
||||
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(); enqueueAndPlay(song); }}
|
||||
aria-label="Play"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="song-list-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); enqueue([songToTrack(song)]); }}
|
||||
aria-label="Enqueue"
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
className={song.artistId ? 'track-artist-link' : ''}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={(e) => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
title={song.artist}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
<div className="song-list-row-cell truncate">
|
||||
{song.albumId ? (
|
||||
<span
|
||||
className="track-artist-link"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/album/${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>
|
||||
<div className="song-list-row-cell song-list-row-duration">{fmtDuration(song.duration)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Column header with the same grid as <SongRow>. Optional — pages can render it above the list. */
|
||||
export function SongListHeader() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="song-list-row song-list-row--header" 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>
|
||||
<div className="song-list-row-cell song-list-row-duration">{t('albumDetail.trackDuration')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SongRow);
|
||||
@@ -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