You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Debate videos did not autoplay on mobile. Tapping one put it into the paused state — so the app thought it had been playing — and only a second tap started it.
The cause is a race in playBothWithMutedFallback. It judges success by whether the elements are actually running shortly after, rather than by whether play() resolved, because a resolved play() can still be mid-transition out of paused. A rejection was assumed to need no special case, on the reasoning that it leaves the element paused and so fails the same check.
It does not, or not in time. play() sets paused to falsesynchronously and only then rejects; the user agent pauses it again afterwards. So the confirm poll sees both elements un-paused and reports 'playing' for a play the browser is in the middle of refusing. From there nothing recovers: the autoplay effect will not retry a card it believes is playing, no refusal is recorded, and the paused glyph never renders — the viewer's first tap only pauses what the app imagined was running.
Measured on the reporter's iPhone in Low Power Mode, which refuses every autoplay:
A NotAllowedError now invalidates the reading — whatever paused said mid-flight, the browser has said plainly that it did not start — and it is reported as its own outcome, 'refused', distinct from 'blocked'.
That distinction carries the behaviour. 'refused' is a policy answer: the same question gets the same answer, so the card stops retrying and offers a tap control instead, with no error copy — the videos are fine, the device simply wants to be asked by a person. 'blocked' is everything else that failed to confirm — a stall, a missing recording, a decode failure — and keeps both its message and its retry.
Two orderings needed care, because attempts overlap by construction (resumeBoth bumps its generation on entry and the autoplay effect re-enters while playing is false, so an attempt is routinely superseded before it reports):
A refusal is recorded above the ownership check. Below it, the answer was discarded every time and the card never learned it had been refused.
But it is dropped when a later attempt has already confirmed playback. Otherwise a stale refusal draws the tap control over a running video, and the tap stops it — the original symptom, relocated.
Diagnostics, behind a flag
PlaybackDiagnostics (flag playbackDiagnostics, off by default) reports every debate video on screen, the sequence of play()/pause() calls against it and how each settled, and the player's own conclusion beside it — the pairing that identified this bug.
These faults do not reproduce anywhere a debugger is attached: headless Chromium plays every card, and headless WebKit reports the clock advancing and play() resolving, because neither carries iOS's media policy or Low Power Mode. Four fixes went in against remote probes and none of them touched the fault; one reading from the phone found it exactly. DebateFeedPlayer also publishes its state as data-debate-* attributes so that readout — or an inspector on a phone — can see what React concluded.
Verified
Confirmed by the reporter on-device: autoplay works in normal mode, and Low Power Mode now shows a working play control instead of a card that looks broken.
Tests: playback-utils covers the synchronous-un-pause race, refusal vs. cancellation, and a stall staying 'blocked'; use-debate-playback covers the refusal surviving supersession, not surviving a newer success, and a stall keeping its error; debate-feed-player covers the control rendering. 6206 passing.
jwalkingjew
changed the title
fix(debates): stop a video when its card leaves the viewport (GEO-2978)
diag(debates): ask the phone why the video is not playing (GEO-2978)
Sep 19, 2026
A debate card on a phone showed a still frame and needed two taps to start: the
first only revealed a play control, the second played it. Measured on the device
with both videos fully buffered, muted and inline:
card0 · ready=true active=true playing=true blocked=false playBtn=0
v0 · 100% shown · PAUSED · t=0.0 · calls=[play REJECTED:NotAllowedError]
The app believed it was playing while the elements sat paused at zero, having
been refused — iOS declines every autoplay in Low Power Mode, and when auto-play
is turned off for a site.
`playBothWithMutedFallback` judges success by polling `paused` shortly after
calling `play()` rather than trusting the promise, because a resolved `play()`
can precede the element leaving `paused` — that is what stopped the feed
claiming "Could not play both videos" on every scroll. Its comment then said a
rejection needs no special case, "a rejection leaves the element paused, so it
fails the same check". It does not, or not in time: `play()` sets `paused` false
*synchronously* and only then rejects, and the user agent re-pauses afterwards.
The first confirm poll sees both elements running and reports 'playing' for a
play being refused.
Nothing recovers from there. The autoplay effect will not retry a card it
believes is playing, no refusal is recorded, and the paused glyph never renders
— so the viewer's first tap only pauses what was never running.
Three things follow:
* A refusal invalidates the confirm reading, on the first attempt and on the
muted retry. `isRefusal` tells `NotAllowedError` — the browser declining —
from the `AbortError` our own `pause()` raises.
* A refusal outlives the attempt that found it, recorded above the ownership
check: it describes the device, while that check exists to stop a superseded
attempt writing playback state. Superseded is the norm, since `resumeBoth`
bumps its generation on entry and the effect re-enters while `playing` is
false.
* The card offers the play control instead of "Could not play both videos. Try
Play again." — copy that named a control which was not on screen and a
failure that had not happened.
`awaitingTap` names the state the control renders from: the viewer paused, or
the browser refused, which are different facts and the same question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Autoplay faults in the feed do not reproduce anywhere a debugger is attached.
Headless Chromium plays every card, and headless WebKit — the engine iOS runs —
reports the clock advancing and `play()` resolving, because neither carries
iOS's media policy, Low Power Mode, or a toolbar that resizes the viewport as
you scroll. Four fixes for GEO-2978 went in against remote probes and none of
them touched the fault; one reading from the phone identified it exactly.
So this asks the phone. With the flag on, a readout reports every debate video
actually on screen — how much is shown, paused or playing, its clock, ready
state, muted and inline — plus the sequence of `play()`/`pause()` calls against
it and how each settled, and the player's own conclusion beside it. `copy` puts
the text on the clipboard, because the call trace is the part a screenshot
loses.
That separates causes which are identical on screen: nothing ever called
`play()`; the browser refused; or it started and something took it back. The
fault this found was the fourth — a card reporting `playing=true` over a refused
`play()`.
Visibility is measured on **both axes**. A vertical-only test counts a card
scrolled out sideways in a row as on screen, which is how an earlier round came
to believe twelve videos were playing at once and spent a day on a decoder limit
that was never reached.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(GEO-2978)
Two faults in the fix, both from folding the browser's "no" together with
everything else that fails to start.
A refusal is now its own outcome. `playBothWithMutedFallback` returns 'blocked'
for any start it cannot confirm inside the ~300ms grace window — a stalled
buffer, a recording that 404s, a decode failure — and the previous commit
latched every one of those as an autoplay refusal. That stops the feed's
autoplay effect retrying (it reads the flag) and, with the old error copy gone,
leaves a stalled card behind a tap control that does nothing and says nothing.
'refused' latches and offers the tap; 'blocked' keeps its message and stays
retryable.
And a stale refusal can no longer contradict a newer attempt that won. The
refusal is recorded above the ownership check on purpose — attempts overlap by
construction, so requiring ownership discarded it every time — but that also
lets an attempt answer after a later one has started the video. Guarded on the
generation that last confirmed playback, so the only refusal dropped is one a
success has already overtaken. Without the guard the card draws the tap control
over a playing video and the tap stops it, which is the two-tap symptom this
whole change removes.
Both are covered by tests that fail without their fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jwalkingjew
changed the title
diag(debates): ask the phone why the video is not playing (GEO-2978)
fix(debates): a refused play() is not a playing video (GEO-2978)
Sep 19, 2026
…e (GEO-2978)
`isRefusal` fell back to matching the rejection's message, and a phrase broad
enough to catch WebKit's "not allowed by the user agent" also catches Chrome
interrupting with the user agent in the sentence. Since an interruption here is
almost always our own `pause()` — a scroll-away, a scrub — that read an ordinary
interaction as a policy refusal, latched the tap control and stopped the card
autoplaying for the rest of the session. The failure this change exists to
remove, reintroduced by the fix for it. Thanks @copilot.
The name is the whole classification. `play()` rejects with a `DOMException` and
the spec names it: `NotAllowedError` for a refusal, `AbortError` for an
interruption. The message is prose an engine writes for a human, worded
differently per engine and overlapping across meanings.
Checking the name also meant fixing how it is read, and that is the larger half.
The guard was `instanceof Error`, which `social-video-share.ts` already
documents as wrong for exactly this: `DOMException` is not reliably an instance
of `Error`, and a guard written that way once cost us a share failure that
reached us as a person reading the string aloud. Under it, every genuine
`NotAllowedError` from `play()` was classified as *not* a refusal — the opposite
error, in the same predicate. jsdom demonstrates it: reverting the predicate
fails seven tests that now reject the way a browser does.
So `errorName` moves out of `social-video-share.ts` — a 'use client' module with
React and analytics in it, which `playback-utils` cannot import — into
`core/utils/error-name.ts`, and the three places that read an error's name for
classification use it: `isRefusal`, `isAbortError`, and the diagnostic's call
trace, which had the same `instanceof Error` guard and would have reported the
refusal that identified this bug as `unknown`.
Test fixtures now reject with a real `DOMException` rather than a plain `Error`
carrying invented prose. The old fixtures only classified because of the message
fallback — a test shaping production code to match itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This global selector includes unrelated media (editor previews, community-call video, ordinary entity videos, etc.) in a readout documented as pairing debate-player state with its videos. Because the output later keeps only the first four visible elements, unrelated videos earlier in document order can displace the failing debate recordings and make card0/v0 appear related when they are not. Scope this list to videos beneath the newly added debate-player marker.
This issue also appears on line 148 of the same file.
The readout built its two halves as independent lists and lined them up by
position: `card0` from the debate players, `v0` from every <video> on the page
in document order. Explore opens with a hero carousel above the feed and any
entity page can carry video of its own, so `v0` was regularly an element `card0`
had never touched — a card appearing to play a video belonging to something
else. Thanks @copilot.
That matters more here than it would elsewhere. This readout exists because
mis-scoped measurements sent the GEO-2978 investigation after causes that were
never there — one round counted off-screen carousel videos, concluded twelve
were playing at once, and spent a day on a decoder limit never reached. An
instrument built to end that cannot carry the same fault.
So the pairing is structural: a card's videos are the ones inside it, reported
underneath it as `card0.v0`. Video the feed does not own is still counted, since
it competes for the same decoders, but counted rather than described, so it can
never be read as a card's own.
Tested, where there was no coverage at all — which is how this survived. The
attribution, the both-axes visibility rule, and the call trace that identified
the bug. Reverting to the positional pairing fails two of them.
Also keyed the lines by position. They were keyed by their own text, and two
cards in the same state produce byte-identical rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addressed the "previously missed" finding from the second Copilot pass — scoping the diagnostic's video list to debate players. It was right, and in the place where it matters most.
The defect.read() built its two halves independently and lined them up by position: card0 from [data-debate-ready], v0 from document.querySelectorAll('video') in document order. Explore opens with a hero carousel above the feed, so v0 was regularly an element card0 had never touched.
That's worse here than it would be elsewhere. This readout exists because mis-scoped measurements sent this investigation after causes that were never there — one round counted off-screen carousel videos, concluded twelve were playing at once, and spent a day on a decoder limit that was never reached. An instrument built to end that cannot carry the same fault.
The fix. The pairing is structural now — a card's videos are the ones inside it:
card0 · ready=true active=true playing=false blocked=true playBtn=1
card0.v0 · 100% shown · PAUSED · t=0.0 · calls=[play REJECTED:NotAllowedError]
1 other video(s) on screen, outside any debate card (0 playing)
Media the feed doesn't own is still counted — it competes for the same decoders, so dropping it silently would be its own blind spot — but counted, never described, so it can't be read as a card's.
Two things beyond the literal fix. The component had no tests at all, which is how this survived: four now cover the attribution, the both-axes visibility rule, and the call trace that identified GEO-2978. Reverting to the positional pairing fails two of them. And the lines were keyed by their own text, where two cards in the same state produce byte-identical rows — keyed by position now, which is correct for a readout regenerated whole every 500ms.
The diagnostic is advertised in both the PR and feature-flag description as reporting every debate video on screen, but this truncates visible players after two. Pages that expose three or more visible cards will silently omit the remaining card state and traces, which can invalidate the device reading. Keep the complete filtered list; the panel already scrolls within its height limit.
A latch is only as good as the conditions that release it, and this one had a
single release — a successful start — for two ways out. Thanks @copilot.
**A block releases it.** A card refused once, then tapped: the browser accepts
the gesture and nothing refuses, but the media stalls, so the attempt reports
'blocked'. That outcome promises a retry and an explanation and delivered
neither — `autoplayBlocked` stayed set from the earlier refusal, `awaitingTap`
stayed true, and the feed's autoplay effect reads it, so the retry never
happened. The card sat behind a manual control saying "Try Play again" about a
control that would not have helped. A block from an attempt we still own is
positive evidence the browser has stopped refusing.
**A different debate releases it too.** Enumerating the rest of the latch's
release set found the second case. `DebateFeed` keys its cards by claim rather
than by debate id, so a re-rank that changes which debate represents a claim
hands a new debate to the same hook instance. The effect that fetches recordings
already resets the playhead for exactly this reason; the refusal and the pause
belong in that reset too. The refusal would likely be true again but nothing has
asked on this debate's behalf yet, and the pause belongs to a debate the viewer
is no longer looking at.
**And the readout reports every visible card.** It truncated after two, while
the PR and the flag description both promise every debate video on screen. A
device reading that quietly omits the third card can be wrong about which card
is misbehaving, which is the failure this file exists to end — and it reads as
confidently as a correct one. The panel already scrolls inside its height limit.
Same for the off-screen list.
Three regression tests, each verified to fail without its fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Reviewed at a0f2e284; you pushed 6b625a5c while it ran, so I re-checked all three findings against the new head before posting.
Two were already fixed by that push, and I have dropped them rather than make you read them:
playback-diagnostics.tsx capping at two players while a third on-screen card fell into neither list — now "Every visible card, not the first two", with the counts reconciling.
autoplayBlocked never clearing on a terminal 'blocked' outcome, leaving a stalled card behind both an error and a play button that the autoplay effect would never retry — now cleared in that branch, with a comment describing the same mechanism.
One finding stands, inline below.
Checked and holding up, so as not to re-raise them: the errorName move leaves no dangling importers; DebateFeedPlayer is the only consumer of the controller, so the new 'refused' outcome has no unhandled call site; the layout-effect install genuinely precedes the feed's passive autoplay effect (sibling after {children}, and layout effects of a commit all run before any passive effect); play() is still invoked inside the click handler's synchronous task, so user activation survives; and navigator.clipboard?.writeText(...).then(...) short-circuits, so no throw on an insecure origin.
Also deliberately not flagged: the never-uninstalled prototype patch (documented, behaviour-preserving, flag-gated), retry.running && retry.refused → 'refused' pausing elements that read as running (the documented sync-unpause race, and the safe direction), and awaitingTap missing from the dep array (the underlying booleans are listed, and this repo does not enable react-hooks/exhaustive-deps).
One note beyond the code: this is the fourth ticket against use-debate-playback.ts for the same class of defect — the playing flag disagreeing with the elements — after GEO-2783, GEO-2895 and the generation guard. Each fix has been correct, and the file is now very well commented about why. Worth asking at some point whether a boolean plus elements that can diverge is the right state model, rather than continuing to close each divergence as it surfaces. Not an objection to this PR.
On the autoplay path this is right, and the muted condition holds: muted = !audible || mutedByUser and audible requires playing, so a resume starting from a stopped card always has both elements muted.
But not every resume starts from a stopped card. endScrub's auto-resume and recoverBackgroundPlayhead on visibilitychange both resume a pair that was already playing with audio on — the speaking element is unmuted. A first-attempt NotAllowedError there falls through to the isCancelled() check below and is reported as 'cancelled', discarding the browser's answer.
That is the exact loss documented at 371-385 as the thing this change fixes. It only self-heals because the last attempt in an overlap chain is by definition not cancelled — so the refusal survives eventually, via the path the comment says it should not need.
Widening the condition to "the refusal is real regardless of mute state" would close it, if there is no case where a refusal on an unmuted pair should be treated as cancellation.
The reason will be displayed to describe this comment to others. Learn more.
Your analysis is right on every point, and I checked each one rather than taking it on faith — muted = !audible || mutedByUser with audible = playing && turnState?.slot === n, so a stopped card does have both elements muted and the autoplay path does take that branch, while endScrub and the visibilitychange recovery resume with the speaking element unmuted and fall through to isCancelled().
I did not widen the condition, and I think widening would be a regression. A refusal on an unmuted pair means only "not unmuted" — the ordinary case the muted fallback exists for (GEO-2783). 'refused' is a statement about the device, and the caller latches it: a tap control, and the autoplay effect stops retrying. Latching that on a card the browser would happily play muted takes the retry away from a card that deserves one. Returning early also skips the muted retry itself, so we would not even find out.
What was actually wrong was the comment, and that is squarely the thing you caught. It called the ordering "the whole fix for GEO-2978" without saying it only applies once both elements are muted. The real fix for GEO-2978 was the refusal invalidating bothRunning's reading; this ordering is what gets the answer out to the card on the autoplay path. The comment now says which path it covers, why the mute condition is deliberate rather than incidental, and names the cost you identified: an unmuted refusal that is also cancelled loses that attempt's answer. It survives only because the last attempt in an overlap chain is by definition not cancelled and starts from a stopped card — exactly the "self-heals via the path the comment says it should not need" you described, now written down as a known cost instead of an unstated assumption.
Pinned with a test, since the reasoning rests on a caller rather than on anything visible in that file: reports a cancellation, not a refusal, when the refused pair still had audio to give up. It also asserts one play() per element, so nothing starts while cancelled.
If you would rather the unmuted refusal not be lost at all, the principled version is separating the two facts — report the outcome for the attempt, and hand the device-level refusal back out of band so the caller can record it without it being mistaken for "tap required". Happy to do that if you think the extra round trip matters; I left it out because the answer is one attempt late rather than absent, and the machinery is real.
…-2978)
Clearing the refusal and the pause when a new debate arrives was half a fix.
`resumeBoth` awaits up to ~600ms, so an attempt belonging to the debate being
replaced is routinely still inside that window, and nothing retired it. Thanks
@copilot.
Its refusal write sits above the ownership check on purpose — a refusal has to
outlive supersession, because attempts overlap by construction — so it came back
after the reset and re-latched the tap control on a debate nothing had asked
about yet, which then skipped its first autoplay. A refusal must outlive
supersession within a debate, not the debate itself.
Enumerating that found the gap is wider than the report. The effect that fetches
recordings never bumped `resumeGenerationRef` at all, so an attempt for the old
debate could pass the *ownership* check too and set `playing`, set a turn, and
pause elements now holding a different `src`. Both counters bump at the boundary
now, and a second counter scopes the one write that deliberately runs above the
check.
Also corrects a comment that overclaimed, found by @ohohoreilly. The refusal
outranks the cancellation check only when both elements are already muted, and
the comment called that ordering "the whole fix for GEO-2978" without saying so.
The mute condition is deliberate and stays: `muted` is `!audible || mutedByUser`
and `audible` requires `playing`, so autoplay of a stopped card always has both
muted and takes that branch, while `endScrub` and the return from a backgrounded
tab resume a pair with the speaking element unmuted — where a refusal means only
"not unmuted", the ordinary case the muted fallback exists for. Latching there
would take the retry away from a card that plays fine muted. The real cost, now
named in the comment and pinned by a test: an unmuted refusal that is also
cancelled loses that attempt's answer, and survives only because the last
attempt in an overlap chain is not cancelled and starts from a stopped card.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A refused
play()is not a playing videoDebate videos did not autoplay on mobile. Tapping one put it into the paused state — so the app thought it had been playing — and only a second tap started it.
The cause is a race in
playBothWithMutedFallback. It judges success by whether the elements are actually running shortly after, rather than by whetherplay()resolved, because a resolvedplay()can still be mid-transition out ofpaused. A rejection was assumed to need no special case, on the reasoning that it leaves the element paused and so fails the same check.It does not, or not in time.
play()setspausedtofalsesynchronously and only then rejects; the user agent pauses it again afterwards. So the confirm poll sees both elements un-paused and reports'playing'for a play the browser is in the middle of refusing. From there nothing recovers: the autoplay effect will not retry a card it believes is playing, no refusal is recorded, and the paused glyph never renders — the viewer's first tap only pauses what the app imagined was running.Measured on the reporter's iPhone in Low Power Mode, which refuses every autoplay:
The fix
A
NotAllowedErrornow invalidates the reading — whateverpausedsaid mid-flight, the browser has said plainly that it did not start — and it is reported as its own outcome,'refused', distinct from'blocked'.That distinction carries the behaviour.
'refused'is a policy answer: the same question gets the same answer, so the card stops retrying and offers a tap control instead, with no error copy — the videos are fine, the device simply wants to be asked by a person.'blocked'is everything else that failed to confirm — a stall, a missing recording, a decode failure — and keeps both its message and its retry.Two orderings needed care, because attempts overlap by construction (
resumeBothbumps its generation on entry and the autoplay effect re-enters whileplayingis false, so an attempt is routinely superseded before it reports):Diagnostics, behind a flag
PlaybackDiagnostics(flagplaybackDiagnostics, off by default) reports every debate video on screen, the sequence ofplay()/pause()calls against it and how each settled, and the player's own conclusion beside it — the pairing that identified this bug.These faults do not reproduce anywhere a debugger is attached: headless Chromium plays every card, and headless WebKit reports the clock advancing and
play()resolving, because neither carries iOS's media policy or Low Power Mode. Four fixes went in against remote probes and none of them touched the fault; one reading from the phone found it exactly.DebateFeedPlayeralso publishes its state asdata-debate-*attributes so that readout — or an inspector on a phone — can see what React concluded.Verified
Confirmed by the reporter on-device: autoplay works in normal mode, and Low Power Mode now shows a working play control instead of a card that looks broken.
Tests:
playback-utilscovers the synchronous-un-pause race, refusal vs. cancellation, and a stall staying'blocked';use-debate-playbackcovers the refusal surviving supersession, not surviving a newer success, and a stall keeping its error;debate-feed-playercovers the control rendering. 6206 passing.