diff --git a/frontend/src/components/PlayerDashboardFullPage.tsx b/frontend/src/components/PlayerDashboardFullPage.tsx index c7ef7e21..5efe3c46 100644 --- a/frontend/src/components/PlayerDashboardFullPage.tsx +++ b/frontend/src/components/PlayerDashboardFullPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useLiveData } from '../hooks/useLiveData'; +import { useMidsummerSound } from '../hooks/useMidsummerSound'; import { PlayerDashboardContent } from './windows/PlayerDashboardWindow'; import { MidsummerBanner } from './midsummer/MidsummerBanner'; @@ -15,6 +16,7 @@ import { MidsummerBanner } from './midsummer/MidsummerBanner'; */ export const PlayerDashboardFullPage: React.FC = () => { const data = useLiveData(); + useMidsummerSound(); const [version, setVersion] = useState(''); // Set tab title. diff --git a/frontend/src/components/map/MapLayout.tsx b/frontend/src/components/map/MapLayout.tsx index 800b665c..7625aeb5 100644 --- a/frontend/src/components/map/MapLayout.tsx +++ b/frontend/src/components/map/MapLayout.tsx @@ -7,6 +7,7 @@ import { WindowRenderer } from '../windows/WindowRenderer'; import { RareNotification } from '../effects/RareNotification'; import { DeathNotification } from '../effects/DeathNotification'; import { usePlayerColors } from '../../hooks/usePlayerColors'; +import { useMidsummerSound } from '../../hooks/useMidsummerSound'; import { MidsummerBanner } from '../midsummer/MidsummerBanner'; import type { DashboardState } from '../../hooks/useLiveData'; @@ -16,6 +17,7 @@ interface Props { export const MapLayout: React.FC = ({ data }) => { const getColor = usePlayerColors(); + useMidsummerSound(); const [showHeatmap, setShowHeatmap] = useState(false); const [showPortals, setShowPortals] = useState(false); const [selectedPlayer, setSelectedPlayer] = useState(null); diff --git a/frontend/src/hooks/useMidsummerSound.ts b/frontend/src/hooks/useMidsummerSound.ts index 6e4ffe30..603dbdaa 100644 --- a/frontend/src/hooks/useMidsummerSound.ts +++ b/frontend/src/hooks/useMidsummerSound.ts @@ -1,2 +1,64 @@ -// Stub — replaced with the full WebAudio synth in Task 8. -export function playSmaGrodorna(): void {} +import { useEffect } from 'react'; +import { useMidsummer } from './useMidsummer'; + +const JINGLE_FLAG = 'mo-midsummer-jingle'; + +let ctx: AudioContext | null = null; + +// Public-domain "Små grodorna" opening phrase (approximation), as +// [frequencyHz, durationSeconds]. Cheerful major-key triangle tones. +const MELODY: [number, number][] = [ + [392, 0.22], [392, 0.22], [392, 0.22], [440, 0.22], [494, 0.42], + [494, 0.22], [440, 0.22], [494, 0.22], [523, 0.22], [587, 0.5], +]; + +/** Play the jingle once. Safe to call from any user gesture. No-op if + * WebAudio is unavailable. Reuses a single AudioContext (no leak). */ +export function playSmaGrodorna(): void { + try { + const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + if (!AC) return; + if (!ctx) ctx = new AC(); + if (ctx.state === 'suspended') void ctx.resume(); + let t = ctx.currentTime + 0.05; + for (const [freq, dur] of MELODY) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.0001, t); + gain.gain.exponentialRampToValueAtTime(0.18, t + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); + osc.connect(gain).connect(ctx.destination); + osc.start(t); + osc.stop(t + dur); + t += dur; + } + } catch { + /* audio not available — ignore */ + } +} + +/** Plays the jingle once per session, on the first user gesture, when the + * theme and sound are both on. Browsers block audio before a gesture, so + * we wait for the first pointerdown/keydown. */ +export function useMidsummerSound(): void { + const { enabled, soundOn } = useMidsummer(); + useEffect(() => { + if (!enabled || !soundOn) return; + if (sessionStorage.getItem(JINGLE_FLAG)) return; + + const fire = () => { + sessionStorage.setItem(JINGLE_FLAG, '1'); + playSmaGrodorna(); + cleanup(); + }; + const cleanup = () => { + window.removeEventListener('pointerdown', fire); + window.removeEventListener('keydown', fire); + }; + window.addEventListener('pointerdown', fire); + window.addEventListener('keydown', fire); + return cleanup; + }, [enabled, soundOn]); +}