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
{{ message }}
Repository navigation
Fix unreachable YouTube player controls on the activity plan hero (web) #8013
On web, a YouTube hero played inline on the activity plan page renders and plays, but the embed's own controls are unreachable. The control bar never appears on hover, so there is no pause, scrub, volume, captions (CC), or quality/speed settings. Only our close (X) strip responds to the mouse.
Captions burned in by YouTube do render, which confirms the iframe is alive and playing — it simply receives no mouse events.
Impact: for a listening activity the learner cannot pause, replay a passage, or turn captions on/off — the controls that matter most for comprehensible input.
Scope: ActivityStartHero's inline player path (web/desktop). Native mobile plays on its own screen (ActivityVideoScreen) and is not affected by the same wrapper.
TO TEST
On web, open an activity whose lead media is a YouTube video — verify the poster shows with a play badge
Tap the play badge — verify the video starts playing inline
Move the mouse over the video — verify YouTube's control bar appears
Click pause on that bar — verify the video actually pauses
Drag the scrubber to a later point — verify playback jumps there
Click the CC button — verify captions toggle off and on
Open the settings (gear) menu — verify playback speed and, when the video offers more than one caption track, the subtitles language list are reachable
Verify the X still closes the video and the role cards come back
On iOS and Android, open the same activity and tap the video — verify it opens on its own full-screen player, plays, and the close button returns to the plan
That node is viewport-sized (0,0,1095,992), carries flt-tappable, and therefore gets pointer-events: all from Flutter web. Every ancestor is correctly pointer-events: none; this single node is the blanket. Its only semantic child is the 40×40 ⓘ map-attribution button in the bottom-left corner, so it originates in the world map's subtree (RichAttributionWidget is SafeArea > Align > Stack, and flutter_map's map gesture surface is itself viewport-sized and tappable).
Proof: setting pointer-events: none on that one node — changing nothing else — immediately made the iframe the top element at the control-bar point, and hovering the video brought up YouTube's full chrome (title, volume, CC, settings gear, scrubber, time). Those are exactly the controls reported dead.
Because the iframe is a DOM platform view and is not part of the semantics tree, any tappable semantics node overlapping it wins native hit-testing regardless of Flutter paint order. So this hits every DOM embed layered over the map — the YouTube <iframe> and an uploaded <video> alike.
Severity is gated on the semantics tree
Staging: main_deploy.yaml writes ENABLE_SEMANTICS=true, so staging always reproduces it.
Production: no ENABLE_SEMANTICS key, so the tree is off and the controls work — except that Flutter web can enable semantics at runtime when it detects assistive technology, which would put AT users in the broken state. (Worth confirming for our Flutter version before deciding priority.)
Fixes tried that do NOT clear it
explicitChildNodes: true on the map's Semantics(label: activities, container: true) wrapper — fixed that container (it stops being tappable) but exposed the next viewport-sized tappable node below it.
showFlutterMapAttribution: false — removed only the node's label, not the blanket.
Semantics(explicitChildNodes: true) wrapped directly around RichAttributionWidget — no change.
The remaining node appears to be flutter_map's own viewport-sized tappable gesture/semantics surface, which can't be neutralised from our side without an ExcludeSemantics over the map (which would also remove the pins' own Semantics(button) a11y), a layout change so the map's node no longer extends under the panels, or an upstream flutter_map fix. That is a design call, not a mechanical one.
Branch reverted to main; no partial fix committed.
Correction to my earlier comment: staging has plenty of YouTube activities. My "0 of 2962" was a measurement bug, not a fact about the data.
I scanned activities-v2 reading doc['media']. The plan's media actually lives at doc['res']['plan']['media'], so every document looked media-less. The tell was in my own output — every row also came back with no title — and I missed it.
Corrected numbers on staging:
342 activities carry a youtube media block.
All 342 lead with it, so they all take the hero inline-player path this issue is about.
Two consequences:
QA is unblocked. No test content needs authoring. English-titled examples: "Km 0 in Madrid" (b62fbccb-f594-43b4-9363-3a4010a32fb7), "Jacket return at Selfridges" (4292b83d-d751-4380-9c60-27c1aab96e8e), "Find the train station" (ddc50b88-2fa9-4f3e-9c24-70600a27482b).
The local repro harness was unnecessary. A real staging activity would have exercised the hero path directly. It does not change the diagnosis or the fix, both of which were verified by measuring the semantics tree, but it does mean the end-to-end click-through can now be done on staging against real content.
Activity
wcjord commentedon Jul 29, 2026
Root cause (reproduced and confirmed on a local web build against staging)
Not the close (X) strip, and nothing in
ActivityStartHero. The embed is smothered by a Flutter accessibility semantics node.With the semantics tree on,
document.elementFromPointover every part of the playing embed — centre, control-bar strip, corners — returns:flt-semantics#flt-semantic-node-N→iframe#youtube-0→flt-glass-paneThat node is viewport-sized (0,0,1095,992), carries
flt-tappable, and therefore getspointer-events: allfrom Flutter web. Every ancestor is correctlypointer-events: none; this single node is the blanket. Its only semantic child is the 40×40 ⓘ map-attribution button in the bottom-left corner, so it originates in the world map's subtree (RichAttributionWidgetisSafeArea > Align > Stack, and flutter_map's map gesture surface is itself viewport-sized and tappable).Proof: setting
pointer-events: noneon that one node — changing nothing else — immediately made the iframe the top element at the control-bar point, and hovering the video brought up YouTube's full chrome (title, volume, CC, settings gear, scrubber, time). Those are exactly the controls reported dead.Because the iframe is a DOM platform view and is not part of the semantics tree, any tappable semantics node overlapping it wins native hit-testing regardless of Flutter paint order. So this hits every DOM embed layered over the map — the YouTube
<iframe>and an uploaded<video>alike.Severity is gated on the semantics tree
main_deploy.yamlwritesENABLE_SEMANTICS=true, so staging always reproduces it.ENABLE_SEMANTICSkey, so the tree is off and the controls work — except that Flutter web can enable semantics at runtime when it detects assistive technology, which would put AT users in the broken state. (Worth confirming for our Flutter version before deciding priority.)Fixes tried that do NOT clear it
explicitChildNodes: trueon the map'sSemantics(label: activities, container: true)wrapper — fixed that container (it stops being tappable) but exposed the next viewport-sized tappable node below it.showFlutterMapAttribution: false— removed only the node's label, not the blanket.Semantics(explicitChildNodes: true)wrapped directly aroundRichAttributionWidget— no change.The remaining node appears to be flutter_map's own viewport-sized tappable gesture/semantics surface, which can't be neutralised from our side without an
ExcludeSemanticsover the map (which would also remove the pins' ownSemantics(button)a11y), a layout change so the map's node no longer extends under the panels, or an upstream flutter_map fix. That is a design call, not a mechanical one.Branch reverted to main; no partial fix committed.
wcjord commentedon Jul 30, 2026
Correction to my earlier comment: staging has plenty of YouTube activities. My "0 of 2962" was a measurement bug, not a fact about the data.
I scanned
activities-v2readingdoc['media']. The plan's media actually lives atdoc['res']['plan']['media'], so every document looked media-less. The tell was in my own output — every row also came back with no title — and I missed it.Corrected numbers on staging:
youtubemedia block.Two consequences:
b62fbccb-f594-43b4-9363-3a4010a32fb7), "Jacket return at Selfridges" (4292b83d-d751-4380-9c60-27c1aab96e8e), "Find the train station" (ddc50b88-2fa9-4f3e-9c24-70600a27482b).fix(a11y): mirror map pin semantics outside the excluded map subtree (C…
fix(a11y): mirror map pin semantics outside the excluded map subtree (C…
fix(a11y): make mirrored map pins transparent to native pointer hit-t…