Files
psysonic/src/components/SongRail.tsx
T
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* 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>
2026-06-24 01:33:34 +02:00

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>
);
}