Audio on by default, per-game volume, and fix real navigation + latency bugs

- Removed the "Enable Sound" button: AudioContext/worklet/WebSocket now
  connect eagerly on page load; only ctx.resume() still needs a user
  gesture, piggybacking on the page's first click/keypress (e.g.
  clicking Start) instead of a dedicated audio-only button.

- Added a per-game volume slider (POST /volume). Despite audio being
  one shared PulseAudio mix, this is a real independent control: every
  game is still its own distinct sink-input, found by matching
  `pactl -f json list sink-inputs`'s application.process.id against
  the game's own PID, then `pactl set-sink-input-volume`.

- Real bug: audio still never played after removing the button, because
  Install/Start/Stop/Uninstall were still <form method="post"> submits.
  Every click caused a full page navigation (303 redirect), tearing
  down whatever AudioContext had just connected; the fresh page after
  reload creates a new suspended context with no further gesture to
  unlock it. Tell: no speaker icon ever appeared on the Chrome tab.
  Fixed by removing <form>s entirely - every button is now
  onclick="doAction(...)", do_POST returns a plain 204, and client-side
  doAction()/refresh() fetch() the action and the updated page, then
  swap only #content's innerHTML. The page itself never navigates, so
  the audio connection survives every action. setInterval(refresh,
  3000) replaces the old <meta refresh> for keeping status current
  without that risk.

- Real bug: once audio worked, ~2s of latency that got worse over time
  plus multi-second delay before volume changes were audible - fixed
  upstream in docker-common's pcm-worklet.js (uncapped playback queue),
  propagated here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
This commit is contained in:
2026-07-28 19:15:51 +02:00
co-authored by Claude Sonnet 5
parent eb63f46978
commit 588086e82c
4 changed files with 203 additions and 40 deletions
+6 -4
View File
@@ -51,14 +51,14 @@ There is **no single shared desktop/display for video** — unlike the other noV
**Game installation & lifecycle** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects:
- A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) — the *only* thing `server.sh` starts. No Xvnc/fluxbox/websockify run until a game is actually started.
- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `<name>.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`release_date`/`publisher`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `<name>.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is a full path relative to the share root (e.g. `Games/Monkey Island/The Secret of Monkey Island.zip`), **not** assumed to live next to its manifest — manifests always live in the `Games\dosbox\` catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g. `Games\Monkey Island\`, `Games\Indiana Jones\`). `release_date`/`publisher` are static declared metadata (historical facts, can't be derived from the share); install size deliberately is **not** a manifest field — `get_zip_sizes()` looks it up live via a batched `smbclient ls` (one `ls "<path>"` per game in a single connection, results split on each command's `N blocks ... available` trailer) so it can't go stale if a zip is replaced.
- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top.
- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top. **None of the action buttons are `<form>`s** — every one is a bare `<button onclick="doAction('<action>','<name>')">`; `doAction()` (client-side JS in the page itself) `fetch()`s the action and then calls `refresh()`, which re-`fetch()`es `/` and replaces `#content`'s `innerHTML` in place. The page itself never navigates. This matters a lot — see Audio below for why a real `<form>` submit here was an actual bug, not just a style choice.
- `POST /install`: `smbget -au` (guest auth) the manifest's `zip_path` into a `/tmp/setup-<name>` scratch dir, then `unzip -o -d ${GAMES_HOME}`. `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download `cd`s into the scratch dir and lets `smbget` save under its default filename instead. Installed-ness is just "non-empty `${GAMES_HOME}/<name>` directory exists"; a zip is expected to contain one top-level folder matching its own basename (this happens to already match ScummVM's own game-target IDs for the SCUMM titles, e.g. `monkey2.zip``monkey2/` — convenient, not enforced by the code).
- `POST /uninstall`: `rm -rf ${GAMES_HOME}/<name>`. Refused while the game is running (stop it first) — there's no auto-stop-then-delete.
- **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}``80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"})`. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`.
- `POST /stop`: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each `terminate()`d then escalated to `kill()` after a 5s grace period, and cleans up the stale-lock files for that display.
- `is_running(name)` does the same full teardown **lazily** if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop. `render_page()` calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling.
- Each running game's row shows its own "Open Screen" link (only while `Running`) — a plain top-level link straight to that game's own noVNC URL (`https://<host>:<novnc_port>/`, opens in a new tab). Deliberately **not** an iframe — see Audio below for why.
- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page auto-refreshes every 3s **only** while an install is actively in flight — deliberately *not* also while a game is running (unlike earlier in this project's history), since this page now also hosts the persistent audio toggle (see below) and a full-page refresh would kill that connection. One consequence: a game that crashes/exits on its own won't show its slot as freed until the next manual reload (`is_running()`'s lazy cleanup still runs on that next load, just not automatically every 3s anymore).
- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page keeps itself current via `setInterval(refresh, 3000)` (client-side JS, not a server-driven `<meta http-equiv="refresh">` — that would navigate the page and kill the persistent audio connection, see Audio below), so a crashed/exited game's slot shows as freed within a few seconds without ever reloading the page.
- **Manifests on the share today**: `stuntcar` (DOSBox), `monkey`/`monkey2`/`atlantis`/`indy3`/`tentacle` (ScummVM). `t7g` still has no manifest and won't appear on the setup page until one's added.
**Playing a game — two overlapping paths right now**:
@@ -68,9 +68,11 @@ There is **no single shared desktop/display for video** — unlike the other noV
**Audio** — VNC/noVNC only ever streams video, so game sound needs a completely separate path to the browser:
- `/etc/asound.conf` (`pcm.!default pulse` / `ctl.!default pulse`, set at build time) routes ALSA's default device through PulseAudio, so `dosbox`/`scummvm` need zero special configuration — confirmed via `pactl list sink-inputs` that a running game shows up as a normal, unmuted, uncorked PulseAudio client with no extra flags.
- `server.sh` starts `pulseaudio --start --exit-idle-time=-1` once, then `${SCRIPTS_HOME}/pcm_ws_bridge.py ${AUDIO_PORT} parec --device=@DEFAULT_MONITOR@ --format=s16le --rate=48000 --channels=2 --raw --latency-msec=20` (backgrounded) before `exec`-ing `setup_server.py`. **`@DEFAULT_MONITOR@` is the correct macro** — `@DEFAULT_SINK@.monitor` (concatenating the sink macro with a literal `.monitor` suffix) looks like it should work but fails with `Stream error: Invalid argument`; this cost real debugging time, don't reintroduce it.
- `pcm_ws_bridge.py` and `pcm-worklet.js` are copies from `docker-common/scripts/` (same copy-not-symlink convention as `signals.sh`) — genuinely reusable, nothing dosbox-specific in either file. `pcm_ws_bridge.py` is a small hand-rolled stdlib-only WebSocket server (handshake via `socket`/`hashlib`/`base64`, manual binary frame writing) since no `websocat`-equivalent package exists in this Debian release and adding a pip dependency would break `setup_server.py`'s stdlib-only philosophy; it just runs an arbitrary command and relays its stdout to every connected client as binary frames, one-directional (server → browser) by design. `pcm-worklet.js` is the matching `AudioWorkletProcessor`: converts incoming Int16 PCM to Float32 and plays it through a small ring buffer that absorbs network jitter.
- `pcm_ws_bridge.py` and `pcm-worklet.js` are copies from `docker-common/scripts/` (same copy-not-symlink convention as `signals.sh`) — genuinely reusable, nothing dosbox-specific in either file. `pcm_ws_bridge.py` is a small hand-rolled stdlib-only WebSocket server (handshake via `socket`/`hashlib`/`base64`, manual binary frame writing) since no `websocat`-equivalent package exists in this Debian release and adding a pip dependency would break `setup_server.py`'s stdlib-only philosophy; it just runs an arbitrary command and relays its stdout to every connected client as binary frames, one-directional (server → browser) by design. `pcm-worklet.js` is the matching `AudioWorkletProcessor`: converts incoming Int16 PCM to Float32 and plays it through a small ring buffer that absorbs network jitter**capped at ~100ms, dropping the oldest excess on overflow, which is load-bearing** (see `docker-common/CLAUDE.md` for why: an uncapped queue was the real cause of ~2s of latency that got worse over time and made volume changes take seconds to be heard).
- **Audio is a single shared mix, not per-game** — every running game's audio ends up mixed into PulseAudio's one default sink (normal PulseAudio behavior, multiple clients to one sink just mix), and there is exactly one bridge process/port (`AUDIO_PORT`) for the container's entire lifetime, independent of any individual game's start/stop. This was a deliberate simplification from an earlier per-slot-isolated design (one sink/bridge/port per game slot, mirroring the video architecture) that was designed and then dropped as unnecessary complexity — the trade-off is that with more than one game running you can't tell their audio apart.
- **The "Enable Sound" toggle lives on the main setup page (`render_page()`), not a per-game page** — creates an `AudioContext`, loads `/pcm-worklet.js` via `audioWorklet.addModule`, opens a `WebSocket` to `AUDIO_PORT`, and pipes incoming binary messages into the worklet node. Meant to be left running in its own tab for as long as you're playing (hence the auto-refresh restriction above). `host` is read server-side from the incoming request's `Host:` header — no client-side hostname-guessing JS needed.
- **Audio is on by default on the main setup page (`render_page()`), not a per-game page, and needs no button.** The `AudioContext`, `/pcm-worklet.js` (`audioWorklet.addModule`), and the `WebSocket` to `AUDIO_PORT` all connect eagerly on page load, piping incoming binary messages into the worklet node. The only thing gated on a user gesture (browsers won't produce sound otherwise) is `ctx.resume()`, called by a one-shot listener on the page's very first `click`/`keydown` — whatever the user was already doing (e.g. clicking Start) — not a dedicated audio-only button. Meant to be left running in its own tab for as long as you're playing. `host` is read server-side from the incoming request's `Host:` header — no client-side hostname-guessing JS needed.
- **The page must never navigate, or the audio connection dies — this was a real, found bug, not just style.** Install/Start/Stop/Uninstall used to be `<form method="post">` submits; every click caused a full page reload via the server's `303` redirect, tearing down whatever `AudioContext` had just connected. The fresh page after that reload creates a *new*, suspended context with no further gesture to unlock it (the reload itself doesn't count as one), so in completely ordinary use — load the page, click Start — audio never actually played. The tell was that Chrome's tab never showed its audio-playing speaker icon. Fixed by removing `<form>`s entirely: every button is `onclick="doAction(action, name)"`, `do_POST` returns a bare `204` instead of a `303`/`Location` redirect, and `doAction()`/`refresh()` (both inline JS in `render_page()`) `fetch()` the action plus the updated page and swap only `#content`'s `innerHTML` — the page itself never navigates, so the `AudioContext`/`WebSocket` set up outside `#content` survive every action.
- **Per-game volume, despite the shared mix**: `POST /volume` (`name`, `level` 0-150) calls `set_volume()`, which finds that game's PulseAudio *sink-input* — matching `pactl -f json list sink-inputs`' `properties["application.process.id"]` against `running_procs[name]["game"].pid` — and runs `pactl set-sink-input-volume <index> <level>%`. Works because every game is still its own distinct sink-input even though they all feed the same default sink; PulseAudio already supports adjusting one independently of the others, no per-game audio isolation required. Each running game's row has a `<input type="range">` that `fetch()`s this route on every `oninput` tick (not a form submit, so no page navigation per drag step).
- **Video and audio are deliberately on separate pages, not one `<iframe>`-based wrapper.** An earlier version had `GET /screen/<name>` render an iframe onto noVNC plus the audio button on one page — dropped after finding that Firefox refuses to load iframe content signed by a certificate whose warning hasn't been accepted at the top level, with no way to click through *inside* the iframe (Chrome is more lenient, which is why this briefly looked fine when only tested there). Now "Open Screen" just opens noVNC's own URL directly in a new tab, where the normal top-level "Accept the Risk and Continue" flow applies.
- **The self-signed cert's `CN`/SAN/extensions all matter for Firefox specifically.** Generated with `CN=localhost` + `subjectAltName=DNS:localhost,IP:127.0.0.1` (not the meaningless placeholder `CN=NY` this project originally inherited) — Firefox validates the WebSocket-Secure connection's certificate against the hostname *independently* of the page load, and rejected a CN mismatch there even after the top-level HTTPS warning had been accepted. Only covers access via `localhost`/`127.0.0.1`; a LAN IP or other hostname will hit the same mismatch, since a static cert can't be pre-populated with every possible address. **Also needs explicit `basicConstraints=critical,CA:FALSE` plus `keyUsage`/`extendedKeyUsage=serverAuth`** — without them, `openssl req -x509 -addext ...` defaults to `CA:TRUE`, and Firefox hard-refuses (no override option at all, unlike a normal self-signed warning) a CA-flagged cert used as a TLS server cert. If the cert's `openssl req` invocation in the Dockerfile is ever touched again, keep all of these `-addext` flags together.
- Realistic end-to-end latency lands somewhere in the tens-to-~150ms range (`parec`'s own buffering plus the worklet's small jitter-absorbing ring buffer) — not literally "tens of ms" best-case, but nowhere near the 1-3s a compressed-stream (`ffmpeg` → mp3/ogg → `<audio>` tag) approach would cost, which is why that approach was rejected up front.
+50 -3
View File
@@ -119,8 +119,55 @@
`basicConstraints=critical,CA:FALSE`, `keyUsage=critical,digitalSignature,keyEncipherment`,
and `extendedKeyUsage=serverAuth` in the same `openssl req -x509 -addext ...` call.
Because the setup page now hosts a persistent `AudioContext`/`WebSocket`, its
`<meta http-equiv="refresh">` auto-refresh was narrowed to only fire while an install is
Because the setup page now hosts a persistent `AudioContext`/`WebSocket`, its old
`<meta http-equiv="refresh">` auto-refresh was narrowed to only fire while an install was
actively in progress — it used to also refresh continuously whenever any game was running
(to catch a crashed game's slot being freed), which would have torn down the live audio
connection every 3 seconds.
connection every 3 seconds. **Superseded entirely below** by a JS-driven refresh that
doesn't navigate the page at all.
**Removed the "Enable Sound" button — audio is on by default now.** The `AudioContext`/
`AudioWorkletNode`/`WebSocket` all connect eagerly on page load; the only thing still
gated on a user gesture (unavoidable browser autoplay policy) is `ctx.resume()`, which
now piggybacks on the page's very first click or keypress — whatever the user was already
doing, e.g. clicking Start on a game — rather than requiring a dedicated audio-only click.
**Added a per-game volume slider.** Despite audio being one shared mix, this is a real,
independent control: every running game is still its own distinct PulseAudio sink-input
even though they all feed the same sink, and `pactl set-sink-input-volume` adjusts one
sink-input without touching the others. `_sink_input_index()` finds the right one by
matching `pactl -f json list sink-inputs`' `application.process.id` against the game's own
PID (confirmed exact via `ps`/`pactl` cross-check). The slider POSTs to a new `/volume`
route via `fetch()` on every tick (not a form submit — no page navigation per drag step).
**Real bug found right after removing the button**: audio still never actually played.
Install/Start/Stop/Uninstall were still plain `<form method="post">` submits — every click
caused a full page navigation (via the server's `303` redirect back to `/`), which tears
down whatever `AudioContext` was just connected. The fresh page after that reload creates
a brand-new *suspended* context with no further gesture to unlock it (the reload itself
doesn't count), so in completely normal usage — load the page, click Start — audio never
actually starts. Symptom that nailed it down: no speaker icon ever appeared on the Chrome
tab. Fixed by converting the whole page away from form-based navigation entirely: every
button is now a bare `<button onclick="doAction(...)">`, `do_POST` returns a plain `204`
instead of a `303` redirect, and client-side `doAction()`/`refresh()` `fetch()` the action
and the updated page, then swap just `#content`'s `innerHTML` in place — the page itself
never navigates, so the audio connection now survives every install/start/stop/uninstall
click, and `setInterval(refresh, 3000)` replaces the old `<meta refresh>` for keeping
status current (crashed-game slot cleanup, install progress) without that risk at all.
**Third real bug, found once audio was actually reaching the speakers**: ~2s of latency,
visibly getting *worse* the longer a game ran (lip sync drifting further out over time),
and volume-slider changes taking a couple seconds to actually be heard. Root cause:
`pcm-worklet.js`'s playback queue (`docker-common/scripts/pcm-worklet.js`, copied into
this project) had no size cap — every incoming WebSocket message just got `push()`ed on
regardless of how fast the network delivered it relative to real-time playback. On a fast
local connection the browser routinely receives data faster than 48kHz real-time consumes
it, so the backlog only ever grew, never shrank; `set-sink-input-volume` changes the volume
at the PulseAudio *source*, so anything already sitting in that ever-growing client queue
still played at the old volume until it drained, i.e. the whole visible backlog's worth of
delay before a slider change was audible. Fixed by capping the queue at ~100ms
(`maxQueuedFrames`) and dropping the *oldest* excess data whenever a new chunk would push
it over that cap — verified directly in Node (stubbing `AudioWorkletProcessor`/
`sampleRate`/`registerProcessor`) that force-feeding a simulated 4.3s burst leaves only
~85ms actually queued afterward, instead of growing unbounded. Propagated to both the
`docker-common` canonical copy and this project's own copy, per the usual convention.
+27 -2
View File
@@ -3,12 +3,23 @@
// interleaved 16-bit signed little-endian stereo frames delivered via
// postMessage as ArrayBuffers; converts to Float32 and plays them back
// through a small ring buffer that absorbs network jitter.
//
// The queue is deliberately capped (see maxQueuedFrames): if the network/
// browser ever delivers data faster than real-time playback consumes it -
// which happens routinely on a fast local connection - an uncapped queue
// just accumulates that backlog forever, growing steadily out of sync with
// the video instead of settling back down. Dropping the oldest excess data
// keeps latency bounded at the cost of an occasional small audible glitch
// on the drop, which is a fair trade for staying live.
class PCMWorkletProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.readOffset = 0;
this.channels = 2;
this.maxQueuedFrames = Math.round(sampleRate * 0.1); // ~100ms cap
this.port.onmessage = (event) => {
const int16 = new Int16Array(event.data);
const float32 = new Float32Array(int16.length);
@@ -16,9 +27,23 @@ class PCMWorkletProcessor extends AudioWorkletProcessor {
float32[i] = int16[i] / 32768;
}
this.queue.push(float32);
this._trimBacklog();
};
}
_queuedFrames() {
let frames = -this.readOffset;
for (const chunk of this.queue) frames += chunk.length / this.channels;
return frames;
}
_trimBacklog() {
while (this._queuedFrames() > this.maxQueuedFrames && this.queue.length > 1) {
this.queue.shift();
this.readOffset = 0;
}
}
process(inputs, outputs) {
const output = outputs[0];
const numChannels = output.length;
@@ -31,11 +56,11 @@ class PCMWorkletProcessor extends AudioWorkletProcessor {
}
const current = this.queue[0];
for (let ch = 0; ch < numChannels; ch++) {
const idx = this.readOffset * numChannels + ch;
const idx = this.readOffset * this.channels + ch;
output[ch][frame] = idx < current.length ? current[idx] : 0;
}
this.readOffset++;
if (this.readOffset * numChannels >= current.length) {
if (this.readOffset * this.channels >= current.length) {
this.queue.shift();
this.readOffset = 0;
}
+120 -31
View File
@@ -42,12 +42,23 @@ page with an <iframe>: an iframe onto the noVNC HTTPS port hits browsers
certificate whose warning hasn't been accepted at the top level, with no
way to click through *inside* the iframe. So "Open Screen" is a plain
top-level link straight to noVNC's own URL (opens in a new tab, normal
"Accept the Risk and Continue" applies), and the one "Enable Sound" toggle
lives on this page instead, meant to be left open in its own tab for as
long as you're playing. Because of that, this page's auto-refresh is
deliberately limited to only while an install is actively in progress -
refreshing while a game is running would tear down the live AudioContext/
WebSocket every few seconds.
"Accept the Risk and Continue" applies), and audio lives on this page
instead, meant to be left open in its own tab for as long as you're
playing. Because of that, this page's auto-refresh is deliberately
limited to only while an install is actively in progress - refreshing
while a game is running would tear down the live AudioContext/WebSocket
every few seconds.
Audio is on by default, no button: the AudioContext/WebSocket connect
eagerly on page load, and just get resumed on this page's first click or
keypress (whatever the user was already doing, e.g. clicking Start),
since browsers require a user gesture before an AudioContext will
actually produce sound. Per-game volume is a real, independent control
even though every game's audio mixes into one shared PulseAudio sink -
each running game is still its own distinct sink-input there, and
PulseAudio already supports adjusting one sink-input's volume without
affecting the others (`pactl set-sink-input-volume`), found by matching
`application.process.id` against the game's own PID.
"""
import html
@@ -265,6 +276,40 @@ def stop_game(name):
running_procs.pop(name, None)
def _sink_input_index(pid):
"""Finds the PulseAudio sink-input belonging to a given PID.
All games mix into PulseAudio's one default sink (see module docstring),
but each game is still its own distinct sink-input there, and PulseAudio
already supports adjusting one sink-input's volume independently of the
others - this is what backs the per-game volume slider, without needing
any per-game audio isolation.
"""
result = subprocess.run(
["pactl", "-f", "json", "list", "sink-inputs"],
capture_output=True, text=True, timeout=5,
)
try:
sink_inputs = json.loads(result.stdout)
except json.JSONDecodeError:
return None
for sink_input in sink_inputs:
if sink_input.get("properties", {}).get("application.process.id") == str(pid):
return sink_input.get("index")
return None
def set_volume(name, level):
with state_lock:
info = running_procs.get(name)
if info is None:
return
index = _sink_input_index(info["game"].pid)
if index is None:
return
subprocess.run(["pactl", "set-sink-input-volume", str(index), f"{level}%"], check=False)
def render_page(host):
games = discover_games()
sizes = get_zip_sizes(games)
@@ -276,16 +321,12 @@ def render_page(host):
novnc_ports = {name: info["novnc_port"] for name, info in running_procs.items()}
slots_used = len(novnc_ports)
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
# Deliberately not refreshing just because slots_used > 0: this page hosts the one
# persistent "Enable Sound" AudioContext/WebSocket, and a full-page refresh would kill it.
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending else ""
def button(action, name, label):
# Not a <form> - a real form submit navigates the page, which would tear down the
# persistent audio AudioContext/WebSocket below. doAction() fetch()es instead and
# re-renders just #content in place, so the page never actually navigates.
return (
f'<form method="post" action="/{action}">'
f'<input type="hidden" name="name" value="{html.escape(name)}">'
f'<button type="submit">{label}</button></form>'
f'<button onclick="doAction(\'{action}\',\'{name}\')">{label}</button>'
)
rows = []
@@ -297,9 +338,16 @@ def render_page(host):
size_html = f"{size / 1_000_000:.1f} MB" if size else "?"
status = pending.get(name)
screen_html = ""
volume_html = ""
if name in novnc_ports:
status_html = '<span class="status busy">Running</span>'
screen_html = f'<a href="https://{host}:{novnc_ports[name]}/" target="_blank">Open Screen</a>'
volume_html = (
f'<input type="range" min="0" max="150" value="100" class="volume" '
f"oninput=\"fetch('/volume',{{method:'POST',"
f"headers:{{'Content-Type':'application/x-www-form-urlencoded'}},"
f"body:'name={name}&level='+this.value}})\">"
)
actions = button("stop", name, "Stop")
elif status in ("downloading", "extracting"):
status_html = f'<span class="status busy">{status}...</span>'
@@ -318,48 +366,75 @@ def render_page(host):
actions = button("install", name, "Install")
rows.append(
f"<tr><td>{title}</td><td>{release_date}</td><td>{publisher}</td>"
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td><td>{screen_html}</td></tr>"
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td>"
f"<td>{screen_html}</td><td>{volume_html}</td></tr>"
)
return f"""<!doctype html>
<html>
<head>
<title>dosbox-novnc game setup</title>
{refresh_tag}
<style>
body {{ font-family: sans-serif; margin: 2em; }}
table {{ border-collapse: collapse; width: 100%; max-width: 800px; }}
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
form {{ margin: 0; display: inline; }}
button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
.status.ok {{ color: #2a2; font-weight: bold; }}
.status.busy {{ color: #a70; font-weight: bold; }}
.status.err {{ color: #c22; }}
.slots {{ margin-bottom: 1.5em; }}
#audio-btn {{ padding: 0.4em 1em; margin-bottom: 1.5em; }}
.volume {{ width: 80px; vertical-align: middle; }}
</style>
</head>
<body>
<button id="audio-btn">Enable Sound</button>
<div id="content">
<p class="slots">{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use</p>
<h1>DOSBox games</h1>
<table>
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th></tr>
{''.join(rows) if rows else '<tr><td colspan="7">No game manifests found on the share</td></tr>'}
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th><th>Volume</th></tr>
{''.join(rows) if rows else '<tr><td colspan="8">No game manifests found on the share</td></tr>'}
</table>
</div>
<script>
document.getElementById('audio-btn').addEventListener('click', async () => {{
const btn = document.getElementById('audio-btn');
const ctx = new AudioContext({{ sampleRate: 48000 }});
await ctx.audioWorklet.addModule('/pcm-worklet.js');
// None of the buttons/inputs above are <form>s - a real form submit (or any other full
// page navigation) would tear down the persistent audio AudioContext/WebSocket set up
// below. doAction()/refresh() fetch() instead and only ever replace #content in place, so
// the page itself never navigates, no matter how many games get installed/started/stopped.
async function refresh() {{
const res = await fetch('/');
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
document.getElementById('content').innerHTML = doc.getElementById('content').innerHTML;
}}
async function doAction(action, name) {{
await fetch('/' + action, {{
method: 'POST',
headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
body: 'name=' + encodeURIComponent(name),
}});
refresh();
}}
setInterval(refresh, 3000);
// Audio is on by default - no button. AudioContexts start suspended until a user
// gesture, so this connects everything eagerly and just resumes on the page's first
// click/keypress (whatever the user was already doing, e.g. clicking Start on a game).
// Safe from the refresh() calls above too, since those never touch anything outside
// #content - this script block, and the AudioContext/WebSocket it created, are untouched.
const ctx = new AudioContext({{ sampleRate: 48000 }});
ctx.audioWorklet.addModule('/pcm-worklet.js').then(() => {{
const node = new AudioWorkletNode(ctx, 'pcm-worklet-processor', {{ outputChannelCount: [2] }});
node.connect(ctx.destination);
const ws = new WebSocket('ws://{host}:{AUDIO_PORT}/');
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => node.port.postMessage(event.data, [event.data]);
btn.disabled = true;
btn.textContent = 'Sound enabled - keep this tab open while playing';
}});
const unlockAudio = () => {{
ctx.resume();
document.removeEventListener('click', unlockAudio);
document.removeEventListener('keydown', unlockAudio);
}};
document.addEventListener('click', unlockAudio);
document.addEventListener('keydown', unlockAudio);
</script>
</body>
</html>"""
@@ -402,13 +477,26 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
action = urlparse(self.path).path.lstrip("/")
if action not in ("install", "uninstall", "start", "stop"):
if action not in ("install", "uninstall", "start", "stop", "volume"):
self._send_html("Not found", status=404)
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8")
name = parse_qs(body).get("name", [""])[0]
params = parse_qs(body)
name = params.get("name", [""])[0]
if action == "volume":
# Fired on every slider tick via fetch(), not a form submit - no page navigation.
try:
level = int(params.get("level", ["100"])[0])
except ValueError:
level = 100
set_volume(name, level)
self.send_response(204)
self.end_headers()
return
games = discover_games()
if name in games:
@@ -422,8 +510,9 @@ class Handler(BaseHTTPRequestHandler):
elif action == "stop":
threading.Thread(target=stop_game, args=(name,), daemon=True).start()
self.send_response(303)
self.send_header("Location", "/")
# No redirect: the client's own doAction()/refresh() re-fetches "/" itself and
# replaces #content in place, never navigating the page (see render_page()'s JS).
self.send_response(204)
self.end_headers()