// Shared Custom Items editor (wizard + advanced section editor)
(function() {
'use strict';
const CUSTOM_IMAGE_FIELDS = [
{ key: 'posterUrl', label: 'Poster URL', required: true },
{ key: 'thumbUrl', label: 'Thumb URL' },
{ key: 'backdropUrl', label: 'Backdrop URL' },
{ key: 'squareUrl', label: 'Square URL' },
{ key: 'bannerUrl', label: 'Banner URL' },
{ key: 'logoUrl', label: 'Logo URL' }
];
const CUSTOM_POSTER_FIELD = CUSTOM_IMAGE_FIELDS[0];
const CUSTOM_ADDITIONAL_IMAGE_FIELDS = CUSTOM_IMAGE_FIELDS.slice(1);
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function renderMaterialIcon(name, className = '') {
return `${escapeHtml(name)}`;
}
function getOptions(options = {}) {
return {
idPrefix: options.idPrefix || 'hsse-custom',
actionAttr: options.actionAttr || 'data-hsse-action',
actions: {
openEditor: 'wizard-open-custom-item-editor',
closeEditor: 'wizard-close-custom-item-editor',
addItem: 'wizard-add-custom-item',
removeItem: 'wizard-remove-custom-item',
toggleImages: 'wizard-toggle-custom-images',
toggleHide: 'wizard-toggle-custom-item-hide',
moveLeft: 'wizard-move-custom-item-left',
moveRight: 'wizard-move-custom-item-right',
...(options.actions || {})
},
intro: options.intro !== false,
// Default Kefin sections pass false — hide only, never delete
allowRemove: options.allowRemove !== false
};
}
function actionAttr(options, action) {
return `${options.actionAttr}="${escapeHtml(options.actions[action])}"`;
}
function fieldId(options, index, key) {
return `${options.idPrefix}-${index}-${key}`;
}
function createBlankCustomItem() {
return {
name: '',
cardFooter: '',
cardUrl: '',
posterUrl: '',
thumbUrl: '',
backdropUrl: '',
squareUrl: '',
bannerUrl: '',
logoUrl: '',
hidden: false
};
}
function staticItemToDraft(item) {
if (!item) return createBlankCustomItem();
const draft = createBlankCustomItem();
draft.name = item.Name || item.name || '';
draft.cardFooter = item.cardFooter || '';
draft.cardUrl = item.cardUrl || '';
draft.posterUrl = item.posterUrl || item.imageUrl || '';
draft.thumbUrl = item.thumbUrl || '';
draft.backdropUrl = item.backdropUrl || '';
draft.squareUrl = item.squareUrl || '';
draft.bannerUrl = item.bannerUrl || '';
draft.logoUrl = item.logoUrl || '';
draft.hidden = item.hidden === true;
if (item.Id || item.id) draft.id = item.Id || item.id;
if (item.Type) draft.type = item.Type;
return draft;
}
function staticItemsToDrafts(items) {
const list = (items || []).map(staticItemToDraft).filter(Boolean);
return list.length ? list : [createBlankCustomItem()];
}
function ensureCustomItemsInitialized(state) {
if (!state.customItems?.length) {
state.customItems = [createBlankCustomItem()];
}
}
function hasAdditionalCustomImages(item) {
return CUSTOM_ADDITIONAL_IMAGE_FIELDS.some(field => item[field.key]?.trim());
}
function isCustomItemAdditionalExpanded(state, index, item) {
const explicit = state.customItemAdditionalExpanded?.[index];
if (explicit === true) return true;
if (explicit === false) return false;
return hasAdditionalCustomImages(item);
}
function reindexCustomItemAdditionalExpanded(state, removedIndex) {
if (!state.customItemAdditionalExpanded) return;
const next = {};
Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => {
const i = parseInt(key, 10);
if (Number.isNaN(i) || value === undefined) return;
if (i < removedIndex) next[i] = value;
else if (i > removedIndex) next[i - 1] = value;
});
state.customItemAdditionalExpanded = next;
}
function swapCustomItemExpanded(state, fromIndex, toIndex) {
if (!state.customItemAdditionalExpanded) return;
const a = state.customItemAdditionalExpanded[fromIndex];
const b = state.customItemAdditionalExpanded[toIndex];
if (a === undefined && b === undefined) return;
if (b === undefined) delete state.customItemAdditionalExpanded[fromIndex];
else state.customItemAdditionalExpanded[fromIndex] = b;
if (a === undefined) delete state.customItemAdditionalExpanded[toIndex];
else state.customItemAdditionalExpanded[toIndex] = a;
}
function moveCustomItemExpanded(state, fromIndex, toIndex) {
if (!state.customItemAdditionalExpanded || fromIndex === toIndex) return;
const next = {};
const moved = state.customItemAdditionalExpanded[fromIndex];
Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => {
const i = parseInt(key, 10);
if (Number.isNaN(i) || i === fromIndex) return;
let dest = i;
if (fromIndex < toIndex && i > fromIndex && i <= toIndex) dest = i - 1;
else if (fromIndex > toIndex && i >= toIndex && i < fromIndex) dest = i + 1;
next[dest] = value;
});
if (moved !== undefined) next[toIndex] = moved;
state.customItemAdditionalExpanded = next;
}
function hasValidCustomItems(items) {
return (items || []).some((item) => {
const name = item.name?.trim();
if (!name) return false;
return !!(item.posterUrl?.trim() || item.cardUrl?.trim());
});
}
function hasCustomItemContent(item) {
if (!item) return false;
return [
item.name, item.cardFooter, item.cardUrl, item.posterUrl,
item.thumbUrl, item.backdropUrl, item.squareUrl, item.bannerUrl, item.logoUrl
].some(value => value?.trim());
}
function buildStaticItemFromDraft(draft) {
const name = draft.name?.trim();
const posterUrl = draft.posterUrl?.trim();
const cardUrl = draft.cardUrl?.trim();
// Name + poster, or Name + link (e.g. liveTv-browse button tiles)
if (!name || (!posterUrl && !cardUrl)) return null;
const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl'];
const provided = {};
imageKeys.forEach(key => {
const value = draft[key]?.trim();
if (value) provided[key] = value;
});
const item = { Name: name };
if (draft.type) item.Type = draft.type;
else if (posterUrl) item.Type = 'Folder';
if (draft.id) item.Id = draft.id;
if (cardUrl) item.cardUrl = cardUrl;
const cardFooter = draft.cardFooter?.trim();
if (cardFooter) item.cardFooter = cardFooter;
if (draft.hidden === true) item.hidden = true;
const keys = Object.keys(provided);
if (keys.length === 1 && provided.posterUrl) {
item.imageUrl = provided.posterUrl;
} else if (keys.length) {
Object.assign(item, provided);
}
return item;
}
function collectCustomItemFromForm(root, state, index, options) {
if (!root || !state.customItems?.[index]) return;
const opts = getOptions(options);
const item = state.customItems[index];
const nameEl = root.querySelector(`#${fieldId(opts, index, 'name')}`);
const cardFooterEl = root.querySelector(`#${fieldId(opts, index, 'cardFooter')}`);
const cardUrlEl = root.querySelector(`#${fieldId(opts, index, 'cardUrl')}`);
if (nameEl) item.name = nameEl.value;
if (cardFooterEl) item.cardFooter = cardFooterEl.value;
if (cardUrlEl) item.cardUrl = cardUrlEl.value;
CUSTOM_IMAGE_FIELDS.forEach(field => {
const el = root.querySelector(`#${fieldId(opts, index, field.key)}`);
if (el) item[field.key] = el.value;
});
}
function collectCustomItemsFromForm(root, state, options) {
if (!root || !state.customItems) return;
const editorIndex = state.customItemEditorIndex;
if (editorIndex != null && !Number.isNaN(editorIndex)) {
collectCustomItemFromForm(root, state, editorIndex, options);
}
}
function resolvePreviewUrl(url) {
const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || '';
const serverId = (typeof ApiClient !== 'undefined' && typeof ApiClient.serverId === 'function')
? ApiClient.serverId()
: '';
return String(url || '')
.replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot)
.replace(/\$\{serverId\}/g, serverId)
.trim();
}
function updateCustomImagePreview(imgEl, url) {
if (!imgEl) return;
const trimmed = resolvePreviewUrl(url);
if (!trimmed) {
imgEl.removeAttribute('src');
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'empty';
return;
}
imgEl.style.display = '';
imgEl.dataset.previewState = 'loading';
imgEl.onerror = () => {
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'error';
};
imgEl.onload = () => {
imgEl.style.display = '';
imgEl.dataset.previewState = 'loaded';
};
imgEl.src = trimmed;
}
function refreshPreviews(root, state, options) {
if (!root || !state.customItems) return;
state.customItems.forEach((item, index) => {
const tileImg = root.querySelector(`img[data-tile-index="${index}"]`);
updateCustomImagePreview(tileImg, item.posterUrl);
});
root.querySelectorAll('[data-hsse-custom-preview], [data-hsae-custom-preview]').forEach(input => {
const img = root.querySelector(`img[data-preview-for="${input.id}"]`);
updateCustomImagePreview(img, input.value);
});
}
function buildCustomImageFieldHTML(index, field, value, options, fieldOptions = {}) {
const opts = getOptions(options);
const { isPoster = false } = fieldOptions;
const requiredMark = field.required ? ' *' : '';
const inputId = fieldId(opts, index, field.key);
const previewAttr = isPoster ? ' data-hsse-custom-preview="true" data-hsae-custom-preview="true"' : '';
const posterHint = isPoster
? `
You can place images in your "Jellyfin\\Server\\jellyfin-web" folder and use the "/web/image_name.png" path to access them.
`
: '';
return `
${posterHint}
`;
}
function renderCustomItemEditorHTML(index, item, state, options) {
const opts = getOptions(options);
const additionalExpanded = isCustomItemAdditionalExpanded(state, index, item);
const posterInputId = fieldId(opts, index, 'posterUrl');
return `
${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '', opts)).join('')}
`;
}
function renderCustomItemTileHTML(index, item, canRemove, options, itemCount) {
const opts = getOptions(options);
const label = item.name?.trim() || `Item ${index + 1}`;
const isHidden = item.hidden === true;
const canMoveLeft = index > 0;
const canMoveRight = index < itemCount - 1;
const hideIcon = isHidden ? 'visibility_off' : 'visibility';
const hideLabel = isHidden ? `Show ${label}` : `Hide ${label}`;
const removeTitle = canRemove
? `Remove ${label}`
: (opts.allowRemove === false
? 'Default section items cannot be deleted — hide instead'
: `Remove ${label}`);
return `
${escapeHtml(label)}
`;
}
function renderCustomItemEditorPanelHTML(state, options) {
const opts = getOptions(options);
const editorIndex = state.customItemEditorIndex;
if (editorIndex == null || Number.isNaN(editorIndex)) return '';
const item = state.customItems?.[editorIndex];
if (!item) return '';
const title = item.name?.trim()
? `Edit: ${item.name.trim()}`
: `Edit Item ${editorIndex + 1}`;
return `
${renderCustomItemEditorHTML(editorIndex, item, state, opts)}
`;
}
function renderCustomItemsHTML(state, options) {
const opts = getOptions(options);
ensureCustomItemsInitialized(state);
const items = state.customItems;
const canRemove = opts.allowRemove !== false && items.length > 1;
return `
${opts.intro ? `
Custom Items
Name required; poster or link recommended. Other images optional.
` : ''}
${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove, opts, items.length)).join('')}
${renderCustomItemEditorPanelHTML(state, opts)}
`;
}
function reindexTileDom(tile, index, itemCount) {
if (!tile) return;
tile.dataset.customItemIndex = String(index);
tile.querySelectorAll('[data-item-index]').forEach((el) => {
el.dataset.itemIndex = String(index);
});
const tileImg = tile.querySelector('img[data-tile-index]');
if (tileImg) tileImg.dataset.tileIndex = String(index);
// Action order: left | hide | delete | right
const actions = tile.querySelectorAll('.hsse-custom-item-actions .hsse-custom-item-action');
if (actions.length >= 4) {
actions[0].disabled = index <= 0;
actions[3].disabled = index >= itemCount - 1;
}
}
function swapAdjacentCustomItemTiles(root, fromIndex, toIndex) {
if (!root || fromIndex === toIndex) return false;
const grid = root.querySelector('.hsse-custom-items-grid');
if (!grid) return false;
const a = grid.querySelector(`[data-custom-item-index="${fromIndex}"]`);
const b = grid.querySelector(`[data-custom-item-index="${toIndex}"]`);
if (!a || !b) return false;
const aNext = a.nextSibling;
const bNext = b.nextSibling;
if (aNext === b) {
grid.insertBefore(b, a);
} else if (bNext === a) {
grid.insertBefore(a, b);
} else {
grid.insertBefore(a, bNext);
grid.insertBefore(b, aNext);
}
const itemCount = grid.querySelectorAll('[data-custom-item-index]').length;
reindexTileDom(a, toIndex, itemCount);
reindexTileDom(b, fromIndex, itemCount);
return true;
}
function handleAction(action, el, root, state, options, onRefresh) {
const opts = getOptions(options);
const actions = opts.actions;
switch (action) {
case actions.openEditor: {
const openIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(openIndex)) break;
if (state.customItemEditorIndex != null && state.customItemEditorIndex !== openIndex) {
collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts);
}
state.customItemEditorIndex = openIndex;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.closeEditor: {
const discard = el.dataset.discard === 'true';
if (!discard && state.customItemEditorIndex != null) {
collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts);
}
state.customItemEditorIndex = null;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.addItem: {
collectCustomItemsFromForm(root, state, opts);
state.customItems.push(createBlankCustomItem());
state.customItemEditorIndex = state.customItems.length - 1;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.removeItem: {
if (opts.allowRemove === false) break;
const removeIndex = parseInt(el.dataset.itemIndex, 10);
if (state.customItems.length <= 1 || Number.isNaN(removeIndex)) break;
if (state.customItemEditorIndex === removeIndex) {
collectCustomItemFromForm(root, state, removeIndex, opts);
} else {
collectCustomItemsFromForm(root, state, opts);
}
const itemToRemove = state.customItems[removeIndex];
if (hasCustomItemContent(itemToRemove) && !confirm('Are you sure you want to remove this item?')) {
break;
}
state.customItems.splice(removeIndex, 1);
reindexCustomItemAdditionalExpanded(state, removeIndex);
if (state.customItemEditorIndex === removeIndex) {
state.customItemEditorIndex = null;
} else if (state.customItemEditorIndex > removeIndex) {
state.customItemEditorIndex -= 1;
}
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.toggleImages: {
const toggleIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(toggleIndex)) break;
collectCustomItemFromForm(root, state, toggleIndex, opts);
if (!state.customItemAdditionalExpanded) state.customItemAdditionalExpanded = {};
const item = state.customItems[toggleIndex];
const currentlyExpanded = isCustomItemAdditionalExpanded(state, toggleIndex, item);
state.customItemAdditionalExpanded[toggleIndex] = !currentlyExpanded;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.toggleHide: {
const hideIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(hideIndex) || !state.customItems?.[hideIndex]) break;
collectCustomItemsFromForm(root, state, opts);
const item = state.customItems[hideIndex];
item.hidden = !item.hidden;
// Remove, then reinsert at the target slot among remaining items
state.customItems.splice(hideIndex, 1);
let insertAt;
if (item.hidden === true) {
// Hidden items stay at the end
insertAt = state.customItems.length;
} else {
// Unhide: last active item, immediately before the first still-hidden item
insertAt = state.customItems.findIndex((it) => it.hidden === true);
if (insertAt < 0) insertAt = state.customItems.length;
}
state.customItems.splice(insertAt, 0, item);
if (insertAt !== hideIndex) {
moveCustomItemExpanded(state, hideIndex, insertAt);
if (state.customItemEditorIndex === hideIndex) {
state.customItemEditorIndex = insertAt;
} else if (state.customItemEditorIndex != null) {
if (hideIndex < state.customItemEditorIndex && insertAt >= state.customItemEditorIndex) {
state.customItemEditorIndex -= 1;
} else if (hideIndex > state.customItemEditorIndex && insertAt <= state.customItemEditorIndex) {
state.customItemEditorIndex += 1;
}
}
}
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.moveLeft: {
const leftIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(leftIndex) || leftIndex <= 0) break;
collectCustomItemsFromForm(root, state, opts);
const swapWith = leftIndex - 1;
const tmp = state.customItems[leftIndex];
state.customItems[leftIndex] = state.customItems[swapWith];
state.customItems[swapWith] = tmp;
swapCustomItemExpanded(state, leftIndex, swapWith);
if (state.customItemEditorIndex === leftIndex) state.customItemEditorIndex = swapWith;
else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = leftIndex;
if (!swapAdjacentCustomItemTiles(root, leftIndex, swapWith) && typeof onRefresh === 'function') {
onRefresh(root);
}
break;
}
case actions.moveRight: {
const rightIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(rightIndex) || rightIndex >= state.customItems.length - 1) break;
collectCustomItemsFromForm(root, state, opts);
const swapWith = rightIndex + 1;
const tmp = state.customItems[rightIndex];
state.customItems[rightIndex] = state.customItems[swapWith];
state.customItems[swapWith] = tmp;
swapCustomItemExpanded(state, rightIndex, swapWith);
if (state.customItemEditorIndex === rightIndex) state.customItemEditorIndex = swapWith;
else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = rightIndex;
if (!swapAdjacentCustomItemTiles(root, rightIndex, swapWith) && typeof onRefresh === 'function') {
onRefresh(root);
}
break;
}
default:
break;
}
}
window.KefinHomeScreenCustomItemsEditor = {
CUSTOM_IMAGE_FIELDS,
createBlankCustomItem,
staticItemToDraft,
staticItemsToDrafts,
ensureCustomItemsInitialized,
hasValidCustomItems,
hasCustomItemContent,
buildStaticItemFromDraft,
collectCustomItemsFromForm,
collectCustomItemFromForm,
renderCustomItemsHTML,
refreshPreviews,
handleAction,
reindexCustomItemAdditionalExpanded,
getCustomItemsOptions: (overrides = {}) => getOptions({
idPrefix: 'hsae-custom',
actionAttr: 'data-hsae-action',
actions: {
openEditor: 'hsae-open-custom-item-editor',
closeEditor: 'hsae-close-custom-item-editor',
addItem: 'hsae-add-custom-item',
removeItem: 'hsae-remove-custom-item',
toggleImages: 'hsae-toggle-custom-images',
toggleHide: 'hsae-toggle-custom-item-hide',
moveLeft: 'hsae-move-custom-item-left',
moveRight: 'hsae-move-custom-item-right'
},
...overrides
})
};
})();