diff --git a/src/app/music/_components/MusicMiniPlayer.tsx b/src/app/music/_components/MusicMiniPlayer.tsx
new file mode 100644
index 0000000..106a79d
--- /dev/null
+++ b/src/app/music/_components/MusicMiniPlayer.tsx
@@ -0,0 +1,73 @@
+'use client'
+
+import { useState } from "react";
+import { Music } from "lucide-react";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "~/components/ui/drawer";
+import { Slider } from "~/components/ui/slider";
+import { useMusicPlayer } from "./MusicPlayerProvider";
+import PlayerControls from "./PlayerControls";
+
+function formatTime(seconds: number) {
+ if (!Number.isFinite(seconds)) return "0:00";
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+/**
+ * Global, persistent mini-player: a floating button (shown once something is
+ * loaded) that opens a drawer with the transport controls and a scrubber, so
+ * playback can be controlled from any page while it keeps running in the
+ * background.
+ */
+export default function MusicMiniPlayer() {
+ const { currentTrack, isPlaying, currentTime, duration, seek } = useMusicPlayer();
+ const [open, setOpen] = useState(false);
+
+ if (!currentTrack) return null;
+
+ return (
+
+
+
+
+ {currentTrack.title}
+
+
+
+
+ {currentTrack.title}
+
+
+
+
+ {formatTime(currentTime)}
+
+ seek(v ?? 0)}
+ />
+
+ {formatTime(duration)}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/music/_components/MusicPlayerProvider.tsx b/src/app/music/_components/MusicPlayerProvider.tsx
new file mode 100644
index 0000000..3a695b1
--- /dev/null
+++ b/src/app/music/_components/MusicPlayerProvider.tsx
@@ -0,0 +1,231 @@
+'use client'
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { trpc } from "~/app/_trpc/Client";
+import MusicMiniPlayer from "./MusicMiniPlayer";
+
+export type PlayerTrack = {
+ id: string;
+ title: string;
+ /** Streaming-friendly source actually played. */
+ src: string;
+ /** Original high-quality file for the download button. */
+ downloadUrl: string;
+ downloadName: string;
+};
+
+type MusicPlayerValue = {
+ tracks: PlayerTrack[];
+ currentId: string | null;
+ currentTrack: PlayerTrack | null;
+ isPlaying: boolean;
+ shuffle: boolean;
+ currentTime: number;
+ duration: number;
+ /** Play button on a track: start it, or toggle play/pause if already current. */
+ toggle: (id: string) => void;
+ /** Global play/pause — acts on the current track (or starts the first one). */
+ togglePlayCurrent: () => void;
+ next: () => void;
+ previous: () => void;
+ toggleShuffle: () => void;
+ seek: (seconds: number) => void;
+ /** Subscribe to playback time updates (for waveform cursors). */
+ subscribeTime: (cb: (time: number) => void) => () => void;
+};
+
+const MusicPlayerContext = createContext
(null);
+
+export function useMusicPlayer() {
+ const ctx = useContext(MusicPlayerContext);
+ if (!ctx) throw new Error("useMusicPlayer must be used within a MusicPlayerProvider");
+ return ctx;
+}
+
+export function MusicPlayerProvider({ children }: { children: ReactNode }) {
+ // The provider owns the playlist so playback survives navigating away from
+ // the music page. The query is cached, so the music page shares this request.
+ const { data } = trpc.music.list.useQuery();
+ const tracks = useMemo(
+ () =>
+ (data ?? []).map((t) => ({
+ id: t.id,
+ title: t.title,
+ src: t.streamUrl ?? t.fileUrl,
+ downloadUrl: t.fileUrl,
+ downloadName: t.fileName,
+ })),
+ [data],
+ );
+
+ const [currentId, setCurrentId] = useState(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [shuffle, setShuffle] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+
+ const audioRef = useRef(null);
+ const tracksRef = useRef(tracks);
+ tracksRef.current = tracks;
+
+ // Lightweight pub/sub so each waveform can follow playback time without every
+ // player re-rendering on the audio element's frequent timeupdate.
+ const timeSubs = useRef void>>(new Set());
+ const subscribeTime = useCallback((cb: (t: number) => void) => {
+ timeSubs.current.add(cb);
+ return () => {
+ timeSubs.current.delete(cb);
+ };
+ }, []);
+ const emitTime = useCallback((t: number) => {
+ timeSubs.current.forEach((cb) => cb(t));
+ }, []);
+
+ const currentTrack = useMemo(
+ () => tracks.find((t) => t.id === currentId) ?? null,
+ [tracks, currentId],
+ );
+
+ const toggle = useCallback((id: string) => {
+ setCurrentId((prev) => {
+ if (prev === id) {
+ setIsPlaying((p) => !p);
+ return prev;
+ }
+ setIsPlaying(true);
+ return id;
+ });
+ }, []);
+
+ const togglePlayCurrent = useCallback(() => {
+ setCurrentId((prev) => {
+ if (prev) {
+ setIsPlaying((p) => !p);
+ return prev;
+ }
+ const first = tracksRef.current[0]?.id ?? null;
+ if (first) setIsPlaying(true);
+ return first;
+ });
+ }, []);
+
+ const step = useCallback((dir: 1 | -1) => {
+ const ids = tracksRef.current.map((t) => t.id);
+ if (ids.length === 0) return;
+ setCurrentId((prev) => {
+ let nextId: string;
+ if (shuffle && dir === 1 && ids.length > 1) {
+ do {
+ nextId = ids[Math.floor(Math.random() * ids.length)]!;
+ } while (nextId === prev);
+ } else {
+ const idx = prev ? ids.indexOf(prev) : -1;
+ nextId = ids[(idx + dir + ids.length) % ids.length]!;
+ }
+ setIsPlaying(true);
+ return nextId;
+ });
+ }, [shuffle]);
+ const next = useCallback(() => step(1), [step]);
+ const previous = useCallback(() => step(-1), [step]);
+ const toggleShuffle = useCallback(() => setShuffle((s) => !s), []);
+
+ const stepRef = useRef(step);
+ stepRef.current = step;
+
+ const seek = useCallback(
+ (s: number) => {
+ if (audioRef.current) audioRef.current.currentTime = s;
+ setCurrentTime(s);
+ emitTime(s);
+ },
+ [emitTime],
+ );
+
+ // Persistent audio element + listeners, created once on the client.
+ useEffect(() => {
+ const audio = new Audio();
+ audio.preload = "metadata";
+ audioRef.current = audio;
+ const onTime = () => {
+ setCurrentTime(audio.currentTime);
+ emitTime(audio.currentTime);
+ };
+ const onMeta = () => setDuration(audio.duration || 0);
+ const onEnded = () => stepRef.current(1);
+ audio.addEventListener("timeupdate", onTime);
+ audio.addEventListener("loadedmetadata", onMeta);
+ audio.addEventListener("ended", onEnded);
+ return () => {
+ audio.pause();
+ audio.removeEventListener("timeupdate", onTime);
+ audio.removeEventListener("loadedmetadata", onMeta);
+ audio.removeEventListener("ended", onEnded);
+ audioRef.current = null;
+ };
+ }, [emitTime]);
+
+ // Swap the source when the current track changes.
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+ if (!currentTrack) {
+ audio.removeAttribute("src");
+ audio.load();
+ return;
+ }
+ if (audio.src !== currentTrack.src) {
+ audio.src = currentTrack.src;
+ audio.load();
+ setCurrentTime(0);
+ setDuration(0);
+ }
+ }, [currentTrack]);
+
+ // Reflect the desired play state onto the element.
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio || !currentTrack) return;
+ if (isPlaying) audio.play().catch(() => {});
+ else audio.pause();
+ }, [isPlaying, currentTrack]);
+
+ const value = useMemo(
+ () => ({
+ tracks,
+ currentId,
+ currentTrack,
+ isPlaying,
+ shuffle,
+ currentTime,
+ duration,
+ toggle,
+ togglePlayCurrent,
+ next,
+ previous,
+ toggleShuffle,
+ seek,
+ subscribeTime,
+ }),
+ [
+ tracks, currentId, currentTrack, isPlaying, shuffle, currentTime, duration,
+ toggle, togglePlayCurrent, next, previous, toggleShuffle, seek, subscribeTime,
+ ],
+ );
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/music/_components/PlayerControls.tsx b/src/app/music/_components/PlayerControls.tsx
new file mode 100644
index 0000000..28ce97f
--- /dev/null
+++ b/src/app/music/_components/PlayerControls.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { Pause, Play, Shuffle, SkipBack, SkipForward } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { useMusicPlayer } from "./MusicPlayerProvider";
+
+export default function PlayerControls() {
+ const { isPlaying, shuffle, togglePlayCurrent, next, previous, toggleShuffle } =
+ useMusicPlayer();
+
+ return (
+
+
+
+
+
+ {isPlaying ? : }
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx
index ae4d441..78de68e 100644
--- a/src/app/music/page.tsx
+++ b/src/app/music/page.tsx
@@ -15,11 +15,11 @@ export default function MusicPage() {
Just Some Music I Made
-
+
All works on this page are licensed under:
-
+
@@ -33,7 +33,7 @@ export default function MusicPage() {
-
+
{track.title}
@@ -41,8 +41,9 @@ export default function MusicPage() {
{track.description && (
{track.description}
)}
-
+