diff --git a/app/globals.css b/app/globals.css index c663222..3c5e690 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index e906af4..243618a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 > - -
{children}
- - + + +
{children}
+ +
diff --git a/components/avatar/avatar-ambient-audio.tsx b/components/avatar/avatar-ambient-audio.tsx deleted file mode 100644 index 19cdfb6..0000000 --- a/components/avatar/avatar-ambient-audio.tsx +++ /dev/null @@ -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(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 ( -
-
- ); -} diff --git a/components/layout/ambient-audio-panel.tsx b/components/layout/ambient-audio-panel.tsx new file mode 100644 index 0000000..a3fcda7 --- /dev/null +++ b/components/layout/ambient-audio-panel.tsx @@ -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(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 ( +
+
+
+
+

+ Bit Tunes +

+

+ Ambient radio +

+
+ +
+ +
+
+
+ +

+ {currentTrack?.label ?? "Track"} +

+
+
+ + +
+
+ + handleVolumeChange(Number(event.target.value))} + className="ambient-radio-slider w-full" + /> + +
+ + Track {trackIndex + 1}/{AVATAR_AMBIENT_PLAYLIST.length} + +
+ + {loadError ? ( +

+ Could not load{" "} + {currentTrack?.filename}. +

+ ) : autoplayBlocked && !muted && volume > 0 ? ( +

+ Tap volume to start audio. +

+ ) : null} +
+
+
+ ); +} diff --git a/components/layout/ambient-audio-provider.tsx b/components/layout/ambient-audio-provider.tsx new file mode 100644 index 0000000..6750842 --- /dev/null +++ b/components/layout/ambient-audio-provider.tsx @@ -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(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(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 ( + + {children} + {enabled ? ( + <> + + ); +} diff --git a/components/layout/global-ambient-audio.tsx b/components/layout/global-ambient-audio.tsx deleted file mode 100644 index ea88d16..0000000 --- a/components/layout/global-ambient-audio.tsx +++ /dev/null @@ -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 ; -} diff --git a/components/layout/header-music-button.tsx b/components/layout/header-music-button.tsx new file mode 100644 index 0000000..f2ae7ac --- /dev/null +++ b/components/layout/header-music-button.tsx @@ -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 ( + + ); +} diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx index 35c282a..52304d3 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -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() { +