mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
|
import React, { useRef, useState, useEffect, useMemo } from 'react';
|
|
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
|
import SongCard from '@/features/home/components/SongCard';
|
|
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
|
import { dedupeById } from '@/lib/util/dedupeById';
|
|
|
|
interface Props {
|
|
title: string;
|
|
songs: SubsonicSong[];
|
|
/** Called when user clicks the reroll button (visible only if provided). */
|
|
onReroll?: () => void | Promise<void>;
|
|
/** Loading state — disables reroll, optional shimmer */
|
|
loading?: boolean;
|
|
/** Empty-state copy when songs is empty AND not loading. */
|
|
emptyText?: string;
|
|
disableArtwork?: boolean;
|
|
disableInteractivity?: boolean;
|
|
artworkSize?: number;
|
|
windowArtworkByViewport?: boolean;
|
|
initialArtworkBudget?: number;
|
|
}
|
|
|
|
export default function SongRail({
|
|
title,
|
|
songs,
|
|
onReroll,
|
|
loading,
|
|
emptyText,
|
|
disableArtwork = false,
|
|
disableInteractivity = false,
|
|
artworkSize,
|
|
windowArtworkByViewport = false,
|
|
initialArtworkBudget = 10,
|
|
}: Props) {
|
|
const perfFlags = usePerfProbeFlags();
|
|
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
|
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const uniqueSongs = useMemo(() => dedupeById(songs), [songs]);
|
|
const [showLeft, setShowLeft] = useState(false);
|
|
const [showRight, setShowRight] = useState(true);
|
|
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
|
|
|
|
const recomputeArtworkBudget = () => {
|
|
if (!windowArtworkByViewport) return;
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const { scrollLeft, clientWidth } = el;
|
|
const firstCard = el.querySelector<HTMLElement>('.song-card');
|
|
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 140;
|
|
const gridStyles = window.getComputedStyle(el);
|
|
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
|
|
const step = Math.max(1, cardW + gap);
|
|
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
|
|
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12);
|
|
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
|
|
};
|
|
|
|
const handleScroll = () => {
|
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
|
|
|
if (!scrollRef.current) return;
|
|
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
|
|
|
if (!interactivityDisabled) {
|
|
setShowLeft(scrollLeft > 0);
|
|
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
handleScroll();
|
|
const raf = window.requestAnimationFrame(() => {
|
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
|
});
|
|
window.addEventListener('resize', handleScroll);
|
|
const ro = new ResizeObserver(() => {
|
|
if (windowArtworkByViewport) recomputeArtworkBudget();
|
|
});
|
|
if (scrollRef.current) ro.observe(scrollRef.current);
|
|
return () => {
|
|
window.cancelAnimationFrame(raf);
|
|
window.removeEventListener('resize', handleScroll);
|
|
ro.disconnect();
|
|
};
|
|
// handleScroll/recomputeArtworkBudget are recreated each render but read live
|
|
// refs/props; the listeners are intentionally (re)bound only when the row data
|
|
// or artwork config changes, not on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
|
|
|
const rowArtworkResetKey = uniqueSongs[0]?.id ?? '';
|
|
useEffect(() => {
|
|
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setArtworkBudget(initialArtworkBudget);
|
|
}, [initialArtworkBudget, rowArtworkResetKey]);
|
|
|
|
const scroll = (dir: 'left' | 'right') => {
|
|
if (!scrollRef.current) return;
|
|
const amount = scrollRef.current.clientWidth * 0.75;
|
|
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
|
};
|
|
|
|
// Hide rail entirely if empty and no empty-state copy
|
|
if (uniqueSongs.length === 0 && !loading && !emptyText) return null;
|
|
|
|
return (
|
|
<section className="song-row-section">
|
|
<div className="song-row-header">
|
|
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
|
<div className="song-row-nav">
|
|
{onReroll && (
|
|
<button
|
|
className="nav-btn song-row-reroll"
|
|
onClick={() => onReroll()}
|
|
disabled={loading}
|
|
aria-label="Reroll"
|
|
data-tooltip="Reroll"
|
|
data-tooltip-pos="top"
|
|
>
|
|
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
|
|
</button>
|
|
)}
|
|
{!interactivityDisabled && (
|
|
<>
|
|
<button
|
|
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
|
onClick={() => scroll('left')}
|
|
disabled={!showLeft}
|
|
>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<button
|
|
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
|
onClick={() => scroll('right')}
|
|
disabled={!showRight}
|
|
>
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="song-grid-wrapper">
|
|
{uniqueSongs.length === 0 && emptyText ? (
|
|
<p className="song-row-empty">{emptyText}</p>
|
|
) : (
|
|
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
|
|
{uniqueSongs.map((s, idx) => (
|
|
<SongCard
|
|
key={s.id}
|
|
song={s}
|
|
disableArtwork={
|
|
artworkDisabled ||
|
|
(windowArtworkByViewport && idx >= artworkBudget)
|
|
}
|
|
artworkSize={artworkSize}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|