feat(midsummer): WebAudio Sma grodorna jingle, plays once on first gesture

This commit is contained in:
Erik 2026-06-19 09:30:00 +02:00
parent e896ef1f21
commit 3cd2165c15
3 changed files with 68 additions and 2 deletions

View file

@ -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.

View file

@ -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<Props> = ({ data }) => {
const getColor = usePlayerColors();
useMidsummerSound();
const [showHeatmap, setShowHeatmap] = useState(false);
const [showPortals, setShowPortals] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<string | null>(null);

View file

@ -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]);
}