diff --git a/app/layout.tsx b/app/layout.tsx index 8ccb9dd..e906af4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +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 { GlobalBottomNav } from "@/components/layout/global-bottom-nav"; import { SiteHeader } from "@/components/layout/site-header"; import { ToastProvider } from "@/components/ui/toast-provider"; @@ -55,6 +56,7 @@ export default function RootLayout({
{children}
+
diff --git a/components/avatar/avatar-ambient-audio.tsx b/components/avatar/avatar-ambient-audio.tsx new file mode 100644 index 0000000..19cdfb6 --- /dev/null +++ b/components/avatar/avatar-ambient-audio.tsx @@ -0,0 +1,249 @@ +"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/global-ambient-audio.tsx b/components/layout/global-ambient-audio.tsx new file mode 100644 index 0000000..ea88d16 --- /dev/null +++ b/components/layout/global-ambient-audio.tsx @@ -0,0 +1,51 @@ +"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/lib/avatar-audio.ts b/lib/avatar-audio.ts new file mode 100644 index 0000000..c7d5fc6 --- /dev/null +++ b/lib/avatar-audio.ts @@ -0,0 +1,74 @@ +export type AvatarAmbientTrack = { + filename: string; + label: string; +}; + +export const AVATAR_AMBIENT_PLAYLIST: readonly AvatarAmbientTrack[] = [ + { filename: "Pixel Meadow.mp3", label: "Pixel Meadow" }, + { filename: "Pixel Meadow faster.mp3", label: "Pixel Meadow (fast)" }, +] as const; + +export function getAvatarAmbientTrackSrc(filename: string) { + return `/audio/${encodeURIComponent(filename)}`; +} + +const STORAGE_KEY = "habit-bit-avatar-audio"; + +export type AvatarAudioPreferences = { + volume: number; + muted: boolean; +}; + +const DEFAULT_PREFERENCES: AvatarAudioPreferences = { + volume: 0.6, + muted: false, +}; + +function clampVolume(value: number) { + return Math.min(1, Math.max(0, value)); +} + +export function getAvatarAudioPreferences(): AvatarAudioPreferences { + if (typeof window === "undefined") { + return DEFAULT_PREFERENCES; + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + + if (!raw) { + return DEFAULT_PREFERENCES; + } + + const parsed = JSON.parse(raw) as Partial; + + return { + volume: + typeof parsed.volume === "number" + ? clampVolume(parsed.volume) + : DEFAULT_PREFERENCES.volume, + muted: + typeof parsed.muted === "boolean" + ? parsed.muted + : DEFAULT_PREFERENCES.muted, + }; + } catch { + return DEFAULT_PREFERENCES; + } +} + +export function setAvatarAudioPreferences( + preferences: AvatarAudioPreferences, +) { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + volume: clampVolume(preferences.volume), + muted: preferences.muted, + }), + ); +} diff --git a/public/audio/.gitkeep b/public/audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/audio/Pixel Meadow faster.mp3 b/public/audio/Pixel Meadow faster.mp3 new file mode 100644 index 0000000..869d4e3 Binary files /dev/null and b/public/audio/Pixel Meadow faster.mp3 differ diff --git a/public/audio/Pixel Meadow.mp3 b/public/audio/Pixel Meadow.mp3 new file mode 100644 index 0000000..791e631 Binary files /dev/null and b/public/audio/Pixel Meadow.mp3 differ