Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,6 @@ node_modules
/phasher
dist
.DS_Store
/.local*
/.local*
.pnpm-store
.tmp
4 changes: 4 additions & 0 deletions ui/v2.5/public/ab-end.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions ui/v2.5/public/ab-loop-toggle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions ui/v2.5/public/ab-start.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions ui/v2.5/src/@types/videojs-abloop.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
18 changes: 18 additions & 0 deletions ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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?.();
}

Expand Down
116 changes: 110 additions & 6 deletions ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ 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";
import "./track-activity";
import "./vrmode";
import "./media-session";
Expand Down Expand Up @@ -125,15 +128,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
}
Expand Down Expand Up @@ -346,6 +351,11 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = 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: {
Expand Down Expand Up @@ -387,7 +397,13 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = 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,
Expand All @@ -408,6 +424,17 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = 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: {},
},
Expand All @@ -421,6 +448,66 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = 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();

// 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<HTMLElement>(
".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 */
const settings = (vjs as any).textTrackSettings;
settings.setValues({
Expand Down Expand Up @@ -929,9 +1016,26 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = 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
Expand Down
135 changes: 135 additions & 0 deletions ui/v2.5/src/components/ScenePlayer/ab-loop-range.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading