PageSourceSearch

https://powercoach.ai/assets/controllers/video_player_controller-22a53d37.js

js powercoach.ai collected 2026-10-05 14:01:15 UTC 95,038 bytes, 1,866 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import {
3  resolveSeekTarget,
4  windowReachedEnd,
5  clampTime,
6  formatClock,
7  scrubWindow,
8  scrubSeekTarget,
9  scrubOffset
10} from "utils/clip_playback";
11import {
12  SPEED_PRESETS,
13  FRAME_STEP_SECONDS,
14  formatPlaybackRate,
15  skipTarget,
16  gameClockLabel,
17  isTypingTarget,
18  shortcutAction
19} from "utils/video_controls";
20import {
21  QUALITY_AUTO,
22  normaliseQuality,
23  qualityLabel,
24  qualityIcon,
25  streamLayoutWidth,
26  scaledFrameStyle,
27  nearestVimeoQuality
28} from "utils/video_quality";
29import { elementFullscreenAvailable } from "utils/fullscreen_support";
30import { behindLiveLabel, isAtLiveEdge, liveEdgeTarget } from "utils/live_playback";
31import { createTooltip } from "utils/tooltip_utils";
32
33// One player, many sources, three backends. A game's footage may be one file for
34// the whole game or a separate file/link per quarter; this controller holds every
35// playable source and switches between them on demand. Each source gets its own
36// backend (native <video>, the Vimeo SDK, or the Cloudflare Stream SDK) built
37// lazily and cached in its own hidden mount; only the active one is shown.
38//
39// Other controllers drive it through window events (pc:video-*), matching the
40// app's pc:ask-brain bus convention:
41//   in  — pc:video-cue {start,end,clipId,sourceId,seek}, pc:video-seek {time},
42//         pc:video-activate {sourceId}, pc:video-play, pc:video-pause
43//   out — pc:video-ready {duration,sourceId}, pc:video-timeupdate {currentTime,sourceId},
44//         pc:video-clip-ended {clipId}, pc:video-next-clip, pc:video-prev-clip,
45//         pc:video-extend-clip {startDelta,endDelta}
46// A cue to a source that isn't showing loads it, waits for it to be ready, then
47// seeks — so a clip in Q3's file plays seamlessly from a room built on Q1's.
48//
49// Every backend draws its own control bar, so we normally add only what they
50// lack: precise skips, slow-mo, clip looping, a game clock, and the clip-level
51// moves (prev/next, widen the window) that the clip rail owns — those go back
52// out on the bus. The bar is _player_controls.html.erb, rendered INSIDE this
53// element so its buttons can be plain data-actions; it only exists when the
54// Video Room asks for it (controls: true), which is also the only player that
55// takes the keyboard.
56//
57// ONE DELIBERATE EXCEPTION — nativeControls: false (the goal-clip modal). There
58// the player is cued to a ~30s window inside a much longer file, so the
59// backend's own bar describes something other than what's on screen: its
60// scrubber spans the whole quarter, and dragging it silently leaves the clip.
61// That mount hides the native bar entirely and draws _goal_clip_controls.html.erb
62// instead — play/pause, replay, mute, and a scrub bar. Hiding it is a
63// MOUNT-TIME decision on all three backends (two are iframe URL params), which
64// is why it's a value rather than a method, and it makes our buttons the only
65// way to start playback — hence the buffering target being load-bearing rather
66// than decoration.
67//
68// The scrub bar (_scrub_bar.html.erb) is the one control that replaces rather
69// than supplements a native one, and scrub-scope-value is what makes that safe:
70// in "clip" scope it spans the CUED WINDOW, so unlike the bar it replaces it can
71// neither misrepresent the clip nor seek out of it. In "source" scope it spans
72// the whole file, which is the Video Room's own scope.
73
74// Minimum gap between seeks while a finger is on the scrub bar. `input` fires at
75// pointer rate (60–120Hz) and every seek is a postMessage into a Vimeo iframe or
76// a property write into a Stream one; 125ms is half the ~250ms timeupdate
77// cadence, so a drag still previews without queueing seeks faster than the
78// bridge can land them. Leading-edge with NO timer — `change` fires on pointer-up
79// and on every keypress and always commits the final value, so nothing is left
80// pending and disconnect() has nothing new to tear down.
81const SCRUB_SEEK_MIN_MS = 125;
82
83// After a seek, how long our own target out-ranks the backend's clock. Every
84// backend reports position only on its NEXT tick, and that tick may have been
85// queued BEFORE the seek landed — stale by up to a quarter second, which is long
86// enough for the thumb to visibly snap backwards on release and jump forward
87// again.
88const SEEK_SETTLE_MS = 600;
89const SEEK_SETTLE_TOLERANCE = 0.5;
90
91export default class extends Controller {
92  static targets = [
93    "mount", "clock", "speedBtn", "speedLabel", "loopBtn", "playBtn", "muteBtn",
94    "buffer", "scrub", "scrubClock", "scrubRow", "liveBadge", "liveBehind", "jumpLive",
95    "qualityBtn", "qualityLabel", "qualityMenu", "qualityIcon", "controls"
96  ];
97  static values = {
98    sources: Array,           // [{ sourceId, sourceType, src, streamHost, signedToken }]
99    preroll: { type: Number, default: 0 },
100    // False hides the backend's own control bar — see the exception above.
101    nativeControls: { type: Boolean, default: true },
102    // Video::ClipResolver#period_timeline — [{ label, periodIndex, sourceId,
103    // videoStart, videoEnd }] — what the game clock reads from. Empty when the
104    // mount has no bar (timing page, goal modal), which just blanks the clock.
105    timeline: { type: Array, default: [] },
106    keyboard: { type: Boolean, default: false },
107    // What the scrub bar spans, and empty (the default) means no scrub bar at
108    // all. "clip" scales it to the cued window — the goal modal, whose file is a
109    // whole quarter with ~30s of it on screen; "source" scales it to the whole
110    // file — the Video Room, whose scope IS the file.
111    //
112    // It sets the bar's DOMAIN and its SEEK POLICY together (see #scrubSeekTo):
113    // a clip-scoped drag is a nudge inside the clip and keeps the armed window,
114    // a source-scoped one means "take me somewhere else" and disarms it, exactly
115    // as seekTo() does.
116    //
117    // NOT inferred from whether a cue happens to be armed: the room cues clips
118    // constantly from the rail, the worm and the chat, so an inferred bar would
119    // silently rescale from 45 minutes to 20 seconds and back — the opposite of
120    // what a room-level scrubber is for.
121    scrubScope: { type: String, default: "" },
122    // Does this mount hold a live feed at all? Set by the server when the game
123    // has a session publishing right now, and it is what makes the bar render
124    // the LIVE badge and Jump to live in the first place.
125    //
126    // Deliberately NOT the same question as "is the picture on screen live".
127    // A room can hold last week's Vimeo file beside the live feed, and a LIVE
128    // badge over a file is a lie — so visibility is decided per source (each
129    // carries its own `live` flag) while this value only decides whether the
130    // controls exist to be shown. See #paintLive.
131    live: { type: Boolean, default: false },
132    // "Our chrome beats theirs, but not at the price of fullscreen."
133    //
134    // On a device where fullscreen_controller hides our fullscreen button, the
135    // BACKEND's own bar is the only way into fullscreen there is — so a mount
136    // carrying this keeps that bar rather than shipping a film room a coach
137    // can't make fullscreen. connect() then removes our overlay, so the two are
138    // never stacked on the same pixels.
139    //
140    // The goal modal must NOT set it: it has no fullscreen button to lose, and
141    // its native scrubber spans the whole quarter — the lie that mount exists to
142    // remove, on every device.
143    nativeControlsFallback: { type: Boolean, default: false },
144    // Legacy single-source values — a fallback when `sources` isn't supplied.
145    sourceType: String,
146    src: String,
147    streamHost: { type: String, default: "" },
148    signedToken: { type: String, default: "" }
149  };
150
151  connect() {
152    this.sources = this.#parseSources();
153    this.players = new Map();  // sourceId -> { adapter, container, duration, ready, readyPromise, resolveReady, currentTime, playing }
154    this.activeId = null;
155    this.windowStart = null;   // the cued clip's start — where a loop rewinds to
156    this.windowEnd = null;
157    this.activeClipId = null;
158    this.cueSeq = 0;
159    this.rate = 1;
160    // Read BEFORE the first #activate, so the opening source is built with the
161    // coach's standing choice rather than mounted at the default and resized a
162    // frame later (on Stream that resize is a rendition switch, i.e. a visible
163    // re-buffer on the very first clip).
164    this.quality = this.#storedQuality();
165    this.looping = false;
166    this.muted = false;
167    // The last cue, kept AFTER the window is cleared. #handleTimeUpdate nulls
168    // windowStart/End when a clip auto-ends, so replay() cannot read them — by
169    // the time a coach wants "again", they're gone. Re-cueing from here also
170    // side-steps a clip whose end sits past the file's duration: `ended` never
171    // clears the window, so replay re-arms it rather than inheriting a stuck one.
172    this.lastCue = null;
173
174    // Read ONCE. A Stimulus value getter re-runs its reader on every property
175    // access, and #paintScrub runs on the ~4/sec tick, where #handleTimeUpdate
176    // is already paying a JSON.parse for this.timelineValue.
177    this.scrubScope = this.scrubScopeValue;
178    this.scrubbing = false;    // a finger owns the thumb; incoming ticks must not fight it
179    this.lastScrubSeekAt = 0;
180    this.settle = null;        // { target, until } — see #displayTime
181
182    // The bus is window-wide, so every handler first asks "is this mine?". The
183    // Video Room has one player holding every source and the question is always
184    // yes; the timing page mounts one player PER source side by side, where an
185    // unscoped pc:video-play would start all four at once and an activate meant
186    // for another player would hide this one's video.
187    this.boundCue = (e) => { if (this.#mine(e)) this.cue(e.detail || {}); };
188    this.boundSeek = (e) => { if (this.#mine(e)) this.seekTo(e.detail?.time); };
189    this.boundActivate = (e) => { if (this.#mine(e)) this.#activate(this.#normalizeId(e.detail?.sourceId)); };
190    this.boundPlay = (e) => { if (this.#mine(e)) this.play(); };
191    this.boundPause = (e) => { if (this.#mine(e)) this.pause(); };
192    window.addEventListener("pc:video-cue", this.boundCue);
193    window.addEventListener("pc:video-seek", this.boundSeek);
194    window.addEventListener("pc:video-activate", this.boundActivate);
195    window.addEventListener("pc:video-play", this.boundPlay);
196    window.addEventListener("pc:video-pause", this.boundPause);
197
198    // Shortcuts are window-bound (in analysis the coach's hands are on the
199    // keyboard, not on a focused element), so they are opt-in per player — see
200    // #handleKeydown for why the timing page must not take them.
201    if (this.keyboardValue) {
202      this.boundKeydown = (e) => this.#handleKeydown(e);
203      window.addEventListener("keydown", this.boundKeydown);
204    }
205
206    // Paint the speed buttons from this.rate rather than trusting the markup's
207    // initial `active`, so the bar and the controller can never disagree.
208    this.#paintSpeed();
209    this.#paintQuality();
210    this.#paintTransport();
211    this.#paintScrub();
212    this.#paintLive();
213
214    // Mutually exclusive, never stacked: if the backend is keeping its own bar
215    // because it is this device's only route into fullscreen, our overlay would
216    // land on the same pixels. REMOVED rather than hidden — fullscreen_controller
217    // records the measured failure of `hidden` and .d-none against a d-flex whose
218    // display is !important. Decided here because nativeControls is fixed at
219    // construction (two backends bake it into an iframe URL) and the first
220    // adapter is built at the end of this method.
221    if (this.hasScrubRowTarget && this.#useNativeControls()) this.scrubRowTarget.remove();
222
223    // ---- the merged fullscreen toolbar ----
224    //
225    // A comment node marking where the analysis bar came from while it is up in
226    // the overlay row. A position HINT, not the merged flag — the flag has to be
227    // in the DOM to survive a reconnect, so it is the .pc-video-fs-merged class.
228    // See #mergeToolbar.
229    this.toolbarHome = null;
230    // Only the Video Room has a bar to merge — the goal-clip and stat-clip
231    // modals mount the same scrub bar with `fullscreen: true` and no
232    // _player_controls at all, and the timing page has neither. No bar, no
233    // listeners: this whole feature simply does not exist on those mounts.
234    if (this.hasControlsTarget) {
235      this.boundFullscreenChange = () => this.#paintMergedToolbar();
236      document.addEventListener("fullscreenchange", this.boundFullscreenChange);
237      document.addEventListener("webkitfullscreenchange", this.boundFullscreenChange);
238      // Turbo caches this page by snapshotting the live DOM at before-cache,
239      // which is BEFORE disconnect() runs — so restoring the bar there would be
240      // too late and the cached snapshot would hold the fullscreen layout, which
241      // is not a layout the normal room has any way to undo. Restore it while
242      // the snapshot is still being taken.
243      this.boundBeforeCache = () => this.#unmergeToolbar();
244      document.addEventListener("turbo:before-cache", this.boundBeforeCache);
245      // Capture phase, on the bar itself so it travels with it: see
246      // #leaveFullscreenForDialog. Must be capture — the Stimulus action on the
247      // button runs on bubble and shows the modal, and we need to have asked to
248      // leave fullscreen before that.
249      this.boundControlsClick = (e) => this.#leaveFullscreenForDialog(e);
250      this.controlsTarget.addEventListener("click", this.boundControlsClick, true);
251      // Reconcile once rather than assuming "a page connects out of fullscreen".
252      // Normally true — a navigation drops fullscreen — but this controller also
253      // connects against DOM Turbo has just replaced, which can happen with the
254      // frame still on screen.
255      this.#paintMergedToolbar();
256    }
257
258    // Show the primary source immediately so a whole-game room has a player up
259    // front and per-quarter footage defaults to the first quarter.
260    const primary = this.#primaryId();
261    if (primary != null) this.#activate(primary);
262  }
263
264  disconnect() {
265    window.removeEventListener("pc:video-cue", this.boundCue);
266    window.removeEventListener("pc:video-seek", this.boundSeek);
267    window.removeEventListener("pc:video-activate", this.boundActivate);
268    window.removeEventListener("pc:video-play", this.boundPlay);
269    window.removeEventListener("pc:video-pause", this.boundPause);
270    if (this.boundKeydown) window.removeEventListener("keydown", this.boundKeydown);
271    // Every document-level registration comes off, for the reason
272    // fullscreen_controller#disconnect records: Turbo caches and restores this
273    // page, so a listener left on `document` outlives the controller and
274    // double-fires on the next visit.
275    if (this.boundFullscreenChange) {
276      document.removeEventListener("fullscreenchange", this.boundFullscreenChange);
277      document.removeEventListener("webkitfullscreenchange", this.boundFullscreenChange);
278      document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
279      this.boundFullscreenChange = null;
280    }
281    // ...and the bar goes home, so a disconnect that isn't a page teardown (a
282    // Turbo Frame swap around the player, a re-render of the room) can't leave
283    // the analysis bar stranded inside the frame with nothing listening for the
284    // fullscreenchange that would bring it back.
285    this.#unmergeToolbar();
286    if (this.boundControlsClick && this.hasControlsTarget) {
287      this.controlsTarget.removeEventListener("click", this.boundControlsClick, true);
288      this.boundControlsClick = null;
289    }
290    this.players.forEach((rec) => { rec.adapter?.destroy?.(); });
291  }
292
293  // ---- public control ---------------------------------------------------
294
295  // Play a clip window { start, end, clipId, sourceId }. Loads and shows the
296  // clip's source if it isn't already active, then seeks a touch before start
297  // and arms an auto-pause at end. A later cue supersedes an in-flight one.
298  //
299  // seek:false re-arms the window WITHOUT moving the playhead. It exists for
300  // "End +5s" (video_room_controller#onExtendClip decides which cues get it):
301  // the coach presses that a second or two from the end, so seeking back to the
302  // start would make them sit through the whole clip again to reach the very
303  // thing they extended for. Defaulted to true so every other caller — the clip
304  // rail, the worm, a goal chip, an evidence launch, a ?clip= deep link — is
305  // untouched.
306  cue({ start, end, clipId = null, sourceId = null, seek = true }) {
307    const targetId = this.#normalizeId(sourceId) ?? this.#primaryId();
308    if (targetId == null) return;
309    const seq = ++this.cueSeq;
310    // Remember the clip's true start (not the preroll seek target): a loop is
311    // "play that passage again", so it rewinds to the clip, not to the run-up.
312    // Unconditional, and deliberately BEFORE the ready check: the re-arm is the
313    // half of a no-seek cue that must always happen, or extending the end would
314    // widen nothing and the clip would still stop where it used to.
315    this.windowStart = start != null ? Number(start) : null;
316    this.windowEnd = end != null ? Number(end) : null;
317    this.activeClipId = clipId;
318    this.lastCue = { start, end, clipId, sourceId: targetId };
319
320    const rec = this.#activate(targetId);
321    if (!rec) return; // a source this player doesn't hold — nothing to seek
322    // In clip scope the bar's SCALE just changed. Repaint before the seek so it
323    // never briefly shows the previous clip's span: #activate only paints when
324    // the SOURCE changed, and cueing another clip in the same file doesn't.
325    this.#paintScrub();
326    // A source that hasn't reported ready sits at second zero — nowhere near the
327    // window — so it has no position worth holding and a no-seek cue on one
328    // falls back to a normal seek. Read here rather than inside `go` because by
329    // the time the promise resolves the answer is always "ready".
330    const hold = !seek && rec.ready;
331    const go = () => {
332      if (seq !== this.cueSeq) return;
333      // Resuming is not optional on the hold path: if the clip had ALREADY hit
334      // its old end, #handleTimeUpdate has cleared the window and paused, so
335      // without this the button would look completely dead. Mid-play it is a
336      // no-op on all three backends — native, Vimeo and Stream play() all
337      // resolve against an already-playing video without re-seeking it — so one
338      // unconditional resume covers both states.
339      if (hold) rec.adapter.play();
340      else this.#seekAndPlay(rec, start);
341    };
342    if (rec.ready) go();
343    else rec.readyPromise.then(go);
344  }
345
346  // A seek from off the bus — a worm click, a chat answer's timestamp — means
347  // "take me somewhere else", so the armed clip is over. The bar's own skip
348  // buttons deliberately do NOT come through here; see skip().
349  seekTo(time) {
350    if (time == null || this.activeId == null) return;
351    this.windowStart = null;
352    this.windowEnd = null;
353    const rec = this.players.get(this.activeId);
354    this.#seek(rec, clampTime(Number(time), rec.duration));
355  }
356
357  play() { this.#active()?.adapter.play(); }
358  pause() { this.#active()?.adapter.pause(); }
359
360  // "Take me back to now." A live Stream asset reports a duration that GROWS
361  // with the stream, so the live edge is simply the far end of it — but seeking
362  // exactly to duration lands past the last decodable frame and stalls, which is
363  // why the target comes out of liveEdgeTarget with a margin rather than being
364  // rec.duration. Null target = the player hasn't reported a duration yet, so
365  // there is no edge to go to and the button does nothing rather than seeking to
366  // second zero.
367  //
368  // Routed through seekTo() on purpose, so it inherits that contract: a jump to
369  // live is "take me somewhere else", and whatever clip was armed is over.
370  jumpToLive() {
371    const rec = this.#active();
372    const target = liveEdgeTarget(rec?.duration);
373    if (target == null) return;
374    this.seekTo(target);
375    rec.adapter.play();
376    // Paint from our own optimism rather than waiting for the next tick: the
377    // badge going red is the feedback that the press worked, and a quarter of a
378    // second of dimmed badge after pressing it reads as a dead button.
379    this.#paintLive();
380  }
381
382  // ---- minimal transport (the goal-clip modal) --------------------------
383  //
384  // Three actions, deliberately no more. Everything else a coach might want —
385  // scrubbing, slow-mo, loop, frame-stepping — lives in the Video Room, which
386  // the modal links out to. See the nativeControls note at the top of the file.
387
388  // Public wrapper: #togglePlay is private and was reachable only from the
389  // keyboard, which this mount never takes.
390  togglePlay() { this.#togglePlay(); }
391
392  // "Show me that again." Re-cues the clip we last played rather than seeking
393  // to windowStart, because the auto-pause has already cleared the window by
394  // the time this button is worth pressing.
395  replay() {
396    if (!this.lastCue) return;
397    this.cue({ ...this.lastCue, seek: true });
398  }
399
400  // Mute is controller-level, not per-source: a per-quarter game switches
401  // sources mid-review and the setting has to survive that (#activate re-applies
402  // it, the same way it re-applies this.rate).
403  toggleMute() {
404    this.muted = !this.muted;
405    this.#active()?.adapter.setMuted?.(this.muted);
406    this.#paintTransport();
407  }
408
409  // ---- scrub bar --------------------------------------------------------
410  //
411  // Two actions, because `input` and `change` mean different things on a range:
412  // `input` is every pixel of a drag, `change` is the release (and every
413  // keypress, which fires both). The drag paints locally and seeks on a
414  // throttle; the release forces the last seek so the playhead always lands
415  // exactly where the thumb was let go.
416  scrub(event) {
417    this.scrubbing = true;
418    this.#scrubSeekTo(Number(event.target.value), false);
419  }
420
421  scrubCommit(event) {
422    this.#scrubSeekTo(Number(event.target.value), true);
423    this.scrubbing = false;
424    this.#paintScrub();
425  }
426
427  #scrubSeekTo(value, force) {
428    const rec = this.#active();
429    const win = this.#scrubWindow();
430    if (!rec || !win) return;
431    const target = scrubSeekTarget(win, value);
432    // The read-out follows the finger on EVERY event, thrown seek or not: no
433    // backend reports position until its next tick (~250ms on Vimeo) and none of
434    // them subscribes to `seeked`, so waiting for the bus would leave the label
435    // a quarter-second behind the thumb.
436    this.#announceScrub(win, target);
437
438    const now = performance.now();
439    if (!force && now - this.lastScrubSeekAt < SCRUB_SEEK_MIN_MS) return;
440    this.lastScrubSeekAt = now;
441
442    if (this.scrubScope === "clip") {
443      // #skipBy's policy, for #skipBy's reason. A move INSIDE the clip you're
444      // dissecting is a nudge, so the window stays armed and the auto-pause and
445      // the loop survive it; landing AT or past the cued end is "show me what
446      // happens next" and disarms it, exactly as +10s already does — otherwise
447      // the next tick reads the arrival as "the clip finished".
448      //
449      // Deliberately NOT seekTo(): that nulls windowStart/windowEnd
450      // unconditionally, so one drag inside a goal clip would kill its
451      // auto-pause and the ~30s clip would run on into the rest of the quarter.
452      if (windowReachedEnd(target, this.windowEnd)) {
453        this.windowStart = null;
454        this.windowEnd = null;
455        this.activeClipId = null;
456      }
457      this.#seek(rec, target);
458    } else {
459      // Whole-file scope: dragging IS "take me somewhere else", so the armed
460      // window goes with it — the same contract as a worm click. One deliberate
461      // consequence worth knowing: pc:video-clip-ended won't fire for that clip,
462      // so "Play all" stops auto-advancing until the next cue.
463      this.seekTo(target);
464    }
465  }
466
467  // ---- control bar ------------------------------------------------------
468
469  // Relative skip (-10 / -5 / +10 from the bar, the same from the keyboard).
470  // Unlike seekTo() this KEEPS an armed window: a skip is a nudge inside the
471  // clip you're already watching ("run that back"), not a jump out of it, so the
472  // auto-pause and the loop stay armed. The corollary is that skipping forward
473  // past the window's end finishes (or loops) the clip on the next tick — which
474  // is the honest reading of "skip past the end of this clip".
475  skip(event) {
476    this.#skipBy(Number(event.params.delta));
477  }
478
479  setSpeed(event) {
480    this.#setRate(Number(event.params.rate));
481  }
482
483  setQuality(event) {
484    this.#setQuality(event.params.quality);
485  }
486
487  // Loop is a mode, not a one-shot: while it's on, every time playback reaches
488  // the cued window's end we rewind and re-arm (see #handleTimeUpdate).
489  toggleLoop() {
490    this.looping = !this.looping;
491    if (!this.hasLoopBtnTarget) return;
492    this.#paintSelected(this.loopBtnTarget, this.looping);
493  }
494
495  // Clip-level moves belong to the rail — it owns the filtered list and which
496  // clip is current — so the bar only asks for them. Routing them through this
497  // controller (rather than hanging a video-room action off the bar) keeps the
498  // whole bar wired one way and works whether or not a rail is on the page.
499  nextClip() { this.#dispatch("pc:video-next-clip"); }
500  prevClip() { this.#dispatch("pc:video-prev-clip"); }
501
502  // Widen the playing clip. The captured event stamps the moment of the kick,
503  // so the build-up the coach wants to show usually sits before it — hence the
504  // asymmetry in which end each button moves.
505  extendStart() { this.#dispatch("pc:video-extend-clip", { startDelta: -5, endDelta: 0 }); }
506  extendEnd() { this.#dispatch("pc:video-extend-clip", { startDelta: 0, endDelta: 5 }); }
507
508  // ---- keyboard ---------------------------------------------------------
509
510  // Bound on window, and only when keyboard-value says so: the Video Room has
511  // one player and wants the shortcuts, while the timing page mounts a player
512  // PER source side by side — one window handler there would drive all of them
513  // at once. Once the coach clicks INTO a Vimeo/Stream iframe the browser sends
514  // keys to the iframe and we stop hearing them; that's unavoidable, and it's
515  // why every shortcut also has a button.
516  #handleKeydown(event) {
517    // The Ask AI Brain chat input is on this very page — typing a "j" into a
518    // question must never seek the video.
519    if (isTypingTarget(document.activeElement)) return;
520    // An open modal covers the room and takes the focus, so its keys are the
521    // modal's. Bootstrap parks focus on the dialog itself (a DIV, so neither the
522    // typing guard nor #activatesOnSpace catches it) — without this, Space in the
523    // scrollable "How Video Analysis works" tour is swallowed as play/pause: the
524    // help doesn't page down and the vision starts playing, with sound, behind it.
525    if (document.querySelector(".modal.show")) return;
526    const action = shortcutAction(event);
527    if (!action) return;
528    // Space still has to activate whatever the coach has tabbed to.
529    if (event.key === " " && this.#activatesOnSpace(document.activeElement)) return;
530    if (this.activeId == null) return; // nothing mounted — leave the key alone
531
532    switch (action) {
533      case "playPause": this.#togglePlay(); break;
534      case "back5": this.#skipBy(-5); break;
535      case "forward5": this.#skipBy(5); break;
536      case "back10": this.#skipBy(-10); break;
537      case "forward10": this.#skipBy(10); break;
538      case "frameBack": this.#stepFrame(-1); break;
539      case "frameForward": this.#stepFrame(1); break;
540      case "slower": this.#stepRate(-1); break;
541      case "faster": this.#stepRate(1); break;
542      case "prevClip": this.prevClip(); break;
543      case "nextClip": this.nextClip(); break;
544      case "toggleLoop": this.toggleLoop(); break;
545      // An action the util grew and this controller hasn't wired: keep the key's
546      // browser default rather than swallowing it into a no-op.
547      default: return;
548    }
549    // Only once something was actually done — an unhandled key keeps Space
550    // scrolling and the arrows moving focus.
551    event.preventDefault();
552  }
553
554  // Space is play/pause everywhere except on a control Space is meant to press.
555  // (input/textarea/select/contenteditable are already gone via isTypingTarget.)
556  #activatesOnSpace(el) {
557    return [ "BUTTON", "A", "SELECT" ].includes(el?.tagName);
558  }
559
560  // rec.playing is fed by the backend's own play/pause events, so it stays right
561  // even when the coach uses the native control bar instead of our buttons.
562  #togglePlay() {
563    const rec = this.#active();
564    if (!rec) return;
565    if (rec.playing) rec.adapter.pause();
566    else rec.adapter.play();
567  }
568
569  // A single frame only reads as a step when the picture is still, so pause
570  // first — rolling playback eats the 40ms before the eye catches it.
571  #stepFrame(direction) {
572    const rec = this.#active();
573    if (!rec) return;
574    rec.adapter.pause();
575    this.#seek(rec, skipTarget(rec.currentTime, direction * FRAME_STEP_SECONDS, rec.duration));
576  }
577
578  // Step through the presets WITHOUT wrapping: at 0.25× "slower" stays at 0.25×.
579  // Jumping from slowest straight to fastest on a keypress is a nasty surprise
580  // when you're feathering through a passage of play.
581  #stepRate(direction) {
582    const i = SPEED_PRESETS.indexOf(this.rate);
583    const from = i === -1 ? SPEED_PRESETS.indexOf(1) : i; // a rate off the list restarts at 1×
584    const next = Math.min(SPEED_PRESETS.length - 1, Math.max(0, from + direction));
585    this.#setRate(SPEED_PRESETS[next]);
586  }
587
588  // ---- bar internals (shared by the buttons and the shortcuts) ----------
589
590  #skipBy(delta) {
591    const rec = this.#active();
592    if (!rec) return;
593    const target = skipTarget(rec.currentTime, delta, rec.duration);
594    // A forward skip that lands AT or past the cued end is the coach asking to
595    // see what happens next, so disarm the window rather than letting the next
596    // tick treat the arrival as "the clip finished". This is the common case,
597    // not a corner: a point clip is 30s (GameVideo::GOAL_CLIP_PADDING_SECONDS
598    // either side of the goal) and the skip is 10s, so +10s anywhere in the
599    // clip's last third used to freeze on the very frame it skipped to — and
600    // with "Play all" ticked, pc:video-clip-ended handed the session on to the
601    // NEXT clip, i.e. "forward 10 seconds" teleported the coach off the passage
602    // they were dissecting. Backward skips can't trigger it (windowReachedEnd is
603    // false below the end, and false outright once the window is null).
604    if (windowReachedEnd(target, this.windowEnd)) {
605      this.windowStart = null;
606      this.windowEnd = null;
607      this.activeClipId = null;
608    }
609    this.#seek(rec, target);
610  }
611
612  #setRate(rate) {
613    this.rate = rate;
614    // #activate re-applies this.rate to whatever source comes next, so a chosen
615    // speed survives switching from Q1's file to Q3's mid-analysis.
616    this.#active()?.adapter.setRate(this.rate);
617    this.#paintSpeed();
618  }
619
620  // Where the coach's standing quality choice lives. Per USER, not per game: it
621  // is a statement about their screen and their connection, and re-picking it
622  // every time they open a different match would be the whole point of the
623  // control lost. localStorage rather than a column for the same reason — it
624  // follows the device, and the coach reviewing on a hotspot at the ground is
625  // often the same person who reviews on a big monitor at home.
626  //
627  // Both accessors swallow: Safari's private mode throws on localStorage, and a
628  // film room that won't load because it couldn't remember a menu choice would
629  // be a much worse bug than a menu that forgets.
630  static QUALITY_STORAGE_KEY = "pc:video-quality";
631
632  #storedQuality() {
633    try {
634      return normaliseQuality(window.localStorage.getItem(this.constructor.QUALITY_STORAGE_KEY));
635    } catch {
636      return QUALITY_AUTO;
637    }
638  }
639
640  #setQuality(value) {
641    this.quality = normaliseQuality(value);
642    try {
643      window.localStorage.setItem(this.constructor.QUALITY_STORAGE_KEY, this.quality);
644    } catch { /* private mode — the choice still applies for this session */ }
645    // Same shape as #setRate: apply to what's on screen now, and let #activate
646    // re-apply to whatever the coach switches to next.
647    this.#active()?.adapter.setQuality?.(this.quality);
648    this.#paintQuality();
649  }
650
651  // The menu, the label, and whether the control exists at all.
652  //
653  // Visibility is per BACKEND and decided here rather than server-side, because
654  // a room can hold sources of one type today and the active one is what the
655  // button would be driving: a native <video> on a plain MP4 has a single
656  // rendition, so a quality menu over it would be four buttons that all do
657  // nothing. Hidden with d-none (not removed) — switching sources switches it
658  // back on, and the room does that constantly.
659  #paintQuality() {
660    if (this.hasQualityMenuTarget) {
661      const supported = this.#active()?.adapter.supportsQuality === true;
662      this.qualityMenuTarget.classList.toggle("d-none", !supported);
663    }
664    this.qualityBtnTargets.forEach((btn) => {
665      const on = btn.dataset.videoPlayerQualityParam === this.quality;
666      btn.classList.toggle("active", on);
667      btn.querySelector("i")?.classList.toggle("opacity-0", !on);
668      if (on) btn.setAttribute("aria-current", "true");
669      else btn.removeAttribute("aria-current");
670    });
671    if (this.hasQualityLabelTarget) {
672      this.qualityLabelTarget.textContent = qualityLabel(this.qual
672ity);
673    }
674    // HD/SD badge. Toggled rather than assigned, so the icon keeps the sizing
675    // classes it was rendered with.
676    if (this.hasQualityIconTarget) {
677      const icon = qualityIcon(this.quality);
678      this.qualityIconTarget.classList.toggle("bi-badge-hd", icon === "bi-badge-hd");
679      this.qualityIconTarget.classList.toggle("bi-badge-sd", icon === "bi-badge-sd");
680    }
681  }
682
683  #paintSpeed() {
684    this.speedBtnTargets.forEach((btn) => {
685      const on = Number(btn.dataset.videoPlayerRateParam) === this.rate;
686      // Two shapes of speed control, one paint. A dropdown ITEM marks itself with
687      // .active (plus a tick, since .active alone is a faint background); a bare
688      // button gets the btn-primary swap, which exists to dodge the unscoped
689      // .btn-outline-light block in _game.scss. A dropdown item never wore that
690      // class, so it has no reason to pay for the workaround.
691      if (btn.classList.contains("dropdown-item")) {
692        btn.classList.toggle("active", on);
693        btn.querySelector("i")?.classList.toggle("opacity-0", !on);
694        if (on) btn.setAttribute("aria-current", "true");
695        else btn.removeAttribute("aria-current");
696      } else {
697        this.#paintSelected(btn, on);
698      }
699    });
700    // The label is the honest part. Where five presets could only show "none
701    // selected" for a rate off the ladder — Vimeo's own 0.75, or a rate the
702    // backend refused and reported back through onRateChange — this names what
703    // is actually playing.
704    if (this.hasSpeedLabelTarget) {
705      this.speedLabelTarget.textContent = formatPlaybackRate(this.rate);
706    }
707  }
708
709  // Repaint the minimal transport from state. Every target is optional, so this
710  // is a no-op on the Video Room and the timing page — they have none of them.
711  //
712  // Called whenever the backend reports play/pause or buffering, which is what
713  // keeps the icons honest: the state comes from the player, never from our own
714  // calls, so an autoplay block or a stall shows the truth.
715  #paintTransport() {
716    const rec = this.#active();
717
718    if (this.hasPlayBtnTarget) {
719      const playing = !!rec?.playing;
720      const icon = this.playBtnTarget.querySelector("i");
721      if (icon) icon.className = `bi ${playing ? "bi-pause-fill" : "bi-play-fill"} fs-5 lh-1`;
722      const label = playing ? "Pause" : "Play";
723      this.playBtnTarget.setAttribute("aria-label", label);
724      this.playBtnTarget.setAttribute("title", label);
725    }
726
727    if (this.hasMuteBtnTarget) {
728      const icon = this.muteBtnTarget.querySelector("i");
729      if (icon) icon.className = `bi ${this.muted ? "bi-volume-mute-fill" : "bi-volume-up-fill"} fs-5 lh-1`;
730      // The NAME stays "Mute" and aria-pressed carries the state. Flipping both
731      // (name -> "Unmute" AND pressed -> true) announces "Unmute, pressed",
732      // which reads as "unmute is switched on" — the opposite of the truth. A
733      // toggle button names the thing it controls once and lets the state change
734      // underneath it. The tooltip still tracks the ACTION, because a mouse user
735      // gets no pressed state and wants to know what the click will do.
736      this.muteBtnTarget.setAttribute("aria-label", "Mute");
737      this.muteBtnTarget.setAttribute("title", this.muted ? "Unmute" : "Mute");
738      this.muteBtnTarget.setAttribute("aria-pressed", this.muted ? "true" : "false");
739    }
740
741    // Only while genuinely waiting AND meant to be playing: a paused clip isn't
742    // buffering, and a spinner over a deliberately-paused frame reads as broken.
743    if (this.hasBufferTarget) {
744      this.bufferTarget.classList.toggle("d-none", !(rec?.buffering && rec?.playing));
745    }
746  }
747
748  // The bar's domain, in absolute video seconds. Clip scope reads this.lastCue,
749  // NOT windowStart/windowEnd — #handleTimeUpdate nulls those the instant a clip
750  // auto-ends, i.e. precisely when a coach reaches for the bar to go back.
751  // lastCue is kept alive for exactly this reason, and is what replay() already
752  // leans on.
753  // Does the backend draw its own control bar? Fixed for the life of the mount —
754  // two of the three backends bake it into an iframe URL — which is why this is
755  // resolved in connect(), before the first #buildAdapter.
756  #useNativeControls() {
757    if (this.nativeControlsValue) return true;
758    return this.nativeControlsFallbackValue && !elementFullscreenAvailable();
759  }
760
761  #scrubWindow() {
762    if (!this.scrubScope) return null;
763    const rec = this.#active();
764    const duration = Number.isFinite(rec?.duration) ? rec.duration : null;
765    return scrubWindow(this.scrubScope === "clip" ? this.lastCue : null, duration);
766  }
767
768  // What the bar should show, in priority order:
769  //   1. our own seek target, until a tick corroborates it — this is what stops
770  //      the thumb snapping backwards on release, and what finally gives the
771  //      skip buttons instant feedback on the two iframe backends
772  //   2. the backend's own clock
773  #displayTime(rec) {
774    if (!rec) return 0;
775    if (this.settle) {
776      if (performance.now() >= this.settle.until) this.settle = null;
777      else if (Math.abs(rec.currentTime - this.settle.target) > SEEK_SETTLE_TOLERANCE) {
778        return this.settle.target;
779      } else this.settle = null;   // the backend caught up
780    }
781    return rec.currentTime;
782  }
783
784  // Repaint the scrub bar from state. Every target is optional, exactly like
785  // #paintTransport, so this is a no-op on the timing page (one barless player
786  // per source) and on the public clip page.
787  //
788  // Reads ONLY plain instance fields, never a Stimulus *Value: value getters
789  // re-run their reader on every access and this runs on the ~4/sec tick.
790  //
791  // DISABLED rather than zeroed while there's no honest scale. In the modal the
792  // cue arrives from an async fetch after the dialog is already on screen, and
793  // in the room rec.duration is null until the backend reports ready — a
794  // 0-length track would claim the clip is empty.
795  #paintScrub() {
796    if (!this.hasScrubTarget) return;
797    if (this.scrubbing) return;   // an incoming tick must not yank the thumb
798
799    const bar = this.scrubTarget;
800    const rec = this.#active();
801    const win = this.#scrubWindow();
802
803    if (!win) {
804      bar.disabled = true;
805      bar.max = "0";
806      bar.value = "0";
807      bar.removeAttribute("aria-valuetext");
808      if (this.hasScrubClockTarget) this.scrubClockTarget.textContent = "–:–– / –:––";
809      return;
810    }
811
812    const span = win.end - win.start;
813    const max = String(span);
814    bar.disabled = false;
815    // Only when it actually changed: this runs ~4/sec and writing max makes the
816    // range re-validate its value.
817    if (bar.max !== max) {
818      bar.max = max;
819      // ~0.5% of the span per arrow press: 0.15s on a 30s clip, ~13s on a
820      // 45-minute file. Reached only in the MODAL, where no keyboard handler is
821      // bound; in the ROOM #handleKeydown claims the arrows as the ±5s skips and
822      // preventDefaults, so the range never steps.
823      bar.step = String(Math.max(0.05, span / 200));
824    }
825    this.#paintScrubPosition(win, this.#displayTime(rec));
826  }
827
828  // Visual only — safe to run on the tick.
829  #paintScrubPosition(win, at) {
830    const offset = scrubOffset(win, at);
831    this.scrubTarget.value = String(offset);
832    if (this.hasScrubClockTarget) {
833      this.scrubClockTarget.textContent =
834        `${formatClock(offset)} / ${formatClock(win.end - win.start)}`;
835    }
836  }
837
838  // Repaint the LIVE badge and Jump to live from the PLAYER, never from the
839  // server. The server knows the session is publishing; only the player knows
840  // whether this coach is watching it happen or has scrubbed twenty minutes back
841  // into the DVR window, and that is the whole distinction the badge exists to
842  // draw. Runs on the same ~4/sec tick as the scrub bar.
843  //
844  // Two questions, kept separate on purpose (see the `live` value): live-value
845  // says this MOUNT has a live feed among its sources, while the ACTIVE source's
846  // own flag says the picture on screen IS that feed. A room can hold last
847  // week's upload beside the live source, and a LIVE badge over a file is a lie
848  // — so the controls hide rather than mislead when the coach switches away.
849  #paintLive() {
850    if (!this.liveValue || !this.hasLiveBadgeTarget) return;
851
852    const source = this.activeId == null ? null : this.#sourceById(this.activeId);
853    const showing = !!source?.live;
854    this.liveBadgeTarget.classList.toggle("d-none", !showing);
855    if (this.hasJumpLiveTarget) this.jumpLiveTarget.classList.toggle("d-none", !showing);
856    if (!showing) return;
857
858    const rec = this.#active();
859    // #displayTime, NOT rec.currentTime — the same precedence the scrub bar
860    // uses, and for the same reason. Every backend reports position only on its
861    // next tick, which may have been queued BEFORE our seek landed; reading the
862    // raw clock here meant a stale tick arriving a beat after jumpToLive() undid
863    // the optimistic repaint that method performs specifically to stop the badge
864    // flicking back to grey. Null (rather than #displayTime's 0 fallback) when
865    // there is no record at all: live_playback rejects an unknown playhead, and
866    // a fabricated zero would report the whole stream as how far behind we are.
867    const at = rec ? this.#displayTime(rec) : null;
868    const atEdge = isAtLiveEdge(at, rec?.duration);
869    // Red while it IS live, grey once it's a replay. Same swap the rest of the
870    // bar uses for state (see #paintSelected) rather than an opacity fade, which
871    // on a coloured badge reads as "disabled" instead of "not right now".
872    this.liveBadgeTarget.classList.toggle("text-bg-danger", atEdge);
873    this.liveBadgeTarget.classList.toggle("text-bg-secondary", !atEdge);
874    if (this.hasLiveBehindTarget) {
875      const behind = behindLiveLabel(at, rec?.duration);
876      this.liveBehindTarget.textContent = behind || "";
877      this.liveBehindTarget.classList.toggle("d-none", !behind);
878    }
879    // At the edge there is nowhere to jump TO. Disabled rather than hidden: a
880    // control that vanished and returned as the coach scrubbed would shuffle
881    // every other button in the bar along with it, several times a minute.
882    if (this.hasJumpLiveTarget) this.jumpLiveTarget.disabled = atEdge;
883  }
884
885  // ...plus aria-valuetext, and ONLY from a user-initiated change.
886  //
887  // aria-valuenow on a time slider announces a bare "12", which is meaningless,
888  // so the spoken value has to be aria-valuetext. But a screen reader re-announces
889  // it on every mutation of the focused element and the tick fires ~4/sec — the
890  // same flood the game clock beside it deliberately refuses to be. So the tick
891  // moves the thumb and the label silently; only a drag or a keypress speaks.
892  #announceScrub(win, at) {
893    this.#paintScrubPosition(win, at);
894    this.scrubTarget.setAttribute(
895      "aria-valuetext",
896      `${formatClock(scrubOffset(win, at))} of ${formatClock(win.end - win.start)}`
897    );
898  }
899
900  // Mark a bar button selected. The state is a CLASS SWAP rather than .active —
901  // _game.scss:1668 styles `.btn-outline-light.active` app-wide with a hardcoded
902  // Bootstrap blue, and the bar wants the brand green. Swapping btn-primary for
903  // btn-outline-secondary is how video_room_controller#applyFilter paints its
904  // filter chips, so the two selected-states on this page agree without either
905  // needing a CSS override.
906  #paintSelected(btn, on) {
907    btn.classList.toggle("btn-primary", on);
908    btn.classList.toggle("btn-outline-secondary", !on);
909    // .menu-btn is what makes the bar match the Video Settings menu above it, but
910    // every one of its properties is !important — left on, its grey background
911    // would paint straight over btn-primary's brand green and the selected state
912    // would be invisible. So it comes off with the outline variant.
913    btn.classList.toggle("menu-btn", !on);
914    btn.setAttribute("aria-pressed", on ? "true" : "false");
915  }
916
917  // ---- merged fullscreen toolbar ----------------------------------------
918  //
919  // THE PROBLEM. There are two control surfaces on this player: the in-frame
920  // overlay row (_scrub_bar.html.erb — play, track, time, speed, quality, loop,
921  // mute, fullscreen), which is a .ratio child of .pc-video-frame, and the
922  // analysis bar (_player_controls.html.erb — clip stepping, ±5/±10s, the game
923  // clock, the clip nudges, Add to playlist), which is a SIBLING of that frame.
924  // The overlay's fullscreen button fullscreens the frame, and the fullscreen
925  // layer renders that element's SUBTREE — so a coach who filled the screen with
926  // the vision lost every analysis control at once. (The film-session button
927  // fullscreens .pc-video-fs-root, which contains both, and is unaffected.)
928  //
929  // A DOM MOVE, NOT CSS. Nothing styled on a sibling can put it into the
930  // fullscreen layer, and nothing rendered twice can carry the state the bar
931  // already has (a live game clock, the LIVE badge, the muted Add-to-playlist).
932  // Moving the one node into the overlay row solves both by construction: the
933  // painted state travels with it, and every #paint* method keeps writing to the
934  // same elements it always did without knowing where they are.
935  //
936  // ONE NODE + display: contents, rather than moving the bar's children
937  // individually. Moving children would mean remembering N positions, would
938  // strand the bar's bootstrap-init wrapper, and would fan the groups out around
939  // the row. The class (_utilities.scss) dissolves only the bar's BOX, so its
940  // groups become items of the overlay row directly and the row's existing
941  // flex-wrap breaks the merged toolbar onto a second line on a narrow screen.
942  //
943  // STRICT IDENTITY against THIS player's frame, never "is anything fullscreen".
944  // Fullscreen nests — the in-frame button works from inside a film session and
945  // pushes the frame on top of it — so a film session must NOT merge (its bar is
946  // already on screen down the page), and popping from frame back to film
947  // session must unmerge. Both fall out of `=== #frame()` and neither falls out
948  // of a truthiness test. The same reasoning fullscreen_controller#toggle
949  // records, for the same nesting.
950  #paintMergedToolbar() {
951    if (this.#frameIsFullscreen()) this.#mergeToolbar();
952    else this.#unmergeToolbar();
953  }
954
955  // The CLASS is the state, not this.toolbarHome. Both methods are idempotent
956  // off it, and it is the one signal that survives a reconnect: an instance
957  // field is re-initialised by connect(), so a controller that reconnects
958  // against DOM left in the merged state would otherwise merge a second time and
959  // then "restore" the bar to a position inside the very row it is trying to
960  // leave. The comment node below is a position HINT, not the flag.
961  #mergeToolbar() {
962    // No overlay row to merge INTO. The native-controls fallback removes it
963    // wholesale on a device where element fullscreen is unavailable — which is
964    // also a device where the button that would trigger this is hidden, so this
965    // guard is belt-and-braces rather than a live path.
966    if (!this.hasControlsTarget || !this.hasScrubTarget) return;
967
968    const bar = this.controlsTarget;
969    if (bar.classList.contains("pc-video-fs-merged")) return;   // already up there
970
971    // BEFORE the move, not after: replaceWith detaches the bar, and a detached
972    // bar is not inside this.element, so a Stimulus target lookup through it
973    // comes back empty for as long as it is off the tree.
974    this.#suspendBarTooltips();
975    // A comment node rather than a remembered { parent, nextSibling } pair: it
976    // IS the position, so it survives anything else reordering the mount's
977    // children while the coach is in fullscreen, and putting the bar back is one
978    // call that cannot land it in the wrong place.
979    this.toolbarHome = document.createComment("video-controls");
980    bar.replaceWith(this.toolbarHome);
981    bar.classList.add("pc-video-fs-merged");
982    // Immediately before the track, i.e. after the play button: position
983    // controls left, scrub in the middle, modes right — the universal player
984    // layout. See _scrub_bar.html.erb for the resulting order in full.
985    this.scrubTarget.before(bar);
986  }
987
988  #unmergeToolbar() {
989    if (!this.hasControlsTarget) return;
990
991    const bar = this.controlsTarget;
992    if (!bar.classList.contains("pc-video-fs-merged")) return;   // already home
993
994    bar.classList.remove("pc-video-fs-merged");
995    const home = this.toolbarHome;
996    this.toolbarHome = null;
997    // The fallback is the server-rendered position rather than a guess:
998    // _player.html.erb renders the bar as the LAST child of this element, after
999    // the frame. It only matters when the hint is gone (a reconnect dropped it),
1000    // and it lands the bar in exactly the same place the hint would have.
1001    if (home?.parentNode) home.replaceWith(bar);
1002    else this.element.appendChild(bar);
1003    // AFTER the move, the mirror of #mergeToolbar's ordering and for the same
1004    // reason: the bar has to be back under this.element for controlsTarget to
1005    // resolve to it.
1006    this.#resumeBarTooltips();
1007  }
1008
1009  // TOOLTIPS: suspended on the way in, resumed on the way out.
1010  //
1011  // Every button in the analysis bar carries data-bs-container=".pc-vi
1011deo-fs-root"
1012  // so its bubble renders inside a film session (Bootstrap's default container is
1013  // <body>, which that fullscreen layer doesn't show). In FRAME fullscreen
1014  // .pc-video-fs-root is itself outside the rendered subtree, so the same
1015  // attribute now points at an invisible layer — the measured behaviour for
1016  // exactly this mistake is recorded in _player_controls.html.erb's header: the
1017  // tip lands at 0,0 behind the picture.
1018  //
1019  // WHY NOT REBUILD THEM WITH container ".pc-video-frame". Because the frame is a
1020  // .ratio, and `.ratio > *` — emitted AFTER _tooltip.scss at equal specificity,
1021  // so it wins — would restyle the bubble to top:0/left:0/100%/100% of the frame,
1022  // i.e. a full-screen box with the arrow in its corner, before overflow-hidden
1023  // gets a say. The overlay row's own inner div is no better: it is a flex
1024  // container, so a tooltip parked in it becomes a flex ITEM and reflows the row
1025  // every time one is shown.
1026  //
1027  // So they fall back to the plain `title` attribute, which is what the surface
1028  // the bar is joining already uses — the overlay's play, mute, loop, speed,
1029  // quality and fullscreen buttons all carry a bare title and no Bootstrap
1030  // tooltip (see _scrub_bar.html.erb). One behaviour across the merged toolbar,
1031  // and no second tooltip configuration to keep in sync with the markup.
1032  //
1033  // The title round-trip is safe in both directions, which is the part that is
1034  // easy to get wrong (fullscreen_controller#repaint documents the same trap):
1035  // Tooltip's constructor MOVES `title` into data-bs-original-title and strips
1036  // `title`, and dispose() writes it back from that attribute — so a disposed
1037  // button has a live native title again, and rebuilding moves it across once
1038  // more. Nothing is stranded and nothing is duplicated.
1039  //
1040  // PARKING THE TRIGGER ATTRIBUTE is what makes this hold. Moving an element
1041  // makes Stimulus disconnect and RECONNECT any controller on it, and this bar
1042  // carries data-controller="bootstrap-init", whose connect() re-initialises
1043  // every [data-bs-toggle="tooltip"] in its subtree. Disposing alone would be
1044  // silently undone one microtask later, with the container that doesn't work
1045  // here. With the attribute parked there is nothing left for it — or for
1046  // anything else that re-scans the page — to find.
1047  #suspendBarTooltips() {
1048    if (!this.hasControlsTarget) return;
1049
1050    this.controlsTarget.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
1051      window.bootstrap?.Tooltip?.getInstance(el)?.dispose();
1052      el.removeAttribute("data-bs-toggle");
1053      el.dataset.pcTooltipParked = "true";
1054    });
1055  }
1056
1057  #resumeBarTooltips() {
1058    if (!this.hasControlsTarget) return;
1059
1060    this.controlsTarget.querySelectorAll("[data-pc-tooltip-parked]").forEach((el) => {
1061      el.setAttribute("data-bs-toggle", "tooltip");
1062      delete el.dataset.pcTooltipParked;
1063      // bootstrap-init's own reconnect would get here too, one microtask later,
1064      // and skip whatever already has an instance — so this is belt-and-braces
1065      // rather than a duplicate. createTooltip, not `new Tooltip`, so the rebuilt
1066      // ones keep the app's touch handling: on a touch device it declines to
1067      // build one on an interactive element at all, which is what makes this
1068      // whole suspend/resume a symmetric no-op there.
1069      if (window.bootstrap?.Tooltip && !window.bootstrap.Tooltip.getInstance(el)) createTooltip(el);
1070    });
1071  }
1072
1073  // ADD TO PLAYLIST: leave frame fullscreen, then let the press through.
1074  //
1075  // It is the only control in the bar that opens a modal, and both of its
1076  // destinations (#videoPlaylistModal and the add-to-playlist hint) are rendered
1077  // inside .pc-video-fs-root but OUTSIDE .pc-video-frame — deliberately, so they
1078  // work mid film session. In frame fullscreen that puts them in the layer
1079  // nothing renders: the coach would press a button and get silence.
1080  //
1081  // The alternative was to move the modals into the frame the way the bar moves,
1082  // and it does not survive contact with the frame's own CSS. The frame is a
1083  // .ratio with overflow-hidden, and Bootstrap emits its .ratio helper AFTER
1084  // _modal.scss at equal specificity — so `.ratio > *` would win and restyle the
1085  // modal from position:fixed to absolute, stretched to the frame and clipped by
1086  // it. On top of that Bootstrap appends the backdrop to <body>, which is outside
1087  // the frame either way, so the dialog would float undimmed over live video. Two
1088  // structural fights and a degraded result, against one line here.
1089  //
1090  // Exiting is also the honest reading of the press: filling the screen with the
1091  // vision is a mode for a room of people, and naming a playlist is not something
1092  // you do in it. Nested, it pops back to the FILM SESSION rather than out to the
1093  // page — the fullscreen element stack is a stack — which is precisely where
1094  // this modal has always worked.
1095  //
1096  // Capture phase and deliberately NOT preventDefault/stopPropagation: the press
1097  // still reaches video-room#addCuedToPlaylist and shows the modal synchronously,
1098  // while the exit resolves a frame or two later. The modal is mid-fade for that
1099  // window and lands on a normal page, rather than being cancelled and re-issued.
1100  #leaveFullscreenForDialog(event) {
1101    if (!event.target?.closest?.("[data-video-room-target='addToPlaylistBtn']")) return;
1102    if (!this.#frameIsFullscreen()) return;
1103
1104    const exit = document.exitFullscreen || document.webkitExitFullscreen;
1105    exit?.call(document)?.catch?.(() => {});
1106  }
1107
1108  // The null check is not defensive noise: with no frame, `#current() ===
1109  // #frame()` would be `null === null` on a page that isn't in fullscreen at all
1110  // — i.e. every player without a frame would think it was fullscreened.
1111  #frameIsFullscreen() {
1112    const frame = this.#frame();
1113    return frame != null && this.#currentFullscreen() === frame;
1114  }
1115
1116  // This player's own frame — .pc-video-frame is scoped to the controller
1117  // element, never the document, because the game dashboard renders the goal
1118  // modal and the stat-clips modal into the same page and a document lookup
1119  // would hand every player the first one.
1120  #frame() {
1121    return this.element.querySelector(".pc-video-frame");
1122  }
1123
1124  #currentFullscreen() {
1125    return document.fullscreenElement || document.webkitFullscreenElement || null;
1126  }
1127
1128  // Does this player hold the event's source? An event with no sourceId is
1129  // addressed to whoever is listening — true for the Video Room's single
1130  // player, and on the timing page the panel always names the source.
1131  #mine(event) {
1132    const raw = event?.detail?.sourceId;
1133    if (raw == null) return true;
1134    const id = this.#normalizeId(raw);
1135    return id != null && !!this.#sourceById(id);
1136  }
1137
1138  // ---- source management ------------------------------------------------
1139
1140  // Show `id` (building its backend on first use), hiding the previously active
1141  // one. Returns the source record. No-op when it's already active.
1142  #activate(id) {
1143    if (id == null) return null;
1144    if (this.activeId === id) return this.players.get(id);
1145
1146    const current = this.#active();
1147    if (current) {
1148      current.adapter.pause?.();
1149      current.container.classList.add("d-none");
1150    }
1151
1152    let rec = this.players.get(id);
1153    if (!rec) rec = this.#buildPlayer(this.#sourceById(id));
1154    if (!rec) return null;
1155    rec.container.classList.remove("d-none");
1156    rec.adapter.setRate?.(this.rate);
1157    rec.adapter.setMuted?.(this.muted);
1158    // Re-applied on every activation, not just at build: a hidden container has
1159    // no box, so a Stream source built while another was on screen could not
1160    // size its frame, and this is the moment it finally can.
1161    rec.adapter.setQuality?.(this.quality);
1162    this.activeId = id;
1163    this.#paintQuality();   // the new backend may not offer the control at all
1164    this.#paintTransport();
1165    this.#paintScrub();   // duration and position both just changed
1166    this.#paintLive();    // ...and so did whether the picture on screen is live
1167    return rec;
1168  }
1169
1170  #buildPlayer(source) {
1171    if (!source) return null;
1172    const container = document.createElement("div");
1173    // position-relative + overflow-hidden are for StreamAdapter's scaled frame:
1174    // it lays its iframe out wider than this box and paints it back down with a
1175    // transform, so the box has to be the containing block that clips it. Inert
1176    // for the other two backends, whose iframe/video is plain w-100 h-100.
1177    container.className = "w-100 h-100 d-none position-relative overflow-hidden";
1178    container.dataset.sourceId = source.sourceId;
1179    (this.hasMountTarget ? this.mountTarget : this.element).appendChild(container);
1180
1181    const adapter = this.#buildAdapter(source);
1182    let resolveReady;
1183    const readyPromise = new Promise((resolve) => { resolveReady = resolve; });
1184    const rec = {
1185      adapter, container, duration: null, ready: false, readyPromise, resolveReady,
1186      // Tracked per source (not globally): a relative skip must read the clock of
1187      // the video it's skipping, and the timing page keeps four of them warm.
1188      currentTime: 0,
1189      playing: false,
1190      buffering: false
1191    };
1192
1193    adapter.mount(container);
1194    adapter.onReady((duration) => {
1195      rec.duration = duration;
1196      rec.ready = true;
1197      // Re-apply the chosen speed now the backend can actually take it. #activate
1198      // sets it too, but Vimeo and Stream mount ASYNCHRONOUSLY: at that point
1199      // their adapter has no player object yet and setRate optional-chains into a
1200      // no-op. Without this, switching to a quarter's file for the first time (or
1201      // picking a speed while the first file is still loading) played it at 1×
1202      // with the bar still showing 0.25× — the slow-mo silently didn't happen.
1203      adapter.setRate?.(this.rate);
1204      adapter.setMuted?.(this.muted);
1205      // Vimeo's is the one that genuinely needs the second pass: getQualities()
1206      // can only be asked once the SDK has a player, so a choice made before
1207      // then was mapped against an empty rung list.
1208      adapter.setQuality?.(this.quality);
1209      this.#dispatch("pc:video-ready", { duration, sourceId: source.sourceId });
1210      // rec.duration was null until now, so a source-scoped bar had no scale —
1211      // and if the coach never presses play, no tick would ever supply one. The
1212      // live badge is in the same position: with no duration there is no edge to
1213      // compare the playhead against, so it can't say LIVE until this fires.
1214      if (this.activeId === source.sourceId) {
1215        this.#paintScrub();
1216        this.#openAtLiveEdge(rec, source);
1217        this.#paintLive();
1218      }
1219      resolveReady(duration);
1220    });
1221    adapter.onTimeUpdate((t) => {
1222      rec.currentTime = t;
1223      if (this.activeId === source.sourceId) this.#handleTimeUpdate(t);
1224    });
1225    // Play state comes from the backend itself, so play/pause pressed on the
1226    // native control bar underneath us keeps our idea of it honest. It is also
1227    // the only signal when the native bar is hidden and our button is the only
1228    // control — hence the repaint.
1229    adapter.onPlayState((playing) => {
1230      rec.playing = playing;
1231      // A stall resolves into playback, so leaving `buffering` set here would
1232      // strand the spinner over a running clip.
1233      if (playing) rec.buffering = false;
1234      if (this.activeId === source.sourceId) this.#paintTransport();
1235    });
1236    adapter.onBuffering?.((buffering) => {
1237      rec.buffering = buffering;
1238      if (this.activeId === source.sourceId) this.#paintTransport();
1239    });
1240    // THE DESYNC FIX. The backends' own control bars can change speed and mute
1241    // behind our back — the room runs with them on today — and until now nothing
1242    // listened: the adapter contract stopped at onReady/onTimeUpdate/onPlayState/
1243    // onBuffering, so the speed presets kept showing the rate WE last set and the
1244    // mute icon the state WE last chose, both silently wrong.
1245    //
1246    // Optional registrars, in the same shape as onBuffering?.(), so a future
1247    // backend can omit them. Both write state and repaint DIRECTLY — routing
1248    // through #setRate or toggleMute would push back into the adapter, which
1249    // re-emits the very event being handled.
1250    adapter.onRateChange?.((rate) => {
1251      // this.rate is CONTROLLER-level, not per-source, and both #activate and
1252      // onReady re-apply it to every source. Without this guard a warm background
1253      // player on the timing page would rewrite the speed of the one on screen.
1254      if (this.activeId !== source.sourceId) return;
1255      const next = Number(rate);
1256      if (!Number.isFinite(next) || next <= 0 || next === this.rate) return;
1257      this.rate = next;
1258      // Honest consequence: a rate off SPEED_PRESETS — the 0.75 in Vimeo's own
1259      // menu, Chrome's 1.25 — leaves EVERY preset unselected, because #paintSpeed
1260      // compares with ===. That's deliberate. Showing a stale 1x as selected is a
1261      // lie; showing none selected is the truth, and #stepRate already restarts
1262      // from 1x on an off-list rate.
1263      this.#paintSpeed();
1264    });
1265    adapter.onVolumeChange?.(({ muted } = {}) => {
1266      if (this.activeId !== source.sourceId) return;
1267      if (typeof muted !== "boolean" || this.muted === muted) return;
1268      this.muted = muted;
1269      this.#paintTransport();
1270    });
1271    // Only ever fired by a backend REFUSING a quality (Vimeo's setQuality
1272    // rejects on a video whose account doesn't expose the control), and the
1273    // reason it exists is the same as onRateChange's: the menu paints the chosen
1274    // item selected the instant it's clicked, so a swallowed rejection would
1275    // leave "1080p" ticked over a video still playing at whatever Vimeo decided.
1276    // Reporting the setting still in force repaints it to the truth.
1277    //
1278    // NOT a read-out of the rendition playing. Nothing here can provide that:
1279    // Stream has no API for it, and Vimeo's own auto-mode changes are not what
1280    // this control claims to be showing.
1281    adapter.onQualityChange?.((quality) => {
1282      if (this.activeId !== source.sourceId) return;
1283      const next = normaliseQuality(quality);
1284      if (next === this.quality) return;
1285      this.quality = next;
1286      this.#paintQuality();
1287    });
1288    // THE LIVE-EDGE FIX. rec.duration was written in exactly one place — onReady,
1289    // off a single `loadedmetadata` — which is right for a file and wrong for a
1290    // live stream, whose duration GROWS with the match. The room is a long-lived
1291    // mount, so a coach who opened it five minutes in froze rec.duration at ~300;
1292    // half an hour later "Jump to live" computed liveEdgeTarget(300) = 298 and
1293    // threw them thirty minutes into the PAST under a button that says it takes
1294    // them to now. The LIVE badge latched on for the same reason, because
1295    // isAtLiveEdge compared a growing playhead against a frozen edge.
1296    //
1297    // Event-driven, never polled: every backend already emits `durationchange`
1298    // as the manifest grows (documented for both the Cloudflare Stream player
1299    // SDK and Vimeo's; a native <video> has fired it since forever), so a timer
1300    // would only add a second, less accurate source of the same fact.
1301    //
1302    // Optional registrar in the same shape as onBuffering?.() / onRateChange?.()
1303    // above, and guarded the same way: a warm background player on the timing
1304    // page must not repaint the bar of the one on screen.
1305    adapter.onDurationChange?.((duration) => {
1306      if (this.activeId !== source.sourceId) return;
1307      const next = Number(duration);
1308      if (!Number.isFinite(next) || next <= 0 || next === rec.duration) return;
1309      rec.duration = next;
1310      // The two paints that read duration: the scrub bar's SCALE is the file's
1311      // length, and the live badge measures the playhead against the far end of
1312      // it. #paintTransport is deliberately not called — play/pause, mute and
1313      // the buffering spinner have nothing duration-derived in them.
1314      this.#paintScrub();
1315      this.#paintLive();
1316    });
1317
1318    this.players.set(source.sourceId, rec);
1319    return rec;
1320  }
1321
1322  // Park a live source at the live edge the first time it reports a duration.
1323  //
1324  // "The room opens on the live match" is the promise, and a DVR manifest is
1325  // served from its FIRST segment (measured 2026-08-06 — that is exactly what
1326  // makes scroll-back work), so without this the room would open at whatever the
1327  // videographer was pointing at when they started publishing and a coach who
1328  // wanted to watch the game would press Jump to live every single time.
1329  //
1330  // Only when NOTHING has been cued. A room opened on a deep link (?clip=) or an
1331  // evidence launch has already had cue() called on it — synchronously, before
1332  // any await — so lastCue is the reliable signal that the coach asked for a
1333  // particular moment, and that request outranks the live edge.
1334  //
1335  // Deliberately does not play(): browsers block autoplay without a gesture, and
1336  // this room has never started playing by itself.
1337  #openAtLiveEdge(rec, source) {
1338    if (!source.live || this.lastCue) return;
1339    const target = liveEdgeTarget(rec.duration);
1340    if (target == null) return;
1341    this.#seek(rec, target);
1342  }
1343
1344  #seekAndPlay(rec, start) {
1345    const target = resolveSeekTarget(start, { preroll: this.prerollValue, duration: rec.duration });
1346    this.#seek(rec, target);
1347    rec.adapter.play();
1348  }
1349
1350  // Seek, and remember where we sent it. Every backend only reports position on
1351  // its next timeupdate tick (~4/sec), so without this the following relative
1352  // skip would compute off a stale clock — two quick taps on -5s would land on
1353  // -5 rather than -10.
1354  #seek(rec, time) {
1355    rec.currentTime = time;
1356    // Remember what we ASKED for: the backend's next tick may have been queued
1357    // before the seek landed, so it arrives stale by up to a quarter second —
1358    // long enough for the thumb to snap backwards after a drag and jump forward
1359    // again. #displayTime prefers this target until a tick corroborates it.
1360    this.settle = { target: time, until: performance.now() + SEEK_SETTLE_MS };
1361    rec.adapter.seek(time);
1362    // Paint from our own optimism rather than waiting for the bus. #paintScrub
1363    // bails while this.scrubbing, so a drag is never yanked by its own seek.
1364    if (rec === this.#active()) this.#paintScrub();
1365  }
1366
1367  // ---- internals --------------------------------------------------------
1368
1369  #handleTimeUpdate(t) {
1370    // The bar's clock reads the MATCH, not the file. gameClockLabel returns null
1371    // outside every lined-up period (before the first bounce, in a break, in a
1372    // quarter nobody marked) — blank it rather than falling back to video
1373    // seconds: the native control bar already shows those, and a video clock
1374    // sitting where a game clock is expected is worse than no clock at all.
1375    if (this.hasClockTarget) {
1376      this.clockTarget.textContent = gameClockLabel(this.timelineValue, t, this.activeId) || "";
1377    }
1378    // MUST sit above the early return below: `if (!windowReachedEnd(...)) return;`
1379    // fires on virtually every tick, so anything appended after it never runs
1380    // during normal playback.
1381    this.#paintScrub();
1382    // On the tick for the same reason the scrub bar is: a live stream's duration
1383    // grows underneath a stationary playhead, so "at the live edge" goes false
1384    // on its own without the coach touching anything.
1385    this.#paintLive();
1386    this.#dispatch("pc:video-timeupdate", { currentTime: t, sourceId: this.activeId });
1387    if (!windowReachedEnd(t, this.windowEnd)) return;
1388
1389    // The second test rules out a window whose start already satisfies the
1390    // end test (a zero-length clip): rewinding would land straight back on the
1391    // trigger and seek on every tick forever.
1392    if (this.looping && this.windowStart != null && !windowReachedEnd(this.windowStart, this.windowEnd)) {
1393      // Rewind and leave the window armed. Deliberately does NOT dispatch
1394      // pc:video-clip-ended: that event drives "Play all" auto-advance in
1395      // video_room_controller, so a looping clip that fired it too would hand
1396      // the playlist on while still replaying itself.
1397      const rec = this.#active();
1398      if (rec) {
1399        this.#seek(rec, this.windowStart);
1400        rec.adapter.play();
1401      }
1402      return;
1403    }
1404    const ended = this.activeClipId;
1405    this.windowStart = null;
1406    this.windowEnd = null;
1407    this.activeClipId = null;
1408    this.#active()?.adapter.pause();
1409    // The window just went null, so a clip-scoped bar has to fall back to
1410    // lastCue's span; repaint so the thumb parks at the clip's end rather than
1411    // wherever the last in-window tick left it.
1412    this.#paintScrub();
1413    this.#dispatch("pc:video-clip-ended", { clipId: ended });
1414  }
1415
1416  #active() {
1417    return this.activeId == null ? null : this.players.get(this.activeId);
1418  }
1419
1420  #dispatch(name, detail) {
1421    window.dispatchEvent(new CustomEvent(name, { detail }));
1422  }
1423
1424  // Normalise a source id to a string (they arrive as strings from data attrs
1425  // but as-is from cue details); null/undefined pass through.
1426  #normalizeId(id) {
1427    return id == null ? null : String(id);
1428  }
1429
1430  #primaryId() {
1431    return this.sources[0]?.sourceId ?? null;
1432  }
1433
1434  #sourceById(id) {
1435    return this.sources.find((s) => s.sourceId === id) || null;
1436  }
1437
1438  // Prefer the `sources` array; fall back to the legacy single-source values so
1439  // an un-migrated mount still plays. Ids are stringified for stable lookups.
1440  #parseSources() {
1441    const list = (this.sourcesValue || [])
1442      .filter((s) => s?.src)
1443      .map((s) => ({ ...s, sourceId: String(s.sourceId ?? "primary") }));
1444    if (list.length) return list;
1445    if (!this.srcValue) return [];
1446    return [ {
1447      sourceId: "primary",
1448      sourceType: this.sourceTypeValue,
1449      src: this.srcValue,
1450      streamHost: this.streamHostValue,
1451      signedToken: this.signedTokenValue
1452    } ];
1453  }
1454
1455  // Whether the backend draws its own bar is fixed at construction: two of the
1456  // three carry it in the iframe URL, so it can't be toggled after mount.
1457  #buildAdapter(source) {
1458    // `live` is per SOURCE, not per mount: the room holds the live feed and any
1459    // uploaded footage side by side, and only the Stream backend has anything to
1460    // do with it (the other two ignore the option).
1461    const opts = {
1462      nativeControls: this.#useNativeControls(),
1463      live: source.live === true,
1464      quality: this.quality
1465    };
1466    switch (source.sourceType) {
1467      case "vimeo": return new VimeoAdapter(source.src, opts);
1468      case "stream": return new StreamAdapter(source.src, source.streamHost, source.signedToken, opts);
1469      default: return new FileAdapter(source.src, opts);
1470    }
1471  }
1472}
1473
1474// Load an external SDK script once, resolving when ready.
1475function loadScript(src) {
1476  return new Promise((resolve, reject) => {
1477    const existing = document.querySelector(`script[src="${src}"]`);
1478    if (existing) {
1479      if (existing.dataset.loaded === "true") return resolve();
1480      existing.addEventListener("load", () => resolve());
1481      existing.addEventListener("error", reject);
1482      return;
1483    }
1484    const s = document.createElement("script");
1485    s.src = src;
1486    s.async = true;
1487    s.addEventListener("load", () => { s.dataset.loaded = "true"; resolve(); });
1488    s.addEventListener("error", reject);
1489    document.head.appendChild(s);
1490  });
1491}
1492
1493// Native <video> — the fully local path (dev, demo, e2e, and R2/file sources).
1494class FileAdapter {
1495  // A plain MP4 has one rendition, so there is nothing to choose between and the
1496  // control hides itself over this backend rather than offering four buttons
1497  // that all do the same nothing. (This is also the room's e2e/demo path, which
1498  // is why the room's own tests see no quality menu.)
1499  supportsQuality = false;
1500
1501  constructor(src, { nativeControls = true } = {}) {
1502    this.src = src;
1503    this.nativeControls = nativeControls;
1504  }
1505
1506  mount(container) {
1507    this.el = document.createElement("video");
1508    this.el.src = this.src;
1509    this.el.controls = this.nativeControls;
1510    this.el.playsInline = true;
1511    this.el.preload = "metadata";
1512    this.el.className = "w-100 h-100 bg-black";
1513    container.appendChild(this.el);
1514    this.el.addEventListener("loadedmetadata", () => this._ready?.(this.el.duration));
1515    this.el.addEventListener("timeupdate", () => this._tick?.(this.el.currentTime));
1516    // Listen for the state rather than assuming it from our own calls — the
1517    // native control bar is right there and the coach will use it.
1518    this.el.addEventListener("play", () => this._state?.(true));
1519    this.el.addEventListener("pause", () => this._state?.(false));
1520    this.el.addEventListener("ended", () => this._state?.(false));
1521    this.el.addEventListener("waiting", () => this._buffer?.(true));
1522    this.el.addEventListener("playing", () => this._buffer?.(false));
1523    this.el.addEventListener("canplay", () => this._buffer?.(false));
1524    // Nobody listened for these until now, which was the whole of the stale-speed
1525    // bug: change speed on the native bar and our presets kept showing the rate
1526    // WE last set.
1527    this.el.addEventListener("ratechange", () => this._rate?.(this.el.playbackRate));
1528    this.el.addEventListener("volumechange", () => this._volume?.({ muted: this.el.muted }));
1529    // A growing duration. Irrelevant to an ordinary file (it fires once, right
1530    // beside loadedmetadata, with the same number onReady already took) and
1531    // load-bearing for a live source, which is why it is wired on every backend
1532    // rather than only on Stream: the local/demo path plays live-ish footage
1533    // through this adapter.
1534    this.el.addEventListener("durationchange", () => this._duration?.(this.el.duration));
1535  }
1536
1537  seek(t) { this.el.currentTime = t; }
1538  play() { this.el.play?.().catch(() => {}); }
1539  pause() { this.el.pause?.(); }
1540  setRate(r) { this.el.playbackRate = r; }
1541  setMuted(muted) { if (this.el) this.el.muted = muted; }
1542  onReady(cb) { this._ready = cb; if (this.el?.readyState >= 1) cb(this.el.duration); }
1543  onTimeUpdate(cb) { this._tick = cb; }
1544  onPlayState(cb) { this._state = cb; }
1545  onBuffering(cb) { this._buffer = cb; }
1546  onRateChange(cb) { this._rate = cb; }
1547  onVolumeChange(cb) { this._volume = cb; }
1548  onDurationChange(cb) { this._duration = cb; }
1549  destroy() { this.el?.pause?.(); }
1550}
1551
1552// Vimeo — ad-free hosted playback, programmatic seek via the Player SDK.
1553class VimeoAdapter {
1554  // The one backend with a real quality API — player.js ships getQualities /
1555  // getQuality / setQuality, so here the choice is an instruction rather than
1556  // the size trick StreamAdapter has to resort to.
1557  supportsQuality = true;
1558
1559  constructor(videoId, { nativeControls = true, quality = QUALITY_AUTO } = {}) {
1560    this.videoId = videoId;
1561    this.nativeControls = nativeControls;
1562    this.quality = normaliseQuality(quality);
1563    // Vimeo's rungs are per video ("1080p", "540p", "4K", …), so the shared menu
1564    // can only be mapped onto them once we know which this video has. Empty
1565    // until getQualities answers; nearestVimeoQuality falls back to asking
1566    // literally in the meantime.
1567    this.availableQualities = [];
1568  }
1569
1570  async mount(container) {
1571    // videoId may already carry an unlisted-video `?h=` token (see
1572    // Video::SourceUrl#vimeo_id) — merge onto it rather than appending blindly.
1573    const [id, existingQuery] = this.videoId.split("?");
1574    const params = new URLSearchParams(existingQuery || "");
1575    params.set("title", "0");
1576    params.set("byline", "0");
1577    params.set("portrait", "0");
1578    params.set("pip", "0");
1579    params.set("dnt", "1");
1580    // Progress bar / button accent in brand green ($primary, #22c55e) instead
1581    // of Vimeo's default blue (00adef). Hex without the `#`.
1582    params.set("color", "22c55e");
1583    params.set("vimeo_logo", "0");
1584    // Both of the above need a Starter-or-above Vimeo plan; on a free account
1585    // the player ignores them and keeps the blue controls and the logo. The
1586    // params are harmless either way, so they stay set for when the plan is there.
1587    // controls=0 strips the bar completely — verified 2026-07-29 against the
1588    // account holding our footage. It also removes click-to-play, so a mount
1589    // that sets this MUST draw its own play control. On a free plan Vimeo would
1590    // ignore it and show its bar under ours; that degradation is ugly but not
1591    // broken, and the alternative (no way to hide a misleading scrubber) is worse.
1592    if (!this.nativeControls) params.set("controls", "0");
1593
1594    this.iframe = document.createElement("iframe");
1595    this.iframe.src = `https://player.vimeo.com/video/${id}?${params}`;
1596    this.iframe.allow = "autoplay; fullscreen; picture-in-picture";
1597    this.iframe.className = "w-100 h-100 border-0";
1598    container.appendChild(this.iframe);
1599    await loadScript("https://player.vimeo.com/api/player.js");
1600    this.player = new window.Vimeo.Player(this.iframe);
1601    this.player.getDuration().then((d) => this._ready?.(d)).catch(() => {});
1602    this.player.on("timeupdate", (data) => this._tick?.(data.seconds));
1603    // Events, not getPaused() — the getter is a promise, and the coach can hit
1604    // Vimeo's own play button anyway.
1605    this.player.on("play", () => this._state?.(true));
1606    this.player.on("pause", () => this._state?.(false));
1607    this.player.on("ended", () => this._state?.(false));
1608    this.player.on("bufferstart", () => this._buffer?.(true));
1609    this.player.on("bufferend", () => this._buffer?.(false));
1610    // Vimeo's own name for it. Best-effort by documentation: a creator can turn
1611    // rate control off on their video, in which case this never fires. It exists
1612    // to correct a bar that has already gone stale, not as a source of truth.
1613    this.player.on("playbackratechange", (d) => this._rate?.(Number(d?.playbackRate)));
1614    // The payload is documented as { volume, muted }, but `muted` has been absent
1615    // in shipped SDK versions — fall back to volume 0 rather than dropping it.
1616    this.player.on("volumechange", (d) => {
1617      const muted = typeof d?.muted === "boolean" ? d.muted : Number(d?.volume) === 0;
1618      this._volume?.({ muted });
1619    });
1620    // Documented ("Triggered when the duration attribute has been updated" —
1621    // player.js README, checked 2026-08-06), so this is wired rather than
1622    // omitted. It will not fire in practice: a Vimeo source is never live in
1623    // this app (only a `stream` source carries live: true), so its duration is
1624    // fixed and this exists to keep the three backends implementing one
1625    // contract rather than two-and-a-bit.
1626    this.player.on("durationchange", (d) => this._duration?.(Number(d?.duration)));
1627    // Apply a mute chosen before the SDK finished loading — the modal can hide
1628    // the native bar and have the coach hit mute while this is still mounting.
1629    if (this._muted != null) this.player.setMuted(this._muted).catch(() => {});
1630    this.player.getPlaybackRate().then((r) => { this._appliedRate = Number(r); }).catch(() => {});
1631    // Learn this video's rungs, then re-apply the standing choice against them.
1632    // The re-apply is the point: a choice made before the SDK loaded was mapped
1633    // against an empty list and asked for literally, which on a video offering
1634    // 540p rather than 480p is the difference between a rung and a no-op.
1635    // Rejections are normal (older SDKs, accounts without the feature) and leave
1636    // the list empty rather than failing the mount.
1637    this.player.getQualities()
1638      .then((qualities) => {
1639        this.availableQualities = Array.isArray(qualities) ? qualities : [];
1640        if (this.quality !== QUALITY_AUTO) this.setQuality(this.quality);
1641      })
1642      .catch(() => {});
1643  }
1644
1645  seek(t) { this.player?.setCurrentTime(t).catch(() => {}); }
1646  play() { this.player?.play().catch(() => {}); }
1647  pause() { this.player?.pause().catch(() => {}); }
1648  // 0.25–2 only: Vimeo rejects rates outside that range, which is why
1649  // SPEED_PRESETS stops at 2× (see utils/video_controls.js).
1650  //
1651  // The rejection is reported rather than swallowed. Vimeo also refuses
1652  // setPlaybackRate when the creator has disabled rate control, while #setRate
1653  // paints the pressed preset brand-green regardless — so a swallowed rejection
1654  // leaves a confident lie on screen. Reporting the rate still in force repaints
1655  // the bar to the truth (no preset selected, since it won't match one).
1656  setRate(r) {
1657    this.player?.setPlaybackRate(r)
1658      .then(() => { this._appliedRate = r; })
1659      .catch(() => this._rate?.(this._appliedRate ?? 1));
1660  }
1661  setMuted(muted) {
1662    this._muted = muted;
1663    this.player?.setMuted(muted).catch(() => {});
1664  }
1665
1666  // Mapped onto a rung this video actually has — our menu's "480p" is Vimeo's
1667  // "540p" on most footage, and asking for a rung that isn't there is a silent
1668  // no-op rather than an error.
1669  //
1670  // Refusals are REPORTED, exactly as setRate reports a refused playback rate
1671  // and for the same reason: the menu has already ticked the item, so a
1672  // swallowed rejection is a lie left on screen. Vimeo can refuse when the
1673  // account's plan doesn't expose quality control — unverified against Phil's
1674  // account, which is precisely why this path exists rather than a bare catch.
1675  setQuality(quality) {
1676    const previous = this.quality;
1677    this.quality = normaliseQuality(quality);
1678    if (!this.player) return;
1679
1680    this.player.setQuality(nearestVimeoQuality(this.quality, this.availableQualities))
1681      .catch(() => {
1682        this.quality = previous;
1683        this._quality?.(previous);
1684      });
1685  }
1686  onReady(cb) { this._ready = cb; }
1687  onTimeUpdate(cb) { this._tick = cb; }
1688  onPlayState(cb) { this._state = cb; }
1689  onBuffering(cb) { this._buffer = cb; }
1690  onRateChange(cb) { this._rate = cb; }
1691  onVolumeChange(cb) { this._volume = cb; }
1692  onDurationChange(cb) { this._duration = cb; }
1693  onQualityChange(cb) { this._quality = cb; }
1694  destroy() { this.player?.destroy?.().catch?.(() => {}); }
1695}
1696
1697// Cloudflare Stream — HLS + adaptive playback, controlled via the Stream SDK.
1698class StreamAdapter {
1699  constructor(uid, host, signedToken,
1700              { nativeControls = true, live = false, quality = QUALITY_AUTO } = {}) {
1701    this.uid = uid;
1702    this.host = host || "customer-stream.cloudflarestream.com";
1703    this.signedToken = signedToken;
1704    this.nativeControls = nativeControls;
1705    this.live = live;
1706    this.quality = normaliseQuality(quality);
1707    // The size trick (see below) shrinks EVERYTHING in the iframe, including
1708    // Cloudflare's own control bar — a 1920-wide bar painted into an 850px box
1709    // is 44% of its intended size, i.e. unusable. So it only runs on mounts
1710    // drawing OUR chrome, which is exactly the set of mounts that render the
1711    // quality control in the first place (connect() removes the scrub row when
1712    // the backend keeps its own bar). The two travel together on purpose: a
1713    // coach never gets the control without the mechanism, or the mechanism
1714    // without a way to turn it down.
1715    //
1716    // That set is the Video Room and the two chromeless modals (goal detail,
1717    // stat clips). It excludes the timing page, which mounts the player partial
1718    // without `controls` and so keeps Cloudflare's bar, and the small-screen
1719    // fallback, where that bar is the only route into fullscreen there is. Both
1720    // keep today's size-capped behaviour — the right trade when the alternative
1721    // is illegible player chrome on the one device that depends on it.
1722    this.sizeToQuality = nativeControls === false;
1723    // Honest by construction: where the mechanism is off there is nothing for
1724    // the menu to drive, so it reports no support and #paintQuality hides it.
1725    this.supportsQuality = this.sizeToQuality;
1726  }
1727
1728  async mount(container) {
1729    // A signed token (when private) is used as the id in the iframe path.
1730    const id = this.signedToken || this.uid;
1731    // Control accent in brand green ($primary) instead of Stream's default
1732    // blue, matching VimeoAdapter. Stream wants the leading `#` (unlike Vimeo)
1733    // — URLSearchParams escapes it to %23 for us. Not plan-gated either.
1734    // preload=metadata: _ready is wired solely to loadedmetadata, and Stream's
1735    // iframe defaults preload to "none". With controls=false there is no player
1736    // UI left to trigger a load either, so duration could stay null forever —
1737    // readyPromise never resolves, queued cues hang, and a source-scoped scrub
1738    // bar renders permanently disabled with nothing in the console to explain it.
1739    const params = new URLSearchParams({ primaryColor: "#22c55e", preload: "metadata" });
1740    // Doc-verified, not measured: there is no Stream asset in dev to hit, and
1741    // production has none either (0 upload sources as of 2026-07-29). The goal
1742    // modal is the only mount that sets this, so a wrong param there costs a
1743    // chrome-less-but-uncontrolled player, not a broken Video Room.
1744    if (!this.nativeControls) params.set("controls", "false");
1745    // DVR. This is what makes scrubbing back to the first bounce work WHILE the
1746    // match is still being played — without it the player holds only Stream's
1747    // ~16-second sliding live window and every clip in the room is unreachable.
1748    //
1749    // Honest provenance, because the two halves have different evidence.
1750    // dvrEnabled on the live MANIFEST was MEASURED against the live account on
1751    // 2026-08-06: 140 segments / 280.0s of scroll-back at 284s elapsed. The
1752    // PLAYER parameter is Cloudflare-documented but was NOT measured. If the
1753    // player ignores it the degradation is bounded and visible rather than
1754    // silent: playback sits on the live edge, Jump to live is a no-op because it
1755    // is already there, and only scroll-back is lost — the recording still hands
1756    // over and the room still works post-game.
1757    if (this.live) params.set("dvrEnabled", "true");
1758    this.iframe = document.createElement("iframe");
1759    this.iframe.src = `https://${this.host}/${id}/iframe?${params}`;
1760    this.iframe.allow = "accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;";
1761    this.iframe.className = "w-100 h-100 border-0";
1762    this.container = container;
1763    container.appendChild(this.iframe);
1764    this.#applyFrameSize();
1765    // The box changes size for three reasons that all have to re-select a
1766    // rendition: the window resizes, the coach enters or leaves fullscreen, and
1767    // the container comes out of d-none when its source is activated (a hidden
1768    // box measures 0x0, so that first real measurement only happens here).
1769    if (this.sizeToQuality && typeof ResizeObserver === "function") {
1770      this.observer = new ResizeObserver(() => this.#applyFrameSize());
1771      this.observer.observe(container);
1772    }
1773    await loadScript("https://embed.cloudflarestream.com/embed/sdk.latest.js");
1774    this.player = window.Stream(this.iframe);
1775    this.player.addEventListener("loadedmetadata", () => this._ready?.(this.player.duration));
1776    this.player.addEventListener("timeupdate", () => this._tick?.(this.player.currentTime));
1777    // Standard media events, same reasoning as the other two backends: the
1778    // Stream player's own control bar can play/pause behind our back.
1779    this.player.addEventListener("play", () => this._state?.(true));
1780    this.player.addEventListener("pause", () => this._state?.(false));
1781    this.player.addEventListener("ended", () => this._state?.(false));
1782    this.player.addEventListener("waiting", () => this._buffer?.(true));
1783    this.player.addEventListener("playing", () => this._buffer?.(false));
1784    this.player.addEventListener("canplay", () => this._buffer?.(false));
1785    // Both read back from the SDK's synchronous shadow state. Note Cloudflare's
1786    // public property table OMITS playbackRate while the shipped SDK's writable
1787    // list includes it — so setRate is not the no-op that page implies, and this
1788    // should not be "corrected" from the docs.
1789    this.player.addEventListener("ratechange", () => this._rate?.(this.player.playbackRate));
1790    this.player.addEventListener("volumechange", () => this._volume?.({ muted: this.player.muted }));
1791    // THE ONE THAT MATTERS FOR LIVE. This is the only backend that ever plays a
1792    // live source, and a live asset's duration grows with the match — so without
1793    // this listener rec.duration would stay frozen at whatever the manifest
1794    // reported when the room was opened, and Jump to live would seek backwards.
1795    // Cloudflare documents durationchange in the player SDK's standard media
1796    // events ("the metadata has loaded or changed, indicating a change in
1797    // duration of the media"), checked 2026-08-06.
1798    this.player.addEventListener("durationchange", () => this._duration?.(this.player.duration));
1799    // Same late-mount catch-up as Vimeo: the SDK arrives after the coach can
1800    // have pressed mute.
1801    if (this._muted != null) this.player.muted = this._muted;
1802  }
1803
1804  // THE WHOLE STREAM QUALITY MECHANISM.
1805  //
1806  // Cloudflare's player has no quality API (see utils/video_quality.js for the
1807  // measurement and the SDK's full property surface), and it picks its rendition
1808  // from the CSS size of its own element while ignoring devicePixelRatio. So the
1809  // way to ask for 1080p is to BE 1920 CSS pixels wide — and the way to stay
1810  // 850px on screen is to paint that back down with a transform.
1811  //
1812  // Measured against the live Palm Beach asset, 2026-08-10: an iframe laid out
1813  // at 1920x1080 and scaled to 44% inside an 850px box pulls the 1080p rendition
1814  // (previously 480p in the same box). Downscaling 1080p into 850px is also
1815  // sharper than the 480p it replaces — supersampled rather than upscaled.
1816  #applyFrameSize() {
1817    if (!this.sizeToQuality || !this.iframe || !this.container) return;
1818
1819    const style = scaledFrameStyle(
1820      this.container.getBoundingClientRect(),
1821      streamLayoutWidth(this.quality)
1822    );
1823    // No area yet — still hidden, or measured before layout. The observer will
1824    // call back the moment it has one.
1825    if (!style) return;
1826
1827    this.iframe.classList.remove("w-100", "h-100");
1828    Object.assign(this.iframe.style, {
1829      position: "absolute",
1830      top: "0",
1831      left: "0",
1832      width: `${style.width}px`,
1833      height: `${style.height}px`,
1834      transform: `scale(${style.scale})`,
1835      transformOrigin: "top left"
1836    });
1837  }
1838
1839  seek(t) { if (this.player) this.player.currentTime = t; }
1840  play() { this.player?.play?.(); }
1841  pause() { this.player?.pause?.(); }
1842  setRate(r) { if (this.player) this.player.playbackRate = r; }
1843  // A CEILING, not a lock — adaptive bitrate still steps below it on a poor
1844  // connection, which is the behaviour we want. Nothing is torn down or
1845  // reloaded: the player re-evaluates on the size change and switches rendition
1846  // at the next segment boundary, so the picture keeps playing through it.
1847  setQuality(quality) {
1848    this.quality = normaliseQuality(quality);
1849    this.#applyFrameSize();
1850  }
1851  setMuted(muted) {
1852    this._muted = muted;
1853    if (this.player) this.player.muted = muted;
1854  }
1855  onReady(cb) { this._ready = cb; }
1856  onTimeUpdate(cb) { this._tick = cb; }
1857  onPlayState(cb) { this._state = cb; }
1858  onBuffering(cb) { this._buffer = cb; }
1859  onRateChange(cb) { this._rate = cb; }
1860  onVolumeChange(cb) { this._volume = cb; }
1861  onDurationChange(cb) { this._duration = cb; }
1862  destroy() {
1863    this.observer?.disconnect();
1864    this.player?.pause?.();
1865  }
1866}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.