oficina / meet Public

room.js: the render watch leaves a paused element to the Play control

The browser gate taught this one immediately: `playfallback` went red on
the first full run after the render watch landed, and it was right to.

An element the autoplay policy refused is paused, its clock is stopped,
and frames are arriving — which is the render watch's exact signature
and none of its business. §5's answer to a refused autoplay is the Play
control in the tile's own caption; a watch that quietly called `play()`
again would take the decision away from the person and hide the button
that says so. Which is what it did: the scenario waited twenty-five
seconds for a control that had already been un-paused out of existence.

So `paused` joins the conditions. The watch is about an element that is
playing and not painting; a paused one is somebody else's business.

One test, mutation-verified: dropping the condition fails it and only
it.

node --check, 204 node tests, make ci.
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 3d2e88047a8fa6513c72d0c3c9379fbdb6aa2d04 parent 088e7c0
2 files changed, +22 −2
  • internal/meet/static/room.js +8 −1
  • test/browser/room-lane.test.mjs +14 −1
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
index 7b6d2d2..32fab0f 100644
--- a/internal/meet/static/room.js
+++ b/internal/meet/static/room.js
@@ -4122,7 +4122,14 @@
// Pure over the tile record, and lifted by name in the node test.
function judgeRender(t, decoded) {
const track = t && t.track;
- const playable = !!(t && t.video && t.stream && track &&
+ // `paused` is in here for a reason the browser gate had to teach:
+ // an element the autoplay policy refused is paused, its clock is
+ // stopped, and frames are arriving — which is this watch's exact
+ // signature and none of its business. §5's answer to that is the
+ // Play control in the tile's own caption, and a watch that quietly
+ // called play() again would take the decision away from the person
+ // and hide the button that says so.
+ const playable = !!(t && t.video && t.stream && track && !t.video.paused &&
track.readyState !== 'ended' && !track.muted);
if (!playable || typeof decoded !== 'number') {
t.renderTime = -1;
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs
index 6b42efa..583fcbe 100644
--- a/test/browser/room-lane.test.mjs
+++ b/test/browser/room-lane.test.mjs
@@ -984,7 +984,7 @@ const render = renderBox(RENDER_STALL_POLLS, RENDER_REPLAYS);
function fakeTile(opts) {
const o = opts || {};
return {
- video: { currentTime: 0 },
+ video: { currentTime: 0, paused: !!o.paused },
stream: {},
track: { readyState: o.readyState || 'live', muted: !!o.muted },
renderTime: -1,
@@ -1040,6 +1040,19 @@ test('a tile that starts painting again forgets the replay it needed', () => {
assert.equal(t.renderReplays, 0, 'a picture that moves again must clear the budget');
});
+test('an element the autoplay policy paused is left to the Play control', () => {
+ // The browser gate taught this one. A refused autoplay leaves the
+ // element paused with its clock stopped while frames arrive, which is
+ // this watch's exact signature and none of its business: §5's answer
+ // is the Play control in the tile's caption, and a watch that called
+ // play() again would take the decision away from the person and hide
+ // the button that says so.
+ const t = fakeTile({ paused: true });
+ const said = paint(t, [[10, 0], [20, 0], [30, 0], [40, 0], [50, 0], [60, 0]]);
+ assert.deepEqual(said, ['', '', '', '', '', '']);
+ assert.equal(t.renderReplays, 0);
+});
+
test('a muted or ended track is nobody here to repair', () => {
for (const t of [fakeTile({ muted: true }), fakeTile({ readyState: 'ended' })]) {
const said = paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5], [50, 1.5]]);