Lobby: MP3 dice sound with pitch per die, DM sound settings

Default dice sound is public/dice.mp3 with a small random pitch and a
base pitch per die size (d4 lighter ... d100 heavier). The DM can upload
an own dice sound and sounds for critical success / failure per lobby,
reset them, and switch result and critical sounds on or off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 19:04:12 +00:00
parent ce24a0ba6f
commit 03ebc3a138
7 changed files with 226 additions and 62 deletions
+58 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll } from './sound.js'
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
@@ -303,7 +303,7 @@ function DieIcon({ sides }) {
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy, sfx }) {
const [snd, setSnd] = useState(sound.enabled)
const [vol, setVol] = useState(sound.volume)
return (
@@ -322,8 +322,8 @@ function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, on
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
</label>
<span className="soundctl small" title="Dice sounds">
<button className="ghost" aria-pressed={snd} aria-label={snd ? 'Mute dice sounds' : 'Turn dice sounds on'} onClick={() => { sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(1, 'success') }}>{snd ? '🔊' : '🔇'}</button>
{snd && <input type="range" min="0" max="1" step="0.05" value={vol} aria-label="Dice sound volume" onChange={(e) => { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(1)} />}
<button className="ghost" aria-pressed={snd} aria-label={snd ? 'Mute dice sounds' : 'Turn dice sounds on'} onClick={() => { sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(20, 1, undefined, sfx) }}>{snd ? '🔊' : '🔇'}</button>
{snd && <input type="range" min="0" max="1" step="0.05" value={vol} aria-label="Dice sound volume" onChange={(e) => { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(20, 1, undefined, sfx)} />}
</span>
{isDm && !to && (
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
@@ -498,6 +498,49 @@ function CharTab({ cid, rules, onRoll, tick }) {
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
// DM: replace the dice sound, add sounds for critical results, switch cues on or off
function SoundSettings({ lobby, id, act, sfx }) {
const sn = lobby.sounds
const [err, setErr] = useState('')
const upload = async (slot, e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr('')
const r = await fetch(`/api/lobbies/${id}/sounds/${slot}`, { method: 'POST', headers: { 'Content-Type': 'audio/mpeg' }, body: f }).catch(() => null)
if (!r || !r.ok) return setErr(r ? ((await r.json().catch(() => ({}))).detail || 'Upload failed') : 'Upload failed')
act()
}
const flag = (k, v) => act(`/api/lobbies/${id}/sounds`, 'PUT', { [k]: v })
const row = (slot, label, defaultText, flagKey) => (
<div className="soundrow">
<div className="row">
{flagKey && <label className="small chk"><input type="checkbox" checked={!!sn[flagKey]} onChange={(e) => flag(flagKey, e.target.checked)} /> {label}</label>}
{!flagKey && <strong className="small">{label}</strong>}
</div>
<div className="row small">
<span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span>
<span className="row">
<button className="ghost" onClick={() => playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true })}>▶ Test</button>
<label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label>
{sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>}
</span>
</div>
</div>
)
return (
<div className="soundsettings">
<h4>Sounds</h4>
<p className="small muted">For everyone in this lobby. MP3 up to 1.5 MB; players can still mute the sounds for themselves.</p>
{row('roll', 'Dice sound', 'Default sound', null)}
<label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label>
{row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')}
{row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')}
{err && <p className="warn small">{err}</p>}
</div>
)
}
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
const [rename, setRename] = useState('')
const [pick, setPick] = useState('')
@@ -604,10 +647,17 @@ function LobbyView({ id, rules, user }) {
return () => clearInterval(t)
}, [load, loadMap, up])
const sfx = useRef({})
const sn = lobby?.sounds
sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL,
crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null,
crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null,
result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL }
useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line
const onRoll = useCallback((m) => {
const key = `${m.id}`
if (m.kind === 'check') playRoll(1, m.check.outcome)
else if (m.kind === 'roll') playRoll(m.roll.n)
if (m.kind === 'check') playRoll(100, 1, m.check.outcome, sfx.current)
else if (m.kind === 'roll') playRoll(m.roll.sides, m.roll.n, undefined, sfx.current)
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
@@ -695,7 +745,7 @@ function LobbyView({ id, rules, user }) {
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
</div>
{lobby.is_dm && leftTab === 'lobby'
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
</div>
@@ -737,7 +787,7 @@ function LobbyView({ id, rules, user }) {
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
</div>
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} sfx={sfx.current} />
</div>
<aside className="lobbychat card" aria-label="Chat">