feat(viewer): expand floating viewer to full-screen pop-out with advanced behaviors
- Implement Document Picture-in-Picture for always-on-top secondary monitor support - Enhance pop-out functionality with fallback to window.open() for compatibility - Refine pop-in/out transitions and user interface interactions - Optimize media loading and error handling for various scenarios - Improve keyboard shortcut integration and behavior - Update CSS animations and z-index hierarchy for better visual presentation - Enhance logging for request handling in the backend
This commit is contained in:
+93
-31
@@ -92,6 +92,7 @@ const getVideoThumbManager = () => {
|
||||
const playing = new Set();
|
||||
const prepared = new Set();
|
||||
const observed = new Set();
|
||||
const pausedForHide = new Set(); // videos paused on tab-hide, resumed when tab returns
|
||||
let cleanupTimer = null;
|
||||
let removeVisibilityListener = null;
|
||||
let removeSettingsListener = null;
|
||||
@@ -113,15 +114,18 @@ const getVideoThumbManager = () => {
|
||||
} catch (e) { console.debug?.(e); }
|
||||
try {
|
||||
while (prepared.size > VIDEO_THUMB_MAX_PREPARED) {
|
||||
const oldest = prepared.values().next().value;
|
||||
prepared.delete(oldest);
|
||||
// First entry in Set = least recently used (re-insertion keeps LRU order).
|
||||
const lru = prepared.values().next().value;
|
||||
prepared.delete(lru);
|
||||
try {
|
||||
oldest?.pause?.();
|
||||
lru?.pause?.();
|
||||
// Clear stale flags so the evicted element doesn't auto-resume.
|
||||
if (lru) { lru._mjrVisible = false; lru._mjrHovered = false; }
|
||||
} catch (e) { console.debug?.(e); }
|
||||
try {
|
||||
if (oldest?.getAttribute?.("src")) {
|
||||
oldest.removeAttribute("src");
|
||||
oldest.load?.();
|
||||
if (lru?.getAttribute?.("src")) {
|
||||
lru.removeAttribute("src");
|
||||
lru.load?.();
|
||||
}
|
||||
} catch (e) { console.debug?.(e); }
|
||||
}
|
||||
@@ -199,19 +203,21 @@ const getVideoThumbManager = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Prefer a later frame to avoid black/fade-in thumbnails when frame 0 is empty.
|
||||
video.currentTime = VIDEO_THUMB_FIRST_FRAME_SEEK_S;
|
||||
// For clips longer than VIDEO_THUMB_FIRST_FRAME_SEEK_S, seek to that position
|
||||
// to skip black/fade-in frames. For shorter clips, seek to 10 % of the
|
||||
// duration (min 0.05 s) so we still avoid frame-0 black frames.
|
||||
const dur = Number.isFinite(video.duration) && video.duration > 0
|
||||
? video.duration : null;
|
||||
const preferred = (dur !== null && dur <= VIDEO_THUMB_FIRST_FRAME_SEEK_S)
|
||||
? Math.max(0.05, dur * 0.1)
|
||||
: VIDEO_THUMB_FIRST_FRAME_SEEK_S;
|
||||
video.currentTime = dur !== null
|
||||
? Math.min(preferred, dur - 0.01)
|
||||
: preferred;
|
||||
} catch {
|
||||
clearTimeout(fallbackTimer);
|
||||
return resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
// Fallback for very short clips where 1.0s may be out of range.
|
||||
if (!Number.isFinite(video.currentTime) || video.currentTime <= 0) {
|
||||
video.currentTime = 0.05;
|
||||
}
|
||||
} catch (e) { console.debug?.(e); }
|
||||
});
|
||||
|
||||
const stopVideo = (video, { releaseSrc = true } = {}) => {
|
||||
@@ -245,8 +251,11 @@ const getVideoThumbManager = () => {
|
||||
const src = video.dataset.src || "";
|
||||
if (!src) return false;
|
||||
|
||||
if (!prepared.has(video)) {
|
||||
prepared.add(video);
|
||||
const isNew = !prepared.has(video);
|
||||
// Re-insert to refresh LRU position (most recently used = last in Set).
|
||||
prepared.delete(video);
|
||||
prepared.add(video);
|
||||
if (isNew) {
|
||||
pruneSets();
|
||||
try {
|
||||
if (!video.getAttribute("src")) {
|
||||
@@ -267,24 +276,26 @@ const getVideoThumbManager = () => {
|
||||
const src = video.dataset.src || "";
|
||||
if (!src) return;
|
||||
|
||||
if (!playing.has(video)) {
|
||||
playing.add(video);
|
||||
} else {
|
||||
playing.delete(video);
|
||||
playing.add(video);
|
||||
}
|
||||
// Refresh insertion order so playing Set acts as LRU.
|
||||
playing.delete(video);
|
||||
playing.add(video);
|
||||
|
||||
await ensurePrepared(video);
|
||||
|
||||
const overlay = (() => { try { return video._mjrPlayOverlay || null; } catch { return null; } })();
|
||||
try {
|
||||
const p = video.play();
|
||||
if (p && typeof p.then === "function") {
|
||||
p.catch(async () => {
|
||||
await ensurePrepared(video);
|
||||
// play() rejected (autoplay policy, missing codec…) — no pause event fires,
|
||||
// so restore the overlay manually.
|
||||
try { if (overlay) overlay.style.opacity = "1"; } catch (e) { console.debug?.(e); }
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
await ensurePrepared(video);
|
||||
try { if (overlay) overlay.style.opacity = "1"; } catch (e) { console.debug?.(e); }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -299,14 +310,27 @@ const getVideoThumbManager = () => {
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (!document.hidden) return;
|
||||
for (const v of Array.from(playing)) {
|
||||
playing.delete(v);
|
||||
stopVideo(v);
|
||||
try {
|
||||
const overlay = v._mjrPlayOverlay || null;
|
||||
if (overlay) overlay.style.opacity = "1";
|
||||
} catch (e) { console.debug?.(e); }
|
||||
if (document.hidden) {
|
||||
// Pause all playing videos; remember them so we can resume on return.
|
||||
for (const v of Array.from(playing)) {
|
||||
pausedForHide.add(v);
|
||||
playing.delete(v);
|
||||
try { v.pause(); } catch (e) { console.debug?.(e); }
|
||||
try {
|
||||
const overlay = v._mjrPlayOverlay || null;
|
||||
if (overlay) overlay.style.opacity = "1";
|
||||
} catch (e) { console.debug?.(e); }
|
||||
}
|
||||
} else {
|
||||
// Tab became visible — resume videos that were paused due to hide.
|
||||
const mode = getVideoAutoplayMode();
|
||||
for (const v of Array.from(pausedForHide)) {
|
||||
pausedForHide.delete(v);
|
||||
if (!v.isConnected || !observed.has(v) || !v._mjrVisible) continue;
|
||||
const shouldResume = mode === "always" || (mode === "hover" && !!v._mjrHovered);
|
||||
if (shouldResume) playVideo(v);
|
||||
}
|
||||
pausedForHide.clear();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -434,6 +458,30 @@ const getVideoThumbManager = () => {
|
||||
video._mjrThumbBound = true;
|
||||
} catch (e) { console.debug?.(e); }
|
||||
|
||||
// Authoritative overlay sync: driven by native playback events.
|
||||
// This resolves any race between async play() and subsequent stop/pause calls.
|
||||
try {
|
||||
video.addEventListener("playing", () => {
|
||||
try {
|
||||
const ol = video._mjrPlayOverlay;
|
||||
if (ol) ol.style.opacity = "0";
|
||||
} catch (e) { console.debug?.(e); }
|
||||
});
|
||||
video.addEventListener("pause", () => {
|
||||
try {
|
||||
const ol = video._mjrPlayOverlay;
|
||||
if (ol) ol.style.opacity = "1";
|
||||
} catch (e) { console.debug?.(e); }
|
||||
});
|
||||
video.addEventListener("ended", () => {
|
||||
// loop=true means this rarely fires, but guard it anyway.
|
||||
try {
|
||||
const ol = video._mjrPlayOverlay;
|
||||
if (ol) ol.style.opacity = "1";
|
||||
} catch (e) { console.debug?.(e); }
|
||||
});
|
||||
} catch (e) { console.debug?.(e); }
|
||||
|
||||
try {
|
||||
video.addEventListener("error", () => {
|
||||
playing.delete(video);
|
||||
@@ -447,12 +495,20 @@ const getVideoThumbManager = () => {
|
||||
},
|
||||
observe(video) {
|
||||
if (!observer || !video) return;
|
||||
let hadTimer = false;
|
||||
try {
|
||||
hadTimer = !!video._mjrReleaseTimer;
|
||||
if (video._mjrReleaseTimer) {
|
||||
clearTimeout(video._mjrReleaseTimer);
|
||||
video._mjrReleaseTimer = null;
|
||||
}
|
||||
} catch (e) { console.debug?.(e); }
|
||||
// No pending release timer → virtual grid recycled this DOM node for a new asset.
|
||||
// Clear stale state so the previous card's playback intent doesn't leak to the new asset.
|
||||
if (!hadTimer) {
|
||||
try { video._mjrResumeOnVisible = false; } catch (e) { console.debug?.(e); }
|
||||
try { video._mjrHovered = false; } catch (e) { console.debug?.(e); }
|
||||
}
|
||||
try {
|
||||
api._bindVideo(video);
|
||||
observed.add(video);
|
||||
@@ -565,6 +621,7 @@ const getVideoThumbManager = () => {
|
||||
try {
|
||||
removeSettingsListener?.();
|
||||
} catch (e) { console.debug?.(e); }
|
||||
try { pausedForHide.clear(); } catch (e) { console.debug?.(e); }
|
||||
// Disconnect the IntersectionObserver so it stops firing after dispose.
|
||||
// observed.clear() alone does not stop the observer from holding
|
||||
// references to previously-observed elements.
|
||||
@@ -852,6 +909,11 @@ function createThumbnail(asset, viewUrl) {
|
||||
|
||||
video.onerror = () => {
|
||||
video.style.display = "none";
|
||||
// Hide the play overlay so it doesn't float over the error placeholder.
|
||||
try {
|
||||
const ol = video._mjrPlayOverlay;
|
||||
if (ol) ol.style.display = "none";
|
||||
} catch (e) { console.debug?.(e); }
|
||||
const err = createMediaErrorPlaceholder("pi pi-video");
|
||||
thumb.appendChild(err);
|
||||
};
|
||||
|
||||
@@ -447,7 +447,18 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
// 2. Grid visuals (badges, details, sizes)
|
||||
// Refresh only when a relevant setting changed to avoid reload storms.
|
||||
if (shouldRefreshGrid) {
|
||||
refreshGrid(gridContainer);
|
||||
// Siblings toggle requires a full data reload: PNG siblings are filtered
|
||||
// during appendAssets (state.assets), so refreshGrid alone can't add them
|
||||
// back or remove them — a re-fetch is needed.
|
||||
if (!isInitialSync && changedKey.startsWith("siblings.")) {
|
||||
try {
|
||||
gridContainer.dispatchEvent(new CustomEvent("mjr:reload-grid"));
|
||||
} catch (e) {
|
||||
refreshGrid(gridContainer);
|
||||
}
|
||||
} else {
|
||||
refreshGrid(gridContainer);
|
||||
}
|
||||
}
|
||||
if (shouldRefreshSidebar && state.sidebarOpen && state.activeAssetId) {
|
||||
sidebarController?.refreshActiveAsset?.();
|
||||
|
||||
@@ -222,14 +222,14 @@ export class FloatingViewer {
|
||||
// stale async metadata enrichment results can be discarded (NM-2).
|
||||
this._refreshGen = 0;
|
||||
|
||||
// Pop-out state: the external window reference and pop-out button.
|
||||
// Pop-out state: external window reference and button.
|
||||
this._popoutWindow = null;
|
||||
this._popoutBtn = null;
|
||||
this._isPopped = false;
|
||||
this._popoutCloseHandler = null;
|
||||
this._popoutCloseHandler = null;
|
||||
this._popoutKeydownHandler = null;
|
||||
this._popoutCloseTimer = null;
|
||||
this._popoutRestoreGuard = false;
|
||||
this._popoutCloseTimer = null;
|
||||
this._popoutRestoreGuard = false;
|
||||
|
||||
// Preview stream state: button ref + last blob URL for cleanup.
|
||||
this._previewBtn = null;
|
||||
@@ -377,7 +377,7 @@ export class FloatingViewer {
|
||||
this._popoutBtn = document.createElement("button");
|
||||
this._popoutBtn.type = "button";
|
||||
this._popoutBtn.className = "mjr-icon-btn";
|
||||
const popoutLabel = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
|
||||
const popoutLabel = t("tooltip.popOutViewer", "Expand to full screen (Esc or button to return)");
|
||||
this._popoutBtn.title = popoutLabel;
|
||||
this._popoutBtn.setAttribute("aria-label", popoutLabel);
|
||||
this._popoutBtn.setAttribute("aria-pressed", "false");
|
||||
@@ -788,12 +788,26 @@ export class FloatingViewer {
|
||||
this._previewBlobUrl = url;
|
||||
// Build a minimal fileData that _resolveUrl can handle
|
||||
const fileData = { url, filename: "preview.jpg", kind: "image", _isPreview: true };
|
||||
this._mediaA = fileData;
|
||||
this._resetMfvZoom();
|
||||
// Force simple mode for preview — don't trigger metadata enrichment
|
||||
if (this._mode !== MFV_MODES.SIMPLE) {
|
||||
this._mode = MFV_MODES.SIMPLE;
|
||||
this._updateModeBtnUI();
|
||||
|
||||
const inCompare = this._mode === MFV_MODES.AB || this._mode === MFV_MODES.SIDE;
|
||||
if (inCompare) {
|
||||
// In compare mode: route the preview steps to the non-pinned slot so the
|
||||
// user's reference image stays intact. Default: preview → B, selection → A.
|
||||
if (this.getPinnedSlot() === "B") {
|
||||
this._mediaA = fileData; // B pinned as reference — stream to A
|
||||
} else {
|
||||
this._mediaB = fileData; // A pinned (or no pin) — stream to B
|
||||
}
|
||||
// Do NOT reset zoom (preview frames arrive rapidly; resetting each time
|
||||
// would make the reference slot unusable) and do NOT switch mode.
|
||||
} else {
|
||||
// Simple mode: preview takes slot A, keep it simple.
|
||||
this._mediaA = fileData;
|
||||
this._resetMfvZoom();
|
||||
if (this._mode !== MFV_MODES.SIMPLE) {
|
||||
this._mode = MFV_MODES.SIMPLE;
|
||||
this._updateModeBtnUI();
|
||||
}
|
||||
}
|
||||
++this._refreshGen;
|
||||
this._refresh();
|
||||
@@ -1207,7 +1221,7 @@ export class FloatingViewer {
|
||||
this.isVisible = false;
|
||||
}
|
||||
|
||||
// ── Pop-out / Pop-in (external window) ──────────────────────────────────
|
||||
// ── Pop-out / Pop-in (separate OS window for second monitor) ────────────
|
||||
|
||||
/**
|
||||
* Move the viewer into a separate browser window so it can be
|
||||
@@ -1219,11 +1233,86 @@ export class FloatingViewer {
|
||||
const el = this.element;
|
||||
this._stopEdgeResize();
|
||||
|
||||
// Remember size for the popup
|
||||
const w = Math.max(el.offsetWidth || 520, 400);
|
||||
const h = Math.max(el.offsetHeight || 420, 300);
|
||||
|
||||
// Open a blank popup centred on the current screen
|
||||
// 1. Document Picture-in-Picture (Chrome 116+) ─────────────────────────
|
||||
// Creates a same-origin always-on-top window that shares the full JS
|
||||
// context of the main page. Bypasses the about:blank isolation that
|
||||
// breaks window.open() in Chrome App mode and Electron-based hosts.
|
||||
if ("documentPictureInPicture" in window) {
|
||||
window.documentPictureInPicture.requestWindow({ width: w, height: h })
|
||||
.then((pipWindow) => {
|
||||
this._popoutWindow = pipWindow;
|
||||
this._isPopped = true;
|
||||
this._popoutRestoreGuard = false;
|
||||
try { this._popoutAC?.abort(); } catch (e) { console.debug?.(e); }
|
||||
this._popoutAC = new AbortController();
|
||||
const popoutSignal = this._popoutAC.signal;
|
||||
|
||||
const handlePopupClosing = () => this._schedulePopInFromPopupClose();
|
||||
this._popoutCloseHandler = handlePopupClosing;
|
||||
|
||||
const doc = pipWindow.document;
|
||||
doc.title = "Majoor Viewer";
|
||||
this._installPopoutStyles(doc);
|
||||
|
||||
// Build a minimal shell directly — no server round-trip needed.
|
||||
doc.body.style.cssText = "margin:0;display:flex;min-height:100vh;background:#111;overflow:hidden;";
|
||||
const root = doc.createElement("div");
|
||||
root.id = "mjr-mfv-popout-root";
|
||||
root.style.cssText = "flex:1;min-width:0;min-height:0;display:flex;";
|
||||
doc.body.appendChild(root);
|
||||
|
||||
try {
|
||||
root.appendChild(doc.adoptNode(el));
|
||||
} catch (e) {
|
||||
console.warn("[MFV] PiP adoptNode failed, falling back to window.open", e);
|
||||
this._isPopped = false;
|
||||
this._popoutWindow = null;
|
||||
try { pipWindow.close(); } catch (_) { /* noop */ }
|
||||
this._fallbackPopout(el, w, h);
|
||||
return;
|
||||
}
|
||||
el.classList.add("is-visible");
|
||||
this.isVisible = true;
|
||||
|
||||
this._resetGenDropdownForCurrentDocument();
|
||||
this._rebindControlHandlers();
|
||||
this._bindDocumentUiHandlers();
|
||||
this._updatePopoutBtnUI();
|
||||
|
||||
pipWindow.addEventListener("pagehide", handlePopupClosing, { signal: popoutSignal });
|
||||
this._startPopoutCloseWatch();
|
||||
|
||||
this._popoutKeydownHandler = (e) => {
|
||||
const tag = String(e?.target?.tagName || "").toLowerCase();
|
||||
if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
const lower = String(e?.key || "").toLowerCase();
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: e.key, code: e.code, keyCode: e.keyCode,
|
||||
ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
|
||||
}));
|
||||
};
|
||||
pipWindow.addEventListener("keydown", this._popoutKeydownHandler, { signal: popoutSignal });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[MFV] Document PiP failed, falling back to window.open", err);
|
||||
this._fallbackPopout(el, w, h);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fallback: classic window.open() to the backend-served shell page.
|
||||
this._fallbackPopout(el, w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classic popup fallback used when Document PiP is unavailable.
|
||||
* Opens /mjr/viewer/popout (served by the backend) and mounts the viewer
|
||||
* once the shell page finishes loading.
|
||||
*/
|
||||
_fallbackPopout(el, w, h) {
|
||||
const left = (window.screenX || window.screenLeft) + Math.round((window.outerWidth - w) / 2);
|
||||
const top = (window.screenY || window.screenTop) + Math.round((window.outerHeight - h) / 2);
|
||||
const features = `width=${w},height=${h},left=${left},top=${top},resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no`;
|
||||
@@ -1235,8 +1324,6 @@ export class FloatingViewer {
|
||||
this._popoutWindow = popup;
|
||||
this._isPopped = true;
|
||||
this._popoutRestoreGuard = false;
|
||||
// Create an AbortController for all popup-window listeners so they can
|
||||
// be removed atomically in popIn() without manual per-event cleanup.
|
||||
try { this._popoutAC?.abort(); } catch (e) { console.debug?.(e); }
|
||||
this._popoutAC = new AbortController();
|
||||
const popoutSignal = this._popoutAC.signal;
|
||||
@@ -1246,7 +1333,8 @@ export class FloatingViewer {
|
||||
|
||||
const mountViewer = () => {
|
||||
if (mounted) return;
|
||||
const doc = popup.document;
|
||||
let doc;
|
||||
try { doc = popup.document; } catch { return; } // cross-origin during navigation
|
||||
if (!doc) return;
|
||||
if (!this._ensurePopoutShell(doc)) return;
|
||||
doc.title = "Majoor Viewer";
|
||||
@@ -1254,7 +1342,12 @@ export class FloatingViewer {
|
||||
const root = doc.getElementById("mjr-mfv-popout-root") || doc.body;
|
||||
if (!root) return;
|
||||
try { root.replaceChildren(); } catch (e) { console.debug?.(e); }
|
||||
root.appendChild(doc.adoptNode(el));
|
||||
try {
|
||||
root.appendChild(doc.adoptNode(el));
|
||||
} catch (e) {
|
||||
console.warn("[MFV] adoptNode failed", e);
|
||||
return;
|
||||
}
|
||||
el.classList.add("is-visible");
|
||||
this.isVisible = true;
|
||||
mounted = true;
|
||||
@@ -1264,27 +1357,48 @@ export class FloatingViewer {
|
||||
this._updatePopoutBtnUI();
|
||||
};
|
||||
|
||||
// Do NOT use { once: true } — Chrome fires load for the initial blank
|
||||
// document state, which consumes the listener before the real page load.
|
||||
try {
|
||||
popup.addEventListener("load", mountViewer, { once: true, signal: popoutSignal });
|
||||
popup.addEventListener("load", mountViewer, { signal: popoutSignal });
|
||||
} catch (e) {
|
||||
console.debug?.("[MFV] pop-out page mount failed", e);
|
||||
}
|
||||
if (popup.document?.readyState === "interactive" || popup.document?.readyState === "complete") {
|
||||
setTimeout(mountViewer, 0);
|
||||
console.debug?.("[MFV] pop-out page load listener failed", e);
|
||||
}
|
||||
|
||||
// When the user closes the popup window, automatically pop the viewer back in.
|
||||
popup.addEventListener("beforeunload", handlePopupClosing, { signal: popoutSignal });
|
||||
popup.addEventListener("pagehide", handlePopupClosing, { signal: popoutSignal });
|
||||
popup.addEventListener("unload", handlePopupClosing, { signal: popoutSignal });
|
||||
this._startPopoutCloseWatch();
|
||||
// Fire immediately if the popup already shows the shell (rare but possible).
|
||||
try {
|
||||
const doc = popup.document;
|
||||
if (doc?.readyState === "complete" && doc.getElementById("mjr-mfv-popout-root")) {
|
||||
setTimeout(mountViewer, 0);
|
||||
}
|
||||
} catch { /* cross-origin during navigation — load event will handle it */ }
|
||||
|
||||
// Relay keyboard shortcuts from popup back to main window
|
||||
this._popoutKeydownHandler = (e) => {
|
||||
const tag = String(e?.target?.tagName || "").toLowerCase();
|
||||
if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") {
|
||||
// Polling safety net: Chrome App mode may suppress popup load events.
|
||||
// Skip ticks where popup.document throws (normal during navigation).
|
||||
let _mountPollCount = 0;
|
||||
const _mountPollId = window.setInterval(() => {
|
||||
if (mounted || ++_mountPollCount > 50 || !this._isPopped || popup.closed) {
|
||||
window.clearInterval(_mountPollId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const doc = popup.document;
|
||||
if (doc?.readyState === "complete" && doc.getElementById("mjr-mfv-popout-root")) {
|
||||
window.clearInterval(_mountPollId);
|
||||
mountViewer();
|
||||
}
|
||||
} catch { /* skip this tick */ }
|
||||
}, 100);
|
||||
popoutSignal.addEventListener("abort", () => window.clearInterval(_mountPollId), { once: true });
|
||||
|
||||
popup.addEventListener("beforeunload", handlePopupClosing, { signal: popoutSignal });
|
||||
popup.addEventListener("pagehide", handlePopupClosing, { signal: popoutSignal });
|
||||
popup.addEventListener("unload", handlePopupClosing, { signal: popoutSignal });
|
||||
this._startPopoutCloseWatch();
|
||||
|
||||
this._popoutKeydownHandler = (e) => {
|
||||
const tag = String(e?.target?.tagName || "").toLowerCase();
|
||||
if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
const lower = String(e?.key || "").toLowerCase();
|
||||
if (lower === "v" && (e?.ctrlKey || e?.metaKey) && !e?.altKey && !e?.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -1342,69 +1456,18 @@ export class FloatingViewer {
|
||||
}
|
||||
}
|
||||
|
||||
_buildPopoutShellHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#1e1e1e">
|
||||
<title>Majoor Viewer</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
color: #ddd;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#mjr-mfv-popout-root {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(95, 179, 255, 0.12), transparent 35%),
|
||||
linear-gradient(180deg, #1a1a1a 0%, #101010 100%);
|
||||
}
|
||||
.mjr-mfv-popout-loading {
|
||||
margin: auto;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(20,20,20,0.82);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.84;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mjr-mfv-popout-root">
|
||||
<div class="mjr-mfv-popout-loading">Preparing viewer...</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the popup window's document contains the shell element served by
|
||||
* the backend at /mjr/viewer/popout. We no longer fall back to doc.write()
|
||||
* because that is blocked in Chrome App mode and is unnecessary now that the
|
||||
* backend serves a real HTML page.
|
||||
*/
|
||||
_ensurePopoutShell(doc) {
|
||||
if (!doc) return false;
|
||||
try {
|
||||
if (doc.getElementById("mjr-mfv-popout-root")) return true;
|
||||
doc.open();
|
||||
doc.write(this._buildPopoutShellHtml());
|
||||
doc.close();
|
||||
return !!doc.getElementById("mjr-mfv-popout-root");
|
||||
} catch (e) {
|
||||
console.debug?.("[MFV] pop-out shell init failed", e);
|
||||
console.debug?.("[MFV] pop-out shell check failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1446,20 +1509,26 @@ export class FloatingViewer {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
/* Override animated hidden state so it shows immediately in popup */
|
||||
display: flex !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
pointer-events: auto !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(overrideStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the viewer back into the main ComfyUI page from the popup window.
|
||||
* Move the viewer back from the popup window into the main ComfyUI page.
|
||||
*/
|
||||
popIn({ closePopupWindow = true } = {}) {
|
||||
if (!this._isPopped || !this.element) return;
|
||||
const popup = this._popoutWindow;
|
||||
this._clearPopoutCloseWatch();
|
||||
// Abort all popup-window listeners atomically instead of manual per-event removal.
|
||||
// Abort all popup-window listeners atomically instead of manual removeEventListener.
|
||||
try { this._popoutAC?.abort(); } catch (e) { console.debug?.(e); }
|
||||
this._popoutAC = null;
|
||||
this._popoutCloseHandler = null;
|
||||
@@ -1482,8 +1551,6 @@ export class FloatingViewer {
|
||||
adopted.setAttribute("aria-hidden", "false");
|
||||
this.isVisible = true;
|
||||
|
||||
// Restore inline position styles (they were cleared by the popup override)
|
||||
// The viewer will revert to its normal fixed-position CSS.
|
||||
this._updatePopoutBtnUI();
|
||||
|
||||
// Close the popup window if it's still open
|
||||
@@ -1492,7 +1559,6 @@ export class FloatingViewer {
|
||||
}
|
||||
this._popoutWindow = null;
|
||||
}
|
||||
|
||||
/** Toggle pop-out button icon between external-link (pop out) and sign-in (pop in). */
|
||||
_updatePopoutBtnUI() {
|
||||
if (!this._popoutBtn) return;
|
||||
@@ -1502,8 +1568,8 @@ export class FloatingViewer {
|
||||
this._popoutBtn.classList.toggle("mjr-popin-active", this._isPopped);
|
||||
const icon = this._popoutBtn.querySelector("i") || document.createElement("i");
|
||||
const label = this._isPopped
|
||||
? t("tooltip.popInViewer", "Return viewer to ComfyUI window")
|
||||
: t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)");
|
||||
? t("tooltip.popInViewer", "Return to floating panel")
|
||||
: t("tooltip.popOutViewer", "Expand to full screen (Esc or button to return)");
|
||||
if (this._isPopped) {
|
||||
icon.className = "pi pi-sign-in";
|
||||
} else {
|
||||
@@ -1993,7 +2059,7 @@ export class FloatingViewer {
|
||||
// Abort document-level and pop-out window listeners atomically.
|
||||
try { this._docAC?.abort(); this._docAC = null; } catch (e) { console.debug?.(e); }
|
||||
try { this._popoutAC?.abort(); this._popoutAC = null; } catch (e) { console.debug?.(e); }
|
||||
// Pop-in before disposing so the element returns to the main document
|
||||
// Pop-in before disposing so the element returns to the main document.
|
||||
try { if (this._isPopped) this.popIn(); } catch (e) { console.debug?.(e); }
|
||||
this._revokePreviewBlob();
|
||||
try { this.element?.remove(); } catch (e) { console.debug?.(e); }
|
||||
|
||||
@@ -263,7 +263,22 @@ export const floatingViewerManager = {
|
||||
inst.show();
|
||||
_syncViewerControls(inst);
|
||||
_bindSelectionListener();
|
||||
inst.loadMediaA(fileData, { autoMode: true });
|
||||
|
||||
const mode = inst._mode;
|
||||
const inCompare = mode === MFV_MODES.AB || mode === MFV_MODES.SIDE;
|
||||
if (inCompare) {
|
||||
// In compare mode: route the live stream to the non-pinned slot so the
|
||||
// user's selection or pin stays intact. Default: stream → B, selection → A.
|
||||
const pin = inst.getPinnedSlot();
|
||||
if (pin === "B") {
|
||||
inst.loadMediaPair(fileData, inst._mediaB); // B pinned — stream to A
|
||||
} else {
|
||||
inst.loadMediaPair(inst._mediaA, fileData); // A pinned (or no pin) — stream to B
|
||||
}
|
||||
} else {
|
||||
inst.loadMediaA(fileData, { autoMode: true });
|
||||
}
|
||||
|
||||
if (!wasVisible) _emitVisibilityChanged(true);
|
||||
},
|
||||
|
||||
@@ -277,9 +292,8 @@ export const floatingViewerManager = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the viewer between popped-out (separate window) and inline.
|
||||
* When popping out, the viewer is first opened (if not visible) so the
|
||||
* user always sees content in the new window.
|
||||
* Toggle the viewer between the expanded dialog overlay and the floating panel.
|
||||
* If the viewer isn't open yet, it is opened first so there's something to see.
|
||||
*/
|
||||
popOut() {
|
||||
const inst = _getInstance();
|
||||
@@ -376,10 +390,6 @@ const _onGlobalKeydown = (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lower === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey && !event?.shiftKey) {
|
||||
consume();
|
||||
floatingViewerManager.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
function _installGlobalHandlers() {
|
||||
@@ -416,7 +426,7 @@ export function teardownFloatingViewerManager() {
|
||||
|
||||
_installGlobalHandlers();
|
||||
|
||||
// Close the pop-out window when the main ComfyUI page unloads
|
||||
// Close the pop-out window when the main ComfyUI page unloads.
|
||||
window.addEventListener("beforeunload", () => {
|
||||
try { if (_instance?.isPopped) _instance.popIn(); } catch (e) { /* noop */ }
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clamp01 } from "./state.js";
|
||||
import { computeProcessorScale } from "./processorUtils.js";
|
||||
|
||||
export function drawMediaError(canvas, message) {
|
||||
export function drawMediaError(canvas, message, hint) {
|
||||
if (!canvas) return;
|
||||
try {
|
||||
if (!(canvas.width > 1 && canvas.height > 1)) {
|
||||
@@ -31,7 +31,8 @@ export function drawMediaError(canvas, message) {
|
||||
const text = message ? String(message) : "Failed to load media";
|
||||
ctx.fillText(text, 14, 14);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.7)";
|
||||
ctx.fillText("Check file permissions / path, or try re-indexing.", 14, 34);
|
||||
const hintText = hint != null ? String(hint) : "Check file permissions / path, or try re-indexing.";
|
||||
ctx.fillText(hintText, 14, 34);
|
||||
} catch (e) { console.debug?.(e); }
|
||||
try {
|
||||
ctx.restore();
|
||||
|
||||
@@ -481,7 +481,25 @@ export function createVideoProcessor({
|
||||
const onError = () => {
|
||||
proc.ready = false;
|
||||
try {
|
||||
drawMediaError(canvas, "Failed to load video (unsupported codec/format?)");
|
||||
const errCode = videoEl?.error?.code;
|
||||
const errMsg = videoEl?.error?.message || "";
|
||||
// MediaError codes: 1=aborted, 2=network, 3=decode, 4=src_not_supported
|
||||
let msg, hint;
|
||||
if (errCode === 2) {
|
||||
msg = "Failed to load video (network / path error)";
|
||||
hint = "Check file permissions / path, or try re-indexing.";
|
||||
} else if (errCode === 3) {
|
||||
msg = "Failed to load video (decode error — unsupported codec?)";
|
||||
hint = "Browser may not support this codec (e.g. H.265/HEVC). Try converting to H.264/MP4.";
|
||||
} else if (errCode === 4) {
|
||||
msg = "Failed to load video (unsupported format or codec)";
|
||||
hint = "Browser cannot decode this file (e.g. H.265/HEVC). Try converting to H.264/MP4.";
|
||||
} else {
|
||||
msg = "Failed to load video";
|
||||
hint = errMsg || "Check file permissions / path, or try re-indexing.";
|
||||
}
|
||||
console.warn("[MJR] Video load error", { code: errCode, message: errMsg, src: videoEl?.src });
|
||||
drawMediaError(canvas, msg, hint);
|
||||
} catch (e) { console.debug?.(e); }
|
||||
};
|
||||
|
||||
|
||||
+30
-11
@@ -1711,19 +1711,19 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 10000;
|
||||
/* Sit above MFV (10000) so the full viewer always covers the floating panel */
|
||||
z-index: 10001;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
animation: mjr-fade-in 0.2s ease-out;
|
||||
}
|
||||
/* Fade-in: animation re-triggers each time JS switches display back to flex */
|
||||
.mjr-viewer-overlay[style*="flex"] {
|
||||
animation: mjr-fade-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes mjr-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
from { opacity: 0; transform: scale(0.995); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.mjr-viewer-header {
|
||||
@@ -2603,7 +2603,8 @@
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
min-width: 200px;
|
||||
z-index: 9999;
|
||||
/* Above viewer overlay (10001) and MFV dialog (10000) */
|
||||
z-index: 10002;
|
||||
display: none;
|
||||
padding: 4px 0;
|
||||
animation: mjr-context-menu-in 0.15s ease-out;
|
||||
@@ -3037,11 +3038,22 @@
|
||||
height: 380px;
|
||||
min-width: 220px;
|
||||
min-height: 160px;
|
||||
display: none; /* shown via .mjr-mfv.is-visible or JS show() */
|
||||
/* Always flex; visibility + opacity control show/hide so CSS transitions work.
|
||||
pointer-events:none while hidden prevents accidental interaction. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
z-index: 10000;
|
||||
/* Hidden state */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
transform-origin: top right;
|
||||
transition: opacity 0.18s ease-out,
|
||||
transform 0.18s ease-out,
|
||||
visibility 0s linear 0.18s;
|
||||
}
|
||||
|
||||
.mjr-mfv.mjr-mfv--resizing {
|
||||
@@ -3049,9 +3061,16 @@
|
||||
}
|
||||
|
||||
.mjr-mfv.is-visible {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0) scale(1);
|
||||
transition: opacity 0.18s ease-out,
|
||||
transform 0.18s ease-out,
|
||||
visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
|
||||
/* Media elements (img/video) sizing variants */
|
||||
.mjr-mfv-media {
|
||||
display: block;
|
||||
|
||||
@@ -348,12 +348,17 @@ def _request_log_key(request: web.Request, status: int | None) -> str:
|
||||
|
||||
|
||||
def _emit_request_log_by_status(status: int | None, fields: dict[str, Any]) -> None:
|
||||
method = fields.get("method") or "?"
|
||||
path = fields.get("path") or "?"
|
||||
dur = fields.get("duration_ms")
|
||||
dur_part = f" ({dur:.0f}ms)" if isinstance(dur, (int, float)) else ""
|
||||
msg = f"Request handled | {method} {path} → {status}{dur_part}"
|
||||
if status is not None and status >= 500:
|
||||
logger.error("Request handled", extra=fields)
|
||||
logger.error(msg, extra=fields)
|
||||
elif status is not None and status >= 400:
|
||||
logger.warning("Request handled", extra=fields)
|
||||
logger.warning(msg, extra=fields)
|
||||
else:
|
||||
logger.info("Request handled", extra=fields)
|
||||
logger.info(msg, extra=fields)
|
||||
|
||||
|
||||
def ensure_observability(app: web.Application) -> None:
|
||||
|
||||
Reference in New Issue
Block a user