Add navbar music player with floating ambient panel.

Move ambient audio into a top-nav toggle that opens a retro floating player while keeping playback running when collapsed.
This commit is contained in:
2026-05-24 10:27:31 -06:00
parent f94fd49894
commit 458ad903db
8 changed files with 614 additions and 305 deletions

View File

@@ -275,6 +275,43 @@
transparent 72%
);
}
.ambient-radio-window-chrome {
@apply border-2 border-border bg-background/95 backdrop-blur-sm;
box-shadow: var(--retro-shadow);
}
.ambient-radio-slider {
@apply accent-secondary;
}
.ambient-radio-nav-active {
animation: ambient-radio-pulse 1.6s steps(3, end) infinite;
}
.ambient-radio-nav-dot {
@apply absolute -right-0.5 -top-0.5 h-1.5 w-1.5 bg-accent;
box-shadow: 0 0 0 1px hsl(var(--background));
}
.ambient-radio-eq {
@apply inline-flex items-end gap-0.5;
height: 0.75rem;
}
.ambient-radio-eq span {
@apply w-1 bg-secondary;
height: 0.25rem;
animation: ambient-eq-bounce 0.9s steps(2, end) infinite;
}
.ambient-radio-eq span:nth-child(2) {
animation-delay: 0.15s;
}
.ambient-radio-eq span:nth-child(3) {
animation-delay: 0.3s;
}
}
@keyframes emotion-pop {
@@ -333,3 +370,25 @@
.animate-star-twinkle {
animation: star-twinkle 3s steps(2, end) infinite;
}
@keyframes ambient-radio-pulse {
0%,
100% {
box-shadow: var(--retro-shadow-sm);
}
50% {
box-shadow:
var(--retro-shadow-sm),
0 0 0 2px hsl(var(--secondary) / 0.35);
}
}
@keyframes ambient-eq-bounce {
0%,
100% {
height: 0.25rem;
}
50% {
height: 0.75rem;
}
}

View File

@@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Press_Start_2P, Share_Tech_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { GlobalAmbientAudio } from "@/components/layout/global-ambient-audio";
import { AmbientAudioProvider } from "@/components/layout/ambient-audio-provider";
import { GlobalBottomNav } from "@/components/layout/global-bottom-nav";
import { SiteHeader } from "@/components/layout/site-header";
import { ToastProvider } from "@/components/ui/toast-provider";
@@ -54,10 +54,11 @@ export default function RootLayout({
disableTransitionOnChange
>
<ToastProvider>
<SiteHeader />
<div className="flex min-h-dvh flex-col pt-topbar">{children}</div>
<GlobalAmbientAudio />
<GlobalBottomNav />
<AmbientAudioProvider>
<SiteHeader />
<div className="flex min-h-dvh flex-col pt-topbar">{children}</div>
<GlobalBottomNav />
</AmbientAudioProvider>
</ToastProvider>
</ThemeProvider>
</body>

View File

@@ -1,249 +0,0 @@
"use client";
import { SkipForward, Volume2, VolumeX } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
AVATAR_AMBIENT_PLAYLIST,
getAvatarAmbientTrackSrc,
getAvatarAudioPreferences,
setAvatarAudioPreferences,
} from "@/lib/avatar-audio";
import { cn } from "@/lib/utils";
type AvatarAmbientAudioProps = {
className?: string;
};
export function AvatarAmbientAudio({ className }: AvatarAmbientAudioProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const volumeRef = useRef(0.6);
const mutedRef = useRef(false);
const [volume, setVolume] = useState(0.6);
const [muted, setMuted] = useState(false);
const [trackIndex, setTrackIndex] = useState(0);
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
const [loadError, setLoadError] = useState(false);
const currentTrack = AVATAR_AMBIENT_PLAYLIST[trackIndex];
const effectiveVolume = muted ? 0 : volume;
useEffect(() => {
volumeRef.current = volume;
}, [volume]);
useEffect(() => {
mutedRef.current = muted;
}, [muted]);
useEffect(() => {
const preferences = getAvatarAudioPreferences();
setVolume(preferences.volume);
setMuted(preferences.muted);
volumeRef.current = preferences.volume;
mutedRef.current = preferences.muted;
}, []);
const persistPreferences = useCallback(
(nextVolume: number, nextMuted: boolean) => {
setAvatarAudioPreferences({ volume: nextVolume, muted: nextMuted });
},
[],
);
const applyVolume = useCallback(async (nextVolume: number) => {
const audio = audioRef.current;
if (!audio) {
return;
}
audio.volume = nextVolume;
if (nextVolume === 0) {
audio.pause();
return;
}
if (audio.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return;
}
try {
await audio.play();
setAutoplayBlocked(false);
} catch {
setAutoplayBlocked(true);
}
}, []);
const loadTrack = useCallback(
(index: number) => {
const audio = audioRef.current;
const track = AVATAR_AMBIENT_PLAYLIST[index];
if (!audio || !track) {
return;
}
setLoadError(false);
audio.src = getAvatarAmbientTrackSrc(track.filename);
const handleCanPlay = () => {
const nextVolume = mutedRef.current ? 0 : volumeRef.current;
void applyVolume(nextVolume);
};
const handleError = () => {
setLoadError(true);
};
audio.addEventListener("canplay", handleCanPlay, { once: true });
audio.addEventListener("error", handleError, { once: true });
audio.load();
},
[applyVolume],
);
useEffect(() => {
loadTrack(trackIndex);
}, [loadTrack, trackIndex]);
useEffect(() => {
void applyVolume(effectiveVolume);
}, [applyVolume, effectiveVolume]);
useEffect(() => {
persistPreferences(volume, muted);
}, [volume, muted, persistPreferences]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
const handleEnded = () => {
setTrackIndex((current) => (current + 1) % AVATAR_AMBIENT_PLAYLIST.length);
};
audio.addEventListener("ended", handleEnded);
return () => {
audio.removeEventListener("ended", handleEnded);
audio.pause();
};
}, []);
const handleToggleMute = () => {
setMuted((current) => !current);
};
const handleVolumeChange = (nextVolume: number) => {
setVolume(nextVolume);
if (nextVolume > 0 && muted) {
setMuted(false);
}
if (nextVolume === 0) {
setMuted(true);
}
};
const handleSkip = () => {
setTrackIndex((current) => (current + 1) % AVATAR_AMBIENT_PLAYLIST.length);
};
const handleUserActivate = () => {
void applyVolume(effectiveVolume);
};
return (
<div
className={cn(
"fixed right-4 z-30 flex max-w-[min(100vw-2rem,18rem)] flex-col gap-2 border-2 border-border bg-background/95 p-3 shadow-[var(--retro-shadow-sm)] backdrop-blur-sm",
className,
)}
style={{
top: "calc(var(--app-topbar-height) + env(safe-area-inset-top, 0px) + 0.75rem)",
}}
onPointerDown={handleUserActivate}
>
<audio ref={audioRef} preload="auto" />
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<Label
htmlFor="avatar-ambient-volume"
className="font-pixel text-[8px] uppercase tracking-wide text-muted-foreground"
>
Ambient
</Label>
<p className="truncate text-[10px] text-foreground">
{currentTrack?.label ?? "Track"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
aria-label="Skip to next track"
onClick={handleSkip}
>
<SkipForward aria-hidden="true" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
aria-label={
muted || volume === 0 ? "Unmute ambient audio" : "Mute ambient audio"
}
aria-pressed={muted || volume === 0}
onClick={handleToggleMute}
>
{muted || volume === 0 ? (
<VolumeX aria-hidden="true" />
) : (
<Volume2 aria-hidden="true" />
)}
</Button>
</div>
</div>
<input
id="avatar-ambient-volume"
type="range"
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label="Ambient audio volume"
onChange={(event) => handleVolumeChange(Number(event.target.value))}
className="w-full accent-primary"
/>
<p className="text-[10px] leading-snug text-muted-foreground">
Track {trackIndex + 1} of {AVATAR_AMBIENT_PLAYLIST.length}
</p>
{loadError ? (
<p className="text-[10px] leading-snug text-red-500">
Could not load{" "}
<span className="font-medium">{currentTrack?.filename}</span>.
</p>
) : autoplayBlocked && effectiveVolume > 0 ? (
<p className="text-[10px] leading-snug text-muted-foreground">
Tap volume to start audio.
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import { SkipForward, Volume2, VolumeX, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { useAmbientAudio } from "@/components/layout/ambient-audio-provider";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { AVATAR_AMBIENT_PLAYLIST } from "@/lib/avatar-audio";
export function AmbientAudioPanel() {
const panelRef = useRef<HTMLDivElement>(null);
const {
panelOpen,
setPanelOpen,
volume,
muted,
trackIndex,
currentTrack,
autoplayBlocked,
loadError,
handleToggleMute,
handleVolumeChange,
handleSkip,
handleUserActivate,
} = useAmbientAudio();
useEffect(() => {
if (!panelOpen) {
return;
}
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (panelRef.current?.contains(target)) {
return;
}
if (
target instanceof Element &&
target.closest("[data-ambient-audio-trigger]")
) {
return;
}
setPanelOpen(false);
};
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("touchstart", handlePointerDown);
return () => {
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("touchstart", handlePointerDown);
};
}, [panelOpen, setPanelOpen]);
if (!panelOpen) {
return null;
}
return (
<div
ref={panelRef}
className="ambient-radio-window fixed z-50 w-[min(100vw-2rem,19rem)]"
style={{
top: "calc(var(--app-topbar-height) + env(safe-area-inset-top, 0px) + 0.5rem)",
right: "max(1rem, calc((100vw - min(100vw, 64rem)) / 2 + 1.25rem))",
}}
onPointerDown={handleUserActivate}
role="dialog"
aria-modal="false"
aria-label="Ambient music player"
>
<div className="ambient-radio-window-chrome">
<div className="flex items-center justify-between gap-2 border-b-2 border-border/70 px-3 py-2">
<div className="min-w-0">
<p className="font-pixel text-[8px] uppercase tracking-[0.22em] text-secondary">
Bit Tunes
</p>
<p className="truncate text-[10px] text-muted-foreground">
Ambient radio
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 rounded-none border border-border/60"
aria-label="Close music player"
onClick={() => setPanelOpen(false)}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
<div className="space-y-3 p-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<Label
htmlFor="avatar-ambient-volume"
className="font-pixel text-[8px] uppercase tracking-wide text-muted-foreground"
>
Now playing
</Label>
<p className="truncate text-sm text-foreground">
{currentTrack?.label ?? "Track"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 rounded-none border-2 shadow-[var(--retro-shadow-sm)]"
aria-label="Skip to next track"
onClick={handleSkip}
>
<SkipForward aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 rounded-none border-2 shadow-[var(--retro-shadow-sm)]"
aria-label={
muted || volume === 0
? "Unmute ambient audio"
: "Mute ambient audio"
}
aria-pressed={muted || volume === 0}
onClick={handleToggleMute}
>
{muted || volume === 0 ? (
<VolumeX aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Volume2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
<input
id="avatar-ambient-volume"
type="range"
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label="Ambient audio volume"
onChange={(event) => handleVolumeChange(Number(event.target.value))}
className="ambient-radio-slider w-full"
/>
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span>
Track {trackIndex + 1}/{AVATAR_AMBIENT_PLAYLIST.length}
</span>
<span className="ambient-radio-eq" aria-hidden="true">
<span />
<span />
<span />
</span>
</div>
{loadError ? (
<p className="text-[10px] leading-snug text-red-500">
Could not load{" "}
<span className="font-medium">{currentTrack?.filename}</span>.
</p>
) : autoplayBlocked && !muted && volume > 0 ? (
<p className="text-[10px] leading-snug text-muted-foreground">
Tap volume to start audio.
</p>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,320 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { AmbientAudioPanel } from "@/components/layout/ambient-audio-panel";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
AVATAR_AMBIENT_PLAYLIST,
getAvatarAmbientTrackSrc,
getAvatarAudioPreferences,
setAvatarAudioPreferences,
type AvatarAmbientTrack,
} from "@/lib/avatar-audio";
import { getOnboardingStatusClient } from "@/lib/onboarding-status";
import { createClient } from "@/lib/supabase/client";
type AmbientAudioContextValue = {
enabled: boolean;
panelOpen: boolean;
setPanelOpen: (open: boolean) => void;
togglePanel: () => void;
volume: number;
muted: boolean;
trackIndex: number;
currentTrack: AvatarAmbientTrack | undefined;
autoplayBlocked: boolean;
loadError: boolean;
isPlaying: boolean;
handleToggleMute: () => void;
handleVolumeChange: (volume: number) => void;
handleSkip: () => void;
handleUserActivate: () => void;
};
const AmbientAudioContext = createContext<AmbientAudioContextValue | null>(null);
export function useAmbientAudio() {
const context = useContext(AmbientAudioContext);
if (!context) {
throw new Error("useAmbientAudio must be used within AmbientAudioProvider.");
}
return context;
}
export function useAmbientAudioOptional() {
return useContext(AmbientAudioContext);
}
type AmbientAudioProviderProps = {
children: ReactNode;
};
export function AmbientAudioProvider({ children }: AmbientAudioProviderProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const volumeRef = useRef(0.6);
const mutedRef = useRef(false);
const [enabled, setEnabled] = useState(false);
const [panelOpen, setPanelOpen] = useState(false);
const [volume, setVolume] = useState(0.6);
const [muted, setMuted] = useState(false);
const [trackIndex, setTrackIndex] = useState(0);
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
const [loadError, setLoadError] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const currentTrack = AVATAR_AMBIENT_PLAYLIST[trackIndex];
const effectiveVolume = muted ? 0 : volume;
useEffect(() => {
volumeRef.current = volume;
}, [volume]);
useEffect(() => {
mutedRef.current = muted;
}, [muted]);
useEffect(() => {
const supabase = createClient();
async function syncAudioState() {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
setEnabled(false);
setPanelOpen(false);
return;
}
const status = await getOnboardingStatusClient();
setEnabled(status?.appComplete === true);
}
void syncAudioState();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
void syncAudioState();
});
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncAudioState);
return () => {
subscription.unsubscribe();
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncAudioState);
};
}, []);
useEffect(() => {
if (!enabled) {
return;
}
const preferences = getAvatarAudioPreferences();
setVolume(preferences.volume);
setMuted(preferences.muted);
volumeRef.current = preferences.volume;
mutedRef.current = preferences.muted;
}, [enabled]);
const persistPreferences = useCallback(
(nextVolume: number, nextMuted: boolean) => {
setAvatarAudioPreferences({ volume: nextVolume, muted: nextMuted });
},
[],
);
const applyVolume = useCallback(async (nextVolume: number) => {
const audio = audioRef.current;
if (!audio) {
return;
}
audio.volume = nextVolume;
if (nextVolume === 0) {
audio.pause();
setIsPlaying(false);
return;
}
if (audio.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return;
}
try {
await audio.play();
setAutoplayBlocked(false);
setIsPlaying(true);
} catch {
setAutoplayBlocked(true);
setIsPlaying(false);
}
}, []);
const loadTrack = useCallback(
(index: number) => {
const audio = audioRef.current;
const track = AVATAR_AMBIENT_PLAYLIST[index];
if (!audio || !track || !enabled) {
return;
}
setLoadError(false);
audio.src = getAvatarAmbientTrackSrc(track.filename);
const handleCanPlay = () => {
const nextVolume = mutedRef.current ? 0 : volumeRef.current;
void applyVolume(nextVolume);
};
const handleError = () => {
setLoadError(true);
setIsPlaying(false);
};
audio.addEventListener("canplay", handleCanPlay, { once: true });
audio.addEventListener("error", handleError, { once: true });
audio.load();
},
[applyVolume, enabled],
);
useEffect(() => {
if (!enabled) {
return;
}
loadTrack(trackIndex);
}, [enabled, loadTrack, trackIndex]);
useEffect(() => {
if (!enabled) {
return;
}
void applyVolume(effectiveVolume);
}, [applyVolume, effectiveVolume, enabled]);
useEffect(() => {
if (!enabled) {
return;
}
persistPreferences(volume, muted);
}, [enabled, volume, muted, persistPreferences]);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !enabled) {
return;
}
const handleEnded = () => {
setTrackIndex((current) => (current + 1) % AVATAR_AMBIENT_PLAYLIST.length);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
audio.addEventListener("ended", handleEnded);
audio.addEventListener("play", handlePlay);
audio.addEventListener("pause", handlePause);
return () => {
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("play", handlePlay);
audio.removeEventListener("pause", handlePause);
audio.pause();
};
}, [enabled]);
useEffect(() => {
if (!panelOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setPanelOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [panelOpen]);
const handleToggleMute = () => {
setMuted((current) => !current);
};
const handleVolumeChange = (nextVolume: number) => {
setVolume(nextVolume);
if (nextVolume > 0 && muted) {
setMuted(false);
}
if (nextVolume === 0) {
setMuted(true);
}
};
const handleSkip = () => {
setTrackIndex((current) => (current + 1) % AVATAR_AMBIENT_PLAYLIST.length);
};
const handleUserActivate = () => {
void applyVolume(effectiveVolume);
};
const togglePanel = () => {
setPanelOpen((current) => !current);
};
const contextValue: AmbientAudioContextValue = {
enabled,
panelOpen,
setPanelOpen,
togglePanel,
volume,
muted,
trackIndex,
currentTrack,
autoplayBlocked,
loadError,
isPlaying,
handleToggleMute,
handleVolumeChange,
handleSkip,
handleUserActivate,
};
return (
<AmbientAudioContext.Provider value={contextValue}>
{children}
{enabled ? (
<>
<audio ref={audioRef} preload="auto" className="hidden" />
<AmbientAudioPanel />
</>
) : null}
</AmbientAudioContext.Provider>
);
}

View File

@@ -1,51 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { AvatarAmbientAudio } from "@/components/avatar/avatar-ambient-audio";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getOnboardingStatusClient } from "@/lib/onboarding-status";
import { createClient } from "@/lib/supabase/client";
export function GlobalAmbientAudio() {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const supabase = createClient();
async function syncAudioState() {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
setEnabled(false);
return;
}
const status = await getOnboardingStatusClient();
setEnabled(status?.appComplete === true);
}
void syncAudioState();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
void syncAudioState();
});
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncAudioState);
return () => {
subscription.unsubscribe();
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncAudioState);
};
}, []);
if (!enabled) {
return null;
}
return <AvatarAmbientAudio />;
}

View File

@@ -0,0 +1,42 @@
"use client";
import { Music2 } from "lucide-react";
import { useAmbientAudioOptional } from "@/components/layout/ambient-audio-provider";
import { cn } from "@/lib/utils";
export function HeaderMusicButton() {
const ambient = useAmbientAudioOptional();
if (!ambient?.enabled) {
return null;
}
const { panelOpen, togglePanel, isPlaying, muted, volume } = ambient;
const active = isPlaying && !muted && volume > 0;
return (
<button
type="button"
data-ambient-audio-trigger
aria-label={panelOpen ? "Close music player" : "Open music player"}
aria-expanded={panelOpen}
aria-pressed={panelOpen}
onClick={togglePanel}
className={cn(
"inline-flex h-10 w-10 items-center justify-center border-2 bg-background/80 shadow-[var(--retro-shadow-sm)] transition-transform",
panelOpen
? "border-secondary bg-secondary/15 text-secondary"
: "border-border/70 text-foreground hover:border-secondary/50 hover:bg-secondary/10",
active && !panelOpen && "ambient-radio-nav-active",
)}
>
<span className="relative flex items-center justify-center">
<Music2 aria-hidden="true" className="size-4" />
{active ? (
<span className="ambient-radio-nav-dot" aria-hidden="true" />
) : null}
</span>
</button>
);
}

View File

@@ -4,6 +4,7 @@ import { AuthButton } from "@/components/auth-button";
import { EnvVarWarning } from "@/components/env-var-warning";
import { HabitBrand } from "@/components/layout/habit-brand";
import { HeaderCoinBalance } from "@/components/layout/header-coin-balance";
import { HeaderMusicButton } from "@/components/layout/header-music-button";
import { routes } from "@/lib/routes";
import { hasEnvVars } from "@/lib/utils";
@@ -26,6 +27,7 @@ export function SiteHeader() {
<Suspense fallback={null}>
<HeaderCoinBalance />
</Suspense>
<HeaderMusicButton />
<Suspense fallback={null}>
<AuthButton />
</Suspense>