// Jellyfin Home Screen Script
// Adds custom scrollable sections to the home screen
// Home screen sections are generated from playlists
// All playlists used for the home screen sections must be public
// Requires: cardBuilder.js, localStorageCache.js, utils.js modules to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks HomeScreen]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HomeScreen]', ...args);
const ERR = (...args) => console.error('[KefinTweaks HomeScreen]', ...args);
LOG('Initializing...');
// Add CSS for discovery section loading indicator
function addDiscoveryLoadingCSS() {
const styleId = 'discovery-loading-styles';
// Check if style already exists
if (document.getElementById(styleId)) {
return;
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
/* Discovery section loading indicator */
.discovery-loading-indicator {
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
padding: 20px;
border-radius: 8px;
font-size: 14px;
color: #666;
transition: opacity 0.3s ease;
order: 99999 !important;
position: relative;
}
.discovery-loading-indicator.show {
opacity: 1;
display: flex;
}
.discovery-loading-indicator .spinner {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
}
@keyframes discovery-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Discovery section smooth reveal animation */
.discovery-section-reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* When section enters viewport, trigger animation */
.discovery-section-reveal.in-viewport {
opacity: 1;
transform: translateY(0);
}
`;
document.head.appendChild(style);
LOG('Added discovery loading CSS styles');
}
// Create loading indicator element
function createDiscoveryLoadingIndicator() {
let loadingDiv = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
if (loadingDiv) {
return loadingDiv;
}
loadingDiv = document.createElement('div');
loadingDiv.className = 'discovery-loading-indicator';
loadingDiv.id = 'discovery-loading-indicator';
loadingDiv.style.visibility = 'hidden';
loadingDiv.innerHTML = `
`;
// Find the home sections container and append the loading indicator
const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
if (container) {
container.appendChild(loadingDiv);
}
return loadingDiv;
}
// Show loading indicator
function showDiscoveryLoadingIndicator() {
let loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
if (!loadingIndicator) {
createDiscoveryLoadingIndicator();
}
loadingIndicator.classList.add('show');
loadingIndicator.style.visibility = 'visible';
loadingIndicator.style.display = 'flex';
LOG('Discovery loading indicator shown');
}
// Hide loading indicator
function hideDiscoveryLoadingIndicator() {
const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
if (loadingIndicator) {
loadingIndicator.classList.remove('show');
loadingIndicator.style.visibility = 'hidden';
if (!enableInfiniteScroll) {
loadingIndicator.style.display = 'none';
}
LOG('Discovery loading indicator hidden');
}
}
// Body class management for home page and themes
function manageBodyClasses() {
const body = document.body;
const currentView = window.KefinTweaksUtils?.getCurrentView();
const isHomePage = currentView === 'home' || currentView === 'home.html';
// Get seasonal animation setting
const homeScreenConfig = window.KefinTweaksConfig?.homeScreen || {};
const seasonalConfig = homeScreenConfig.seasonal || {};
const enableSeasonalAnimations = seasonalConfig.enableSeasonalAnimations !== false;
// Manage home-screen class
if (isHomePage && body) {
body.classList.add('home-screen');
} else {
body.classList.remove('home-screen');
}
// Manage theme classes (only on home page)
if (isHomePage) {
// Remove all existing theme classes
const themeClasses = ['halloween-theme', 'christmas-theme', 'valentines-theme', 'newyear-theme'];
themeClasses.forEach(themeClass => body.classList.remove(themeClass));
// Add appropriate theme class based on current date
if (isHalloweenPeriod()) {
body.classList.add('halloween-theme');
} else if (isChristmasPeriod()) {
body.classList.add('christmas-theme');
if (enableSeasonalAnimations) {
// Import the Flurry module and leverage it from jQuery. /scripts/third party/flurry.js by appending it to the head
const flurryScriptSrc = (window.KefinTweaksConfig.kefinTweaksRoot || '') + 'scripts/third party/jquery.flurry.min.js';
// Function to run flurry safely
const startFlurry = () => {
if (window.jQuery && typeof window.jQuery.fn.flurry === 'function') {
window.jQuery("body").flurry();
}
};
// Check if script is already present
if (!document.querySelector(`script[src="${flurryScriptSrc}"]`)) {
const flurryScript = document.createElement('script');
flurryScript.src = flurryScriptSrc;
flurryScript.onload = startFlurry; // Run after load
document.head.appendChild(flurryScript);
} else {
// Script loaded, try running directly
startFlurry();
}
}
} else if (isValentinesPeriod()) {
body.classList.add('valentines-theme');
} else if (isNewYearsPeriod()) {
body.classList.add('newyear-theme');
}
} else {
// Remove all theme classes when not on home page
const themeClasses = ['halloween-theme', 'christmas-theme', 'valentines-theme', 'newyear-theme'];
themeClasses.forEach(themeClass => body.classList.remove(themeClass));
}
}
// Initialize body class management
manageBodyClasses();
// Initialize discovery loading CSS
addDiscoveryLoadingCSS();
// Set up page view monitoring
if (window.KefinTweaksUtils) {
window.KefinTweaksUtils.onViewPage(manageBodyClasses, {
pages: []
});
LOG('Registered body class management for home page');
} else {
WARN('KefinTweaksUtils not available, body class management may not work correctly');
}
/**
* Migrates old homeScreen config format to new format
* Small, transparent function that runs automatically
*/
function migrateHomeScreenConfig() {
if (!window.KefinTweaksConfig || !window.KefinTweaksConfig.homeScreen) {
return;
}
const old = window.KefinTweaksConfig.homeScreen;
const migrated = {};
let needsMigration = false;
// Check if old format exists (has enableNewAndTrending, etc.)
if (old.enableNewAndTrending !== undefined || old.enableNewMovies !== undefined) {
needsMigration = true;
// Migrate recently released
migrated.recentlyReleased = {
enabled: old.enableNewAndTrending !== false,
order: 30,
movies: {
enabled: old.enableNewMovies !== false,
itemLimit: 16,
sortOrder: "ReleaseDate",
sortOrderDirection: "Descending",
cardFormat: "Poster",
order: 30
},
episodes: {
enabled: old.enableNewEpisodes !== false,
itemLimit: 16,
sortOrder: "PremiereDate",
sortOrderDirection: "Descending",
cardFormat: "Backdrop",
order: 31
}
};
// Migrate trending
if (old.enableTrending !== undefined) {
migrated.trending = {
enabled: old.enableTrending || false,
itemLimit: 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 32
};
}
// Migrate popular TV networks
if (old.minimumShowsForNetwork !== undefined) {
migrated.popularTVNetworks = {
enabled: false,
minimumShowsForNetwork: old.minimumShowsForNetwork || 5,
itemLimit: 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 61
};
}
// Migrate watchlist
if (old.enableWatchlist !== undefined) {
migrated.watchlist = {
enabled: old.enableWatchlist || false,
itemLimit: 16,
sortOrder: "DateAdded",
sortOrderDirection: "Descending",
cardFormat: "Poster",
order: 60
};
}
// Migrate seasonal
if (old.enableSeasonal !== undefined) {
const seasons = [];
// Check if old format has seasons array
if (old.seasonal && old.seasonal.seasons && Array.isArray(old.seasonal.seasons)) {
// Migrate each season from old format to new format
for (const oldSeason of old.seasonal.seasons) {
const sections = [];
// Migrate genres to Genre sections
if (oldSeason.genres && Array.isArray(oldSeason.genres)) {
for (const genre of oldSeason.genres) {
sections.push({
id: `${oldSeason.id || 'season'}-genre-${genre.toLowerCase().replace(/\s+/g, '-')}`,
enabled: true,
name: `${genre} Movies`,
type: "Genre",
source: genre,
itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16,
sortOrder: oldSeason.sortOrder || "Random",
sortOrderDirection: oldSeason.sortOrderDirection || "Ascending",
cardFormat: oldSeason.cardFormat || "Poster",
order: sections.length + 50
});
}
}
// Migrate tags to Tag sections
if (oldSeason.tags && Array.isArray(oldSeason.tags)) {
for (const tag of oldSeason.tags) {
sections.push({
id: `${oldSeason.id || 'season'}-tag-${tag.toLowerCase().replace(/\s+/g, '-')}`,
enabled: true,
name: `${tag} Movies`,
type: "Tag",
source: tag,
itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16,
sortOrder: oldSeason.sortOrder || "Random",
sortOrderDirection: oldSeason.sortOrderDirection || "Ascending",
cardFormat: oldSeason.cardFormat || "Poster",
order: sections.length + 50
});
}
}
// Migrate collections to Collection sections
if (oldSeason.collections && Array.isArray(oldSeason.collections)) {
for (const collectionId of oldSeason.collections) {
sections.push({
id: `${oldSeason.id || 'season'}-collection-${collectionId}`,
enabled: true,
name: "Collection",
type: "Collection",
source: collectionId,
itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16,
sortOrder: oldSeason.sortOrder || "Random",
sortOrderDirection: oldSeason.sortOrderDirection || "Ascending",
cardFormat: oldSeason.cardFormat || "Poster",
order: sections.length + 50
});
}
}
// Migrate playlists to Playlist sections
if (oldSeason.playlists && Array.isArray(oldSeason.playlists)) {
for (const playlistId of oldSeason.playlists) {
sections.push({
id: `${oldSeason.id || 'season'}-playlist-${playlistId}`,
enabled: true,
name: "Playlist",
type: "Playlist",
source: playlistId,
itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16,
sortOrder: oldSeason.sortOrder || "Random",
sortOrderDirection: oldSeason.sortOrderDirection || "Ascending",
cardFormat: oldSeason.cardFormat || "Poster",
order: sections.length + 50
});
}
}
// If no sections were created, create a default one from genres if available
if (sections.length === 0 && oldSeason.genres && oldSeason.genres.length > 0) {
sections.push({
id: `${oldSeason.id || 'season'}-default`,
enabled: true,
name: oldSeason.name || "Seasonal Movies",
type: "Genre",
source: oldSeason.genres.join(', '),
itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16,
sortOrder: oldSeason.sortOrder || "Random",
sortOrderDirection: oldSeason.sortOrderDirection || "Ascending",
cardFormat: oldSeason.cardFormat || "Poster",
order: 50
});
}
seasons.push({
id: oldSeason.id || "halloween",
name: oldSeason.name || "Halloween",
enabled: oldSeason.enabled !== false,
startDate: oldSeason.startDate || "10-01",
endDate: oldSeason.endDate || "10-31",
sections: sections
});
}
} else {
// Default migration: create Halloween and Christmas seasons
seasons.push({
id: "halloween",
name: "Halloween",
enabled: true,
startDate: "10-01",
endDate: "10-31",
sections: [
{
id: "halloween-horror",
enabled: true,
name: "Horror Movies",
type: "Genre",
source: "Horror",
itemLimit: old.seasonalItemLimit || 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 50
},
{
id: "halloween-thriller",
enabled: true,
name: "Thriller Movies",
type: "Genre",
source: "Thriller",
itemLimit: old.seasonalItemLimit || 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 51
}
]
});
seasons.push({
id: "christmas",
name: "Christmas",
enabled: true,
startDate: "12-01",
endDate: "12-31",
sections: [
{
id: "christmas-genre",
enabled: true,
name: "Christmas Movies",
type: "Genre",
source: "Christmas",
itemLimit: old.seasonalItemLimit || 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 60
},
{
id: "christmas-family",
enabled: true,
name: "Family Movies",
type: "Genre",
source: "Family",
itemLimit: old.seasonalItemLimit || 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 61
}
]
});
}
migrated.seasonal = {
enabled: old.enableSeasonal !== false,
defaultItemLimit: old.seasonalItemLimit || 16,
defaultSortOrder: "Random",
defaultCardFormat: "Poster",
seasons: seasons
};
}
// Migrate existing seasonal config if it's in new format but old structure
if (old.seasonal && old.seasonal.seasons && Array.isArray(old.seasonal.seasons)) {
const needsRestructure = old.seasonal.seasons.some(season =>
season.genres || season.tags || season.collections || season.playlists
);
if (needsRestructure && !migrated.seasonal) {
// Already handled above, but ensure we don't lose it
migrated.seasonal = migrated.seasonal || {
enabled: old.seasonal.enabled !== false,
defaultItemLimit: old.seasonal.defaultItemLimit || 16,
defaultSortOrder: old.seasonal.defaultSortOrder || "Random",
defaultCardFormat: old.seasonal.defaultCardFormat || "Poster",
seasons: []
};
}
}
// Migrate discovery
if (old.enableDiscovery !== undefined || old.minPeopleAppearances !== undefined) {
migrated.discovery = {
enabled: old.enableDiscovery !== false,
infiniteScroll: old.enableInfiniteScroll !== false,
minPeopleAppearances: old.minPeopleAppearances || 10,
minGenreMovieCount: old.minGenreMovieCount || 50,
defaultItemLimit: 16,
defaultSortOrder: "Random",
defaultCardFormat: "Poster",
sectionTypes: {
spotlightGenre: true,
spotlightNetwork: true,
genreMovies: true,
studioShows: true,
collections: {
enabled: true,
minimumItems: 10,
itemLimit: null,
sortOrder: null,
sortOrderDirection: null,
cardFormat: null
},
becauseYouWatched: true,
becauseYouLiked: true,
starringTopActor: true,
directedByTopDirector: true,
writtenByTopWriter: true,
becauseYouRecentlyWatched: true,
starringActorRecentlyWatched: true,
directedByDirectorRecentlyWatched: true,
writtenByWriterRecentlyWatched: true
}
};
}
// Migrate custom sections
if (old.customSections && Array.isArray(old.customSections)) {
migrated.customSections = old.customSections.map(section => {
// If already in new format (has type and source), keep as is
if (section.type && section.source !== undefined) {
return section;
}
// Migrate from old format (sourceId) to new format
const sourceId = section.sourceId || section.id;
if (!sourceId) {
return section; // Can't migrate without sourceId
}
// Try to determine type from existing data or default to Collection
let type = section.type || 'Collection';
let source = section.source || sourceId;
// If no type specified, we'll auto-detect in renderCustomSection
// But for migration, default to Collection
return {
id: section.id || `custom-${Math.random().toString(36).substr(2, 9)}`,
enabled: section.enabled !== false,
name: section.name || 'Custom Section',
type: type,
source: source,
itemLimit: section.itemLimit || 16,
sortOrder: section.sortOrder || 'Random',
sortOrderDirection: section.sortOrderDirection || 'Ascending',
cardFormat: section.cardFormat || 'Poster',
order: section.order || 100,
spotlight: section.spotlight === true,
discoveryEnabled: section.discoveryEnabled === true
};
});
}
// Add new sections that didn't exist in old format (use defaults if not already present)
// These sections are new and won't be in old configs, so we add them with default values
if (!window.KefinTweaksConfig.homeScreen.upcoming) {
migrated.upcoming = {
enabled: true,
itemLimit: 48,
cardFormat: "Thumb",
order: 20
};
}
if (!window.KefinTweaksConfig.homeScreen.imdbTop250) {
migrated.imdbTop250 = {
enabled: true,
itemLimit: 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 21
};
}
if (!window.KefinTweaksConfig.homeScreen.watchAgain) {
migrated.watchAgain = {
enabled: false,
itemLimit: 16,
sortOrder: "Random",
sortOrderDirection: "Ascending",
cardFormat: "Poster",
order: 62
};
}
// Merge migrated config with existing (new format takes precedence)
window.KefinTweaksConfig.homeScreen = {
...migrated,
...window.KefinTweaksConfig.homeScreen
};
LOG('Migrated homeScreen config from old format to new format');
// Save migrated config back to JS Injector plugin (async, don't block)
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
window.KefinTweaksUtils.saveConfigToJavaScriptInjector().catch(err => {
ERR('Failed to save migrated config to JS Injector:', err);
});
} else {
WARN('KefinTweaksUtils.saveConfigToJavaScriptInjector not available, config migration not persisted');
}
}
// --- NEW UPDATE LOGIC ---
// Runs even if no major migration was needed, to catch specific updates
// 1. Ensure Recently Released date fields exist
let configChanged = false;
// Use the current config (which might have been just migrated)
const currentConfig = window.KefinTweaksConfig.homeScreen;
if (currentConfig.recentlyReleased) {
if (currentConfig.recentlyReleased.movies && currentConfig.recentlyReleased.movies.minPremiereDate === undefined) {
currentConfig.recentlyReleased.movies.minPremiereDate = "";
currentConfig.recentlyReleased.movies.maxPremiereDate = "";
configChanged = true;
}
if (currentConfig.recentlyReleased.episodes && currentConfig.recentlyReleased.episodes.minPremiereDate === undefined) {
currentConfig.recentlyReleased.episodes.minPremiereDate = "";
currentConfig.recentlyReleased.episodes.maxPremiereDate = "";
configChanged = true;
}
}
// 2. Update Seasonal Sections (Christmas/Halloween legacy IDs)
if (currentConfig.seasonal && Array.isArray(currentConfig.seasonal.seasons)) {
// Helper function to update a section if it matches an old ID
const updateSectionIfMatch = (season, oldId, newConfig) => {
if (!season.sections || !Array.isArray(season.sections)) return false;
const index = season.sections.findIndex(s => s.id === oldId);
if (index !== -1) {
season.sections[index] = newConfig;
return true;
}
return false;
};
// Iterate ALL seasons to find legacy sections, regardless of season ID/Name
for (const season of currentConfig.seasonal.seasons) {
if (!season.sections || !Array.isArray(season.sections)) continue;
// Update old "Christmas Movies" (genre) to new "Christmas Movies" (tag)
if (updateSectionIfMatch(season, 'christmas-genre', {
"id": "seasonal-1-section-0",
"enabled": true,
"name": "Christmas Movies",
"type": "Tag",
"source": "christmas",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 50,
"spotlight": true,
"discoveryEnabled": false,
"searchTerm": "",
"includeItemTypes": ["Movie"],
"additionalQueryOptions": []
})) {
configChanged = true;
}
// Update old "Family Movies" (genre) to new "Christmas Episodes" (parent/search)
if (updateSectionIfMatch(season, 'christmas-family', {
"id": "seasonal-1-section-1",
"enabled": true,
"name": "Christmas Episodes",
"type": "Parent",
"source": "",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Thumb",
"order": 51,
"spotlight": false,
"discoveryEnabled": false,
"searchTerm": "christmas",
"includeItemTypes": ["Episode"],
"additionalQueryOptions": []
})) {
configChanged = true;
}
// Update Halloween Horror (ensure correct config)
if (updateSectionIfMatch(season, 'halloween-horror', {
"id": "halloween-horror",
"enabled": true,
"name": "Horror Genre",
"type": "Genre",
"source": "Horror",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 51
})) {
configChanged = true;
}
// Update Halloween Thriller (ensure correct config)
if (updateSectionIfMatch(season, 'halloween-thriller', {
"id": "halloween-thriller",
"enabled": true,
"name": "Thriller Genre",
"type": "Genre",
"source": "Thriller",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 52
})) {
configChanged = true;
}
}
}
if (configChanged && !needsMigration) {
LOG('Applied specific configuration updates (recently released dates / christmas sections)');
// Save updated config back to JS Injector plugin
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
window.KefinTweaksUtils.saveConfigToJavaScriptInjector().catch(err => {
ERR('Failed to save updated config to JS Injector:', err);
});
}
}
}
// Run migration before reading config
migrateHomeScreenConfig();
// Read new config structure
const homeScreenConfig = window.KefinTweaksConfig?.homeScreen || {};
const defaultItemLimit = homeScreenConfig.defaultItemLimit || 16;
const defaultSortOrder = homeScreenConfig.defaultSortOrder || 'Random';
const defaultCardFormat = homeScreenConfig.defaultCardFormat || 'Poster';
// Spotlight configuration
const spotlightConfig = homeScreenConfig.spotlight || {};
const spotlightAutoPlay = spotlightConfig.autoPlay || true;
const spotlightInterval = spotlightConfig.interval || 5000;
const spotlightShowDots = spotlightConfig.showDots || true;
const spotlightShowNavButtons = spotlightConfig.showNavButtons || true;
const spotlightShowClearArt = spotlightConfig.showClearArt || true;
// Custom home sections configuration
const customHomeSections = homeScreenConfig.customSections || [];
// Recently Released sections configuration
const recentlyReleasedConfig = homeScreenConfig.recentlyReleased || {};
const enableNewAndTrending = recentlyReleasedConfig.enabled !== false;
const newMoviesConfig = recentlyReleasedConfig.movies || {};
const enableNewMovies = newMoviesConfig.enabled !== false;
const newEpisodesConfig = recentlyReleasedConfig.episodes || {};
const enableNewEpisodes = newEpisodesConfig.enabled !== false;
// Trending sections configuration
const trendingConfig = homeScreenConfig.trending || {};
const enableTrending = trendingConfig.enabled || false;
// Popular TV Networks configuration
const popularTVNetworksConfig = homeScreenConfig.popularTVNetworks || {};
const minimumShowsForNetwork = popularTVNetworksConfig.minimumShowsForNetwork || 5;
// Watchlist configuration
const watchlistConfig = homeScreenConfig.watchlist || {};
const enableWatchlist = watchlistConfig.enabled || false;
// Watch Again configuration
const watchAgainConfig = homeScreenConfig.watchAgain || {};
const enableWatchAgain = watchAgainConfig.enabled || false;
// Upcoming configuration
const upcomingConfig = homeScreenConfig.upcoming || {};
const enableUpcoming = upcomingConfig.enabled !== false;
// IMDb Top 250 configuration
const imdbTop250Config = homeScreenConfig.imdbTop250 || {};
const enableImdbTop250 = imdbTop250Config.enabled !== false;
// Seasonal configuration
const seasonalConfig = homeScreenConfig.seasonal || {};
const enableSeasonal = seasonalConfig.enabled !== false;
const enableSeasonalAnimations = seasonalConfig.enableSeasonalAnimations !== false;
const SEASONAL_ITEM_LIMIT = seasonalConfig.defaultItemLimit || 16;
// Discovery sections configuration
const discoveryConfig = homeScreenConfig.discovery || {};
const enableDiscovery = discoveryConfig.enabled !== false;
const enableInfiniteScroll = discoveryConfig.infiniteScroll !== false;
const minPeopleAppearances = discoveryConfig.minPeopleAppearances || 10;
const minGenreMovieCount = discoveryConfig.minGenreMovieCount || 50;
const DISCOVERY_ORDER = 1000; // Discovery sections use dynamic ordering
const DISCOVERY_SECTION_DEFINITIONS = [
{ key: 'spotlightGenre', defaultName: 'Spotlight' },
{ key: 'spotlightNetwork', defaultName: 'Spotlight' },
{ key: 'genreMovies', defaultName: '[Genre] Movies' },
{ key: 'studioShows', defaultName: 'Shows from [Studio]' },
{ key: 'collections', defaultName: '[Collection Name]', minimumItems: 10 },
{ key: 'becauseYouWatched', defaultName: 'Because you watched [Movie]' },
{ key: 'becauseYouLiked', defaultName: 'Because you liked [Movie]' },
{ key: 'starringTopActor', defaultName: 'Starring [Actor]' },
{ key: 'directedByTopDirector', defaultName: 'Directed by [Director]' },
{ key: 'writtenByTopWriter', defaultName: 'Written by [Writer]' },
{ key: 'becauseYouRecentlyWatched', defaultName: 'Because you recently watched [Movie]' },
{ key: 'starringActorRecentlyWatched', defaultName: 'Starring [Actor] because you recently watched [Movie]' },
{ key: 'directedByDirectorRecentlyWatched', defaultName: 'Directed by [Director] because you recently watched [Movie]' },
{ key: 'writtenByWriterRecentlyWatched', defaultName: 'Written by [Writer] because you recently watched [Movie]' }
];
const discoverySectionDefinitionMap = DISCOVERY_SECTION_DEFINITIONS.reduce((map, definition) => {
map[definition.key] = definition;
return map;
}, {});
const discoverySectionTypeMap = {
'genre': 'genreMovies',
'director': 'directedByTopDirector',
'writer': 'writtenByTopWriter',
'actor': 'starringTopActor',
'watched': 'becauseYouWatched',
'liked': 'becauseYouLiked',
'studio': 'studioShows',
'watched-recent': 'becauseYouRecentlyWatched',
'actor-recent': 'starringActorRecentlyWatched',
'director-recent': 'directedByDirectorRecentlyWatched',
'writer-recent': 'writtenByWriterRecentlyWatched',
'spotlight-genre': 'spotlightGenre',
'spotlight-network': 'spotlightNetwork',
'collection': 'collections'
};
const discoverySectionSettingsCache = {};
/**
* Helper to get IsPlayed filter string from config
* @param {Object} config - Section configuration object
* @returns {string|null} - 'IsPlayed', 'IsUnplayed', or null
*/
function getIsPlayedFilter(config) {
if (config && config.isPlayed === true) return 'IsPlayed';
if (config && config.isPlayed === false) return 'IsUnplayed';
return null;
}
let collectionsData = null;
async function getCollectionsData() {
if (collectionsData !== null) {
return collectionsData;
}
collectionsData = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
IncludeItemTypes: 'BoxSet',
Recursive: true,
Fields: 'RecursiveItemCount,ChildCount'
});
return collectionsData;
}
if (!discoveryConfig.sectionTypes) {
discoveryConfig.sectionTypes = {};
}
if (discoveryConfig.collectionSection) {
const legacySection = discoveryConfig.collectionSection;
const existingCollectionEntry = discoveryConfig.sectionTypes.collections;
if (!existingCollectionEntry || typeof existingCollectionEntry !== 'object') {
discoveryConfig.sectionTypes.collections = {
enabled: legacySection.enabled !== false,
name: legacySection.name || discoveryConfig.sectionNames?.collections || '[Collection Name]',
minimumItems: legacySection.minimumItems ?? 10,
itemLimit: legacySection.itemLimit ?? null,
sortOrder: legacySection.sortOrder ?? null,
sortOrderDirection: legacySection.sortOrderDirection ?? null,
cardFormat: legacySection.cardFormat ?? null,
order: legacySection.order ?? 50
};
} else if (legacySection.minimumItems !== undefined && existingCollectionEntry.minimumItems === undefined) {
existingCollectionEntry.minimumItems = legacySection.minimumItems;
}
delete discoveryConfig.collectionSection;
}
function parseDiscoveryNumber(value, fallback) {
if (value === null || value === undefined || value === '') {
return fallback;
}
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function getDiscoverySectionKeyFromType(sectionType) {
return discoverySectionTypeMap[sectionType] || null;
}
function getDiscoverySectionSettings(sectionKey) {
if (!sectionKey) return null;
if (discoverySectionSettingsCache[sectionKey]) {
return discoverySectionSettingsCache[sectionKey];
}
const definition = discoverySectionDefinitionMap[sectionKey] || {};
const rawConfig = discoveryConfig.sectionTypes?.[sectionKey];
const legacyNames = discoveryConfig.sectionNames || {};
const defaultItemLimitForDiscovery = discoveryConfig.defaultItemLimit ?? defaultItemLimit;
const defaultSortOrderForDiscovery = discoveryConfig.defaultSortOrder ?? defaultSortOrder;
const defaultCardFormatForDiscovery = discoveryConfig.defaultCardFormat ?? defaultCardFormat;
let normalized = {
enabled: true,
name: legacyNames[sectionKey] || definition.defaultName || '',
itemLimit: defaultItemLimitForDiscovery,
sortOrder: defaultSortOrderForDiscovery,
sortOrderDirection: 'Ascending',
cardFormat: defaultCardFormatForDiscovery
};
if (definition.minimumItems !== undefined) {
normalized.minimumItems = definition.minimumItems;
}
if (rawConfig === false) {
normalized.enabled = false;
} else if (rawConfig && typeof rawConfig === 'object') {
normalized = { ...normalized, ...rawConfig };
} else if (rawConfig === true) {
normalized.enabled = true;
}
normalized.name = normalized.name || definition.defaultName || '';
normalized.itemLimit = parseDiscoveryNumber(normalized.itemLimit, defaultItemLimitForDiscovery);
normalized.sortOrder = normalized.sortOrder || defaultSortOrderForDiscovery;
normalized.sortOrderDirection = normalized.sortOrderDirection || 'Ascending';
normalized.cardFormat = normalized.cardFormat || defaultCardFormatForDiscovery;
if (normalized.minimumItems !== undefined) {
normalized.minimumItems = parseDiscoveryNumber(normalized.minimumItems, definition.minimumItems ?? 10);
}
discoverySectionSettingsCache[sectionKey] = normalized;
return normalized;
}
function applyDiscoverySectionOrdering(items, sectionConfig) {
if (!Array.isArray(items)) return [];
// Filter by IsPlayed if configured
let filteredItems = items;
const isPlayedFilter = sectionConfig?.isPlayed;
if (isPlayedFilter === true) {
filteredItems = items.filter(item => item.UserData && item.UserData.Played);
} else if (isPlayedFilter === false) {
filteredItems = items.filter(item => !item.UserData || !item.UserData.Played);
}
const sortOrder = sectionConfig?.sortOrder || discoveryConfig.defaultSortOrder || defaultSortOrder;
const sortDirection = sectionConfig?.sortOrderDirection || 'Ascending';
let sortedItems = filteredItems;
if (sortOrder === 'Random') {
sortedItems = [...filteredItems].sort(() => Math.random() - 0.5);
} else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') {
sortedItems = window.cardBuilder.sortItems(filteredItems, sortOrder, sortDirection);
}
const limit = parseDiscoveryNumber(
sectionConfig?.itemLimit,
discoveryConfig.defaultItemLimit ?? defaultItemLimit
);
return sortedItems.slice(0, limit);
}
// Processing flag to prevent parallel execution
let isProcessing = false;
let isLoadingDiscoveryGroup = false;
// Seasonal data storage
let halloweenMovies = [];
let horrorMovies = [];
let thrillerMovies = [];
let halloweenViewMoreUrls = {
horror: null,
thriller: null
};
// Discovery sections data storage
let moviesTopPeople = null;
let renderedSections = new Set(); // Track rendered sections to avoid duplicates
let isGeneratingDiscoverySections = false; // Prevent parallel generation (legacy)
let isRenderingDiscoveryGroup = false; // Prevent parallel rendering from buffer
let isInitializingPeopleCache = false; // Prevent parallel people cache initialization
let isPeopleCacheComplete = false; // Track if all people data has been loaded
let preloadedSections = []; // Preloaded sections ready for instant rendering
// Track rendered items to avoid duplicates
let renderedActors = new Set(); // Track rendered actor IDs
let renderedDirectors = new Set(); // Track rendered director IDs
let renderedWriters = new Set(); // Track rendered writer IDs
let renderedStudios = new Set(); // Track rendered studio IDs
let renderedWatchedMovies = new Set(); // Track rendered watched movie IDs for "Because you watched" sections
let renderedGenres = new Set(); // Track rendered genre IDs for spotlight sections
let renderedNetworks = new Set(); // Track rendered network IDs for spotlight sections
let renderedCollections = new Set(); // Track rendered collection IDs
let renderedCustomDiscoverySections = new Set(); // Track custom sections used in discovery
let cachedQualifyingGenres = null; // Cache qualifying genres to avoid refetching
let isCachingQualifyingGenres = false; // Prevent parallel caching
let renderedFavoriteMovies = new Set(); // Track rendered favorite movie IDs
let renderedStarringWatchedMovies = new Set(); // Track movies used for "Starring X since you watched Y" sections
let renderedDirectedWatchedMovies = new Set(); // Track movies used for "Directed by X since you watched Y" sections
let renderedWrittenWatchedMovies = new Set(); // Track movies used for "Written by X since you watched Y" sections
// Pre-rendered sections waiting to be revealed
let hiddenDiscoverySections = []; // Array of pre-rendered section DOM elements
// Discovery buffer system
let discoveryBuffer = []; // Buffer holding groups of discovery items (always 2 groups)
let additionalDiscoveryContent = true; // Flag indicating if more discovery content is available
let preloadedSectionElements = []; // Preloaded section DOM elements (hidden)
let isPreloadingSections = false; // Prevent parallel preloading
let discoveryScrollHandler = null; // Reference to scroll handler for cleanup
let discoveryWheelHandler = null; // Reference to wheel handler for cleanup
let discoveryTouchStartHandler = null; // Reference to touchstart handler for cleanup
let discoveryTouchMoveHandler = null; // Reference to touchmove handler for cleanup
/************ Helpers ************/
/**
* Fetches collection data from Jellyfin API
* @param {string} collectionId - The collection ID to fetch
* @returns {Promise