Fazekit

Code

"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;