"use client"; import { CompassIcon, GripVerticalIcon, HeartIcon, HomeIcon, ListMusicIcon, MenuIcon, MicVocalIcon, PauseIcon, PlayIcon, RadioIcon, Repeat1Icon, RepeatIcon, ShuffleIcon, SkipBackIcon, SkipForwardIcon, Volume1Icon, Volume2Icon, VolumeXIcon, XIcon, } from "lucide-react"; import { AnimatePresence, type DragControls, motion, Reorder, useDragControls, useReducedMotion, } from "motion/react"; import { type KeyboardEvent, type ReactNode, type RefObject, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; function useReducedMotionAfterMount() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && Boolean(prefers); } export type CoverArt = "sunset" | "ocean" | "forest" | "dusk" | "citrus" | "rose" | "aurora" | "slate"; export interface LyricLine { time: number; text: string; } export interface MusicTrack { id: string; title: string; artist: string; album: string; duration: number; art: CoverArt; lyrics?: LyricLine[]; } export interface MusicPlaylist { id: string; name: string; art: CoverArt; trackIds: string[]; } export interface MusicPlayerProps { className?: string; appName?: string; tracks?: MusicTrack[]; playlists?: MusicPlaylist[]; initialTrackId?: string; initialPosition?: number; autoPlay?: boolean; } const spring = { type: "spring", stiffness: 520, damping: 46, mass: 1 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[color,background-color,box-shadow,scale] duration-150 ease-out active:scale-[0.97]"; const ring = "outline-none focus-visible:ring-2 focus-visible:ring-ring"; const arts: Record = { sunset: "from-amber-300 via-orange-500 to-rose-700", ocean: "from-sky-300 via-blue-600 to-indigo-950", forest: "from-lime-300 via-emerald-600 to-teal-950", dusk: "from-fuchsia-400 via-violet-700 to-indigo-950", citrus: "from-yellow-200 via-lime-400 to-emerald-600", rose: "from-pink-200 via-rose-400 to-red-700", aurora: "from-teal-200 via-cyan-500 to-violet-800", slate: "from-stone-200 via-stone-500 to-stone-900", }; function Motif({ art }: { art: CoverArt }) { const common = { "aria-hidden": true, className: "absolute inset-0 size-full", viewBox: "0 0 100 100", preserveAspectRatio: "xMidYMid slice" } as const; switch (art) { case "sunset": return ( {[ [72, 34], [78, 24], [84, 15], [90, 7], ].map(([y, w]) => ( ))} ); case "ocean": return ( {[52, 62, 72, 82].map((y, i) => ( ))} ); case "forest": return ( ); case "dusk": return ( {[ [18, 20], [34, 12], [80, 18], [62, 30], [12, 44], [88, 46], ].map(([x, y]) => ( ))} ); case "citrus": return ( {[34, 26, 18, 10].map((r) => ( ))} ); case "rose": return ( ); case "aurora": return ( ); case "slate": return ( {[20, 40, 60, 80].map((v) => ( ))} ); } } function Cover({ art, className }: { art: CoverArt; className?: string }) { return ( ); } function time(s: number) { const t = Math.max(0, Math.floor(s)); return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`; } function waveform(seed: string, n: number) { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 33 + seed.charCodeAt(i)) >>> 0; return Array.from({ length: n }, (_, i) => { h = (h * 1103515245 + 12345) >>> 0; const grit = (h % 1000) / 1000; const swell = 0.55 + 0.45 * Math.sin((i / n) * Math.PI * 3.2 + (seed.length % 5)); const v = 0.18 + 0.82 * (0.45 * swell + 0.55 * grit) * Math.min(1, (i + 2) / 5) * Math.min(1, (n - i + 1) / 5); return Math.max(0.12, Math.min(1, v)); }); } function EqBars({ playing, className }: { playing: boolean; className?: string }) { const reduce = useReducedMotionAfterMount(); return ( {[0.6, 1, 0.75].map((h, i) => ( ))} ); } function Scrubber({ track, position, onSeek, bars = 72, }: { track: MusicTrack; position: number; onSeek: (s: number) => void; bars?: number; }) { const ref = useRef(null); const [hover, setHover] = useState(null); const [dragging, setDragging] = useState(false); const heights = waveform(track.id, bars); const pct = Math.min(100, (position / track.duration) * 100); const at = (clientX: number) => { const box = ref.current?.getBoundingClientRect(); if (!box) return 0; return Math.min(1, Math.max(0, (clientX - box.left) / box.width)); }; const onKey = (e: KeyboardEvent) => { const step = e.shiftKey ? 15 : 5; if (e.key === "ArrowRight" || e.key === "ArrowUp") onSeek(Math.min(track.duration - 1, position + step)); else if (e.key === "ArrowLeft" || e.key === "ArrowDown") onSeek(Math.max(0, position - step)); else if (e.key === "Home") onSeek(0); else if (e.key === "End") onSeek(track.duration - 1); else return; e.preventDefault(); }; const layer = (cls: string) => (
{heights.map((h, i) => ( ))}
); return (
{ e.currentTarget.setPointerCapture(e.pointerId); setDragging(true); onSeek(at(e.clientX) * track.duration); }} onPointerLeave={() => setHover(null)} onPointerMove={(e) => { const f = at(e.clientX); if (dragging) onSeek(f * track.duration); if (e.pointerType === "mouse") setHover(f); }} onPointerCancel={() => setDragging(false)} onPointerUp={() => setDragging(false)} ref={ref} role="slider" tabIndex={0} > {layer("text-foreground/15")} {hover !== null && layer("text-foreground/35")}
{layer("text-foreground")}
{hover !== null && (
{layer("text-foreground/35")}
)} {hover !== null && ( {time(hover * track.duration)} )}
{time(position)} -{time(track.duration - position)}
); } function QueueRow({ track, index, total, onPlay, onRemove, onMove, }: { track: MusicTrack; index: number; total: number; onPlay: () => void; onRemove: () => void; onMove: (dir: -1 | 1) => void; }) { const controls: DragControls = useDragControls(); const reduce = useReducedMotionAfterMount(); return ( ); } const defaultTracks: MusicTrack[] = [ { id: "t1", title: "Paper Lanterns", artist: "Wren & the Low Tide", album: "Harbour Lights", duration: 214, art: "sunset", lyrics: [ { time: 0, text: "♪" }, { time: 8, text: "Streetlights hum a song we never learned" }, { time: 14, text: "Your coat still smells of rain and turning leaves" }, { time: 20, text: "We count the windows burning on the hill" }, { time: 26, text: "And name the ones still waiting up for us" }, { time: 33, text: "Paper lanterns, drifting over water" }, { time: 39, text: "Every little light a thing we didn't say" }, { time: 45, text: "Paper lanterns, hold on a little longer" }, { time: 51, text: "Till the tide comes in and carries them away" }, { time: 60, text: "♪" }, { time: 70, text: "The ferry horn is calling out the hour" }, { time: 76, text: "You laugh the way you did when we were nine" }, { time: 82, text: "I fold the evening up and keep it small" }, { time: 88, text: "A pocket full of maybe, one of mine" }, { time: 96, text: "Paper lanterns, drifting over water" }, { time: 102, text: "Every little light a thing we didn't say" }, { time: 108, text: "Paper lanterns, hold on a little longer" }, { time: 114, text: "Till the tide comes in and carries them away" }, { time: 124, text: "♪" }, { time: 140, text: "And if the dark gets heavy, let it" }, { time: 148, text: "We were never meant to hold the sky" }, { time: 156, text: "Paper lanterns…" }, { time: 164, text: "…carry us away" }, { time: 176, text: "♪" }, ], }, { id: "t2", title: "Low Orbit", artist: "Pale Static", album: "Satellite Summer", duration: 187, art: "dusk", lyrics: [ { time: 0, text: "♪" }, { time: 12, text: "Radio static where your voice used to be" }, { time: 19, text: "I keep the dial right where you left it" }, { time: 26, text: "Low orbit, circling the kitchen light" }, { time: 33, text: "Too far to land and too close to forget it" }, { time: 44, text: "♪" }, { time: 58, text: "Low orbit, low orbit" }, { time: 64, text: "Spinning slow so I can see you twice" }, { time: 76, text: "♪" }, ], }, { id: "t3", title: "Fieldnotes", artist: "Marisol Vega", album: "Green Hours", duration: 241, art: "forest" }, { id: "t4", title: "Saltwater Radio", artist: "The Quiet Coast", album: "Long Weekend", duration: 198, art: "ocean" }, { id: "t5", title: "Citrus Season", artist: "Bloom Arcade", album: "Sugar & Static", duration: 176, art: "citrus" }, { id: "t6", title: "Velvet Hour", artist: "June Okafor", album: "Night Swim", duration: 229, art: "rose" }, { id: "t7", title: "Northern Hearts", artist: "Atlas Frame", album: "Polar", duration: 263, art: "aurora" }, { id: "t8", title: "Concrete Garden", artist: "Mono Theory", album: "Grey Matter", duration: 205, art: "slate" }, { id: "t9", title: "Afterglow", artist: "Wren & the Low Tide", album: "Harbour Lights", duration: 192, art: "sunset" }, ]; const defaultPlaylists: MusicPlaylist[] = [ { id: "p1", name: "Late night drives", art: "dusk", trackIds: ["t2", "t6", "t7", "t1", "t8"] }, { id: "p2", name: "Sunday reset", art: "citrus", trackIds: ["t5", "t3", "t4", "t9"] }, { id: "p3", name: "Deep focus", art: "slate", trackIds: ["t8", "t3", "t7", "t4"] }, { id: "p4", name: "Harbour Lights", art: "sunset", trackIds: ["t1", "t9"] }, ]; type Pane = "playing" | "queue" | "lyrics"; type Repeat = "off" | "all" | "one"; export function MusicPlayer({ className, appName = "Murmur", tracks = defaultTracks, playlists = defaultPlaylists, initialTrackId, initialPosition = 47, autoPlay = true, }: MusicPlayerProps) { const reduce = useReducedMotionAfterMount(); const first = tracks.find((t) => t.id === initialTrackId) ?? tracks[0]; const [current, setCurrent] = useState(first); const [queue, setQueue] = useState(() => tracks.filter((t) => t.id !== first.id)); const [history, setHistory] = useState([]); const [position, setPosition] = useState(initialPosition); const [playing, setPlaying] = useState(autoPlay); const [shuffle, setShuffle] = useState(false); const [repeat, setRepeat] = useState("off"); const [liked, setLiked] = useState(["t2", "t6"]); const [volume, setVolume] = useState(72); const [muted, setMuted] = useState(false); const [side, setSide] = useState<"queue" | "lyrics">("lyrics"); const [pane, setPane] = useState("playing"); const [direction, setDirection] = useState<1 | -1>(1); const [activePlaylist, setActivePlaylist] = useState(null); const [drawer, setDrawer] = useState(false); const preShuffle = useRef(null); const drawerRef = useRef(null); const lyricsRef = useRef(null); const mobileLyricsRef = useRef(null); const state = useRef({ queue, history, current, repeat }); state.current = { queue, history, current, repeat }; const goTo = (track: MusicTrack, rest: MusicTrack[], dir: 1 | -1 = 1, pushHistory = true) => { if (pushHistory) setHistory((h) => [...h, state.current.current]); setDirection(dir); setCurrent(track); setQueue(rest); setPosition(0); }; const next = (auto = false) => { const { queue: q, history: h, repeat: r, current: c } = state.current; if (auto && r === "one") { setPosition(0); return; } if (q.length) goTo(q[0], q.slice(1)); else if (r === "all" && h.length) { setHistory([]); goTo(h[0], [...h.slice(1), c], 1, false); } else { setPlaying(false); setPosition(c.duration); } }; const prev = () => { if (position > 3 || history.length === 0) { setPosition(0); return; } const last = history[history.length - 1]; setHistory((h) => h.slice(0, -1)); goTo(last, [current, ...queue], -1, false); }; useEffect(() => { if (!playing) return; const t = setInterval(() => { setPosition((p) => Math.min(state.current.current.duration, p + 0.25)); }, 250); return () => clearInterval(t); }, [playing, current.id]); useEffect(() => { if (playing && position >= current.duration) next(true); }, [position, playing, current.duration]); useEffect(() => { if (drawer) drawerRef.current?.focus({ preventScroll: true }); }, [drawer]); const lyrics = current.lyrics ?? []; const activeLine = lyrics.reduce((acc, l, i) => (l.time <= position ? i : acc), -1); useEffect(() => { for (const box of [lyricsRef.current, mobileLyricsRef.current]) { if (!box || box.offsetParent === null) continue; const line = box.querySelector(`[data-line="${activeLine}"]`); if (!line) continue; const top = line.offsetTop - box.clientHeight / 2 + line.clientHeight / 2; box.scrollTo({ top: Math.max(0, top), behavior: reduce ? "auto" : "smooth" }); } }, [activeLine, current.id, side, pane, reduce]); const toggleShuffle = () => { if (!shuffle) { preShuffle.current = queue.map((t) => t.id); const copy = [...queue]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } setQueue(copy); } else if (preShuffle.current) { const order = preShuffle.current; setQueue((q) => [...q].sort((a, b) => (order.indexOf(a.id) + 1 || 999) - (order.indexOf(b.id) + 1 || 999))); } setShuffle((s) => !s); }; const playFromQueue = (index: number) => { const q = queue; setHistory((h) => [...h, current, ...q.slice(0, index)]); goTo(q[index], q.slice(index + 1), 1, false); setPlaying(true); }; const playPlaylist = (p: MusicPlaylist) => { const list = p.trackIds.map((id) => tracks.find((t) => t.id === id)).filter(Boolean) as MusicTrack[]; if (!list.length) return; setActivePlaylist(p.id); goTo(list[0], list.slice(1)); setPlaying(true); setDrawer(false); }; const move = (index: number, dir: -1 | 1) => { setQueue((q) => { const copy = [...q]; const [item] = copy.splice(index, 1); copy.splice(index + dir, 0, item); return copy; }); }; const isLiked = liked.includes(current.id); const toggleLike = () => setLiked((l) => (isLiked ? l.filter((x) => x !== current.id) : [...l, current.id])); const queueTime = queue.reduce((s, t) => s + t.duration, 0); const VolumeIcon = muted || volume === 0 ? VolumeXIcon : volume < 50 ? Volume1Icon : Volume2Icon; const playButton = (size: "lg" | "sm") => ( ); const iconButton = (label: string, onClick: () => void, icon: ReactNode, active?: boolean, className?: string) => ( ); const lyricsList = (refObj: RefObject, big?: boolean) => lyrics.length === 0 ? (
No lyrics for this one {current.artist} hasn't shared lyrics for “{current.title}” yet.
) : (
    {lyrics.map((l, i) => { const state = i === activeLine ? "now" : i < activeLine ? "past" : "next"; return (
  1. ); })}
); const queueList = (
Now playing
{current.title} {current.artist}
Up next · {queue.length} songs, {Math.round(queueTime / 60)} min {queue.length > 0 && ( )}
{queue.length === 0 ? (
Queue's empty Pick a playlist to keep the music going.
) : ( {queue.map((t, i) => ( move(i, d)} onPlay={() => playFromQueue(i)} onRemove={() => setQueue((q) => q.filter((x) => x.id !== t.id))} total={queue.length} track={t} /> ))} )}
); const sidebar = (
{appName}
Playlists {playlists.map((p) => ( ))}
); const nowPlaying = (

{current.title}

{current.artist} · {current.album}

{iconButton("Shuffle", toggleShuffle, , shuffle)}
{iconButton("Previous", prev, )} {playButton("lg")} {iconButton("Next", () => next(), )}
{iconButton( repeat === "one" ? "Repeat one" : repeat === "all" ? "Repeat all" : "Repeat", () => setRepeat((r) => (r === "off" ? "all" : r === "all" ? "one" : "off")), repeat === "one" ? : , repeat !== "off", )}
{iconButton(muted ? "Unmute" : "Mute", () => setMuted((m) => !m), , undefined, "size-8 text-muted-foreground")} "Volume"} className="flex-1 [&_[data-slot=slider-control]]:min-w-0" max={100} onValueChange={(v) => { setVolume(Array.isArray(v) ? v[0] : (v as number)); setMuted(false); }} value={muted ? 0 : volume} /> {muted ? 0 : volume}
); const paneTabs: { value: Pane; label: string }[] = [ { value: "playing", label: "Playing" }, { value: "queue", label: "Up next" }, { value: "lyrics", label: "Lyrics" }, ]; return (
{ if (e.key === "Escape" && drawer) setDrawer(false); }} > {drawer && ( <> setDrawer(false)} transition={{ duration: 0.2, ease: easeOut }} /> {sidebar} )}
{appName}
{paneTabs.map((t) => ( ))}
{nowPlaying}
{pane !== "playing" && (
{pane === "queue" ? queueList : lyricsList(mobileLyricsRef, true)}
)}
{pane !== "playing" && (
{playButton("sm")} {iconButton("Next", () => next(), )}
)}
); }