From 13a69c1237cb5e4baaf4b84852f4042b488bb685 Mon Sep 17 00:00:00 2001
From: Ewald <129623145+MajoorWaldi@users.noreply.github.com>
Date: Fri, 10 Apr 2026 15:18:53 +0200
Subject: [PATCH] fix: wire video autoplay mode setting to grid cards
- Import APP_CONFIG in AssetCardInner.vue and read GRID_VIDEO_AUTOPLAY_MODE
- Add reactive videoMode ref updated via mjr-settings-changed listener
- Add bindVideoAutoplay() with IntersectionObserver for 'always' mode
- Add cleanupVideoBehaviors()/applyVideoMode() to switch between off/hover/always
- watch(videoMode) re-applies behavior on runtime setting change
- Fix rating reset bug: mark_recent_generated before ExifTool write (sync.py/watcher.py)
- Add scope-switch cache, exit-similar-scope, and rating/tags ID tests
---
js/features/grid/StackGroupCards.js | 4 +
.../panel/controllers/gridController.js | 36 +-
js/features/panel/panelRuntime.js | 76 +-
js/features/panel/panelSimilarSearch.js | 25 -
js/stores/usePanelStore.js | 3 +
...d_controller_exit_similar_scope.vitest.mjs | 142 +
js/tests/grid_scope_switch_cache.vitest.mjs | 254 +
js/tests/rating_tags_id_save.vitest.mjs | 109 +
js/tests/scope_controller.vitest.mjs | 6 +
js/tests/stack_group_cards.vitest.mjs | 53 +
js/theme/theme-comfy.css | 41 +
js/vue/components/common/RatingEditor.vue | 3 +-
js/vue/components/common/TagsEditor.vue | 3 +-
js/vue/components/grid/AssetCardInner.vue | 157 +-
.../components/grid/VirtualAssetGridHost.vue | 94 +-
js/vue/composables/useGridLoader.js | 11 +-
.../assets/comfyui-majoor-assetsmanager.css | 2 +-
...Bzy37aKj.js => FloatingViewer-eFdyLyMp.js} | 2 +-
...OZGMZ.js => LiveStreamTracker-DbQnXqIi.js} | 2 +-
.../{entry-DDLQfahJ.js => entry-BXyIAWFG.js} | 31985 ++++++++--------
...BNlx3aIz.js => mjr-vue-vendor-C2uEs-2e.js} | 27 +-
js_dist/entry.js | 2 +-
mjr_am_backend/features/index/watcher.py | 2 +
mjr_am_backend/features/tags/sync.py | 5 +
24 files changed, 17006 insertions(+), 16038 deletions(-)
create mode 100644 js/tests/grid_controller_exit_similar_scope.vitest.mjs
create mode 100644 js/tests/grid_scope_switch_cache.vitest.mjs
create mode 100644 js/tests/rating_tags_id_save.vitest.mjs
rename js_dist/chunks/{FloatingViewer-Bzy37aKj.js => FloatingViewer-eFdyLyMp.js} (99%)
rename js_dist/chunks/{LiveStreamTracker-Bi0OZGMZ.js => LiveStreamTracker-DbQnXqIi.js} (99%)
rename js_dist/chunks/{entry-DDLQfahJ.js => entry-BXyIAWFG.js} (87%)
rename js_dist/chunks/{mjr-vue-vendor-BNlx3aIz.js => mjr-vue-vendor-C2uEs-2e.js} (99%)
diff --git a/js/features/grid/StackGroupCards.js b/js/features/grid/StackGroupCards.js
index 6ae778b7..95fc9dbd 100644
--- a/js/features/grid/StackGroupCards.js
+++ b/js/features/grid/StackGroupCards.js
@@ -78,6 +78,8 @@ export function ensureStackGroupCard(gridContainer, card, asset) {
}
return;
}
+ // Vue cards handle their own stack buttons reactively via AssetCardInner.vue
+ if (card._mjrIsVue) return;
const groupKey = _getGroupKey(asset);
if (!groupKey) {
card.querySelector?.(".mjr-stack-group-button")?.remove?.();
@@ -179,6 +181,8 @@ export function disposeStackGroupCards(gridContainer) {
*/
export function ensureDupStackCard(gridContainer, card, asset) {
if (!card || !asset) return;
+ // Vue cards handle their own dup-stack buttons reactively via AssetCardInner.vue
+ if (card._mjrIsVue) return;
const isDupStack = !!asset._mjrDupStack;
const count = Number(asset._mjrDupCount || 0) || 0;
diff --git a/js/features/panel/controllers/gridController.js b/js/features/panel/controllers/gridController.js
index 67eae57e..21038ac8 100644
--- a/js/features/panel/controllers/gridController.js
+++ b/js/features/panel/controllers/gridController.js
@@ -46,6 +46,7 @@ export function createGridController({
]);
let _isReloading = false;
let _pendingReload = false;
+ let _nextReloadOptions = {};
let _lastReloadErrorAt = 0;
let _lastAiHintAt = 0;
const RELOAD_WATCHDOG_MS = 30000;
@@ -209,7 +210,7 @@ export function createGridController({
*
* Explicit activation only: semantic toggle or "ai:" prefix.
*/
- const _loadWithSemanticFallback = async (query) => {
+ const _loadWithSemanticFallback = async (query, loadOptions = {}) => {
let q = String(query || "").trim();
// ── "ai:" prefix forces semantic search ────────────────────
@@ -221,7 +222,7 @@ export function createGridController({
const aiEnabled = _isAiEnabled();
const semanticMode = aiEnabled && _isSemanticMode();
if (!aiEnabled) {
- return await loadAssets(gridContainer, q || "*");
+ return await loadAssets(gridContainer, q || "*", loadOptions);
}
const semanticRequested = semanticMode || hasAiPrefix;
let aiAttempted = false;
@@ -247,6 +248,7 @@ export function createGridController({
return await loadAssetsFromList(gridContainer, hybRes.data, {
title: `AI Search: "${q}" (${hybRes.data.length} results)`,
reset: true,
+ ...loadOptions,
});
}
if (hybRes?.ok === false) {
@@ -268,6 +270,7 @@ export function createGridController({
return await loadAssetsFromList(gridContainer, vecRes.data, {
title: `AI Search: "${q}" (${vecRes.data.length} results)`,
reset: true,
+ ...loadOptions,
});
}
if (vecRes?.ok === false) {
@@ -279,7 +282,7 @@ export function createGridController({
console.debug?.("[Majoor] Semantic search failed, falling back to FTS", err);
}
}
- const ftsResult = await loadAssets(gridContainer, q || "*");
+ const ftsResult = await loadAssets(gridContainer, q || "*", loadOptions);
if (aiAttempted && aiError) {
const count = Number(ftsResult?.count || 0) || 0;
@@ -291,7 +294,7 @@ export function createGridController({
return ftsResult;
};
- const runReloadOnce = async () => {
+ const runReloadOnce = async (reloadOptions = {}) => {
// Expose the current query on the container so external listeners (ComfyUI executed events)
// can decide whether to do incremental upserts or avoid disrupting an active search.
try {
@@ -299,7 +302,14 @@ export function createGridController({
} catch (e) {
console.debug?.(e);
}
+ const prevViewScope = String(gridContainer.dataset.mjrViewScope || "")
+ .trim()
+ .toLowerCase();
+ const nextViewScope = String(read("viewScope", state?.viewScope || "") || "")
+ .trim()
+ .toLowerCase();
gridContainer.dataset.mjrScope = read("scope", "output");
+ gridContainer.dataset.mjrViewScope = nextViewScope;
gridContainer.dataset.mjrCustomRootId = read("customRootId", "") || "";
const subfolder = read("currentFolderRelativePath", "") || "";
gridContainer.dataset.mjrSubfolder = subfolder;
@@ -317,6 +327,7 @@ export function createGridController({
gridContainer.dataset.mjrFilterDateRange = read("dateRangeFilter", "") || "";
gridContainer.dataset.mjrFilterDateExact = read("dateExactFilter", "") || "";
gridContainer.dataset.mjrSort = read("sort", "mtime_desc") || "mtime_desc";
+ gridContainer.dataset.mjrCollectionId = read("collectionId", "") || "";
gridContainer.dataset.mjrGroupStacks =
APP_CONFIG.EXECUTION_GROUPING_ENABLED &&
(read("scope", "output") === "output" || read("scope", "output") === "all")
@@ -382,7 +393,7 @@ export function createGridController({
write("collectionName", "");
}
- const viewScope = String(read("viewScope", state?.viewScope || "") || "").toLowerCase();
+ const viewScope = nextViewScope;
if (viewScope === "similar") {
const bridgeList = Array.isArray(read("similarResults", [])) ? read("similarResults", []) : [];
const stateList = Array.isArray(state?.similarResults) ? state.similarResults : [];
@@ -400,6 +411,7 @@ export function createGridController({
const result = await loadAssetsFromList(gridContainer, list, {
title: title || "Similar",
reset: true,
+ ...reloadOptions,
});
try {
write("lastGridCount", Number(result?.count || 0) || 0);
@@ -413,7 +425,12 @@ export function createGridController({
return;
}
- const result = await _loadWithSemanticFallback(getQuery());
+ const exitingVirtualSimilarScope = prevViewScope === "similar" && viewScope !== "similar";
+ const result = await _loadWithSemanticFallback(getQuery(), {
+ ...reloadOptions,
+ preserveVisibleUntilReady:
+ reloadOptions.preserveVisibleUntilReady ?? !exitingVirtualSimilarScope,
+ });
// Track search query timing if timer was started
try {
@@ -447,13 +464,15 @@ export function createGridController({
_isReloading = true;
try {
while (_pendingReload) {
+ const reloadOptions = { ...(_nextReloadOptions || {}) };
+ _nextReloadOptions = {};
_pendingReload = false;
try {
const useAiTimeout =
_isAiEnabled() &&
(_isSemanticMode() || String(getQuery?.() || "").length >= 12);
await runWithWatchdog(
- () => runReloadOnce(),
+ () => runReloadOnce(reloadOptions),
useAiTimeout ? AI_RELOAD_WATCHDOG_MS : RELOAD_WATCHDOG_MS,
);
} catch (err) {
@@ -477,8 +496,9 @@ export function createGridController({
// Input debounce: batches rapid reloadGrid() calls (e.g., scope switch +
// watcher event arriving simultaneously) into a single _doReload execution.
// The inner coalescing loop still handles calls that arrive mid-reload.
- const reloadGrid = () => {
+ const reloadGrid = (options = {}) => {
return new Promise((resolve, reject) => {
+ _nextReloadOptions = { ...(_nextReloadOptions || {}), ...(options || {}) };
if (_debounceTimer) clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(() => {
_debounceTimer = null;
diff --git a/js/features/panel/panelRuntime.js b/js/features/panel/panelRuntime.js
index aa81b98c..f6df4b0d 100644
--- a/js/features/panel/panelRuntime.js
+++ b/js/features/panel/panelRuntime.js
@@ -245,7 +245,9 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter
gridWrapper.appendChild(gridContainer);
}
setActiveGridContainer(gridContainer);
- try { gridContainer.dataset.mjrScope = state.scope; } catch (e) { console.debug?.(e); }
+ try {
+ gridContainer.dataset.mjrScope = String(readPanelValue("scope", state.scope || "output") || "output");
+ } catch (e) { console.debug?.(e); }
const summaryBarDisposers = [];
const registerSummaryDispose = (fn) => {
@@ -385,8 +387,8 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter
// ── 13. DUPLICATE ALERTS ─────────────────────────────────────────────
const refreshDuplicateAlerts = async () => {
- const scope = String(state.scope || "output").toLowerCase();
- const customRootId = String(state.customRootId || "").trim();
+ const scope = String(readPanelValue("scope", state.scope || "output") || "output").toLowerCase();
+ const customRootId = String(readPanelValue("customRootId", state.customRootId || "") || "").trim();
if (scope === "custom" && !customRootId) {
_duplicatesAlert = null;
notifyContextChanged();
@@ -510,20 +512,40 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter
reconcileSelection: reconcileVisibleSelection,
onChanged: () => {
try {
- if (gridContainer) gridContainer.dataset.mjrScope = state.scope;
+ if (gridContainer) {
+ gridContainer.dataset.mjrScope = String(
+ readPanelValue("scope", state.scope || "output") || "output",
+ );
+ }
} catch (e) { console.debug?.(e); }
notifyContextChanged();
},
onScopeChanged: async () => {
- try { await applyWatcherForScope(state.scope); } catch (e) { console.debug?.(e); }
+ try {
+ await applyWatcherForScope(
+ String(readPanelValue("scope", state.scope || "output") || "output"),
+ );
+ } catch (e) { console.debug?.(e); }
try { await refreshDuplicateAlerts(); } catch (e) { console.debug?.(e); }
},
onBeforeReload: async () => {
+ // Sync key dataset fields *before* prepareGridForScopeSwitch so that
+ // snapshot-cache hydration uses the correct (new-scope) key.
+ try {
+ gridContainer.dataset.mjrScope = readPanelValue("scope", "output");
+ gridContainer.dataset.mjrCustomRootId = readPanelValue("customRootId", "") || "";
+ gridContainer.dataset.mjrSubfolder = readPanelValue("currentFolderRelativePath", "") || "";
+ gridContainer.dataset.mjrCollectionId = readPanelValue("collectionId", "") || "";
+ gridContainer.dataset.mjrViewScope = readPanelValue("viewScope", "") || "";
+ } catch (e) { console.debug?.(e); }
try { prepareGridForScopeSwitchFn(gridContainer); } catch (e) { console.debug?.(e); }
try {
await updateStatus(
statusDot, statusText, capabilitiesSection,
- { scope: state.scope, customRootId: state.customRootId },
+ {
+ scope: readPanelValue("scope", state.scope || "output"),
+ customRootId: readPanelValue("customRootId", state.customRootId || ""),
+ },
null,
{ signal: panelLifecycleAC?.signal || null },
);
@@ -533,7 +555,7 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter
const exitSimilarViewIfActive = async ({ reload = false } = {}) => {
try {
- if (String(state.viewScope || "").trim().toLowerCase() !== "similar") return;
+ if (String(readPanelValue("viewScope", state.viewScope || "") || "").trim().toLowerCase() !== "similar") return;
await scopeController?.clearSimilarScope?.({ reload });
} catch (e) { console.debug?.(e); }
};
@@ -990,34 +1012,34 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter
didHydrateFromSnapshot = await hydrateGridFromSnapshotFn(
gridContainer,
{
- scope: state.scope || "output",
+ scope: readPanelValue("scope", state.scope || "output") || "output",
query: String(
normalizeQuery(
panelStateBridge.read("searchQuery", state.searchQuery || "*") || "*",
) || "*",
),
- customRootId: state.customRootId || "",
- subfolder: state.currentFolderRelativePath || "",
- collectionId: state.collectionId || "",
- viewScope: state.viewScope || "",
- kind: state.kindFilter || "",
- workflowOnly: !!state.workflowOnly,
- minRating: state.minRating || "",
- minSizeMB: state.minSizeMB || "",
- maxSizeMB: state.maxSizeMB || "",
- resolutionCompare: state.resolutionCompare || "",
- minWidth: state.minWidth || "",
- minHeight: state.minHeight || "",
- maxWidth: state.maxWidth || "",
- maxHeight: state.maxHeight || "",
- workflowType: state.workflowType || "",
- dateRange: state.dateRangeFilter || "",
- dateExact: state.dateExactFilter || "",
- sort: state.sort || "mtime_desc",
+ customRootId: readPanelValue("customRootId", state.customRootId || "") || "",
+ subfolder: readPanelValue("currentFolderRelativePath", state.currentFolderRelativePath || "") || "",
+ collectionId: readPanelValue("collectionId", state.collectionId || "") || "",
+ viewScope: readPanelValue("viewScope", state.viewScope || "") || "",
+ kind: readPanelValue("kindFilter", state.kindFilter || "") || "",
+ workflowOnly: !!readPanelValue("workflowOnly", state.workflowOnly),
+ minRating: readPanelValue("minRating", state.minRating || "") || "",
+ minSizeMB: readPanelValue("minSizeMB", state.minSizeMB || "") || "",
+ maxSizeMB: readPanelValue("maxSizeMB", state.maxSizeMB || "") || "",
+ resolutionCompare: readPanelValue("resolutionCompare", state.resolutionCompare || "") || "",
+ minWidth: readPanelValue("minWidth", state.minWidth || "") || "",
+ minHeight: readPanelValue("minHeight", state.minHeight || "") || "",
+ maxWidth: readPanelValue("maxWidth", state.maxWidth || "") || "",
+ maxHeight: readPanelValue("maxHeight", state.maxHeight || "") || "",
+ workflowType: readPanelValue("workflowType", state.workflowType || "") || "",
+ dateRange: readPanelValue("dateRangeFilter", state.dateRangeFilter || "") || "",
+ dateExact: readPanelValue("dateExactFilter", state.dateExactFilter || "") || "",
+ sort: readPanelValue("sort", state.sort || "mtime_desc") || "mtime_desc",
},
{
title:
- state.collectionName ||
+ readPanelValue("collectionName", state.collectionName || "") ||
panelStateBridge.read("searchQuery", state.searchQuery || "") ||
"Cached",
},
diff --git a/js/features/panel/panelSimilarSearch.js b/js/features/panel/panelSimilarSearch.js
index 93109b2b..38ae5547 100644
--- a/js/features/panel/panelSimilarSearch.js
+++ b/js/features/panel/panelSimilarSearch.js
@@ -1,6 +1,5 @@
import { EVENTS } from "../../app/events.js";
import { vectorFindSimilar } from "../../api/client.js";
-import { loadAssetsFromList } from "../grid/gridApi.js";
import { comfyToast } from "../../app/toast.js";
import { t } from "../../app/i18n.js";
@@ -27,18 +26,6 @@ export function bindSimilarSearch({
scopeController,
closePopovers,
}) {
- // ── Helper: load a list into the grid and update stats ─────────────────
-
- const _loadSimilarList = async (list, title) => {
- const loaded = await loadAssetsFromList(gridContainer, list, { title, reset: true });
- const count = Number((loaded?.count ?? list.length) || 0) || 0;
- writePanelValue("lastGridCount", count);
- writePanelValue("lastGridTotal", count);
- gridContainer.dispatchEvent?.(
- new CustomEvent("mjr:grid-stats", { detail: { count, total: count } }),
- );
- };
-
// ── Similar button click ───────────────────────────────────────────────
similarBtn?.addEventListener(
@@ -103,12 +90,6 @@ export function bindSimilarSearch({
}),
);
await scopeController?.setScope?.("similar");
- try {
- const title = String(readPanelValue("similarTitle", "") || "Similar").trim();
- await _loadSimilarList(list, title);
- } catch (err) {
- console.debug?.(err);
- }
} catch (err) {
console.debug?.(err);
comfyToast(
@@ -140,12 +121,6 @@ export function bindSimilarSearch({
`Generation group (${list.length} assets)`,
);
await scopeController?.setScope?.("similar");
- try {
- const title = String(readPanelValue("similarTitle", "") || "Similar").trim();
- await _loadSimilarList(list, title);
- } catch (err) {
- console.debug?.(err);
- }
} catch (err) {
console.debug?.(err);
}
diff --git a/js/stores/usePanelStore.js b/js/stores/usePanelStore.js
index 29a9445e..35c26f1f 100644
--- a/js/stores/usePanelStore.js
+++ b/js/stores/usePanelStore.js
@@ -274,6 +274,9 @@ export const usePanelStore = defineStore("mjr-panel", () => {
customRootId.value = opts.customRootId || "";
customRootLabel.value = opts.customRootLabel || "";
viewScope.value = "";
+ similarResults.value = [];
+ similarTitle.value = "";
+ similarSourceAssetId.value = "";
clearSelection();
}
diff --git a/js/tests/grid_controller_exit_similar_scope.vitest.mjs b/js/tests/grid_controller_exit_similar_scope.vitest.mjs
new file mode 100644
index 00000000..593782e0
--- /dev/null
+++ b/js/tests/grid_controller_exit_similar_scope.vitest.mjs
@@ -0,0 +1,142 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const bridgeState = vi.hoisted(() => ({
+ values: {
+ scope: "output",
+ customRootId: "",
+ currentFolderRelativePath: "",
+ kindFilter: "",
+ workflowOnly: false,
+ minRating: 0,
+ minSizeMB: 0,
+ maxSizeMB: 0,
+ resolutionCompare: "gte",
+ minWidth: 0,
+ minHeight: 0,
+ maxWidth: 0,
+ maxHeight: 0,
+ workflowType: "",
+ dateRangeFilter: "",
+ dateExactFilter: "",
+ sort: "mtime_desc",
+ activeAssetId: "",
+ selectedAssetIds: [],
+ collectionId: "",
+ collectionName: "",
+ viewScope: "",
+ similarResults: [],
+ similarTitle: "",
+ similarSourceAssetId: "",
+ lastGridCount: 0,
+ lastGridTotal: 0,
+ },
+}));
+
+vi.mock("../stores/panelStateBridge.js", () => ({
+ createPanelStateBridge: () => ({
+ read: (key, fallback = "") =>
+ Object.prototype.hasOwnProperty.call(bridgeState.values, key)
+ ? bridgeState.values[key]
+ : fallback,
+ write: vi.fn((key, value) => {
+ bridgeState.values[key] = value;
+ }),
+ }),
+}));
+
+vi.mock("../api/client.js", () => ({
+ vectorSearch: vi.fn(),
+ hybridSearch: vi.fn(),
+ vectorStats: vi.fn(),
+}));
+
+vi.mock("../app/toast.js", () => ({
+ comfyToast: vi.fn(),
+}));
+
+vi.mock("../app/i18n.js", () => ({
+ t: (_key, fallback) => fallback,
+}));
+
+vi.mock("../app/settings.js", () => ({
+ loadMajoorSettings: () => ({ ai: { vectorSearchEnabled: true } }),
+}));
+
+function createGridContainer() {
+ return {
+ dataset: { mjrViewScope: "similar" },
+ dispatchEvent: vi.fn(),
+ };
+}
+
+describe("gridController exiting similar scope", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ bridgeState.values = {
+ scope: "output",
+ customRootId: "",
+ currentFolderRelativePath: "",
+ kindFilter: "",
+ workflowOnly: false,
+ minRating: 0,
+ minSizeMB: 0,
+ maxSizeMB: 0,
+ resolutionCompare: "gte",
+ minWidth: 0,
+ minHeight: 0,
+ maxWidth: 0,
+ maxHeight: 0,
+ workflowType: "",
+ dateRangeFilter: "",
+ dateExactFilter: "",
+ sort: "mtime_desc",
+ activeAssetId: "",
+ selectedAssetIds: [],
+ collectionId: "",
+ collectionName: "",
+ viewScope: "",
+ similarResults: [],
+ similarTitle: "",
+ similarSourceAssetId: "",
+ lastGridCount: 0,
+ lastGridTotal: 0,
+ };
+ globalThis.window = {};
+ globalThis.CustomEvent = class {
+ constructor(type, init = {}) {
+ this.type = type;
+ this.detail = init.detail;
+ }
+ };
+ });
+
+ it("does not preserve stale similar cards when switching back to normal scope", async () => {
+ const { createGridController } = await import("../features/panel/controllers/gridController.js");
+
+ const loadAssets = vi.fn(async () => ({ ok: true, count: 175, total: 175 }));
+ const loadAssetsFromList = vi.fn(async () => ({ ok: true, count: 2, total: 2 }));
+
+ const controller = createGridController({
+ gridContainer: createGridContainer(),
+ loadAssets,
+ loadAssetsFromList,
+ getCollectionAssets: vi.fn(),
+ disposeGrid: vi.fn(),
+ getQuery: () => "*",
+ searchInputEl: { dataset: { mjrSemanticMode: "0" } },
+ state: {
+ scope: "output",
+ viewScope: "",
+ },
+ });
+
+ await controller.reloadGrid();
+
+ expect(loadAssets).toHaveBeenCalledWith(
+ expect.any(Object),
+ "*",
+ expect.objectContaining({ preserveVisibleUntilReady: false }),
+ );
+ expect(loadAssetsFromList).not.toHaveBeenCalled();
+ });
+});
diff --git a/js/tests/grid_scope_switch_cache.vitest.mjs b/js/tests/grid_scope_switch_cache.vitest.mjs
new file mode 100644
index 00000000..ec1f9d43
--- /dev/null
+++ b/js/tests/grid_scope_switch_cache.vitest.mjs
@@ -0,0 +1,254 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const bridgeState = vi.hoisted(() => ({
+ values: {
+ scope: "output",
+ customRootId: "",
+ currentFolderRelativePath: "",
+ kindFilter: "",
+ workflowOnly: false,
+ minRating: 0,
+ minSizeMB: 0,
+ maxSizeMB: 0,
+ resolutionCompare: "gte",
+ minWidth: 0,
+ minHeight: 0,
+ maxWidth: 0,
+ maxHeight: 0,
+ workflowType: "",
+ dateRangeFilter: "",
+ dateExactFilter: "",
+ sort: "mtime_desc",
+ activeAssetId: "",
+ selectedAssetIds: [],
+ collectionId: "",
+ collectionName: "",
+ viewScope: "",
+ similarResults: [],
+ similarTitle: "",
+ similarSourceAssetId: "",
+ lastGridCount: 0,
+ lastGridTotal: 0,
+ },
+}));
+
+vi.mock("../stores/panelStateBridge.js", () => ({
+ createPanelStateBridge: () => ({
+ read: (key, fallback = "") =>
+ Object.prototype.hasOwnProperty.call(bridgeState.values, key)
+ ? bridgeState.values[key]
+ : fallback,
+ write: vi.fn((key, value) => {
+ bridgeState.values[key] = value;
+ }),
+ }),
+}));
+
+vi.mock("../api/client.js", () => ({
+ vectorSearch: vi.fn(),
+ hybridSearch: vi.fn(),
+ vectorStats: vi.fn(),
+}));
+
+vi.mock("../app/toast.js", () => ({
+ comfyToast: vi.fn(),
+}));
+
+vi.mock("../app/i18n.js", () => ({
+ t: (_key, fallback) => fallback,
+}));
+
+vi.mock("../app/settings.js", () => ({
+ loadMajoorSettings: () => ({ ai: { vectorSearchEnabled: true } }),
+}));
+
+vi.mock("../app/config.js", () => ({
+ APP_CONFIG: { EXECUTION_GROUPING_ENABLED: true },
+}));
+
+function createGridContainer() {
+ return {
+ dataset: { mjrViewScope: "", mjrScope: "output" },
+ dispatchEvent: vi.fn(),
+ };
+}
+
+describe("gridController scope switch cache behavior", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Object.assign(bridgeState.values, {
+ scope: "output",
+ customRootId: "",
+ currentFolderRelativePath: "",
+ kindFilter: "",
+ workflowOnly: false,
+ minRating: 0,
+ minSizeMB: 0,
+ maxSizeMB: 0,
+ resolutionCompare: "gte",
+ minWidth: 0,
+ minHeight: 0,
+ maxWidth: 0,
+ maxHeight: 0,
+ workflowType: "",
+ dateRangeFilter: "",
+ dateExactFilter: "",
+ sort: "mtime_desc",
+ activeAssetId: "",
+ selectedAssetIds: [],
+ collectionId: "",
+ collectionName: "",
+ viewScope: "",
+ similarResults: [],
+ similarTitle: "",
+ similarSourceAssetId: "",
+ lastGridCount: 0,
+ lastGridTotal: 0,
+ });
+ globalThis.window = {};
+ globalThis.CustomEvent = class {
+ constructor(type, init = {}) {
+ this.type = type;
+ this.detail = init.detail;
+ }
+ };
+ });
+
+ it("writes mjrCollectionId to the dataset during reload", async () => {
+ const { createGridController } = await import(
+ "../features/panel/controllers/gridController.js"
+ );
+
+ bridgeState.values.collectionId = "col-123";
+ bridgeState.values.collectionName = "Test Collection";
+
+ const gridContainer = createGridContainer();
+ const loadAssets = vi.fn(async () => ({ ok: true, count: 5, total: 5 }));
+ const getCollectionAssets = vi.fn(async () => ({
+ ok: true,
+ data: { assets: [{ id: 1 }, { id: 2 }] },
+ }));
+ const loadAssetsFromList = vi.fn(async () => ({ ok: true, count: 2, total: 2 }));
+
+ const controller = createGridController({
+ gridContainer,
+ loadAssets,
+ loadAssetsFromList,
+ getCollectionAssets,
+ disposeGrid: vi.fn(),
+ getQuery: () => "*",
+ searchInputEl: { dataset: { mjrSemanticMode: "0" } },
+ state: {},
+ });
+
+ await controller.reloadGrid();
+
+ expect(gridContainer.dataset.mjrCollectionId).toBe("col-123");
+ });
+
+ it("loads collection assets when collectionId is set", async () => {
+ const { createGridController } = await import(
+ "../features/panel/controllers/gridController.js"
+ );
+
+ bridgeState.values.collectionId = "col-456";
+ bridgeState.values.collectionName = "My Collection";
+
+ const gridContainer = createGridContainer();
+ const loadAssets = vi.fn(async () => ({ ok: true, count: 5, total: 5 }));
+ const collectionAssets = [
+ { id: 10, filename: "a.png" },
+ { id: 11, filename: "b.png" },
+ ];
+ const getCollectionAssets = vi.fn(async () => ({
+ ok: true,
+ data: { assets: collectionAssets },
+ }));
+ const loadAssetsFromList = vi.fn(async () => ({ ok: true, count: 2, total: 2 }));
+
+ const controller = createGridController({
+ gridContainer,
+ loadAssets,
+ loadAssetsFromList,
+ getCollectionAssets,
+ disposeGrid: vi.fn(),
+ getQuery: () => "*",
+ searchInputEl: { dataset: { mjrSemanticMode: "0" } },
+ state: {},
+ });
+
+ await controller.reloadGrid();
+
+ expect(getCollectionAssets).toHaveBeenCalledWith("col-456");
+ expect(loadAssetsFromList).toHaveBeenCalledWith(
+ gridContainer,
+ collectionAssets,
+ expect.objectContaining({ title: "Collection: My Collection" }),
+ );
+ // Normal loadAssets should NOT be called when collection is active
+ expect(loadAssets).not.toHaveBeenCalled();
+ });
+
+ it("falls back to normal load when collection fetch fails", async () => {
+ const { createGridController } = await import(
+ "../features/panel/controllers/gridController.js"
+ );
+
+ bridgeState.values.collectionId = "col-bad";
+
+ const gridContainer = createGridContainer();
+ const loadAssets = vi.fn(async () => ({ ok: true, count: 50, total: 50 }));
+ const getCollectionAssets = vi.fn(async () => ({ ok: false, error: "Not found" }));
+ const loadAssetsFromList = vi.fn();
+
+ const controller = createGridController({
+ gridContainer,
+ loadAssets,
+ loadAssetsFromList,
+ getCollectionAssets,
+ disposeGrid: vi.fn(),
+ getQuery: () => "*",
+ searchInputEl: { dataset: { mjrSemanticMode: "0" } },
+ state: {},
+ });
+
+ await controller.reloadGrid();
+
+ // Should fall back to normal loadAssets
+ expect(loadAssets).toHaveBeenCalled();
+ // collectionId should be cleared
+ expect(bridgeState.values.collectionId).toBe("");
+ });
+
+ it("switches from output to input scope and reloads normally", async () => {
+ const { createGridController } = await import(
+ "../features/panel/controllers/gridController.js"
+ );
+
+ const gridContainer = createGridContainer();
+ const loadAssets = vi.fn(async () => ({ ok: true, count: 100, total: 100 }));
+
+ const controller = createGridController({
+ gridContainer,
+ loadAssets,
+ loadAssetsFromList: vi.fn(async () => ({ ok: true })),
+ getCollectionAssets: vi.fn(),
+ disposeGrid: vi.fn(),
+ getQuery: () => "*",
+ searchInputEl: { dataset: { mjrSemanticMode: "0" } },
+ state: {},
+ });
+
+ // First load in output scope
+ await controller.reloadGrid();
+ expect(gridContainer.dataset.mjrScope).toBe("output");
+
+ // Switch to input scope
+ bridgeState.values.scope = "input";
+ loadAssets.mockClear();
+ await controller.reloadGrid();
+
+ expect(gridContainer.dataset.mjrScope).toBe("input");
+ expect(loadAssets).toHaveBeenCalled();
+ });
+});
diff --git a/js/tests/rating_tags_id_save.vitest.mjs b/js/tests/rating_tags_id_save.vitest.mjs
new file mode 100644
index 00000000..cca76e42
--- /dev/null
+++ b/js/tests/rating_tags_id_save.vitest.mjs
@@ -0,0 +1,109 @@
+/**
+ * rating_tags_id_save.vitest.mjs
+ *
+ * Tests that after a successful rating/tag save the returned DB asset_id
+ * is stored on the asset object — even when the existing id is a synthetic
+ * string ("asset:…") produced by AssetCardRenderer for unindexed assets.
+ *
+ * Bug: the original condition `props.asset.id == null` never triggered for
+ * synthetic ids, so subsequent calls fell back to filepath-based resolution
+ * that could fail with "File not found".
+ */
+import { describe, expect, it, vi } from "vitest";
+import { normalizeAssetId } from "../utils/ids.js";
+
+// ---------------------------------------------------------------------------
+// normalizeAssetId rejects synthetic IDs
+// ---------------------------------------------------------------------------
+
+describe("normalizeAssetId rejects synthetic card IDs", () => {
+ it("returns empty for asset:… prefixed ids", () => {
+ expect(normalizeAssetId("asset:output||foo/bar.png||bar.png")).toBe("");
+ });
+
+ it("returns empty for non-integer string ids", () => {
+ expect(normalizeAssetId("abc-123")).toBe("");
+ expect(normalizeAssetId("asset:0")).toBe("");
+ expect(normalizeAssetId(null)).toBe("");
+ expect(normalizeAssetId(undefined)).toBe("");
+ expect(normalizeAssetId(0)).toBe("");
+ });
+
+ it("returns valid string for positive integers", () => {
+ expect(normalizeAssetId(42)).toBe("42");
+ expect(normalizeAssetId("42")).toBe("42");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// ID save-back logic (unit-level, no Vue mount)
+// ---------------------------------------------------------------------------
+
+/**
+ * Simulates the ID save-back logic used in both RatingEditor.vue and
+ * TagsEditor.vue after a successful API response.
+ */
+function applyIdSaveBack(asset, responseAssetId) {
+ const newId = responseAssetId ?? null;
+ // NEW condition matching the fix:
+ if (newId != null && !normalizeAssetId(asset.id)) {
+ asset.id = newId;
+ }
+}
+
+describe("ID save-back after successful rating/tag update", () => {
+ it("saves DB id when asset.id is null", () => {
+ const asset = { id: null, filepath: "/output/img.png" };
+ applyIdSaveBack(asset, 123);
+ expect(asset.id).toBe(123);
+ });
+
+ it("saves DB id when asset.id is undefined", () => {
+ const asset = { filepath: "/output/img.png" };
+ applyIdSaveBack(asset, 456);
+ expect(asset.id).toBe(456);
+ });
+
+ it("saves DB id when asset.id is a synthetic string", () => {
+ const asset = { id: "asset:output||foo/bar.png||bar.png", filepath: "/output/bar.png" };
+ applyIdSaveBack(asset, 789);
+ expect(asset.id).toBe(789);
+ });
+
+ it("saves DB id when asset.id is empty string", () => {
+ const asset = { id: "", filepath: "/output/img.png" };
+ applyIdSaveBack(asset, 100);
+ expect(asset.id).toBe(100);
+ });
+
+ it("saves DB id when asset.id is 0", () => {
+ const asset = { id: 0, filepath: "/output/img.png" };
+ applyIdSaveBack(asset, 55);
+ expect(asset.id).toBe(55);
+ });
+
+ it("does NOT overwrite a valid integer id", () => {
+ const asset = { id: 42, filepath: "/output/img.png" };
+ applyIdSaveBack(asset, 999);
+ // Already has a valid DB id — do not overwrite
+ expect(asset.id).toBe(42);
+ });
+
+ it("does NOT overwrite a valid integer-string id", () => {
+ const asset = { id: "42", filepath: "/output/img.png" };
+ applyIdSaveBack(asset, 999);
+ expect(asset.id).toBe("42");
+ });
+
+ it("does not save when response asset_id is null", () => {
+ const asset = { id: "asset:synth", filepath: "/x.png" };
+ applyIdSaveBack(asset, null);
+ expect(asset.id).toBe("asset:synth");
+ });
+
+ it("does not save when response asset_id is undefined", () => {
+ const asset = { id: "asset:synth", filepath: "/x.png" };
+ applyIdSaveBack(asset, undefined);
+ expect(asset.id).toBe("asset:synth");
+ });
+});
diff --git a/js/tests/scope_controller.vitest.mjs b/js/tests/scope_controller.vitest.mjs
index d8a5ff53..b1ae9316 100644
--- a/js/tests/scope_controller.vitest.mjs
+++ b/js/tests/scope_controller.vitest.mjs
@@ -87,6 +87,9 @@ describe("scopeController similar scope", () => {
await controller.clearSimilarScope({ reload: false });
expect(panelStore.viewScope).toBe("");
+ expect(panelStore.similarResults).toEqual([]);
+ expect(panelStore.similarTitle).toBe("");
+ expect(panelStore.similarSourceAssetId).toBe("");
expect(tabButtons.tabSimilar.style.display).toBe("none");
await controller.setScope("output");
@@ -94,6 +97,9 @@ describe("scopeController similar scope", () => {
expect(panelStore.collectionId).toBe("");
expect(panelStore.collectionName).toBe("");
expect(panelStore.currentFolderRelativePath).toBe("");
+ expect(panelStore.similarResults).toEqual([]);
+ expect(panelStore.similarTitle).toBe("");
+ expect(panelStore.similarSourceAssetId).toBe("");
expect(reconcileSelection).toHaveBeenCalledTimes(1);
});
});
diff --git a/js/tests/stack_group_cards.vitest.mjs b/js/tests/stack_group_cards.vitest.mjs
index af7f70b1..3e092368 100644
--- a/js/tests/stack_group_cards.vitest.mjs
+++ b/js/tests/stack_group_cards.vitest.mjs
@@ -80,4 +80,57 @@ describe("StackGroupCards overlay buttons", () => {
expect(detail.stackId).toBe("stack-1");
expect(detail.members.map((entry) => entry.id)).toEqual(["asset-2", "asset-1"]);
});
+
+ it("skips imperative button creation for Vue cards (_mjrIsVue guard)", async () => {
+ const { ensureStackGroupCard, ensureDupStackCard } = await import("../features/grid/StackGroupCards.js");
+
+ const grid = document.createElement("div");
+ grid.dataset.mjrGroupStacks = "1";
+ const card = document.createElement("div");
+ card.className = "mjr-asset-card";
+ card._mjrIsVue = true;
+ grid.appendChild(card);
+
+ const asset = {
+ id: "asset-1",
+ filename: "image.png",
+ stack_id: "stack-1",
+ stack_asset_count: 3,
+ _mjrDupStack: true,
+ _mjrDupCount: 2,
+ };
+
+ ensureStackGroupCard(grid, card, asset);
+ expect(card.querySelector(".mjr-stack-group-button")).toBeNull();
+
+ ensureDupStackCard(grid, card, asset);
+ expect(card.querySelector(".mjr-dup-stack-button")).toBeNull();
+ });
+
+ it("still creates buttons for non-Vue cards", async () => {
+ const { ensureStackGroupCard, ensureDupStackCard } = await import("../features/grid/StackGroupCards.js");
+
+ const grid = document.createElement("div");
+ grid.dataset.mjrGroupStacks = "1";
+ const card = document.createElement("div");
+ card.className = "mjr-asset-card";
+ // _mjrIsVue not set
+ grid.appendChild(card);
+
+ const asset = {
+ id: "asset-1",
+ filename: "image.png",
+ stack_id: "stack-1",
+ stack_asset_count: 3,
+ _mjrDupStack: true,
+ _mjrDupCount: 2,
+ _mjrDupMembers: [{ id: "a1" }, { id: "a2" }],
+ };
+
+ ensureStackGroupCard(grid, card, asset);
+ expect(card.querySelector(".mjr-stack-group-button")).toBeTruthy();
+
+ ensureDupStackCard(grid, card, asset);
+ expect(card.querySelector(".mjr-dup-stack-button")).toBeTruthy();
+ });
});
diff --git a/js/theme/theme-comfy.css b/js/theme/theme-comfy.css
index c928869a..c0316ad4 100644
--- a/js/theme/theme-comfy.css
+++ b/js/theme/theme-comfy.css
@@ -5319,6 +5319,47 @@
transform: none !important;
}
+/* ── Visual stacking effect — layered cards behind the main card ──────────── */
+/* Creates pseudo-element "cards" offset behind the primary card to visually */
+/* communicate that this card represents a group of multiple images. */
+.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"] {
+ position: relative;
+ z-index: 1;
+}
+
+.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::before,
+.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--comfy-menu-bg, #1a1a2e);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ pointer-events: none;
+ z-index: -1;
+ transition: transform 180ms ease, opacity 180ms ease;
+}
+
+.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::before {
+ transform: translate(3px, 3px) rotate(1deg);
+ opacity: 0.45;
+}
+
+.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]::after {
+ transform: translate(6px, 6px) rotate(2.2deg);
+ opacity: 0.25;
+}
+
+.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]:hover::before {
+ transform: translate(4px, 4px) rotate(1.5deg);
+ opacity: 0.55;
+}
+
+.mjr-assets-manager .mjr-asset-card[data-mjr-stacked="true"]:hover::after {
+ transform: translate(8px, 8px) rotate(3deg);
+ opacity: 0.35;
+}
+
.mjr-stack-group-popover {
width: min(440px, 92vw);
}
diff --git a/js/vue/components/common/RatingEditor.vue b/js/vue/components/common/RatingEditor.vue
index d5eb238b..c36b3edb 100644
--- a/js/vue/components/common/RatingEditor.vue
+++ b/js/vue/components/common/RatingEditor.vue
@@ -11,6 +11,7 @@ import { ASSET_RATING_CHANGED_EVENT } from "../../../app/events.js";
import { t } from "../../../app/i18n.js";
import { comfyToast } from "../../../app/toast.js";
import { safeDispatchCustomEvent } from "../../../utils/events.js";
+import { normalizeAssetId } from "../../../utils/ids.js";
function clampRating(value) {
return Math.max(0, Math.min(5, Number(value) || 0));
@@ -113,7 +114,7 @@ async function flushSaves() {
try {
const newId = result?.data?.asset_id ?? null;
- if (props.asset.id == null && newId != null) props.asset.id = newId;
+ if (newId != null && !normalizeAssetId(props.asset.id)) props.asset.id = newId;
} catch (e) {
console.debug?.(e);
}
diff --git a/js/vue/components/common/TagsEditor.vue b/js/vue/components/common/TagsEditor.vue
index 05845568..048cbf20 100644
--- a/js/vue/components/common/TagsEditor.vue
+++ b/js/vue/components/common/TagsEditor.vue
@@ -11,6 +11,7 @@ import { ASSET_TAGS_CHANGED_EVENT } from "../../../app/events.js";
import { t } from "../../../app/i18n.js";
import { comfyToast } from "../../../app/toast.js";
import { safeDispatchCustomEvent } from "../../../utils/events.js";
+import { normalizeAssetId } from "../../../utils/ids.js";
const MAX_TAG_LEN = 100;
const MAX_TAGS = 200;
@@ -212,7 +213,7 @@ async function saveTags() {
try {
const newId = result?.data?.asset_id ?? null;
- if (props.asset.id == null && newId != null) props.asset.id = newId;
+ if (newId != null && !normalizeAssetId(props.asset.id)) props.asset.id = newId;
} catch (e) {
console.debug?.(e);
}
diff --git a/js/vue/components/grid/AssetCardInner.vue b/js/vue/components/grid/AssetCardInner.vue
index 99134601..24fcd79b 100644
--- a/js/vue/components/grid/AssetCardInner.vue
+++ b/js/vue/components/grid/AssetCardInner.vue
@@ -9,7 +9,7 @@
*
* Phase 4.2 — full inner card replacement.
*/
-import { computed, ref, watch, watchEffect, onUnmounted } from "vue";
+import { computed, inject, ref, watch, watchEffect, onMounted, onUnmounted } from "vue";
import { buildAssetViewURL } from "../../../api/endpoints.js";
import {
genTimeColor,
@@ -19,6 +19,7 @@ import {
} from "../../../components/Badges.js";
import { formatDuration, formatDate, formatTime } from "../../../utils/format.js";
import { MediaBlobCache } from "../../../features/grid/MediaBlobCache.js";
+import { APP_CONFIG } from "../../../app/config.js";
import RatingBadge from "../common/RatingBadge.vue";
import TagsBadge from "../common/TagsBadge.vue";
import GenTimeBadge from "../common/GenTimeBadge.vue";
@@ -64,13 +65,13 @@ async function observeVideoThumb(video) {
try {
const src = String(video.dataset?.src || "").trim();
if (!src || video.getAttribute("src")) return;
-
+
// Check if already errored
if (MediaBlobCache.hasError(src)) {
video.src = src; // Let browser show error
return;
}
-
+
// Try to get from blob cache (or fetch + cache)
const cachedUrl = await MediaBlobCache.acquireUrl(src);
if (cachedUrl) {
@@ -123,16 +124,66 @@ function bindVideoThumbHover(thumbEl, video) {
};
}
-function unobserveVideoThumb(video) {
+function bindVideoAutoplay(video) {
+ if (!video) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ try {
+ if (entry.isIntersecting) {
+ video.play?.().catch?.(() => {});
+ } else {
+ video.pause?.();
+ video.currentTime = 0;
+ }
+ } catch (e) {
+ console.debug?.(e);
+ }
+ }
+ },
+ { threshold: 0.25 },
+ );
+ observer.observe(video);
+ video._mjrAutoplayCleanup = () => {
+ try {
+ observer.unobserve(video);
+ observer.disconnect();
+ } catch (e) {
+ console.debug?.(e);
+ }
+ };
+}
+
+function cleanupVideoBehaviors(video) {
if (!video) return;
try {
video._mjrHoverCleanup?.();
video._mjrHoverCleanup = null;
+ video._mjrAutoplayCleanup?.();
+ video._mjrAutoplayCleanup = null;
+ video.pause?.();
+ video.currentTime = 0;
} catch (e) {
console.debug?.(e);
}
}
+function applyVideoMode(thumbEl, video, mode) {
+ if (!video) return;
+ cleanupVideoBehaviors(video);
+ if (mode === "hover") {
+ bindVideoThumbHover(thumbEl, video);
+ } else if (mode === "always") {
+ bindVideoAutoplay(video);
+ }
+ // "off" — no bindings, video stays paused
+}
+
+function unobserveVideoThumb(video) {
+ if (!video) return;
+ cleanupVideoBehaviors(video);
+}
+
// ─── Props ───────────────────────────────────────────────────────────────────
const props = defineProps({
@@ -207,6 +258,43 @@ const fileBadgeBg = computed(() => {
return map[kind.value] || "var(--mjr-badge-image, #888)";
});
+// ─── Stack / dup-stack (Vue-reactive buttons, replacing imperative DOM) ──────
+
+const stackService = inject("mjrStackService", null);
+
+const hasStackGroup = computed(() => {
+ if (!stackService) return false;
+ const stackId = String(props.asset.stack_id || "").trim();
+ if (!stackId) return false;
+ return Number(props.asset.stack_asset_count || props.asset._mjrFeedGroupCount || 0) > 1;
+});
+const stackCount = computed(() =>
+ Number(props.asset.stack_asset_count || props.asset._mjrFeedGroupCount || 0) || 0,
+);
+
+const hasDupStack = computed(() => !!props.asset._mjrDupStack && Number(props.asset._mjrDupCount || 0) >= 2);
+const dupCount = computed(() => Number(props.asset._mjrDupCount || 0) || 0);
+
+async function onStackGroupClick(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ try {
+ await stackService?.openStackGroup?.(props.asset);
+ } catch (err) {
+ console.debug?.(err);
+ }
+}
+
+function onDupStackClick(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ try {
+ stackService?.openDupGroup?.(props.asset);
+ } catch (err) {
+ console.debug?.(err);
+ }
+}
+
// ─── Template refs ───────────────────────────────────────────────────────────
const thumbRef = ref(null);
@@ -233,6 +321,18 @@ watch(
{ immediate: true }
);
+// ─── Video autoplay mode (reactive to settings changes) ──────────────────────
+
+const videoMode = ref(APP_CONFIG.GRID_VIDEO_AUTOPLAY_MODE || "hover");
+
+function onSettingsChangedForVideo() {
+ videoMode.value = APP_CONFIG.GRID_VIDEO_AUTOPLAY_MODE || "hover";
+}
+
+onMounted(() => {
+ window.addEventListener("mjr-settings-changed", onSettingsChangedForVideo);
+});
+
// ─── Video thumb lifecycle (observe/unobserve) ────────────────────────────────
watch(videoRef, (newEl, oldEl) => {
@@ -242,12 +342,21 @@ watch(videoRef, (newEl, oldEl) => {
if (newEl && thumbRef.value) {
try {
observeVideoThumb(newEl);
- bindVideoThumbHover(thumbRef.value, newEl);
+ applyVideoMode(thumbRef.value, newEl, videoMode.value);
} catch {}
}
});
+watch(videoMode, (mode) => {
+ const video = videoRef.value;
+ const thumb = thumbRef.value;
+ if (video && thumb) {
+ applyVideoMode(thumb, video, mode);
+ }
+});
+
onUnmounted(() => {
+ window.removeEventListener("mjr-settings-changed", onSettingsChangedForVideo);
const v = videoRef.value;
if (v) {
try { unobserveVideoThumb(v); } catch {}
@@ -467,4 +576,42 @@ function onFileBadgeClick(event) {
style="position:absolute;right:8px;bottom:6px;z-index:2"
/>
+
+
+
+
+
+
diff --git a/js/vue/components/grid/VirtualAssetGridHost.vue b/js/vue/components/grid/VirtualAssetGridHost.vue
index 8e1321fa..f272b6b5 100644
--- a/js/vue/components/grid/VirtualAssetGridHost.vue
+++ b/js/vue/components/grid/VirtualAssetGridHost.vue
@@ -1,7 +1,10 @@