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 @@
+
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 @@
+
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 */