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:
@@ -275,6 +275,43 @@
|
|||||||
transparent 72%
|
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 {
|
@keyframes emotion-pop {
|
||||||
@@ -333,3 +370,25 @@
|
|||||||
.animate-star-twinkle {
|
.animate-star-twinkle {
|
||||||
animation: star-twinkle 3s steps(2, end) infinite;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import { Press_Start_2P, Share_Tech_Mono } from "next/font/google";
|
import { Press_Start_2P, Share_Tech_Mono } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
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 { GlobalBottomNav } from "@/components/layout/global-bottom-nav";
|
||||||
import { SiteHeader } from "@/components/layout/site-header";
|
import { SiteHeader } from "@/components/layout/site-header";
|
||||||
import { ToastProvider } from "@/components/ui/toast-provider";
|
import { ToastProvider } from "@/components/ui/toast-provider";
|
||||||
@@ -54,10 +54,11 @@ export default function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<SiteHeader />
|
<AmbientAudioProvider>
|
||||||
<div className="flex min-h-dvh flex-col pt-topbar">{children}</div>
|
<SiteHeader />
|
||||||
<GlobalAmbientAudio />
|
<div className="flex min-h-dvh flex-col pt-topbar">{children}</div>
|
||||||
<GlobalBottomNav />
|
<GlobalBottomNav />
|
||||||
|
</AmbientAudioProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
185
components/layout/ambient-audio-panel.tsx
Normal file
185
components/layout/ambient-audio-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
components/layout/ambient-audio-provider.tsx
Normal file
320
components/layout/ambient-audio-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
|
||||||
}
|
|
||||||
42
components/layout/header-music-button.tsx
Normal file
42
components/layout/header-music-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { AuthButton } from "@/components/auth-button";
|
|||||||
import { EnvVarWarning } from "@/components/env-var-warning";
|
import { EnvVarWarning } from "@/components/env-var-warning";
|
||||||
import { HabitBrand } from "@/components/layout/habit-brand";
|
import { HabitBrand } from "@/components/layout/habit-brand";
|
||||||
import { HeaderCoinBalance } from "@/components/layout/header-coin-balance";
|
import { HeaderCoinBalance } from "@/components/layout/header-coin-balance";
|
||||||
|
import { HeaderMusicButton } from "@/components/layout/header-music-button";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
import { hasEnvVars } from "@/lib/utils";
|
import { hasEnvVars } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export function SiteHeader() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<HeaderCoinBalance />
|
<HeaderCoinBalance />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<HeaderMusicButton />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AuthButton />
|
<AuthButton />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
Reference in New Issue
Block a user