// Pin items to home screen via context menu
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks HomeScreenPin]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HomeScreenPin]', ...args);
const PARENT_PIN_TYPES = new Set([
'CollectionFolder',
'BoxSet',
'Playlist',
'Series',
'Season',
'MusicAlbum',
'MusicArtist',
'MusicGenre',
'Genre',
'Studio',
'Person',
'Tag'
]);
let capturedItemId = null;
let capturedCard = null;
let pendingUnpinStash = null;
const UNPIN_FADE_MS = 350;
const LAST_PINNED_LIST_KEY = 'kefinTweaks_lastPinnedList';
function getLastPinnedListKey() {
const serverId = typeof ApiClient !== 'undefined' && ApiClient?.serverId
? ApiClient.serverId()
: '';
const userId = typeof ApiClient !== 'undefined' && ApiClient?.getCurrentUserId
? ApiClient.getCurrentUserId()
: '';
return `${LAST_PINNED_LIST_KEY}_${serverId}_${userId}`;
}
function getLastPinnedList() {
try {
return localStorage.getItem(getLastPinnedListKey()) || '';
} catch {
return '';
}
}
function setLastPinnedList(listName) {
if (!listName) return;
try {
localStorage.setItem(getLastPinnedListKey(), listName);
} catch {
/* ignore quota / private mode */
}
}
function getUserSettings() {
return window.KefinTweaksConfig?.homeScreenConfig?.USER_HOME_SCREEN_SETTINGS || {};
}
function isEnabled() {
return getUserSettings().pinning !== false;
}
function getConfigApi() {
return window.KefinUserHomeScreenConfig;
}
async function loadHomeScreen() {
const api = getConfigApi();
if (!window.userHelper?.getUserDisplayPreferences) {
return api.createEmptyHomeScreen();
}
const { promise } = await window.userHelper.getUserDisplayPreferences();
const displayPrefs = await promise;
return api.parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs);
}
async function saveHomeScreen(homeScreen, { refreshHome = true } = {}) {
const ok = await getConfigApi().saveKefinTweaksHomeScreen(homeScreen);
if (ok && refreshHome && window.homeScreen3?.refreshHomeSections) {
await window.homeScreen3.refreshHomeSections();
} else if (ok && refreshHome && window.KefinTweaksToaster?.toast) {
window.KefinTweaksToaster.toast('Pinned — refresh home to see changes.');
}
return ok;
}
function cloneHomeScreen(homeScreen) {
const hs = homeScreen || {};
return {
sections: [...(hs.sections || [])],
pinnedLists: [...(hs.pinnedLists || [])],
pinnedParents: [...(hs.pinnedParents || [])]
};
}
function getSectionDisplayName(sectionEl, fallback) {
const title = sectionEl?.querySelector?.(
'.sectionTitle, .sectionTitle-cards, .spotlight-section-title'
)?.textContent?.trim();
return title || fallback || 'Pinned';
}
function getCardDisplayName(card, fallback) {
const fromAria = card?.querySelector?.('.cardImageContainer')?.getAttribute('aria-label')?.trim();
const fromTitle = card?.querySelector?.('.cardText-first, .cardText')?.textContent?.trim();
return fromAria || fromTitle || fallback || 'item';
}
function fadeOutSection(el) {
return new Promise((resolve) => {
if (!el) {
resolve({ parent: null, nextSibling: null });
return;
}
const parent = el.parentNode;
const nextSibling = el.nextSibling;
el.style.transition = `opacity ${UNPIN_FADE_MS}ms ease`;
el.style.opacity = '0';
setTimeout(() => resolve({ parent, nextSibling }), UNPIN_FADE_MS);
});
}
function fadeInSection(el) {
if (!el) return;
el.style.transition = `opacity ${UNPIN_FADE_MS}ms ease`;
el.style.opacity = '0';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.opacity = '1';
});
});
}
function showUnpinnedToast(name, onUndo) {
const toaster = window.KefinTweaksToaster;
if (!toaster?.toast) return null;
const handle = toaster.toast(
`Unpinned ${name}`,
'8',
true,
{
action: {
icon: 'undo',
label: 'Undo',
onClick: () => {
onUndo?.();
handle?.dismiss?.();
}
}
}
);
return handle;
}
async function undoUnpin() {
const stash = pendingUnpinStash;
pendingUnpinStash = null;
if (!stash?.restoreHomeScreen) {
window.homeScreen3?.refreshHomeSections?.();
return;
}
const ok = await saveHomeScreen(stash.restoreHomeScreen, { refreshHome: false });
if (!ok) {
window.homeScreen3?.refreshHomeSections?.();
return;
}
const el = stash.element;
const parentStillValid = stash.parent && document.contains(stash.parent);
if (el && parentStillValid) {
el.style.transition = '';
el.style.opacity = '0';
if (stash.nextSibling && stash.nextSibling.parentNode === stash.parent) {
stash.parent.insertBefore(el, stash.nextSibling);
} else {
stash.parent.appendChild(el);
}
if (stash.runtimeEntry) {
stash.runtimeEntry.element = el;
window.KefinHomeScreenSectionRuntime?.register?.(stash.runtimeEntry);
}
el.querySelectorAll?.('.unpin-item-button[disabled]')?.forEach((btn) => {
btn.disabled = false;
});
fadeInSection(el);
const itemsContainer = el.classList?.contains('card')
? el.closest?.('.itemsContainer')
: el.querySelector?.('.itemsContainer');
if (itemsContainer) updateAllReorderChevrons(itemsContainer);
return;
}
window.homeScreen3?.refreshHomeSections?.();
}
async function beginSectionUnpin({ sectionId, sectionEl, sectionName, restoreHomeScreen }) {
const runtimeEntry = window.KefinHomeScreenSectionRuntime?.get?.(sectionId) || null;
const { parent, nextSibling } = await fadeOutSection(sectionEl);
pendingUnpinStash = {
sectionId,
sectionName,
element: sectionEl,
parent,
nextSibling,
restoreHomeScreen,
runtimeEntry
};
sectionEl?.remove();
window.KefinHomeScreenSectionRuntime?.unregister?.(sectionId);
showUnpinnedToast(sectionName || 'section', () => {
void undoUnpin();
});
}
async function beginItemUnpin({ card, itemName, restoreHomeScreen }) {
const { parent, nextSibling } = await fadeOutSection(card);
pendingUnpinStash = {
sectionId: null,
sectionName: itemName,
element: card,
parent,
nextSibling,
restoreHomeScreen,
runtimeEntry: null
};
card?.remove();
showUnpinnedToast(itemName || 'item', () => {
void undoUnpin();
});
}
function findMoreMenuButton(element) {
return element?.closest?.('button[data-action="menu"], button.btnMoreCommands.detailButton') || null;
}
function clearCapturedPinTarget() {
capturedItemId = null;
capturedCard = null;
}
function isItemActionSheet(actionSheetContent) {
if (!actionSheetContent) return false;
return !!(
actionSheetContent.querySelector('button[data-id="multiSelect"]')
|| actionSheetContent.querySelector('button[data-id="resume"]')
|| actionSheetContent.querySelector('button[data-id="addtoplaylist"]')
);
}
function getDetailPageItemId(detailButtons) {
if (!detailButtons) return null;
const playstateBtn = detailButtons.querySelector(
'button.button-flat.btnPlaystate.detailButton.emby-button[data-id]'
) || detailButtons.querySelector('button.btnPlaystate.detailButton[data-id]');
return playstateBtn?.getAttribute('data-id') || playstateBtn?.dataset?.id || null;
}
function capturePinTargetFromTrigger(trigger) {
if (!trigger) {
clearCapturedPinTarget();
return false;
}
const menuBtn = findMoreMenuButton(trigger);
const contextEl = menuBtn || trigger;
const card = contextEl.closest?.('.card[data-id]');
if (card) {
capturedCard = card;
capturedItemId = card.getAttribute('data-id');
if (capturedItemId) return true;
clearCapturedPinTarget();
return false;
}
const detailButtons = contextEl.closest?.('.mainDetailButtons');
const detailItemId = getDetailPageItemId(detailButtons);
if (detailItemId) {
capturedCard = null;
capturedItemId = detailItemId;
return true;
}
clearCapturedPinTarget();
return false;
}
function captureFromEvent(e) {
if (e.target.closest('.actionSheetContent')) return;
capturePinTargetFromTrigger(e.target);
}
function getListNames(homeScreen) {
const names = (homeScreen.pinnedLists || [])
.map(str => getConfigApi().parsePinnedListString(str)?.name)
.filter(Boolean);
if (!names.includes(getConfigApi().DEFAULT_PINNED_LIST_NAME)) {
names.unshift(getConfigApi().DEFAULT_PINNED_LIST_NAME);
}
return [...new Set(names)];
}
function upsertPinnedList(homeScreen, listName, itemId) {
const api = getConfigApi();
const idx = (homeScreen.pinnedLists || []).findIndex(str => {
const parsed = api.parsePinnedListString(str);
return parsed?.name === listName;
});
if (idx >= 0) {
const parsed = api.parsePinnedListString(homeScreen.pinnedLists[idx]);
const ids = [...new Set([...(parsed?.ids || []), itemId])];
homeScreen.pinnedLists[idx] = api.serializePinnedList(listName, ids);
} else {
homeScreen.pinnedLists.push(api.serializePinnedList(listName, [itemId]));
}
return homeScreen;
}
function upsertPinnedParent(homeScreen, listName, parentId, parentType) {
const api = getConfigApi();
const idx = (homeScreen.pinnedParents || []).findIndex(str => {
const parsed = api.parsePinnedParentString(str);
return parsed?.parentId === parentId;
});
const entry = api.serializePinnedParent(listName, parentId, parentType);
if (idx >= 0) homeScreen.pinnedParents[idx] = entry;
else homeScreen.pinnedParents.push(entry);
return homeScreen;
}
function removeItemFromPinnedList(homeScreen, listName, itemId) {
const api = getConfigApi();
homeScreen.pinnedLists = (homeScreen.pinnedLists || []).map(str => {
const parsed = api.parsePinnedListString(str);
if (!parsed || parsed.name !== listName) return str;
const ids = parsed.ids.filter(id => id !== itemId);
return ids.length ? api.serializePinnedList(listName, ids) : null;
}).filter(Boolean);
return homeScreen;
}
function removePinnedParent(homeScreen, parentId) {
homeScreen.pinnedParents = (homeScreen.pinnedParents || []).filter(str => {
const parsed = getConfigApi().parsePinnedParentString(str);
return parsed?.parentId !== parentId;
});
return homeScreen;
}
function getHomeSectionsContainer() {
return document.querySelector('.homePage:not(.hide) #homeTab .sections');
}
function findSectionById(container, sectionId) {
if (!container || !sectionId) return null;
return container.querySelector(`[data-section-id="${CSS.escape(sectionId)}"]`);
}
function scrollToHomeSection(el) {
if (!el) return;
try {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (_) {
el.scrollIntoView(true);
}
}
function getPinnedDefaultOrder() {
const value = parseInt(getConfigApi()?.PINNED_DEFAULT_ORDER, 10);
return Number.isFinite(value) ? value : 5;
}
function readSectionOrder(el) {
const fromData = parseInt(el?.dataset?.order, 10);
if (Number.isFinite(fromData)) return fromData;
const fromStyle = parseInt(el?.style?.order, 10);
if (Number.isFinite(fromStyle)) return fromStyle;
return 0;
}
/**
* Shift live DOM/runtime section orders at or above slot by +1.
*/
function shiftSectionsAtOrAbove(container, slot) {
if (!container) return;
container.querySelectorAll('[data-section-id]').forEach((el) => {
const order = readSectionOrder(el);
if (order < slot) return;
const next = order + 1;
el.dataset.order = String(next);
el.style.order = String(next);
const runtime = window.KefinHomeScreenSectionRuntime?.get?.(el.dataset.sectionId);
if (runtime?.effectiveConfig) runtime.effectiveConfig.order = next;
});
}
/**
* Mutate homeScreen prefs: bump order for every section at/above slot, then
* assign the new pinned section order = slot. Uses DOM orders when a section
* has no saved pref yet.
*/
function prepareNewPinnedSectionOrder(homeScreen, container, newSectionId) {
const api = getConfigApi();
const slot = getPinnedDefaultOrder();
let hs = homeScreen || api.createEmptyHomeScreen();
if (!Array.isArray(hs.sections)) hs.sections = [];
const prefMap = new Map();
hs.sections.forEach((str) => {
const pref = api.parseSectionPrefString(str);
if (pref?.id) prefMap.set(pref.id, pref);
});
const sectionOrders = new Map();
prefMap.forEach((pref, id) => {
if (pref.order !== undefined) sectionOrders.set(id, pref.order);
});
container?.querySelectorAll?.('[data-section-id]')?.forEach((el) => {
const id = el.dataset.sectionId;
if (!id || id === newSectionId) return;
if (!sectionOrders.has(id)) sectionOrders.set(id, readSectionOrder(el));
});
sectionOrders.forEach((order, id) => {
if (id === newSectionId || order < slot) return;
const existing = prefMap.get(id);
const next = existing
? { ...existing, order: order + 1 }
: { id, enabled: true, order: order + 1 };
hs = api.upsertSectionPref(hs, next);
prefMap.set(id, next);
});
hs = api.upsertSectionPref(hs, {
id: newSectionId,
enabled: true,
order: slot
});
return hs;
}
function mountPinnedSection(container, config, items) {
if (!container || !config || !window.cardBuilder?.renderCards) return null;
if (!items?.length) return null;
const el = window.cardBuilder.renderCards(
items,
config.name,
config.viewMoreUrl || null,
true,
config.cardFormat || 'Poster',
null,
'Ascending'
);
if (!el) return null;
el.setAttribute('data-section-id', config.id);
el.dataset.order = String(config.order);
el.style.order = String(config.order);
container.appendChild(el);
fadeInSection(el);
window.cardBuilder.attachSectionControlButtons?.(config, el, items);
return el;
}
function updateRuntimeAfterListPin(sectionId, item, ids) {
const entry = window.KefinHomeScreenSectionRuntime?.get?.(sectionId);
if (!entry) return;
if (Array.isArray(entry.cachedItems) && item && !entry.cachedItems.some(i => i?.Id === item.Id)) {
entry.cachedItems = [...entry.cachedItems, item];
}
if (Array.isArray(ids) && Array.isArray(entry.cachedItems)) {
const byId = new Map(entry.cachedItems.filter(Boolean).map((i) => [String(i.Id), i]));
entry.cachedItems = ids.map((id) => byId.get(String(id))).filter(Boolean);
}
const query = entry.effectiveConfig?.queries?.[0];
if (query?.queryOptions && Array.isArray(ids)) {
query.queryOptions.Ids = [...ids];
}
}
async function applyPinItInline(item, listName, homeScreen) {
const container = getHomeSectionsContainer();
if (!container || !item?.Id) return;
const api = getConfigApi();
const sectionId = api.getPinnedListSectionId(listName);
const existing = findSectionById(container, sectionId);
if (existing) {
const itemsContainer = existing.querySelector('.itemsContainer');
if (itemsContainer && !itemsContainer.querySelector(`.card[data-id="${CSS.escape(item.Id)}"]`)) {
const cardFormat = existing.getAttribute('data-card-format')
|| window.KefinHomeScreenSectionRuntime?.get?.(sectionId)?.effectiveConfig?.cardFormat
|| 'Poster';
const card = window.cardBuilder?.buildCard?.(item, true, cardFormat);
if (card) {
itemsContainer.appendChild(card);
window.cardBuilder?.invalidateLastRowPadding?.(itemsContainer);
addUnpinButton(card);
addReorderButtons(card);
updateAllReorderChevrons(itemsContainer);
}
}
const listEntry = (homeScreen.pinnedLists || []).find(str => {
const parsed = api.parsePinnedListString(str);
return parsed && api.getPinnedListSectionId(parsed.name) === sectionId;
});
const parsed = api.parsePinnedListString(listEntry);
updateRuntimeAfterListPin(sectionId, item, parsed?.ids);
scrollToHomeSection(existing);
return;
}
const configs = api.buildPinnedSectionConfigs(homeScreen) || [];
const config = configs.find(c => c.id === sectionId);
if (!config) return;
const slot = getPinnedDefaultOrder();
shiftSectionsAtOrAbove(container, slot);
config.order = slot;
const el = mountPinnedSection(container, config, [item]);
scrollToHomeSection(el);
}
async function applyPinChildrenInline(item, homeScreen) {
const container = getHomeSectionsContainer();
if (!container || !item?.Id) return;
const api = getConfigApi();
const sectionId = api.getPinnedParentSectionId(item.Id);
const existing = findSectionById(container, sectionId);
if (existing) {
scrollToHomeSection(existing);
return;
}
const configs = api.buildPinnedSectionConfigs(homeScreen) || [];
const config = configs.find(c => c.id === sectionId);
if (!config) return;
const slot = getPinnedDefaultOrder();
shiftSectionsAtOrAbove(container, slot);
config.order = slot;
const parent = {
name: (item.Name || '').trim() || config.name,
parentId: item.Id,
parentType: item.Type || null
};
config.viewMoreUrl = api.buildPinnedParentViewMoreUrl?.(parent, ApiClient.serverId())
|| `#/details?id=${encodeURIComponent(item.Id)}&serverId=${encodeURIComponent(ApiClient.serverId())}`;
const queryOptions = api.buildPinnedParentQueryOptions?.(parent) || {
ParentId: item.Id,
Limit: 16,
SortBy: 'Random'
};
let items = [];
try {
const result = await ApiClient.getItems(ApiClient.getCurrentUserId(), queryOptions);
items = result?.Items || [];
} catch (err) {
WARN('Failed to load pinned parent children:', err);
return;
}
if (!items.length) return;
const el = mountPinnedSection(container, config, items);
scrollToHomeSection(el);
}
function openPinItModal(item) {
const modalId = 'kefin-pin-it-modal';
loadHomeScreen().then(homeScreen => {
const listNames = getListNames(homeScreen);
const lastList = getLastPinnedList();
const defaultList = listNames.includes(lastList)
? lastList
: (listNames[0] || getConfigApi().DEFAULT_PINNED_LIST_NAME);
const options = listNames.map((name) => {
const selected = name === defaultList ? ' selected' : '';
return ``;
}).join('')
+ '';
const content = `
`;
window.ModalSystem.create({
id: modalId,
content,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modal) => {
const select = modal.dialogContent.querySelector('#kefin-pin-list-select');
const newWrap = modal.dialogContent.querySelector('#kefin-pin-new-list-wrap');
select?.addEventListener('change', () => {
if (newWrap) newWrap.style.display = select.value === '__new__' ? 'block' : 'none';
});
modal.dialogContent.querySelector('#kefin-pin-it-confirm')?.addEventListener('click', async () => {
let listName = select?.value || getConfigApi().DEFAULT_PINNED_LIST_NAME;
if (listName === '__new__') {
listName = modal.dialogContent.querySelector('#kefin-pin-new-list-name')?.value?.trim();
if (!listName) return;
}
const api = getConfigApi();
const sectionId = api.getPinnedListSectionId(listName);
const container = getHomeSectionsContainer();
let hs = await loadHomeScreen();
const listAlreadyExists = (hs.pinnedLists || []).some((str) => {
const parsed = api.parsePinnedListString(str);
return parsed
&& api.getPinnedListSectionId(parsed.name) === sectionId
&& parsed.ids.length > 0;
});
const isNewSection = !findSectionById(container, sectionId) && !listAlreadyExists;
hs = upsertPinnedList(hs, listName, item.Id);
if (isNewSection) {
hs = prepareNewPinnedSectionOrder(hs, container, sectionId);
}
const ok = await saveHomeScreen(hs, { refreshHome: false });
if (ok) {
setLastPinnedList(listName);
window.ModalSystem.close(modalId);
if (window.KefinTweaksToaster?.toast) {
window.KefinTweaksToaster.toast(`Pinned to ${listName}`);
}
try {
await applyPinItInline(item, listName, hs);
} catch (err) {
WARN('Inline pin list update failed:', err);
}
}
});
}
});
});
}
async function pinChildrenImmediate(item, modalIdToClose = null) {
if (!item?.Id) return false;
if (modalIdToClose) window.ModalSystem.close(modalIdToClose);
const api = getConfigApi();
const name = (item.Name || '').trim() || 'Pinned Items';
const sectionId = api.getPinnedParentSectionId(item.Id);
const container = getHomeSectionsContainer();
let hs = await loadHomeScreen();
const parentAlreadyExists = (hs.pinnedParents || []).some((str) => {
const parsed = api.parsePinnedParentString(str);
return parsed?.parentId === item.Id;
});
const isNewSection = !findSectionById(container, sectionId) && !parentAlreadyExists;
hs = upsertPinnedParent(hs, name, item.Id, item.Type);
if (isNewSection) {
hs = prepareNewPinnedSectionOrder(hs, container, sectionId);
}
const ok = await saveHomeScreen(hs, { refreshHome: false });
if (ok && window.KefinTweaksToaster?.toast) {
window.KefinTweaksToaster.toast(`Pinned children as "${name}"`);
}
if (ok) {
try {
await applyPinChildrenInline(item, hs);
} catch (err) {
WARN('Inline pin children update failed:', err);
}
}
return ok;
}
function openPinChoiceModal(item) {
const modalId = 'kefin-pin-choice-modal';
const content = `
`;
window.ModalSystem.create({
id: modalId,
content,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modal) => {
modal.dialogContent.querySelector('#kefin-pin-choice-it')?.addEventListener('click', () => {
window.ModalSystem.close(modalId);
openPinItModal(item);
});
modal.dialogContent.querySelector('#kefin-pin-choice-children')?.addEventListener('click', async () => {
await pinChildrenImmediate(item, modalId);
});
}
});
}
async function handlePinAction() {
if (!capturedItemId || !window.ApiClient?.getItem) return;
try {
const item = await window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), capturedItemId);
if (!item) return;
if (PARENT_PIN_TYPES.has(item.Type)) {
openPinChoiceModal(item);
} else {
openPinItModal(item);
}
} catch (e) {
WARN('Failed to load item for pin:', e);
}
}
function insertPinActionSheetItem(actionSheetContent, pinButton) {
const scroller = actionSheetContent.querySelector('.actionSheetScroller') || actionSheetContent;
const dividers = scroller.querySelectorAll('.actionsheetDivider, .actionSheetDivider');
if (dividers.length >= 2) {
scroller.insertBefore(pinButton, dividers[1]);
return;
}
if (dividers.length >= 1) {
scroller.insertBefore(pinButton, dividers[0]);
return;
}
const menuButtons = scroller.querySelectorAll('button.actionSheetMenuItem');
if (menuButtons.length > 0) {
const lastButton = menuButtons[menuButtons.length - 1];
if (lastButton.nextSibling) {
scroller.insertBefore(pinButton, lastButton.nextSibling);
} else {
scroller.appendChild(pinButton);
}
return;
}
scroller.appendChild(pinButton);
}
function offsetActionSheetForPinButton(actionSheetContent, pinButton) {
const dialog = actionSheetContent.closest('.focuscontainer.dialog.actionSheet')
|| actionSheetContent.closest('.focuscontainer.dialog');
if (!dialog || !pinButton) return;
const clampToViewport = () => {
const margin = 8;
const maxBottom = window.innerHeight - margin;
const availableHeight = Math.max(0, window.innerHeight - (margin * 2));
const scroller = actionSheetContent.querySelector('.actionSheetScroller') || actionSheetContent;
let rect = dialog.getBoundingClientRect();
// If taller than the viewport, scroll internally so top/bottom stay on-screen
if (rect.height > availableHeight && availableHeight > 0) {
const chrome = Math.max(0, rect.height - scroller.clientHeight);
const scrollerMax = Math.max(40, availableHeight - chrome);
scroller.style.maxHeight = `${scrollerMax}px`;
scroller.style.overflowY = 'auto';
rect = dialog.getBoundingClientRect();
}
let top = parseFloat(dialog.style.top);
if (Number.isNaN(top)) top = rect.top;
// Prefer keeping the bottom in view after the pin row grew the menu
if (rect.bottom > maxBottom) {
top -= (rect.bottom - maxBottom);
}
if (top < margin) {
top = margin;
}
dialog.style.top = `${top}px`;
// Re-check after applying top (height may still overflow if max-height wasn't needed earlier)
rect = dialog.getBoundingClientRect();
if (rect.bottom > maxBottom) {
top -= (rect.bottom - maxBottom);
if (top < margin) top = margin;
dialog.style.top = `${top}px`;
}
if (rect.top < margin || dialog.getBoundingClientRect().top < margin) {
dialog.style.top = `${margin}px`;
}
};
// Double rAF so pin button height is included in layout before measuring
requestAnimationFrame(() => {
requestAnimationFrame(clampToViewport);
});
}
function closeOpenActionSheet(fromEl) {
const dialog = fromEl?.closest?.('.focuscontainer.dialog')
|| fromEl?.closest?.('.dialog');
if (!dialog) return;
window.Dashboard?.dialogHelper?.close?.(dialog);
}
function injectPinActionSheetItem(actionSheetContent) {
if (!isEnabled()) return;
if (!isItemActionSheet(actionSheetContent)) {
clearCapturedPinTarget();
return;
}
if (!capturedItemId) return;
if (actionSheetContent.querySelector('[data-kefin-pin-action]')) return;
const pinButton = document.createElement('button');
pinButton.setAttribute('is', 'emby-button');
pinButton.type = 'button';
pinButton.className = 'listItem listItem-button actionSheetMenuItem emby-button';
pinButton.setAttribute('data-kefin-pin-action', 'true');
pinButton.innerHTML = `
`;
pinButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
closeOpenActionSheet(pinButton);
handlePinAction();
});
insertPinActionSheetItem(actionSheetContent, pinButton);
offsetActionSheetForPinButton(actionSheetContent, pinButton);
}
function setupContextMenuCapture() {
document.addEventListener('mousedown', (e) => {
const menuBtn = findMoreMenuButton(e.target);
if (menuBtn) {
capturePinTargetFromTrigger(menuBtn);
} else if (!e.target.closest?.('.actionSheetContent')) {
// Opening a non-item menu should not reuse a previous item id
clearCapturedPinTarget();
}
}, true);
document.addEventListener('contextmenu', captureFromEvent, true);
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const sheets = node.matches?.('.actionSheetContent')
? [node]
: [...(node.querySelectorAll?.('.actionSheetContent') || [])];
sheets.forEach(injectPinActionSheetItem);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
function isPinnedSectionId(sectionId) {
return sectionId?.startsWith('pinned-list-') || sectionId?.startsWith('pinned-parent-');
}
function isPinnedListSectionId(sectionId) {
return sectionId?.startsWith('pinned-list-');
}
function getRealPinnedCards(itemsContainer) {
if (!itemsContainer) return [];
return [...itemsContainer.querySelectorAll(':scope > .card[data-id]:not(.card-layout-dummy)')];
}
function updateReorderChevronState(card) {
if (!card) return;
const itemsContainer = card.closest('.itemsContainer');
const cards = getRealPinnedCards(itemsContainer);
const idx = cards.indexOf(card);
const left = card.querySelector('.pin-reorder-left');
const right = card.querySelector('.pin-reorder-right');
if (left) {
const hide = idx <= 0;
left.hidden = hide;
left.disabled = hide;
left.setAttribute('aria-hidden', hide ? 'true' : 'false');
}
if (right) {
const hide = idx < 0 || idx >= cards.length - 1;
right.hidden = hide;
right.disabled = hide;
right.setAttribute('aria-hidden', hide ? 'true' : 'false');
}
}
function updateAllReorderChevrons(itemsContainer) {
getRealPinnedCards(itemsContainer).forEach(updateReorderChevronState);
}
async function persistPinnedListOrder(sectionEl) {
const sectionId = sectionEl?.dataset?.sectionId;
if (!isPinnedListSectionId(sectionId)) return false;
const itemsContainer = sectionEl.querySelector('.itemsContainer');
const orderedIds = getRealPinnedCards(itemsContainer)
.map((c) => c.getAttribute('data-id'))
.filter(Boolean);
if (!orderedIds.length) return false;
const api = getConfigApi();
let hs = await loadHomeScreen();
const idx = (hs.pinnedLists || []).findIndex((str) => {
const parsed = api.parsePinnedListString(str);
return parsed && api.getPinnedListSectionId(parsed.name) === sectionId;
});
if (idx < 0) return false;
const parsed = api.parsePinnedListString(hs.pinnedLists[idx]);
hs.pinnedLists[idx] = api.serializePinnedList(parsed.name, orderedIds);
const ok = await saveHomeScreen(hs, { refreshHome: false });
if (ok) {
updateRuntimeAfterListPin(sectionId, null, orderedIds);
window.cardBuilder?.invalidateLastRowPadding?.(itemsContainer);
}
return ok;
}
async function movePinnedCard(card, direction) {
const sectionEl = card?.closest?.('[data-section-id]');
const itemsContainer = card?.closest?.('.itemsContainer');
if (!sectionEl || !itemsContainer) return;
const cards = getRealPinnedCards(itemsContainer);
const idx = cards.indexOf(card);
const swapIdx = direction === 'left' ? idx - 1 : idx + 1;
if (idx < 0 || swapIdx < 0 || swapIdx >= cards.length) return;
const other = cards[swapIdx];
if (direction === 'left') {
itemsContainer.insertBefore(card, other);
} else {
itemsContainer.insertBefore(card, other.nextSibling);
}
updateAllReorderChevrons(itemsContainer);
try {
await persistPinnedListOrder(sectionEl);
} catch (err) {
WARN('Pinned list reorder save failed:', err);
}
}
function addReorderButtons(cardOrOverlay) {
if (!isEnabled()) return;
const card = cardOrOverlay?.classList?.contains('card')
? cardOrOverlay
: cardOrOverlay?.closest?.('.card');
if (!card || card.classList.contains('card-layout-dummy')) return;
if (card.querySelector(':scope > .pin-reorder-left')) return;
const sectionEl = card.closest('[data-section-id]');
const sectionId = sectionEl?.dataset?.sectionId;
if (!isPinnedListSectionId(sectionId)) return;
if (!card.getAttribute('data-id')) return;
const makeBtn = (cls, iconName, title, direction) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `pin-reorder-btn paper-icon-button-light ${cls}`;
btn.setAttribute('data-action', 'none');
btn.title = title;
btn.setAttribute('aria-label', title);
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.textContent = iconName;
icon.setAttribute('aria-hidden', 'true');
btn.appendChild(icon);
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (btn.disabled || btn.hidden) return;
void movePinnedCard(card, direction);
});
return btn;
};
const leftBtn = makeBtn('pin-reorder-left', 'chevron_left', 'Move left', 'left');
const rightBtn = makeBtn('pin-reorder-right', 'chevron_right', 'Move right', 'right');
// Attach to the card host so edges stay correct with large border styles
card.appendChild(leftBtn);
card.appendChild(rightBtn);
updateReorderChevronState(card);
}
function addUnpinButton(cardOrOverlay) {
if (!isEnabled()) return;
const card = cardOrOverlay?.classList?.contains('card')
? cardOrOverlay
: cardOrOverlay?.closest?.('.card');
if (!card || card.classList.contains('card-layout-dummy')) return;
if (card.querySelector(':scope > .unpin-item-button')) return;
const sectionEl = card.closest('[data-section-id]');
const sectionId = sectionEl?.dataset?.sectionId;
if (!isPinnedSectionId(sectionId)) return;
if (sectionId.startsWith('pinned-parent-')) return;
const itemId = card.getAttribute('data-id');
if (!itemId) return;
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'unpin-item-button paper-icon-button-light';
removeButton.setAttribute('data-action', 'none');
removeButton.title = 'Unpin from Home Screen';
removeButton.setAttribute('aria-label', 'Unpin from Home Screen');
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.textContent = 'push_pin';
icon.setAttribute('aria-hidden', 'true');
removeButton.appendChild(icon);
removeButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
removeButton.disabled = true;
try {
const api = getConfigApi();
let hs = await loadHomeScreen();
const restoreHomeScreen = cloneHomeScreen(hs);
const realCards = sectionEl
? [...sectionEl.querySelectorAll('.itemsContainer > .card[data-id]:not(.card-layout-dummy)')]
: [];
const isLastInSection = realCards.length <= 1;
let listName = null;
if (sectionId.startsWith('pinned-list-')) {
const listEntry = (hs.pinnedLists || []).find(str => {
const parsed = api.parsePinnedListString(str);
return parsed && api.getPinnedListSectionId(parsed.name) === sectionId;
});
const parsed = api.parsePinnedListString(listEntry);
if (parsed) {
listName = parsed.name;
hs = removeItemFromPinnedList(hs, parsed.name, itemId);
}
} else if (sectionId.startsWith('pinned-parent-')) {
const parentId = sectionId.replace('pinned-parent-', '');
hs = removePinnedParent(hs, parentId);
}
const ok = await saveHomeScreen(hs, { refreshHome: false });
if (!ok) {
removeButton.disabled = false;
return;
}
if (isLastInSection && sectionEl) {
const sectionName = getSectionDisplayName(sectionEl, listName);
await beginSectionUnpin({
sectionId,
sectionEl,
sectionName,
restoreHomeScreen
});
} else {
const itemName = getCardDisplayName(card, 'item');
await beginItemUnpin({
card,
itemName,
restoreHomeScreen
});
const itemsContainer = sectionEl?.querySelector('.itemsContainer');
if (itemsContainer) updateAllReorderChevrons(itemsContainer);
}
} catch (err) {
WARN('Unpin failed:', err);
removeButton.disabled = false;
}
});
card.appendChild(removeButton);
}
function setupUnpinObserver() {
const style = document.createElement('style');
style.textContent = `
.unpin-item-button {
position: absolute !important;
top: 8px !important;
right: 8px !important;
z-index: 20 !important;
width: 32px !important;
height: 32px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
opacity: 0 !important;
transform: scale(0.8) !important;
padding: 0 !important;
pointer-events: auto !important;
}
.card:hover > .unpin-item-button,
.unpin-item-button:focus-visible {
opacity: 1 !important;
transform: scale(1) !important;
}
.unpin-item-button:hover {
background: rgba(0, 0, 0, 0.15) !important;
transform: scale(1.1) !important;
}
.unpin-item-button .material-icons {
font-size: 18px !important;
}
.pin-reorder-btn {
position: absolute !important;
top: 50% !important;
z-index: 20 !important;
margin-top: -14px !important;
width: 28px !important;
height: 28px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease !important;
opacity: 0 !important;
transform: scale(0.85) !important;
padding: 0 !important;
pointer-events: auto !important;
}
[data-section-id^="pinned-list-"] .card[data-id]:not(.card-layout-dummy) {
position: relative !important;
}
.pin-reorder-left {
left: 6px !important;
}
.pin-reorder-right {
right: 6px !important;
}
.card:hover > .pin-reorder-btn:not([hidden]),
.pin-reorder-btn:focus-visible:not([hidden]) {
opacity: 0.92 !important;
transform: scale(1) !important;
}
.pin-reorder-btn:hover:not([hidden]):not(:disabled) {
background: rgba(0, 0, 0, 0.15) !important;
opacity: 1 !important;
transform: scale(1.08) !important;
}
.pin-reorder-btn[hidden] {
display: none !important;
}
.pin-reorder-btn .material-icons {
font-size: 20px !important;
line-height: 1 !important;
}
[data-modal-id="kefin-pin-choice-modal"] .formDialog {
min-width: min(92vw, 28rem);
}
[data-modal-id="kefin-pin-it-modal"] .formDialog {
min-width: min(92vw, 18rem);
}
[data-modal-id="kefin-pin-it-modal"] .kefin-pin-it-actions {
display: flex;
justify-content: flex-end;
gap: 0.5em;
margin-top: 1em;
}
[data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-grid {
display: flex;
flex-direction: column;
gap: 1em;
}
[data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-card {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
width: 100%;
padding: 1.75em 1.75em 2.25em;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
font-family: inherit;
color: inherit;
}
[data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-card:hover {
border-color: rgba(0, 164, 220, 0.5);
background: rgba(0, 164, 220, 0.08);
}
[data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-title {
margin: 0 0 0.5em;
font-size: 1.125rem;
font-weight: 600;
line-height: 1.25;
}
[data-modal-id="kefin-pin-choice-modal"] .kefin-pin-choice-desc {
margin: 0;
font-size: 0.9375rem;
line-height: 1.6;
}
`;
document.head.appendChild(style);
const processOverlay = (overlayContainer) => {
if (overlayContainer?.querySelector?.('.cardOverlayButton-br') || overlayContainer?.classList?.contains('cardOverlayContainer')) {
const card = overlayContainer.closest?.('.card');
if (card) {
addUnpinButton(card);
addReorderButtons(card);
}
}
};
const processCard = (card) => {
if (!card?.classList?.contains('card')) return;
addUnpinButton(card);
addReorderButtons(card);
};
document.querySelectorAll('.cardOverlayContainer').forEach(processOverlay);
document.querySelectorAll('[data-section-id^="pinned-list-"] .card[data-id]:not(.card-layout-dummy)').forEach(processCard);
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.classList?.contains('cardOverlayContainer')) processOverlay(node);
node.querySelectorAll?.('.cardOverlayContainer').forEach(processOverlay);
if (node.classList?.contains('card')) processCard(node);
node.querySelectorAll?.('.card[data-id]').forEach(processCard);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
function isPinnedParentSection(sectionId) {
return sectionId?.startsWith('pinned-parent-');
}
async function unpinParentSection(sectionId, sectionElement) {
if (!isPinnedParentSection(sectionId)) return false;
const parentId = sectionId.replace('pinned-parent-', '');
try {
let hs = await loadHomeScreen();
const restoreHomeScreen = cloneHomeScreen(hs);
const parentEntry = (hs.pinnedParents || []).find((str) => {
const parsed = getConfigApi().parsePinnedParentString(str);
return parsed?.parentId === parentId;
});
const parsedParent = getConfigApi().parsePinnedParentString(parentEntry);
const sectionName = getSectionDisplayName(sectionElement, parsedParent?.name);
hs = removePinnedParent(hs, parentId);
const ok = await saveHomeScreen(hs, { refreshHome: false });
if (!ok) return false;
if (sectionElement) {
await beginSectionUnpin({
sectionId,
sectionEl: sectionElement,
sectionName,
restoreHomeScreen
});
} else {
window.KefinHomeScreenSectionRuntime?.unregister?.(sectionId);
}
return true;
} catch (err) {
WARN('Unpin parent failed:', err);
return false;
}
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function escapeAttr(str) {
return escapeHtml(str).replace(/'/g, ''');
}
if (isEnabled()) {
setupContextMenuCapture();
setupUnpinObserver();
}
window.KefinHomeScreenPin = {
isEnabled,
isPinnedParentSection,
unpinParentSection
};
LOG('Module loaded');
})();