oficina / meet Public

holding screen: devices.js, preview, pickers, mute state, join

Camera/mic/speaker pickers, a muted <video> preview via srcObject, mute
and camera-off toggles, and a Join button, all scripted in
devices.js and wired from join.html's new {{define "head"}} block
(matching room.html and the default-src 'self' CSP: no inline
script). Preferences (chosen device ids, muted, cameraOff) persist
under the single localStorage key meet.devices; nothing reaches the
server.

Deviations from the brief:

- devices.js and meet.css load from join.html's head block, not the
  content region the brief's Step 1 markup had them in. room.html
  already made that move (fix round 1, Important 15, for the CSP),
  and ruling 5 for this task says to follow the same pattern.

- The #devices block carries a new data-join-href attribute
  ("/m/{sqid}" or, when join.html's existing OpenElsewhere notice is
  showing, "/m/{sqid}/takeover"), and the join button navigates
  there instead of always to the room. Ruling 1 requires this: a
  fresh stream at the room would 409 for a session that already has
  a peer here. The brief's Step 1/2 code predates OpenElsewhere and
  always went to /m/{sqid}.

- On a denied or absent device (NotAllowedError / no device found),
  Join is enabled once the attempt settles rather than disabled.
  The brief's own interface note said to disable Join on these
  errors, but ruling 4 for this task explicitly overrides that: 'a
  denied permission shows a sentence and still allows joining with
  nothing (the room handles a missing local stream)'. Followed the
  ruling.

- Test added to internal/meettest/holding_test.go (the brief named
  a nonexistent holding_test.go at the package root; the real
  black-box suite lives in internal/meettest).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev 8cafec1eb7c0c06a66d1950cf78e4ecfc2a51a0c parent 1193a51
3 files changed, +245 −2
  • internal/meet/static/devices.js +185 −0
  • internal/meet/templates/join.html +17 −2
  • internal/meettest/holding_test.go +43 −0

CI passed — run details

diff --git a/internal/meet/static/devices.js b/internal/meet/static/devices.js
new file mode 100644
index 0000000..97a3fcc
--- /dev/null
+++ b/internal/meet/static/devices.js
@@ -0,0 +1,185 @@
+// devices.js: the holding screen's scripted half (spec §3.3 "The
+// holding screen", §9 "The scriptless floor"). Reveals the device
+// pickers and camera preview the server hid, enumerates cameras,
+// microphones and (where the browser supports routing audio output)
+// speakers, remembers the chosen device ids and the mute/camera-off
+// defaults in localStorage, and hands the join button off to the room
+// — or, when this session already has the room open in another tab
+// (join.html's own server-rendered notice), to the takeover confirm
+// page instead, because a fresh stream at the room would 409.
+//
+// Nothing here reaches the server: preferences live only in this
+// browser, and no device label or id ever rides in a request.
+(() => {
+ 'use strict';
+ const root = document.getElementById('devices');
+ if (!root || !navigator.mediaDevices) return;
+ root.hidden = false;
+
+ const STORAGE_KEY = 'meet.devices';
+ const $ = (id) => document.getElementById(id);
+ const preview = $('preview');
+ const pickCamera = $('pick-camera');
+ const pickMic = $('pick-mic');
+ const pickSpeaker = $('pick-speaker');
+ const speakerRow = $('speaker-row');
+ const muteButton = $('toggle-mute');
+ const cameraButton = $('toggle-camera');
+ const joinButton = $('join');
+ const status = $('status');
+
+ let stream = null;
+ const prefs = loadPrefs();
+
+ // --- preferences: read and written to localStorage only ---
+
+ function loadPrefs() {
+ try {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {};
+ } catch {
+ return {};
+ }
+ }
+
+ function savePrefs() {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
+ } catch {
+ // Private browsing, or the quota is full: the choice just won't
+ // be remembered next time. Nothing here is required to join.
+ }
+ }
+
+ function say(text) {
+ status.textContent = text;
+ }
+
+ // --- the preview stream ---
+
+ async function startPreview() {
+ stopPreview();
+ const constraints = {
+ audio: prefs.mic ? { deviceId: { exact: prefs.mic } } : true,
+ video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true,
+ };
+ try {
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
+ } catch (err) {
+ if (prefs.camera || prefs.mic) {
+ // The remembered device may be gone — unplugged, or its own
+ // permission revoked — so forget it and try once more with no
+ // constraint before giving up on media entirely.
+ delete prefs.camera;
+ delete prefs.mic;
+ savePrefs();
+ return startPreview();
+ }
+ preview.srcObject = null;
+ say(err.name === 'NotAllowedError'
+ ? 'Meet needs permission to use your camera and microphone. You can still join with neither.'
+ : 'No camera or microphone was found. You can still join with neither.');
+ applyToggles();
+ await fillPickers();
+ joinButton.disabled = false;
+ return;
+ }
+ preview.srcObject = stream;
+ applyToggles();
+ await fillPickers();
+ joinButton.disabled = false;
+ say('');
+ }
+
+ function stopPreview() {
+ if (!stream) return;
+ for (const track of stream.getTracks()) track.stop();
+ stream = null;
+ }
+
+ // --- pickers: (re)built from enumerateDevices, which only carries ---
+ // --- real labels once permission has been granted, which is why ---
+ // --- this always runs after a getUserMedia attempt, never before. ---
+
+ async function fillPickers() {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const fillOne = (select, kind, chosen) => {
+ select.replaceChildren();
+ let count = 0;
+ for (const device of devices) {
+ if (device.kind !== kind) continue;
+ count++;
+ const option = document.createElement('option');
+ option.value = device.deviceId;
+ option.textContent = device.label || labelFor(kind, count);
+ if (device.deviceId === chosen) option.selected = true;
+ select.append(option);
+ }
+ return count;
+ };
+ fillOne(pickCamera, 'videoinput', prefs.camera || currentDeviceId('video'));
+ fillOne(pickMic, 'audioinput', prefs.mic || currentDeviceId('audio'));
+ if (typeof HTMLMediaElement.prototype.setSinkId === 'function' &&
+ fillOne(pickSpeaker, 'audiooutput', prefs.speaker) > 0) {
+ speakerRow.hidden = false;
+ }
+ }
+
+ function labelFor(kind, count) {
+ return (kind === 'videoinput' ? 'Camera ' : 'Microphone ') + count;
+ }
+
+ function currentDeviceId(kind) {
+ const track = stream && stream.getTracks().find((t) => t.kind === kind);
+ return track ? track.getSettings().deviceId : '';
+ }
+
+ // --- mute and camera-off defaults: applied to the live tracks when ---
+ // --- there is a stream, always reflected on the toggle buttons ---
+ // --- even when there isn't one, so the choice still holds at join. ---
+
+ function applyToggles() {
+ if (stream) {
+ for (const track of stream.getAudioTracks()) track.enabled = !prefs.muted;
+ for (const track of stream.getVideoTracks()) track.enabled = !prefs.cameraOff;
+ }
+ muteButton.setAttribute('aria-pressed', String(!!prefs.muted));
+ muteButton.textContent = prefs.muted ? 'Unmute' : 'Mute';
+ cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff));
+ cameraButton.textContent = prefs.cameraOff ? 'Camera on' : 'Camera off';
+ }
+
+ // --- wiring ---
+
+ pickCamera.addEventListener('change', () => {
+ prefs.camera = pickCamera.value;
+ savePrefs();
+ startPreview();
+ });
+ pickMic.addEventListener('change', () => {
+ prefs.mic = pickMic.value;
+ savePrefs();
+ startPreview();
+ });
+ pickSpeaker.addEventListener('change', () => {
+ prefs.speaker = pickSpeaker.value;
+ savePrefs();
+ });
+ muteButton.addEventListener('click', () => {
+ prefs.muted = !prefs.muted;
+ savePrefs();
+ applyToggles();
+ });
+ cameraButton.addEventListener('click', () => {
+ prefs.cameraOff = !prefs.cameraOff;
+ savePrefs();
+ applyToggles();
+ });
+ joinButton.addEventListener('click', () => {
+ stopPreview();
+ location.assign(root.dataset.joinHref);
+ });
+ navigator.mediaDevices.addEventListener?.('devicechange', fillPickers);
+ window.addEventListener('pagehide', stopPreview);
+
+ startPreview();
+})();
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html
index 5f3b0be..3405f1b 100644
--- a/internal/meet/templates/join.html
+++ b/internal/meet/templates/join.html
@@ -1,4 +1,7 @@
{{define "title"}}{{.Title}}{{end}}
+{{define "head"}}<link rel="stylesheet" href="{{asset "static/meet.css"}}">
+<script defer src="{{asset "static/devices.js"}}"></script>
+{{end}}
{{define "content"}}
{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}}
{{template "page-header" dict "Title" .Title}}
@@ -6,8 +9,20 @@
<p>You are {{.Name}}, joining as a {{.Standing}}.</p>
{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
{{if .OpenElsewhere}}<p rst-notice>This meeting is already open in another tab. <a href="/m/{{.Sqid}}/takeover">Use this tab instead</a>.</p>{{end}}
-<p>Calls coming soon</p>
<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.</p></noscript>
-<div id="devices" data-sqid="{{.Sqid}}" hidden></div>
+<div id="devices" data-sqid="{{.Sqid}}" data-join-href="{{if .OpenElsewhere}}/m/{{.Sqid}}/takeover{{else}}/m/{{.Sqid}}{{end}}" hidden>
+ <video id="preview" muted autoplay playsinline class="meet-preview"></video>
+ <div class="meet-pickers">
+ <label>Camera <select id="pick-camera"></select></label>
+ <label>Microphone <select id="pick-mic"></select></label>
+ <label id="speaker-row" hidden>Speaker <select id="pick-speaker"></select></label>
+ </div>
+ <div class="meet-controls">
+ <button type="button" id="toggle-mute" rst-btn aria-pressed="false">Mute</button>
+ <button type="button" id="toggle-camera" rst-btn aria-pressed="false">Camera off</button>
+ <button type="button" id="join" rst-btn="primary lg" disabled>Join the call</button>
+ </div>
+ <p id="status" class="meet-status" role="status"></p>
+</div>
{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}}
{{end}}
diff --git a/internal/meettest/holding_test.go b/internal/meettest/holding_test.go
index 0f20ebf..dba20ad 100644
--- a/internal/meettest/holding_test.go
+++ b/internal/meettest/holding_test.go
@@ -68,6 +68,49 @@ func TestHoldingPageOffersTakeoverWhenThisSessionIsAlreadyInTheRoom(t *testing.T
if !strings.Contains(body, "already open in another tab") || !strings.Contains(body, "/m/"+sqid+"/takeover") {
t.Fatalf("holding page lacks the takeover offer once a peer is live: %s", body)
}
+ // devices.js's own join button must not offer a fresh stream that
+ // would 409: once this session already has a peer here, the
+ // #devices block's join target is the takeover confirm page, not
+ // the room.
+ if !strings.Contains(body, `data-join-href="/m/`+sqid+`/takeover"`) {
+ t.Fatalf("holding page's join target should switch to takeover once a peer is live: %s", body)
+ }
+}
+
+// Task 7: devices.js takes over the region the slice-1 placeholder
+// held. The script tag lives in <head> (the room page's own pattern,
+// and the CSP's default-src 'self' wants no inline script), the
+// #devices block still carries the sqid the script reads, and its
+// default join target is the room itself — not the takeover page,
+// which is only for a session that already has a peer here (the next
+// test).
+func TestHoldingPageHasDevicesScriptAndNoLegacyPlaceholder(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ rec := c.get("/m/" + sqid + "/join")
+ body := rec.Body.String()
+
+ headEnd := strings.Index(body, "</head>")
+ // asset() fingerprints the filename (static/devices.<hash>.js), so
+ // the literal "devices.js" never appears — match the stable prefix,
+ // as room_test.go does for meet.css.
+ script := strings.Index(body, "/static/devices.")
+ if headEnd < 0 || script < 0 || script > headEnd {
+ t.Fatalf("devices.js is not inside <head>: head ends at %d, script at %d\n%s", headEnd, script, body)
+ }
+ if !strings.Contains(body, `id="devices" data-sqid="`+sqid+`"`) {
+ t.Fatalf("holding page lacks the devices block's data-sqid: %s", body)
+ }
+ if !strings.Contains(body, `data-join-href="/m/`+sqid+`"`) {
+ t.Fatalf("holding page's default join target should be the room: %s", body)
+ }
+ if !strings.Contains(body, "<noscript>") || !strings.Contains(body, "needs JavaScript") {
+ t.Fatal("holding page lacks its scriptless paragraph")
+ }
+ if strings.Contains(body, "Calls coming soon") {
+ t.Fatal("holding page still carries the slice-1 placeholder")
+ }
}
func TestHoldingPageIs404ForEndedMeetings(t *testing.T) {