The volume display read 0% always, because the level never reached a
client. Three holes, each sufficient on its own:
- `Init` hardcoded `volume: 0.0`, `mute: false` and a zeroed position.
- `ChangeVolume` broadcast nothing, so no client learned the level had
changed and the display could not recover from that init value. The web
slider was equally silent and never tracked J/K.
- `PlaybackCommand::VolumeChanged`/`MuteChanged` are handled but sent by
nobody — a broadcast path that looks real and does nothing, which is
presumably how the above went unnoticed. Left alone here.
`ChangeVolume` now broadcasts the level the engine actually took, so
clients see the 1.1 clamp rather than what they asked for, plus
`Mute(false)`, since `set_volume` unmutes and the indicator would
otherwise stick.
The hardcoding had a cause: the init response is built while holding the
queue's std Mutex guard, which cannot be held across an await. The player
reads now happen before the lock is taken, each with a 1 s budget —
`Init` is the connect path, and the engine can be 30 s deep in opening a
stream, so a client must get a usable snapshot rather than hang.
Fixing the init position also fixes click-to-seek against a paused
server: no position ticks flow while paused, so the web client sent
`target - 0` and the engine added it to the real position.
Adds `Player::is_muted`, since only `toggle_mute` existed and that cannot
ask without changing the state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>