From 95a48f8d8a6e1635ab177f02842dcf53bcf6139d Mon Sep 17 00:00:00 2001 From: Mateus Filipe <317431369+mateus3355@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:02:16 +0000 Subject: [PATCH 1/4] New: Mobile AB-Loop Controls Add touch-friendly AB-loop controls for mobile, alongside the existing desktop videojs-abloop control-bar buttons: - Floating start/end buttons in the mobile jog-button overlay, colored to signal when their bound has been set - Icon-only loop enable/disable toggle, replacing the vendor plugin's text button on mobile - Active loop range and individual start/end markers highlighted on the progress bar - Start/end buttons and progress-bar markers only show once the loop toggle is on Also: - Reset (disable + clear start/end) an active AB loop when manually skipping to another video, so it doesn't carry over onto the next scene's timeline - Fix a full-video loop (no range set) occasionally advancing to the next queued scene instead of looping, by handling the loop ourselves on ended as a backstop for the vendor plugin's timeupdate-margin race against the real end of playback --- .gitignore | 4 +- ui/v2.5/public/ab-end.svg | 4 + ui/v2.5/public/ab-loop-toggle.svg | 4 + ui/v2.5/public/ab-start.svg | 4 + ui/v2.5/src/@types/videojs-abloop.d.ts | 7 + .../components/ScenePlayer/PlaylistButtons.ts | 18 ++ .../components/ScenePlayer/ScenePlayer.tsx | 86 +++++- .../components/ScenePlayer/ab-loop-range.ts | 135 +++++++++ .../components/ScenePlayer/ab-loop-toggle.ts | 93 ++++++ .../src/components/ScenePlayer/big-buttons.ts | 98 ++++++- .../src/components/ScenePlayer/styles.scss | 276 +++++++++++++++--- 11 files changed, 668 insertions(+), 61 deletions(-) create mode 100644 ui/v2.5/public/ab-end.svg create mode 100644 ui/v2.5/public/ab-loop-toggle.svg create mode 100644 ui/v2.5/public/ab-start.svg create mode 100644 ui/v2.5/src/components/ScenePlayer/ab-loop-range.ts create mode 100644 ui/v2.5/src/components/ScenePlayer/ab-loop-toggle.ts diff --git a/.gitignore b/.gitignore index 00d144ccae..4982d6864f 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,6 @@ node_modules /phasher dist .DS_Store -/.local* \ No newline at end of file +/.local* +.pnpm-store +.tmp \ No newline at end of file diff --git a/ui/v2.5/public/ab-end.svg b/ui/v2.5/public/ab-end.svg new file mode 100644 index 0000000000..58e945653b --- /dev/null +++ b/ui/v2.5/public/ab-end.svg @@ -0,0 +1,4 @@ + + +B + diff --git a/ui/v2.5/public/ab-loop-toggle.svg b/ui/v2.5/public/ab-loop-toggle.svg new file mode 100644 index 0000000000..43c810689c --- /dev/null +++ b/ui/v2.5/public/ab-loop-toggle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/v2.5/public/ab-start.svg b/ui/v2.5/public/ab-start.svg new file mode 100644 index 0000000000..886b61b70d --- /dev/null +++ b/ui/v2.5/public/ab-start.svg @@ -0,0 +1,4 @@ + + +A + diff --git a/ui/v2.5/src/@types/videojs-abloop.d.ts b/ui/v2.5/src/@types/videojs-abloop.d.ts index 2c201d722f..3fef100e7e 100644 --- a/ui/v2.5/src/@types/videojs-abloop.d.ts +++ b/ui/v2.5/src/@types/videojs-abloop.d.ts @@ -22,6 +22,13 @@ declare module "videojs-abloop" { class Plugin extends videojs.Plugin { getOptions(): Options; setOptions(o: Options): void; + // assignable by callers to be notified whenever the loop options + // change, whether via setOptions() or any other API method + onOptionsChange?: ( + details: unknown, + api: Plugin, + player: videojs.Player + ) => void; } } diff --git a/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts b/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts index ad7826f733..c4d4193279 100644 --- a/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts +++ b/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts @@ -1,4 +1,9 @@ import videojs, { VideoJsPlayer } from "video.js"; +import type { AbLoopPluginApi } from "./util"; + +function getAbLoopApi(player: VideoJsPlayer) { + return player.abLoopPlugin as unknown as AbLoopPluginApi | undefined; +} interface ControlOptions extends videojs.ComponentOptions { direction: "forward" | "back"; @@ -28,11 +33,24 @@ class SkipButtonPlugin extends videojs.getPlugin("plugin") { else this.player.removeClass("vjs-skip-buttons-prev"); } + // manually skipping to another video invalidates any AB-loop range set up + // for the one being left - carrying an enabled loop over onto the next + // video's timeline would silently loop the wrong section of it + private resetAbLoop() { + const api = getAbLoopApi(this.player); + const opts = api?.getOptions(); + if (api && opts?.enabled) { + api.setOptions({ ...opts, start: 0, end: false, enabled: false }); + } + } + handleForward() { + this.resetAbLoop(); this.onNext?.(); } handleBackward() { + this.resetAbLoop(); this.onPrevious?.(); } diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index c298bd28a9..2192034240 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -21,6 +21,8 @@ import MarkersPlugin, { type IMarker } from "./markers"; void MarkersPlugin; import "./vtt-thumbnails"; import "./big-buttons"; +import "./ab-loop-toggle"; +import "./ab-loop-range"; import "./track-activity"; import "./vrmode"; import "./media-session"; @@ -125,15 +127,17 @@ function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) { const skipButtons = player.skipButtons(); if (skipButtons) { - // handle multimedia keys + // handle multimedia keys - routed through handleForward/handleBackward + // (rather than calling onNext/onPrevious directly) so this also resets + // an active AB loop, same as clicking the skip buttons does switch (event.key) { case "MediaTrackNext": if (!skipButtons.onNext) return; - skipButtons.onNext(); + skipButtons.handleForward(); break; case "MediaTrackPrevious": if (!skipButtons.onPrevious) return; - skipButtons.onPrevious(); + skipButtons.handleBackward(); break; // MediaPlayPause handled by videojs } @@ -346,6 +350,11 @@ export const ScenePlayer: React.FC = PatchComponent( inline: false, }, chaptersButton: false, + // current/total time show inline ("0:03 / 0:11") in the button + // row on desktop as normal; on mobile styles.scss repositions + // them to float either side of the progress bar instead, where + // remaining-time/divider aren't needed + remainingTimeDisplay: false, }, html5: { dash: { @@ -387,7 +396,13 @@ export const ScenePlayer: React.FC = PatchComponent( markers: {}, sourceSelector: {}, persistVolume: {}, - bigButtons: {}, + // bigButtons is the floating play/pause + jog (+ AB-loop + // start/end, when enabled) overlay, shown only on mobile widths + // in styles.scss; on desktop these stay as regular control-bar + // buttons instead + bigButtons: { + showAbLoop: uiConfig?.showAbLoopControls ?? false, + }, seekButtons: { forward: 10, back: 10, @@ -408,6 +423,17 @@ export const ScenePlayer: React.FC = PatchComponent( pauseBeforeLooping: false, createButtons: uiConfig?.showAbLoopControls ?? false, }, + // icon-only enable/disable toggle, shown on mobile in place of + // the vendor plugin's own "LOOP ON"/"Loop off" text button above + // (hidden there via styles.scss) - see ab-loop-toggle.ts for why + // that button can't just be re-skinned in place + abLoopToggle: { + enabled: uiConfig?.showAbLoopControls ?? false, + }, + // highlights the active AB-loop range on the progress bar + abLoopRange: { + enabled: uiConfig?.showAbLoopControls ?? false, + }, mediaSession: {}, wakeSentinel: {}, }, @@ -421,6 +447,37 @@ export const ScenePlayer: React.FC = PatchComponent( const vjs = videojs(videoEl, options); + // bridge AB-loop option changes (from any source: this menu, the "L" + // hotkey, marker clicks elsewhere) onto a player event, since the + // vendor plugin has no other change-notification hook. ab-loop-toggle.ts + // and ab-loop-range.ts listen for this to keep their state in sync. + // Also mirror "enabled" onto a player-level class: styles.scss uses it + // to keep the mobile start/end buttons hidden until looping is + // actually on (see big-buttons.ts's AbBoundButton). + vjs.abLoopPlugin.onOptionsChange = (_details, api, player) => { + player.toggleClass("vjs-ab-loop-enabled", api.getOptions().enabled); + player.trigger("abloopchange"); + }; + + // swap the fullscreen toggle and the AB-loop enable/disable button: + // the vendor plugin just appends its buttons at the end of the + // control bar (after fullscreen), unlike every other custom button + // here which inserts itself before fullscreen - so fullscreen ends + // up stranded in the middle. Queued via ready() so it runs after the + // vendor plugin's own (also ready()-deferred) button creation. + vjs.ready(() => { + const controlBarEl = vjs.controlBar.el(); + const fullscreenEl = vjs.controlBar.getChild("fullscreenToggle")?.el(); + const toggleEl = controlBarEl.querySelector(".abLoopButton.enabled"); + if (!fullscreenEl || !toggleEl) return; + + const marker = document.createComment(""); + controlBarEl.insertBefore(marker, toggleEl); + controlBarEl.insertBefore(toggleEl, fullscreenEl); + controlBarEl.insertBefore(fullscreenEl, marker); + marker.remove(); + }); + /* biome-ignore lint/suspicious/noExplicitAny: intentional */ const settings = (vjs as any).textTrackSettings; settings.setValues({ @@ -929,9 +986,26 @@ export const ScenePlayer: React.FC = PatchComponent( const player = getPlayer(); if (!player) return; - player.on("ended", onComplete); + function ended(this: VideoJsPlayer) { + // the vendor plugin loops by watching timeupdate for currentTime + // getting within ~1s of the end and seeking back before playback + // actually completes - a margin that a full-video loop (nothing + // set, so the loop point *is* the true end) can lose the race + // against, letting "ended" fire and advance the queue instead of + // looping. Enforce the loop directly here as a backstop. + const opts = this.abLoopPlugin.getOptions(); + if (opts.enabled) { + this.currentTime(typeof opts.start === "number" ? opts.start : 0); + this.play(); + return; + } + + onComplete(); + } + + player.on("ended", ended); - return () => player.off("ended"); + return () => player.off("ended", ended); }, [getPlayer, onComplete]); // set up mediaSession plugin diff --git a/ui/v2.5/src/components/ScenePlayer/ab-loop-range.ts b/ui/v2.5/src/components/ScenePlayer/ab-loop-range.ts new file mode 100644 index 0000000000..8336e7f52c --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/ab-loop-range.ts @@ -0,0 +1,135 @@ +import videojs, { VideoJsPlayer } from "video.js"; +import type { AbLoopPluginApi } from "./util"; + +function getAbLoopApi(player: VideoJsPlayer) { + return player.abLoopPlugin as unknown as AbLoopPluginApi | undefined; +} + +interface AbLoopRangeOptions { + /** + * Whether to show the range highlight at all (mirrors showAbLoopControls). + * @default false + */ + enabled?: boolean; +} + +// Highlights the active AB-loop range on the progress bar, so there's some +// visual answer to "what time range is currently selected" beyond having +// to remember what you last tapped Start/End at. Also drops a thin marker +// at the start and/or end point individually, shown as soon as that point +// is set even if the other one isn't - see refresh() below. Appended +// directly into .vjs-progress-holder (not .vjs-progress-control, like +// markers.ts's range markers) so it shares that element's own coordinate +// space - no need to separately compensate for the holder's 15px side +// margins the way markers.ts has to when positioning from the outer +// container instead. +class AbLoopRangePlugin extends videojs.getPlugin("plugin") { + private rangeEl?: HTMLDivElement; + private startMarkerEl?: HTMLDivElement; + private endMarkerEl?: HTMLDivElement; + + constructor(player: VideoJsPlayer, options?: AbLoopRangeOptions) { + super(player, options); + if (!options?.enabled) return; + + player.on("ready", () => { + const holder = player.el().querySelector(".vjs-progress-holder"); + if (!holder) return; + + const el = document.createElement("div"); + el.className = "vjs-ab-loop-range"; + el.style.display = "none"; + holder.appendChild(el); + this.rangeEl = el; + + const startMarker = document.createElement("div"); + startMarker.className = "vjs-ab-loop-marker vjs-ab-loop-marker-start"; + startMarker.style.display = "none"; + holder.appendChild(startMarker); + this.startMarkerEl = startMarker; + + const endMarker = document.createElement("div"); + endMarker.className = "vjs-ab-loop-marker vjs-ab-loop-marker-end"; + endMarker.style.display = "none"; + holder.appendChild(endMarker); + this.endMarkerEl = endMarker; + + const refresh = () => this.refresh(); + player.on("abloopchange", refresh); + player.on(["loadedmetadata", "durationchange"], refresh); + refresh(); + }); + } + + private positionMarker( + el: HTMLDivElement | undefined, + show: boolean, + time: number | false | undefined, + duration: number + ) { + if (!el) return; + if (!show || typeof time !== "number") { + el.style.display = "none"; + return; + } + el.style.display = "block"; + el.style.left = `${(time / duration) * 100}%`; + } + + private refresh() { + const opts = getAbLoopApi(this.player)?.getOptions(); + const duration = this.player.duration(); + const hasDuration = Number.isFinite(duration) && duration > 0; + + // the shaded range: the region that's actually looping right now, once + // enabled - even with neither point explicitly set, this covers the + // whole video (the vendor plugin's own start:0/end:false defaults), + // which is exactly what an enabled-but-untouched loop plays + const showRange = !!opts?.enabled && hasDuration; + if (this.rangeEl) { + if (!showRange || !opts) { + this.rangeEl.style.display = "none"; + } else { + const start = typeof opts.start === "number" ? opts.start : 0; + const end = opts.end === false ? duration : opts.end; + this.rangeEl.style.display = "block"; + this.rangeEl.style.left = `${(start / duration) * 100}%`; + this.rangeEl.style.width = `${ + (Math.max(0, end - start) / duration) * 100 + }%`; + } + } + + // the individual pins - each shown only once that specific bound has + // been set (not the vendor plugin's 0/false default), independently of + // whether the other one has been too + const startIsSet = typeof opts?.start === "number" && opts.start > 0; + const endIsSet = typeof opts?.end === "number"; + this.positionMarker( + this.startMarkerEl, + !!opts?.enabled && hasDuration && startIsSet, + opts?.start, + duration + ); + this.positionMarker( + this.endMarkerEl, + !!opts?.enabled && hasDuration && endIsSet, + opts?.end, + duration + ); + } +} + +// Register the plugin with video.js. +videojs.registerPlugin("abLoopRange", AbLoopRangePlugin); + +declare module "video.js" { + interface VideoJsPlayer { + abLoopRange: () => AbLoopRangePlugin; + } + interface VideoJsPlayerPluginOptions { + abLoopRange?: AbLoopRangeOptions; + } +} + +export default AbLoopRangePlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/ab-loop-toggle.ts b/ui/v2.5/src/components/ScenePlayer/ab-loop-toggle.ts new file mode 100644 index 0000000000..1109787347 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/ab-loop-toggle.ts @@ -0,0 +1,93 @@ +import videojs, { VideoJsPlayer } from "video.js"; +import type { AbLoopPluginApi } from "./util"; + +function getAbLoopApi(player: VideoJsPlayer) { + return player.abLoopPlugin as unknown as AbLoopPluginApi | undefined; +} + +interface AbLoopToggleOptions { + /** + * Whether to show the button at all (mirrors showAbLoopControls). + * @default false + */ + enabled?: boolean; +} + +// Icon-only counterpart to the vendor videojs-abloop plugin's own +// "LOOP ON"/"Loop off" text button, shown on mobile only (see styles.scss) +// where that text button is hidden. Uses the normal Button structure +// (icon-placeholder + control-text, same as every built-in button) rather +// than reusing the vendor's own button element, since that element's text +// gets overwritten wholesale (element.textContent = ...) on every change, +// which would wipe out any icon markup injected into it. +class AbLoopToggleButton extends videojs.getComponent("Button") { + constructor(player: VideoJsPlayer, options?: videojs.ComponentOptions) { + super(player, options); + this.controlText(this.localize("Toggle AB loop")); + + const refresh = () => { + const enabled = getAbLoopApi(player)?.getOptions().enabled; + if (enabled) this.addClass("vjs-ab-loop-active"); + else this.removeClass("vjs-ab-loop-active"); + }; + player.on("abloopchange", refresh); + refresh(); + } + + buildCSSClass() { + return `vjs-ab-loop-toggle ${super.buildCSSClass()}`; + } + + handleClick(event: videojs.EventTarget.Event) { + // Prevent the click from bubbling up and affecting the video player + event.stopPropagation(); + + const player = this.player() as unknown as VideoJsPlayer; + const api = getAbLoopApi(player); + if (!api) return; + + const opts = api.getOptions(); + api.setOptions({ ...opts, enabled: !opts.enabled }); + } +} + +class AbLoopTogglePlugin extends videojs.getPlugin("plugin") { + private button: AbLoopToggleButton; + private added = false; + + constructor(player: VideoJsPlayer, options?: AbLoopToggleOptions) { + super(player, options); + + this.button = new AbLoopToggleButton(player); + + player.on("ready", () => { + if (options?.enabled) this.addButton(); + }); + } + + private addButton() { + if (this.added) return; + const { controlBar } = this.player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle"); + controlBar.addChild(this.button); + if (fullscreenToggle) { + controlBar.el().insertBefore(this.button.el(), fullscreenToggle.el()); + } + this.added = true; + } +} + +// Register the plugin with video.js. +videojs.registerComponent("AbLoopToggleButton", AbLoopToggleButton); +videojs.registerPlugin("abLoopToggle", AbLoopTogglePlugin); + +declare module "video.js" { + interface VideoJsPlayer { + abLoopToggle: () => AbLoopTogglePlugin; + } + interface VideoJsPlayerPluginOptions { + abLoopToggle?: AbLoopToggleOptions; + } +} + +export default AbLoopTogglePlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/big-buttons.ts b/ui/v2.5/src/components/ScenePlayer/big-buttons.ts index e1cc44a8dd..05759fca72 100644 --- a/ui/v2.5/src/components/ScenePlayer/big-buttons.ts +++ b/ui/v2.5/src/components/ScenePlayer/big-buttons.ts @@ -1,4 +1,5 @@ import videojs, { VideoJsPlayer } from "video.js"; +import type { AbLoopOptions, AbLoopPluginApi } from "./util"; // prettier-ignore const BigPlayButton = videojs.getComponent( @@ -19,9 +20,83 @@ class BigPlayPauseButton extends BigPlayButton { } } +function getAbLoopApi(player: VideoJsPlayer) { + return player.abLoopPlugin as unknown as AbLoopPluginApi | undefined; +} + +interface AbBoundButtonOptions extends videojs.ComponentOptions { + bound: "start" | "end"; +} + +// Sets the AB-loop start/end point to the current playback position. Lives +// in the floating button group (mobile only, see styles.scss) as the +// touch-friendly counterpart to the Start/End buttons the videojs-abloop +// plugin itself renders in the control bar on desktop. +class AbBoundButton extends videojs.getComponent("Button") { + private bound: "start" | "end"; + + constructor(player: VideoJsPlayer, options: AbBoundButtonOptions) { + super(player, options); + this.bound = options.bound; + this.controlText( + this.localize( + this.bound === "start" ? "Set AB loop start" : "Set AB loop end" + ) + ); + + // recolor once this bound has actually been set (as opposed to still + // sitting at the vendor plugin's start:0/end:false defaults), so + // there's some feedback beyond remembering whether it was tapped - + // see styles.scss's vjs-ab-bound-active + const refresh = () => { + const opts = getAbLoopApi(player)?.getOptions(); + const isSet = + this.bound === "start" + ? typeof opts?.start === "number" && opts.start > 0 + : typeof opts?.end === "number"; + this.toggleClass("vjs-ab-bound-active", isSet); + }; + player.on("abloopchange", refresh); + refresh(); + } + + buildCSSClass() { + // called synchronously from within super(), before the constructor body + // below runs - so this.bound isn't set yet at this point. this.options_ + // is though (Component's own constructor populates it before calling + // createEl(), which is what triggers this). + const bound = (this.options_ as AbBoundButtonOptions).bound; + return `vjs-ab-${bound}-button ${super.buildCSSClass()}`; + } + + handleClick(event: videojs.EventTarget.Event) { + // Prevent the click from bubbling up and affecting the video player + event.stopPropagation(); + + const player = this.player() as unknown as VideoJsPlayer; + const api = getAbLoopApi(player); + if (!api) return; + + const opts = api.getOptions(); + const changes: Partial = + this.bound === "start" + ? { start: player.currentTime() } + : { end: player.currentTime() }; + api.setOptions({ ...opts, ...changes }); + } +} + +interface BigButtonGroupOptions extends videojs.ComponentOptions { + showAbLoop?: boolean; +} + class BigButtonGroup extends videojs.getComponent("Component") { - constructor(player: VideoJsPlayer) { - super(player); + constructor(player: VideoJsPlayer, options?: BigButtonGroupOptions) { + super(player, options); + + if (options?.showAbLoop) { + this.addChild("AbBoundButton", { bound: "start" }); + } this.addChild("seekButton", { direction: "back", @@ -34,6 +109,10 @@ class BigButtonGroup extends videojs.getComponent("Component") { direction: "forward", seconds: 10, }); + + if (options?.showAbLoop) { + this.addChild("AbBoundButton", { bound: "end" }); + } } createEl() { @@ -43,12 +122,18 @@ class BigButtonGroup extends videojs.getComponent("Component") { } } +interface BigButtonsOptions { + showAbLoop?: boolean; +} + class BigButtonsPlugin extends videojs.getPlugin("plugin") { - constructor(player: VideoJsPlayer) { - super(player); + constructor(player: VideoJsPlayer, options?: BigButtonsOptions) { + super(player, options); player.ready(() => { - player.addChild("BigButtonGroup"); + player.addChild("BigButtonGroup", { + showAbLoop: options?.showAbLoop ?? false, + }); }); } } @@ -56,6 +141,7 @@ class BigButtonsPlugin extends videojs.getPlugin("plugin") { // Register the plugin with video.js. videojs.registerComponent("BigButtonGroup", BigButtonGroup); videojs.registerComponent("BigPlayPauseButton", BigPlayPauseButton); +videojs.registerComponent("AbBoundButton", AbBoundButton); videojs.registerPlugin("bigButtons", BigButtonsPlugin); declare module "video.js" { @@ -63,7 +149,7 @@ declare module "video.js" { bigButtons: () => BigButtonsPlugin; } interface VideoJsPlayerPluginOptions { - bigButtons?: object; + bigButtons?: BigButtonsOptions; } } diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 87e704cf5d..7b7e3babe7 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -78,7 +78,7 @@ $sceneTabWidth: 450px; justify-content: space-around; opacity: 0; position: absolute; - top: calc(50% - 40px); + top: calc(50% - 65px); width: 100%; z-index: 1; @@ -92,6 +92,89 @@ $sceneTabWidth: 450px; line-height: 80px; } } + + // SVG icons via mask-image (rather than a font-glyph/::before letter or + // a plain background-image) so vjs-ab-bound-active below can recolor + // them - the same idiom .vjs-ab-loop-toggle further down uses for the + // same reason. Hidden until AB-looping is actually on; see + // &.vjs-ab-loop-enabled below. + .vjs-ab-start-button, + .vjs-ab-end-button { + background-color: rgba(255, 255, 255, 0.9); + display: none; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 45%; + mask-size: 45%; + + .vjs-icon-placeholder { + display: none; + } + + // signals that this bound has actually been set, rather than just + // sitting at the vendor plugin's start:0/end:false defaults + &.vjs-ab-bound-active { + background-color: $link-color; + } + } + + .vjs-ab-start-button { + -webkit-mask-image: url("/ab-start.svg"); + mask-image: url("/ab-start.svg"); + } + + .vjs-ab-end-button { + -webkit-mask-image: url("/ab-end.svg"); + mask-image: url("/ab-end.svg"); + } + } + + // the floating start/end buttons only make sense once AB-looping is + // actually on - tapping them beforehand would silently arm a loop that + // isn't running, with no "LOOP ON" label here (unlike desktop) to explain + // why nothing happened. vjs-ab-loop-enabled is toggled on the player by + // the abLoopPlugin.onOptionsChange bridge in ScenePlayer.tsx. + &.vjs-ab-loop-enabled .vjs-big-button-group { + .vjs-ab-start-button, + .vjs-ab-end-button { + display: block; + } + } + + // hidden by default (desktop keeps the vendor plugin's own "LOOP ON"/ + // "Loop off" text button); shown on mobile instead - see the + // max-width: 768px block below, which also hides that vendor button + .vjs-ab-loop-toggle { + display: none; + } + + // highlights the active AB-loop range on the progress bar (see + // ab-loop-range.ts); shown at all widths, desktop and mobile alike + .vjs-ab-loop-range { + background-color: rgba($link-color, 0.85); + border-radius: 1px; + height: 100%; + pointer-events: none; + position: absolute; + top: 0; + } + + // pinpoints an individually-set A/B point on the progress bar, + // independent of whether the other point has been set too (see + // ab-loop-range.ts's refresh()) - the same $link-color as + // .vjs-ab-loop-range and the active-state buttons above, so all three + // read as one visual language + .vjs-ab-loop-marker { + background-color: $link-color; + height: 100%; + pointer-events: none; + position: absolute; + top: 0; + transform: translateX(-50%); + width: 3px; + z-index: 0; } .vjs-airplay-button .vjs-icon-placeholder, @@ -176,6 +259,20 @@ $sceneTabWidth: 450px; } } + .vjs-progress-control { + bottom: 2.5em; + height: 3em; + position: absolute; + width: 100%; + + .vjs-progress-holder { + margin: 0 15px; + } + } + + // current/total time, inline in the button row as usual on desktop; on + // mobile they instead float above either end of the progress bar - see + // the max-width: 768px block below, which overrides the position .vjs-time-control { align-items: center; display: flex; @@ -193,21 +290,6 @@ $sceneTabWidth: 450px; margin-right: auto; } - .vjs-remaining-time { - display: none; - } - - .vjs-progress-control { - bottom: 2.5em; - height: 3em; - position: absolute; - width: 100%; - - .vjs-progress-holder { - margin: 0 15px; - } - } - /* stylelint-disable declaration-no-important */ .vjs-play-progress .vjs-time-tooltip { display: none !important; @@ -361,39 +443,137 @@ $sceneTabWidth: 450px; transform: scale(-1, 1); } - @media (pointer: coarse) { - &.vjs-touch-enabled { - &.vjs-has-started .vjs-big-button-group { - display: flex; - opacity: 1; - visibility: visible; - } + // the vendor abLoop buttons need to fit their own text ("0:00.0", + // "LOOP ON", ...), not the fixed width every other .vjs-control gets - + // .vjs-control.abLoopButton (3 classes) rather than just .abLoopButton + // so this reliably outranks the width rules below regardless of source + // order, on both the vendor buttons themselves + .vjs-control-bar .vjs-control.abLoopButton { + padding: 10px; + white-space: nowrap; + width: fit-content; + } - &.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-big-button-group { - opacity: 0; - pointer-events: none; - transition: visibility 1s, opacity 1s; - visibility: visible; - } + // trim control width and the AB-loop vendor buttons' padding between here + // and the full mobile breakpoint below - without this, the growing + // number of controls in the bar overflows before the mobile layout + // (which hides/relocates several of them) kicks in to compensate + @media (max-width: 992px) { + .vjs-control-bar .vjs-control { + width: 3.2em; + } - .vjs-big-play-pause-button .vjs-icon-placeholder::before { - content: "\f101"; - font-family: VideoJS; - } + .vjs-control-bar .vjs-control.abLoopButton { + padding: 6px; + } + } + + // floating play/pause + jog buttons over the video, mobile only - on + // desktop the +/-10s buttons stay as regular control-bar buttons instead + @media (max-width: 768px) { + &.vjs-has-started .vjs-big-button-group { + display: flex; + opacity: 1; + visibility: visible; + } + + &.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-big-button-group { + opacity: 0; + pointer-events: none; + transition: visibility 1s, opacity 1s; + visibility: visible; + } + + .vjs-big-play-pause-button .vjs-icon-placeholder::before { + content: "\f101"; + font-family: VideoJS; + } + + &.vjs-playing .vjs-big-play-pause-button .vjs-icon-placeholder::before { + content: "\f103"; + } + + .vjs-vtt-thumbnail-display { + bottom: 2.8em; + } + + // the floating group above takes over jogging on mobile + .vjs-control-bar .vjs-seek-button { + display: none; + } - &.vjs-playing .vjs-big-play-pause-button .vjs-icon-placeholder::before { - content: "\f103"; + // ...and all 3 AB-loop buttons - the floating group's A/B buttons and + // vjs-ab-loop-toggle below take over on mobile, so hide the vendor's + // own text buttons entirely here + .vjs-control-bar .abLoopButton.start, + .vjs-control-bar .abLoopButton.end, + .vjs-control-bar .abLoopButton.enabled { + display: none; + } + + // the mobile-only AB-loop enable/disable icon button (ab-loop-toggle.ts) + // - a real Button with the normal icon-placeholder/control-text + // structure, so the icon is masked onto vjs-icon-placeholder itself + // rather than the button root + .vjs-ab-loop-toggle { + display: inline-block; + + .vjs-icon-placeholder { + background-color: #fff; + display: inline-block; + height: 18px; + -webkit-mask-image: url("/ab-loop-toggle.svg"); + mask-image: url("/ab-loop-toggle.svg"); + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-size: contain; + width: 18px; } - .vjs-vtt-thumbnail-display { - bottom: 2.8em; + &.vjs-ab-loop-active .vjs-icon-placeholder { + background-color: $link-color; } + } - // hide the regular seek buttons on touch screens - .vjs-control-bar .vjs-seek-button { + // current/total time, floated above either end of the progress bar. + // Shares the same bottom/height band as .vjs-progress-control, but + // aligned to the top of it (the bar is pushed to the bottom of that + // band) so the two don't overlap. + .vjs-progress-control { + align-items: flex-end; + } + + .vjs-current-time, + .vjs-duration { + align-items: flex-start; + bottom: 3.5em; + display: flex; + height: 3em; + pointer-events: none; + position: absolute; + z-index: 1; + + .vjs-control-text { display: none; } } + + .vjs-current-time { + left: 15px; + } + + .vjs-duration { + right: 15px; + } + + // the "/" divider only makes sense between the two when they're + // adjacent, not floating at opposite ends of the progress bar + .vjs-time-divider { + display: none; + } } @media (max-width: 576px) { .vjs-control-bar { @@ -412,10 +592,18 @@ $sceneTabWidth: 450px; .vjs-progress-control { height: 2em; + padding-bottom: 10px; width: 100%; } + // video.js's own customControlSpacer (which would normally push + // everything from here on out to the right edge) only kicks in at + // its own internal vjs-layout-x-small breakpoint, which we don't + // reliably hit here - so push the whole trailing cluster + // (1x / settings / AB-loop toggle / fullscreen) right ourselves. + // It's the first of that group, so this is enough on its own. .vjs-playback-rate { + margin-left: auto; width: 3em; } @@ -455,18 +643,10 @@ $sceneTabWidth: 450px; } } - .vjs-time-control { - font-size: 12px; - } - .vjs-big-button-group .vjs-button { font-size: 2em; width: 50px; } - - .vjs-current-time { - margin-left: 1em; - } } } @@ -699,4 +879,4 @@ $sceneTabWidth: 450px; max-width: calc(100% - 15px); } } -} +} \ No newline at end of file From ec21bfb36ec919b5400905ec4a41d30624c29654 Mon Sep 17 00:00:00 2001 From: Mateus Filipe <317431369+mateus3355@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:46:49 +0000 Subject: [PATCH 2/4] fix: mobile scrub cause real video seek --- .../components/ScenePlayer/ScenePlayer.tsx | 1 + .../ScenePlayer/deferred-touch-seek.ts | 74 +++++++++++++++++++ .../src/components/ScenePlayer/styles.scss | 4 + 3 files changed, 79 insertions(+) create mode 100644 ui/v2.5/src/components/ScenePlayer/deferred-touch-seek.ts diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 2192034240..6cf29baf54 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -20,6 +20,7 @@ import "./autostart-button"; import MarkersPlugin, { type IMarker } from "./markers"; void MarkersPlugin; import "./vtt-thumbnails"; +import "./deferred-touch-seek"; import "./big-buttons"; import "./ab-loop-toggle"; import "./ab-loop-range"; diff --git a/ui/v2.5/src/components/ScenePlayer/deferred-touch-seek.ts b/ui/v2.5/src/components/ScenePlayer/deferred-touch-seek.ts new file mode 100644 index 0000000000..d0c7ad86b1 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/deferred-touch-seek.ts @@ -0,0 +1,74 @@ +import videojs from "video.js"; + +const SeekBar = videojs.getComponent( + "SeekBar" +) as unknown as typeof videojs.SeekBar; + +// The stock SeekBar seeks live - it calls player.currentTime() (a real +// seek) on every touchmove while dragging, the same code path used for +// mouse dragging. That's essentially free for a local file, but Stash +// scenes are typically streamed (HLS/DASH, or range-requested direct +// play), so sweeping a finger across the bar issues a real segment/range +// load at every intermediate point swept over on the way to wherever the +// finger lands, not just the one it's released on - most noticeable as a +// long stall/lots of loading when scrubbing on mobile. +// +// Defer the actual seek to touchend/release. The fill bar is still moved +// on every touchmove - computed directly from pointer position rather +// than from the player's actual current time - so dragging still looks +// and feels responsive; vtt-thumbnails.ts's hover preview tracks pointer +// position independently of any of this, so it's unaffected either way. +// Mouse dragging (desktop) is untouched, since only touch* events take +// this path. +class DeferredTouchSeekBar extends SeekBar { + private pendingSeekPercent?: number; + + handleMouseMove(event: videojs.EventTarget.Event, mouseDown?: boolean) { + if (event.type !== "touchstart" && event.type !== "touchmove") { + // the public type only declares handleMouseMove(event), but the + // real implementation also takes the mouseDown flag Slider. + // handleMouseDown() passes on the initial down event - forward it + // through so mouse-drag behavior is unchanged + ( + super.handleMouseMove as ( + event: videojs.EventTarget.Event, + mouseDown?: boolean + ) => void + )(event, mouseDown); + return; + } + + const percent = this.calculateDistance(event); + this.pendingSeekPercent = percent; + + /* biome-ignore lint/suspicious/noExplicitAny: `bar` isn't part of video.js's public Slider/SeekBar types, but is set by the base Slider constructor */ + const bar = (this as any).bar; + if (bar) { + (bar.el() as HTMLElement).style.width = `${(percent * 100).toFixed(2)}%`; + } + this.el().setAttribute("aria-valuenow", (percent * 100).toFixed(2)); + } + + handleMouseUp(event: videojs.EventTarget.Event) { + const percent = this.pendingSeekPercent; + this.pendingSeekPercent = undefined; + + // apply the deferred seek before delegating to the base class, so that + // by the time it decides whether to resume playback, currentTime + // already reflects where the drag ended - same end state as the stock + // continuous-seek behavior, just without the intermediate loads + if (percent !== undefined) { + const player = this.player(); + player.currentTime(percent * player.duration()); + } + + super.handleMouseUp(event); + } +} + +// Replace the built-in SeekBar - this is video.js's own supported way to +// customize a default component, and every consumer (ProgressControl, +// keyboard handling, etc.) looks it up by this same registered name. +videojs.registerComponent("SeekBar", DeferredTouchSeekBar); + +export default DeferredTouchSeekBar; diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 7b7e3babe7..4beabbb318 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -466,6 +466,10 @@ $sceneTabWidth: 450px; .vjs-control-bar .vjs-control.abLoopButton { padding: 6px; } + + .vjs-control-bar .vjs-control:has(.vjs-progress-holder) { + width: 100%; + } } // floating play/pause + jog buttons over the video, mobile only - on From 648982f2ff60ad4c109b135850b28d7a0f67fa30 Mon Sep 17 00:00:00 2001 From: Mateus Filipe <317431369+mateus3355@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:05:03 +0000 Subject: [PATCH 3/4] fix: mobile breakpoints and loop icon svg --- ui/v2.5/public/ab-loop-toggle.svg | 5 +--- .../src/components/ScenePlayer/styles.scss | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/ui/v2.5/public/ab-loop-toggle.svg b/ui/v2.5/public/ab-loop-toggle.svg index 43c810689c..a07a199c22 100644 --- a/ui/v2.5/public/ab-loop-toggle.svg +++ b/ui/v2.5/public/ab-loop-toggle.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 4beabbb318..71c14ff660 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -172,7 +172,6 @@ $sceneTabWidth: 450px; pointer-events: none; position: absolute; top: 0; - transform: translateX(-50%); width: 3px; z-index: 0; } @@ -457,8 +456,12 @@ $sceneTabWidth: 450px; // trim control width and the AB-loop vendor buttons' padding between here // and the full mobile breakpoint below - without this, the growing // number of controls in the bar overflows before the mobile layout - // (which hides/relocates several of them) kicks in to compensate - @media (max-width: 992px) { + // (which hides/relocates several of them) kicks in to compensate. + // Keyed off the player's own rendered width (.scene-player-container, + // see its container-type there) rather than the viewport's - expanding + // .scene-tabs narrows the player without the viewport changing size at + // all, and controls need to respond to that too. + @container scene-player (max-width: 992px) { .vjs-control-bar .vjs-control { width: 3.2em; } @@ -473,8 +476,10 @@ $sceneTabWidth: 450px; } // floating play/pause + jog buttons over the video, mobile only - on - // desktop the +/-10s buttons stay as regular control-bar buttons instead - @media (max-width: 768px) { + // desktop the +/-10s buttons stay as regular control-bar buttons instead. + // See the container-query comment above: keyed off the player's own + // width, not the viewport's. + @container scene-player (max-width: 768px) { &.vjs-has-started .vjs-big-button-group { display: flex; opacity: 1; @@ -520,7 +525,10 @@ $sceneTabWidth: 450px; // structure, so the icon is masked onto vjs-icon-placeholder itself // rather than the button root .vjs-ab-loop-toggle { + align-items: center; display: inline-block; + display: flex; + justify-content: center; .vjs-icon-placeholder { background-color: #fff; @@ -579,7 +587,7 @@ $sceneTabWidth: 450px; display: none; } } - @media (max-width: 576px) { + @container scene-player (max-width: 576px) { .vjs-control-bar { .vjs-autostart-button { display: none; @@ -588,7 +596,7 @@ $sceneTabWidth: 450px; } // make controls a little more compact on smaller screens - @media (max-width: 768px) { + @container scene-player (max-width: 768px) { .vjs-control-bar { .vjs-control { width: 2.5em; @@ -662,6 +670,13 @@ $sceneTabWidth: 450px; } .scene-player-container { + // named so the player's own responsive rules (video-js's `@container + // scene-player (...)` queries below) can react to how much width the + // player actually has, rather than the viewport's - the two diverge + // whenever .scene-tabs is expanded rather than collapsed, since that + // narrows this container without the viewport itself getting any + // smaller + container: scene-player / inline-size; padding-right: 15px; } From ff198c2476351543d61b951089afbc49a1bd0cb5 Mon Sep 17 00:00:00 2001 From: Mateus Filipe <317431369+mateus3355@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:03:19 +0000 Subject: [PATCH 4/4] fix: ab loop start button touch event fix --- .../components/ScenePlayer/ScenePlayer.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 6cf29baf54..7869859158 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -477,6 +477,35 @@ export const ScenePlayer: React.FC = PatchComponent( controlBarEl.insertBefore(toggleEl, fullscreenEl); controlBarEl.insertBefore(fullscreenEl, marker); marker.remove(); + + // the vendor plugin's own Start/End/Loop buttons bind their click + // handler with a plain .on('click', ...) and never stop propagation, + // unlike every custom button in this file (see AbBoundButton, + // AbLoopToggleButton) - so a tap on them also reaches whatever's + // listening underneath (videojs-mobile-ui's tap-to-toggle-controls/ + // play-pause handling). On touch this compounds with the browser's + // own delayed touch->click compatibility event, which can fire a + // second, separate click after ours - together producing the + // toggles-twice/also-pauses-the-video behavior these buttons are + // known for on touch. Intercept touchend directly (stopping that + // delayed click from ever happening, and stopping propagation + // before it reaches anything else) and fire exactly one clean click. + const vendorAbLoopButtonEls = + controlBarEl.querySelectorAll( + ".abLoopButton.enabled, .abLoopButton.start, .abLoopButton.end" + ); + vendorAbLoopButtonEls.forEach((btnEl) => { + btnEl.addEventListener("click", (e) => e.stopPropagation()); + btnEl.addEventListener( + "touchend", + (e) => { + e.preventDefault(); + e.stopPropagation(); + btnEl.click(); + }, + { passive: false } + ); + }); }); /* biome-ignore lint/suspicious/noExplicitAny: intentional */