diff --git a/components/ResultView.tsx b/components/ResultView.tsx index c2aeaa6..078ed12 100644 --- a/components/ResultView.tsx +++ b/components/ResultView.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, Volume2, VolumeX } from "lucide-react"; import type { Card } from "@/lib/scoring/types"; import PlayerCard from "./PlayerCard"; import StoryFrame from "./StoryFrame"; @@ -19,6 +19,7 @@ import DistributionPanel from "./DistributionPanel"; import { confettiPalette, resolveResultTheme } from "./finishTheme"; import { useReveal } from "@/hooks/useReveal"; import { burstConfetti } from "@/lib/confetti"; +import { isMuted, playRevealChime, setMuted } from "@/lib/sound"; const HowItWorksModal = dynamic(() => import("./HowItWorksModal"), { ssr: false }); @@ -70,6 +71,26 @@ export default function ResultView({ if (phase === "burst") burstConfetti(confettiPalette(card)); }, [phase, card]); + // Reveal chime: a small "pop" on ignite for common tiers, a brighter arpeggio + // on burst for rare ones (same tier split as the confetti above). + useEffect(() => { + if (phase === "ignite") playRevealChime(card.finish, false); + if (phase === "burst") playRevealChime(card.finish, true); + }, [phase, card]); + + const [muted, setMutedState] = useState(false); + useEffect(() => { + const t = setTimeout(() => setMutedState(isMuted()), 0); + return () => clearTimeout(t); + }, []); + const toggleMuted = () => { + setMutedState((prev) => { + const next = !prev; + setMuted(next); + return next; + }); + }; + const ignited = phase === "ignite" || phase === "burst" || phase === "freeze"; return ( @@ -125,6 +146,15 @@ export default function ResultView({ > how it works ↗ + diff --git a/lib/sound.ts b/lib/sound.ts new file mode 100644 index 0000000..81f4253 --- /dev/null +++ b/lib/sound.ts @@ -0,0 +1,73 @@ +import type { Finish } from "@/lib/scoring/types"; + +// Synthesized reveal chimes — dependency-free, same philosophy as lib/confetti.ts +// (no bundled audio assets, no audio library). A single shared AudioContext is +// reused across calls; oscillator nodes are scheduled and torn down per chime. + +const MUTE_KEY = "gitfut:muted"; + +let ctx: AudioContext | null = null; + +function getContext(): AudioContext | null { + if (typeof window === "undefined") return null; + const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return null; + try { + if (!ctx) ctx = new Ctor(); + if (ctx.state === "suspended") void ctx.resume(); + return ctx; + } catch { + return null; + } +} + +export function isMuted(): boolean { + try { + return localStorage.getItem(MUTE_KEY) === "1"; + } catch { + return false; + } +} + +export function setMuted(muted: boolean): void { + try { + localStorage.setItem(MUTE_KEY, muted ? "1" : "0"); + } catch {} +} + +// Plays one note: a short envelope on a single oscillator, starting `delay` +// seconds from now. +function playNote(audio: AudioContext, freq: number, delay: number, duration: number, type: OscillatorType, peak: number): void { + const osc = audio.createOscillator(); + const gain = audio.createGain(); + osc.type = type; + osc.frequency.value = freq; + const t0 = audio.currentTime + delay; + gain.gain.setValueAtTime(0, t0); + gain.gain.linearRampToValueAtTime(peak, t0 + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration); + osc.connect(gain); + gain.connect(audio.destination); + osc.start(t0); + osc.stop(t0 + duration + 0.02); +} + +// Rare tiers (totw/toty/icon/founder) get a brighter ascending arpeggio; every +// other tier gets a single short "pop" — see lib/reveal.ts for which finishes +// reach the "burst" phase. +export function playRevealChime(finish: Finish, burst: boolean): void { + if (isMuted()) return; + const audio = getContext(); + if (!audio) return; + + try { + if (burst) { + const notes = [523.25, 659.25, 783.99]; // C5, E5, G5 + notes.forEach((freq, i) => playNote(audio, freq, i * 0.09, 0.35, "triangle", 0.09)); + } else { + playNote(audio, 440, 0, 0.15, "triangle", 0.06); + } + } catch { + // Autoplay/policy restrictions or a torn-down context — never break the reveal. + } +} diff --git a/tests/sound.test.ts b/tests/sound.test.ts new file mode 100644 index 0000000..f919e24 --- /dev/null +++ b/tests/sound.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { isMuted, playRevealChime, setMuted } from "@/lib/sound"; + +// The test environment is plain node (see vitest.config.ts) — no DOM, so no +// global `localStorage`. A minimal in-memory stand-in is enough to exercise +// the isMuted/setMuted read-write path the same way a browser would. +class MemoryStorage implements Storage { + private store = new Map(); + get length() { + return this.store.size; + } + clear(): void { + this.store.clear(); + } + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + removeItem(key: string): void { + this.store.delete(key); + } + setItem(key: string, value: string): void { + this.store.set(key, value); + } +} +globalThis.localStorage = new MemoryStorage(); + +describe("mute preference", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults to unmuted", () => { + expect(isMuted()).toBe(false); + }); + + it("round-trips through setMuted/isMuted", () => { + setMuted(true); + expect(isMuted()).toBe(true); + setMuted(false); + expect(isMuted()).toBe(false); + }); +}); + +describe("playRevealChime", () => { + it("does not throw without a browser AudioContext (node test environment)", () => { + expect(() => playRevealChime("bronze", false)).not.toThrow(); + expect(() => playRevealChime("icon", true)).not.toThrow(); + }); + + it("no-ops silently when muted", () => { + setMuted(true); + expect(() => playRevealChime("toty", true)).not.toThrow(); + }); +});