Full Overhaul of KefinTweaks Configuration UI + Enhanced Home Screen

This commit is contained in:
HighImKevin
2026-01-28 20:15:40 -05:00
parent 936f2f9cef
commit 60d3e532a2
20 changed files with 14165 additions and 611 deletions
+27 -1
View File
@@ -1,3 +1,8 @@
<div align="center" style="display: flex; align-items: center; flex-wrap: wrap; justify-content: center;">
<img src="logo.png" width="200"/>
<span style="font-size: 6rem; font-family: monospace;">KefinTweaks</span>
</div>
# KefinTweaks for Jellyfin
KefinTweaks is a comprehensive collection of enhancements and customizations for Jellyfin, focused on, but not limited to, filling gaps in functionality based on the community's most desired [Feature Requests](https://features.jellyfin.org/?view=most-wanted).
@@ -61,6 +66,7 @@ Note: The Slideshow at the top of the Home Screen in the video below is from the
- [License](#license)
- [Acknowledgments](#acknowledgments)
- [Support](#support)
- [FAQ](#faq)
- [Roadmap](#roadmap)
- [Planned Features](#planned-features)
- [Version History](#version-history)
@@ -559,7 +565,7 @@ A few things to note about KefinTweaks functionality in general and how it inter
- Search
- The default Jellyfin search is hidden in favor of the KefinTweaks search
- KefinTweaks blocks default Jellyfin search requests, and only performs its own search requests
- KefinTweaks does not block default Jellyfin search requests, but they will only be triggered when manually navigating to the search page with a searchTerm in the url. In this circumstance, the duplicate search results are hidden.
- Dashboard Button Fix
- This fix prevents the Back button in the Dashboad from navigating back to the "new tab" page in your browser
<hr>
@@ -598,6 +604,26 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- **[movie-monk-b0t](https://github.com/movie-monk-b0t)**: For the IMDb Top 250 JSON data
- **[joshmcrty](https://github.com/joshmcrty)**: For the [Flurry](https://github.com/joshmcrty/Flurry) jQuery plugin
## FAQ
> ### I saved the KefinTweaks Plugin script in JS Injector but I don't see KefinTweaks in the Dashboard menu
You must complete the installation by clicking the KefinTweaks plugin and selecting the installation source (either GitHub or local) from the KefinTweaks plugin card at the very bottom of your Plugins page:
<div align="center">
<img src="pages\images\faq\pending.png" alt="jsinjector publicjs" style="max-width: 100%; height: auto;"/>
</div> <br/>
> ### KefinTweaks Plugin never appears in the Plugins page even though I saved the KefinTweaks Plugin script in JS Injector
1. Ensure `JavaScriptInjector\public.js` is loaded by the client:
- Open DevTools (right-click page > inspect)
- Click Sources tab
<div align="center">
<img src="pages\images\faq\publicjs.png" alt="jsinjector publicjs" style="max-width: 100%; height: auto;"/>
</div>
## Support
- **Issues**:
@@ -0,0 +1,174 @@
// KefinTweaks Custom Menu Links Configuration UI
// Modular configuration modal for managing custom menu links
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks CustomMenuLinks Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks CustomMenuLinks Config]', ...args);
const MODAL_ID = 'kefin-custommenulinks-config';
/**
* Load configuration from window.KefinTweaksConfig
*/
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.customMenuLinks || [];
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(customMenuLinks) {
try {
// Ensure window.KefinTweaksConfig exists
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update customMenuLinks config
window.KefinTweaksConfig.customMenuLinks = customMenuLinks;
// Save to JS Injector using utils
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else {
// Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
}
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
/**
* Build configuration HTML
*/
function buildConfigHTML(config) {
return `
<div style="max-width: 800px;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Custom Menu Links JSON</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Add custom menu links that will appear in the custom menu. Use Material icon names for icons.</div>
<textarea id="customMenuLinksJson" class="fld emby-textarea" rows="12" placeholder='[{"name":"Link Name","icon":"link","url":"#/..."}]' style="width: 100%; font-family: monospace; font-size: 0.9em; line-height: 1.5;">${JSON.stringify(config, null, 2)}</textarea>
<details style="margin-top: 0.75em;">
<summary class="listItemBodyText secondary" style="font-size: 0.9em; color: #4a9eff; cursor: pointer;">View Example Format</summary>
<pre style="background: rgba(0,0,0,0.3); padding: 1em; border-radius: 4px; margin-top: 0.5em; overflow-x: auto; font-size: 0.85em; line-height: 1.6;">[
{
"name": "My Custom Link",
"icon": "link",
"url": "#/userpluginsettings.html?pageUrl=https://domain.com/custom-page",
"openInNewTab": false
},
{
"name": "External Link",
"icon": "open_in_new",
"url": "https://example.com",
"openInNewTab": true
}
]</pre>
</details>
</div>
</div>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
try {
const jsonText = document.getElementById('customMenuLinksJson')?.value || '[]';
return JSON.parse(jsonText);
} catch (e) {
ERR('Error parsing custom menu links JSON:', e);
alert('Invalid JSON format. Please check your input.');
return [];
}
}
/**
* Open configuration modal
*/
async function openConfigModal() {
try {
// Load config
const config = await loadConfig();
// Build content
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
// Build footer
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-custommenulinks-config-btn">Save</button>
`;
// Create modal
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Custom Menu Links Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-custommenulinks-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const customMenuLinks = collectConfig();
const success = await saveConfig(customMenuLinks);
if (success) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Custom Menu Links configuration saved!');
}
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
}
});
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '1400px';
modalInstance.dialog.style.height = '90vh';
LOG('Custom Menu Links configuration modal opened');
} catch (error) {
ERR('Error opening custom menu links config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
// Export
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.customMenuLinks = { openConfigModal };
console.log('[KefinTweaks CustomMenuLinks Config] Script loaded');
})();
@@ -0,0 +1,95 @@
[data-modal-id="kefin-homescreen-config-main"] .config-tab-content details {
max-height: calc(90vh - 19em);
overflow: auto;
}
[data-type="Collections"], [data-type="Playlists"], [data-type="Generic Parent"] {
display: none;
}
[data-parent-item-type="Collection"] [data-type="Collections"] {
display: block;
}
[data-parent-item-type="Playlist"] [data-type="Playlists"] {
display: block;
}
[data-parent-item-type="Generic Parent"] [data-type="Generic Parent"] {
display: block;
}
[data-modal-id="kefin-homescreen-config-main"] details[open] summary.listItemBodyText::after {
content: '';
}
[data-modal-id="kefin-homescreen-config-main"] #section-content {
padding-right: 1em;
}
[data-modal-id="kefin-homescreen-config-main"] div#section-content {
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 0.75em;
max-height: calc(90vh - 19em);
overflow: auto;
}
div#order-sections-container .editable-group-name {
pointer-events: none;
}
.section-group[data-section-type-seasonal="false"] .section-row[data-section-type="Seasonal"] {
display: none !important;
}
.section-group[data-section-type-default="false"] .section-row[data-section-type="Default"] {
display: none !important;
}
.section-group[data-section-type-custom="false"] .section-row[data-section-type="Custom"] {
display: none !important;
}
div#discovery-sections-list .section-preview-btn {
display: none;
}
.listItem.section-row:has(.discovery-tag) {
box-shadow: black inset 1px 1px 2px 1px;
}
div#order-sections-container .section-group .group-sections-list {
max-height: calc(90vh - 25em);
overflow: auto;
margin-bottom: -1em;
}
span.listItemBodyText.secondary.discovery-tag {
background: #3F51B5;
padding: 2px 5px;
}
span.listItemBodyText.secondary.spotlight-tag {
background: #501515;
padding: 2px 5px;
}
span.listItemBodyText.secondary.seasonal-tag {
background: #3b5562;
padding: 2px 5px;
}
.discovery-tag, .spotlight-tag, .seasonal-tag {
color: rgba(255, 255, 255, 0.9) !important;
border-radius: 5px;
}
.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) form>div:first-child {
display: none;
}
.homeScreenSettingsContainer form {
display: flex;
flex-direction: column;
}
.homeScreenSettingsContainer form > * {
order: 2;
}
.homeScreenSettingsContainer #kefin-user-home-sections-editor {
order: 1;
}
File diff suppressed because it is too large Load Diff
+649
View File
@@ -0,0 +1,649 @@
// KefinTweaks HomeScreen Configuration Migration
// Migrates legacy KefinTweaksConfig.homeScreen to new KefinTweaksConfig.homeScreenConfig format
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks HomeScreen Migration]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HomeScreen Migration]', ...args);
const ERR = (...args) => console.error('[KefinTweaks HomeScreen Migration]', ...args);
/**
* Check if migration is needed and perform migration
* @returns {Promise<boolean>} True if migration was performed, false otherwise
*/
async function migrateHomeScreenConfig() {
// Check if migration is needed
if (!window.KefinTweaksConfig) {
LOG('KefinTweaksConfig not found, skipping migration');
return false;
}
// If homeScreenConfig already exists, skip migration
if (window.KefinTweaksConfig.homeScreenConfig) {
LOG('homeScreenConfig already exists, skipping migration');
return false;
}
// If homeScreen doesn't exist, nothing to migrate
if (!window.KefinTweaksConfig.homeScreen) {
LOG('No legacy homeScreen config found, skipping migration');
return false;
}
// Check if KefinHomeConfig2 defaults are available
if (!window.KefinHomeConfig2) {
WARN('KefinHomeConfig2 defaults not available, cannot perform migration');
return false;
}
LOG('Starting migration from legacy homeScreen to homeScreenConfig...');
try {
const legacyHomeScreen = window.KefinTweaksConfig.homeScreen;
const defaults = window.KefinHomeConfig2;
// Initialize new config structure with defaults
const newHomeScreenConfig = {
HOME_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.HOME_SECTION_GROUPS || [])),
SEASONAL_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || [])),
DISCOVERY_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || [])),
CUSTOM_SECTION_GROUPS: [],
REMOVE_CONFLICTING_SECTIONS: legacyHomeScreen.removeConflictingSections !== false,
MERGE_NEXT_UP: false,
DISCOVERY_SETTINGS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SETTINGS || {})),
SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {}))
};
// Migrate discovery settings
migrateDiscoverySettings(legacyHomeScreen, newHomeScreenConfig);
// Migrate seasonal theme settings
migrateSeasonalThemeSettings(legacyHomeScreen, newHomeScreenConfig);
// Migrate HOME sections
migrateHomeSections(legacyHomeScreen, newHomeScreenConfig);
// Migrate SEASONAL sections
migrateSeasonalSections(legacyHomeScreen, newHomeScreenConfig);
// Migrate DISCOVERY sections
migrateDiscoverySections(legacyHomeScreen, newHomeScreenConfig);
// Migrate CUSTOM sections
migrateCustomSections(legacyHomeScreen, newHomeScreenConfig);
// Save migrated config to JS Injector plugin
window.KefinTweaksConfig.homeScreenConfig = newHomeScreenConfig;
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');
}
LOG('Migration completed successfully');
} catch (error) {
ERR('Migration failed:', error);
} finally {
return newHomeScreenConfig;
}
}
/**
* Migrate discovery settings
*/
function migrateDiscoverySettings(legacyHomeScreen, newConfig) {
if (!legacyHomeScreen.discovery) return;
const discovery = legacyHomeScreen.discovery;
const settings = newConfig.DISCOVERY_SETTINGS;
LOG('Migrating discovery settings...');
if (discovery.enabled !== undefined) settings.enabled = discovery.enabled;
if (discovery.infiniteScroll !== undefined) settings.infiniteScroll = discovery.infiniteScroll;
if (discovery.minPeopleAppearances !== undefined) settings.minPeopleAppearances = discovery.minPeopleAppearances;
if (discovery.minGenreMovieCount !== undefined) settings.minGenreMovieCount = discovery.minGenreMovieCount;
if (discovery.defaultItemLimit !== undefined) settings.defaultItemLimit = discovery.defaultItemLimit;
if (discovery.defaultSortOrder !== undefined) settings.defaultSortOrder = discovery.defaultSortOrder;
if (discovery.defaultCardFormat !== undefined) settings.defaultCardFormat = discovery.defaultCardFormat;
if (discovery.spotlightDiscoveryChance !== undefined) settings.spotlightDiscoveryChance = discovery.spotlightDiscoveryChance;
if (discovery.renderSpotlightAboveMatching !== undefined) settings.renderSpotlightAboveMatching = discovery.renderSpotlightAboveMatching;
if (discovery.randomizeOrder !== undefined) settings.randomizeOrder = discovery.randomizeOrder;
if (discovery.spotlight && typeof discovery.spotlight === 'object') {
Object.assign(settings.spotlight, discovery.spotlight);
}
}
/**
* Migrate seasonal theme settings
*/
function migrateSeasonalThemeSettings(legacyHomeScreen, newConfig) {
if (!legacyHomeScreen.seasonal) return;
const seasonal = legacyHomeScreen.seasonal;
const settings = newConfig.SEASONAL_THEME_SETTINGS;
LOG('Migrating seasonal theme settings...');
if (seasonal.enabled !== undefined) settings.enabled = seasonal.enabled;
if (seasonal.enableSeasonalAnimations !== undefined) settings.enableSeasonalAnimations = seasonal.enableSeasonalAnimations;
if (seasonal.enableSeasonalBackground !== undefined) settings.enableSeasonalBackground = seasonal.enableSeasonalBackground;
}
/**
* Find section in default groups by ID
*/
function findSectionInDefaults(sectionId, groups) {
for (const group of groups) {
if (group.sections) {
const section = group.sections.find(s => s.id === sectionId);
if (section) {
return { section, group, sectionIndex: group.sections.indexOf(section) };
}
}
}
return null;
}
/**
* Find seasonal section using matching algorithm
*/
function findSeasonalSectionByMatch(legacySection, seasonGroup, seasonId) {
if (!seasonGroup || !seasonGroup.sections) return null;
const sections = seasonGroup.sections;
// Try exact ID match (for descriptive IDs like halloween-tag)
if (legacySection.id && !legacySection.id.startsWith('seasonal-')) {
const exactMatch = sections.find(s => {
const expectedId = `seasonal.${seasonId}.${legacySection.id}`;
return s.id === expectedId || s.id.endsWith(`.${legacySection.id}`);
});
if (exactMatch) {
LOG(` Matched section by exact ID: ${legacySection.id} → ${exactMatch.id}`);
return exactMatch;
}
}
// Try name match
if (legacySection.name) {
const nameMatch = sections.find(s => s.name === legacySection.name);
if (nameMatch) {
LOG(` Matched section by name: "${legacySection.name}" → ${nameMatch.id}`);
return nameMatch;
}
}
// Try type + source pattern match
if (legacySection.type && legacySection.source !== undefined) {
const typeSourceMatch = sections.find(s => {
if (!s.queries || !s.queries[0] || !s.queries[0].queryOptions) return false;
const queryOptions = s.queries[0].queryOptions;
if (legacySection.type === 'Tag' && queryOptions.Tags === legacySection.source) return true;
if (legacySection.type === 'Genre' && (queryOptions.Genres === legacySection.source || queryOptions.GenreIds === legacySection.source)) return true;
if (legacySection.type === 'Parent' && queryOptions.ParentId === legacySection.source) return true;
return false;
});
if (typeSourceMatch) {
LOG(` Matched section by type+source: ${legacySection.type}/${legacySection.source} → ${typeSourceMatch.id}`);
return typeSourceMatch;
}
}
return null;
}
/**
* Find discovery section by ID
*/
function findDiscoverySectionById(sectionId, groups) {
for (const group of groups) {
if (group.sections) {
const section = group.sections.find(s => s.id === sectionId);
if (section) {
return { section, group, sectionIndex: group.sections.indexOf(section) };
}
}
}
return null;
}
/**
* Merge legacy section config into default section
*/
function mergeSectionConfig(defaultSection, legacyConfig) {
const merged = JSON.parse(JSON.stringify(defaultSection));
// Map root fields
if (legacyConfig.enabled !== undefined) merged.enabled = legacyConfig.enabled;
if (legacyConfig.name !== undefined) merged.name = legacyConfig.name;
if (legacyConfig.order !== undefined) merged.order = legacyConfig.order;
if (legacyConfig.cardFormat !== undefined) merged.cardFormat = legacyConfig.cardFormat;
if (legacyConfig.startDate !== undefined) merged.startDate = legacyConfig.startDate;
if (legacyConfig.endDate !== undefined) merged.endDate = legacyConfig.endDate;
if (legacyConfig.discoveryEnabled !== undefined) merged.discoveryEnabled = legacyConfig.discoveryEnabled;
// RenderMode mapping: only use spotlight if renderMode doesn't exist
if (legacyConfig.renderMode !== undefined) {
merged.renderMode = legacyConfig.renderMode;
} else if (legacyConfig.spotlight === true) {
merged.renderMode = 'Spotlight';
}
if (legacyConfig.spotlightConfig !== undefined) merged.spotlightConfig = legacyConfig.spotlightConfig;
if (legacyConfig.overflowCard !== undefined) merged.overflowCard = legacyConfig.overflowCard;
if (legacyConfig.minimumShowsForNetwork !== undefined) merged.minimumShowsForNetwork = legacyConfig.minimumShowsForNetwork;
if (legacyConfig.flattenSeries !== undefined) merged.flattenSeries = legacyConfig.flattenSeries;
if (legacyConfig.viewMoreUrl !== undefined) merged.viewMoreUrl = legacyConfig.viewMoreUrl;
// Map query options fields
if (merged.queries && merged.queries.length > 0) {
const query = merged.queries[0];
if (!query.queryOptions) query.queryOptions = {};
if (legacyConfig.itemLimit !== undefined) query.queryOptions.Limit = legacyConfig.itemLimit;
if (legacyConfig.sortOrder !== undefined) query.queryOptions.SortBy = legacyConfig.sortOrder;
if (legacyConfig.sortOrderDirection !== undefined) query.queryOptions.SortOrder = legacyConfig.sortOrderDirection;
if (legacyConfig.searchTerm !== undefined && legacyConfig.searchTerm) query.queryOptions.SearchTerm = legacyConfig.searchTerm;
if (legacyConfig.includeItemTypes !== undefined) {
query.queryOptions.IncludeItemTypes = Array.isArray(legacyConfig.includeItemTypes)
? legacyConfig.includeItemTypes
: legacyConfig.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean);
}
if (legacyConfig.isPlayed !== undefined && legacyConfig.isPlayed !== null) {
query.queryOptions.IsUnplayed = legacyConfig.isPlayed === false;
}
if (legacyConfig.minAgeInDays !== undefined && legacyConfig.minAgeInDays !== null) {
query.minAge = legacyConfig.minAgeInDays;
}
if (legacyConfig.maxAgeInDays !== undefined && legacyConfig.maxAgeInDays !== null) {
query.maxAge = legacyConfig.maxAgeInDays;
}
// Merge additionalQueryOptions
if (legacyConfig.additionalQueryOptions && Array.isArray(legacyConfig.additionalQueryOptions)) {
legacyConfig.additionalQueryOptions.forEach(opt => {
if (opt && opt.key && opt.value !== undefined) {
query.queryOptions[opt.key] = opt.value;
}
});
}
}
return merged;
}
/**
* Convert legacy Type/source to queries array
*/
function convertLegacyTypeToQuery(type, source, legacySection) {
const query = {
queryOptions: {}
};
// Build base queryOptions from legacy section fields
if (legacySection.itemLimit !== undefined) query.queryOptions.Limit = legacySection.itemLimit;
if (legacySection.sortOrder !== undefined) query.queryOptions.SortBy = legacySection.sortOrder;
if (legacySection.sortOrderDirection !== undefined) query.queryOptions.SortOrder = legacySection.sortOrderDirection;
if (legacySection.searchTerm !== undefined && legacySection.searchTerm) query.queryOptions.SearchTerm = legacySection.searchTerm;
if (legacySection.includeItemTypes !== undefined) {
query.queryOptions.IncludeItemTypes = Array.isArray(legacySection.includeItemTypes)
? legacySection.includeItemTypes
: legacySection.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean);
}
if (legacySection.isPlayed !== undefined && legacySection.isPlayed !== null) {
query.queryOptions.IsUnplayed = legacySection.isPlayed === false;
}
if (legacySection.minAgeInDays !== undefined && legacySection.minAgeInDays !== null) {
query.minAge = legacySection.minAgeInDays;
}
if (legacySection.maxAgeInDays !== undefined && legacySection.maxAgeInDays !== null) {
query.maxAge = legacySection.maxAgeInDays;
}
// Convert Type/source to queryOptions
if (type === 'Playlist' || type === 'Collection' || type === 'Parent') {
if (source && source.trim()) {
query.queryOptions.ParentId = source.trim();
}
} else if (type === 'Genre') {
const genreSource = source ? source.trim() : '';
if (genreSource) {
// Check if it's a 32-char hex ID
if (/^[a-f0-9]{32}$/i.test(genreSource)) {
query.queryOptions.GenreIds = genreSource;
} else {
// Comma-separated genre names
query.queryOptions.Genres = genreSource;
}
}
} else if (type === 'Tag') {
if (source && source.trim()) {
query.queryOptions.Tags = source.trim();
}
}
// Merge additionalQueryOptions
if (legacySection.additionalQueryOptions && Array.isArray(legacySection.additionalQueryOptions)) {
legacySection.additionalQueryOptions.forEach(opt => {
if (opt && opt.key && opt.value !== undefined) {
query.queryOptions[opt.key] = opt.value;
}
});
}
return query;
}
/**
* Migrate HOME sections
*/
function migrateHomeSections(legacyHomeScreen, newConfig) {
LOG('Migrating HOME sections...');
// Migrate recentlyReleased.movies
if (legacyHomeScreen.recentlyReleased?.movies) {
const found = findSectionInDefaults('recentlyReleased.movies', newConfig.HOME_SECTION_GROUPS);
if (found) {
const moviesConfig = legacyHomeScreen.recentlyReleased.movies;
const parentEnabled = legacyHomeScreen.recentlyReleased.enabled !== false;
const merged = mergeSectionConfig(found.section, {
...moviesConfig,
enabled: moviesConfig.enabled !== undefined ? moviesConfig.enabled : parentEnabled
});
Object.assign(found.section, merged);
LOG(' Migrated recentlyReleased.movies');
}
}
// Migrate recentlyReleased.episodes
if (legacyHomeScreen.recentlyReleased?.episodes) {
const found = findSectionInDefaults('recentlyReleased.episodes', newConfig.HOME_SECTION_GROUPS);
if (found) {
const episodesConfig = legacyHomeScreen.recentlyReleased.episodes;
const parentEnabled = legacyHomeScreen.recentlyReleased.enabled !== false;
const merged = mergeSectionConfig(found.section, {
...episodesConfig,
enabled: episodesConfig.enabled !== undefined ? episodesConfig.enabled : parentEnabled
});
Object.assign(found.section, merged);
LOG(' Migrated recentlyReleased.episodes');
}
}
// Migrate recentlyAddedInLibrary
if (legacyHomeScreen.recentlyAddedInLibrary && typeof legacyHomeScreen.recentlyAddedInLibrary === 'object') {
const libraryConfigs = legacyHomeScreen.recentlyAddedInLibrary;
let recentlyAddedGroup = newConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Recently Added' || g.id === 'home-recently-added');
if (!recentlyAddedGroup) {
recentlyAddedGroup = {
id: 'home-recently-added',
name: 'Recently Added',
sections: []
};
newConfig.HOME_SECTION_GROUPS.push(recentlyAddedGroup);
}
Object.entries(libraryConfigs).forEach(([libraryId, libConfig]) => {
const sectionId = `recently-added-${libraryId}`;
let includeItemTypes = ['Movie', 'Episode'];
if (libConfig.includeItemTypes) {
includeItemTypes = Array.isArray(libConfig.includeItemTypes)
? libConfig.includeItemTypes
: libConfig.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean);
}
const newSection = {
id: sectionId,
name: libConfig.name || `Recently Added Media`,
enabled: libConfig.enabled !== false,
order: libConfig.order || 61,
cardFormat: libConfig.cardFormat || 'Poster',
queries: [{
path: '/Items/Latest',
queryOptions: {
ParentId: libraryId,
IncludeItemTypes: includeItemTypes,
SortBy: 'DateCreated',
SortOrder: 'Descending',
Limit: libConfig.itemLimit || 16
}
}]
};
recentlyAddedGroup.sections.push(newSection);
LOG(` Migrated recentlyAddedInLibrary[${libraryId}]`);
});
}
// Migrate other home sections
const sectionMappings = {
watchAgain: 'watchAgain',
watchlist: 'watchlist',
upcoming: 'upcoming',
imdbTop250: 'imdbTop250',
popularTVNetworks: 'popularTVNetworks'
};
Object.entries(sectionMappings).forEach(([legacyKey, sectionId]) => {
if (legacyHomeScreen[legacyKey]) {
const found = findSectionInDefaults(sectionId, newConfig.HOME_SECTION_GROUPS);
if (found) {
const merged = mergeSectionConfig(found.section, legacyHomeScreen[legacyKey]);
Object.assign(found.section, merged);
LOG(` Migrated ${legacyKey}`);
}
}
});
}
/**
* Migrate SEASONAL sections
*/
function migrateSeasonalSections(legacyHomeScreen, newConfig) {
if (!legacyHomeScreen.seasonal?.seasons || !Array.isArray(legacyHomeScreen.seasonal.seasons)) {
return;
}
LOG('Migrating SEASONAL sections...');
legacyHomeScreen.seasonal.seasons.forEach(legacySeason => {
const seasonId = legacySeason.id || legacySeason.name?.toLowerCase().replace(/\s+/g, '-');
if (!seasonId) {
WARN(' Skipping season without id or name');
return;
}
// Find matching seasonal group
const seasonalGroup = newConfig.SEASONAL_SECTION_GROUPS.find(g => {
const groupId = g.id || g.name?.toLowerCase().replace(/\s+/g, '-');
return groupId === `seasonal-${seasonId}` || groupId === seasonId;
});
if (!seasonalGroup) {
WARN(` No matching seasonal group found for season: ${seasonId}`);
return;
}
LOG(` Processing season: ${seasonId}`);
if (legacySeason.sections && Array.isArray(legacySeason.sections)) {
legacySeason.sections.forEach(legacySection => {
// Try to find matching section
const matchedSection = findSeasonalSectionByMatch(legacySection, seasonalGroup, seasonId);
if (matchedSection) {
// Merge legacy config into matched section
const merged = mergeSectionConfig(matchedSection, {
...legacySection,
startDate: legacySeason.startDate || matchedSection.startDate,
endDate: legacySeason.endDate || matchedSection.endDate
});
Object.assign(matchedSection, merged);
LOG(` Migrated section: ${legacySection.id || legacySection.name} → ${matchedSection.id}`);
} else {
LOG(`Converting generic section to custom: ${legacySection.id}`);
convertSeasonalSectionToCustom(legacySection, legacySeason, newConfig);
}
});
}
});
}
/**
* Convert seasonal section with generic ID to custom section
*/
function convertSeasonalSectionToCustom(legacySection, legacySeason, newConfig) {
const query = convertLegacyTypeToQuery(legacySection.type, legacySection.source, legacySection);
const customSection = {
id: `custom-${Date.now()}-${Math.random()}`,
name: legacySection.name || 'Custom Section',
enabled: legacySection.enabled !== false,
order: legacySection.order,
cardFormat: legacySection.cardFormat || 'Poster',
queries: [query]
};
// Map _parentItemType based on type
if (legacySection.type === 'Playlist') {
customSection._parentItemType = 'Playlist';
} else if (legacySection.type === 'Collection') {
customSection._parentItemType = 'Collection';
} else if (legacySection.type === 'Parent') {
customSection._parentItemType = 'Generic Parent';
}
// Tag and Genre don't need _parentItemType
// Map additional fields
if (legacySection.renderMode !== undefined) {
customSection.renderMode = legacySection.renderMode;
} else if (legacySection.spotlight === true) {
customSection.renderMode = 'Spotlight';
}
if (legacySection.discoveryEnabled !== undefined) customSection.discoveryEnabled = legacySection.discoveryEnabled;
if (legacySection.spotlightConfig !== undefined) customSection.spotlightConfig = legacySection.spotlightConfig;
if (legacySection.overflowCard !== undefined) customSection.overflowCard = legacySection.overflowCard;
const startDate = legacySection.startDate || legacySeason.startDate;
const endDate = legacySection.endDate || legacySeason.endDate;
if (startDate !== undefined) customSection.startDate = startDate;
if (endDate !== undefined) customSection.endDate = endDate;
// Add to custom sections group
let customGroup = newConfig.CUSTOM_SECTION_GROUPS.find(g => g.name === 'Custom Sections' || g.id === 'custom-sections');
if (!customGroup) {
customGroup = {
id: 'custom-sections',
name: 'Custom Sections',
sections: []
};
newConfig.CUSTOM_SECTION_GROUPS.push(customGroup);
}
customGroup.sections.push(customSection);
LOG(` Added as custom section: ${customSection.id}`);
}
/**
* Migrate DISCOVERY sections
*/
function migrateDiscoverySections(legacyHomeScreen, newConfig) {
if (!legacyHomeScreen.discovery?.sectionTypes) {
return;
}
LOG('Migrating DISCOVERY sections...');
const discoverySectionTypes = legacyHomeScreen.discovery.sectionTypes;
Object.entries(discoverySectionTypes).forEach(([sectionKey, legacySectionConfig]) => {
// Use the key as the section ID
const found = findDiscoverySectionById(sectionKey, newConfig.DISCOVERY_SECTION_GROUPS);
if (found) {
// Discovery sections still use legacy format, so we map fields directly
if (legacySectionConfig.enabled !== undefined) found.section.enabled = legacySectionConfig.enabled;
if (legacySectionConfig.name !== undefined) found.section.name = legacySectionConfig.name;
if (legacySectionConfig.itemLimit !== undefined) found.section.itemLimit = legacySectionConfig.itemLimit;
if (legacySectionConfig.cardFormat !== undefined) found.section.cardFormat = legacySectionConfig.cardFormat;
if (legacySectionConfig.isPlayed !== undefined && legacySectionConfig.isPlayed !== null) {
found.section.isPlayed = legacySectionConfig.isPlayed;
}
if (legacySectionConfig.minimumItems !== undefined) found.section.minimumItems = legacySectionConfig.minimumItems;
if (legacySectionConfig.sortOrder !== undefined) found.section.sortOrder = legacySectionConfig.sortOrder;
if (legacySectionConfig.sortOrderDirection !== undefined) found.section.sortOrderDirection = legacySectionConfig.sortOrderDirection;
LOG(` Migrated discovery section: ${sectionKey}`);
} else {
WARN(` Discovery section not found in defaults: ${sectionKey}`);
}
});
}
/**
* Migrate CUSTOM sections
*/
function migrateCustomSections(legacyHomeScreen, newConfig) {
if (!legacyHomeScreen.customSections || !Array.isArray(legacyHomeScreen.customSections)) {
return;
}
LOG('Migrating CUSTOM sections...');
let customGroup = newConfig.CUSTOM_SECTION_GROUPS.find(g => g.name === 'Custom Sections' || g.id === 'custom-sections');
if (!customGroup) {
customGroup = {
id: 'custom-sections',
name: 'Custom Sections',
sections: []
};
newConfig.CUSTOM_SECTION_GROUPS.push(customGroup);
}
legacyHomeScreen.customSections.forEach(legacySection => {
const query = convertLegacyTypeToQuery(legacySection.type, legacySection.source, legacySection);
const customSection = {
id: `custom-${Date.now()}-${Math.random()}`,
name: legacySection.name || 'Custom Section',
enabled: legacySection.enabled !== false,
order: legacySection.order,
cardFormat: legacySection.cardFormat || 'Poster',
queries: [query]
};
// Map _parentItemType based on type
if (legacySection.type === 'Playlist') {
customSection._parentItemType = 'Playlist';
} else if (legacySection.type === 'Collection') {
customSection._parentItemType = 'Collection';
} else if (legacySection.type === 'Parent') {
customSection._parentItemType = 'Generic Parent';
}
// Tag and Genre don't need _parentItemType
// Map additional fields
if (legacySection.renderMode !== undefined) {
customSection.renderMode = legacySection.renderMode;
} else if (legacySection.spotlight === true) {
customSection.renderMode = 'Spotlight';
}
if (legacySection.discoveryEnabled !== undefined) customSection.discoveryEnabled = legacySection.discoveryEnabled;
if (legacySection.spotlightConfig !== undefined) customSection.spotlightConfig = legacySection.spotlightConfig;
if (legacySection.overflowCard !== undefined) customSection.overflowCard = legacySection.overflowCard;
customGroup.sections.push(customSection);
LOG(` Migrated custom section: ${customSection.id}`);
});
}
// Expose migration function
window.migrateHomeScreenConfig = migrateHomeScreenConfig;
LOG('HomeScreen migration module loaded');
})();
@@ -0,0 +1,650 @@
// KefinTweaks User Home Screen Configuration
// Allows users to customize their home screen by re-ordering and enabling/disabling sections
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks User HomeScreen Config]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks User HomeScreen Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks User HomeScreen Config]', ...args);
// All Jellyfin home section base IDs (homesection0-8)
const JELLYFIN_HOME_SECTIONS = [
'smalllibrarytiles',
'librarybuttons',
'resume',
'resumeaudio',
'resumebook',
'nextup',
'latestmedia',
'livetv',
'activerecordings'
];
// Map Jellyfin sections to their KefinTweaks equivalents
// When a KefinTweaks section exists, it replaces the Jellyfin section in the UI
const JELLYFIN_HOME_SECTIONS_MAP = {
'resume': 'continueWatching',
'nextup': 'nextUp',
'latestmedia': 'recently-added*' // Pattern match - any section starting with 'recently-added-'
};
/**
* Flatten section groups into a flat array
*/
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;
}
/**
* Get Jellyfin default sections from display preferences
*/
function getJellyfinSectionsFromPrefs(displayPrefs) {
const sections = [];
const customPrefs = displayPrefs?.CustomPrefs || {};
// Build a set of all base IDs that appear in any homesectionN field (0-8)
const enabledBaseIds = new Set();
for (let i = 0; i <= 8; i++) {
const homesectionValue = customPrefs[`homesection${i}`];
if (homesectionValue && homesectionValue !== '' && homesectionValue !== 'none') {
enabledBaseIds.add(homesectionValue);
}
}
// Parse kefinHomeScreen if it's a string (Jellyfin stores CustomPrefs as strings)
let kefinHomeScreen = [];
if (customPrefs.kefinHomeScreen) {
if (typeof customPrefs.kefinHomeScreen === 'string') {
try {
kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen);
} catch (e) {
// Ignore parse errors, use empty array
}
} else if (Array.isArray(customPrefs.kefinHomeScreen)) {
kefinHomeScreen = customPrefs.kefinHomeScreen;
}
}
// Process each Jellyfin section
JELLYFIN_HOME_SECTIONS.forEach((baseId, index) => {
const homesectionKey = `homesection${index}`;
// Section is enabled if its base ID appears in ANY homesectionN field (0-8)
const isEnabled = enabledBaseIds.has(baseId);
// Get order and enabled status from kefinHomeScreen if available
let order = index;
let enabledFromPrefs = isEnabled; // Default to homesectionN value
if (Array.isArray(kefinHomeScreen) && kefinHomeScreen.length > 0) {
// Look for section by base ID (new format) or homesectionKey (old format for backward compatibility)
const sectionPref = kefinHomeScreen.find(s =>
s.id === baseId || s.id === homesectionKey
);
if (sectionPref) {
if (sectionPref.order !== undefined) {
order = sectionPref.order;
}
if (sectionPref.enabled !== undefined) {
enabledFromPrefs = sectionPref.enabled;
}
}
}
sections.push({
id: baseId, // Use base ID directly, no -jellyfin suffix
name: getJellyfinSectionName(baseId),
enabled: enabledFromPrefs,
order: order,
hidden: false,
isJellyfin: true,
jellyfinBaseId: baseId, // Track the Jellyfin base ID
homesectionKey: homesectionKey // Keep for backward compatibility
});
});
return sections;
}
/**
* Get display name for Jellyfin section
*/
function getJellyfinSectionName(baseId) {
const names = {
'smalllibrarytiles': 'My Media',
'librarybuttons': 'My Media (small)',
'resume': 'Resume',
'resumeaudio': 'Resume Audio',
'resumebook': 'Resume Book',
'nextup': 'Next Up',
'latestmedia': 'Latest Media',
'livetv': 'Live TV',
'activerecordings': 'Active Recordings'
};
return names[baseId] || baseId;
}
/**
* Load user preferences from display preferences
* @param {Object} displayPrefs - Optional display preferences object to use instead of fetching
*/
async function loadUserPreferences(displayPrefs = null) {
try {
if (!displayPrefs) {
if (!window.userHelper || !window.userHelper.getUserDisplayPreferences) {
WARN('userHelper not available');
return { kefinHomeScreen: [], homesections: {} };
}
const { promise } = await window.userHelper.getUserDisplayPreferences();
displayPrefs = await promise;
}
if (!displayPrefs || !displayPrefs.CustomPrefs) {
return { kefinHomeScreen: [], homesections: {} };
}
const customPrefs = displayPrefs.CustomPrefs;
let kefinHomeScreen = [];
// kefinHomeScreen is stored as a JSON string in CustomPrefs
if (customPrefs.kefinHomeScreen) {
if (typeof customPrefs.kefinHomeScreen === 'string') {
try {
kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen);
} catch (e) {
WARN('Failed to parse kefinHomeScreen:', e);
kefinHomeScreen = [];
}
} else if (Array.isArray(customPrefs.kefinHomeScreen)) {
// Handle legacy format (direct array)
kefinHomeScreen = customPrefs.kefinHomeScreen;
}
}
// Extract homesectionN values
const homesections = {};
for (let i = 0; i <= 8; i++) {
const key = `homesection${i}`;
homesections[key] = customPrefs[key] || 'none';
}
return {
kefinHomeScreen: Array.isArray(kefinHomeScreen) ? kefinHomeScreen : [],
homesections: homesections
};
} catch (error) {
ERR('Error loading user preferences:', error);
return { kefinHomeScreen: [], homesections: {} };
}
}
/**
* Merge user preferences with sections
* @param {Array} sections - Sections from config
* @param {Object} userPrefs - User preferences object
* @param {Object} mergedConfig - Merged config from getConfig() for fallback defaults
* @param {Object} displayPrefs - Display preferences object to check homesectionN fields
*/
function mergeUserPreferences(sections, userPrefs, mergedConfig = null, displayPrefs = null) {
const kefinHomeScreen = userPrefs.kefinHomeScreen || [];
const customPrefs = displayPrefs?.CustomPrefs || {};
// Create maps of section-id -> order and enabled status
const orderMap = new Map();
const enabledMap = new Map();
kefinHomeScreen.forEach(pref => {
if (pref.id) {
if (pref.order !== undefined) {
orderMap.set(pref.id, pref.order);
}
if (pref.enabled !== undefined) {
enabledMap.set(pref.id, pref.enabled);
}
}
});
// Build set of base IDs that appear in ANY homesectionN field (0-8) for Jellyfin sections
const enabledJellyfinBaseIds = new Set();
for (let i = 0; i <= 8; i++) {
const homesectionValue = customPrefs[`homesection${i}`];
if (homesectionValue && homesectionValue !== '' && homesectionValue !== 'none') {
enabledJellyfinBaseIds.add(homesectionValue);
}
}
// Helper to get default enabled status from config
const getDefaultEnabled = (sectionId) => {
if (!mergedConfig) return true; // Default to enabled if no config
// Search through all section groups
const allGroups = [
...(mergedConfig.HOME_SECTION_GROUPS || []),
...(mergedConfig.SEASONAL_SECTION_GROUPS || []),
...(mergedConfig.DISCOVERY_SECTION_GROUPS || []),
...(mergedConfig.CUSTOM_SECTION_GROUPS || [])
];
for (const group of allGroups) {
if (group.sections) {
const section = group.sections.find(s => s.id === sectionId);
if (section) {
return section.enabled !== false;
}
}
}
return true; // Default to enabled if not found
};
// Helper to check if a section ID is a Jellyfin section
const isJellyfinSection = (sectionId) => {
return JELLYFIN_HOME_SECTIONS.includes(sectionId);
};
// Helper to get the Jellyfin base ID for a KefinTweaks section that maps to Jellyfin
const getMappedJellyfinId = (sectionId) => {
// Check if this KefinTweaks section maps to a Jellyfin section
for (const [jellyfinId, kefinId] of Object.entries(JELLYFIN_HOME_SECTIONS_MAP)) {
if (jellyfinId === 'latestmedia' && sectionId.startsWith('recently-added-')) {
return jellyfinId;
}
if (kefinId === sectionId) {
return jellyfinId;
}
}
return null;
};
// Update sections with user preferences
return sections.map(section => {
const sectionId = section.id;
let isEnabled;
// For Jellyfin sections, check if base ID appears in any homesectionN field
if (section.isJellyfin || isJellyfinSection(sectionId)) {
const baseId = section.jellyfinBaseId || sectionId;
isEnabled = enabledJellyfinBaseIds.has(baseId);
} else {
// For KefinTweaks sections, check if user has explicit enabled/disabled preference, otherwise use default from config
// But also check homesectionN if this section maps to a Jellyfin section
const mappedJellyfinId = getMappedJellyfinId(sectionId);
if (mappedJellyfinId && enabledJellyfinBaseIds.has(mappedJellyfinId)) {
// If mapped Jellyfin section is enabled in homesectionN, use that
isEnabled = true;
} else {
const hasUserEnabledPref = enabledMap.has(sectionId);
isEnabled = hasUserEnabledPref
? enabledMap.get(sectionId)
: getDefaultEnabled(sectionId);
}
}
const userOrder = orderMap.get(sectionId);
return {
...section,
enabled: isEnabled,
order: userOrder !== undefined ? userOrder : (section.order || 0)
};
});
}
/**
* Save user preferences to display preferences
*/
async function saveUserPreferences(sections) {
try {
if (!window.userHelper || !window.userHelper.getUserDisplayPreferences || !window.userHelper.updateDisplayPreferences) {
ERR('userHelper not available');
return false;
}
// Get current display preferences
const { promise } = await window.userHelper.getUserDisplayPreferences();
const displayPrefs = await promise;
if (!displayPrefs) {
ERR('Could not load display preferences');
return false;
}
if (!displayPrefs.CustomPrefs) {
displayPrefs.CustomPrefs = {};
}
const customPrefs = displayPrefs.CustomPrefs;
// Helper to check if a section ID is a Jellyfin section
const isJellyfinSection = (sectionId) => {
if (JELLYFIN_HOME_SECTIONS.includes(sectionId)) {
return true;
}
return getMappedJellyfinId(sectionId) !== null;
};
// Helper to get the Jellyfin base ID for a KefinTweaks section that maps to Jellyfin
const getMappedJellyfinId = (sectionId) => {
for (const [jellyfinId, kefinId] of Object.entries(JELLYFIN_HOME_SECTIONS_MAP)) {
if (jellyfinId === 'latestmedia' && sectionId.startsWith('recently-added-')) {
return jellyfinId;
}
if (kefinId === sectionId) {
return jellyfinId;
}
}
return null;
};
// Build kefinHomeScreen array with all sections (including enabled status)
// Use section IDs directly, no homesectionKey
const kefinHomeScreen = sections.map(section => ({
id: section.id,
order: section.order || 0,
enabled: section.enabled !== false
}));
// Jellyfin expects CustomPrefs values to be JSON strings
customPrefs.kefinHomeScreen = JSON.stringify(kefinHomeScreen);
// Update homesectionN properties for Jellyfin sections
// First, clear all homesectionN (0-9)
for (let i = 0; i <= 9; i++) {
customPrefs[`homesection${i}`] = 'none';
}
// Collect sections that should be saved to homesectionN fields
const sectionsForHomesectionN = [];
// Add non-mapped Jellyfin sections
sections.forEach(section => {
if (section.enabled !== false && (section.isJellyfin || isJellyfinSection(section.id) || section.mapsToJellyfin)) {
const baseId = section.mapsToJellyfin || section.jellyfinBaseId || section.id;
sectionsForHomesectionN.push({
jellyfinBaseId: baseId,
order: section.order || 0
});
}
});
// Sort all sections by order
sectionsForHomesectionN.sort((a, b) => (a.order || 0) - (b.order || 0));
// Assign each section to homesectionN sequentially
sectionsForHomesectionN.forEach((section, index) => {
if (index <= 8) { // Only homesection0 through homesection8
customPrefs[`homesection${index}`] = section.jellyfinBaseId;
}
});
// Save
const success = await window.userHelper.updateDisplayPreferences(displayPrefs);
if (success) {
LOG('User preferences saved successfully');
} else {
ERR('Failed to save user preferences');
}
return success;
} catch (error) {
ERR('Error saving user preferences:', error);
return false;
}
}
/**
* Render user home sections editor
*/
async function renderUserHomeSectionsEditor(container) {
if (!container) {
ERR('Container not provided');
return;
}
try {
// Check dependencies
if (!window.KefinHomeScreen || !window.KefinHomeScreen.getConfig) {
ERR('KefinHomeScreen.getConfig not available');
return;
}
if (!window.KefinTweaksUI || !window.KefinTweaksUI.renderHomeSectionsOrderEditor) {
ERR('KefinTweaksUI.renderHomeSectionsOrderEditor not available');
return;
}
// Get merged config
const mergedConfig = await window.KefinHomeScreen.getConfig();
// Flatten all section groups
const allGroups = [
...(mergedConfig.HOME_SECTION_GROUPS || []),
...(mergedConfig.SEASONAL_SECTION_GROUPS || []),
/* ...(mergedConfig.DISCOVERY_SECTION_GROUPS || []), */
...(mergedConfig.CUSTOM_SECTION_GROUPS || [])
];
let kefinSections = flattenSectionGroups(allGroups);
// Filter out hidden sections
kefinSections = kefinSections.filter(s => !s.hidden);
// Filter out disabled sections
kefinSections = kefinSections.filter(s => s.enabled !== false);
// Filter out discovery sections
kefinSections = kefinSections.filter(s => s.type !== 'discovery');
// Filter out discovery sections
kefinSections = kefinSections.filter(s => s.discoveryEnabled !== true);
// Get display preferences once
if (!window.userHelper || !window.userHelper.getUserDisplayPreferences) {
ERR('userHelper not available');
container.innerHTML = '<div class="listItemBodyText secondary">User helper not available.</div>';
return;
}
const { promise } = await window.userHelper.getUserDisplayPreferences();
const displayPrefs = await promise;
// Load user preferences using the fetched displayPrefs
const userPrefs = await loadUserPreferences(displayPrefs);
// Get Jellyfin sections from display preferences
let jellyfinSections = getJellyfinSectionsFromPrefs(displayPrefs);
// Mark KefinTweaks sections that map to Jellyfin sections
kefinSections = kefinSections.map(section => {
const mappedJellyfinId = Object.entries(JELLYFIN_HOME_SECTIONS_MAP).find(([jellyfinId, kefinId]) => {
if (jellyfinId === 'latestmedia' && section.id && section.id.startsWith('recently-added-')) {
return true;
}
return kefinId === section.id;
});
if (mappedJellyfinId) {
return {
...section,
mapsToJellyfin: mappedJellyfinId[0] // Store the Jellyfin base ID
};
}
return section;
});
// Filter out mapped Jellyfin sections if their KefinTweaks equivalent exists
const kefinSectionIds = new Set(kefinSections.map(s => s.id));
const hasRecentlyAdded = kefinSections.some(s => s.id && s.id.startsWith('recently-added-'));
jellyfinSections = jellyfinSections.filter(jellyfinSection => {
const baseId = jellyfinSection.jellyfinBaseId || jellyfinSection.id;
const mappedKefinId = JELLYFIN_HOME_SECTIONS_MAP[baseId];
// For latestmedia, check if any recently-added-* section exists
if (baseId === 'latestmedia') {
return !hasRecentlyAdded; // Remove if any recently-added exists
}
// For other mapped sections, check if the mapped KefinTweaks section exists
if (mappedKefinId) {
return !kefinSectionIds.has(mappedKefinId); // Remove if mapped Kefin section exists
}
return true; // Keep non-mapped sections
});
// Combine all sections
let allSections = [...kefinSections, ...jellyfinSections];
// Merge user preferences (pass mergedConfig for fallback defaults and displayPrefs for homesectionN checks)
allSections = mergeUserPreferences(allSections, userPrefs, mergedConfig, displayPrefs);
// Sort by order
allSections.sort((a, b) => (a.order || 0) - (b.order || 0));
// Set any section types that aren't "seasonal" to "home"
allSections.forEach(section => {
if (section.type && section.type.toLowerCase() !== 'seasonal') {
section.type = 'home';
}
});
// Create editor HTML
const editorHTML = window.KefinTweaksUI.renderHomeSectionsOrderEditor(allSections, {
showUpDownButtons: true
});
container.innerHTML = `
<div class="verticalSection verticalSection-extrabottompadding">
<h2 class="sectionTitle">Home Sections Order</h2>
${editorHTML}
</div>
`;
// Setup event listeners
// Find the sections-container which wraps both enabled and disabled lists
const editorContainer = container.querySelector('.sections-container') || container.querySelector('.group-sections-list');
if (editorContainer && window.KefinTweaksUI.setupOrderEditorListeners) {
// Track current section state
let currentSections = [...allSections];
let isSaving = false;
// Extract save logic into reusable function
const performSave = async () => {
if (isSaving) return; // Prevent concurrent saves
isSaving = true;
try {
// Read DOM order and update sections array
// Only read order from enabled sections list (disabled sections preserve their order)
const enabledList = container.querySelector('.enabled-sections-list') || editorContainer.querySelector('.enabled-sections-list');
const sectionIdToOrder = new Map();
if (enabledList) {
// Get all section rows in enabled list in their current DOM order
const sectionRows = Array.from(enabledList.querySelectorAll('.section-row'));
// Assign order values starting from 1, incrementing by 1 for each section in DOM order
sectionRows.forEach((row, index) => {
const sectionId = row.dataset.sectionId;
if (sectionId) {
sectionIdToOrder.set(sectionId, index + 1);
}
});
}
// Update order values in currentSections based on DOM order (only for enabled sections)
currentSections.forEach(section => {
const domOrder = sectionIdToOrder.get(section.id);
if (domOrder !== undefined) {
// Only update order if section is in enabled list
section.order = domOrder;
}
// Disabled sections keep their existing order value (not updated)
});
await saveUserPreferences(currentSections);
} finally {
isSaving = false;
}
};
const handleToggleChange = async (sectionId, enabled) => {
const section = currentSections.find(s => (s.id || '') === sectionId);
if (section) {
section.enabled = enabled;
}
// Auto-save when toggle changes
await performSave();
};
// Setup listeners with auto-save on order changes
window.KefinTweaksUI.setupOrderEditorListeners(editorContainer, {
onOrderChange: null, // Don't update order values during drag/drop, we'll read from DOM
onToggleChange: handleToggleChange,
getSectionOrder: null, // Not needed since we're not calculating orders
sections: currentSections,
onSave: performSave // Pass save function to be called on drag/drop and up/down
});
}
LOG('User home sections editor rendered');
} catch (error) {
ERR('Error rendering user home sections editor:', error);
container.innerHTML = '<div class="listItemBodyText secondary">Error loading editor. Please refresh the page.</div>';
}
}
// Register onViewPage handler for mypreferenceshome page
function registerPreferencesPageHandler() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
setTimeout(registerPreferencesPageHandler, 1000);
return;
}
window.KefinTweaksUtils.onViewPage(async (view, element, hash) => {
// Check if we're on the mypreferenceshome page
LOG('Preferences home page detected');
const form = document.querySelector('.libraryPage:not(.hide) .homeScreenSettingsContainer > form');
if (form && form.firstChild) {
// Check if editor already rendered
if (form.querySelector('#kefin-user-home-sections-editor')) {
return;
}
// Create container for editor
const editorContainer = document.createElement('div');
editorContainer.id = 'kefin-user-home-sections-editor';
// Insert as last child of the form
form.appendChild(editorContainer);
// Render editor
await renderUserHomeSectionsEditor(editorContainer);
}
}, {
pages: ['mypreferenceshome', 'userpreferences']
});
LOG('Registered preferences page handler');
}
// Initialize
registerPreferencesPageHandler();
// Expose render function globally
window.KefinUserHomeScreenConfig = {
renderUserHomeSectionsEditor
};
LOG('User Home Screen Configuration loaded');
})();
+156
View File
@@ -0,0 +1,156 @@
// KefinTweaks Search Configuration UI
// Modular configuration modal for managing search settings
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Search Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Search Config]', ...args);
const MODAL_ID = 'kefin-search-config';
/**
* Load configuration from window.KefinTweaksConfig
*/
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.search || {};
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(searchConfig) {
try {
// Ensure window.KefinTweaksConfig exists
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update search config
window.KefinTweaksConfig.search = searchConfig;
// Save to JS Injector using utils
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else {
// Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
}
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
/**
* Build configuration HTML
*/
function buildConfigHTML(config) {
return `
<div style="max-width: 800px;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Enable Jellyseerr</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Enable Jellyseerr integration for request functionality</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="search_enableJellyseerr" ${config.enableJellyseerr === true ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
</label>
</div>
</div>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
const enableJellyseerr = document.getElementById('search_enableJellyseerr')?.checked === true;
return {
enableJellyseerr: enableJellyseerr
};
}
/**
* Open configuration modal
*/
async function openConfigModal() {
try {
// Load config
const config = await loadConfig();
// Build content
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
// Build footer
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-search-config-btn">Save</button>
`;
// Create modal
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Search Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-search-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const searchConfig = collectConfig();
const success = await saveConfig(searchConfig);
if (success) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Search configuration saved!');
}
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
}
});
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '1400px';
modalInstance.dialog.style.height = '90vh';
LOG('Search configuration modal opened');
} catch (error) {
ERR('Error opening search config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
// Export
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.search = { openConfigModal };
console.log('[KefinTweaks Search Config] Script loaded');
})();
@@ -0,0 +1,157 @@
// KefinTweaks Series Episodes Configuration UI
// Modular configuration modal for managing series episodes settings
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks SeriesEpisodes Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes Config]', ...args);
const MODAL_ID = 'kefin-seriesepisodes-config';
/**
* Load configuration from window.KefinTweaksConfig
*/
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.flattenSingleSeasonShows || {};
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(flattenShowsConfig) {
try {
// Ensure window.KefinTweaksConfig exists
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update flattenSingleSeasonShows config
window.KefinTweaksConfig.flattenSingleSeasonShows = flattenShowsConfig;
// Save to JS Injector using utils
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else {
// Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
}
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
/**
* Build configuration HTML
*/
function buildConfigHTML(config) {
const hideSingleSeasonContainer = config.hideSingleSeasonContainer === true;
return `
<div style="max-width: 800px;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Flatten Single Season Shows</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">When enabled, hides the season container section when a series has only one season, since episodes are already displayed directly.</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="flattenSingleSeasonShows_hideSingleSeasonContainer" ${hideSingleSeasonContainer ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
</label>
</div>
</div>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
const hideSingleSeasonContainer = document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true;
return {
hideSingleSeasonContainer: hideSingleSeasonContainer
};
}
/**
* Open configuration modal
*/
async function openConfigModal() {
try {
// Load config
const config = await loadConfig();
// Build content
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
// Build footer
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-seriesepisodes-config-btn">Save</button>
`;
// Create modal
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Episodes On Series Page Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-seriesepisodes-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const flattenShowsConfig = collectConfig();
const success = await saveConfig(flattenShowsConfig);
if (success) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration saved!');
}
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
}
});
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '1400px';
modalInstance.dialog.style.height = '90vh';
LOG('Series Episodes configuration modal opened');
} catch (error) {
ERR('Error opening series episodes config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
// Export
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.seriesEpisodes = { openConfigModal };
console.log('[KefinTweaks SeriesEpisodes Config] Script loaded');
})();
+157
View File
@@ -0,0 +1,157 @@
// KefinTweaks Series Episodes Configuration UI
// Modular configuration modal for managing series episodes settings
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks SeriesInfo Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks SeriesInfo Config]', ...args);
const MODAL_ID = 'kefin-seriesinfo-config';
/**
* Load configuration from window.KefinTweaksConfig
*/
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.seriesInfo || {};
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(seriesInfoConfig) {
try {
// Ensure window.KefinTweaksConfig exists
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update flattenSingleSeasonShows config
window.KefinTweaksConfig.seriesInfo = seriesInfoConfig;
// Save to JS Injector using utils
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else {
// Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
}
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
/**
* Build configuration HTML
*/
function buildConfigHTML(config) {
const use24HourTimeFormat = config.use24HourTimeFormat === true;
return `
<div style="max-width: 800px;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Use 24 Hour Time Format</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">When enabled, uses a 24 hour time format for the series info.</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="seriesInfo_use24HourTimeFormat" ${use24HourTimeFormat ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
</label>
</div>
</div>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
const use24HourTimeFormat = document.getElementById('seriesInfo_use24HourTimeFormat')?.checked === true;
return {
use24HourTimeFormat: use24HourTimeFormat
};
}
/**
* Open configuration modal
*/
async function openConfigModal() {
try {
// Load config
const config = await loadConfig();
// Build content
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
// Build footer
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-seriesinfo-config-btn">Save</button>
`;
// Create modal
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Series Info Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-seriesinfo-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const seriesInfoConfig = collectConfig();
const success = await saveConfig(seriesInfoConfig);
if (success) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration saved!');
}
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
}
});
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '1400px';
modalInstance.dialog.style.height = '90vh';
LOG('Series Info configuration modal opened');
} catch (error) {
ERR('Error opening series info config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
// Export
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.seriesInfo = { openConfigModal };
console.log('[KefinTweaks SeriesInfo Config] Script loaded');
})();
+649
View File
@@ -0,0 +1,649 @@
// KefinTweaks Skin Manager Configuration UI
// Modular configuration modal for managing skins, themes, and optional includes
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks SkinManager Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks SkinManager Config]', ...args);
const MODAL_ID = 'kefin-skinmanager-config';
// Store loaded config for use in listeners
let currentLoadedConfig = null;
// Helper function to build Jellyfin checkbox
function buildJellyfinCheckbox(id, checked, label, dataAttributes = {}) {
let className = 'emby-checkbox';
const otherAttrs = {};
for (const [key, value] of Object.entries(dataAttributes)) {
if (key === 'class') {
className += ' ' + value;
} else {
otherAttrs[key] = value;
}
}
const dataAttrs = Object.entries(otherAttrs).map(([key, value]) => `${key}="${value}"`).join(' ');
const checkedAttr = checked ? 'checked' : '';
return `
<div class="checkboxContainer">
<label class="emby-checkbox-label">
<input is="emby-checkbox" type="checkbox" id="${id}" class="${className}" data-embycheckbox="true" ${checkedAttr} ${dataAttrs}>
<span class="checkboxLabel">${label}</span>
<span class="checkboxOutline">
<span class="material-icons checkboxIcon checkboxIcon-checked check" aria-hidden="true"></span>
<span class="material-icons checkboxIcon checkboxIcon-unchecked" aria-hidden="true"></span>
</span>
</label>
</div>
`;
}
// Extract GitHub repository URL from skin configuration
function getSkinGitHubUrl(skin) {
if (skin.github) {
return skin.github;
}
if (skin.url && Array.isArray(skin.url)) {
for (const urlObj of skin.url) {
if (urlObj.urls && Array.isArray(urlObj.urls)) {
for (const url of urlObj.urls) {
const match = url.match(/https:\/\/cdn\.jsdelivr\.net\/gh\/([^\/]+)\/([^\/@]+)/);
if (match) {
const owner = match[1];
const repo = match[2];
return `https://github.com/${owner}/${repo}`;
}
}
}
}
}
return null;
}
// Build skin toggle switches
function buildSkinToggles(skins, skinSources = {}) {
return skins.map(skin => {
const isEnabled = skin.enabled !== false;
const githubUrl = getSkinGitHubUrl(skin);
const source = skinSources[skin.name] || 'default';
let borderStyle = '1px solid rgba(255,255,255,0.1)';
let backgroundStyle = 'rgba(255,255,255,0.02)';
let badgeText = '';
let badgeStyle = '';
let badgeIcon = '';
if (source === 'default') {
backgroundStyle = 'rgba(255,255,255,0.01)';
badgeText = 'D';
badgeStyle = 'background: rgba(0,122,255,0.2); color: rgba(0,122,255,0.9);';
} else if (source === 'overridden') {
borderStyle = '2px solid rgba(0, 122, 255, 0.5)';
backgroundStyle = 'rgba(0,122,255,0.05)';
badgeIcon = 'warning';
badgeStyle = 'color: rgba(255,165,0,0.9);';
}
let tooltipText = '';
if (source === 'default') {
tooltipText = 'This skin is included automatically by KefinTweaks';
} else if (source === 'overridden') {
tooltipText = 'This default skin has been overridden by a custom configuration';
} else if (githubUrl) {
tooltipText = 'View on GitHub';
}
return `
<div class="listItem" data-skin-source="${source}" style="border: ${borderStyle}; border-radius: 4px; padding: 0.5em; background: ${backgroundStyle};" ${tooltipText ? `title="${tooltipText}"` : ''}>
<div class="listItemContent" style="display: flex; justify-content: space-between; align-items: center; gap: 0.5em;">
<div style="flex: 1; display: flex; align-items: center; gap: 0.5em; min-width: 0;">
${githubUrl ? `
<a href="${githubUrl}" target="_blank" rel="noopener noreferrer" style="display: flex; align-items: center; gap: 0.4em; color: inherit; text-decoration: none; opacity: 0.85; transition: opacity 0.2s; min-width: 0;" title="View on GitHub" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.85'">
<span class="listItemBodyText" style="font-weight: 500; font-size: 0.9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${skin.name}</span>
<span class="material-icons" style="font-size: 1em; flex-shrink: 0;">info</span>
</a>
` : `
<div class="listItemBodyText" style="font-weight: 500; font-size: 0.9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${skin.name}</div>
`}
${badgeIcon ? `<span class="material-icons" style="font-size: 1.1em; ${badgeStyle}" title="${tooltipText}">${badgeIcon}</span>` : ''}
${badgeText ? `<span style="font-size: 0.75em; padding: 0.15em 0.4em; border-radius: 3px; ${badgeStyle}">${badgeText}</span>` : ''}
</div>
${buildJellyfinCheckbox(`skin_${skin.name.replace(/[^a-zA-Z0-9]/g, '_')}`, isEnabled, '', { 'data-skin-name': skin.name })}
</div>
</div>
`;
}).join('');
}
// Build optional includes skin options
function buildOptionalIncludesSkinOptions(skins) {
const skinsWithOptionalIncludes = skins.filter(skin =>
skin.optionalIncludes && skin.optionalIncludes.length > 0
);
return skinsWithOptionalIncludes.map(skin =>
`<option value="${skin.name}">${skin.name}</option>`
).join('');
}
// Extract author from URL
function extractAuthorFromUrl(url) {
if (!url) return '';
try {
const jsdelivrMatch = url.match(/cdn\.jsdelivr\.net\/gh\/([^\/]+)\//);
if (jsdelivrMatch) {
return jsdelivrMatch[1];
}
} catch (e) {
// Ignore errors
}
return '';
}
// Generate optional include key
function generateOptionalIncludeKey(skinName, author, url) {
const normalizedSkinName = skinName === 'global' ? 'global' : skinName.replace(/\s+/g, '_');
const filename = url ? url.split('?')[0].split('#')[0].split('/').pop() : '';
const normalizedAuthor = (author || '').replace(/\s+/g, '_');
return `${normalizedSkinName}-${normalizedAuthor}-${filename}`;
}
// Extract filename from URL
function extractFilenameFromUrl(url) {
if (!url) return '';
try {
const urlWithoutParams = url.split('?')[0].split('#')[0];
const pathParts = urlWithoutParams.split('/');
return pathParts[pathParts.length - 1] || '';
} catch (e) {
return '';
}
}
// Build optional includes editor (simplified version)
function buildOptionalIncludesEditor(category, config, skins) {
let optionalIncludes = [];
if (category === 'global') {
const defaultGlobalIncludes = window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || [];
optionalIncludes = defaultGlobalIncludes.map(include => ({ ...include }));
const adminOptionalIncludes = config.optionalIncludes || [];
adminOptionalIncludes.forEach(adminEntry => {
if (typeof adminEntry === 'object' && adminEntry.key) {
const keyParts = adminEntry.key.split('-');
if (keyParts[0] === 'global' && keyParts.length >= 3) {
const author = keyParts[1];
const filename = keyParts.slice(2).join('-');
const matchingInclude = optionalIncludes.find(inc => {
const incAuthor = extractAuthorFromUrl(inc.url);
const incFilename = extractFilenameFromUrl(inc.url);
return incAuthor === author && incFilename === filename;
});
if (matchingInclude) {
matchingInclude.enabled = adminEntry.enabled;
}
}
}
});
} else {
const skin = skins.find(s => s.name === category);
if (skin && skin.optionalIncludes) {
optionalIncludes = skin.optionalIncludes.map(include => {
const author = extractAuthorFromUrl(include.url);
const key = generateOptionalIncludeKey(category, author, include.url);
const adminOptionalIncludes = config.optionalIncludes || [];
const adminEntry = adminOptionalIncludes.find(entry =>
typeof entry === 'object' && entry.key === key
);
return {
...include,
key: key,
enabled: adminEntry ? adminEntry.enabled : (include.enabled || false)
};
});
}
}
if (optionalIncludes.length === 0) {
return `
<div class="listItemBodyText secondary" style="padding: 1em; text-align: center; opacity: 0.7;">
No optional CSS modules available for ${category === 'global' ? 'global options' : category}.
</div>
`;
}
return `
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
Configure which optional CSS modules are enabled by default. Users can override these settings individually.
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.5em;">
${optionalIncludes.map((include, index) => {
const author = extractAuthorFromUrl(include.url);
const key = include.key || generateOptionalIncludeKey(category, author, include.url);
const filename = include.url ? extractFilenameFromUrl(include.url) : '';
return `
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; background: rgba(255,255,255,0.02);">
<div class="listItemContent">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5em; gap: 0.5em;">
<div style="flex: 1; min-width: 0;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.25em;">${include.name}</div>
${author ? `<div class="listItemBodyText secondary" style="font-size: 0.85em;">by <a href="https://github.com/${author}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline; opacity: 0.8; transition: opacity 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.8'">${author}</a></div>` : ''}
</div>
<input type="checkbox"
id="optionalInclude_${category}_${index}"
class="checkbox optionalIncludeCheckbox"
data-category="${category}"
data-key="${key}"
data-url="${include.url || ''}"
${include.enabled ? 'checked' : ''}
style="width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin-top: 0.125em;">
</div>
${filename ? `<div class="listItemBodyText secondary" style="font-size: 0.75em; opacity: 0.5; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl;" title="${include.url}">${filename}</div>` : ''}
</div>
</div>
`;
}).join('')}
</div>
`;
}
/**
* Load configuration from window.KefinTweaksConfig
*/
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
// Reconstruct full skin list (enabled + disabled) for configuration
const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || [];
const adminSkins = config.skins || [];
const mergedSkins = [];
// Add all default skins first
defaultSkins.forEach(skin => {
mergedSkins.push({ ...skin, enabled: true });
});
// Override/Add with admin skins
adminSkins.forEach(adminSkin => {
const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name);
if (existingIndex >= 0) {
mergedSkins[existingIndex] = { ...mergedSkins[existingIndex], ...adminSkin };
} else {
mergedSkins.push({ ...adminSkin });
}
});
return {
skins: mergedSkins,
themes: config.themes || [],
defaultSkin: config.defaultSkin || null,
optionalIncludes: config.optionalIncludes || []
};
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(skinManagerConfig) {
try {
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update skin manager config
window.KefinTweaksConfig.skins = skinManagerConfig.skins;
window.KefinTweaksConfig.themes = skinManagerConfig.themes;
window.KefinTweaksConfig.defaultSkin = skinManagerConfig.defaultSkin;
window.KefinTweaksConfig.optionalIncludes = skinManagerConfig.optionalIncludes;
// Save to JS Injector using utils
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else {
// Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
}
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
/**
* Build configuration HTML
*/
function buildConfigHTML(config) {
const allSkinNames = config.skins.map(skin => skin.name).filter(Boolean);
const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {};
const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden');
return `
<div style="max-width: 1200px;">
<!-- Default Skin -->
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Default Skin</div>
<select id="defaultSkin" class="fld emby-select-withcolor emby-select" style="width: 100%; max-width: 400px;">
${allSkinNames.map(name => `<option value="${name}" ${config.defaultSkin === name ? 'selected' : ''}>${name}</option>`).join('')}
${allSkinNames.length === 0 ? '<option value="">No skins available</option>' : ''}
</select>
<div class="listItemBodyText secondary" style="margin-top: 0.5em; font-size: 0.9em;">Select a default skin for all users</div>
</div>
</div>
<!-- Enable/Disable Skins -->
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent" style="width: 100%;">
<h3 class="listItemBodyText" style="margin-bottom: 0.5em;">Enable/Disable Skins</h3>
<div class="listItemBodyText secondary" style="margin-bottom: 1em; font-size: 0.9em;">Disabled skins will not appear in the appearance dropdowns for users</div>
<div class="listItemBodyText secondary" style="margin-bottom: 1em; font-size: 0.85em; color: rgba(255,255,255,0.7); line-height: 1.5;">
All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations.
KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility.
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.5em;">
${buildSkinToggles(config.skins, skinSources)}
</div>
</div>
</div>
${hasOverriddenSkins ? `
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.</div>
<button type="button" id="removeDuplicateDefaultsBtn" class="emby-button raised" style="padding: 0.5em 1.5em; font-size: 0.9em;">
<span>Remove Custom Skins Overriding Defaults</span>
</button>
</div>
</div>
` : ''}
<!-- Optional CSS Modules -->
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemContent">
<h3 class="listItemBodyText" style="margin-bottom: 0.25em;">Optional CSS Modules</h3>
<div class="listItemBodyText secondary" style="margin-bottom: 1em; font-size: 0.9em;">Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.</div>
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Select Skin</div>
<select id="optionalIncludesCategory" class="fld emby-select-withcolor emby-select" style="width: 100%; max-width: 400px; margin-bottom: 1em;">
<option value="global">Global (applies to all skins)</option>
${buildOptionalIncludesSkinOptions(config.skins)}
</select>
<div id="optionalIncludesEditor">
${buildOptionalIncludesEditor('global', { optionalIncludes: config.optionalIncludes }, config.skins)}
</div>
</div>
</div>
<!-- Skins JSON -->
<details class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0; margin-bottom: 1em; display: grid;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; padding: 0.75em; display: flex; align-items: center; gap: 0.5em; list-style: none; user-select: none;">
<span class="material-icons" style="font-size: 1.2em; transition: transform 0.2s;">chevron_right</span>
<span>Skins JSON</span>
</summary>
<div style="padding: 0.75em; border-top: 1px solid rgba(255,255,255,0.1);">
<div class="listItemContent">
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.</div>
<textarea id="skinsJson" class="fld emby-textarea" rows="15" placeholder='[{"name":"Skin Name","author":"Author","url":[...]}]' style="width: 100%; font-family: monospace; font-size: 0.9em; line-height: 1.5;">${JSON.stringify(config.skins.filter(s => {
const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name));
return !defaultSkinNames.has(s.name);
}).map(s => ({
name: s.name,
author: s.author,
url: s.url,
colorSchemes: s.colorSchemes || []
})), null, 2)}</textarea>
</div>
</div>
</details>
<!-- Themes JSON -->
<details class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0; margin-bottom: 1em; display: grid;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; padding: 0.75em; display: flex; align-items: center; gap: 0.5em; list-style: none; user-select: none;">
<span class="material-icons" style="font-size: 1.2em; transition: transform 0.2s;">chevron_right</span>
<span>Themes JSON</span>
</summary>
<div style="padding: 0.75em; border-top: 1px solid rgba(255,255,255,0.1);">
<div class="listItemContent">
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.</div>
<textarea id="themesJson" class="fld emby-textarea" rows="12" placeholder='[{"name":"Theme Name","url":"https://..."}]' style="width: 100%; font-family: monospace; font-size: 0.9em; line-height: 1.5;">${JSON.stringify(config.themes, null, 2)}</textarea>
</div>
</div>
</details>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
// Get default skin
const defaultSkin = document.getElementById('defaultSkin')?.value || null;
if (defaultSkin === '') {
defaultSkin = null;
}
// Get skin enabled states
const skinEnabledStates = {};
document.querySelectorAll('[data-skin-name]').forEach(checkbox => {
const skinName = checkbox.getAttribute('data-skin-name');
skinEnabledStates[skinName] = checkbox.checked;
});
// Get default skin names to filter them out
const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name));
// Parse skins JSON
let skinsFromJson = [];
try {
const skinsJsonText = document.getElementById('skinsJson')?.value || '[]';
skinsFromJson = JSON.parse(skinsJsonText);
} catch (e) {
ERR('Error parsing skins JSON:', e);
}
// Only save admin skins (from JSON textarea) - filter out any that match default names
const customSkins = skinsFromJson
.filter(skin => !defaultSkinNames.has(skin.name))
.map(skin => ({
...skin,
enabled: skinEnabledStates[skin.name] !== false,
hidden: false
}));
// Handle disabled default skins
const disabledDefaultSkins = [];
defaultSkinNames.forEach(name => {
if (skinEnabledStates[name] === false) {
disabledDefaultSkins.push({
name: name,
enabled: false
});
}
});
// Combine custom skins and disabled default skins
const skins = [...customSkins, ...disabledDefaultSkins];
// Parse themes JSON
let themes = [];
try {
const themesJsonText = document.getElementById('themesJson')?.value || '[]';
themes = JSON.parse(themesJsonText);
} catch (e) {
ERR('Error parsing themes JSON:', e);
}
// Collect optional includes for the currently selected category only
const categorySelect = document.getElementById('optionalIncludesCategory');
const currentCategory = categorySelect ? categorySelect.value : 'global';
// Get existing optional includes from config
const existingOptionalIncludes = (window.KefinTweaksConfig?.optionalIncludes || []).slice();
// Collect only the optional includes for the current category
const currentCategoryOptionalIncludes = [];
document.querySelectorAll('.optionalIncludeCheckbox').forEach(checkbox => {
const category = checkbox.getAttribute('data-category');
const key = checkbox.getAttribute('data-key');
const enabled = checkbox.checked;
// Only collect if it matches the current category
if (category === currentCategory) {
currentCategoryOptionalIncludes.push({
key: key,
enabled: enabled
});
}
});
// Merge: remove existing entries for this category, then add new ones
const optionalIncludes = existingOptionalIncludes.filter(entry => {
if (typeof entry === 'object' && entry.key) {
const keyParts = entry.key.split('-');
const entryCategory = keyParts[0];
return entryCategory !== currentCategory;
}
return true;
});
// Add the current category's optional includes
optionalIncludes.push(...currentCategoryOptionalIncludes);
return {
skins: skins,
themes: themes,
defaultSkin: defaultSkin,
optionalIncludes: optionalIncludes
};
}
/**
* Attach event listeners
*/
function attachListeners(modalInstance) {
// Optional includes category change
const categorySelect = modalInstance.dialogContent.querySelector('#optionalIncludesCategory');
if (categorySelect) {
categorySelect.addEventListener('change', () => {
const category = categorySelect.value;
const editor = modalInstance.dialogContent.querySelector('#optionalIncludesEditor');
if (editor) {
// Use saved config from window.KefinTweaksConfig to get the actual saved optionalIncludes
// This ensures the UI reflects the saved state, not just what's currently visible
const savedOptionalIncludes = window.KefinTweaksConfig?.optionalIncludes || [];
const config = { optionalIncludes: savedOptionalIncludes };
const mergedSkins = currentLoadedConfig ? currentLoadedConfig.skins : collectConfig().skins;
editor.innerHTML = buildOptionalIncludesEditor(category, config, mergedSkins);
attachListeners(modalInstance); // Re-attach listeners for new checkboxes
}
});
}
// Remove duplicate defaults button
const removeBtn = modalInstance.dialogContent.querySelector('#removeDuplicateDefaultsBtn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
// This would need to be implemented to remove overridden skins
alert('This feature will remove custom skins that override defaults. Implementation needed.');
});
}
}
/**
* Open configuration modal
*/
async function openConfigModal() {
try {
// Load config
const config = await loadConfig();
// Store loaded config for use in listeners
currentLoadedConfig = config;
// Build content
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
// Build footer
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-skinmanager-config-btn">Save</button>
`;
// Create modal
const modalInstance = window.ModalSystem.create({
id: MODAL_ID,
title: 'Skin Manager Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modal) => {
attachListeners(modal);
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-skinmanager-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const skinManagerConfig = collectConfig();
const success = await saveConfig(skinManagerConfig);
if (success) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Skin Manager configuration saved!');
}
window.ModalSystem.close(MODAL_ID);
} else {
alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
});
}
}
});
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '1400px';
modalInstance.dialog.style.height = '90vh';
LOG('Skin Manager configuration modal opened');
} catch (error) {
ERR('Error opening skin manager config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
// Export
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.skinManager = { openConfigModal };
console.log('[KefinTweaks SkinManager Config] Script loaded');
})();
+125 -13
View File
@@ -63,7 +63,6 @@
search: true, // Enhanced search functionality
headerTabs: true, // Header tab enhancements
customMenuLinks: true, // Custom menu links functionality
exclusiveElsewhere: true, // Exclusive elsewhere branding
updoot: false, // Upvote functionality
backdropLeakFix: true, // Memory leak fixes
dashboardButtonFix: true, // Dashboard button fix
@@ -107,6 +106,104 @@
dependencies: [],
description: 'Default skin configuration for KefinTweaks'
},
{
name: 'ui',
script: 'ui.js',
css: null,
dependencies: [],
description: 'UI helper functions for KefinTweaks'
},
{
name: 'homeScreenConfig',
script: 'homeScreenConfig.js',
css: null,
dependencies: [],
description: 'Default home screen configuration for KefinTweaks'
},
{
name: 'homeScreenConfig2',
script: 'homeScreenConfig2.js',
css: null,
dependencies: [],
description: 'Default home screen configuration for KefinTweaks'
},
{
name: 'homeScreen-migration',
script: '../configuration/homeScreen-migration.js',
css: null,
dependencies: [],
description: 'Home screen migration for KefinTweaks'
},
{
name: 'homeScreen-configuration',
script: '../configuration/homeScreen-configuration.js',
css: '../configuration/homeScreen-configuration.css',
dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui'],
description: 'Home screen configuration for KefinTweaks'
},
{
name: 'search-configuration',
script: '../configuration/search-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils'],
description: 'Search configuration for KefinTweaks'
},
{
name: 'seriesEpisodes-configuration',
script: '../configuration/seriesEpisodes-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils'],
description: 'Series episodes configuration for KefinTweaks'
},
{
name: 'seriesInfo-configuration',
script: '../configuration/seriesInfo-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils'],
description: 'Series info configuration for KefinTweaks'
},
{
name: 'skinManager-configuration',
script: '../configuration/skinManager-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils'],
description: 'Skin manager configuration for KefinTweaks'
},
{
name: 'customMenuLinks-configuration',
script: '../configuration/customMenuLinks-configuration.js',
css: null,
dependencies: ['modal', 'toaster', 'utils'],
description: 'Custom menu links configuration for KefinTweaks'
},
{
name: 'homeScreenConfigCommunity',
script: 'homeScreenConfig-community.js',
css: null,
dependencies: [],
description: 'Community home screen sections for KefinTweaks'
},
{
name: 'peopleCache',
script: 'peopleCache.js',
css: null,
dependencies: [],
description: 'People cache for KefinTweaks'
},
{
name: 'studiosCache',
script: 'studiosCache.js',
css: null,
dependencies: [],
description: 'Studios cache for KefinTweaks'
},
{
name: 'moviesCache',
script: 'moviesCache.js',
css: null,
dependencies: [],
description: 'Movies cache for KefinTweaks'
},
{
name: 'skinManager',
script: 'skinManager.js',
@@ -119,14 +216,14 @@
name: 'apiHelper',
script: 'apiHelper.js',
css: null,
dependencies: [],
dependencies: ['dataHelper'],
description: 'API helper functions for common Jellyfin operations'
},
{
name: 'cardBuilder',
script: 'cardBuilder.js',
css: 'cardBuilder.css',
dependencies: ['apiHelper'],
dependencies: ['apiHelper', 'userHelper'],
description: 'Core card building functionality (required by other scripts)'
},
{
@@ -150,6 +247,27 @@
dependencies: [],
description: 'Generic modal system for Jellyfin-style dialogs'
},
{
name: 'userHelper',
script: 'userHelper.js',
css: null,
dependencies: [],
description: 'User helper functions for Jellyfin operations'
},
{
name: 'dataHelper',
script: '../helpers/dataHelper.js',
css: null,
dependencies: [],
description: 'Data helper functions for Jellyfin operations'
},
{
name: 'websocketHelper',
script: '../helpers/websocketHelper.js',
css: null,
dependencies: [],
description: 'WebSocket helper functions for Jellyfin operations'
},
{
name: 'toaster',
script: 'toaster.js',
@@ -166,9 +284,10 @@
},
{
name: 'homeScreen',
script: 'homeScreen.js',
script: 'homeScreen3.js',
css: 'homeScreen.css',
dependencies: ['cardBuilder', 'localStorageCache', 'utils'],
dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'homeScreenConfig', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'websocketHelper', 'homeScreen-migration', 'homeScreen-user-configuration'],
priority: true, // Load immediately after dependencies to reduce UI disruption
description: 'Adds custom home screen sections'
},
{
@@ -192,13 +311,6 @@
dependencies: ['utils'],
description: 'Load and add custom menu links from configuration'
},
{
name: 'exclusiveElsewhere',
script: 'exclusiveElsewhere.js',
css: null,
dependencies: [],
description: 'Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality to add custom branding when items are not available on streaming services'
},
{
name: 'backdropLeakFix',
script: 'backdropLeakFix.js',
@@ -604,7 +716,7 @@
}
async function loadConfigurationJS() {
const configDependencyNames = ['modal', 'toaster', 'utils'];
const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'skinManager-configuration', 'customMenuLinks-configuration'];
for (const depName of configDependencyNames) {
const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName);
if (depScript) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+151 -1
View File
@@ -3,7 +3,7 @@
.spotlight-section {
position: relative;
width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%)));
margin-bottom: 2em;
margin: 2em 0;
}
.spotlight-banner-container {
@@ -85,6 +85,9 @@
object-fit: contain;
margin-bottom: 16px;
margin-top: 10px;
background-size: contain;
background-repeat: no-repeat;
background-position: left;
}
.spotlight-clearart {
@@ -408,3 +411,150 @@
height: 125px;
}
}
.skeleton-card {
margin-bottom: 45px !important;
}
[data-item-type="Movie"] .spotlight-item-logo {
height: 120px;
max-width: 310.2px;
}
[data-item-type="Movie"] .spotlight-tagline {
-webkit-line-clamp: 1;
min-height: 30px;
max-height: 30px;
}
// Remove this if we switch back to Jellyfin's default scroll buttons
.emby-scrollbuttons.padded-right {
-webkit-align-items: center;
align-items: center;
color: #fff;
display: -webkit-flex;
display: flex;
-webkit-justify-content: center;
justify-content: center;
min-height: 24px;
min-width: 104px;
padding-top: .85em;
position: absolute;
top: 0;
z-index: 1;
}
/* Progressive Section Reveal Animation */
.cardbuilder-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 */
.cardbuilder-section-reveal.in-viewport {
opacity: 1;
transform: translateY(0);
}
.show-all-button {
margin-left: 10px;
font-size: 12px;
padding: 4px 8px;
min-width: auto;
background: transparent;
border-radius: 4px;
cursor: pointer;
align-self: center;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: var(--main-text, #fff) !important;
position: relative;
top: -3px;
display: none; /* Hidden by default */
}
/* Show button when section is expandable */
.emby-scroller-container[data-expandable="true"] .show-all-button {
display: initial !important;
}
/* Expanded grid view styles */
.itemsContainer[data-expanded="true"] {
display: flex !important;
flex-wrap: wrap !important;
white-space: normal !important;
transform: none !important;
transition: none !important;
}
/* Hide scroll buttons when expanded */
.emby-scroller-container:has(.itemsContainer[data-expanded="true"]) .emby-scrollbuttons {
display: none !important;
}
/* Refresh Button Styles */
.section-refresh-button {
margin-left: 8px;
padding: 4px;
min-width: auto;
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
align-items: center;
justify-content: center;
color: var(--main-text, rgba(255, 255, 255, 0.87));
border-radius: 50%;
font-size: 18px;
transition: transform 0.3s ease, color 0.3s ease, background 0.2s ease;
opacity: 0.65;
}
.section-refresh-button:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Spinning animation when refreshing */
[data-refreshing="true"] .section-refresh-button {
animation: spin 1s linear infinite;
}
/* Checkmark state - green color to indicate success */
.section-refresh-button.check_circle {
animation: none;
}
/* Smooth transition when changing icons */
.section-refresh-button.refresh,
.section-refresh-button.check_circle {
transition: transform 0.3s ease, color 0.3s ease, background 0.2s ease;
}
/* Fade in/out transitions for icon changes */
.section-refresh-button::after {
transition: opacity 0.2s ease;
}
.section-refresh-button.icon-fade-out::after {
opacity: 0;
}
.section-refresh-button.icon-fade-in::after {
opacity: 1;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Ensure button remains visible when expanded (data-expandable takes precedence, but this ensures it) */
.emby-scroller-container:has(.itemsContainer[data-expanded="true"]) .show-all-button {
display: initial !important;
}
+584 -590
View File
File diff suppressed because it is too large Load Diff
+137 -6
View File
@@ -21,6 +21,7 @@
.sections.homeSectionsContainer {
display: flex;
flex-direction: column;
padding-bottom: 100px;
}
.homeSectionsContainer > .section0 {
@@ -96,7 +97,7 @@
margin-top: 0;
}
div#discovery-loading-indicator:not(.show)::before {
/* div#discovery-loading-indicator:not(.show)::before {
content: 'Loading...';
position: absolute;
top: 50%;
@@ -107,7 +108,7 @@ div#discovery-loading-indicator:not(.show)::before {
visibility: visible;
text-align: center;
opacity: 1;
}
} */
#indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before {
content: 'Discover More';
@@ -126,13 +127,13 @@ div#discovery-loading-indicator:not(.show)::after {
opacity: 1;
}
.libraryPage:has(.load-more-discovery-btn) div#discovery-loading-indicator {
opacity: 0;
[data-loading-discovery="true"] div#discovery-loading-indicator {
display: flex;
}
#indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
display: none;
}
} */
#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after {
content: '';
@@ -144,4 +145,134 @@ div#discovery-loading-indicator:not(.show)::after {
}
.spotlight-section {
width: calc(100% - (2 * max(env(safe-area-inset-left),3.3%)));
}
.load-more-discovery-btn {
display: block;
visibility: visible;
order: 99998 !important;
margin: 0 auto;
}
.sections.loading-discovery .load-more-discovery-btn {
visibility: hidden !important;
}
/* Discovery section loading indicator */
.sections.loading-discovery .discovery-loading-indicator {
visibility: visible !important;
opacity: 1 !important;
}
.discovery-loading-indicator, .load-more-discovery-btn {
height: 50px;
}
.discovery-loading-indicator {
display: none;
opacity: 1;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
border-radius: 8px;
font-size: 14px;
color: #666;
transition: opacity 0.3s ease;
order: 99999 !important;
position: relative;
justify-content: start;
}
.discovery-loading-indicator .spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
}
@keyframes discovery-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Hide next up section if next up if KefinTweaks next up section is present */
.homeSectionsContainer:has([data-section-id="nextUp"]) > div:nth-child(-n+10):has(a[href*="nextup"]) {
display: none;
}
/* Hide continue watching section if continue watching if KefinTweaks continue watching section is present */
.homeSectionsContainer:has([data-section-id="continueWatching"]) > div:nth-child(-n+10):has([data-positionticks][data-mediatype="Video"]) {
display: none;
}
/* Hide recently added section if recently added if KefinTweaks recently added section is present */
.homeSectionsContainer:has([data-section-id*="recently-added"]) > div:nth-child(-n+10):has(a[href*="movies&tab=1"]),
.homeSectionsContainer:has([data-section-id*="recently-added"]) > div:nth-child(-n+10):has(a[href*="tv&tab=1"]),
.homeSectionsContainer:has([data-section-id*="recently-added"]) > div:nth-child(-n+10):has(a[href*="music&tab=1"])
{
display: none;
}
/* Hide scroll buttons on mobile and tablet layouts */
@media (max-width: 768px) {
.emby-scrollbuttons {
display: none !important;
}
}
/* Hide scroll buttons on mobile using Jellyfin's layout classes */
.layout-mobile .emby-scrollbuttons,
.layout-tv .emby-scrollbuttons {
display: none !important;
}
.load-more-discovery-btn {
display: block;
}
.sections[data-loading-discovery="true"] .load-more-discovery-btn {
display: none !important;
}
.sections[data-loading-discovery="true"] .discovery-loading-indicator {
display: flex !important;
opacity: 1 !important;
}
.discovery-loading-indicator {
display: none;
opacity: 0;
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;
justify-content: start;
min-height: 200px;
}
.discovery-loading-indicator .spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
}
@keyframes discovery-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.homeSectionsContainer [data-section-id="popularTVNetworks"] .card .cardText {
visibility: hidden;
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
// KefinTweaks Community Home Screen Sections
// This file contains curated collections of home screen sections submitted by the community.
// Submit your own: [URL Placeholder]
(function() {
'use strict';
const COMMUNITY_COLLECTIONS = [
{
"name": "Based on Your Watched History [Discovery]",
"author": "HighImKevin",
"sections": [
{
"name": "Movies You Forgot About",
"enabled": true,
"order": 22,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "PlayCount",
"SortOrder": "Descending",
"Filters": "IsResumable",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1768607068997",
"type": "custom"
},
{
"name": "Best Movies You've Never Seen",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"queryOptions": {
"IsUnplayed": true,
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Descending",
"Limit": 20,
"MinCommunityRating": 9
}
}
],
"flattenSeries": false,
"id": "custom-1769640868950",
"type": "custom"
},
{
"name": "Rewatch One Of Your Favorites",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsFavorite",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1769644637599",
"type": "custom"
}
]
}
// Add more collections here
];
// Expose to window
window.KefinCommunityConfig = {
collections: COMMUNITY_COLLECTIONS
};
})();
+868
View File
@@ -0,0 +1,868 @@
// KefinTweaks Home Screen Configuration 2
// Streamlined configuration with queries array format
// Defines default sections, discovery templates, and cache settings
(function() {
'use strict';
// Default Cache Configuration (times in milliseconds)
const CACHE_CONFIG = {
DEFAULT_TTL: 60 * 60 * 1000, // 1 hour
VERY_SHORT_TTL: 1 * 60 * 1000, // 1 minute
SHORT_TTL: 5 * 60 * 1000, // 5 minutes
LONG_TTL: 24 * 60 * 60 * 1000, // 24 hours
STATIC_TTL: 7 * 24 * 60 * 60 * 1000, // 1 week
DISCOVERY_TTL: 6 * 60 * 60 * 1000, // 6 hours
FORCE_REFRESH_TTL: 0 // 0
};
// Standard Home Sections (non-seasonal)
// New streamlined format with queries array, organized into groups
const KEFINTWEAKS_HOME_SECTION_GROUPS = [
// Continue Watching Group
{
id: 'home-continue-watching',
name: 'Continue Watching',
sections: [
{
id: 'continueWatching',
name: 'Resume',
enabled: false,
hidden: false,
order: 10,
cardFormat: 'Thumb',
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
queries: [
{
queryOptions: {
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Filters: 'IsResumable',
IncludeItemTypes: ['Episode', 'Movie'],
Limit: 24
}
}
]
},
{
id: 'nextUp',
name: 'Next Up',
enabled: false,
hidden: false,
order: 11,
cardFormat: 'Thumb',
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
queries: [
{
path: '/Shows/NextUp',
queryOptions: {
Limit: 24,
EnableResumable: 'false'
}
}
]
},
{
id: 'continueWatchingAndNextUp',
name: 'Continue Watching',
enabled: false, // Disabled by default - only used when merge setting is enabled
order: 10,
hidden: true,
cardFormat: 'Thumb',
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
sortBy: 'DatePlayed', // Used to sort merged results from both queries
sortOrder: 'Descending', // Most recent first
queries: [
{
// Continue Watching (Movies only when merged)
queryOptions: {
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Filters: 'IsResumable',
IncludeItemTypes: ['Movie'],
Limit: 24
}
},
{
// Next Up (Episodes)
path: '/Shows/NextUp',
queryOptions: {
Limit: 24,
EnableResumable: 'true'
}
}
]
}
]
},
// Upcoming Group
{
id: 'home-upcoming',
name: 'Upcoming',
sections: [
{
id: 'upcoming',
name: 'Coming Soon',
enabled: true,
order: 60,
cardFormat: 'Thumb',
flattenSeries: true,
ttl: CACHE_CONFIG.LONG_TTL,
queries: [
{
path: '/Shows/Upcoming',
queryOptions: {
Limit: 48,
}
}
]
}
]
},
// Recently Released Group
{
id: 'home-recently-released',
name: 'Recently Released',
sections: [
{
id: 'recentlyReleased.movies',
name: 'Recently Released Movies',
enabled: true,
order: 20,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
queries: [
{
minAge: 0,
maxAge: 30,
queryOptions: {
IncludeItemTypes: ['Movie'],
SortBy: 'PremiereDate',
SortOrder: 'Descending',
Limit: 16,
Fields: 'PremiereDate'
}
}
]
},
{
id: 'recentlyReleased.episodes',
name: 'Recently Aired Episodes',
enabled: true,
order: 21,
cardFormat: 'Thumb',
flattenSeries: true,
ttl: CACHE_CONFIG.DEFAULT_TTL,
queries: [
{
minAge: 0,
maxAge: 7,
queryOptions: {
IncludeItemTypes: ['Episode'],
IsMissing: 'false',
SortBy: 'PremiereDate',
SortOrder: 'Descending',
Limit: 100 // Fetch more for dedupe
}
}
]
}
]
},
// IMDb Top 250 Group
{
id: 'home-imdb-top-250',
name: 'IMDb Top 250',
sections: [
{
id: 'imdbTop250',
name: 'IMDb Top 250',
enabled: true,
order: 70,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.LONG_TTL,
queries: [
{
dataSource: 'MoviesCache.getImdbTop250Movies',
queryOptions: {
Limit: 20,
SortBy: 'Random',
IncludeItemTypes: ['Movie']
}
}
]
}
]
},
// Popular TV Networks Group
{
id: 'home-popular-tv-networks',
name: 'Popular TV Networks',
sections: [
{
id: 'popularTVNetworks',
name: 'Popular TV Networks',
enabled: true,
order: 72,
cardFormat: 'Thumb',
ttl: CACHE_CONFIG.STATIC_TTL,
queries: [
{
dataSource: 'StudiosCache.getPopularTVNetworks',
queryOptions: {
SortBy: 'SortName',
Limit: 20
}
}
]
}
]
},
// Watch Again Group
{
id: 'home-watch-again',
name: 'Watch Again',
sections: [
{
id: 'watchAgain',
name: 'Watch Again',
enabled: true,
order: 73,
cardFormat: 'Random',
ttl: CACHE_CONFIG.DEFAULT_TTL,
queries: [
{
queryOptions: {
Filters: 'IsPlayed',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
}
]
},
// Watchlist Group
{
id: 'home-watchlist',
name: 'Watchlist',
sections: [
{
id: 'watchlist',
name: 'Watchlist',
enabled: true,
order: 71,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
queries: [
{
queryOptions: {
Filters: 'Likes',
SortBy: 'DateCreated',
SortOrder: 'Descending',
Limit: 20
}
}
]
}
]
},
// Resume Playlists Group
{
id: 'home-resume-playlists',
name: 'Pick Up Where You Left Off',
sections: [
{
id: 'resumePlaylists',
name: 'Continue Watching Your Playlists',
enabled: false,
order: 74,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
queries: [
{
queryOptions: {
IsUnplayed: true,
IncludeItemTypes: ['Playlist'],
SortBy: 'PlayedPercentage',
SortOrder: 'Descending',
Limit: 0
}
}
]
},
{
id: 'completeCollections',
name: 'Finish Watching Your Collections',
enabled: false,
order: 75,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
queries: [
{
queryOptions: {
IsUnplayed: true,
IncludeItemTypes: ['BoxSet'],
SortBy: 'PlayedPercentage',
SortOrder: 'Descending'
}
}
]
}
]
}
];
// Seasonal Sections
// Sections that only appear during specific date ranges
const KEFINTWEAKS_SEASONAL_SECTION_GROUPS = [
// Seasonal Sections - Halloween
{
id: 'seasonal-halloween',
name: 'Halloween',
sections: [
{
id: 'seasonal.halloween.halloween-tag',
name: 'Halloween Movies',
enabled: true,
startDate: '10-01',
endDate: '10-31',
order: 50,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
Tags: 'halloween',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
},
{
id: 'seasonal.halloween.halloween-horror',
name: 'Horror Movies',
enabled: true,
startDate: '10-01',
endDate: '10-31',
order: 51,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
Genres: 'Horror',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
},
{
id: 'seasonal.halloween.halloween-thriller',
name: 'Thriller Movies',
enabled: true,
startDate: '10-01',
endDate: '10-31',
order: 52,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
Genres: 'Thriller',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
}
]
},
// Seasonal Sections - Christmas
{
id: 'seasonal-christmas',
name: 'Christmas',
sections: [
{
id: 'seasonal.christmas.christmas-movies',
name: 'Christmas Movies',
enabled: true,
startDate: '12-01',
endDate: '12-31',
order: 50,
cardFormat: 'Poster',
renderMode: 'Spotlight',
queries: [
{
queryOptions: {
Tags: 'christmas',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
},
{
id: 'seasonal.christmas.christmas-episodes',
name: 'Christmas Episodes',
enabled: true,
startDate: '12-01',
endDate: '12-31',
order: 51,
cardFormat: 'Thumb',
queries: [
{
queryOptions: {
SearchTerm: 'christmas',
IncludeItemTypes: ['Episode'],
SortBy: 'Random',
Limit: 20
}
}
]
}
]
},
// Seasonal Sections - New Year's
{
id: 'seasonal-new-years',
name: 'New Year\'s',
sections: [
{
id: 'seasonal.new-years.new-years-movies',
name: 'New Year\'s Movies',
enabled: true,
startDate: '01-01',
endDate: '01-02',
order: 50,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
Tags: 'new year\'s eve',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
},
{
id: 'seasonal.new-years.new-years-episodes',
name: 'New Year\'s Episodes',
enabled: true,
startDate: '01-01',
endDate: '01-02',
order: 51,
cardFormat: 'Thumb',
queries: [
{
queryOptions: {
SearchTerm: 'new year',
IncludeItemTypes: ['Episode'],
SortBy: 'Random',
Limit: 20
}
}
]
}
]
},
// Seasonal Sections - Valentine's
{
id: 'seasonal-valentines',
name: 'Valentine\'s',
sections: [
{
id: 'seasonal.valentines.valentines-movies',
name: 'Valentine\'s Movies',
enabled: true,
startDate: '02-01',
endDate: '02-15',
order: 50,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
Tags: 'valentine\'s day',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
},
{
id: 'seasonal.valentines.valentines-episodes',
name: 'Valentine\'s Episodes',
enabled: true,
startDate: '02-01',
endDate: '02-15',
order: 51,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
SearchTerm: 'valentine',
IncludeItemTypes: ['Episode'],
SortBy: 'Random',
Limit: 20
}
}
]
}
]
},
// Seasonal Sections - Thanksgiving
{
id: 'seasonal-thanksgiving',
name: 'Thanksgiving',
sections: [
{
id: 'seasonal.thanksgiving.thanksgiving-movies',
name: 'Thanksgiving Movies',
enabled: true,
startDate: '11-20',
endDate: '11-30',
order: 50,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
Tags: 'thanksgiving',
IncludeItemTypes: ['Movie'],
SortBy: 'Random',
Limit: 20
}
}
]
},
{
id: 'seasonal.thanksgiving.thanksgiving-episodes',
name: 'Thanksgiving Episodes',
enabled: true,
startDate: '11-20',
endDate: '11-30',
order: 51,
cardFormat: 'Poster',
queries: [
{
queryOptions: {
SearchTerm: 'thanksgiving',
IncludeItemTypes: ['Episode'],
SortBy: 'Random',
Limit: 20
}
}
]
}
]
}
];
// Discovery Templates
// These use "Dynamic" types where 'source' is determined at runtime
// Organized into groups
const KEFINTWEAKS_DISCOVERY_SECTION_GROUPS = [
// Genre Movies Group
{
id: 'discovery-genre-movies',
name: 'Genre Movies',
sections: [
{
id: 'genreMovies',
type: 'Genre',
source: 'Dynamic', // Will be filled with a random Genre ID/Name
name: '{Genre} Movies',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
},
{
id: 'spotlightGenre',
type: 'Genre',
source: 'Dynamic',
name: 'Top Rated {Genre}',
enabled: true,
itemLimit: 20,
sortOrder: 'CommunityRating',
sortOrderDirection: 'Descending',
includeItemTypes: ['Movie'],
renderMode: 'Spotlight', // Replaces 'type: spotlight-genre' with attribute
ttl: CACHE_CONFIG.DISCOVERY_TTL
}
]
},
// People Movies Group
{
id: 'discovery-people-movies',
name: 'People Movies',
sections: [
{
id: 'directedByTopDirector',
type: 'Person',
source: 'Dynamic', // Random Director ID
personType: 'Director', // Extra metadata for dynamic logic
name: 'Directed by {Person}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
},
{
id: 'writtenByTopWriter',
type: 'Person',
source: 'Dynamic', // Random Writer ID
personType: 'Writer',
name: 'Written by {Person}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
},
{
id: 'starringTopActor',
type: 'Person',
source: 'Dynamic', // Random Actor ID
personType: 'Actor',
name: 'Starring {Person}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
}
]
},
// Recently Watched People Group
{
id: 'discovery-recently-watched-people',
name: 'Recently Watched People',
sections: [
{
id: 'starringActorRecentlyWatched',
type: 'Person',
source: 'Dynamic', // Actor from Recently Watched
personType: 'Actor',
sourceType: 'watched-recent', // Hint for source generator
name: 'Starring {Person} since you watched {Title}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
},
{
id: 'directedByDirectorRecentlyWatched',
type: 'Person',
source: 'Dynamic', // Director from Recently Watched
personType: 'Director',
sourceType: 'watched-recent',
name: 'Directed by {Person} since you watched {Title}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
},
{
id: 'writtenByWriterRecentlyWatched',
type: 'Person',
source: 'Dynamic',
personType: 'Writer',
sourceType: 'watched-recent',
name: 'Written by {Person} since you watched {Title}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
}
]
},
// Similar to Watched Group
{
id: 'discovery-similar-to-watched',
name: 'Similar to Watched',
sections: [
{
id: 'becauseYouWatched',
type: 'Similar',
source: 'Dynamic', // Random Watched Item ID
sourceType: 'watched',
name: 'Because you watched {Title}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random', // Typically 'Similarity' but usually returned sorted by API
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
},
{
id: 'becauseYouRecentlyWatched',
type: 'Similar',
source: 'Dynamic', // Random Recently Watched ID
sourceType: 'watched-recent',
name: 'Because you recently watched {Title}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
}
]
},
// Liked Group
{
id: 'discovery-liked',
name: 'Liked',
sections: [
{
id: 'becauseYouLiked',
type: 'Similar',
source: 'Dynamic', // Random Favorite Item ID
sourceType: 'liked',
name: 'Because you liked {Title}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Movie'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
}
]
},
// Studios Group
{
id: 'discovery-studios',
name: 'Studios',
sections: [
{
id: 'studioShows',
type: 'Studio',
source: 'Dynamic', // Random Studio ID
name: 'Shows from {Studio}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
includeItemTypes: ['Series'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
},
{
id: 'spotlightNetwork',
type: 'Studio',
source: 'Dynamic',
name: 'Top Rated from {Studio}',
enabled: true,
itemLimit: 20,
sortOrder: 'CommunityRating',
sortOrderDirection: 'Descending',
includeItemTypes: ['Series'],
renderMode: 'Spotlight',
ttl: CACHE_CONFIG.DISCOVERY_TTL
}
]
},
// Collections Group
{
id: 'discovery-collections',
name: 'Collections',
sections: [
{
id: 'collections',
type: 'Collection', // Standard type, but source is dynamic here
source: 'Dynamic',
name: '{Collection}',
enabled: true,
itemLimit: 20,
sortOrder: 'Random', // Order within the collection
includeItemTypes: ['Movie', 'Series'],
ttl: CACHE_CONFIG.DISCOVERY_TTL
}
]
}
];
const DISCOVERY_SETTINGS = {
enabled: true,
infiniteScroll: true,
minPeopleAppearances: 10,
minGenreMovieCount: 50,
defaultItemLimit: 16,
defaultSortOrder: "Random",
defaultCardFormat: "Poster",
spotlightDiscoveryChance: 0.5,
renderSpotlightAboveMatching: false,
spotlight: {
autoPlay: true,
interval: 5000,
showDots: true,
showNavButtons: true,
showClearArt: true
},
randomizeOrder: false
}
const SEASONAL_THEME_SETTINGS = {
enabled: true,
enableSeasonalAnimations: true,
enableSeasonalBackground: true,
seasonToggles: [
{
id: 'halloween',
name: 'Halloween',
enabled: false
},
{
id: 'christmas',
name: 'Christmas',
enabled: false
},
{
id: 'new-years',
name: 'New Year\'s',
enabled: false
},
{
id: 'valentines',
name: 'Valentine\'s Day',
enabled: false
}
]
}
const HOME_SETTINGS = {
minimumSeriesForPopularTVNetworks: 10,
}
// Expose configuration
window.KefinHomeConfig2 = {
CACHE: CACHE_CONFIG,
HOME_SECTION_GROUPS: KEFINTWEAKS_HOME_SECTION_GROUPS,
SEASONAL_SECTION_GROUPS: KEFINTWEAKS_SEASONAL_SECTION_GROUPS,
DISCOVERY_SECTION_GROUPS: KEFINTWEAKS_DISCOVERY_SECTION_GROUPS,
CUSTOM_SECTION_GROUPS: [], // Empty by default - user-created/imported sections
REMOVE_CONFLICTING_SECTIONS: true,
MERGE_NEXT_UP: false,
HOME_SETTINGS: HOME_SETTINGS,
DISCOVERY_SETTINGS: DISCOVERY_SETTINGS,
SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS
};
console.log('[KefinTweaks HomeConfig2] Configuration loaded');
})();
+1566
View File
File diff suppressed because it is too large Load Diff