mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 07:42:47 +01:00
2843 lines
118 KiB
JavaScript
2843 lines
118 KiB
JavaScript
// KefinTweaks Home Screen v3 (Config-Driven & Streamlined)
|
||
(function() {
|
||
'use strict';
|
||
|
||
const LOG = (...args) => console.log('[KefinTweaks HomeScreen3]', ...args);
|
||
const WARN = (...args) => console.warn('[KefinTweaks HomeScreen3]', ...args);
|
||
const ERR = (...args) => console.error('[KefinTweaks HomeScreen3]', ...args);
|
||
|
||
// Configuration
|
||
const PRE_FETCH_DISCOVERY_DATA = false; // If true, fetches first discovery group immediately. If false, waits for scroll/click.
|
||
/** Max sections resolved/rendered per discovery group (infinite/chevron pages). */
|
||
const DISCOVERY_SECTIONS_PER_GROUP = 100;
|
||
/** IntersectionObserver rootMargin for discovery section progressive enhance (batches + any discovery enhance). */
|
||
const DISCOVERY_ENHANCE_ROOT_MARGIN = '30% 0px 30% 0px';
|
||
|
||
// Dependencies
|
||
const ApiHelper = window.apiHelper;
|
||
const Config = window.KefinHomeConfig2; // Updated to use Config2
|
||
const PeopleCache = window.PeopleCache;
|
||
const StudiosCache = window.StudiosCache;
|
||
|
||
if (!Config) {
|
||
ERR('Configuration not found! Ensure homeScreenConfig2.js is loaded.');
|
||
return;
|
||
}
|
||
|
||
/**
|
||
* Hide native Jellyfin .sectionN rows only once homeScreen3 has successfully
|
||
* started (so clients that cannot parse this file keep the native home).
|
||
* Persist for the session via a single injected stylesheet.
|
||
*/
|
||
function ensureNativeHomeSectionsHidden() {
|
||
const styleId = 'kefin-hide-native-homesections';
|
||
if (document.getElementById(styleId)) return;
|
||
const style = document.createElement('style');
|
||
style.id = styleId;
|
||
style.textContent = [
|
||
'.section0:not([data-page]),',
|
||
'.section1:not([data-page]),',
|
||
'.section2:not([data-page]),',
|
||
'.section3:not([data-page]),',
|
||
'.section4:not([data-page]),',
|
||
'.section5:not([data-page]),',
|
||
'.section6:not([data-page]),',
|
||
'.section7:not([data-page]),',
|
||
'.section8:not([data-page]),',
|
||
'.section9:not([data-page]) {',
|
||
' display: none !important;',
|
||
'}'
|
||
].join('\n');
|
||
(document.head || document.documentElement).appendChild(style);
|
||
}
|
||
ensureNativeHomeSectionsHidden();
|
||
|
||
/**
|
||
* Flatten section groups into a flat array of sections
|
||
* @param {Array} groups - Array of HomeScreenSectionGroup objects
|
||
* @returns {Array} Flat array of all sections
|
||
*/
|
||
function flattenSectionGroups(groups) {
|
||
if (!Array.isArray(groups)) return [];
|
||
const flattened = [];
|
||
groups.forEach(group => {
|
||
if (group && Array.isArray(group.sections)) {
|
||
flattened.push(...group.sections);
|
||
}
|
||
});
|
||
return flattened;
|
||
}
|
||
|
||
// State
|
||
const state = {
|
||
renderedSections: new Set(),
|
||
renderedDiscoveryIds: {
|
||
genres: new Set(),
|
||
genresSeries: new Set(),
|
||
seriesStudios: new Set(),
|
||
seriesStudiosTopRated: new Set(),
|
||
collections: new Set(),
|
||
people: new Set(),
|
||
similar: new Set(),
|
||
watchlist: new Set(),
|
||
customDiscoverySections: new Set()
|
||
},
|
||
currentDiscoveryGenre: null,
|
||
currentDiscoveryStudio: null,
|
||
isPeopleCacheComplete: false,
|
||
discoveryGroupIndex: 0,
|
||
isRenderingDiscovery: false,
|
||
discoveryEnabled: true,
|
||
discoveryBufferPromise: null,
|
||
discoveryBuffer: [],
|
||
/** Unresolved section configs left after capping a discovery group render. */
|
||
discoveryGroupRemainder: [],
|
||
discoverySectionOrder: 100000000,
|
||
discoverySectionsRemain: true,
|
||
ensuringDiscoveryBuffer: false,
|
||
discoveryNeedsMoreScroll: false,
|
||
discoveryMode: null, // 'infinite' | 'chevron'
|
||
infiniteScrollHandler: null,
|
||
discoveryWheelHandler: null,
|
||
discoveryTouchStartHandler: null,
|
||
discoveryTouchMoveHandler: null,
|
||
discoveryScrollDebounceTimer: null,
|
||
userDisplayPreferences: null,
|
||
firstDiscoveryBuffer: null,
|
||
getDisplayPrefernces: null,
|
||
kefinNextUp: false,
|
||
kefinContinueWatching: false,
|
||
kefinLatestMedia: false,
|
||
jellyfinOrders: {},
|
||
homePaintedEventFired: false,
|
||
isRenderingHome: false
|
||
};
|
||
|
||
async function fetchDisplayPreferences() {
|
||
if (!window.userHelper?.getUserDisplayPreferences) {
|
||
WARN('userHelper.getUserDisplayPreferences unavailable');
|
||
return null;
|
||
}
|
||
const { promise, cached } = await window.userHelper.getUserDisplayPreferences({ cacheOnly: true });
|
||
const prefs = cached || await promise;
|
||
state.userDisplayPreferences = prefs;
|
||
return prefs;
|
||
}
|
||
state.getDisplayPrefernces = fetchDisplayPreferences;
|
||
|
||
// Cache used to support re-rendering our sections after Jellyfin overwrites the container
|
||
const sectionCache = {
|
||
renderedSections: [], // Array of section configs + results
|
||
fragmentCache: null, // Reserved for future use if we cache DOM fragments
|
||
isInitialRender: true, // Track if this is first render for the current view
|
||
jellyfinHasRendered: false, // Track if Jellyfin has rendered native home sections
|
||
pendingJellyfinSectionObservers: []
|
||
};
|
||
|
||
// Create loading indicator as last child of the sections container (keeps height during load; TV Down focus)
|
||
function createDiscoveryLoadingIndicator(container) {
|
||
// Prefer the provided container, but fall back to the standard sections container
|
||
if (!container) {
|
||
container = document.querySelector('.homePage:not(.hide) #homeTab .sections')
|
||
|| document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
|
||
}
|
||
|
||
let loadingEl = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator')
|
||
|| document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
|
||
|
||
// Migrate legacy div indicator to a real button (Jellyfin TV spatial nav targets buttons)
|
||
if (loadingEl && loadingEl.tagName !== 'BUTTON') {
|
||
const replacement = document.createElement('button');
|
||
replacement.type = 'button';
|
||
replacement.id = loadingEl.id;
|
||
replacement.className = loadingEl.className;
|
||
replacement.innerHTML = loadingEl.innerHTML || '<div class="spinner"></div>';
|
||
loadingEl.replaceWith(replacement);
|
||
loadingEl = replacement;
|
||
}
|
||
|
||
if (loadingEl) {
|
||
if (container) {
|
||
container.appendChild(loadingEl);
|
||
}
|
||
bindDiscoveryLoadMoreControl(loadingEl);
|
||
syncDiscoveryLoadMoreFocus(loadingEl);
|
||
return loadingEl;
|
||
}
|
||
|
||
if (!container) {
|
||
WARN('Discovery loading indicator: sections container not found');
|
||
return null;
|
||
}
|
||
|
||
loadingEl = document.createElement('button');
|
||
loadingEl.type = 'button';
|
||
loadingEl.className = 'discovery-loading-indicator';
|
||
loadingEl.id = 'discovery-loading-indicator';
|
||
loadingEl.innerHTML = `
|
||
<div class="spinner"></div>
|
||
`;
|
||
|
||
container.appendChild(loadingEl);
|
||
LOG('Created discovery loading indicator as last child of sections container');
|
||
bindDiscoveryLoadMoreControl(loadingEl);
|
||
syncDiscoveryLoadMoreFocus(loadingEl);
|
||
return loadingEl;
|
||
}
|
||
|
||
function removeDiscoveryLoadingIndicator() {
|
||
document.querySelectorAll('#discovery-loading-indicator').forEach((el) => el.remove());
|
||
}
|
||
|
||
function isDiscoveryLoadMoreInteractive() {
|
||
if (state.discoveryMode !== 'chevron') return false;
|
||
if (state.discoverySectionsRemain === false) return false;
|
||
if (state.isRenderingDiscovery) return false;
|
||
const page = getDiscoveryHomePageEl();
|
||
if (page?.dataset?.discoveryExhausted === 'true') return false;
|
||
const indicator = document.querySelector('#discovery-loading-indicator');
|
||
if (indicator?.classList.contains('show')) return false;
|
||
return true;
|
||
}
|
||
|
||
function syncDiscoveryLoadMoreFocus(indicator) {
|
||
const el = indicator
|
||
|| document.querySelector('.homePage:not(.hide) #discovery-loading-indicator')
|
||
|| document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator')
|
||
|| document.querySelector('#discovery-loading-indicator');
|
||
if (!el) return;
|
||
|
||
const tv = document.documentElement.classList.contains('layout-tv');
|
||
const interactive = isDiscoveryLoadMoreInteractive();
|
||
|
||
if (tv && interactive) {
|
||
el.disabled = false;
|
||
el.removeAttribute('disabled');
|
||
el.setAttribute('tabindex', '0');
|
||
el.setAttribute('aria-label', 'Discover More');
|
||
// emby-button: Jellyfin TV spatial nav treats plain controls as Tab-only otherwise
|
||
el.classList.add('emby-button');
|
||
el.classList.add('show-focus');
|
||
el.classList.add('discovery-load-more-focusable');
|
||
} else {
|
||
el.setAttribute('tabindex', '-1');
|
||
el.classList.remove('emby-button');
|
||
el.classList.remove('show-focus');
|
||
el.classList.remove('discovery-load-more-focusable');
|
||
if (state.discoverySectionsRemain === false || getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') {
|
||
el.setAttribute('aria-label', 'No More Content to Discover');
|
||
} else {
|
||
el.removeAttribute('aria-label');
|
||
}
|
||
}
|
||
}
|
||
|
||
function activateDiscoveryLoadMore() {
|
||
if (!isDiscoveryLoadMoreInteractive()) return false;
|
||
if (!isDiscoveryHomeContext()) return false;
|
||
LOG('Discover More activated; loading next discovery group...');
|
||
renderNextDiscoveryGroup();
|
||
return true;
|
||
}
|
||
|
||
function bindDiscoveryLoadMoreControl(indicator) {
|
||
if (!indicator || indicator.dataset.loadMoreBound === '1') return;
|
||
indicator.dataset.loadMoreBound = '1';
|
||
|
||
indicator.addEventListener('click', (e) => {
|
||
if (!isDiscoveryLoadMoreInteractive()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
activateDiscoveryLoadMore();
|
||
});
|
||
|
||
indicator.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
if (!isDiscoveryLoadMoreInteractive()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
activateDiscoveryLoadMore();
|
||
});
|
||
}
|
||
|
||
function getDiscoveryHomePageEl() {
|
||
return document.querySelector('#indexPage')
|
||
|| document.querySelector('.homePage:not(.hide)')
|
||
|| document.querySelector('.libraryPage:not(.hide)');
|
||
}
|
||
|
||
function setDiscoveryPageAttrs({ ready = false, infinite = false, exhausted = false } = {}) {
|
||
const page = getDiscoveryHomePageEl();
|
||
if (!page) return;
|
||
if (ready) page.dataset.discoveryReady = 'true';
|
||
else delete page.dataset.discoveryReady;
|
||
if (infinite) page.dataset.infiniteScroll = 'true';
|
||
else delete page.dataset.infiniteScroll;
|
||
if (exhausted) page.dataset.discoveryExhausted = 'true';
|
||
else delete page.dataset.discoveryExhausted;
|
||
syncDiscoveryLoadMoreFocus();
|
||
}
|
||
|
||
function showDiscoveryLoadingIndicator() {
|
||
const loadingIndicator = createDiscoveryLoadingIndicator(
|
||
document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer')
|
||
);
|
||
if (!loadingIndicator) return;
|
||
loadingIndicator.classList.add('show');
|
||
loadingIndicator.style.visibility = 'visible';
|
||
loadingIndicator.style.display = 'flex';
|
||
syncDiscoveryLoadMoreFocus(loadingIndicator);
|
||
}
|
||
|
||
function hideDiscoveryLoadingIndicator() {
|
||
const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
|
||
if (!loadingIndicator) return;
|
||
loadingIndicator.classList.remove('show');
|
||
// Chevron / exhausted idle UI is shown via data-discovery-ready CSS
|
||
if (state.discoveryMode === 'chevron' || state.discoverySectionsRemain === false) {
|
||
loadingIndicator.style.visibility = '';
|
||
loadingIndicator.style.display = '';
|
||
} else {
|
||
loadingIndicator.style.visibility = 'hidden';
|
||
loadingIndicator.style.display = 'none';
|
||
}
|
||
syncDiscoveryLoadMoreFocus(loadingIndicator);
|
||
}
|
||
|
||
function isDiscoveryHomeContext() {
|
||
const currentView = window.KefinTweaksUtils?.getCurrentView();
|
||
const isHomePage = currentView === 'home' || currentView === 'home.html';
|
||
if (!isHomePage) return false;
|
||
const activeTab = document.querySelector('.headerTabs .emby-tab-button-active');
|
||
if (!activeTab || activeTab.getAttribute('data-index') !== '0') return false;
|
||
return true;
|
||
}
|
||
|
||
function removeDiscoveryScrollListeners() {
|
||
if (state.discoveryScrollDebounceTimer) {
|
||
clearTimeout(state.discoveryScrollDebounceTimer);
|
||
state.discoveryScrollDebounceTimer = null;
|
||
}
|
||
if (state.infiniteScrollHandler) {
|
||
window.removeEventListener('scroll', state.infiniteScrollHandler);
|
||
state.infiniteScrollHandler = null;
|
||
}
|
||
if (state.discoveryWheelHandler) {
|
||
window.removeEventListener('wheel', state.discoveryWheelHandler);
|
||
state.discoveryWheelHandler = null;
|
||
}
|
||
if (state.discoveryTouchStartHandler) {
|
||
window.removeEventListener('touchstart', state.discoveryTouchStartHandler);
|
||
state.discoveryTouchStartHandler = null;
|
||
}
|
||
if (state.discoveryTouchMoveHandler) {
|
||
window.removeEventListener('touchmove', state.discoveryTouchMoveHandler);
|
||
state.discoveryTouchMoveHandler = null;
|
||
}
|
||
const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
|
||
if (container) delete container.dataset.discoveryHandler;
|
||
}
|
||
|
||
function markDiscoveryExhausted() {
|
||
state.discoverySectionsRemain = false;
|
||
state.discoveryBuffer = null;
|
||
state.discoveryGroupRemainder = [];
|
||
removeDiscoveryScrollListeners();
|
||
setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: true });
|
||
const loadingIndicator = createDiscoveryLoadingIndicator(
|
||
document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer')
|
||
);
|
||
if (loadingIndicator) {
|
||
loadingIndicator.classList.remove('show');
|
||
loadingIndicator.style.visibility = '';
|
||
loadingIndicator.style.display = '';
|
||
syncDiscoveryLoadMoreFocus(loadingIndicator);
|
||
}
|
||
LOG('Discovery exhausted — No More Content to Discover');
|
||
}
|
||
|
||
// Legacy helper kept for compatibility – no longer used in the main flow
|
||
async function waitForHomeSectionsContainer() {
|
||
|
||
const maxAttempts = 50;
|
||
let attempts = 0;
|
||
let homeSectionsContainer = null;
|
||
while (attempts < maxAttempts && !homeSectionsContainer) {
|
||
homeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer');
|
||
if (homeSectionsContainer) {
|
||
break;
|
||
}
|
||
attempts++;
|
||
await new Promise(resolve => setTimeout(resolve, 50));
|
||
}
|
||
|
||
if (!homeSectionsContainer) {
|
||
LOG('Home sections container not found');
|
||
return false;
|
||
}
|
||
|
||
return homeSectionsContainer;
|
||
}
|
||
|
||
const HOME_SECTIONS_SELECTOR = '.homePage:not(.hide) #homeTab.is-active .sections';
|
||
let homeSectionsReadyWaitGeneration = 0;
|
||
|
||
function disconnectHomeSectionsReadyObserver() {
|
||
// Cancels in-flight waitForElement/poll by bumping generation
|
||
homeSectionsReadyWaitGeneration += 1;
|
||
LOG('Cancelled home sections ready wait');
|
||
}
|
||
|
||
function connectHomeSectionsReadyObserver() {
|
||
const generation = ++homeSectionsReadyWaitGeneration;
|
||
|
||
const existing = document.querySelector(HOME_SECTIONS_SELECTOR);
|
||
if (existing) {
|
||
LOG('Home sections container already present; enhancing');
|
||
enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' });
|
||
return;
|
||
}
|
||
|
||
const onFound = () => {
|
||
if (generation !== homeSectionsReadyWaitGeneration) return;
|
||
if (!document.querySelector(HOME_SECTIONS_SELECTOR)) return;
|
||
LOG('Home sections container appeared; enhancing');
|
||
enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' });
|
||
};
|
||
|
||
const utils = window.KefinTweaksUtils;
|
||
if (utils?.waitForElement) {
|
||
LOG('Waiting for home sections via waitForElement');
|
||
utils.waitForElement(HOME_SECTIONS_SELECTOR, { timeout: 20000, quiet: true }).then((el) => {
|
||
if (el) onFound();
|
||
});
|
||
return;
|
||
}
|
||
if (utils?.pollUntil) {
|
||
LOG('Waiting for home sections via pollUntil');
|
||
utils.pollUntil(
|
||
() => (generation === homeSectionsReadyWaitGeneration
|
||
? document.querySelector(HOME_SECTIONS_SELECTOR)
|
||
: true),
|
||
{ intervalMs: 200, timeoutMs: 20000 }
|
||
).then((el) => {
|
||
if (el && el !== true) onFound();
|
||
});
|
||
return;
|
||
}
|
||
WARN('KefinTweaksUtils wait helpers unavailable; cannot wait for home sections container');
|
||
}
|
||
|
||
function mulberry32(seed) {
|
||
let t = seed >>> 0;
|
||
return () => {
|
||
t += 0x6D2B79F5;
|
||
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
||
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
|
||
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Dense procedural blood edge for Halloween header (mostly filled, tiny gaps,
|
||
* uneven height up to 10px). Returns a CSS url(...) data SVG.
|
||
*/
|
||
function generateHalloweenBloodSplatter({
|
||
width = 1400,
|
||
height = 10,
|
||
dropletCount = 420,
|
||
clusterCount = 5,
|
||
gapCount = 4,
|
||
seed = (Date.now() ^ (Math.random() * 1e9)) >>> 0,
|
||
} = {}) {
|
||
const rand = mulberry32(seed);
|
||
|
||
const clusters = Array.from({ length: clusterCount }, () => ({
|
||
x: rand() * width,
|
||
r: 60 + rand() * 120,
|
||
weight: 1.4 + rand() * 1.8,
|
||
tall: rand() < 0.55,
|
||
}));
|
||
|
||
const gaps = Array.from({ length: gapCount }, () => ({
|
||
x: rand() * width,
|
||
r: 14 + rand() * 28,
|
||
}));
|
||
|
||
const inGap = (x) => gaps.some((g) => Math.abs(x - g.x) < g.r);
|
||
|
||
const sampleCluster = (x) => {
|
||
let boost = 0;
|
||
let tall = false;
|
||
for (const c of clusters) {
|
||
const d = Math.abs(x - c.x) / c.r;
|
||
if (d < 1) {
|
||
const w = c.weight * (1 - d) * (1 - d);
|
||
boost += w;
|
||
if (c.tall && w > 0.35) tall = true;
|
||
}
|
||
}
|
||
return { boost, tall };
|
||
};
|
||
|
||
const drops = [];
|
||
let attempts = 0;
|
||
while (drops.length < dropletCount && attempts < dropletCount * 16) {
|
||
attempts += 1;
|
||
const x = rand() * width;
|
||
if (inGap(x)) continue;
|
||
|
||
const { boost, tall } = sampleCluster(x);
|
||
const accept = 0.82 + Math.min(0.18, boost * 0.08);
|
||
if (rand() > accept) continue;
|
||
|
||
let maxRise = 3.5 + rand() * 2.5;
|
||
if (tall) maxRise = height * (0.75 + rand() * 0.25);
|
||
else if (rand() < 0.06) maxRise = height * (0.7 + rand() * 0.3);
|
||
|
||
const y = height - rand() * maxRise;
|
||
const maxR = tall ? (0.9 + rand() * 1.6) : (0.45 + rand() * 1.1);
|
||
const r = 0.3 + rand() * maxR;
|
||
const fill = rand() < 0.3 ? '#990000' : (rand() < 0.55 ? '#8b0000' : '#7a0000');
|
||
|
||
if (rand() < 0.16) {
|
||
drops.push(
|
||
`<ellipse cx="${x.toFixed(2)}" cy="${y.toFixed(2)}" rx="${(r * (1.1 + rand())).toFixed(2)}" ry="${(r * (0.4 + rand() * 0.45)).toFixed(2)}" fill="${fill}"/>`,
|
||
);
|
||
} else {
|
||
drops.push(`<circle cx="${x.toFixed(2)}" cy="${y.toFixed(2)}" r="${r.toFixed(2)}" fill="${fill}"/>`);
|
||
}
|
||
}
|
||
|
||
const svg =
|
||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">` +
|
||
drops.join('') +
|
||
`</svg>`;
|
||
|
||
return {
|
||
seed,
|
||
cssUrl: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
|
||
};
|
||
}
|
||
|
||
const HALLOWEEN_BLOOD_STYLE_ID = 'kefin-halloween-blood-styles';
|
||
|
||
function clearHalloweenBloodInlinePollution(body = document.body) {
|
||
if (!body) return;
|
||
body.style.removeProperty('--halloween-blood-band');
|
||
body.style.removeProperty('--halloween-blood-opacity');
|
||
body.style.removeProperty('--halloween-blood-splatter');
|
||
delete body.dataset.halloweenBloodSeed;
|
||
try {
|
||
sessionStorage.removeItem('kefin-halloween-blood-seed');
|
||
} catch (_) { /* private mode */ }
|
||
}
|
||
|
||
/** Inject procedural blood SVG via a <style> tag (once per page load). */
|
||
function applyHalloweenBloodVars() {
|
||
if (document.getElementById(HALLOWEEN_BLOOD_STYLE_ID)) return;
|
||
|
||
clearHalloweenBloodInlinePollution(document.body);
|
||
|
||
const { cssUrl } = generateHalloweenBloodSplatter();
|
||
const style = document.createElement('style');
|
||
style.id = HALLOWEEN_BLOOD_STYLE_ID;
|
||
style.textContent =
|
||
`html[data-active-page="home"] body[data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"]{` +
|
||
`--halloween-blood-band:10px;` +
|
||
`--halloween-blood-opacity:0.95;` +
|
||
`--halloween-blood-splatter:${cssUrl};` +
|
||
`}`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
async function manageBodyClasses() {
|
||
|
||
const homeScreenConfig = await window.KefinHomeScreen.getConfig();
|
||
const seasonalSectionGroups = homeScreenConfig.SEASONAL_SECTION_GROUPS || [];
|
||
const seasonalConfig = homeScreenConfig.SEASONAL_THEME_SETTINGS || {};
|
||
const enableSeasonalAnimations = seasonalConfig.enableSeasonalAnimations !== false;
|
||
const enableSeasonalBackground = seasonalConfig.enableSeasonalBackground !== false;
|
||
const seasonalThemes = seasonalConfig.seasonalThemes || {};
|
||
|
||
if (!enableSeasonalBackground && !enableSeasonalAnimations) {
|
||
LOG('Seasonal background and animations are disabled');
|
||
return;
|
||
}
|
||
|
||
const body = document.body;
|
||
const currentView = window.KefinTweaksUtils?.getCurrentView ? window.KefinTweaksUtils.getCurrentView() : null;
|
||
const isHomePage = currentView === 'home' || currentView === 'home.html' || window.location.href.includes('home.html');
|
||
|
||
// Get the active Seasonal groups by checking if any section in the group is enabled and where the current date falls within the StartDate and EndDate of the section
|
||
const activeSeasonalGroups = seasonalSectionGroups.filter(sectionGroup => {
|
||
return sectionGroup.sections.some(s => s.enabled && isInSeasonalPeriod(s.startDate, s.endDate));
|
||
});
|
||
|
||
if (activeSeasonalGroups.length === 0) {
|
||
LOG('No active seasonal groups found');
|
||
return;
|
||
}
|
||
|
||
let activeSeasonalThemeGroup;
|
||
if (enableSeasonalBackground && enableSeasonalAnimations) {
|
||
activeSeasonalThemeGroup = activeSeasonalGroups.find(sectionGroup => {
|
||
const theme = seasonalThemes[sectionGroup.id] || {};
|
||
return theme.backgroundImage && theme.animation;
|
||
});
|
||
} else {
|
||
activeSeasonalThemeGroup = activeSeasonalGroups.find(sectionGroup => {
|
||
const theme = seasonalThemes[sectionGroup.id] || {};
|
||
return (enableSeasonalBackground && theme.backgroundImage)
|
||
|| (enableSeasonalAnimations && theme.animation);
|
||
});
|
||
}
|
||
|
||
if (!activeSeasonalThemeGroup) {
|
||
LOG('No seasonal theme found');
|
||
return;
|
||
}
|
||
|
||
document.body.dataset.seasonalTheme = activeSeasonalThemeGroup.id;
|
||
|
||
if (enableSeasonalBackground && !document.body.dataset.seasonalBackground) {
|
||
document.body.dataset.seasonalBackground = 'true';
|
||
|
||
// Add the background image as a css variable to the body
|
||
const backgroundImage = seasonalThemes[activeSeasonalThemeGroup.id]?.backgroundImage;
|
||
if (backgroundImage) {
|
||
document.body.style.setProperty('--seasonal-background-image', backgroundImage);
|
||
}
|
||
}
|
||
|
||
if (enableSeasonalBackground && activeSeasonalThemeGroup.id === 'seasonal-halloween') {
|
||
applyHalloweenBloodVars();
|
||
}
|
||
|
||
if (enableSeasonalAnimations && !document.body.dataset.seasonalAnimations) {
|
||
document.body.dataset.seasonalAnimations = 'true';
|
||
|
||
// Add the animation script to the head
|
||
const theme = seasonalThemes[activeSeasonalThemeGroup.id] || {};
|
||
const animation = theme.animation;
|
||
document.body.dataset.seasonalShape = theme.shape || 'snowflake';
|
||
document.body.dataset.seasonalAnimationStyle = theme.animationStyle || 'fall';
|
||
if (animation) {
|
||
const animationScript = document.querySelector(`script[src*="${animation}.js"]`);
|
||
if (!animationScript) {
|
||
const animationScriptSrc = (window.KefinTweaksConfig.kefinTweaksRoot || '') + `scripts/seasonal/${animation}.js`;
|
||
const script = document.createElement('script');
|
||
script.src = animationScriptSrc;
|
||
document.head.appendChild(script);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Main Entry Point
|
||
*/
|
||
async function enhanceHomeScreen() {
|
||
LOG('Initializing Home Screen v3...');
|
||
|
||
mountCreateSectionButton();
|
||
// Defer mountCategoryRail until pinned availability is known (avoids first-load tab jump)
|
||
syncHomeScreenChromeVisibility();
|
||
|
||
// Reset cache for a new initialization cycle
|
||
sectionCache.renderedSections = [];
|
||
sectionCache.fragmentCache = null;
|
||
sectionCache.isInitialRender = true;
|
||
sectionCache.jellyfinHasRendered = false;
|
||
|
||
const container = document.querySelector(HOME_SECTIONS_SELECTOR);
|
||
if (!container) {
|
||
LOG('Home sections container not found');
|
||
connectHomeSectionsReadyObserver();
|
||
return;
|
||
}
|
||
|
||
disconnectHomeSectionsReadyObserver();
|
||
|
||
// Create Kefin home sections container as a sibling of the Jellyfin sections container
|
||
/* let kefinHomeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer');
|
||
if (!kefinHomeSectionsContainer) {
|
||
kefinHomeSectionsContainer = document.createElement('div');
|
||
kefinHomeSectionsContainer.className = 'sections homeSectionsContainer kefinHomeSectionsContainer';
|
||
if (container.parentNode) {
|
||
container.parentNode.insertBefore(kefinHomeSectionsContainer, container.nextSibling);
|
||
} else {
|
||
LOG('Sections container has no parent node; appending Kefin container to body as fallback');
|
||
document.body.appendChild(kefinHomeSectionsContainer);
|
||
}
|
||
} */
|
||
|
||
// If Home-category sections already exist, avoid re-running full init.
|
||
// Discovery-only remounts (e.g. back to Discover) must not skip home render.
|
||
if (hasHomeCategorySections(container)) {
|
||
LOG('Home screen already initialized and Jellyfin has rendered');
|
||
try {
|
||
const kefinTweaksHomeSections = container.querySelectorAll('[data-section-id]');
|
||
updatePinnedCategoryAvailability(
|
||
Array.from(kefinTweaksHomeSections).map((el) => ({
|
||
id: el.dataset.sectionId,
|
||
category: el.dataset.category
|
||
})),
|
||
null
|
||
);
|
||
refreshHomeCategoryChrome();
|
||
} catch (err) {
|
||
ERR('Failed to sync home category chrome for existing home:', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Fetch display preferences
|
||
//state.userDisplayPreferences = await fetchDisplayPreferences();
|
||
|
||
// Load user preferences and apply filtering/ordering
|
||
const userHomeConfig = window.KefinUserHomeScreenConfig;
|
||
let filteredHomeSections;
|
||
let homeScreen;
|
||
let userDisplayPreferences = null;
|
||
|
||
if (typeof userHomeConfig?.getConfig === 'function') {
|
||
const userConfig = await userHomeConfig.getConfig();
|
||
filteredHomeSections = userConfig.sections || [];
|
||
homeScreen = userConfig.homeScreen;
|
||
userDisplayPreferences = await state.getDisplayPrefernces();
|
||
} else {
|
||
const mergedHomeSections = await mergeHomeSectionConfigs();
|
||
userDisplayPreferences = await state.getDisplayPrefernces();
|
||
const customPrefs = userDisplayPreferences?.CustomPrefs || {};
|
||
homeScreen = userHomeConfig.parseKefinTweaksHomeScreen(customPrefs);
|
||
const pinnedSections = userHomeConfig.buildPinnedSectionConfigs(homeScreen);
|
||
|
||
const serverSectionsById = new Map();
|
||
mergedHomeSections.forEach(section => {
|
||
if (section?.id) serverSectionsById.set(section.id, section);
|
||
});
|
||
|
||
filteredHomeSections = [...mergedHomeSections, ...pinnedSections];
|
||
filteredHomeSections = userHomeConfig.applyUserSectionOverrides(filteredHomeSections, homeScreen, { serverSectionsById });
|
||
filteredHomeSections = filteredHomeSections.filter(section => section.enabled !== false);
|
||
}
|
||
|
||
updatePinnedCategoryAvailability(filteredHomeSections, homeScreen);
|
||
mountCategoryRail();
|
||
syncHomeScreenChromeVisibility();
|
||
|
||
const nextupSectionConfig = filteredHomeSections.find(s => s.id === 'nextUp');
|
||
const continueWatchingSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatching');
|
||
const continueWatchingAndNextUpSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatchingAndNextUp');
|
||
const recentlyAddedSectionConfig = filteredHomeSections.find(s => s.id?.startsWith?.('recently-added-') && s.enabled === true);
|
||
state.kefinNextUp = nextupSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true;
|
||
state.kefinContinueWatching = continueWatchingSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true;
|
||
state.kefinLatestMedia = recentlyAddedSectionConfig?.enabled === true;
|
||
|
||
// Set up mutation observer BEFORE rendering, so we can react when Jellyfin renders home sections
|
||
/* const handleJellyfinRender = async () => {
|
||
const jellyHome = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer');
|
||
const kefinHome = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer');
|
||
|
||
if (!jellyHome || !kefinHome) {
|
||
LOG('Jellyfin render detected but required containers not found; skipping Jellyfin section mirroring');
|
||
return;
|
||
}
|
||
|
||
// Disconnect any pending "wait for children" observers from a previous run
|
||
sectionCache.pendingJellyfinSectionObservers.forEach(obs => { try { obs.disconnect(); } catch (_) {} });
|
||
sectionCache.pendingJellyfinSectionObservers = [];
|
||
|
||
// Remove any previously cloned Jellyfin sections from our container
|
||
kefinHome.querySelectorAll('[data-jellyfin-section-clone="true"]').forEach(el => el.remove());
|
||
|
||
// Copy Jellyfin .sectionN containers into the Kefin container (clone now if has children; else observe until children appear if in jellyfinOrders)
|
||
const jellySections = jellyHome.querySelectorAll('.section0, .section1, .section2, .section3, .section4, .section5, .section6, .section7, .section8');
|
||
|
||
jellySections.forEach(sectionEl => {
|
||
const sectionIndex = getSectionIndexFromElement(sectionEl);
|
||
const homesectionKey = sectionIndex >= 0 ? `homesection${sectionIndex}` : null;
|
||
if (sectionHasAllItemsContainersPopulated(sectionEl)) {
|
||
const clone = sectionEl.cloneNode(true);
|
||
clone.dataset.jellyfinSectionClone = 'true';
|
||
kefinHome.appendChild(clone);
|
||
} else if (homesectionKey && state.jellyfinOrders[homesectionKey] !== undefined) {
|
||
observeSectionUntilChildrenThenClone(sectionEl, kefinHome);
|
||
}
|
||
});
|
||
}; */
|
||
|
||
//let containerForRender = container;
|
||
|
||
/* if (!container.classList.contains('homeSectionsContainer')) {
|
||
const observer = observeContainerMutations(container, handleJellyfinRender);
|
||
containerForRender = kefinHomeSectionsContainer;
|
||
} */
|
||
|
||
// Set flag to indicate we've started initializing this container
|
||
//containerForRender.dataset.kefinHomeScreen = 'true';
|
||
|
||
// Initial render directly into Kefin container (no waiting for Jellyfin)
|
||
LOG('Rendering Standard/Seasonal Sections (initial render)...');
|
||
await renderHomeSections(filteredHomeSections, container, false);
|
||
|
||
LOG('Initializing Discovery Sections...');
|
||
setupDiscoveryInteraction(container);
|
||
|
||
try {
|
||
refreshHomeCategoryChrome();
|
||
} catch (err) {
|
||
ERR('Failed to refresh home category chrome after home render:', err);
|
||
}
|
||
|
||
/* // If Jellyfin hasn't rendered after 5 seconds, assume it won't and clean up the observer
|
||
setTimeout(() => {
|
||
if (!sectionCache.jellyfinHasRendered && observer) {
|
||
LOG('Jellyfin render timeout - assuming native sections disabled');
|
||
sectionCache.jellyfinHasRendered = true;
|
||
observer.disconnect();
|
||
}
|
||
}, 5000); */
|
||
}
|
||
|
||
function getHomeScreenConfig() {
|
||
const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen;
|
||
if (!userConfig) {
|
||
return Config;
|
||
}
|
||
|
||
const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []);
|
||
const homeSections = mergeHomeSectionConfigs(defaultHomeSections);
|
||
}
|
||
|
||
/**
|
||
* Merges default Home Sections with Admin/User Config overrides
|
||
* Now reads from all 4 groups: HOME, SEASONAL, DISCOVERY, CUSTOM
|
||
*/
|
||
async function mergeHomeSectionConfigs(defaultSections) {
|
||
const kefinSections = await window.KefinHomeScreen.getSections();
|
||
return kefinSections.enabledHomeSections;
|
||
|
||
// Return all enabled sections from HOME_SECTION_GROUPS
|
||
const homeSections = flattenSectionGroups(kefinHomeScreenConfig.HOME_SECTION_GROUPS).filter(s => s.enabled === true);
|
||
// Return all enabled sections from SEASONAL_SECTION_GROUPS that fall within the current date
|
||
const seasonalSections = flattenSectionGroups(kefinHomeScreenConfig.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && isInSeasonalPeriod(s.startDate, s.endDate));
|
||
// Return all enabled sections from CUSTOM_SECTION_GROUPS that aren't discovery sections and if they have a start/end date that also fall within that date range
|
||
const customSections = flattenSectionGroups(kefinHomeScreenConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true));
|
||
|
||
const mergedSections = [...homeSections, ...seasonalSections, ...customSections];
|
||
|
||
return mergedSections.sort((a, b) => a.order - b.order);
|
||
|
||
/* const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen;
|
||
const homeScreenConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig;
|
||
|
||
const merged = [];
|
||
|
||
// Helper function to merge sections with user config overrides
|
||
const mergeSectionWithUserConfig = (section) => {
|
||
let userSectionConfig = null;
|
||
|
||
if (section.id.includes('.')) {
|
||
const parts = section.id.split('.');
|
||
if (userConfig && userConfig[parts[0]] && userConfig[parts[0]][parts[1]]) {
|
||
userSectionConfig = userConfig[parts[0]][parts[1]];
|
||
}
|
||
} else if (userConfig) {
|
||
userSectionConfig = userConfig[section.id];
|
||
}
|
||
|
||
if (userSectionConfig) {
|
||
return {
|
||
...section,
|
||
...userSectionConfig,
|
||
id: section.id
|
||
};
|
||
}
|
||
return section;
|
||
};
|
||
|
||
// Process HOME_SECTION_GROUPS (default + admin overrides)
|
||
const defaultHome = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []);
|
||
const adminHome = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.HOME_SECTION_GROUPS || []) : [];
|
||
const defaultHomeIds = new Set(defaultHome.map(s => s.id));
|
||
|
||
// Merge default home sections with user config
|
||
defaultHome.forEach(section => {
|
||
merged.push(mergeSectionWithUserConfig(section));
|
||
});
|
||
|
||
// Add admin overrides for home sections (including recently-added)
|
||
adminHome.forEach(section => {
|
||
const existingIndex = merged.findIndex(s => s.id === section.id);
|
||
if (existingIndex !== -1) {
|
||
// Update existing section
|
||
merged[existingIndex] = { ...merged[existingIndex], ...section };
|
||
} else if (!defaultHomeIds.has(section.id)) {
|
||
// New section from admin config (e.g., recently-added)
|
||
merged.push(section);
|
||
}
|
||
});
|
||
|
||
// Process SEASONAL_SECTION_GROUPS (default + admin overrides)
|
||
const defaultSeasonal = flattenSectionGroups(Config.SEASONAL_SECTION_GROUPS || []);
|
||
const adminSeasonal = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.SEASONAL_SECTION_GROUPS || []) : [];
|
||
const defaultSeasonalIds = new Set(defaultSeasonal.map(s => s.id));
|
||
|
||
// Merge default seasonal sections with user config
|
||
defaultSeasonal.forEach(section => {
|
||
merged.push(mergeSectionWithUserConfig(section));
|
||
});
|
||
|
||
// Add admin overrides for seasonal sections
|
||
adminSeasonal.forEach(section => {
|
||
const existingIndex = merged.findIndex(s => s.id === section.id);
|
||
if (existingIndex !== -1) {
|
||
// Update existing section
|
||
merged[existingIndex] = { ...merged[existingIndex], ...section };
|
||
} else if (!defaultSeasonalIds.has(section.id)) {
|
||
// New seasonal section from admin config
|
||
merged.push(section);
|
||
}
|
||
});
|
||
|
||
// Process CUSTOM_SECTION_GROUPS (user-created/imported sections)
|
||
const customSections = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.CUSTOM_SECTION_GROUPS || []) : [];
|
||
customSections.forEach(section => {
|
||
if (section.discoveryEnabled === true) {
|
||
// Skip if discovery is enabled, the section will be rendered in the discovery sections
|
||
return;
|
||
}
|
||
if (!merged.find(s => s.id === section.id)) {
|
||
merged.push(section);
|
||
}
|
||
});
|
||
|
||
// Legacy support: Also check old recentlyAddedInLibrary location for backward compatibility
|
||
if (isRecentlyAddedEnabled() && userConfig) {
|
||
const libraryIds = Object.keys(userConfig.recentlyAddedInLibrary || {});
|
||
const libraries = await window.dataHelper.getLibraries();
|
||
|
||
libraryIds.forEach(libId => {
|
||
const libConfig = userConfig.recentlyAddedInLibrary[libId];
|
||
const sectionId = `recently-added-${libId}`;
|
||
|
||
// Skip if already added from HOME_SECTION_GROUPS
|
||
if (merged.find(s => s.id === sectionId)) {
|
||
return;
|
||
}
|
||
|
||
if (libConfig && libConfig.enabled !== false) {
|
||
const library = libraries.find(l => l.Id === libId);
|
||
|
||
if (!library) {
|
||
LOG(`Library not found: ${libId}`);
|
||
return;
|
||
}
|
||
|
||
if (library.CollectionType &&
|
||
library.CollectionType !== 'movies' &&
|
||
library.CollectionType !== 'tvshows' &&
|
||
library.CollectionType !== 'music' &&
|
||
library.CollectionType !== 'homevideos' &&
|
||
library.CollectionType !== 'musicvideos' &&
|
||
library.CollectionType !== 'musicvideos' &&
|
||
library.CollectionType !== 'books') {
|
||
LOG(`Skipping library: ${library.CollectionType}`);
|
||
return;
|
||
}
|
||
|
||
let viewMoreUrl = null;
|
||
if (library.CollectionType === 'movies') {
|
||
viewMoreUrl = `#/movies.html?topParentId=${libId}&collectionType=movies&tab=1`;
|
||
} else if (library.CollectionType === 'tvshows') {
|
||
viewMoreUrl = `#/tv.html?topParentId=${libId}&collectionType=tvshows&tab=1`;
|
||
}
|
||
|
||
// Convert to new format with queries array
|
||
const newSection = {
|
||
id: sectionId,
|
||
name: libConfig.name || 'Recently Added',
|
||
enabled: true,
|
||
order: libConfig.order || 61,
|
||
cardFormat: libConfig.cardFormat || 'Poster',
|
||
jellyfinId: 'latestmedia',
|
||
viewMoreUrl: viewMoreUrl,
|
||
queries: [{
|
||
path: `/Items/Latest`,
|
||
queryOptions: {
|
||
ParentId: libId,
|
||
SortBy: 'DateCreated',
|
||
SortOrder: 'Descending',
|
||
Limit: libConfig.itemLimit || 16
|
||
}
|
||
}]
|
||
}
|
||
|
||
merged.push(newSection);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Filter seasonal sections based on date
|
||
const filteredMerged = merged.filter(section => {
|
||
if (section.startDate && section.endDate) {
|
||
if (section.enabled === false) {
|
||
LOG(`Skipping disabled seasonal section: ${section.id}`);
|
||
return false;
|
||
}
|
||
if (!isInSeasonalPeriod(section.startDate, section.endDate)) {
|
||
LOG(`Skipping seasonal section (out of date): ${section.id}`);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// Legacy support: Also check old customSections location
|
||
if (userConfig && userConfig.customSections) {
|
||
Object.keys(userConfig.customSections).forEach(key => {
|
||
if (!filteredMerged.find(s => s.id === key)) {
|
||
filteredMerged.push(userConfig.customSections[key]);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Legacy support: Also check old seasonal location
|
||
if (userConfig && userConfig.seasonal?.seasons) {
|
||
const seasonalSections = userConfig.seasonal.seasons.flatMap(s => s.sections.map(section => ({
|
||
...section,
|
||
startDate: s.startDate,
|
||
endDate: s.endDate
|
||
})));
|
||
seasonalSections.forEach(section => {
|
||
if (!filteredMerged.find(s => s.id === section.id)) {
|
||
if (section.enabled === false) {
|
||
LOG(`Skipping disabled seasonal section: ${section.id}`);
|
||
return;
|
||
}
|
||
if (section.startDate && section.endDate) {
|
||
if (!isInSeasonalPeriod(section.startDate, section.endDate)) {
|
||
LOG(`Skipping seasonal section (out of date): ${section.id}`);
|
||
return;
|
||
}
|
||
}
|
||
filteredMerged.push(section);
|
||
}
|
||
});
|
||
}
|
||
|
||
return filteredMerged; */
|
||
}
|
||
|
||
async function getDiscoverySections() {
|
||
const settings = getDiscoverySettings();
|
||
|
||
if (!settings || settings.enabled === false) {
|
||
LOG('Discovery is disabled');
|
||
return [];
|
||
}
|
||
|
||
const page = state.discoveryGroupIndex + 1;
|
||
|
||
const filterByDiscoveryPage = (sections) => (sections || []).filter((section) => {
|
||
if (section.pageNumber == null) return true; // native discovery templates
|
||
return Number(section.pageNumber) === page;
|
||
});
|
||
|
||
const userHomeScreenConfig = await window.KefinUserHomeScreenConfig.getConfig();
|
||
if (userHomeScreenConfig) {
|
||
return filterByDiscoveryPage(userHomeScreenConfig.enabledDiscoverySections);
|
||
}
|
||
|
||
const homeScreenConfig = window.KefinHomeScreen.getConfig();
|
||
|
||
// Get default discovery sections
|
||
const defaultDiscovery = flattenSectionGroups(Config.DISCOVERY_SECTION_GROUPS || []);
|
||
|
||
// Get admin overrides for discovery sections
|
||
const adminDiscoverySections = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig && window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS && flattenSectionGroups(window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS);
|
||
const mappedSections = Object.keys(adminDiscoverySections || {}).map(key => ({
|
||
id: key,
|
||
...adminDiscoverySections[key]
|
||
}));
|
||
|
||
// Merge default discovery sections with admin overrides
|
||
const mergedSections = defaultDiscovery.map(section => {
|
||
const mappedSection = mappedSections.find(s => s.id === section.id);
|
||
if (mappedSection) {
|
||
return { ...section, ...mappedSection, type: section.type };
|
||
}
|
||
return section;
|
||
});
|
||
|
||
const isInSeasonalPeriodLocal = (startDate, endDate) => isInSeasonalPeriod(startDate, endDate);
|
||
|
||
const customGroups = homeScreenConfig?.CUSTOM_SECTION_GROUPS
|
||
|| window.KefinTweaksConfig?.homeScreenConfig?.CUSTOM_SECTION_GROUPS
|
||
|| [];
|
||
const collect = window.KefinHomeScreen?.collectCustomDiscoverySections;
|
||
const customDiscoverySections = typeof collect === 'function'
|
||
? collect(customGroups, {
|
||
isSectionActive: (s) => s.enabled === true,
|
||
isInSeasonalPeriod: isInSeasonalPeriodLocal
|
||
})
|
||
: [];
|
||
|
||
mergedSections.push(...filterByDiscoveryPage(customDiscoverySections));
|
||
|
||
// Legacy support: Also check old customSections location
|
||
const legacyCustomSections = window.KefinTweaksConfig?.homeScreen?.customSections || [];
|
||
if (legacyCustomSections.length > 0) {
|
||
legacyCustomSections.forEach(section => {
|
||
if (section.enabled && section.discoveryEnabled) {
|
||
const legacyPage = section.pageNumber == null ? 1 : Number(section.pageNumber);
|
||
if (legacyPage !== page) return;
|
||
const customDedupe = window.sectionHelper?.getDiscoveryState?.()?.renderedDiscoveryIds?.customDiscoverySections;
|
||
if (customDedupe?.has(section.id)) {
|
||
return;
|
||
}
|
||
mergedSections.push(section);
|
||
customDedupe?.add(section.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
return mergedSections;
|
||
}
|
||
|
||
function getDiscoverySettings() {
|
||
const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig;
|
||
if (!userConfig) {
|
||
return Config.DISCOVERY_SETTINGS;
|
||
}
|
||
|
||
return {
|
||
...Config.DISCOVERY_SETTINGS,
|
||
...userConfig.DISCOVERY_SETTINGS
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Load user home screen preferences
|
||
*/
|
||
function loadUserHomeScreenSections(userDisplayPreferences) {
|
||
try {
|
||
//if (!state.userDisplayPreferences) {
|
||
// state.userDisplayPreferences = await fetchDisplayPreferences();
|
||
//}
|
||
|
||
const customPrefs = userDisplayPreferences?.CustomPrefs || {};
|
||
const kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen || '[]');
|
||
|
||
return kefinHomeScreen;
|
||
|
||
/* // Create a homesectionN : order value mapping for the custom CSS orders to be applied
|
||
const jellyfinOrders = {};
|
||
for (let i = 0; i <= 8; i++) {
|
||
const homeSection = customPrefs[`homesection${i}`];
|
||
|
||
if (!homeSection) {
|
||
continue;
|
||
}
|
||
if (homeSection.toLowerCase() === 'nextup' && state.kefinNextUp) {
|
||
continue;
|
||
}
|
||
if (homeSection.toLowerCase() === 'resume' && state.kefinContinueWatching) {
|
||
continue;
|
||
}
|
||
if (homeSection.toLowerCase() === 'continuewatchingandnextup' && state.kefinNextUp && state.kefinContinueWatching) {
|
||
continue;
|
||
}
|
||
if (homeSection.toLowerCase() === 'latestmedia' && state.kefinLatestMedia) {
|
||
continue;
|
||
}
|
||
|
||
// Match to homeSection in kefinHomeScreen with .toLowerCase() id
|
||
let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase() && s.enabled === true);
|
||
|
||
if (!kefinHomeScreenSection) {
|
||
// Check if it's "latestmedia"
|
||
if (homeSection.toLowerCase() === 'latestmedia' && !state.kefinLatestMedia) {
|
||
kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase().startsWith('recently-added') && s.enabled === true);
|
||
}
|
||
}
|
||
|
||
if (kefinHomeScreenSection) {
|
||
jellyfinOrders[`homesection${i}`] = kefinHomeScreenSection.order;
|
||
}
|
||
}
|
||
|
||
state.jellyfinOrders = jellyfinOrders;
|
||
|
||
addUserHomeScreenOrderCSS(jellyfinOrders);
|
||
|
||
return kefinHomeScreen; */
|
||
|
||
/* if (!Array.isArray(kefinHomeScreen)) {
|
||
return { enabledSectionIds: new Set(), orderMap: new Map(), jellyfinOrders: {} };
|
||
}
|
||
|
||
// Create set of enabled section IDs
|
||
const enabledSectionIds = new Set(kefinHomeScreen.map(s => s['section-id']));
|
||
|
||
// Create map of section-id -> order
|
||
const orderMap = new Map();
|
||
kefinHomeScreen.forEach(pref => {
|
||
if (pref['section-id'] && pref.order !== undefined) {
|
||
orderMap.set(pref['section-id'], pref.order);
|
||
}
|
||
});
|
||
|
||
// Get Jellyfin section orders (homesectionN -> order mapping)
|
||
const jellyfinOrders = {};
|
||
kefinHomeScreen.forEach(pref => {
|
||
const sectionId = pref['section-id'];
|
||
if (sectionId && sectionId.startsWith('homesection') && pref.order !== undefined) {
|
||
jellyfinOrders[sectionId] = pref.order;
|
||
}
|
||
});
|
||
|
||
return { enabledSectionIds, orderMap, jellyfinOrders }; */
|
||
} catch (error) {
|
||
ERR('Error loading user home screen preferences:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Filter sections based on user preferences
|
||
*/
|
||
function filterSectionsByUserPreferences(sections, userPrefs) {
|
||
const { enabledSectionIds } = userPrefs;
|
||
|
||
// If no user preferences, return all sections
|
||
if (enabledSectionIds.size === 0) {
|
||
return sections;
|
||
}
|
||
|
||
// Filter out disabled sections (not in enabledSectionIds)
|
||
return sections.filter(section => {
|
||
const sectionId = section.id;
|
||
return enabledSectionIds.has(sectionId);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply user-defined orders to sections
|
||
*/
|
||
function applyUserSectionOrders(sections, userPrefs) {
|
||
const { orderMap } = userPrefs;
|
||
|
||
return sections.map(section => {
|
||
const sectionId = section.id;
|
||
const userOrder = orderMap.get(sectionId);
|
||
|
||
if (userOrder !== undefined) {
|
||
return {
|
||
...section,
|
||
order: userOrder
|
||
};
|
||
}
|
||
|
||
return section;
|
||
});
|
||
}
|
||
|
||
async function renderHomeSections(sections, container, useCache = false) {
|
||
if (!container) {
|
||
WARN('renderHomeSections called without a valid container');
|
||
return;
|
||
}
|
||
|
||
if (state.isRenderingHome) {
|
||
LOG('Home render already in progress, skipping');
|
||
return;
|
||
}
|
||
|
||
state.isRenderingHome = true;
|
||
try {
|
||
// Only skip when Home-category (none) sections already exist — discovery-only DOM must still render home
|
||
if (hasHomeCategorySections(container)) {
|
||
LOG('Sections already rendered, skipping');
|
||
return;
|
||
}
|
||
|
||
container.dataset.sectionsRendered = 'true';
|
||
applyActiveHomeCategory(homeChromeActiveCategory || 'none');
|
||
syncHomeScreenChromeVisibility();
|
||
|
||
const sectionsToRender = [];
|
||
|
||
if (useCache && sectionCache.renderedSections.length > 0) {
|
||
LOG('Re-rendering home sections from cache');
|
||
sectionsToRender.push(...sectionCache.renderedSections);
|
||
} else {
|
||
const sortedSections = [...sections].sort((a, b) => {
|
||
const orderA = (a.order !== undefined && a.order !== null) ? a.order : 99;
|
||
const orderB = (b.order !== undefined && b.order !== null) ? b.order : 99;
|
||
return orderA - orderB;
|
||
});
|
||
|
||
const sectionPromises = [];
|
||
|
||
for (const sectionConfig of sortedSections) {
|
||
if (sectionConfig.enabled === false) {
|
||
LOG(`Skipping disabled section: ${sectionConfig.id}`);
|
||
continue;
|
||
}
|
||
|
||
if (sectionConfig.startDate && sectionConfig.endDate) {
|
||
if (!isInSeasonalPeriod(sectionConfig.startDate, sectionConfig.endDate)) {
|
||
LOG(`Skipping seasonal section (out of date): ${sectionConfig.id}`);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const sectionIdAttr = (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
|
||
? CSS.escape(String(sectionConfig.id))
|
||
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
const existingSection = container.querySelector(`[data-section-id="${sectionIdAttr}"]`);
|
||
|
||
if (existingSection) {
|
||
LOG(`Section already rendered: ${sectionConfig.id}`);
|
||
continue;
|
||
}
|
||
|
||
sectionPromises.push(loadSectionForRendering(sectionConfig));
|
||
}
|
||
|
||
// Resolve all section promises
|
||
let sectionResults = await Promise.all(sectionPromises);
|
||
sectionResults = sectionResults.filter(result => result !== null);
|
||
sectionsToRender.push(...sectionResults);
|
||
|
||
// Cache the rendered sections for potential re-render after Jellyfin overwrites the container
|
||
sectionCache.renderedSections = [...sectionsToRender];
|
||
}
|
||
|
||
const homeConfig = await window.KefinHomeScreen.getConfig();
|
||
const showStaleDataBeforeRefresh = homeConfig?.HOME_SETTINGS?.showStaleDataBeforeRefresh === true;
|
||
|
||
const targetContainer = container;
|
||
|
||
if (hasHomeCategorySections(container)) {
|
||
LOG('Sections already rendered, skipping');
|
||
return;
|
||
}
|
||
|
||
await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, {
|
||
waitForContainerClass: 'homeSectionsContainer',
|
||
showStaleDataBeforeRefresh,
|
||
enhanceOnVisible: true,
|
||
});
|
||
|
||
// If MediaBar plugin is in use, call LayoutSync.update() to update the layout
|
||
if (typeof LayoutSync !== 'undefined' && LayoutSync && typeof LayoutSync.update === 'function') {
|
||
LayoutSync.update();
|
||
}
|
||
|
||
// Allow deferred library-cache crawls to start (movies/series/people scheduleBootstrap)
|
||
if (!state.homePaintedEventFired) {
|
||
state.homePaintedEventFired = true;
|
||
try {
|
||
document.dispatchEvent(new CustomEvent('kefinTweaksHomePainted'));
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
|
||
// Check if target container is homeSectionsContainer
|
||
if (!targetContainer.classList.contains('homeSectionsContainer')) {
|
||
targetContainer.dataset.sectionsPrerendered = 'true';
|
||
}
|
||
} finally {
|
||
state.isRenderingHome = false;
|
||
}
|
||
}
|
||
|
||
function isRecentlyAddedEnabled() {
|
||
const userConfig = window.KefinTweaksConfig?.homeScreen;
|
||
return userConfig?.recentlyAddedInLibrary && Object.keys(userConfig.recentlyAddedInLibrary).length > 0;
|
||
}
|
||
|
||
async function loadSectionForRendering(sectionConfig) {
|
||
return window.sectionHelper.loadSectionForRendering(sectionConfig);
|
||
}
|
||
|
||
async function buildDiscoverySectionInstance(template, options = {}) {
|
||
return window.sectionHelper.buildDiscoverySectionInstance(template, options);
|
||
}
|
||
|
||
function disconnectDiscoveryInteraction() {
|
||
removeDiscoveryScrollListeners();
|
||
|
||
const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn');
|
||
if (loadMoreButton) {
|
||
loadMoreButton.remove();
|
||
}
|
||
|
||
// Remove the discovery loading indicator (exhausted path keeps it via markDiscoveryExhausted)
|
||
if (state.discoverySectionsRemain !== false) {
|
||
const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
|
||
if (loadingIndicator) {
|
||
loadingIndicator.remove();
|
||
}
|
||
setDiscoveryPageAttrs({ ready: false, infinite: false, exhausted: false });
|
||
}
|
||
|
||
state.discoveryMode = null;
|
||
state.discoveryGroupRemainder = [];
|
||
}
|
||
|
||
/**
|
||
* Ensures discovery buffer is populated (pre-fetches next group)
|
||
*/
|
||
async function ensureDiscoveryBuffer() {
|
||
if (!isDiscoveryAllowedForActiveCategory()) {
|
||
LOG('Discovery buffer skipped: active category does not allow discovery');
|
||
return state.discoveryBuffer;
|
||
}
|
||
if (state.ensuringDiscoveryBuffer) {
|
||
LOG('Already ensuring discovery buffer...');
|
||
|
||
while (state.ensuringDiscoveryBuffer) {
|
||
LOG('Waiting for discovery buffer to be populated...');
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
}
|
||
|
||
return state.discoveryBuffer;
|
||
}
|
||
LOG('Ensuring discovery buffer...');
|
||
|
||
state.ensuringDiscoveryBuffer = true;
|
||
|
||
try {
|
||
if (state.discoveryBuffer && state.discoveryBuffer.length > 0) {
|
||
LOG('Discovery buffer already exists...');
|
||
return state.discoveryBuffer;
|
||
}
|
||
|
||
LOG('Fetching next discovery group data...');
|
||
|
||
let groupSections;
|
||
if (Array.isArray(state.discoveryGroupRemainder) && state.discoveryGroupRemainder.length > 0) {
|
||
groupSections = state.discoveryGroupRemainder.splice(0, DISCOVERY_SECTIONS_PER_GROUP);
|
||
LOG(`Draining discovery remainder (${groupSections.length} sections, ${state.discoveryGroupRemainder.length} left)...`);
|
||
} else {
|
||
groupSections = await generateDiscoveryGroup();
|
||
}
|
||
|
||
if (!groupSections || groupSections.length === 0) {
|
||
LOG('No more discovery sections available for buffer.');
|
||
state.discoveryBuffer = [];
|
||
state.discoveryGroupRemainder = [];
|
||
state.discoverySectionsRemain = false;
|
||
return state.discoveryBuffer;
|
||
}
|
||
|
||
state.discoveryBuffer = await Promise.all(groupSections.map(async (sectionConfig) => {
|
||
const isDynamic = !!(sectionConfig.discoveryType || sectionConfig.source === 'Dynamic');
|
||
if (isDynamic) {
|
||
// Sync stub + lazy resolve; do not await ensureData here
|
||
return await window.sectionHelper.buildDiscoverySectionPromise(sectionConfig, {
|
||
order: sectionConfig.order,
|
||
pairSpotlight: true
|
||
});
|
||
}
|
||
return await loadSectionForRendering(sectionConfig);
|
||
}));
|
||
|
||
// Filter hard failures only; progressive stubs that later return empty are removed by cardBuilder enhance
|
||
state.discoveryBuffer = state.discoveryBuffer.filter(result => result !== null);
|
||
|
||
if (!state.discoveryBuffer.length) {
|
||
// Only exhaust when this chunk failed AND nothing remains to try next
|
||
if (!state.discoveryGroupRemainder?.length) {
|
||
state.discoverySectionsRemain = false;
|
||
}
|
||
}
|
||
|
||
return state.discoveryBuffer;
|
||
} finally {
|
||
state.ensuringDiscoveryBuffer = false;
|
||
}
|
||
}
|
||
|
||
|
||
async function setupDiscoveryInteraction(container) {
|
||
const userConfig = getDiscoverySettings();
|
||
|
||
if (userConfig && userConfig.enabled === false) {
|
||
LOG('Discovery sections disabled in config.');
|
||
state.discoveryEnabled = false;
|
||
return;
|
||
}
|
||
|
||
// Ensure at least 1 discovery section is enabled
|
||
const discoverySections = await getDiscoverySections();
|
||
if (!discoverySections || discoverySections.length === 0 || discoverySections.every(section => section.enabled === false)) {
|
||
LOG('No discovery sections enabled in config.');
|
||
state.discoveryEnabled = false;
|
||
return;
|
||
}
|
||
|
||
if (!container) {
|
||
WARN('setupDiscoveryInteraction called without a valid container');
|
||
return;
|
||
}
|
||
|
||
// getDiscoverySections() above may mark legacy customDiscoverySections; clear only that
|
||
// so generateDiscoveryGroup can claim them without wiping genre/person/similar dedupe.
|
||
window.sectionHelper?.getDiscoveryState?.()?.renderedDiscoveryIds?.customDiscoverySections?.clear?.();
|
||
state.discoverySectionsRemain = true;
|
||
state.discoveryNeedsMoreScroll = false;
|
||
state.discoveryGroupRemainder = [];
|
||
|
||
if (PRE_FETCH_DISCOVERY_DATA) {
|
||
ensureDiscoveryBuffer();
|
||
}
|
||
|
||
await waitForHomeSectionsContainer(container);
|
||
|
||
createDiscoveryLoadingIndicator(container);
|
||
|
||
const useInfiniteScroll = userConfig?.infiniteScroll === true;
|
||
|
||
if (useInfiniteScroll) {
|
||
state.discoveryMode = 'infinite';
|
||
setDiscoveryPageAttrs({ ready: false, infinite: true, exhausted: false });
|
||
setupInfiniteScroll(container);
|
||
} else {
|
||
state.discoveryMode = 'chevron';
|
||
setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: false });
|
||
setupChevronDiscoverMore(container);
|
||
}
|
||
}
|
||
|
||
async function renderNextDiscoveryGroup() {
|
||
const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
|
||
if (!container) return;
|
||
|
||
if (!isDiscoveryAllowedForActiveCategory()) {
|
||
LOG('Discovery render skipped: active category does not allow discovery');
|
||
return;
|
||
}
|
||
|
||
// Single-flight: set synchronously before any await
|
||
if (state.isRenderingDiscovery) return;
|
||
if (state.discoverySectionsRemain === false) return;
|
||
|
||
state.isRenderingDiscovery = true;
|
||
// Require another user scroll before auto-loading the next group (infinite mode)
|
||
state.discoveryNeedsMoreScroll = true;
|
||
container.dataset.loadingDiscovery = 'true';
|
||
container.classList.add('loading-discovery');
|
||
showDiscoveryLoadingIndicator();
|
||
syncDiscoveryLoadMoreFocus();
|
||
|
||
// Chevron mode: nudge scroll so newly appended sections start entering the viewport
|
||
if (state.discoveryMode === 'chevron') {
|
||
window.scrollBy({ top: 200, behavior: 'smooth' });
|
||
}
|
||
|
||
try {
|
||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
|
||
await ensureDiscoveryBuffer();
|
||
|
||
let bufferedSections = state.discoveryBuffer;
|
||
|
||
if (!bufferedSections || bufferedSections.length === 0) {
|
||
LOG('No more discovery sections available.');
|
||
markDiscoveryExhausted();
|
||
return;
|
||
}
|
||
|
||
// Stable append: honor pre-assigned section.order after parallel loads
|
||
bufferedSections = bufferedSections.slice().sort((a, b) => {
|
||
const ao = Number(a?.config?.order);
|
||
const bo = Number(b?.config?.order);
|
||
return (Number.isFinite(ao) ? ao : 0) - (Number.isFinite(bo) ? bo : 0);
|
||
});
|
||
|
||
LOG(`Rendering Discovery Group ${state.discoveryGroupIndex + 1} (${bufferedSections.length} sections)...`);
|
||
|
||
const config = await window.KefinHomeScreen.getConfig();
|
||
const revealSectionsSequentially = config.DISCOVERY_SETTINGS?.fadeInSections === true;
|
||
|
||
// Spinner stays mounted as last child; sections insert before it (cardBuilder)
|
||
const indicator = createDiscoveryLoadingIndicator(container);
|
||
|
||
await window.cardBuilder.renderProgressiveSections(container, bufferedSections, {
|
||
revealSectionsSequentially,
|
||
enhanceOnVisible: true,
|
||
enhanceRootMargin: DISCOVERY_ENHANCE_ROOT_MARGIN,
|
||
showStaleDataBeforeRefresh: config.HOME_SETTINGS?.showStaleDataBeforeRefresh === true,
|
||
insertBefore: indicator || undefined,
|
||
});
|
||
|
||
createDiscoveryLoadingIndicator(container);
|
||
|
||
container.dataset.loadingDiscovery = 'false';
|
||
container.classList.remove('loading-discovery');
|
||
|
||
// Advance page/group only after the current logical group is fully drained
|
||
if (!state.discoveryGroupRemainder?.length) {
|
||
state.discoveryGroupIndex++;
|
||
}
|
||
|
||
state.discoveryBuffer = null;
|
||
if (PRE_FETCH_DISCOVERY_DATA) {
|
||
ensureDiscoveryBuffer();
|
||
}
|
||
|
||
// TV chevron: hand focus to first card of the first newly added section
|
||
if (
|
||
document.documentElement.classList.contains('layout-tv')
|
||
&& state.discoveryMode === 'chevron'
|
||
) {
|
||
const firstId = bufferedSections[0]?.config?.id;
|
||
if (firstId != null) {
|
||
const sectionIdAttr =
|
||
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||
? CSS.escape(String(firstId))
|
||
: String(firstId).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
const sectionEl = container.querySelector(`[data-section-id="${sectionIdAttr}"]`);
|
||
focusFirstCardInDiscoverySection(sectionEl);
|
||
}
|
||
}
|
||
|
||
} catch (e) {
|
||
ERR('Error rendering discovery group:', e);
|
||
state.discoveryBuffer = null;
|
||
createDiscoveryLoadingIndicator(container);
|
||
} finally {
|
||
state.isRenderingDiscovery = false;
|
||
if (state.discoverySectionsRemain !== false) {
|
||
hideDiscoveryLoadingIndicator();
|
||
container.dataset.loadingDiscovery = 'false';
|
||
container.classList.remove('loading-discovery');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generates a balanced group of discovery sections
|
||
*/
|
||
async function generateDiscoveryGroup() {
|
||
/* if (state.firstDiscoveryBuffer) {
|
||
const firstBufferConfig = state.firstDiscoveryBuffer;
|
||
delete state.firstDiscoveryBuffer;
|
||
localCache.clear('firstDiscoveryBuffer');
|
||
|
||
firstBufferConfig.forEach(config => {
|
||
config.order = state.discoverySectionOrder++;
|
||
});
|
||
|
||
return firstBufferConfig;
|
||
} */
|
||
|
||
/* let discoveryTemplates = flattenSectionGroups(Config.DISCOVERY_SECTION_GROUPS || []);
|
||
const userDiscoverySectionGroups = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig && window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS;
|
||
const userDiscoverySections = userDiscoverySectionGroups ? flattenSectionGroups(userDiscoverySectionGroups) : [];
|
||
|
||
if (userDiscoverySections.length > 0) {
|
||
discoveryTemplates = discoveryTemplates.map(tpl => {
|
||
const userSection = userDiscoverySections.find(s => s.id === tpl.id);
|
||
if (userSection) {
|
||
return { ...tpl, ...userSection };
|
||
}
|
||
return tpl;
|
||
});
|
||
} */
|
||
|
||
const selectedConfigs = [];
|
||
|
||
let discoveryTemplates = await getDiscoverySections();
|
||
|
||
if (getDiscoverySettings().randomizeOrder) {
|
||
discoveryTemplates = [...discoveryTemplates].sort(() => 0.5 - Math.random());
|
||
}
|
||
|
||
// Collect enabled templates unresolved — progressive resolve happens in ensureDiscoveryBuffer
|
||
for (const template of discoveryTemplates) {
|
||
if (template.enabled === false) continue;
|
||
if (window.sectionHelper?.isEmptyDiscoverySourceTemplate?.(template)) continue;
|
||
selectedConfigs.push(template);
|
||
}
|
||
|
||
if (getDiscoverySettings().renderSpotlightAboveMatching) {
|
||
const spotlightGenreSection = selectedConfigs.find((section) => {
|
||
const id = section.id.split('-')[0];
|
||
return id === 'spotlightGenre' || id === 'spotlightGenreSeries';
|
||
});
|
||
const spotlightNetworkSection = selectedConfigs.find(section => section.id.split('-')[0] === 'spotlightNetwork');
|
||
|
||
if (spotlightGenreSection) {
|
||
const spotlightBaseId = spotlightGenreSection.id.split('-')[0];
|
||
const genreBaseId = spotlightBaseId === 'spotlightGenreSeries' ? 'genreSeries' : 'genreMovies';
|
||
const genreSection = selectedConfigs.find(section => section.id.split('-')[0] === genreBaseId);
|
||
if (genreSection) {
|
||
const genreSectionIndex = selectedConfigs.indexOf(genreSection);
|
||
const spotlightGenreSectionIndex = selectedConfigs.indexOf(spotlightGenreSection);
|
||
|
||
if (genreSectionIndex < spotlightGenreSectionIndex) {
|
||
selectedConfigs.splice(genreSectionIndex, 1);
|
||
selectedConfigs.splice(spotlightGenreSectionIndex, 0, genreSection);
|
||
} else if (genreSectionIndex > spotlightGenreSectionIndex) {
|
||
selectedConfigs.splice(genreSectionIndex, 1);
|
||
selectedConfigs.splice(spotlightGenreSectionIndex + 1, 0, genreSection);
|
||
}
|
||
}
|
||
}
|
||
if (spotlightNetworkSection) {
|
||
const studioSection = selectedConfigs.find(section => section.id.split('-')[0] === 'studioShows');
|
||
if (studioSection) {
|
||
const studioSectionIndex = selectedConfigs.indexOf(studioSection);
|
||
const spotlightNetworkSectionIndex = selectedConfigs.indexOf(spotlightNetworkSection);
|
||
if (studioSectionIndex < spotlightNetworkSectionIndex) {
|
||
selectedConfigs.splice(studioSectionIndex, 1);
|
||
selectedConfigs.splice(spotlightNetworkSectionIndex, 0, studioSection);
|
||
} else if (studioSectionIndex > spotlightNetworkSectionIndex) {
|
||
selectedConfigs.splice(studioSectionIndex, 1);
|
||
selectedConfigs.splice(spotlightNetworkSectionIndex + 1, 0, studioSection);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply user prefs keyed by template prefix for non-custom discovery variants
|
||
try {
|
||
const userHomeConfig = window.KefinUserHomeScreenConfig;
|
||
if (userHomeConfig?.applyUserSectionOverrides && selectedConfigs.length) {
|
||
const prefs = state.getDisplayPrefernces
|
||
? await state.getDisplayPrefernces()
|
||
: null;
|
||
const homeScreen = userHomeConfig.parseKefinTweaksHomeScreen?.(prefs?.CustomPrefs)
|
||
|| userHomeConfig.createEmptyHomeScreen?.()
|
||
|| { sections: [] };
|
||
const serverSectionsById = new Map();
|
||
selectedConfigs.forEach((section) => {
|
||
const storedId = userHomeConfig.getStoredSectionPrefId?.(section) || section.id;
|
||
if (storedId && !serverSectionsById.has(storedId)) {
|
||
serverSectionsById.set(storedId, { ...section, id: storedId });
|
||
}
|
||
});
|
||
const overridden = userHomeConfig.applyUserSectionOverrides(
|
||
selectedConfigs,
|
||
homeScreen,
|
||
{ serverSectionsById }
|
||
);
|
||
selectedConfigs.length = 0;
|
||
selectedConfigs.push(...overridden.filter(s => s.enabled !== false));
|
||
}
|
||
} catch (e) {
|
||
WARN('Failed to apply user overrides to discovery sections:', e);
|
||
}
|
||
|
||
// Order + tag the full logical group, then cap what we resolve this pass.
|
||
// Leftovers are drained on subsequent ensureDiscoveryBuffer calls.
|
||
selectedConfigs.forEach((section) => {
|
||
section.order = state.discoverySectionOrder++;
|
||
// Mark discovery render set so section roots get data-discovery-section
|
||
// (templated discovery rows use Genre/Studio/etc. as type, not "discovery").
|
||
section.discoverySection = true;
|
||
});
|
||
|
||
const capped = selectedConfigs.slice(0, DISCOVERY_SECTIONS_PER_GROUP);
|
||
state.discoveryGroupRemainder = selectedConfigs.slice(DISCOVERY_SECTIONS_PER_GROUP);
|
||
|
||
//localCache.set('discoveryBuffer', capped);
|
||
|
||
return capped;
|
||
}
|
||
|
||
function setupInfiniteScroll(container) {
|
||
const target = container
|
||
|| document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
|
||
if (!target) return;
|
||
|
||
removeDiscoveryScrollListeners();
|
||
target.dataset.discoveryHandler = 'true';
|
||
|
||
let lastScrollTop = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||
|
||
const handleScroll = () => {
|
||
if (!isDiscoveryHomeContext()) return;
|
||
if (!target.dataset.sectionsRendered) return;
|
||
if (state.isRenderingDiscovery) return;
|
||
if (state.discoverySectionsRemain === false) return;
|
||
|
||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||
const delta = Math.abs(scrollTop - lastScrollTop);
|
||
lastScrollTop = scrollTop;
|
||
|
||
// After a group loads, ignore threshold until the user scrolls again
|
||
if (state.discoveryNeedsMoreScroll) {
|
||
if (delta < 2) return;
|
||
state.discoveryNeedsMoreScroll = false;
|
||
}
|
||
|
||
const windowHeight = window.innerHeight;
|
||
const documentHeight = document.documentElement.scrollHeight;
|
||
const threshold = windowHeight * 1.5;
|
||
|
||
if (scrollTop + windowHeight < documentHeight - threshold) return;
|
||
|
||
if (state.discoveryScrollDebounceTimer) {
|
||
clearTimeout(state.discoveryScrollDebounceTimer);
|
||
}
|
||
state.discoveryScrollDebounceTimer = setTimeout(() => {
|
||
state.discoveryScrollDebounceTimer = null;
|
||
if (state.isRenderingDiscovery || state.discoveryNeedsMoreScroll) return;
|
||
if (!isDiscoveryHomeContext()) return;
|
||
const st = window.pageYOffset || document.documentElement.scrollTop;
|
||
const wh = window.innerHeight;
|
||
const dh = document.documentElement.scrollHeight;
|
||
if (st + wh >= dh - wh * 1.5) {
|
||
renderNextDiscoveryGroup();
|
||
}
|
||
}, 200);
|
||
};
|
||
|
||
state.infiniteScrollHandler = handleScroll;
|
||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||
LOG('Infinite scroll enabled for discovery (debounced + scroll-gated re-arm).');
|
||
}
|
||
|
||
/**
|
||
* infiniteScroll=false: Discover More + chevron; load when user scrolls again at bottom.
|
||
*/
|
||
function setupChevronDiscoverMore(container) {
|
||
const target = container
|
||
|| document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
|
||
if (!target) return;
|
||
|
||
removeDiscoveryScrollListeners();
|
||
target.dataset.discoveryHandler = 'true';
|
||
createDiscoveryLoadingIndicator(target);
|
||
hideDiscoveryLoadingIndicator();
|
||
|
||
let lastTouchY = null;
|
||
|
||
const scheduleLoadFromBottomGesture = () => {
|
||
if (state.isRenderingDiscovery) return;
|
||
if (state.discoverySectionsRemain === false) return;
|
||
if (state.discoveryScrollDebounceTimer) {
|
||
clearTimeout(state.discoveryScrollDebounceTimer);
|
||
}
|
||
state.discoveryScrollDebounceTimer = setTimeout(() => {
|
||
state.discoveryScrollDebounceTimer = null;
|
||
if (state.isRenderingDiscovery) return;
|
||
if (!isDiscoveryHomeContext()) return;
|
||
LOG('User scrolled at bottom (chevron mode); loading next discovery group...');
|
||
renderNextDiscoveryGroup();
|
||
}, 200);
|
||
};
|
||
|
||
const isAtDocumentBottom = () => {
|
||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||
const windowHeight = window.innerHeight;
|
||
const documentHeight = document.documentElement.scrollHeight;
|
||
return scrollTop + windowHeight >= documentHeight - 2;
|
||
};
|
||
|
||
const handleWheel = (event) => {
|
||
if (!isDiscoveryHomeContext()) return;
|
||
if (!target.dataset.sectionsRendered) return;
|
||
if (state.isRenderingDiscovery) return;
|
||
if (event.deltaY <= 0) return;
|
||
if (!isAtDocumentBottom()) return;
|
||
scheduleLoadFromBottomGesture();
|
||
};
|
||
|
||
const handleTouchStart = (event) => {
|
||
const touch = event.touches && event.touches[0];
|
||
lastTouchY = touch ? touch.clientY : null;
|
||
};
|
||
|
||
const handleTouchMove = (event) => {
|
||
if (!isDiscoveryHomeContext()) return;
|
||
if (!target.dataset.sectionsRendered) return;
|
||
if (state.isRenderingDiscovery) return;
|
||
|
||
const touch = event.touches && event.touches[0];
|
||
if (!touch) return;
|
||
|
||
if (lastTouchY == null) {
|
||
lastTouchY = touch.clientY;
|
||
return;
|
||
}
|
||
|
||
const deltaY = touch.clientY - lastTouchY;
|
||
lastTouchY = touch.clientY;
|
||
|
||
// Upward swipe scrolls content down
|
||
if (deltaY >= 0) return;
|
||
if (!isAtDocumentBottom()) return;
|
||
scheduleLoadFromBottomGesture();
|
||
};
|
||
|
||
state.discoveryWheelHandler = handleWheel;
|
||
state.discoveryTouchStartHandler = handleTouchStart;
|
||
state.discoveryTouchMoveHandler = handleTouchMove;
|
||
window.addEventListener('wheel', handleWheel, { passive: true });
|
||
window.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||
window.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||
|
||
const indicator = createDiscoveryLoadingIndicator(target);
|
||
bindDiscoveryLoadMoreControl(indicator);
|
||
syncDiscoveryLoadMoreFocus(indicator);
|
||
LOG('Chevron Discover More enabled for discovery (scroll-again at bottom / TV Enter).');
|
||
}
|
||
|
||
function isInSeasonalPeriod(start, end) {
|
||
if (typeof window.KefinHomeScreen?.isInSeasonalPeriod === 'function'
|
||
&& window.KefinHomeScreen.isInSeasonalPeriod !== isInSeasonalPeriod) {
|
||
return window.KefinHomeScreen.isInSeasonalPeriod(start, end);
|
||
}
|
||
if (start == null || end == null || start === '' || end === '') return false;
|
||
const parseMonthDay = (value) => {
|
||
const parts = String(value).trim().split(/[-/.]/).map((p) => parseInt(p, 10));
|
||
if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null;
|
||
let month;
|
||
let day;
|
||
if (parts.length >= 3 && parts[0] > 31) {
|
||
month = parts[1];
|
||
day = parts[2];
|
||
} else {
|
||
month = parts[0];
|
||
day = parts[1];
|
||
}
|
||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||
return { month, day };
|
||
};
|
||
const startMd = parseMonthDay(start);
|
||
const endMd = parseMonthDay(end);
|
||
if (!startMd || !endMd) return false;
|
||
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const startDate = new Date(year, startMd.month - 1, startMd.day, 0, 0, 0, 0);
|
||
let endDate = new Date(year, endMd.month - 1, endMd.day, 23, 59, 59, 999);
|
||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return false;
|
||
|
||
if (endDate < startDate) {
|
||
if (now >= startDate) {
|
||
endDate.setFullYear(year + 1);
|
||
} else {
|
||
startDate.setFullYear(year - 1);
|
||
}
|
||
}
|
||
return now >= startDate && now <= endDate;
|
||
}
|
||
|
||
function isHalloweenPeriod() {
|
||
return isInSeasonalPeriod('10-01', '10-31');
|
||
}
|
||
|
||
function isChristmasPeriod() {
|
||
return isInSeasonalPeriod('12-01', '12-31');
|
||
}
|
||
|
||
function isNewYearsPeriod() {
|
||
return isInSeasonalPeriod('01-01', '01-05');
|
||
}
|
||
|
||
async function isValentinesPeriod() {
|
||
const homeScreenConfig = await window.KefinHomeScreen.getConfig();
|
||
const valentinesConfig = homeScreenConfig.SEASONAL_SECTION_GROUPS.find(s => s.id === 'seasonal-valentines');
|
||
if (!valentinesConfig) {
|
||
return false;
|
||
}
|
||
|
||
const activeSection = valentinesConfig.sections.find(s => s.enabled);
|
||
|
||
if (!activeSection) {
|
||
return false;
|
||
}
|
||
|
||
return isInSeasonalPeriod(activeSection.startDate, activeSection.endDate);
|
||
}
|
||
|
||
async function getPopularTVNetworks() {
|
||
if (!StudiosCache) return [];
|
||
|
||
// Get Minimum Series Count from config
|
||
|
||
await StudiosCache.init();
|
||
return StudiosCache.getPopularTVNetworks();
|
||
}
|
||
|
||
async function refreshHomeSections() {
|
||
const container = document.querySelector('.homePage:not(.hide) #homeTab .sections');
|
||
if (!container) {
|
||
LOG('refreshHomeSections: container not found');
|
||
return;
|
||
}
|
||
|
||
container.querySelectorAll('[data-section-id]').forEach(el => el.remove());
|
||
delete container.dataset.sectionsRendered;
|
||
delete container.dataset.kefinHomeScreen;
|
||
|
||
sectionCache.renderedSections = [];
|
||
sectionCache.fragmentCache = null;
|
||
sectionCache.isInitialRender = true;
|
||
|
||
return enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' });
|
||
}
|
||
|
||
const HOME_CHROME_CREATE_ID = 'kefin-home-create-section-btn';
|
||
const HOME_CHROME_CATEGORY_RAIL_ID = 'kefin-home-category-rail';
|
||
const HOME_CHROME_CATEGORY_STYLE_ID = 'kefin-home-category-filter-styles';
|
||
const HOME_CHROME_CLASSIC_RAIL_CLASSES = 'skinHeader skinHeader-withBackground emby-tabs-slider emby-tab-button';
|
||
const HOME_CHROME_MODERN_RAIL_CLASSES = 'MuiPaper-root MuiPaper-elevation MuiPaper-elevation1 MuiAppBar-root MuiAppBar-colorDefault MuiAppBar-positionFixed mui-fixed';
|
||
let homeChromeActiveCategory = 'none';
|
||
let homeChromeCreateMounted = false;
|
||
let homeChromeCreateMounting = false;
|
||
let homeChromeCategoryMounted = false;
|
||
let homeChromeHasPinnedContent = false;
|
||
let homeChromeVisibilityObserver = null;
|
||
let homeChromeRailScrollBound = false;
|
||
let homeChromeRailLastY = 0;
|
||
const HOME_CHROME_RAIL_SCROLL_DELTA = 8;
|
||
const HOME_CHROME_RAIL_MOBILE_MQ = '(max-width: 899px)';
|
||
|
||
function isHomeCategoryRailMobile() {
|
||
return typeof window.matchMedia === 'function'
|
||
? window.matchMedia(HOME_CHROME_RAIL_MOBILE_MQ).matches
|
||
: window.innerWidth < 900;
|
||
}
|
||
|
||
function setHomeCategoryRailScrollHidden(hidden) {
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (!rail) return;
|
||
rail.classList.toggle('is-scroll-hidden', !!hidden);
|
||
}
|
||
|
||
function clearHomeCategoryRailScrollHidden() {
|
||
setHomeCategoryRailScrollHidden(false);
|
||
}
|
||
|
||
function handleHomeCategoryRailScroll() {
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (!rail || rail.classList.contains('hide') || !isHomeCategoryRailMobile()) {
|
||
clearHomeCategoryRailScrollHidden();
|
||
return;
|
||
}
|
||
const y = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||
if (y <= HOME_CHROME_RAIL_SCROLL_DELTA) {
|
||
clearHomeCategoryRailScrollHidden();
|
||
homeChromeRailLastY = y;
|
||
return;
|
||
}
|
||
const delta = y - homeChromeRailLastY;
|
||
if (Math.abs(delta) < HOME_CHROME_RAIL_SCROLL_DELTA) return;
|
||
if (delta > 0) {
|
||
setHomeCategoryRailScrollHidden(true);
|
||
} else {
|
||
setHomeCategoryRailScrollHidden(false);
|
||
}
|
||
homeChromeRailLastY = y;
|
||
}
|
||
|
||
function syncHomeCategoryRailScrollListener() {
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
const showFilters = getHomeSettings().showCategoryFilters !== false;
|
||
const shouldListen = !!(rail && !rail.classList.contains('hide') && isHomePageVisible() && showFilters && isHomeCategoryRailMobile());
|
||
if (shouldListen) {
|
||
if (!homeChromeRailScrollBound) {
|
||
homeChromeRailLastY = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||
window.addEventListener('scroll', handleHomeCategoryRailScroll, { passive: true });
|
||
window.addEventListener('resize', syncHomeCategoryRailScrollListener, { passive: true });
|
||
homeChromeRailScrollBound = true;
|
||
}
|
||
if (!isHomeCategoryRailMobile()) clearHomeCategoryRailScrollHidden();
|
||
return;
|
||
}
|
||
if (homeChromeRailScrollBound) {
|
||
window.removeEventListener('scroll', handleHomeCategoryRailScroll);
|
||
window.removeEventListener('resize', syncHomeCategoryRailScrollListener);
|
||
homeChromeRailScrollBound = false;
|
||
}
|
||
clearHomeCategoryRailScrollHidden();
|
||
}
|
||
|
||
function getHomeSettings() {
|
||
return window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS
|
||
|| window.KefinHomeConfig2?.HOME_SETTINGS
|
||
|| {};
|
||
}
|
||
|
||
function getConfiguredCategories() {
|
||
const defaults = window.KefinHomeConfig2?.HOME_SETTINGS?.categories || [];
|
||
const configured = getHomeSettings().categories;
|
||
if (Array.isArray(configured) && configured.length) return configured;
|
||
return defaults;
|
||
}
|
||
|
||
function sectionIsPinnedCategory(section) {
|
||
const id = String(section?.id || '');
|
||
return id.startsWith('pinned-list-')
|
||
|| id.startsWith('pinned-parent-')
|
||
|| section?.category === 'pinned';
|
||
}
|
||
|
||
function updatePinnedCategoryAvailability(sections, homeScreen) {
|
||
const fromSections = (sections || []).some(s => sectionIsPinnedCategory(s) && s.enabled !== false);
|
||
homeChromeHasPinnedContent = fromSections;
|
||
}
|
||
|
||
function getRailCategories() {
|
||
return getConfiguredCategories().filter((cat) => {
|
||
if (cat?.id === 'pinned') return homeChromeHasPinnedContent;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function isModernUIChrome() {
|
||
return !!document.querySelector('main.MuiBox-root') || localStorage.getItem('layout')?.length === 0;
|
||
}
|
||
|
||
function getCategoryRailNativeClasses() {
|
||
const base = 'kefin-home-chrome kefin-home-category-rail';
|
||
if (isModernUIChrome()) {
|
||
return `${base} ${HOME_CHROME_MODERN_RAIL_CLASSES}`;
|
||
}
|
||
return `${base} ${HOME_CHROME_CLASSIC_RAIL_CLASSES}`;
|
||
}
|
||
|
||
function isHomePageVisible() {
|
||
const active = document.documentElement.getAttribute('data-active-page');
|
||
if (active !== null) {
|
||
return active === 'home';
|
||
}
|
||
return !!document.querySelector('.homePage:not(.hide) #homeTab.is-active');
|
||
}
|
||
|
||
function syncHomeScreenChromeVisibility() {
|
||
const onHome = isHomePageVisible();
|
||
const createBtn = document.getElementById(HOME_CHROME_CREATE_ID);
|
||
if (createBtn) {
|
||
createBtn.classList.toggle('hide', !onHome);
|
||
createBtn.setAttribute('aria-hidden', onHome ? 'false' : 'true');
|
||
}
|
||
const showFilters = getHomeSettings().showCategoryFilters !== false;
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (rail) {
|
||
const visible = onHome && showFilters;
|
||
rail.classList.toggle('hide', !visible);
|
||
rail.setAttribute('aria-hidden', visible ? 'false' : 'true');
|
||
if (!visible) clearHomeCategoryRailScrollHidden();
|
||
}
|
||
if (onHome) {
|
||
applyActiveHomeCategory(homeChromeActiveCategory || 'none');
|
||
}
|
||
syncHomeCategoryRailScrollListener();
|
||
syncCategoryRailTvFocus();
|
||
}
|
||
|
||
function disconnectHomeChromeVisibilityObserver() {
|
||
if (!homeChromeVisibilityObserver) return;
|
||
homeChromeVisibilityObserver.disconnect();
|
||
homeChromeVisibilityObserver = null;
|
||
}
|
||
|
||
function ensureHomeChromeVisibilityObserver() {
|
||
// Attributes+class needed for tab hide/show — dedicated MO, page-scoped when possible.
|
||
// Disconnect off home so we do not keep a body-wide attributes+subtree watcher alive.
|
||
if (!isHomePageVisible()) {
|
||
disconnectHomeChromeVisibilityObserver();
|
||
return;
|
||
}
|
||
|
||
const homePage = document.querySelector('.homePage:not(.hide)') || document.querySelector('.homePage');
|
||
const target = homePage || document.documentElement;
|
||
const config = homePage
|
||
? { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }
|
||
: { attributes: true, attributeFilter: ['data-active-page', 'class'] };
|
||
|
||
if (homeChromeVisibilityObserver) {
|
||
const sameTarget = homeChromeVisibilityObserver.__kefinTarget === target;
|
||
const sameMode = homeChromeVisibilityObserver.__kefinNarrow === !homePage;
|
||
if (sameTarget && sameMode) return;
|
||
disconnectHomeChromeVisibilityObserver();
|
||
}
|
||
homeChromeVisibilityObserver = new MutationObserver(() => syncHomeScreenChromeVisibility());
|
||
homeChromeVisibilityObserver.__kefinTarget = target;
|
||
homeChromeVisibilityObserver.__kefinNarrow = !homePage;
|
||
homeChromeVisibilityObserver.observe(target, config);
|
||
}
|
||
|
||
async function mountCreateSectionButton() {
|
||
const existingNodes = document.querySelectorAll(`#${HOME_CHROME_CREATE_ID}`);
|
||
if (existingNodes.length > 1) {
|
||
existingNodes.forEach((el, idx) => {
|
||
if (idx > 0) el.remove();
|
||
});
|
||
}
|
||
if (homeChromeCreateMounted || homeChromeCreateMounting || document.getElementById(HOME_CHROME_CREATE_ID)) {
|
||
homeChromeCreateMounted = !!document.getElementById(HOME_CHROME_CREATE_ID);
|
||
return;
|
||
}
|
||
if (getHomeSettings().showCreateSectionButtonOnHome !== true) return;
|
||
|
||
homeChromeCreateMounting = true;
|
||
try {
|
||
let isAdmin = false;
|
||
try {
|
||
isAdmin = !!(await window.apiHelper?.isAdmin?.());
|
||
} catch {
|
||
return;
|
||
}
|
||
if (!isAdmin) return;
|
||
|
||
// Re-check after await — another caller may have mounted meanwhile
|
||
if (document.getElementById(HOME_CHROME_CREATE_ID)) {
|
||
homeChromeCreateMounted = true;
|
||
return;
|
||
}
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.id = HOME_CHROME_CREATE_ID;
|
||
wrap.className = 'kefin-home-chrome kefin-home-create-section';
|
||
wrap.classList.toggle('hide', !isHomePageVisible());
|
||
wrap.setAttribute('aria-hidden', isHomePageVisible() ? 'false' : 'true');
|
||
wrap.innerHTML = `
|
||
<button type="button" class="paper-icon-button-light emby-button kefin-home-create-section-btn" title="Create Section" aria-label="Create Section">
|
||
<span class="material-icons" aria-hidden="true">add_circle</span>
|
||
</button>
|
||
`;
|
||
wrap.querySelector('button')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
try {
|
||
window.KefinHomeScreen?.openNewCustomSection?.({ refreshHomeOnSave: true });
|
||
} catch (err) {
|
||
ERR('Failed to open create section from home:', err);
|
||
}
|
||
});
|
||
document.body.appendChild(wrap);
|
||
homeChromeCreateMounted = true;
|
||
} finally {
|
||
homeChromeCreateMounting = false;
|
||
}
|
||
}
|
||
|
||
function buildCategoryFilterStyles(categories) {
|
||
const rules = [];
|
||
rules.push(`
|
||
.homeSectionsContainer[data-render-categories-separately="true"][data-category="none"] > [data-section-id]:not([data-category="none"]) {
|
||
display: none !important;
|
||
}
|
||
`);
|
||
(categories || []).forEach((cat) => {
|
||
const id = String(cat?.id || '').trim();
|
||
if (!id || id === 'none') return;
|
||
const safe = CSS.escape(id);
|
||
rules.push(`
|
||
.homeSectionsContainer[data-category="${safe}"] > :not([data-category="${safe}"]):not(#discovery-loading-indicator):not(.discovery-loading-indicator) {
|
||
display: none !important;
|
||
}
|
||
`);
|
||
});
|
||
return rules.join('\n');
|
||
}
|
||
|
||
function ensureCategoryFilterStyles() {
|
||
let styleEl = document.getElementById(HOME_CHROME_CATEGORY_STYLE_ID);
|
||
if (!styleEl) {
|
||
styleEl = document.createElement('style');
|
||
styleEl.id = HOME_CHROME_CATEGORY_STYLE_ID;
|
||
document.head.appendChild(styleEl);
|
||
}
|
||
styleEl.textContent = buildCategoryFilterStyles(getConfiguredCategories());
|
||
}
|
||
|
||
/**
|
||
* True when at least one Kefin Home-category section (data-category="none") is in the DOM.
|
||
* Discovery-only remounts must not count as "home already rendered".
|
||
* @param {ParentNode|null|undefined} root
|
||
* @returns {boolean}
|
||
*/
|
||
function hasHomeCategorySections(root) {
|
||
const scope = root || document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer');
|
||
if (!scope) return false;
|
||
return !!scope.querySelector('[data-section-id][data-category="none"]');
|
||
}
|
||
|
||
/**
|
||
* Before category CSS hides other sections: pick the target-category section to scroll to.
|
||
* Last match that has reached the viewport (top <= viewport bottom); if all are still below, use the first.
|
||
* @param {HTMLElement} container
|
||
* @param {string} categoryId
|
||
* @returns {HTMLElement|null}
|
||
*/
|
||
function pickHomeCategoryScrollTarget(container, categoryId) {
|
||
if (!container || !categoryId) return null;
|
||
const safe = typeof CSS !== 'undefined' && CSS.escape
|
||
? CSS.escape(categoryId)
|
||
: String(categoryId).replace(/"/g, '\\"');
|
||
const matches = Array.from(
|
||
container.querySelectorAll(`[data-section-id][data-category="${safe}"]`)
|
||
);
|
||
if (!matches.length) return null;
|
||
|
||
const viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0;
|
||
let lastSeen = null;
|
||
for (let i = 0; i < matches.length; i++) {
|
||
const rect = matches[i].getBoundingClientRect();
|
||
if (rect.top <= viewportBottom) lastSeen = matches[i];
|
||
else break;
|
||
}
|
||
return lastSeen || matches[0];
|
||
}
|
||
|
||
function scrollHomeSectionToStart(el) {
|
||
if (!el) return;
|
||
try {
|
||
el.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||
} catch (_) {
|
||
try {
|
||
el.scrollIntoView({ block: 'start', behavior: 'auto' });
|
||
} catch (__) {
|
||
el.scrollIntoView(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyActiveHomeCategory(categoryId) {
|
||
const categories = getRailCategories();
|
||
const next = categories.some((c) => c.id === categoryId) ? categoryId : 'none';
|
||
const prev = homeChromeActiveCategory || 'none';
|
||
const categoryChanged = prev !== next;
|
||
const separate = getHomeSettings().renderCategoriesSeparately === true;
|
||
|
||
const visibleContainer = document.querySelector(
|
||
'.libraryPage:not(.hide) .homeSectionsContainer, .homePage:not(.hide) .homeSectionsContainer'
|
||
);
|
||
|
||
homeChromeActiveCategory = next;
|
||
|
||
document.querySelectorAll('.homeSectionsContainer').forEach((container) => {
|
||
container.dataset.category = next;
|
||
container.dataset.renderCategoriesSeparately = separate ? 'true' : 'false';
|
||
});
|
||
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (rail) {
|
||
rail.dataset.category = next;
|
||
rail.querySelectorAll('.kefin-home-category-item').forEach((item) => {
|
||
const active = item.dataset.categoryId === next;
|
||
item.classList.toggle('is-active', active);
|
||
const btn = item.querySelector('.kefin-home-category-btn');
|
||
if (!btn) return;
|
||
btn.classList.toggle('is-active', active);
|
||
btn.classList.toggle('emby-tab-button-active', active);
|
||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
// Always scroll to the top of the screen, regardless of categoryChanged or visibleContainer
|
||
if (categoryChanged && visibleContainer) {
|
||
requestAnimationFrame(() => {
|
||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||
});
|
||
}
|
||
|
||
|
||
if (next === 'discovery' && isDiscoveryAllowedForActiveCategory()) {
|
||
const container = visibleContainer;
|
||
if (container) {
|
||
const hasDiscovery = !!(
|
||
container.querySelector('[data-discovery-section="true"], [data-category="discovery"]')
|
||
);
|
||
if (!hasDiscovery) {
|
||
try {
|
||
renderNextDiscoveryGroup();
|
||
} catch (err) {
|
||
ERR('Failed to bootstrap discovery group for Discover category:', err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function isDiscoveryAllowedForActiveCategory() {
|
||
const separate = getHomeSettings().renderCategoriesSeparately === true;
|
||
const active = homeChromeActiveCategory || 'none';
|
||
if (separate) return active === 'discovery';
|
||
return active === 'none' || active === 'discovery';
|
||
}
|
||
|
||
function mountCategoryRail() {
|
||
if (getHomeSettings().showCategoryFilters === false) {
|
||
const existing = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (existing) existing.remove();
|
||
homeChromeCategoryMounted = false;
|
||
ensureCategoryFilterStyles();
|
||
homeChromeActiveCategory = 'none';
|
||
applyActiveHomeCategory('none');
|
||
syncHomeCategoryRailScrollListener();
|
||
return;
|
||
}
|
||
|
||
if (homeChromeCategoryMounted && document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID)) {
|
||
ensureCategoryFilterStyles();
|
||
applyActiveHomeCategory(homeChromeActiveCategory || 'none');
|
||
return;
|
||
}
|
||
|
||
const categories = getRailCategories();
|
||
ensureCategoryFilterStyles();
|
||
|
||
const desiredCategory = categories.some((c) => c.id === homeChromeActiveCategory)
|
||
? homeChromeActiveCategory
|
||
: 'none';
|
||
|
||
const outer = document.createElement('div');
|
||
outer.id = HOME_CHROME_CATEGORY_RAIL_ID;
|
||
outer.className = 'sectionTabs';
|
||
outer.classList.toggle('hide', !isHomePageVisible());
|
||
outer.setAttribute('role', 'toolbar');
|
||
outer.setAttribute('aria-label', 'Home categories');
|
||
outer.setAttribute('aria-hidden', isHomePageVisible() ? 'false' : 'true');
|
||
|
||
const inner = document.createElement('div');
|
||
inner.className = getCategoryRailNativeClasses();
|
||
inner.innerHTML = categories.map((cat) => {
|
||
const id = escapeHtmlChrome(cat.id);
|
||
const rawName = typeof cat.name === 'string' ? cat.name.trim() : '';
|
||
const name = escapeHtmlChrome(rawName || cat.id);
|
||
const icon = escapeHtmlChrome(cat.icon || 'label');
|
||
const active = desiredCategory === cat.id;
|
||
const labelHtml = rawName
|
||
? `<span class="kefin-home-category-btn-label">${escapeHtmlChrome(rawName)}</span>`
|
||
: '';
|
||
return `
|
||
<div class="kefin-home-category-item${active ? ' is-active' : ''}" data-category-id="${id}">
|
||
<button type="button"
|
||
class="paper-icon-button-light emby-button kefin-home-category-btn${active ? ' is-active emby-tab-button-active' : ''}"
|
||
data-category-id="${id}"
|
||
title="${name}"
|
||
aria-label="${name}"
|
||
aria-pressed="${active ? 'true' : 'false'}">
|
||
<span class="material-icons emby-button-foreground ${icon}" aria-hidden="true"></span>
|
||
</button>
|
||
${labelHtml}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
outer.appendChild(inner);
|
||
outer.addEventListener('click', (e) => {
|
||
const item = e.target.closest('.kefin-home-category-item');
|
||
const btn = item?.querySelector('.kefin-home-category-btn') || e.target.closest('.kefin-home-category-btn');
|
||
if (!btn) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
applyActiveHomeCategory(btn.dataset.categoryId || item?.dataset.categoryId || 'none');
|
||
btn.blur();
|
||
});
|
||
|
||
const slides = document.getElementById('slides-container');
|
||
if (slides && slides.parentNode === document.body) {
|
||
document.body.insertBefore(outer, slides);
|
||
} else {
|
||
document.body.appendChild(outer);
|
||
}
|
||
homeChromeCategoryMounted = true;
|
||
applyActiveHomeCategory(desiredCategory);
|
||
syncHomeCategoryRailScrollListener();
|
||
syncCategoryRailTvFocus();
|
||
}
|
||
|
||
function isTvLayoutChrome() {
|
||
return document.documentElement.classList.contains('layout-tv');
|
||
}
|
||
|
||
let homeChromeRailReturnFocus = null;
|
||
let homeChromeRailKeyHandler = null;
|
||
|
||
function getRailCategoryButtons() {
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (!rail || rail.classList.contains('hide')) return [];
|
||
return Array.from(rail.querySelectorAll('.kefin-home-category-btn'));
|
||
}
|
||
|
||
function isFocusInsideCategoryRail(el) {
|
||
return !!el?.closest?.(`#${HOME_CHROME_CATEGORY_RAIL_ID}`);
|
||
}
|
||
|
||
function isLeftmostContentFocus(el) {
|
||
if (!el || isFocusInsideCategoryRail(el)) return false;
|
||
// Skip when a Kefin/Jellyfin dialog owns focus
|
||
if (el.closest?.('.dialogContainer, .focuscontainer.dialog, .kefin-section-configure-popover')) {
|
||
return false;
|
||
}
|
||
|
||
// Undrilled spotlight metadata is a leftmost content stop (Left → category rail)
|
||
if (el.classList?.contains('metadata-container') && el.dataset?.metadataDrilled !== 'true') {
|
||
return true;
|
||
}
|
||
|
||
const row = el.closest?.('.focuscontainer-x, .itemsContainer-tv, .sectionTitleContainer, .spotlight-section-title-container');
|
||
if (row) {
|
||
const focusables = Array.from(
|
||
row.querySelectorAll(
|
||
'button:not([disabled]):not([tabindex="-1"]), a[href]:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])',
|
||
),
|
||
).filter((node) => {
|
||
const style = window.getComputedStyle(node);
|
||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||
});
|
||
if (focusables.length && focusables[0] === el) return true;
|
||
// First card in a horizontal row
|
||
const cards = focusables.filter((n) => n.classList?.contains('card') || n.classList?.contains('itemAction'));
|
||
if (cards.length && cards[0] === el) return true;
|
||
}
|
||
|
||
// Geometric fallback: among visible home content focusables, this is among the leftmost
|
||
const homeRoot =
|
||
document.querySelector('.homeSectionsContainer, .sections.homeSectionsContainer, .homePage') || document.body;
|
||
const candidates = Array.from(
|
||
homeRoot.querySelectorAll(
|
||
'button:not([disabled]):not([tabindex="-1"]), a[href]:not([tabindex="-1"]), .card.itemAction, .itemAction[data-action="link"]',
|
||
),
|
||
).filter((node) => {
|
||
if (isFocusInsideCategoryRail(node)) return false;
|
||
if (node.closest?.('.dialogContainer, .focuscontainer.dialog')) return false;
|
||
const r = node.getBoundingClientRect();
|
||
return r.width > 0 && r.height > 0;
|
||
});
|
||
if (!candidates.length) return false;
|
||
let minLeft = Infinity;
|
||
candidates.forEach((node) => {
|
||
minLeft = Math.min(minLeft, node.getBoundingClientRect().left);
|
||
});
|
||
const elLeft = el.getBoundingClientRect().left;
|
||
return elLeft <= minLeft + 8;
|
||
}
|
||
|
||
function focusCategoryRailFromContent(fromEl) {
|
||
const buttons = getRailCategoryButtons();
|
||
if (!buttons.length) return false;
|
||
homeChromeRailReturnFocus = fromEl;
|
||
const active =
|
||
buttons.find((b) => b.classList.contains('is-active') || b.getAttribute('aria-pressed') === 'true') ||
|
||
buttons[0];
|
||
try {
|
||
active.focus({ preventScroll: true });
|
||
} catch (_) {
|
||
active.focus();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function isVisibleFocusTarget(el) {
|
||
if (!el || !document.contains(el)) return false;
|
||
const style = window.getComputedStyle(el);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
|
||
return false;
|
||
}
|
||
const rect = el.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
function getFirstFocusableInDiscoverySection(sectionEl) {
|
||
if (!sectionEl) return null;
|
||
const card = Array.from(
|
||
sectionEl.querySelectorAll(
|
||
'.card.itemAction:not([tabindex="-1"]), button.card:not([tabindex="-1"]), a.card:not([tabindex="-1"])',
|
||
),
|
||
).find((node) => isVisibleFocusTarget(node));
|
||
if (card) return card;
|
||
|
||
// Spotlight / non-card sections: first visible focusable
|
||
return Array.from(
|
||
sectionEl.querySelectorAll(
|
||
'button:not([disabled]):not([tabindex="-1"]), a[href]:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])',
|
||
),
|
||
).find((node) => isVisibleFocusTarget(node)) || null;
|
||
}
|
||
|
||
function focusFirstCardInDiscoverySection(sectionEl) {
|
||
const tryFocus = () => {
|
||
const target = getFirstFocusableInDiscoverySection(sectionEl);
|
||
if (!target || typeof target.focus !== 'function') return false;
|
||
try {
|
||
target.focus({ preventScroll: true });
|
||
} catch (_) {
|
||
target.focus();
|
||
}
|
||
try {
|
||
target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
return true;
|
||
};
|
||
|
||
if (tryFocus()) return;
|
||
// Skeleton may not have cards yet — one frame retry
|
||
requestAnimationFrame(() => {
|
||
tryFocus();
|
||
});
|
||
}
|
||
|
||
/** Live spotlight target after slide change (inactive/drilled nodes go stale). */
|
||
function getActiveSpotlightReturnFocus() {
|
||
const activeSlide = document.querySelector(
|
||
'.homeSectionsContainer .spotlight-item[data-active="true"], .sections .spotlight-item[data-active="true"]',
|
||
);
|
||
if (!activeSlide) return null;
|
||
const meta = activeSlide.querySelector('.metadata-container');
|
||
if (meta && isVisibleFocusTarget(meta)) {
|
||
if (meta.dataset.metadataDrilled === 'true') {
|
||
meta.dataset.metadataDrilled = 'false';
|
||
meta.setAttribute('tabindex', '0');
|
||
meta.querySelectorAll(
|
||
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
|
||
).forEach((link) => link.setAttribute('tabindex', '-1'));
|
||
}
|
||
return meta;
|
||
}
|
||
const header = activeSlide.closest('.spotlight-section')?.querySelector('.spotlight-header-container')
|
||
|| document.querySelector('.spotlight-section .spotlight-header-container');
|
||
if (header) {
|
||
const focusable = Array.from(
|
||
header.querySelectorAll(
|
||
'button:not([disabled]):not([tabindex="-1"]), a[href]:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])',
|
||
),
|
||
).find((node) => isVisibleFocusTarget(node));
|
||
if (focusable) return focusable;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function exitCategoryRailToContent() {
|
||
const buttons = getRailCategoryButtons();
|
||
let returnEl = homeChromeRailReturnFocus;
|
||
homeChromeRailReturnFocus = null;
|
||
|
||
if (!isVisibleFocusTarget(returnEl)) {
|
||
returnEl = getActiveSpotlightReturnFocus();
|
||
}
|
||
if (!isVisibleFocusTarget(returnEl)) {
|
||
returnEl = document.querySelector(
|
||
'.homeSectionsContainer .card.itemAction, .homeSectionsContainer a.sectionTitle-link, .sections .card.itemAction',
|
||
);
|
||
}
|
||
if (returnEl && typeof returnEl.focus === 'function' && isVisibleFocusTarget(returnEl)) {
|
||
try {
|
||
returnEl.focus({ preventScroll: true });
|
||
} catch (_) {
|
||
returnEl.focus();
|
||
}
|
||
return true;
|
||
}
|
||
if (buttons[0]) buttons[0].blur();
|
||
return false;
|
||
}
|
||
|
||
function moveCategoryRailFocus(delta) {
|
||
const buttons = getRailCategoryButtons();
|
||
if (!buttons.length) return;
|
||
const active = document.activeElement;
|
||
const idx = Math.max(0, buttons.indexOf(active));
|
||
const next = buttons[Math.min(buttons.length - 1, Math.max(0, idx + delta))];
|
||
if (next && next !== active) {
|
||
try {
|
||
next.focus({ preventScroll: true });
|
||
} catch (_) {
|
||
next.focus();
|
||
}
|
||
}
|
||
}
|
||
|
||
function onCategoryRailTvKeydown(e) {
|
||
if (!isTvLayoutChrome()) return;
|
||
const key = e.key;
|
||
if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown') return;
|
||
|
||
const active = document.activeElement;
|
||
const inRail = isFocusInsideCategoryRail(active);
|
||
|
||
if (inRail) {
|
||
if (key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
exitCategoryRailToContent();
|
||
return;
|
||
}
|
||
if (key === 'ArrowUp' || key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
moveCategoryRailFocus(key === 'ArrowUp' ? -1 : 1);
|
||
return;
|
||
}
|
||
if (key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (key === 'ArrowLeft' && isLeftmostContentFocus(active)) {
|
||
if (focusCategoryRailFromContent(active)) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
}
|
||
}
|
||
|
||
function syncCategoryRailTvFocus() {
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
const buttons = getRailCategoryButtons();
|
||
const tv = isTvLayoutChrome();
|
||
|
||
buttons.forEach((btn) => {
|
||
if (tv) {
|
||
btn.setAttribute('tabindex', '-1');
|
||
btn.classList.add('show-focus');
|
||
} else {
|
||
btn.removeAttribute('tabindex');
|
||
btn.classList.remove('show-focus');
|
||
}
|
||
});
|
||
|
||
if (tv && rail && !homeChromeRailKeyHandler) {
|
||
homeChromeRailKeyHandler = onCategoryRailTvKeydown;
|
||
document.addEventListener('keydown', homeChromeRailKeyHandler, true);
|
||
} else if (!tv && homeChromeRailKeyHandler) {
|
||
document.removeEventListener('keydown', homeChromeRailKeyHandler, true);
|
||
homeChromeRailKeyHandler = null;
|
||
homeChromeRailReturnFocus = null;
|
||
}
|
||
}
|
||
|
||
function getMountedRailCategoryIds() {
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (!rail) return null;
|
||
return Array.from(rail.querySelectorAll('.kefin-home-category-btn'))
|
||
.map((btn) => btn.dataset.categoryId);
|
||
}
|
||
|
||
function refreshHomeCategoryChrome() {
|
||
ensureCategoryFilterStyles();
|
||
const desired = getRailCategories().map((c) => String(c?.id || ''));
|
||
const mounted = getMountedRailCategoryIds();
|
||
const same = Array.isArray(mounted)
|
||
&& mounted.length === desired.length
|
||
&& mounted.every((id, i) => id === desired[i]);
|
||
|
||
if (!same) {
|
||
const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID);
|
||
if (rail) rail.remove();
|
||
homeChromeCategoryMounted = false;
|
||
mountCategoryRail();
|
||
} else {
|
||
applyActiveHomeCategory(homeChromeActiveCategory || 'none');
|
||
}
|
||
syncHomeScreenChromeVisibility();
|
||
syncCategoryRailTvFocus();
|
||
}
|
||
|
||
function escapeHtmlChrome(value) {
|
||
return String(value ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
async function initHomeScreenChrome() {
|
||
ensureHomeChromeVisibilityObserver();
|
||
await mountCreateSectionButton();
|
||
mountCategoryRail();
|
||
syncHomeScreenChromeVisibility();
|
||
}
|
||
|
||
window.homeScreen3 = {
|
||
init: () => enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }),
|
||
refreshHomeSections,
|
||
resolveDiscoverySection: buildDiscoverySectionInstance,
|
||
sectionHelper: () => window.sectionHelper,
|
||
getActiveHomeCategory: () => homeChromeActiveCategory || 'none',
|
||
isDiscoveryAllowedForActiveCategory,
|
||
refreshHomeCategoryChrome
|
||
};
|
||
|
||
//enhanceHomeScreen();
|
||
connectHomeSectionsReadyObserver();
|
||
//initHomeScreenChrome();
|
||
|
||
// Serialize home-view work: latest-wins pending + one drain at a time
|
||
let homeViewPending = undefined; // undefined = empty; else { type, hash? }
|
||
let homeViewDrain = Promise.resolve();
|
||
|
||
function enqueueHomeViewJob(job) {
|
||
if (!job || !job.type) return homeViewDrain;
|
||
// Chrome must not overwrite a pending home job (enhance already syncs chrome)
|
||
if (job.type === 'chrome' && homeViewPending?.type === 'home') {
|
||
return homeViewDrain;
|
||
}
|
||
homeViewPending = job;
|
||
homeViewDrain = homeViewDrain
|
||
.then(async () => {
|
||
if (homeViewPending === undefined) return;
|
||
const next = homeViewPending;
|
||
homeViewPending = undefined;
|
||
|
||
if (next.type === 'chrome') {
|
||
syncHomeScreenChromeVisibility();
|
||
ensureHomeChromeVisibilityObserver();
|
||
return;
|
||
}
|
||
|
||
if (next.type === 'home') {
|
||
const hash = typeof next.hash === 'string' ? next.hash : '';
|
||
const hashParams = hash.includes('?') ? hash.split('?')[1] : '';
|
||
const urlParams = new URLSearchParams(hashParams);
|
||
const currentTab = urlParams.get('tab');
|
||
const currentTabIndex = currentTab ? parseInt(currentTab, 10) : 0;
|
||
|
||
if (currentTabIndex !== 0) {
|
||
syncHomeScreenChromeVisibility();
|
||
ensureHomeChromeVisibilityObserver();
|
||
return;
|
||
}
|
||
|
||
void manageBodyClasses().catch((err) => {
|
||
ERR('manageBodyClasses failed:', err);
|
||
});
|
||
await enhanceHomeScreen();
|
||
syncHomeScreenChromeVisibility();
|
||
ensureHomeChromeVisibilityObserver();
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
ERR('Home view queue failed:', err);
|
||
});
|
||
return homeViewDrain;
|
||
}
|
||
|
||
if (window.KefinTweaksUtils && typeof window.KefinTweaksUtils.onViewPage === 'function') {
|
||
window.KefinTweaksUtils.onViewPage(() => {
|
||
enqueueHomeViewJob({ type: 'chrome' });
|
||
}, { pages: [] });
|
||
|
||
window.KefinTweaksUtils.onViewPage((view, element, hash) => {
|
||
enqueueHomeViewJob({ type: 'home', hash: hash || '' });
|
||
}, { pages: ['home', 'home.html'] });
|
||
}
|
||
|
||
/* if (window.KefinTweaksUtils && typeof window.KefinTweaksUtils.onViewPage === 'function') {
|
||
window.KefinTweaksUtils.onViewPage(manageBodyClasses, { pages: [] });
|
||
} */
|
||
|
||
})();
|
||
|