Skip to content

Instantly share code, notes, and snippets.

@ashtonmeuser
Last active April 4, 2025 16:17
Show Gist options
  • Save ashtonmeuser/1a65e50a7cc6bcb10b1225a43e4d6a30 to your computer and use it in GitHub Desktop.
Save ashtonmeuser/1a65e50a7cc6bcb10b1225a43e4d6a30 to your computer and use it in GitHub Desktop.
Set loop points on HTML video elements
// bookmarklet-title: Loop
// bookmarklet-about: Set loop points on HTML video elements. Enable the console option for a lighter-weight console log in place of toast message.
// bookmarklet-var(uuid): uuid
// bookmarklet-var(boolean): console
import toast from '/ashtonmeuser/e20e5ffd7ea503ff6b0fd80787b00b82/raw/8eb2f8190d7bfd94b0866735546131f94a7e30f1/toast.ts';
import StateManager from '/ashtonmeuser/1a65e50a7cc6bcb10b1225a43e4d6a30/raw//StateManager.ts';
import VideoLoopState from '/ashtonmeuser/1a65e50a7cc6bcb10b1225a43e4d6a30/raw//VideoLoopState.ts';
export class ToastVideoLoopState extends VideoLoopState {
constructor() {
super(loop => {
const message = loop ? `Loop ${this.format(loop.start)} → ${this.format(loop.end)}` : 'Loop cleared';
if (console) console.log(message);
else toast(message);
}, 100);
}
private format(seconds?: number): string {
if (seconds === undefined) return '—';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
seconds = Math.floor(seconds % 60);
const parts: string[] = [];
if (hours > 0) parts.push(hours.toString().padStart(2, '0'));
parts.push(minutes.toString().padStart(2, '0'));
parts.push(seconds.toString().padStart(2, '0'));
return parts.join(':');
}
}
const manager = new StateManager(uuid, ToastVideoLoopState);
manager.state.toggle();
export default class StateManager<T> {
private static _instance: StateManager<any> | null;
private readonly type = 'StateManager';
state!: T;
constructor(id: string, constructor: new () => T) {
if (StateManager._instance) return StateManager._instance;
if (typeof globalThis === 'object' && id in globalThis && (globalThis as any)[id]?.type === 'StateManager') {
StateManager._instance = (globalThis as any)[id];
return StateManager._instance!;
}
this.state = new constructor();
StateManager._instance = this;
Object.assign(globalThis, {
[id]: this,
});
}
}
interface VideoLoop {
start?: number;
end?: number;
}
type LoopCallback = (loop: VideoLoop | null) => void;
export default class VideoLoopState {
private loops = new WeakMap<HTMLMediaElement, VideoLoop>();
protected callback?: LoopCallback;
constructor(callback?: LoopCallback, interval: number = 1000) {
this.callback = callback;
this.startPolling(interval);
}
toggle() {
const videos = this.getVisibleVideos();
for (const video of videos) {
const loop = this.loops.get(video);
if (!loop || loop.start === undefined) { // Set loop start
this.setLoop(video, { start: video.currentTime });
} else if (loop.end === undefined) { // Set loop end
this.setLoop(video, video.currentTime < loop.start ? { start: video.currentTime } : { start: loop.start, end: video.currentTime });
} else { // Clear loop
this.setLoop(video, null);
}
}
}
private setLoop(video: HTMLMediaElement, loop: VideoLoop | null) {
loop === null ? this.loops.delete(video) : this.loops.set(video, loop);
this.callback?.(loop);
}
private getVisibleVideos(): HTMLMediaElement[] {
return Array.from(document.getElementsByTagName('video'))
.filter(video => video.offsetParent !== null && video.getBoundingClientRect().width > 0);
}
private startPolling(interval: number) {
window.setInterval(() => {
const videos = this.getVisibleVideos();
for (const video of videos) {
const loop = this.loops.get(video);
if (!loop) continue;
if ((loop.start !== undefined && video.currentTime < loop.start)
|| (loop.start !== undefined && loop.end !== undefined && video.currentTime >= loop.end)) {
video.currentTime = loop.start;
}
}
}, interval);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment