diff --git a/js/components/Card.js b/js/components/Card.js index 1c29407f..8ffacead 100644 --- a/js/components/Card.js +++ b/js/components/Card.js @@ -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); }; diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js index 1a3be0e0..c112bdc8 100644 --- a/js/features/panel/AssetsManagerPanel.js +++ b/js/features/panel/AssetsManagerPanel.js @@ -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?.(); diff --git a/js/features/viewer/FloatingViewer.js b/js/features/viewer/FloatingViewer.js index 572fdcbb..2421081c 100644 --- a/js/features/viewer/FloatingViewer.js +++ b/js/features/viewer/FloatingViewer.js @@ -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 ` - -
- - - -