Refresh/update cards in place after editing metadata/artwork for an item

This commit is contained in:
HighImKevin committed 2026-10-15 12:15:22 -04:00
1 parent 6971b6be41
commit a060312354
2 files changed
+719 -145

No files matched your search

+193 -104
View File
@@ -1,143 +1,221 @@
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks WebSocketHelper]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks WebSocketHelper]', ...args);
const ERR = (...args) => console.error('[KefinTweaks WebSocketHelper]', ...args);
// Registry of event listeners: MessageType -> array of callbacks
const listeners = {};
// Track initialization state
let isInitialized = false;
// messageType -> unsubscribe from ApiClient.subscribe
const subscribeUnsubs = {};
// messageType -> whether we already attached a subscribe fan-out
const subscribeAttached = {};
let hookedSocket = null;
let hookedOriginalOnMessage = null;
let pollingIntervalId = null;
let deliveryMode = null; // 'subscribe' | 'onmessage' | null
const POLL_INTERVAL_MS = 1500;
/**
* Returns the ApiClient WebSocket if available (e.g. after user has logged in).
*/
function getSocket() {
if (!window.ApiClient) return null;
return window.ApiClient.webSocket || window.ApiClient._webSocket || null;
function getApiClient() {
return window.ApiClient || null;
}
/**
* Initialize WebSocket monitoring by hooking into ApiClient's WebSocket.
* Attempts once; returns true if hooked, false if socket not yet available.
*/
function initialize() {
if (isInitialized) return true;
function getSocket() {
const api = getApiClient();
if (!api) return null;
return api.webSocket || api._webSocket || null;
}
function canSubscribe() {
const api = getApiClient();
return !!(api && typeof api.subscribe === 'function');
}
function dispatchMessage(data) {
if (!data || typeof data !== 'object') return;
const messageType = data.MessageType;
if (!messageType || !listeners[messageType]) return;
listeners[messageType].forEach((callback) => {
try {
callback(data);
} catch (err) {
ERR(`Error in WebSocket listener for ${messageType}:`, err);
}
});
}
function ensureSubscribeForType(messageType) {
if (subscribeAttached[messageType]) return true;
if (!canSubscribe()) return false;
try {
const socket = getSocket();
if (!socket) return false;
// Store original handler if it exists
const originalHandler = socket.onmessage;
// Hook into onmessage
socket.onmessage = function(event) {
try {
// Pass it through so Jellyfin still works normally
if (originalHandler) {
originalHandler.call(this, event);
}
// Parse the message data
const messageData = event.Data || event.data;
const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
// Get the MessageType
const messageType = data.MessageType;
if (messageType && listeners[messageType]) {
// Fire all callbacks for this message type
listeners[messageType].forEach(callback => {
try {
callback(data);
} catch (err) {
ERR(`Error in WebSocket listener for ${messageType}:`, err);
}
});
}
} catch (err) {
// Log parse errors but don't break the original handler
WARN('WebSocket message parse error:', err);
}
};
isInitialized = true;
LOG('WebSocket monitoring initialized');
const api = getApiClient();
const unsub = api.subscribe([messageType], (msg) => {
// SDK/subscribe may pass the outbound message object directly
const data = msg?.MessageType ? msg : { MessageType: messageType, Data: msg?.Data ?? msg };
if (!data.MessageType) data.MessageType = messageType;
dispatchMessage(data);
});
subscribeAttached[messageType] = true;
subscribeUnsubs[messageType] = typeof unsub === 'function' ? unsub : null;
deliveryMode = 'subscribe';
LOG(`Subscribed via ApiClient.subscribe for ${messageType}`);
return true;
} catch (err) {
ERR('Error initializing WebSocket monitoring:', err);
ERR(`ApiClient.subscribe failed for ${messageType}:`, err);
return false;
}
}
/**
* Poll until ApiClient WebSocket is available, then initialize once and stop.
* Safe to call multiple times; only one polling loop runs.
*/
function startPolling() {
if (isInitialized) return;
if (pollingIntervalId != null) return; // already polling
if (initialize()) return;
pollingIntervalId = setInterval(() => {
if (isInitialized) {
clearInterval(pollingIntervalId);
pollingIntervalId = null;
return;
function ensureSubscribeForAllRegisteredTypes() {
if (!canSubscribe()) return false;
let any = false;
Object.keys(listeners).forEach((messageType) => {
if (listeners[messageType]?.length) {
if (ensureSubscribeForType(messageType)) any = true;
}
if (initialize()) {
clearInterval(pollingIntervalId);
pollingIntervalId = null;
});
return any;
}
function unhookSocket() {
if (hookedSocket && hookedOriginalOnMessage !== undefined) {
try {
hookedSocket.onmessage = hookedOriginalOnMessage;
} catch (e) {
// ignore
}
}
hookedSocket = null;
hookedOriginalOnMessage = null;
}
/**
* Legacy path: wrap socket.onmessage, re-bind when the socket instance changes.
*/
function hookSocketIfNeeded() {
const socket = getSocket();
if (!socket) return false;
if (hookedSocket === socket) return true;
unhookSocket();
try {
const originalHandler = socket.onmessage;
hookedOriginalOnMessage = originalHandler;
hookedSocket = socket;
socket.onmessage = function(event) {
try {
if (originalHandler) {
originalHandler.call(this, event);
}
const messageData = event.Data || event.data;
const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
dispatchMessage(data);
} catch (err) {
WARN('WebSocket message parse error:', err);
}
};
deliveryMode = 'onmessage';
LOG('WebSocket onmessage hook attached (legacy fallback)', {
readyState: socket.readyState,
});
return true;
} catch (err) {
ERR('Error hooking WebSocket onmessage:', err);
hookedSocket = null;
hookedOriginalOnMessage = null;
return false;
}
}
function tryInitializeDelivery() {
// Prefer JF-web style subscribe (reconnect-safe)
if (canSubscribe()) {
if (ensureSubscribeForAllRegisteredTypes()) {
return true;
}
}
// Fallback: raw socket onmessage with rebind support
return hookSocketIfNeeded();
}
function startPolling() {
if (pollingIntervalId != null) return;
if (tryInitializeDelivery()) {
// Still poll in legacy mode so we rebind if socket is replaced
if (deliveryMode === 'subscribe') return;
}
pollingIntervalId = setInterval(() => {
if (canSubscribe()) {
ensureSubscribeForAllRegisteredTypes();
if (deliveryMode === 'subscribe') {
// Subscribe path owns delivery; drop legacy hook if any
unhookSocket();
clearInterval(pollingIntervalId);
pollingIntervalId = null;
LOG('Delivery mode: ApiClient.subscribe');
return;
}
}
const socket = getSocket();
if (!socket) return;
if (hookedSocket !== socket) {
hookSocketIfNeeded();
}
}, POLL_INTERVAL_MS);
}
/**
* Register a callback to listen for a specific WebSocket message type
* @param {string} messageType - The MessageType to listen for (e.g., 'UserDataChanged', 'PlaybackStopped')
* @param {Function} callback - Function to call when the message type is received. Receives the parsed message data.
* @param {string} messageType - e.g. 'UserDataChanged', 'LibraryChanged'
* @param {Function} callback - Receives the parsed message object
*/
function listen(messageType, callback) {
if (typeof messageType !== 'string' || !messageType) {
ERR('listen() requires a non-empty string messageType');
return;
}
if (typeof callback !== 'function') {
ERR('listen() requires a callback function');
return;
}
// Initialize listeners array for this message type if it doesn't exist
if (!listeners[messageType]) {
listeners[messageType] = [];
}
// Avoid duplicate registrations of the same callback
if (listeners[messageType].includes(callback)) {
LOG(`Listener already registered for message type: ${messageType}`);
if (!isInitialized) {
startPolling();
}
startPolling();
return;
}
// Add the callback
listeners[messageType].push(callback);
LOG(`Registered listener for message type: ${messageType} (${listeners[messageType].length} total listeners)`);
// Ensure we're polling for WebSocket if not yet initialized
if (!isInitialized) {
startPolling();
LOG(`Registered listener for message type: ${messageType} (${listeners[messageType].length} total)`, {
deliveryMode: deliveryMode || 'pending',
canSubscribe: canSubscribe(),
});
if (canSubscribe()) {
ensureSubscribeForType(messageType);
}
startPolling();
}
/**
* Remove a callback registered with listen().
* @param {string} messageType
@@ -147,15 +225,26 @@
if (!listeners[messageType]) return;
listeners[messageType] = listeners[messageType].filter((cb) => cb !== callback);
LOG(`Unregistered listener for message type: ${messageType} (${listeners[messageType].length} remaining)`);
if (!listeners[messageType].length && subscribeUnsubs[messageType]) {
try {
subscribeUnsubs[messageType]();
} catch (e) {
WARN(`Failed to unsubscribe ${messageType}:`, e);
}
delete subscribeUnsubs[messageType];
delete subscribeAttached[messageType];
}
}
// Expose the API globally
window.websocketHelper = {
listen: listen,
unlisten: unlisten
listen,
unlisten,
/** @returns {'subscribe'|'onmessage'|null} */
getDeliveryMode() {
return deliveryMode;
},
};
// Start polling until ApiClient WebSocket is available (e.g. after user logs in)
startPolling();
})();
+526 -41
View File
@@ -2113,19 +2113,24 @@
}
let cardUserDataListenerStarted = false;
let cardLibraryChangedListenerStarted = false;
let pendingLibraryUpdatedIds = new Set();
let libraryUpdatedFlushTimer = null;
const LIBRARY_UPDATED_DEBOUNCE_MS = 400;
// Match apiHelper DEFAULT_ITEM_FIELDS so rebuilt cards keep aspect/overlays/indicators.
const LIVE_SYNC_ITEM_FIELDS =
'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount';
function escapeCssIdentForCardSync(value) {
return typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(String(value))
: String(value).replace(/["\\]/g, '\\$&');
}
function getPrimaryUserIdForCardSync() {
return typeof ApiClient !== 'undefined' && typeof ApiClient.getCurrentUserId === 'function' ? ApiClient.getCurrentUserId() : null;
}
function extractUserDataChangePayload(event) {
const data = event?.detail ?? event;
const userId = data?.Data?.UserId ?? data?.UserId ?? null;
const list = data?.Data?.UserDataList ?? data?.UserDataList;
if (!Array.isArray(list)) return { userId, entries: [] };
return { userId, entries: list };
}
function isPrimaryUserDataChange(userId) {
const primaryId = getPrimaryUserIdForCardSync();
if (!primaryId) return false;
@@ -2140,8 +2145,7 @@
function updateCardResumeAttributes(itemId, userData) {
if (!itemId || !userData) return;
const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(itemId) : itemId.replace(/["\\]/g, '\\$&');
const cards = document.querySelectorAll(`.card[data-id="${escapedId}"]`);
const cards = findVisibleCardsForItemId(itemId);
if (!cards.length) return;
const ticks = userData.PlaybackPositionTicks;
@@ -2169,10 +2173,12 @@
likedHelper.set(itemId, isLiked);
}
const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(itemId) : itemId.replace(/["\\]/g, '\\$&');
document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => {
button.dataset.active = isLiked ? 'true' : 'false';
button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
itemIdDomVariants(itemId).forEach((variant) => {
const escapedId = escapeCssIdentForCardSync(variant);
document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => {
button.dataset.active = isLiked ? 'true' : 'false';
button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
});
});
}
@@ -2186,39 +2192,516 @@
});
}
function handleCardUserDataChangedEvent(event) {
const { userId, entries } = extractUserDataChangePayload(event);
if (!entries.length) return;
applyUserDataEntriesToCards(entries, userId);
}
function startCardUserDataListener() {
if (cardUserDataListenerStarted) return;
let registered = false;
if (window.ApiClient?.addEventListener) {
window.ApiClient.addEventListener('userdatachanged', handleCardUserDataChangedEvent);
registered = true;
}
if (window.websocketHelper?.listen) {
window.websocketHelper.listen('UserDataChanged', (msg) => {
const userId = msg?.Data?.UserId;
const entries = msg?.Data?.UserDataList;
if (!Array.isArray(entries) || !entries.length) return;
applyUserDataEntriesToCards(entries, userId);
});
registered = true;
}
if (registered) {
cardUserDataListenerStarted = true;
console.log('[KefinTweaks CardBuilder] Listening for UserDataChanged to sync card resume attributes');
if (!window.websocketHelper?.listen) {
setTimeout(startCardUserDataListener, 1000);
return;
}
setTimeout(startCardUserDataListener, 1000);
window.websocketHelper.listen('UserDataChanged', (msg) => {
const userId = msg?.Data?.UserId;
const entries = msg?.Data?.UserDataList;
if (!Array.isArray(entries) || !entries.length) return;
applyUserDataEntriesToCards(entries, userId);
});
cardUserDataListenerStarted = true;
console.log('[KefinTweaks CardBuilder] Listening for UserDataChanged to sync card resume attributes');
}
function liveSyncLog(...args) {
console.log('[KefinTweaks CardBuilder][LiveSync]', ...args);
}
function liveSyncWarn(...args) {
console.warn('[KefinTweaks CardBuilder][LiveSync]', ...args);
}
/** Strip GUID dashes for comparison (server LibraryChanged often uses "N" format). */
function normalizeItemIdKey(id) {
return String(id || '')
.replace(/-/g, '')
.toLowerCase();
}
/** Candidate data-id strings for DOM queries (dashed + undashed). */
function itemIdDomVariants(id) {
const raw = String(id || '').trim();
if (!raw) return [];
const variants = new Set([raw]);
const naked = normalizeItemIdKey(raw);
if (/^[0-9a-f]{32}$/.test(naked)) {
variants.add(naked);
variants.add(
`${naked.slice(0, 8)}-${naked.slice(8, 12)}-${naked.slice(12, 16)}-${naked.slice(16, 20)}-${naked.slice(20)}`,
);
}
return Array.from(variants);
}
function findVisibleCardsForItemId(itemId) {
if (!itemId) return [];
const found = new Set();
itemIdDomVariants(itemId).forEach((variant) => {
const escapedId = escapeCssIdentForCardSync(variant);
document.querySelectorAll(`.card[data-id="${escapedId}"]:not(.skeleton-card)`).forEach((card) => {
found.add(card);
});
});
return Array.from(found);
}
function resolveCardRebuildArgs(card) {
const sectionEl = card?.closest?.('[data-section-id]');
const sectionId = sectionEl?.getAttribute('data-section-id') || sectionEl?.dataset?.sectionId || null;
const runtimeCfg = sectionId ? window.KefinHomeScreenSectionRuntime?.get?.(sectionId)?.effectiveConfig : null;
const cardFormat = sectionEl?.getAttribute('data-card-format') || runtimeCfg?.cardFormat || null;
let overflowCard;
if (runtimeCfg && typeof runtimeCfg.overflowCard === 'boolean') {
overflowCard = runtimeCfg.overflowCard;
} else if (/\boverflow(?:Portrait|Backdrop|Square|Banner)Card\b/.test(card?.className || '')) {
overflowCard = true;
} else if (/\b(?:portrait|backdrop|square|banner)Card\b/.test(card?.className || '')) {
overflowCard = false;
} else {
// Home rows default to overflow sizing.
overflowCard = true;
}
const useParentCard = !!runtimeCfg?.useParentCard;
const borderStyle =
runtimeCfg?.borderStyle || sectionEl?.dataset?.borderStyle || sectionEl?.dataset?.border || null;
return { cardFormat, overflowCard, useParentCard, borderStyle, sectionId };
}
/** Image tags the card builder may put in data-src (any format / fallback path). */
function collectItemImageTagsForLiveSync(item) {
const tags = new Set();
const add = (t) => {
if (t != null && t !== '') tags.add(String(t));
};
const it = item?.ImageTags;
if (it) {
add(it.Primary);
add(it.Thumb);
add(it.Logo);
add(it.Banner);
add(it.Art);
add(it.Disc);
}
add(item?.BackdropImageTags?.[0]);
add(item?.PrimaryImageTag);
add(item?.SeriesPrimaryImageTag);
add(item?.SeriesThumbImageTag);
add(item?.ParentThumbImageTag);
add(item?.ParentBackdropImageTags?.[0]);
add(item?.ParentPrimaryImageTag);
add(item?.ParentLogoImageTag);
return tags;
}
function extractTagFromImageUrl(url) {
if (!url) return '';
try {
const m = String(url).match(/[?&]tag=([^&]*)/i);
return m?.[1] ? decodeURIComponent(m[1]) : '';
} catch (e) {
return '';
}
}
/**
* Skip DOM replace when painted card signals already match the fresh item.
* Compares name, image tag in data-src, resume ticks, played / unplayed indicators.
*/
function cardNeedsLiveSyncRebuild(oldCard, freshItem) {
if (!oldCard || !freshItem) return false;
const img =
oldCard.querySelector?.('.cardImageContainer[data-src]') ||
oldCard.querySelector?.('.cardImageContainer') ||
null;
const dataSrc = img?.getAttribute('data-src') || '';
const paintedTag = extractTagFromImageUrl(dataSrc);
const paintedName = (img?.getAttribute('aria-label') || '').trim();
const paintedTicks = oldCard.getAttribute('data-positionticks') || '';
const paintedPlayed = !!oldCard.querySelector?.('.playedIndicator');
const paintedUnplayed = (oldCard.querySelector?.('.countIndicator.indicator')?.textContent || '').trim();
const paintedMediaSources = (oldCard.querySelector?.('.mediaSourceIndicator')?.textContent || '').trim();
const expectedName = String(freshItem.Name || 'Unknown').trim();
const nameChanged = paintedName !== expectedName && paintedName !== String(freshItem.Name || '').trim();
const freshTags = collectItemImageTagsForLiveSync(freshItem);
const hadImage = !!dataSrc;
const hasImageTags = freshTags.size > 0;
let imageChanged = false;
if (paintedTag) {
// Painted tag no longer on the item → artwork changed for this card.
imageChanged = !freshTags.has(paintedTag);
} else if (!hadImage && hasImageTags) {
imageChanged = true;
} else if (hadImage && !hasImageTags) {
imageChanged = true;
}
const freshPlayed = freshItem.UserData?.Played === true;
const freshTicks =
!freshPlayed && typeof freshItem.UserData?.PlaybackPositionTicks === 'number' && freshItem.UserData.PlaybackPositionTicks > 0
? String(freshItem.UserData.PlaybackPositionTicks)
: '';
const ticksChanged = paintedTicks !== freshTicks;
const playedChanged = paintedPlayed !== freshPlayed;
const freshUnplayed =
freshItem.UserData?.UnplayedItemCount && freshItem.UserData.UnplayedItemCount > 0
? String(freshItem.UserData.UnplayedItemCount)
: '';
const unplayedChanged = paintedUnplayed !== freshUnplayed;
const freshMediaSources =
typeof freshItem.MediaSourceCount === 'number' && freshItem.MediaSourceCount > 1
? String(freshItem.MediaSourceCount)
: '';
const mediaSourcesChanged = paintedMediaSources !== freshMediaSources;
const needs = nameChanged || imageChanged || ticksChanged || playedChanged || unplayedChanged || mediaSourcesChanged;
if (!needs) {
liveSyncLog('Skipping rebuild — no significant painted changes', {
itemId: freshItem.Id,
name: expectedName,
paintedTag,
});
}
return needs;
}
function replaceVisibleCardWithFreshItem(oldCard, freshItem) {
if (!oldCard || !freshItem) return false;
if (!cardNeedsLiveSyncRebuild(oldCard, freshItem)) return false;
const { cardFormat, overflowCard, useParentCard, borderStyle, sectionId } = resolveCardRebuildArgs(oldCard);
liveSyncLog('Rebuilding card', {
itemId: freshItem.Id,
name: freshItem.Name,
sectionId,
cardFormat,
overflowCard,
useParentCard,
borderStyle,
imagePrimaryTag: freshItem.ImageTags?.Primary || null,
});
const next = createCardElement(
freshItem,
overflowCard,
cardFormat,
freshItem.cardFooter || freshItem.CustomFooterText || null,
useParentCard,
borderStyle,
);
const index = oldCard.getAttribute('data-index');
if (index != null) next.setAttribute('data-index', index);
const hadFocus = document.activeElement === oldCard;
oldCard.replaceWith(next);
// New card has data-src + .lazy but is not in any IntersectionObserver yet.
registerLazyImagesIn(next);
if (hadFocus && typeof next.focus === 'function') {
try {
next.focus({ preventScroll: true });
} catch (e) {
next.focus();
}
}
return true;
}
async function flushVisibleCardLibraryUpdates() {
libraryUpdatedFlushTimer = null;
const ids = Array.from(pendingLibraryUpdatedIds);
pendingLibraryUpdatedIds.clear();
liveSyncLog('Flush start', { pendingIds: ids });
if (!ids.length) return;
const matchCounts = {};
const idsToFetch = ids.filter((id) => {
const count = findVisibleCardsForItemId(id).length;
matchCounts[id] = count;
return count > 0;
});
liveSyncLog('Visible card matches', matchCounts);
if (!idsToFetch.length) {
liveSyncLog('No on-page cards for updated ids — skipping fetch');
return;
}
if (typeof ApiClient === 'undefined' || typeof ApiClient.getItems !== 'function') {
liveSyncWarn('ApiClient.getItems unavailable — cannot refresh cards');
return;
}
const userId = ApiClient.getCurrentUserId?.();
liveSyncLog('Fetching items', { userId, idsToFetch, fields: LIVE_SYNC_ITEM_FIELDS });
try {
const response = await ApiClient.getItems(userId, {
Ids: idsToFetch.join(','),
Recursive: false,
Fields: LIVE_SYNC_ITEM_FIELDS,
});
const itemsById = new Map();
(response?.Items || []).forEach((item) => {
if (!item?.Id) return;
itemsById.set(normalizeItemIdKey(item.Id), item);
});
liveSyncLog('Fetch result', {
requested: idsToFetch.length,
returned: itemsById.size,
itemSummaries: Array.from(itemsById.values()).map((item) => ({
id: item.Id,
name: item.Name,
primaryTag: item.ImageTags?.Primary || null,
})),
});
let rebuilt = 0;
let skipped = 0;
idsToFetch.forEach((id) => {
const freshItem = itemsById.get(normalizeItemIdKey(id));
if (!freshItem) {
liveSyncWarn('Item missing from getItems response', id);
return;
}
const cards = findVisibleCardsForItemId(id);
liveSyncLog('Replacing cards for item', { id, cardCount: cards.length });
cards.forEach((card) => {
if (replaceVisibleCardWithFreshItem(card, freshItem)) rebuilt += 1;
else skipped += 1;
});
});
liveSyncLog('Flush complete', { rebuilt, skipped });
} catch (err) {
console.error('[KefinTweaks CardBuilder][LiveSync] Visible card live sync failed:', err);
}
}
function queueLibraryUpdatedItemIds(ids, source = 'unknown') {
if (!Array.isArray(ids) || !ids.length) return;
const before = pendingLibraryUpdatedIds.size;
ids.forEach((id) => {
if (id == null || id === '') return;
pendingLibraryUpdatedIds.add(String(id));
});
liveSyncLog('Queued item ids', {
source,
incoming: ids.map(String),
pendingSizeBefore: before,
pendingSizeAfter: pendingLibraryUpdatedIds.size,
debounceMs: LIBRARY_UPDATED_DEBOUNCE_MS,
});
if (libraryUpdatedFlushTimer) clearTimeout(libraryUpdatedFlushTimer);
libraryUpdatedFlushTimer = setTimeout(flushVisibleCardLibraryUpdates, LIBRARY_UPDATED_DEBOUNCE_MS);
}
function handleLibraryChangedMessage(msg) {
const data = msg?.Data ?? msg;
const updated = data?.ItemsUpdated;
const dataKeys = data && typeof data === 'object' ? Object.keys(data) : [];
liveSyncLog('LibraryChanged received', {
deliveryMode: window.websocketHelper?.getDeliveryMode?.() || null,
messageType: msg?.MessageType,
dataKeys,
itemsUpdatedType: Array.isArray(updated) ? 'array' : typeof updated,
itemsUpdatedCount: Array.isArray(updated) ? updated.length : null,
itemsUpdated: Array.isArray(updated) ? updated : updated,
itemsAdded: data?.ItemsAdded,
itemsRemoved: data?.ItemsRemoved,
});
if (!Array.isArray(updated) || !updated.length) {
liveSyncLog('Ignoring LibraryChanged — ItemsUpdated empty or not an array');
return;
}
queueLibraryUpdatedItemIds(updated, 'LibraryChanged.ItemsUpdated');
}
function startCardLibraryChangedListener() {
if (cardLibraryChangedListenerStarted) return;
if (!window.websocketHelper?.listen) {
liveSyncLog('websocketHelper not ready — retrying listener in 1s');
setTimeout(startCardLibraryChangedListener, 1000);
return;
}
window.websocketHelper.listen('LibraryChanged', handleLibraryChangedMessage);
cardLibraryChangedListenerStarted = true;
liveSyncLog('Listening for LibraryChanged to sync visible card metadata', {
deliveryMode: window.websocketHelper?.getDeliveryMode?.() || 'pending',
});
}
let cardEditorCloseLiveSyncStarted = false;
const editorDialogDirty = new WeakMap();
function isImageEditorDialog(dlg) {
if (!dlg?.querySelector) return false;
return !!(dlg.querySelector('.imageEditorCard') || dlg.querySelector('#images') || dlg.querySelector('#backdrops'));
}
function isMetadataEditorDialog(dlg) {
if (!dlg?.querySelector) return false;
if (isImageEditorDialog(dlg)) return false;
return !!(
dlg.querySelector('.metadataEditor') ||
dlg.querySelector('#metadataEditor') ||
dlg.querySelector('.metadataEditorContent') ||
dlg.querySelector('form.metadataEditorForm') ||
dlg.querySelector('.btnSaveMetadata, #btnSaveMetadata')
);
}
function resolveEditorDialogItemId(dlg) {
if (!dlg) return null;
const fromCard = dlg.querySelector?.('.imageEditorCard[data-id], [data-id][data-imagetype], [data-itemid]');
const attrId =
fromCard?.getAttribute('data-id') ||
fromCard?.getAttribute('data-itemid') ||
dlg.getAttribute?.('data-itemid') ||
dlg.getAttribute?.('data-id') ||
dlg.dataset?.itemId ||
null;
if (attrId) return attrId;
try {
const hash = String(window.location.hash || '');
const match = hash.match(/[?&]id=([^&]+)/i);
if (match?.[1]) return decodeURIComponent(match[1]);
} catch (e) {
// ignore
}
return null;
}
function markEditorDialogDirty(dlg) {
if (!dlg) return;
editorDialogDirty.set(dlg, true);
}
function handleEditorDialogClose(dlg) {
if (!dlg) return;
const isImage = isImageEditorDialog(dlg);
const isMeta = isMetadataEditorDialog(dlg);
if (!isImage && !isMeta) return;
const dirty = editorDialogDirty.get(dlg) === true;
// Mirror imageeditor hasChanges: only rebuild when the dialog was dirtied while open.
if (!dirty) {
liveSyncLog('Editor closed without changes — skip rebuild', {
kind: isImage ? 'image' : 'metadata',
});
return;
}
const itemId = resolveEditorDialogItemId(dlg);
if (!itemId) {
liveSyncWarn('Editor closed but could not resolve item id');
return;
}
liveSyncLog('Editor closed — queueing live sync', {
itemId,
kind: isImage ? 'image' : 'metadata',
dirty,
});
queueLibraryUpdatedItemIds([itemId], isImage ? 'imageEditor.close' : 'metadataEditor.close');
}
function watchEditorDialog(dlg) {
if (!dlg || dlg.__kefinLiveSyncWatched) return;
if (!isImageEditorDialog(dlg) && !isMetadataEditorDialog(dlg)) return;
dlg.__kefinLiveSyncWatched = true;
editorDialogDirty.set(dlg, false);
if (isImageEditorDialog(dlg)) {
// imageeditor.js mutates #images during initial reload — ignore until settled.
let armed = false;
const armTimer = setTimeout(() => {
armed = true;
editorDialogDirty.set(dlg, false);
}, 800);
dlg.__kefinLiveSyncArmTimer = armTimer;
const mo = new MutationObserver(() => {
if (!armed) return;
markEditorDialogDirty(dlg);
});
const images = dlg.querySelector('#images') || dlg.querySelector('#backdrops') || dlg;
try {
mo.observe(images, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-src', 'style', 'src'],
});
} catch (e) {
mo.observe(dlg, { childList: true, subtree: true });
}
dlg.__kefinLiveSyncMo = mo;
} else if (isMetadataEditorDialog(dlg)) {
const onFieldEdit = () => markEditorDialogDirty(dlg);
dlg.addEventListener('input', onFieldEdit, true);
dlg.addEventListener('change', onFieldEdit, true);
dlg.__kefinLiveSyncFieldEdit = onFieldEdit;
}
const onClose = () => {
if (dlg.__kefinLiveSyncArmTimer) {
clearTimeout(dlg.__kefinLiveSyncArmTimer);
}
if (dlg.__kefinLiveSyncFieldEdit) {
dlg.removeEventListener('input', dlg.__kefinLiveSyncFieldEdit, true);
dlg.removeEventListener('change', dlg.__kefinLiveSyncFieldEdit, true);
}
try {
dlg.__kefinLiveSyncMo?.disconnect?.();
} catch (e) {
// ignore
}
handleEditorDialogClose(dlg);
dlg.removeEventListener('close', onClose);
};
dlg.addEventListener('close', onClose);
liveSyncLog('Watching editor dialog for close', {
image: isImageEditorDialog(dlg),
metadata: isMetadataEditorDialog(dlg),
itemId: resolveEditorDialogItemId(dlg),
});
}
function startCardEditorCloseLiveSync() {
if (cardEditorCloseLiveSyncStarted) return;
cardEditorCloseLiveSyncStarted = true;
document.querySelectorAll('.formDialog').forEach((dlg) => watchEditorDialog(dlg));
const bodyObserver = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
if (node.matches?.('.formDialog')) watchEditorDialog(node);
node.querySelectorAll?.('.formDialog').forEach((dlg) => watchEditorDialog(dlg));
});
});
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
liveSyncLog('Editor-close live sync observer started');
}
// Main card builder object
@@ -9996,6 +10479,8 @@
await window.userHelper.waitForLogin();
}
startCardUserDataListener();
startCardLibraryChangedListener();
startCardEditorCloseLiveSync();
try {
const userId = typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId ? ApiClient.getCurrentUserId() : null;
if (userId) {