digital-turntable/src/components/Album/Album.tsx
Chandler Swift d188c25e6e
Slop: Add scrolling title
Prompt:
> Sometimes this album title is too long, and it overflows the box. I don't want it to do that. Instead, I want it to be cut off at max width, and then to have a marquee effect, translating left. For example:
> `Monty Python and the`
> becomes
> `ty Python and the Hol`
> becomes
> `thon and the Holy Grail`
> becomes
> `the Holy Grail     Monty Py`
> (note a many-space gap between Grail and Monty as the title restarts)
>
> How can I create this effect?
2026-02-11 22:48:21 -06:00

94 lines
3.3 KiB
TypeScript

import Track from "./Track";
import "./Album.css";
import type { Album, Track as TrackType } from "../../album";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
export default function Album({ album, dismissAlbum, play, nowPlaying }: { album: Album; dismissAlbum: () => void, play: (i: number) => void, nowPlaying: TrackType }) {
const titleViewportRef = useRef<HTMLSpanElement | null>(null);
const titleTextRef = useRef<HTMLSpanElement | null>(null);
const [isMarquee, setIsMarquee] = useState(false);
const [marqueeDistancePx, setMarqueeDistancePx] = useState(0);
// Tunables (constant px/sec + visible gap between repeats)
const marqueeGapPx = 96;
const marqueeSpeedPxPerSec = 60;
useLayoutEffect(() => {
const viewportEl = titleViewportRef.current;
const textEl = titleTextRef.current;
if (!viewportEl || !textEl) return;
let raf = 0;
const measure = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const viewportWidth = viewportEl.clientWidth;
const textWidth = textEl.scrollWidth;
const shouldMarquee = textWidth > viewportWidth + 1;
setIsMarquee(shouldMarquee);
setMarqueeDistancePx(shouldMarquee ? textWidth + marqueeGapPx : 0);
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(viewportEl);
// Fonts can load after first paint and change measurements.
void (document as unknown as { fonts?: { ready: Promise<void> } }).fonts?.ready.then(measure);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, [album.title]);
const marqueeStyle = useMemo(() => {
if (!isMarquee) return undefined;
const durationSec = marqueeSpeedPxPerSec > 0 ? marqueeDistancePx / marqueeSpeedPxPerSec : 0;
return {
["--marquee-gap" as never]: `${marqueeGapPx}px`,
["--marquee-distance" as never]: `${marqueeDistancePx}px`,
["--marquee-duration" as never]: `${durationSec}s`,
};
}, [isMarquee, marqueeDistancePx]);
return (
<section className="panel">
<div className="meta">
<div className="title-row">
<h1 className="title" id="release-title">
<span
ref={titleViewportRef}
className={`title-viewport${isMarquee ? " is-marquee" : ""}`}
style={marqueeStyle}
title={album.title}
>
<span className="title-track">
<span ref={titleTextRef} className="title-text">{album.title}</span>
{isMarquee ? (
<span className="title-text title-text--clone">{album.title}</span>
) : null}
</span>
</span>
</h1>
<button type="button" className="dismiss" onClick={dismissAlbum}></button>
</div>
<p className="subtitle" id="release-artist">
{album.artistName} {album.releaseDate.year ? ' • ' + album.releaseDate.year : ''} {/* TODO: .toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) */}
</p>
<div className="queue" id="queue">
{album.tracks.map((t, i) => (
<Track key={t.id} idx={i} track={t} play={play} nowPlaying={nowPlaying} />
))}
</div>
</div>
</section>
);
}