mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
7c724a642f
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
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<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>
|
|
);
|
|
}
|