"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { Disc3, Heart, Home, ListMusic, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { DEFAULT_LIKED, defaultLibrary } from "./data";
import { FullPlayer, MiniPlayer, NowPlayingBar } from "./player-bar";
import { QueueDrawer } from "./queue-drawer";
import { TrackList } from "./track-list";
import type { LibraryView, MusicLibrary, Track } from "./types";
import { playerReducer, usePlaybackClock, usePreviewTone, type PlayerState } from "./use-player";
import { AlbumView, CollectionView, HomeView } from "./views";
export type { Album, LibraryView, MusicLibrary, Playlist, RepeatMode, Track } from "./types";
export interface MusicPlayerAppProps {
library?: MusicLibrary;
initialLiked?: string[];
/** Track to cue up on load (paused). Defaults to the first track of the first album. */
initialTrackId?: string;
appName?: string;
onPlay?: (track: Track) => void;
onLikeChange?: (trackId: string, liked: boolean) => void;
className?: string;
}
function isTypingTarget(el: EventTarget | null) {
if (!(el instanceof HTMLElement)) return false;
return el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
}
export function MusicPlayerApp({ library = defaultLibrary, initialLiked = DEFAULT_LIKED, initialTrackId, appName = "Lumen", onPlay, onLikeChange, className }: MusicPlayerAppProps) {
const { albums: albumList, tracks: trackList, playlists } = library;
const albums = React.useMemo(() => Object.fromEntries(albumList.map((a) => [a.id, a])), [albumList]);
const tracks = React.useMemo(() => Object.fromEntries(trackList.map((t) => [t.id, t])), [trackList]);
const reduce = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const mainRef = React.useRef<HTMLElement>(null);
const [view, setView] = React.useState<LibraryView>({ kind: "home" });
const [liked, setLiked] = React.useState(() => new Set(initialLiked));
const [queueOpen, setQueueOpen] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const [tone, setTone] = React.useState(false);
const [query, setQuery] = React.useState("");
const [state, dispatch] = React.useReducer(playerReducer, undefined, (): PlayerState => {
const first = initialTrackId ? tracks[initialTrackId] : trackList[0];
const album = first ? albums[first.albumId] : undefined;
const queue = album ? album.trackIds : trackList.map((t) => t.id);
return {
queue,
original: null,
index: first ? Math.max(0, queue.indexOf(first.id)) : 0,
playing: false,
position: 48,
shuffle: false,
repeat: "off",
volume: 0.7,
muted: false,
seed: 1,
durations: Object.fromEntries(trackList.map((t) => [t.id, t.duration])),
};
});
usePlaybackClock(state.playing, dispatch);
const currentId = state.queue[state.index] ?? null;
const track = currentId ? (tracks[currentId] ?? null) : null;
const album = track ? (albums[track.albumId] ?? null) : null;
usePreviewTone(tone, state.playing, currentId ?? "", state.muted ? 0 : state.volume);
const onPlayRef = React.useRef(onPlay);
React.useEffect(() => {
onPlayRef.current = onPlay;
}, [onPlay]);
React.useEffect(() => {
if (state.playing && track) onPlayRef.current?.(track);
}, [state.playing, track]);
// Keyboard: Space = play/pause, ←/→ = seek 5s (Shift = 15s). Ignored while typing.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const root = rootRef.current;
const active = document.activeElement;
if (!root || (active && active !== document.body && !root.contains(active))) return;
if (isTypingTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target instanceof HTMLElement ? e.target : null;
if (e.key === "Escape") {
if (queueOpen) setQueueOpen(false);
else if (expanded) setExpanded(false);
return;
}
if (t?.getAttribute("role") === "slider") return;
if (e.code === "Space" || e.key === " ") {
if (t && (t.tagName === "BUTTON" || t.getAttribute("role") === "button")) return;
e.preventDefault();
dispatch({ type: "toggle" });
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
dispatch({ type: "seek-by", by: (e.key === "ArrowRight" ? 1 : -1) * (e.shiftKey ? 15 : 5) });
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [queueOpen, expanded]);
const toggleLike = (id: string) => {
setLiked((prev) => {
const next = new Set(prev);
const on = !next.has(id);
if (on) next.add(id);
else next.delete(id);
onLikeChange?.(id, on);
return next;
});
};
const go = (v: LibraryView) => {
setView(v);
setQuery("");
mainRef.current?.scrollTo({ top: 0 });
};
const common = {
albums,
currentId,
playing: state.playing,
liked,
onPlayList: (ids: string[], start?: number, shuffle?: boolean) => dispatch({ type: "play-list", ids, start, shuffle }),
onToggle: () => dispatch({ type: "toggle" }),
onLike: toggleLike,
};
const likedTracks = trackList.filter((t) => liked.has(t.id));
const q = query.trim().toLowerCase();
const searchHits = q ? trackList.filter((t) => t.title.toLowerCase().includes(q) || albums[t.albumId]?.artist.toLowerCase().includes(q) || albums[t.albumId]?.title.toLowerCase().includes(q)) : [];
const navItems: { v: LibraryView; label: string; icon: React.ReactNode; count?: number }[] = [
{ v: { kind: "home" }, label: "Home", icon: <Home className="size-4" /> },
{ v: { kind: "liked" }, label: "Liked Songs", icon: <Heart className="size-4" />, count: liked.size },
...playlists.map((p) => ({ v: { kind: "playlist", id: p.id } as LibraryView, label: p.name, icon: <ListMusic className="size-4" />, count: p.trackIds.length })),
];
const isActive = (v: LibraryView) => v.kind === view.kind && ("id" in v ? "id" in view && view.id === v.id : true);
const viewKey = `${view.kind}-${"id" in view ? view.id : ""}-${q ? "search" : ""}`;
const barProps = {
state,
dispatch,
track,
album,
liked: currentId ? liked.has(currentId) : false,
onLike: () => currentId && toggleLike(currentId),
queueOpen,
onQueue: () => setQueueOpen((o) => !o),
tone,
onTone: () => setTone((t) => !t),
onOpenAlbum: (id: string) => go({ kind: "album", id }),
};
let content: React.ReactNode;
if (q) {
content = (
<div>
<h2 className="mb-3 text-lg font-bold tracking-tight">
{searchHits.length} result{searchHits.length === 1 ? "" : "s"} for “{query.trim()}”
</h2>
{searchHits.length ? (
<TrackList
tracks={searchHits}
albums={albums}
currentId={currentId}
playing={state.playing}
liked={liked}
showAlbum
onPlay={(i) => common.onPlayList(searchHits.map((t) => t.id), i)}
onToggle={common.onToggle}
onLike={toggleLike}
/>
) : (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<Search className="size-8 text-muted-foreground" aria-hidden />
<p className="font-semibold">No songs found</p>
<p className="text-sm text-muted-foreground">Check the spelling or try an artist name.</p>
</div>
)}
</div>
);
} else if (view.kind === "album" && albums[view.id]) {
const a = albums[view.id];
content = <AlbumView album={a} tracks={a.trackIds.map((id) => tracks[id]).filter(Boolean)} {...common} />;
} else if (view.kind === "liked") {
content = <CollectionView kind="liked" tracks={likedTracks} {...common} />;
} else if (view.kind === "playlist") {
const p = playlists.find((x) => x.id === view.id);
content = <CollectionView kind="playlist" playlist={p} tracks={(p?.trackIds ?? []).map((id) => tracks[id]).filter(Boolean)} {...common} />;
} else {
content = <HomeView albumList={albumList} playlists={playlists} tracksById={tracks} onOpenAlbum={(id) => go({ kind: "album", id })} onOpenPlaylist={(id) => go({ kind: "playlist", id })} {...common} />;
}
return (
<div ref={rootRef} className={cn("relative flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
<div className="flex min-h-0 flex-1">
{/* Sidebar */}
<aside className="hidden w-60 shrink-0 flex-col gap-5 border-r bg-muted/30 p-3 md:flex" aria-label="Library">
<div className="flex items-center gap-2 px-2 pt-1">
<span className="grid size-8 place-items-center rounded-full bg-primary text-primary-foreground">
<Disc3 className="size-4" aria-hidden />
</span>
<span className="text-base font-bold tracking-tight">{appName}</span>
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">
<LayoutGroup id="mp-nav">
{navItems.map((n, i) => (
<React.Fragment key={n.label}>
{i === 2 && <p className="px-3 pb-1 pt-4 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Playlists</p>}
<button
type="button"
onClick={() => go(n.v)}
aria-current={isActive(n.v) && !q ? "page" : undefined}
className={cn("relative flex h-9 items-center gap-3 rounded-md px-3 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", isActive(n.v) && !q ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{isActive(n.v) && !q && <motion.span layoutId="mp-nav-pill" className="absolute inset-0 rounded-md bg-foreground/[0.07]" transition={{ type: "spring", stiffness: 420, damping: 34 }} />}
<span className="relative">{n.icon}</span>
<span className="relative flex-1 truncate">{n.label}</span>
{n.count !== undefined && <span className="relative text-xs tabular-nums text-muted-foreground">{n.count}</span>}
</button>
</React.Fragment>
))}
</LayoutGroup>
</nav>
<p className="px-3 text-[11px] leading-relaxed text-muted-foreground">
<kbd className="rounded border bg-background px-1 font-sans">Space</kbd> play/pause · <kbd className="rounded border bg-background px-1 font-sans">←</kbd>
<kbd className="ml-0.5 rounded border bg-background px-1 font-sans">→</kbd> seek
</p>
</aside>
<div className="relative flex min-w-0 flex-1 flex-col">
{/* Top bar: search + mobile nav */}
<div className="shrink-0 space-y-3 border-b px-4 py-3 sm:px-6">
<div className="flex items-center gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground md:hidden">
<Disc3 className="size-4" aria-hidden />
</span>
<label className="relative block w-full max-w-sm">
<span className="sr-only">Search songs, albums, artists</span>
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
placeholder="What do you want to hear?"
className="h-9 w-full rounded-full border bg-muted/50 pl-9 pr-9 text-sm outline-none transition placeholder:text-muted-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:ring-2 focus-visible:ring-ring/30"
/>
{query && (
<button type="button" onClick={() => setQuery("")} aria-label="Clear search" className="absolute right-2 top-1/2 grid size-6 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:bg-accent">
<X className="size-3.5" />
</button>
)}
</label>
</div>
<nav className="-mx-4 flex gap-2 overflow-x-auto px-4 [scrollbar-width:none] md:hidden" aria-label="Library">
{navItems.map((n) => (
<button
key={n.label}
type="button"
onClick={() => go(n.v)}
aria-current={isActive(n.v) && !q ? "page" : undefined}
className={cn("shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", isActive(n.v) && !q ? "bg-foreground text-background" : "bg-foreground/[0.07] text-foreground/80")}
>
{n.label}
</button>
))}
</nav>
</div>
<main ref={mainRef} className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-4 sm:p-6">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={viewKey} initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0 }} transition={{ duration: 0.18 }}>
{content}
</motion.div>
</AnimatePresence>
</main>
<QueueDrawer open={queueOpen} onClose={() => setQueueOpen(false)} state={state} dispatch={dispatch} tracks={tracks} albums={albums} />
</div>
</div>
<LayoutGroup id="mp-player">
<NowPlayingBar {...barProps} />
{!expanded && <MiniPlayer {...barProps} onExpand={() => setExpanded(true)} />}
<AnimatePresence>{expanded && <FullPlayer {...barProps} onClose={() => setExpanded(false)} />}</AnimatePresence>
</LayoutGroup>
</div>
);
}
export default MusicPlayerApp;