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.