From 91315730acfe6e9340f29221cd11fba4e76232dd Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Tue, 16 Jun 2026 19:00:59 +0200 Subject: [PATCH] background playback --- src/app/layout.tsx | 3 + src/app/music/_components/AudioPlayer.tsx | 70 ++++-- src/app/music/_components/MusicMiniPlayer.tsx | 73 ++++++ .../music/_components/MusicPlayerProvider.tsx | 231 ++++++++++++++++++ src/app/music/_components/PlayerControls.tsx | 40 +++ src/app/music/page.tsx | 9 +- 6 files changed, 402 insertions(+), 24 deletions(-) create mode 100644 src/app/music/_components/MusicMiniPlayer.tsx create mode 100644 src/app/music/_components/MusicPlayerProvider.tsx create mode 100644 src/app/music/_components/PlayerControls.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7c7095..df87941 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -12,6 +12,7 @@ import TrpcProvider from "./_trpc/TrpcProvider"; import ThemeProvider from './_providers/ThemeProvider' import GsapProvider from "./_providers/GsapProvicer"; import {MessagesProvider} from "./_providers/MessagesProvider"; +import { MusicPlayerProvider } from "./music/_components/MusicPlayerProvider"; import { CodeHighlightStyle } from "./_components/CodeHighlightSyle"; import { cn } from "~/lib/utils"; import AnimatedBackGroundContainer from "./_components/Animated/AnimatedBackGroundContainer"; @@ -49,6 +50,7 @@ export default async function RootLayout({ +
@@ -57,6 +59,7 @@ export default async function RootLayout({ {modal} + diff --git a/src/app/music/_components/AudioPlayer.tsx b/src/app/music/_components/AudioPlayer.tsx index e703676..bfc25d2 100644 --- a/src/app/music/_components/AudioPlayer.tsx +++ b/src/app/music/_components/AudioPlayer.tsx @@ -6,6 +6,7 @@ import type WaveSurfer from "wavesurfer.js"; import { Download, Loader2, Pause, Play } from "lucide-react"; import { Button } from "~/components/ui/button"; import { toast } from "sonner"; +import { useMusicPlayer } from "./MusicPlayerProvider"; function formatTime(seconds: number) { if (!Number.isFinite(seconds)) return "0:00"; @@ -28,32 +29,46 @@ function waveColors() { }; } +/** + * Per-track waveform. Playback itself is owned by the shared MusicPlayer engine + * (so it keeps running across navigation); this wavesurfer instance is only a + * visual + seek surface that mirrors the engine when its track is active. + */ export default function AudioPlayer(props: { - /** Streaming-friendly source the player actually plays. */ + id: string; src: string; - /** Original high-quality file offered via the download button. */ downloadUrl: string; downloadName: string; }) { const containerRef = useRef(null); const wsRef = useRef(null); - const [playing, setPlaying] = useState(false); - const [current, setCurrent] = useState(0); - const [duration, setDuration] = useState(0); const [ready, setReady] = useState(false); + const [duration, setDuration] = useState(0); const [downloading, setDownloading] = useState(false); const { resolvedTheme } = useTheme(); + const { currentId, isPlaying, currentTime, toggle, seek, subscribeTime } = useMusicPlayer(); + const isActive = currentId === props.id; + + // Reach live values from the once-created wavesurfer callbacks. + const isActiveRef = useRef(isActive); + isActiveRef.current = isActive; + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; + const toggleRef = useRef(toggle); + toggleRef.current = toggle; + const seekRef = useRef(seek); + seekRef.current = seek; + useEffect(() => { let ws: WaveSurfer | null = null; let cancelled = false; setReady(false); - setCurrent(0); (async () => { const WaveSurferClass = (await import("wavesurfer.js")).default; if (cancelled || !containerRef.current) return; - ws = WaveSurferClass.create({ + const instance = WaveSurferClass.create({ container: containerRef.current, url: props.src, height: 44, @@ -64,15 +79,20 @@ export default function AudioPlayer(props: { cursorWidth: 1, ...waveColors(), }); - wsRef.current = ws; - ws.on("ready", () => { + ws = instance; + wsRef.current = instance; + instance.on("ready", () => { + // This media is for drawing only — never let it make sound. + instance.setMuted(true); setReady(true); - setDuration(ws?.getDuration() ?? 0); + setDuration(instance.getDuration()); + if (isActiveRef.current) instance.setTime(currentTimeRef.current); + }); + // Clicking the waveform: start this track if it isn't playing, then seek. + instance.on("interaction", (time: number) => { + if (!isActiveRef.current) toggleRef.current(props.id); + seekRef.current(time); }); - ws.on("play", () => setPlaying(true)); - ws.on("pause", () => setPlaying(false)); - ws.on("finish", () => setPlaying(false)); - ws.on("timeupdate", (t) => setCurrent(t)); })(); return () => { @@ -82,15 +102,23 @@ export default function AudioPlayer(props: { }; }, [props.src]); + // Mirror the engine's playback position onto the cursor while active. + useEffect(() => { + const ws = wsRef.current; + if (!ws || !ready) return; + if (!isActive) { + ws.setTime(0); + return; + } + ws.setTime(currentTimeRef.current); + return subscribeTime((t) => wsRef.current?.setTime(t)); + }, [isActive, ready, subscribeTime]); + // Re-tint the waveform when the user toggles light/dark. useEffect(() => { wsRef.current?.setOptions(waveColors()); }, [resolvedTheme]); - function togglePlay() { - wsRef.current?.playPause(); - } - async function handleDownload() { setDownloading(true); try { @@ -114,6 +142,8 @@ export default function AudioPlayer(props: { } } + const playing = isActive && isPlaying; + return (
- {formatTime(current)} + {formatTime(isActive ? currentTime : 0)}
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} + +
+
+ + {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 ( +
+ + + + +
+ ); +} 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}

)} - +