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
3 files changed,
+245
−2
internal/meet/static/devices.js+185 −0internal/meet/templates/join.html+17 −2internal/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) { |