feat: Add video playback support and settings validation
- Implemented a continuous animation loop in 'MagnifyGlass' that activates automatically when video widgets are detected, ensuring smooth playback for standard videos and VHS previews. - Added robust validation to 'StateManager' for loading settings, including bounds checking for dimensions/opacity and format verification for colors and enums. - Updated widget detection logic to correctly identify and handle 'videopreview' and 'audiopreview' widgets. - Ensured animation loops are properly stopped when the glass is hidden or destroyed to preserve resources.
This commit is contained in:
@@ -47,6 +47,12 @@ export class MagnifyGlass {
|
||||
/** Current media element under cursor */
|
||||
currentMediaElement: HTMLImageElement | HTMLVideoElement | null;
|
||||
|
||||
/** Animation loop ID for continuous video rendering */
|
||||
private animationLoopId: number | null;
|
||||
|
||||
/** Whether videos are currently visible in magnified view */
|
||||
private hasVisibleVideos: boolean;
|
||||
|
||||
constructor() {
|
||||
this.config = new ConfigManager();
|
||||
this.state = new MagnifierState();
|
||||
@@ -74,6 +80,10 @@ export class MagnifyGlass {
|
||||
// Media tracking
|
||||
this.isOverMedia = false;
|
||||
this.currentMediaElement = null;
|
||||
|
||||
// Animation loop for video rendering
|
||||
this.animationLoopId = null;
|
||||
this.hasVisibleVideos = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +157,8 @@ export class MagnifyGlass {
|
||||
if (this.eventHandler) {
|
||||
this.eventHandler.updateInitialPosition();
|
||||
}
|
||||
// Start continuous animation loop for smooth video playback
|
||||
this.startAnimationLoop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +168,8 @@ export class MagnifyGlass {
|
||||
*/
|
||||
private forceHideAllComponents(): void {
|
||||
this.state.active = false;
|
||||
this.stopAnimationLoop();
|
||||
this.hasVisibleVideos = false;
|
||||
this.ui.hide();
|
||||
|
||||
// 1. Nuclear option: Direct DOM ID targeting
|
||||
@@ -370,6 +384,9 @@ export class MagnifyGlass {
|
||||
|
||||
this.ui.htmlOverlayContainer.innerHTML = '';
|
||||
|
||||
// Track if any videos are found for animation loop management
|
||||
let foundVideos = false;
|
||||
|
||||
const magnifyRect: Rectangle = {
|
||||
x: this.state.sourceX,
|
||||
y: this.state.sourceY,
|
||||
@@ -425,7 +442,19 @@ export class MagnifyGlass {
|
||||
let isImageElement = false;
|
||||
let elementToProcess: HTMLElement | null = null;
|
||||
|
||||
if (widget.element) {
|
||||
// Check for VHS-style widgets with videoEl property (VideoHelperSuite pattern)
|
||||
const widgetName = String(widget.name || '').toLowerCase();
|
||||
if (widgetName === 'videopreview' || widgetName === 'audiopreview') {
|
||||
const videoEl = widget.videoEl as HTMLVideoElement | undefined;
|
||||
if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) {
|
||||
isVideoElement = true;
|
||||
foundVideos = true;
|
||||
elementToProcess = videoEl;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for standard DOM element widgets (existing logic)
|
||||
if (!elementToProcess && widget.element) {
|
||||
const element = widget.element as HTMLElement;
|
||||
// Skip text elements - now rendered natively on canvas via OffscreenRenderer
|
||||
if (widget.type === "text" || widget.type === "string" || element.tagName === 'TEXTAREA') {
|
||||
@@ -433,6 +462,7 @@ export class MagnifyGlass {
|
||||
// elementToProcess = element;
|
||||
} else if (element.tagName === 'VIDEO') {
|
||||
isVideoElement = true;
|
||||
foundVideos = true;
|
||||
elementToProcess = element;
|
||||
} else if (element.tagName === 'IMG') {
|
||||
isImageElement = true;
|
||||
@@ -441,6 +471,7 @@ export class MagnifyGlass {
|
||||
const potentialVideo = element.querySelector('video');
|
||||
if (potentialVideo) {
|
||||
isVideoElement = true;
|
||||
foundVideos = true;
|
||||
elementToProcess = potentialVideo;
|
||||
} else {
|
||||
const potentialImage = element.querySelector('img');
|
||||
@@ -581,6 +612,77 @@ export class MagnifyGlass {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manage animation loop based on video presence
|
||||
if (foundVideos && !this.hasVisibleVideos) {
|
||||
this.hasVisibleVideos = true;
|
||||
this.startAnimationLoop();
|
||||
} else if (!foundVideos && this.hasVisibleVideos) {
|
||||
this.hasVisibleVideos = false;
|
||||
// Loop will stop itself on next iteration
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the animation loop for continuous video rendering.
|
||||
* Only runs when videos are visible in the magnified view.
|
||||
* This loop directly renders without going through updateMagnifiedView's
|
||||
* scheduling logic to ensure continuous frame updates for video playback.
|
||||
*/
|
||||
private startAnimationLoop(): void {
|
||||
if (this.animationLoopId !== null) return; // Already running
|
||||
|
||||
const animate = () => {
|
||||
// Stop if glass is deactivated or renderer unavailable
|
||||
if (!this.state.active || !this.renderer || !this.litegraphCanvas) {
|
||||
this.stopAnimationLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if glass preview is hidden
|
||||
if (this.state.isPreviewHidden) {
|
||||
this.animationLoopId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update canvas transformation and source region
|
||||
this.updateCanvasTransformation();
|
||||
this.calculateSourceRegion();
|
||||
|
||||
// Direct render (bypass isRenderScheduled to ensure continuous updates)
|
||||
let sourceCanvas: HTMLCanvasElement = this.litegraphCanvas;
|
||||
if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) {
|
||||
const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas);
|
||||
if (highResCanvas) {
|
||||
sourceCanvas = highResCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the magnified view
|
||||
this.renderer.render(sourceCanvas);
|
||||
|
||||
// Render HTML overlays for video widgets
|
||||
this.renderHtmlOverlays();
|
||||
|
||||
// Send frame to pop-out tab if open
|
||||
if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) {
|
||||
this.popOutManager.sendFrame(this.ui.glassCanvas);
|
||||
}
|
||||
|
||||
this.animationLoopId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
this.animationLoopId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the animation loop.
|
||||
*/
|
||||
private stopAnimationLoop(): void {
|
||||
if (this.animationLoopId !== null) {
|
||||
cancelAnimationFrame(this.animationLoopId);
|
||||
this.animationLoopId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -666,6 +768,7 @@ export class MagnifyGlass {
|
||||
* Cleanup all resources.
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.stopAnimationLoop();
|
||||
this.eventHandler.detachListeners();
|
||||
this.popOutManager.cleanup();
|
||||
this.ui.cleanup();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||||
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
||||
import { getSettingValue } from "../shared/utils.js";
|
||||
import { getSettingValue, clamp } from "../shared/utils.js";
|
||||
import { DEFAULT_PANEL_SETTINGS } from "../shared/settings/defaults.js";
|
||||
import "/scripts/app.js";
|
||||
class StateManager {
|
||||
@@ -187,13 +187,101 @@ class StateManager {
|
||||
}
|
||||
}
|
||||
loadSettings() {
|
||||
Object.keys(DEFAULT_PANEL_SETTINGS).forEach((key) => {
|
||||
const defaults = DEFAULT_PANEL_SETTINGS;
|
||||
Object.keys(defaults).forEach((key) => {
|
||||
if (key !== "🔍MagnifyGlass.InfoPanelTheme") {
|
||||
this.state.settings[key] = getSettingValue(key, DEFAULT_PANEL_SETTINGS[key]);
|
||||
this.state.settings[key] = getSettingValue(key, defaults[key]);
|
||||
}
|
||||
});
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelWidth"] = this.validateNumber(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelWidth"],
|
||||
200,
|
||||
600,
|
||||
defaults["🔍MagnifyGlass.InfoPanelWidth"]
|
||||
);
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelMaxHeight"] = this.validateNumber(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelMaxHeight"],
|
||||
200,
|
||||
1500,
|
||||
defaults["🔍MagnifyGlass.InfoPanelMaxHeight"]
|
||||
);
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelOpacity"] = this.validateNumber(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelOpacity"],
|
||||
10,
|
||||
100,
|
||||
defaults["🔍MagnifyGlass.InfoPanelOpacity"]
|
||||
);
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelFontSize"] = this.validateNumber(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelFontSize"],
|
||||
8,
|
||||
24,
|
||||
defaults["🔍MagnifyGlass.InfoPanelFontSize"]
|
||||
);
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelTextColor"] = this.validateColor(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelTextColor"],
|
||||
defaults["🔍MagnifyGlass.InfoPanelTextColor"]
|
||||
);
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelAccentColor"] = this.validateColor(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelAccentColor"],
|
||||
defaults["🔍MagnifyGlass.InfoPanelAccentColor"]
|
||||
);
|
||||
const validPanelPositions = ["Bottom", "Top", "Left", "Right"];
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelPosition"] = this.validateStringOption(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelPosition"],
|
||||
validPanelPositions,
|
||||
defaults["🔍MagnifyGlass.InfoPanelPosition"]
|
||||
);
|
||||
const validControlPositions = ["top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right"];
|
||||
this.state.settings["🔍MagnifyGlass.ControlsPosition"] = this.validateStringOption(
|
||||
this.state.settings["🔍MagnifyGlass.ControlsPosition"],
|
||||
validControlPositions,
|
||||
defaults["🔍MagnifyGlass.ControlsPosition"]
|
||||
);
|
||||
const validFonts = [
|
||||
"System Default",
|
||||
"Inter",
|
||||
"Roboto",
|
||||
"JetBrains Mono",
|
||||
"Fira Code",
|
||||
"IBM Plex Sans",
|
||||
"Space Grotesk",
|
||||
"Lexend",
|
||||
"Outfit",
|
||||
"monospace",
|
||||
"system-ui"
|
||||
];
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"] = this.validateStringOption(
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"],
|
||||
validFonts,
|
||||
defaults["🔍MagnifyGlass.InfoPanelFontFamily"]
|
||||
);
|
||||
this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme;
|
||||
}
|
||||
/**
|
||||
* Validate numeric input with bounds checking.
|
||||
*/
|
||||
validateNumber(value, min, max, fallback) {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return fallback;
|
||||
return clamp(num, min, max);
|
||||
}
|
||||
/**
|
||||
* Validate color string (hex).
|
||||
*/
|
||||
validateColor(color, fallback) {
|
||||
if (!color || typeof color !== "string") return fallback;
|
||||
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(color)) {
|
||||
return color;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
/**
|
||||
* Validate string against an allowlist.
|
||||
*/
|
||||
validateStringOption(value, options, fallback) {
|
||||
if (typeof value !== "string") return fallback;
|
||||
return options.includes(value) ? value : fallback;
|
||||
}
|
||||
updateSettings() {
|
||||
const oldSettings = { ...this.state.settings };
|
||||
this.loadSettings();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,6 +32,10 @@ class MagnifyGlass {
|
||||
__publicField(this, "isOverMedia");
|
||||
/** Current media element under cursor */
|
||||
__publicField(this, "currentMediaElement");
|
||||
/** Animation loop ID for continuous video rendering */
|
||||
__publicField(this, "animationLoopId");
|
||||
/** Whether videos are currently visible in magnified view */
|
||||
__publicField(this, "hasVisibleVideos");
|
||||
this.config = new ConfigManager();
|
||||
this.state = new MagnifierState();
|
||||
this.popOutManager = new PopOutManager();
|
||||
@@ -48,6 +52,8 @@ class MagnifyGlass {
|
||||
this.lastKnownMousePosition = { x: 0, y: 0 };
|
||||
this.isOverMedia = false;
|
||||
this.currentMediaElement = null;
|
||||
this.animationLoopId = null;
|
||||
this.hasVisibleVideos = false;
|
||||
}
|
||||
/**
|
||||
* Initialize the magnifying glass.
|
||||
@@ -93,6 +99,7 @@ class MagnifyGlass {
|
||||
if (this.eventHandler) {
|
||||
this.eventHandler.updateInitialPosition();
|
||||
}
|
||||
this.startAnimationLoop();
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -101,6 +108,8 @@ class MagnifyGlass {
|
||||
*/
|
||||
forceHideAllComponents() {
|
||||
this.state.active = false;
|
||||
this.stopAnimationLoop();
|
||||
this.hasVisibleVideos = false;
|
||||
this.ui.hide();
|
||||
const panelEl = document.getElementById(INFO_PANEL_ID);
|
||||
if (panelEl) {
|
||||
@@ -247,6 +256,7 @@ class MagnifyGlass {
|
||||
return;
|
||||
}
|
||||
this.ui.htmlOverlayContainer.innerHTML = "";
|
||||
let foundVideos = false;
|
||||
const magnifyRect = {
|
||||
x: this.state.sourceX,
|
||||
y: this.state.sourceY,
|
||||
@@ -290,11 +300,21 @@ class MagnifyGlass {
|
||||
let isVideoElement = false;
|
||||
let isImageElement = false;
|
||||
let elementToProcess = null;
|
||||
if (widget.element) {
|
||||
const widgetName = String(widget.name || "").toLowerCase();
|
||||
if (widgetName === "videopreview" || widgetName === "audiopreview") {
|
||||
const videoEl = widget.videoEl;
|
||||
if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) {
|
||||
isVideoElement = true;
|
||||
foundVideos = true;
|
||||
elementToProcess = videoEl;
|
||||
}
|
||||
}
|
||||
if (!elementToProcess && widget.element) {
|
||||
const element = widget.element;
|
||||
if (widget.type === "text" || widget.type === "string" || element.tagName === "TEXTAREA") ;
|
||||
else if (element.tagName === "VIDEO") {
|
||||
isVideoElement = true;
|
||||
foundVideos = true;
|
||||
elementToProcess = element;
|
||||
} else if (element.tagName === "IMG") {
|
||||
isImageElement = true;
|
||||
@@ -303,6 +323,7 @@ class MagnifyGlass {
|
||||
const potentialVideo = element.querySelector("video");
|
||||
if (potentialVideo) {
|
||||
isVideoElement = true;
|
||||
foundVideos = true;
|
||||
elementToProcess = potentialVideo;
|
||||
} else {
|
||||
const potentialImage = element.querySelector("img");
|
||||
@@ -315,16 +336,15 @@ class MagnifyGlass {
|
||||
}
|
||||
if (elementToProcess && (isVideoElement || isImageElement)) {
|
||||
const widgetRect = elementToProcess.getBoundingClientRect();
|
||||
const canvasRect = this.litegraphCanvas.getBoundingClientRect();
|
||||
const dpr2 = canvasRect.width > 0 ? this.litegraphCanvas.width / canvasRect.width : 1;
|
||||
const canvasRect = rect;
|
||||
const currentScale = this.state.canvasScale;
|
||||
const isVirtualZoomMode = currentScale < 0.7;
|
||||
const widgetCssX = widgetRect.left - canvasRect.left;
|
||||
const widgetCssY = widgetRect.top - canvasRect.top;
|
||||
const widgetCssWidth = widgetRect.width;
|
||||
const widgetCssHeight = widgetRect.height;
|
||||
const pivotCssX = this.state.x / dpr2;
|
||||
const pivotCssY = this.state.y / dpr2;
|
||||
const pivotCssX = this.state.x / dpr;
|
||||
const pivotCssY = this.state.y / dpr;
|
||||
let finalWidgetCssX;
|
||||
let finalWidgetCssY;
|
||||
let finalWidgetCssWidth;
|
||||
@@ -340,10 +360,10 @@ class MagnifyGlass {
|
||||
finalWidgetCssWidth = widgetCssWidth;
|
||||
finalWidgetCssHeight = widgetCssHeight;
|
||||
}
|
||||
const widgetCanvasX = finalWidgetCssX * dpr2;
|
||||
const widgetCanvasY = finalWidgetCssY * dpr2;
|
||||
const widgetCanvasWidth = finalWidgetCssWidth * dpr2;
|
||||
const widgetCanvasHeight = finalWidgetCssHeight * dpr2;
|
||||
const widgetCanvasX = finalWidgetCssX * dpr;
|
||||
const widgetCanvasY = finalWidgetCssY * dpr;
|
||||
const widgetCanvasWidth = finalWidgetCssWidth * dpr;
|
||||
const widgetCanvasHeight = finalWidgetCssHeight * dpr;
|
||||
const widgetSourceRect = {
|
||||
x: widgetCanvasX,
|
||||
y: widgetCanvasY,
|
||||
@@ -392,6 +412,56 @@ class MagnifyGlass {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (foundVideos && !this.hasVisibleVideos) {
|
||||
this.hasVisibleVideos = true;
|
||||
this.startAnimationLoop();
|
||||
} else if (!foundVideos && this.hasVisibleVideos) {
|
||||
this.hasVisibleVideos = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Start the animation loop for continuous video rendering.
|
||||
* Only runs when videos are visible in the magnified view.
|
||||
* This loop directly renders without going through updateMagnifiedView's
|
||||
* scheduling logic to ensure continuous frame updates for video playback.
|
||||
*/
|
||||
startAnimationLoop() {
|
||||
if (this.animationLoopId !== null) return;
|
||||
const animate = () => {
|
||||
if (!this.state.active || !this.renderer || !this.litegraphCanvas) {
|
||||
this.stopAnimationLoop();
|
||||
return;
|
||||
}
|
||||
if (this.state.isPreviewHidden) {
|
||||
this.animationLoopId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
this.updateCanvasTransformation();
|
||||
this.calculateSourceRegion();
|
||||
let sourceCanvas = this.litegraphCanvas;
|
||||
if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) {
|
||||
const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas);
|
||||
if (highResCanvas) {
|
||||
sourceCanvas = highResCanvas;
|
||||
}
|
||||
}
|
||||
this.renderer.render(sourceCanvas);
|
||||
this.renderHtmlOverlays();
|
||||
if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) {
|
||||
this.popOutManager.sendFrame(this.ui.glassCanvas);
|
||||
}
|
||||
this.animationLoopId = requestAnimationFrame(animate);
|
||||
};
|
||||
this.animationLoopId = requestAnimationFrame(animate);
|
||||
}
|
||||
/**
|
||||
* Stop the animation loop.
|
||||
*/
|
||||
stopAnimationLoop() {
|
||||
if (this.animationLoopId !== null) {
|
||||
cancelAnimationFrame(this.animationLoopId);
|
||||
this.animationLoopId = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Update config from current settings values.
|
||||
@@ -457,6 +527,7 @@ class MagnifyGlass {
|
||||
* Cleanup all resources.
|
||||
*/
|
||||
cleanup() {
|
||||
this.stopAnimationLoop();
|
||||
this.eventHandler.detachListeners();
|
||||
this.popOutManager.cleanup();
|
||||
this.ui.cleanup();
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user