import type { SubsonicSong } from '../api/subsonicTypes'; import React, { useRef, useState, useEffect, useMemo } from 'react'; import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; import SongCard from './SongCard'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { dedupeById } from '../utils/dedupeById'; interface Props { title: string; songs: SubsonicSong[]; /** Called when user clicks the reroll button (visible only if provided). */ onReroll?: () => void | Promise; /** 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(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('.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(); }; }, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]); const rowArtworkResetKey = uniqueSongs[0]?.id ?? ''; useEffect(() => { 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 (

{title}

{onReroll && ( )} {!interactivityDisabled && ( <> )}
{uniqueSongs.length === 0 && emptyText ? (

{emptyText}

) : (
{uniqueSongs.map((s, idx) => ( = artworkBudget) } artworkSize={artworkSize} /> ))}
)}
); }