Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion components/ResultView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -125,6 +146,15 @@ export default function ResultView({
>
how it works ↗
</button>
<button
type="button"
onClick={toggleMuted}
aria-label={muted ? "Unmute reveal sound" : "Mute reveal sound"}
aria-pressed={muted}
className="inline-flex size-[30px] shrink-0 items-center justify-center rounded-full border border-line bg-bg-deep/55 text-ink-soft backdrop-blur-md transition hover:border-ink-mute hover:bg-bg-deep/80 hover:text-ink"
>
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
</button>
<GithubStar stars={stars ?? null} />
</div>
</div>
Expand Down
73 changes: 73 additions & 0 deletions lib/sound.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
57 changes: 57 additions & 0 deletions tests/sound.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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();
});
});