222 lines
7.1 KiB
TypeScript
222 lines
7.1 KiB
TypeScript
/** The album detail sheet: cover, metadata, and a numbered track list. */
|
||
|
||
import { useEffect, useRef } from "react";
|
||
|
||
import type { Album } from "../api/types";
|
||
import { isBook, unitLabel } from "../lib/covers";
|
||
import { clock } from "../lib/format";
|
||
import { ANIMATE_VIEW_TRANSITIONS } from "../lib/theme";
|
||
import { Cover } from "./Cover";
|
||
|
||
interface Props {
|
||
album: Album;
|
||
currentAlbumId: string | null;
|
||
currentTrackIndex: number;
|
||
/** The track Ctrl+j/k has highlighted, so the keyboard has something visible to move. */
|
||
selectedIndex: number;
|
||
onClose: () => void;
|
||
onPlay: (trackIndex: number) => void;
|
||
}
|
||
|
||
export function AlbumModal({
|
||
album,
|
||
currentAlbumId,
|
||
currentTrackIndex,
|
||
selectedIndex,
|
||
onClose,
|
||
onPlay,
|
||
}: Props) {
|
||
const book = isBook(album);
|
||
const sheet = useRef<HTMLDivElement | null>(null);
|
||
|
||
// Keep the Ctrl+j/k highlight on screen as it moves past the fold, the same way the
|
||
// browse grid keeps its own keyboard selection visible.
|
||
useEffect(() => {
|
||
const box = sheet.current;
|
||
const element = box?.querySelector<HTMLElement>(`[data-nav-index="${selectedIndex}"]`);
|
||
if (!box || !element) return;
|
||
const top = element.offsetTop - box.offsetTop;
|
||
const bottom = top + element.offsetHeight;
|
||
const pad = 12;
|
||
if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad);
|
||
else if (bottom + pad > box.scrollTop + box.clientHeight) {
|
||
box.scrollTop = bottom + pad - box.clientHeight;
|
||
}
|
||
}, [selectedIndex]);
|
||
|
||
return (
|
||
<div
|
||
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
||
style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }}
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
ref={sheet}
|
||
className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined}
|
||
onClick={(event) => event.stopPropagation()}
|
||
style={{
|
||
background: "oklch(96% 0.012 210)",
|
||
borderRadius: 26,
|
||
padding: 26,
|
||
width: 640,
|
||
maxWidth: "100%",
|
||
maxHeight: "100%",
|
||
overflow: "auto",
|
||
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||
<div style={{ width: 150, flex: "none" }}>
|
||
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label />
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 26,
|
||
fontWeight: 900,
|
||
color: "oklch(22% 0.03 210)",
|
||
lineHeight: 1.15,
|
||
}}
|
||
>
|
||
{album.title}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 15,
|
||
fontWeight: 700,
|
||
color: "oklch(45% 0.03 210 / .85)",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{album.artist}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
color: "oklch(50% 0.03 210 / .7)",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{book ? "📖 Hörbuch" : "🎵 Musik"} · {unitLabel(album, album.tracks.length)} ·{" "}
|
||
{clock(album.duration)}
|
||
{album.figure && ` · 🧸 ${album.figure}`}
|
||
</div>
|
||
<button
|
||
onClick={() => onPlay(0)}
|
||
style={{
|
||
marginTop: 14,
|
||
border: "none",
|
||
cursor: "pointer",
|
||
background: "var(--accent)",
|
||
color: "#fff",
|
||
fontSize: 16,
|
||
fontWeight: 800,
|
||
padding: "12px 22px",
|
||
borderRadius: 999,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 10,
|
||
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 14,
|
||
height: 16,
|
||
background: "#fff",
|
||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||
}}
|
||
/>
|
||
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
aria-label="Schließen"
|
||
style={{
|
||
border: "none",
|
||
cursor: "pointer",
|
||
background: "oklch(88% 0.02 210)",
|
||
color: "var(--ink)",
|
||
fontSize: 18,
|
||
fontWeight: 900,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 999,
|
||
flex: "none",
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 22 }}>
|
||
{album.tracks.map((track, index) => {
|
||
const current = currentAlbumId === album.id && currentTrackIndex === index;
|
||
return (
|
||
<button
|
||
key={index}
|
||
data-nav-index={index}
|
||
onClick={() => onPlay(index)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 14,
|
||
padding: "10px 14px",
|
||
borderRadius: 14,
|
||
cursor: "pointer",
|
||
border: "none",
|
||
font: "inherit",
|
||
textAlign: "left",
|
||
background: current
|
||
? "oklch(70% 0.16 340 / .16)"
|
||
: "oklch(30% 0.03 210 / .05)",
|
||
outline: index === selectedIndex ? "3px solid var(--accent)" : "none",
|
||
outlineOffset: 2,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: 999,
|
||
flex: "none",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
font: "800 13px ui-monospace, Menlo, monospace",
|
||
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
|
||
color: current ? "#fff" : "oklch(35% 0.03 210)",
|
||
}}
|
||
>
|
||
{index + 1}
|
||
</div>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
fontSize: 16,
|
||
fontWeight: 800,
|
||
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
|
||
}}
|
||
>
|
||
{track.title}
|
||
</div>
|
||
<div
|
||
style={{
|
||
font: "700 13px ui-monospace, Menlo, monospace",
|
||
color: "oklch(45% 0.03 210 / .7)",
|
||
}}
|
||
>
|
||
{clock(track.duration)}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|