Merge Experimental branch into main... finally!

This commit is contained in:
HighImKevin
2026-10-18 15:14:38 -04:00
275 changed files with 95675 additions and 7838 deletions
+528 -310
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,200 @@
// 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 {
customMenuLinks: Array.isArray(config.customMenuLinks) ? config.customMenuLinks : []
};
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(customMenuLinks) {
try {
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
window.KefinTweaksConfig.customMenuLinks = customMenuLinks;
// Placement is per-link; do not keep the legacy global top-nav flag
delete window.KefinTweaksConfig.showCustomMenuLinksInTopNav;
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else 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;
} else {
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 with per-link placements. Require <code>name</code> and either <code>url</code> or <code>action</code> (a dotted path on <code>window</code>, e.g. <code>MyPlugin.openDialog</code>). Defaults: <code>sideMenu</code> true, <code>topNavigation</code> "none", <code>userMenu</code> false. Use <code>collapsed</code> with <code>topNavigation: "main"</code> on v12 to place under More. Optional <code>order</code> overrides top-nav position: for <code>main</code>, left-to-right child index (<code>0</code> = first); for <code>right</code>, right-to-left (<code>0</code> = last child).</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">These links are not added by modifying your config.json, so if you wish for these to appear on supported third party clients which don't use jellyfin-web, you will want to manually edit your config.json rather than adding the links here. You can find the information about doing that <a href="https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links">here</a>.</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.customMenuLinks, 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,
"sideMenu": true,
"topNavigation": "none",
"userMenu": false,
"collapsed": false
},
{
"name": "Top Nav First",
"icon": "star",
"url": "#/home",
"sideMenu": false,
"topNavigation": "main",
"order": 0
},
{
"name": "Top Nav Link",
"icon": "star",
"url": "#/home",
"sideMenu": false,
"topNavigation": "main",
"collapsed": true
},
{
"name": "Header Right Near End",
"icon": "extension",
"action": "MyPlugin.openDialog",
"sideMenu": false,
"topNavigation": "right",
"order": 1
},
{
"name": "External Link",
"icon": "open_in_new",
"url": "https://example.com",
"openInNewTab": true,
"sideMenu": true,
"topNavigation": "none"
}
]</pre>
</details>
</div>
</div>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
try {
const jsonText = document.getElementById('customMenuLinksJson')?.value || '[]';
const customMenuLinks = JSON.parse(jsonText);
return { customMenuLinks };
} catch (e) {
ERR('Error parsing custom menu links JSON:', e);
alert('Invalid JSON format. Please check your input.');
return null;
}
}
/**
* Open configuration modal
*/
async function openConfigModal() {
try {
const config = await loadConfig();
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
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>
`;
window.ModalSystem.create({
id: MODAL_ID,
title: 'Custom Menu Links Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined,
onOpen: (modal) => {
const saveBtn = modal.dialogFooter.querySelector('#save-custommenulinks-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const collected = collectConfig();
if (!collected) return;
const success = await saveConfig(collected.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.');
}
});
}
}
});
LOG('Custom Menu Links configuration modal opened');
} catch (error) {
ERR('Error opening custom menu links config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.customMenuLinks = { openConfigModal };
console.log('[KefinTweaks CustomMenuLinks Config] Script loaded');
})();
+403
View File
@@ -0,0 +1,403 @@
[data-modal-id="kefin-homescreen-config-main"] .config-tab-content details {
max-height: calc(90vh - 19em);
overflow: auto;
}
[data-modal-id="kefin-homescreen-config-main"] .config-tab-nav {
display: flex;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 1em;
flex-wrap: wrap;
gap: 0.25em;
}
[data-modal-id="kefin-homescreen-config-main"] .config-tab-btn {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.75em 1.25em;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-weight: 500;
}
[data-modal-id="kefin-homescreen-config-main"] .config-tab-btn.active {
border-bottom-color: var(--theme-primary-color, #00a4dc);
color: var(--theme-primary-color, #00a4dc);
}
[data-modal-id="kefin-homescreen-config-main"] .config-tab-icon {
font-size: 1.25em;
line-height: 1;
}
[data-modal-id="kefin-homescreen-config-main"] #communityCollectionsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1em;
max-height: 600px;
overflow-y: auto;
padding-right: 0.5em;
}
.layout-mobile [data-modal-id="kefin-homescreen-config-main"] .config-tab-label {
display: none;
}
.layout-mobile [data-modal-id="kefin-homescreen-config-main"] .config-tab-btn {
padding: 0.75em;
}
[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,
[data-modal-id="kefin-homescreen-config-main"] div#global-settings-content,
[data-modal-id="kefin-homescreen-config-main"] #home-screen-benchmark-root {
max-height: calc(90vh - 19em);
overflow: auto;
scrollbar-width: none;
}
[data-modal-id="kefin-homescreen-config-main"] div#global-settings-content > *:last-child,
[data-modal-id="kefin-homescreen-config-main"] #home-screen-benchmark-root > *:last-child,
[data-modal-id="kefin-homescreen-config-main"] #section-content > div > *:last-child {
margin-bottom: 4em !important;
}
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;
}
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 form {
display: flex;
flex-direction: column;
}
.homeScreenSettingsContainer form > * {
order: 2;
}
.homeScreenSettingsContainer #kefin-user-home-sections-editor {
order: 1;
}
.kefin-restore-defaults-btn .restoreBtn::after {
content: "Restore Defaults";
}
/* Split panes: side nav on desktop, horizontal nav on mobile */
[data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5em;
}
.layout-mobile [data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane,
[data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane.hsc-split-pane-narrow {
grid-template-columns: 1fr;
gap: 1em;
}
.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation > div,
.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #section-navigation > div {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap;
gap: 0.5em;
max-height: none;
overflow-x: auto;
}
.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation .global-settings-nav-btn,
.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #section-navigation .section-type-nav-btn {
flex: 0 0 auto;
white-space: nowrap;
}
@media (max-width: 899px) {
[data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane {
grid-template-columns: 1fr;
gap: 1em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation > div,
[data-modal-id="kefin-homescreen-config-main"] #section-navigation > div {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap;
gap: 0.5em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation .global-settings-nav-btn,
[data-modal-id="kefin-homescreen-config-main"] #section-navigation .section-type-nav-btn {
flex: 0 0 auto;
white-space: nowrap;
}
[data-modal-id="kefin-homescreen-config-main"] .config-tab-label {
display: none;
}
[data-modal-id="kefin-homescreen-config-main"] .config-tab-btn {
padding: 0.75em;
}
}
/* .homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) form>div:first-child > *:not(.verticalSection-extrabottompadding) {
display: none;
}
.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) form>div:first-child {
margin-bottom: 0;
}
.listItem.section-row:has(.discovery-tag) {
box-shadow: black inset 1px 1px 2px 1px;
} */
.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) .selectContainer {
display: none;
}
/* Global Settings panel layouts */
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-panel {
display: flex;
flex-direction: column;
gap: 1em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-desc {
font-size: 0.9em;
line-height: 1.5;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-desc p {
margin: 0 0 0.75em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-desc p:last-child {
margin-bottom: 0;
}
.hsc-settings-grid {
display: grid;
gap: 0.75em;
align-items: stretch;
}
.hsc-settings-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
}
.hsc-settings-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hsc-settings-span-2 {
grid-column: 1 / -1;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid {
display: grid;
gap: 0.75em 1.25em;
margin-top: 0.75em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-inner > .hsae-field-grid:first-child,
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-inner > .hsae-toggle-card-grid:first-child {
margin-top: 0;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-toggle-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75em;
margin-top: 0.75em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-wrap {
min-width: 0;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-wrap .fld,
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-wrap select {
width: 100%;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-query-divider {
height: 1px;
margin: 1em 0;
background: rgba(255, 255, 255, 0.1);
border: 0;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-backdrops-row {
margin-top: 0.75em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-animations {
display: grid;
gap: 0.75em 1.25em;
grid-template-columns: minmax(0, 1fr);
margin-top: 0.75em;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-anim-group {
min-width: 0;
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-row-autoplay {
margin-top: 0.75em;
align-items: end;
}
@media (max-width: 899px) {
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hsc-config-toolbar button > span:nth-child(2) {
display: none;
}
.hsc-config-toolbar button > span {
margin: 0 !important;
}
}
@media (max-width: 640px) {
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-2,
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-3,
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-2,
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-4,
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-toggle-card-grid {
grid-template-columns: 1fr;
}
}
[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-span-2 {
display: flex;
}
[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-grid .hsc-settings-span-2 .kefin-toggle-card {
flex: 1;
}
[data-modal-id="kefin-homescreen-config-main"] .kefin-settings-input-card {
align-items: center;
}
[data-modal-id="kefin-homescreen-config-main"] .kefin-settings-input-card-control {
flex-shrink: 0;
width: 7.5em;
}
[data-modal-id="kefin-homescreen-config-main"] .kefin-settings-input-card-control .emby-input {
width: 100%;
box-sizing: border-box;
}
.section-row-actions .btnSectionConfigure {
display: none;
}
.dialogContainer[data-modal-id="kefin-homescreen-config-main"] .formDialogHeader + div > div {
padding: 1.25em 1.5em;
height: calc(100% - 2.5em);
}
.dialogContainer[data-modal-id="kefin-homescreen-config-main"] .formDialogHeader + div {
padding: 0 !important;
overflow-y: hidden !important;
}
.dialogContainer[data-modal-id="kefin-homescreen-config-main"] .formDialogHeader + div > div > div #section-content > div {
padding-bottom: 4em;
}
.config-tab-nav button[data-tab="order"] {
display: none;
}
.listItem[data-section-type="discovery"]:not([data-section-id^="custom"]) .section-row-title-line,
.listItem[data-section-type="discovery"]:not([data-section-id^="custom"]) .section-row-caption {
display: none !important;
}
.listItem[data-section-type="discovery"]:not([data-section-id^="custom"]) .section-row-description {
font-size: 1em !important;
margin-top: 0 !important;
}
File diff suppressed because it is too large Load Diff
+726
View File
@@ -0,0 +1,726 @@
// 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: [],
DISCOVERY_SETTINGS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SETTINGS || {})),
SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {})),
HOME_SETTINGS: JSON.parse(JSON.stringify(defaults.HOME_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);
return false;
}
return true;
}
/**
* 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.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') {
settings.spotlight = {};
Object.assign(settings.spotlight, discovery.spotlight);
}
// Legacy discovery.minPeopleAppearances → HOME_SETTINGS mins
if (discovery.minPeopleAppearances !== undefined) {
if (!newConfig.HOME_SETTINGS) newConfig.HOME_SETTINGS = {};
const min = discovery.minPeopleAppearances;
const home = newConfig.HOME_SETTINGS;
if (home.minPeopleAppearancesTotal == null) home.minPeopleAppearancesTotal = min;
if (home.minPeopleAppearancesMovies == null) home.minPeopleAppearancesMovies = min;
if (home.minPeopleAppearancesSeries == null) home.minPeopleAppearancesSeries = min;
if (home.minPeopleAppearancesEpisodes == null) home.minPeopleAppearancesEpisodes = min;
}
}
/**
* 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 — missing legacy enabled means on (same as seasonal/custom)
merged.enabled = legacyConfig.enabled !== false;
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',
jellyfinId: 'latestmedia',
ttl: 0,
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',
trending: 'trending',
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}`);
}
const RECENTLY_WATCHED_PERSON_IDS = new Set([
'starringActorRecentlyWatched',
'directedByDirectorRecentlyWatched',
'writtenByWriterRecentlyWatched'
]);
const RECENTLY_WATCHED_CAPTION_DEFAULT = 'Because you recently watched {Title}';
const RECENTLY_WATCHED_NAME_DEFAULTS = {
starringActorRecentlyWatched: 'Starring {Person}',
directedByDirectorRecentlyWatched: 'Directed by {Person}',
writtenByWriterRecentlyWatched: 'Written by {Person}'
};
function normalizeDiscoveryTemplatePlaceholders(text) {
if (!text || typeof text !== 'string') return text;
return text
.replace(/\[Actor\]/gi, '{Person}')
.replace(/\[Director\]/gi, '{Person}')
.replace(/\[Writer\]/gi, '{Person}')
.replace(/\[Person\]/gi, '{Person}')
.replace(/\[Movie\]/gi, '{Title}')
.replace(/\[Title\]/gi, '{Title}');
}
/**
* Split legacy combined "Starring X because you recently watched Y" into name + caption.
*/
function splitLegacyRecentlyWatchedName(legacyName) {
if (!legacyName || typeof legacyName !== 'string') return null;
const match = legacyName.match(/^(.*?)\s+because you recently watched\s+(.*)$/i);
if (!match) return null;
let name = normalizeDiscoveryTemplatePlaceholders(match[1].trim());
let captionRight = normalizeDiscoveryTemplatePlaceholders(match[2].trim());
const caption = captionRight
? `Because you recently watched ${captionRight}`
: RECENTLY_WATCHED_CAPTION_DEFAULT;
// Prefer canonical caption placeholder when right side is Movie/Title token only
if (/^\{Title\}$/i.test(captionRight) || /^\[Movie\]$/i.test(match[2].trim())) {
return { name, caption: RECENTLY_WATCHED_CAPTION_DEFAULT };
}
return { name, caption };
}
/**
* 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]) => {
const found = findDiscoverySectionById(sectionKey, newConfig.DISCOVERY_SECTION_GROUPS);
if (found) {
found.section.enabled = legacySectionConfig.enabled !== false;
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;
const isRecentlyWatchedPerson = RECENTLY_WATCHED_PERSON_IDS.has(sectionKey);
if (legacySectionConfig.name !== undefined) {
const split = splitLegacyRecentlyWatchedName(legacySectionConfig.name);
if (split) {
found.section.name = split.name;
found.section.caption = split.caption;
} else if (isRecentlyWatchedPerson) {
found.section.name = RECENTLY_WATCHED_NAME_DEFAULTS[sectionKey]
|| normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.name);
if (!found.section.caption) {
found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT;
}
} else {
found.section.name = legacySectionConfig.name;
}
} else if (isRecentlyWatchedPerson && !found.section.caption) {
found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT;
}
if (legacySectionConfig.caption !== undefined) {
found.section.caption = normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.caption);
}
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');
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,631 @@
// Shared Custom Items editor (wizard + advanced section editor)
(function() {
'use strict';
const CUSTOM_IMAGE_FIELDS = [
{ key: 'posterUrl', label: 'Poster URL', required: true },
{ key: 'thumbUrl', label: 'Thumb URL' },
{ key: 'backdropUrl', label: 'Backdrop URL' },
{ key: 'squareUrl', label: 'Square URL' },
{ key: 'bannerUrl', label: 'Banner URL' },
{ key: 'logoUrl', label: 'Logo URL' }
];
const CUSTOM_POSTER_FIELD = CUSTOM_IMAGE_FIELDS[0];
const CUSTOM_ADDITIONAL_IMAGE_FIELDS = CUSTOM_IMAGE_FIELDS.slice(1);
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderMaterialIcon(name, className = '') {
return `<span class="material-icons${className ? ' ' + className : ''}" aria-hidden="true">${escapeHtml(name)}</span>`;
}
function getOptions(options = {}) {
return {
idPrefix: options.idPrefix || 'hsse-custom',
actionAttr: options.actionAttr || 'data-hsse-action',
actions: {
openEditor: 'wizard-open-custom-item-editor',
closeEditor: 'wizard-close-custom-item-editor',
addItem: 'wizard-add-custom-item',
removeItem: 'wizard-remove-custom-item',
toggleImages: 'wizard-toggle-custom-images',
toggleHide: 'wizard-toggle-custom-item-hide',
moveLeft: 'wizard-move-custom-item-left',
moveRight: 'wizard-move-custom-item-right',
...(options.actions || {})
},
intro: options.intro !== false,
// Default Kefin sections pass false — hide only, never delete
allowRemove: options.allowRemove !== false
};
}
function actionAttr(options, action) {
return `${options.actionAttr}="${escapeHtml(options.actions[action])}"`;
}
function fieldId(options, index, key) {
return `${options.idPrefix}-${index}-${key}`;
}
function createBlankCustomItem() {
return {
name: '',
cardFooter: '',
cardUrl: '',
posterUrl: '',
thumbUrl: '',
backdropUrl: '',
squareUrl: '',
bannerUrl: '',
logoUrl: '',
hidden: false
};
}
function staticItemToDraft(item) {
if (!item) return createBlankCustomItem();
const draft = createBlankCustomItem();
draft.name = item.Name || item.name || '';
draft.cardFooter = item.cardFooter || '';
draft.cardUrl = item.cardUrl || '';
draft.posterUrl = item.posterUrl || item.imageUrl || '';
draft.thumbUrl = item.thumbUrl || '';
draft.backdropUrl = item.backdropUrl || '';
draft.squareUrl = item.squareUrl || '';
draft.bannerUrl = item.bannerUrl || '';
draft.logoUrl = item.logoUrl || '';
draft.hidden = item.hidden === true;
if (item.Id || item.id) draft.id = item.Id || item.id;
if (item.Type) draft.type = item.Type;
return draft;
}
function staticItemsToDrafts(items) {
const list = (items || []).map(staticItemToDraft).filter(Boolean);
return list.length ? list : [createBlankCustomItem()];
}
function ensureCustomItemsInitialized(state) {
if (!state.customItems?.length) {
state.customItems = [createBlankCustomItem()];
}
}
function hasAdditionalCustomImages(item) {
return CUSTOM_ADDITIONAL_IMAGE_FIELDS.some(field => item[field.key]?.trim());
}
function isCustomItemAdditionalExpanded(state, index, item) {
const explicit = state.customItemAdditionalExpanded?.[index];
if (explicit === true) return true;
if (explicit === false) return false;
return hasAdditionalCustomImages(item);
}
function reindexCustomItemAdditionalExpanded(state, removedIndex) {
if (!state.customItemAdditionalExpanded) return;
const next = {};
Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => {
const i = parseInt(key, 10);
if (Number.isNaN(i) || value === undefined) return;
if (i < removedIndex) next[i] = value;
else if (i > removedIndex) next[i - 1] = value;
});
state.customItemAdditionalExpanded = next;
}
function swapCustomItemExpanded(state, fromIndex, toIndex) {
if (!state.customItemAdditionalExpanded) return;
const a = state.customItemAdditionalExpanded[fromIndex];
const b = state.customItemAdditionalExpanded[toIndex];
if (a === undefined && b === undefined) return;
if (b === undefined) delete state.customItemAdditionalExpanded[fromIndex];
else state.customItemAdditionalExpanded[fromIndex] = b;
if (a === undefined) delete state.customItemAdditionalExpanded[toIndex];
else state.customItemAdditionalExpanded[toIndex] = a;
}
function moveCustomItemExpanded(state, fromIndex, toIndex) {
if (!state.customItemAdditionalExpanded || fromIndex === toIndex) return;
const next = {};
const moved = state.customItemAdditionalExpanded[fromIndex];
Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => {
const i = parseInt(key, 10);
if (Number.isNaN(i) || i === fromIndex) return;
let dest = i;
if (fromIndex < toIndex && i > fromIndex && i <= toIndex) dest = i - 1;
else if (fromIndex > toIndex && i >= toIndex && i < fromIndex) dest = i + 1;
next[dest] = value;
});
if (moved !== undefined) next[toIndex] = moved;
state.customItemAdditionalExpanded = next;
}
function hasValidCustomItems(items) {
return (items || []).some((item) => {
const name = item.name?.trim();
if (!name) return false;
return !!(item.posterUrl?.trim() || item.cardUrl?.trim());
});
}
function hasCustomItemContent(item) {
if (!item) return false;
return [
item.name, item.cardFooter, item.cardUrl, item.posterUrl,
item.thumbUrl, item.backdropUrl, item.squareUrl, item.bannerUrl, item.logoUrl
].some(value => value?.trim());
}
function buildStaticItemFromDraft(draft) {
const name = draft.name?.trim();
const posterUrl = draft.posterUrl?.trim();
const cardUrl = draft.cardUrl?.trim();
// Name + poster, or Name + link (e.g. liveTv-browse button tiles)
if (!name || (!posterUrl && !cardUrl)) return null;
const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl'];
const provided = {};
imageKeys.forEach(key => {
const value = draft[key]?.trim();
if (value) provided[key] = value;
});
const item = { Name: name };
if (draft.type) item.Type = draft.type;
else if (posterUrl) item.Type = 'Folder';
if (draft.id) item.Id = draft.id;
if (cardUrl) item.cardUrl = cardUrl;
const cardFooter = draft.cardFooter?.trim();
if (cardFooter) item.cardFooter = cardFooter;
if (draft.hidden === true) item.hidden = true;
const keys = Object.keys(provided);
if (keys.length === 1 && provided.posterUrl) {
item.imageUrl = provided.posterUrl;
} else if (keys.length) {
Object.assign(item, provided);
}
return item;
}
function collectCustomItemFromForm(root, state, index, options) {
if (!root || !state.customItems?.[index]) return;
const opts = getOptions(options);
const item = state.customItems[index];
const nameEl = root.querySelector(`#${fieldId(opts, index, 'name')}`);
const cardFooterEl = root.querySelector(`#${fieldId(opts, index, 'cardFooter')}`);
const cardUrlEl = root.querySelector(`#${fieldId(opts, index, 'cardUrl')}`);
if (nameEl) item.name = nameEl.value;
if (cardFooterEl) item.cardFooter = cardFooterEl.value;
if (cardUrlEl) item.cardUrl = cardUrlEl.value;
CUSTOM_IMAGE_FIELDS.forEach(field => {
const el = root.querySelector(`#${fieldId(opts, index, field.key)}`);
if (el) item[field.key] = el.value;
});
}
function collectCustomItemsFromForm(root, state, options) {
if (!root || !state.customItems) return;
const editorIndex = state.customItemEditorIndex;
if (editorIndex != null && !Number.isNaN(editorIndex)) {
collectCustomItemFromForm(root, state, editorIndex, options);
}
}
function resolvePreviewUrl(url) {
const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || '';
const serverId = (typeof ApiClient !== 'undefined' && typeof ApiClient.serverId === 'function')
? ApiClient.serverId()
: '';
return String(url || '')
.replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot)
.replace(/\$\{serverId\}/g, serverId)
.trim();
}
function updateCustomImagePreview(imgEl, url) {
if (!imgEl) return;
const trimmed = resolvePreviewUrl(url);
if (!trimmed) {
imgEl.removeAttribute('src');
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'empty';
return;
}
imgEl.style.display = '';
imgEl.dataset.previewState = 'loading';
imgEl.onerror = () => {
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'error';
};
imgEl.onload = () => {
imgEl.style.display = '';
imgEl.dataset.previewState = 'loaded';
};
imgEl.src = trimmed;
}
function refreshPreviews(root, state, options) {
if (!root || !state.customItems) return;
state.customItems.forEach((item, index) => {
const tileImg = root.querySelector(`img[data-tile-index="${index}"]`);
updateCustomImagePreview(tileImg, item.posterUrl);
});
root.querySelectorAll('[data-hsse-custom-preview], [data-hsae-custom-preview]').forEach(input => {
const img = root.querySelector(`img[data-preview-for="${input.id}"]`);
updateCustomImagePreview(img, input.value);
});
}
function buildCustomImageFieldHTML(index, field, value, options, fieldOptions = {}) {
const opts = getOptions(options);
const { isPoster = false } = fieldOptions;
const requiredMark = field.required ? ' <span class="hsse-required-mark">*</span>' : '';
const inputId = fieldId(opts, index, field.key);
const previewAttr = isPoster ? ' data-hsse-custom-preview="true" data-hsae-custom-preview="true"' : '';
const posterHint = isPoster
? `<p class="listItemBodyText secondary hsse-field-hint hsse-custom-image-field-hint">You can place images in your &quot;Jellyfin\\Server\\jellyfin-web&quot; folder and use the &quot;/web/image_name.png&quot; path to access them.</p>`
: '';
return `
<div class="hsse-field hsse-custom-image-field${isPoster ? '' : ' hsse-custom-image-field-compact'}">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${inputId}">${escapeHtml(field.label)}${requiredMark}</label>
<input type="url" id="${inputId}" class="fld emby-input hsse-custom-image-input" value="${escapeHtml(value)}" placeholder="https://..." aria-label="${escapeHtml(field.label)}"${previewAttr}>
${posterHint}
</div>
`;
}
function renderCustomItemEditorHTML(index, item, state, options) {
const opts = getOptions(options);
const additionalExpanded = isCustomItemAdditionalExpanded(state, index, item);
const posterInputId = fieldId(opts, index, 'posterUrl');
return `
<div class="hsse-custom-item-main">
<div class="hsse-custom-item-fields">
<div class="hsse-field hsse-custom-field">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${fieldId(opts, index, 'name')}">Name <span class="hsse-required-mark">*</span></label>
<input type="text" id="${fieldId(opts, index, 'name')}" class="fld emby-input" value="${escapeHtml(item.name)}" placeholder="Item name" aria-label="Item name">
</div>
<div class="hsse-field hsse-custom-field">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${fieldId(opts, index, 'cardFooter')}">Card Footer</label>
<input type="text" id="${fieldId(opts, index, 'cardFooter')}" class="fld emby-input" value="${escapeHtml(item.cardFooter || '')}" placeholder="Optional footer text" aria-label="Card footer">
</div>
<div class="hsse-field hsse-custom-field">
<label class="listItemBodyText hsse-section-label hsse-custom-field-label" for="${fieldId(opts, index, 'cardUrl')}">Link URL</label>
<input type="url" id="${fieldId(opts, index, 'cardUrl')}" class="fld emby-input" value="${escapeHtml(item.cardUrl)}" placeholder="#/movies.html or https://..." aria-label="Link URL">
</div>
${buildCustomImageFieldHTML(index, CUSTOM_POSTER_FIELD, item.posterUrl || '', opts, { isPoster: true })}
</div>
<div class="hsse-custom-item-preview-col" aria-hidden="true">
<div class="hsse-image-preview hsse-item-main-preview">
<img class="hsse-image-preview-img" alt="" data-preview-for="${posterInputId}">
</div>
</div>
</div>
<div class="hsse-custom-image-toggle-wrap">
<button type="button" class="emby-button raised hsse-custom-image-toggle" ${actionAttr(opts, 'toggleImages')} data-item-index="${index}" aria-expanded="${additionalExpanded ? 'true' : 'false'}">
${additionalExpanded ? 'Hide Additional Image Types' : 'Configure Additional Image Types'}
</button>
</div>
<div class="hsse-field-grid hsse-custom-image-grid hsse-custom-image-additional${additionalExpanded ? '' : ' hsse-collapsed'}"${additionalExpanded ? '' : ' hidden'}>
${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '', opts)).join('')}
</div>
`;
}
function renderCustomItemTileHTML(index, item, canRemove, options, itemCount) {
const opts = getOptions(options);
const label = item.name?.trim() || `Item ${index + 1}`;
const isHidden = item.hidden === true;
const canMoveLeft = index > 0;
const canMoveRight = index < itemCount - 1;
const hideIcon = isHidden ? 'visibility_off' : 'visibility';
const hideLabel = isHidden ? `Show ${label}` : `Hide ${label}`;
const removeTitle = canRemove
? `Remove ${label}`
: (opts.allowRemove === false
? 'Default section items cannot be deleted — hide instead'
: `Remove ${label}`);
return `
<div class="hsse-custom-item-tile${isHidden ? ' is-hidden' : ''}" data-custom-item-index="${index}">
<button type="button" class="hsse-custom-item-tile-image hsse-image-preview hsse-item-tile-preview" ${actionAttr(opts, 'openEditor')} data-item-index="${index}" aria-label="Edit ${escapeHtml(label)}">
<img class="hsse-image-preview-img" alt="" data-tile-index="${index}">
<span class="hsse-custom-item-edit-badge">${renderMaterialIcon('edit')}</span>
</button>
<div class="listItemBodyText hsse-custom-item-tile-label">${escapeHtml(label)}</div>
<div class="hsse-custom-item-actions">
<button type="button" class="emby-button raised hsse-custom-item-action" ${actionAttr(opts, 'moveLeft')} data-item-index="${index}"${canMoveLeft ? '' : ' disabled'} aria-label="Move ${escapeHtml(label)} left">
${renderMaterialIcon('chevron_left', 'hsse-custom-item-action-icon')}
</button>
<button type="button" class="emby-button raised hsse-custom-item-action hsse-custom-item-hide" ${actionAttr(opts, 'toggleHide')} data-item-index="${index}" aria-label="${escapeHtml(hideLabel)}" aria-pressed="${isHidden ? 'true' : 'false'}">
${renderMaterialIcon(hideIcon, 'hsse-custom-item-action-icon')}
</button>
<button type="button" class="emby-button raised hsse-custom-item-action hsse-custom-item-trash" ${actionAttr(opts, 'removeItem')} data-item-index="${index}"${canRemove ? '' : ' disabled'} title="${escapeHtml(removeTitle)}" aria-label="${escapeHtml(removeTitle)}">
${renderMaterialIcon('delete', 'hsse-custom-item-action-icon hsse-custom-item-trash-icon')}
</button>
<button type="button" class="emby-button raised hsse-custom-item-action" ${actionAttr(opts, 'moveRight')} data-item-index="${index}"${canMoveRight ? '' : ' disabled'} aria-label="Move ${escapeHtml(label)} right">
${renderMaterialIcon('chevron_right', 'hsse-custom-item-action-icon')}
</button>
</div>
</div>
`;
}
function renderCustomItemEditorPanelHTML(state, options) {
const opts = getOptions(options);
const editorIndex = state.customItemEditorIndex;
if (editorIndex == null || Number.isNaN(editorIndex)) return '';
const item = state.customItems?.[editorIndex];
if (!item) return '';
const title = item.name?.trim()
? `Edit: ${item.name.trim()}`
: `Edit Item ${editorIndex + 1}`;
return `
<div class="hsse-custom-item-editor-overlay">
<button type="button" class="hsse-custom-item-editor-backdrop" ${actionAttr(opts, 'closeEditor')} aria-label="Close editor"></button>
<div class="hsse-custom-item-editor-panel dialog" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
<div class="hsse-custom-item-editor-header">
<div class="listItemBodyText hsse-custom-item-editor-title">${escapeHtml(title)}</div>
</div>
<div class="hsse-custom-item-editor-body">
${renderCustomItemEditorHTML(editorIndex, item, state, opts)}
</div>
<div class="hsse-custom-item-editor-footer">
<button type="button" class="emby-button raised" ${actionAttr(opts, 'closeEditor')} data-discard="true">Cancel</button>
<button type="button" class="emby-button raised button-submit" ${actionAttr(opts, 'closeEditor')}>Done</button>
</div>
</div>
</div>
`;
}
function renderCustomItemsHTML(state, options) {
const opts = getOptions(options);
ensureCustomItemsInitialized(state);
const items = state.customItems;
const canRemove = opts.allowRemove !== false && items.length > 1;
return `
<div class="hsse-custom-items-stage">
${opts.intro ? `
<div class="hsse-section-block hsse-custom-items-intro">
<div class="listItemBodyText hsse-section-label">Custom Items</div>
<p class="listItemBodyText secondary hsse-field-hint">Name required; poster or link recommended. Other images optional.</p>
</div>` : ''}
<div class="hsse-custom-items-grid">
${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove, opts, items.length)).join('')}
<button type="button" class="hsse-custom-item-add-tile" ${actionAttr(opts, 'addItem')} aria-label="Add item">
${renderMaterialIcon('add', 'hsse-custom-item-add-icon')}
</button>
</div>
${renderCustomItemEditorPanelHTML(state, opts)}
</div>
`;
}
function reindexTileDom(tile, index, itemCount) {
if (!tile) return;
tile.dataset.customItemIndex = String(index);
tile.querySelectorAll('[data-item-index]').forEach((el) => {
el.dataset.itemIndex = String(index);
});
const tileImg = tile.querySelector('img[data-tile-index]');
if (tileImg) tileImg.dataset.tileIndex = String(index);
// Action order: left | hide | delete | right
const actions = tile.querySelectorAll('.hsse-custom-item-actions .hsse-custom-item-action');
if (actions.length >= 4) {
actions[0].disabled = index <= 0;
actions[3].disabled = index >= itemCount - 1;
}
}
function swapAdjacentCustomItemTiles(root, fromIndex, toIndex) {
if (!root || fromIndex === toIndex) return false;
const grid = root.querySelector('.hsse-custom-items-grid');
if (!grid) return false;
const a = grid.querySelector(`[data-custom-item-index="${fromIndex}"]`);
const b = grid.querySelector(`[data-custom-item-index="${toIndex}"]`);
if (!a || !b) return false;
const aNext = a.nextSibling;
const bNext = b.nextSibling;
if (aNext === b) {
grid.insertBefore(b, a);
} else if (bNext === a) {
grid.insertBefore(a, b);
} else {
grid.insertBefore(a, bNext);
grid.insertBefore(b, aNext);
}
const itemCount = grid.querySelectorAll('[data-custom-item-index]').length;
reindexTileDom(a, toIndex, itemCount);
reindexTileDom(b, fromIndex, itemCount);
return true;
}
function handleAction(action, el, root, state, options, onRefresh) {
const opts = getOptions(options);
const actions = opts.actions;
switch (action) {
case actions.openEditor: {
const openIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(openIndex)) break;
if (state.customItemEditorIndex != null && state.customItemEditorIndex !== openIndex) {
collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts);
}
state.customItemEditorIndex = openIndex;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.closeEditor: {
const discard = el.dataset.discard === 'true';
if (!discard && state.customItemEditorIndex != null) {
collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts);
}
state.customItemEditorIndex = null;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.addItem: {
collectCustomItemsFromForm(root, state, opts);
state.customItems.push(createBlankCustomItem());
state.customItemEditorIndex = state.customItems.length - 1;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.removeItem: {
if (opts.allowRemove === false) break;
const removeIndex = parseInt(el.dataset.itemIndex, 10);
if (state.customItems.length <= 1 || Number.isNaN(removeIndex)) break;
if (state.customItemEditorIndex === removeIndex) {
collectCustomItemFromForm(root, state, removeIndex, opts);
} else {
collectCustomItemsFromForm(root, state, opts);
}
const itemToRemove = state.customItems[removeIndex];
if (hasCustomItemContent(itemToRemove) && !confirm('Are you sure you want to remove this item?')) {
break;
}
state.customItems.splice(removeIndex, 1);
reindexCustomItemAdditionalExpanded(state, removeIndex);
if (state.customItemEditorIndex === removeIndex) {
state.customItemEditorIndex = null;
} else if (state.customItemEditorIndex > removeIndex) {
state.customItemEditorIndex -= 1;
}
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.toggleImages: {
const toggleIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(toggleIndex)) break;
collectCustomItemFromForm(root, state, toggleIndex, opts);
if (!state.customItemAdditionalExpanded) state.customItemAdditionalExpanded = {};
const item = state.customItems[toggleIndex];
const currentlyExpanded = isCustomItemAdditionalExpanded(state, toggleIndex, item);
state.customItemAdditionalExpanded[toggleIndex] = !currentlyExpanded;
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.toggleHide: {
const hideIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(hideIndex) || !state.customItems?.[hideIndex]) break;
collectCustomItemsFromForm(root, state, opts);
const item = state.customItems[hideIndex];
item.hidden = !item.hidden;
// Remove, then reinsert at the target slot among remaining items
state.customItems.splice(hideIndex, 1);
let insertAt;
if (item.hidden === true) {
// Hidden items stay at the end
insertAt = state.customItems.length;
} else {
// Unhide: last active item, immediately before the first still-hidden item
insertAt = state.customItems.findIndex((it) => it.hidden === true);
if (insertAt < 0) insertAt = state.customItems.length;
}
state.customItems.splice(insertAt, 0, item);
if (insertAt !== hideIndex) {
moveCustomItemExpanded(state, hideIndex, insertAt);
if (state.customItemEditorIndex === hideIndex) {
state.customItemEditorIndex = insertAt;
} else if (state.customItemEditorIndex != null) {
if (hideIndex < state.customItemEditorIndex && insertAt >= state.customItemEditorIndex) {
state.customItemEditorIndex -= 1;
} else if (hideIndex > state.customItemEditorIndex && insertAt <= state.customItemEditorIndex) {
state.customItemEditorIndex += 1;
}
}
}
if (typeof onRefresh === 'function') onRefresh(root);
break;
}
case actions.moveLeft: {
const leftIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(leftIndex) || leftIndex <= 0) break;
collectCustomItemsFromForm(root, state, opts);
const swapWith = leftIndex - 1;
const tmp = state.customItems[leftIndex];
state.customItems[leftIndex] = state.customItems[swapWith];
state.customItems[swapWith] = tmp;
swapCustomItemExpanded(state, leftIndex, swapWith);
if (state.customItemEditorIndex === leftIndex) state.customItemEditorIndex = swapWith;
else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = leftIndex;
if (!swapAdjacentCustomItemTiles(root, leftIndex, swapWith) && typeof onRefresh === 'function') {
onRefresh(root);
}
break;
}
case actions.moveRight: {
const rightIndex = parseInt(el.dataset.itemIndex, 10);
if (Number.isNaN(rightIndex) || rightIndex >= state.customItems.length - 1) break;
collectCustomItemsFromForm(root, state, opts);
const swapWith = rightIndex + 1;
const tmp = state.customItems[rightIndex];
state.customItems[rightIndex] = state.customItems[swapWith];
state.customItems[swapWith] = tmp;
swapCustomItemExpanded(state, rightIndex, swapWith);
if (state.customItemEditorIndex === rightIndex) state.customItemEditorIndex = swapWith;
else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = rightIndex;
if (!swapAdjacentCustomItemTiles(root, rightIndex, swapWith) && typeof onRefresh === 'function') {
onRefresh(root);
}
break;
}
default:
break;
}
}
window.KefinHomeScreenCustomItemsEditor = {
CUSTOM_IMAGE_FIELDS,
createBlankCustomItem,
staticItemToDraft,
staticItemsToDrafts,
ensureCustomItemsInitialized,
hasValidCustomItems,
hasCustomItemContent,
buildStaticItemFromDraft,
collectCustomItemsFromForm,
collectCustomItemFromForm,
renderCustomItemsHTML,
refreshPreviews,
handleAction,
reindexCustomItemAdditionalExpanded,
getCustomItemsOptions: (overrides = {}) => getOptions({
idPrefix: 'hsae-custom',
actionAttr: 'data-hsae-action',
actions: {
openEditor: 'hsae-open-custom-item-editor',
closeEditor: 'hsae-close-custom-item-editor',
addItem: 'hsae-add-custom-item',
removeItem: 'hsae-remove-custom-item',
toggleImages: 'hsae-toggle-custom-images',
toggleHide: 'hsae-toggle-custom-item-hide',
moveLeft: 'hsae-move-custom-item-left',
moveRight: 'hsae-move-custom-item-right'
},
...overrides
})
};
})();
+165
View File
@@ -0,0 +1,165 @@
// KefinTweaks Home Screen Section Editor Profiles
(function() {
'use strict';
const EDITOR_PROFILES = {
full: {
queryFields: 'all',
advancedFields: 'all',
lockVisibility: false,
allowGroupChange: true,
allowItemSourceChange: true
},
minimal: {
queryFields: ['limit'],
advancedFields: ['cache', 'useParentCard'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false,
hideCustomItems: false
},
postProcessing: {
queryFields: ['limit'],
advancedFields: ['cache', 'sectionSort', 'minimumItems', 'itemLimit', 'limitBeforeSort', 'useParentCard'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false,
hideCustomItems: false
},
normal: {
queryFields: ['limit', 'sortBy', 'sortOrder'],
advancedFields: ['cache', 'sectionSort', 'itemLimit', 'minimumItems', 'flattenSeries', 'limitBeforeSort', 'useParentCard'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false
},
dateCutoffs: {
queryFields: ['limit', 'sortBy', 'sortOrder'],
advancedFields: ['cache', 'sectionSort', 'itemLimit', 'minimumItems', 'flattenSeries', 'limitBeforeSort', 'minAge', 'maxAge', 'useParentCard'],
lockVisibility: true,
allowGroupChange: false,
allowItemSourceChange: false
}
};
const SECTION_PROFILE_BY_ID = {
'my-media': 'minimal',
'my-media-small': 'minimal',
'continueWatching': 'minimal',
'nextUp': 'minimal',
'continueWatchingAndNextUp': 'minimal',
'continueListening': 'minimal',
'continueReading': 'minimal',
'upcoming': 'minimal',
'thematic-story-origins': 'minimal',
'thematic-core-concepts': 'minimal',
'thematic-crime-stories': 'minimal',
'thematic-character-perspective': 'minimal',
'thematic-settings': 'minimal',
'thematic-tone-style': 'minimal',
'thematic-themes-topics': 'minimal',
'popularTVStudios': 'postProcessing',
'popularMovieStudios': 'postProcessing',
'popularStudios': 'postProcessing',
'recentlyReleased.movies': 'dateCutoffs',
'recentlyReleased.episodes': 'dateCutoffs',
'liveTv-recordings': 'normal',
'liveTv-timers': 'normal',
'liveTv-browse': 'normal',
'liveTv-on-now': 'normal',
'liveTv-programs': 'normal',
'liveTv-channels': 'normal',
'liveTv-series-on-now': 'normal',
'liveTv-movies-on-now': 'normal',
'liveTv-sports-on-now': 'normal',
'liveTv-news-on-now': 'normal',
'liveTv-kids-on-now': 'normal',
'imdbTop250': 'normal',
'watchAgain': 'normal',
'watchlist': 'normal',
'resumePlaylists': 'normal',
'completeCollections': 'normal',
'playlists': 'normal',
'collections': 'normal',
'seasonal.halloween.halloween-tag': 'normal',
'seasonal.halloween.halloween-horror': 'normal',
'seasonal.halloween.halloween-thriller': 'normal',
'seasonal.christmas.christmas-movies': 'normal',
'seasonal.christmas.christmas-episodes': 'normal',
'seasonal.new-years.new-years-movies': 'normal',
'seasonal.new-years.new-years-episodes': 'normal',
'seasonal.valentines.valentines-movies': 'normal',
'seasonal.valentines.valentines-episodes': 'normal',
'seasonal.thanksgiving.thanksgiving-movies': 'normal',
'seasonal.thanksgiving.thanksgiving-episodes': 'normal',
'genreMovies': 'normal',
'spotlightGenre': 'normal',
'directedByTopDirector': 'normal',
'writtenByTopWriter': 'normal',
'starringTopActor': 'normal',
'starringActorRecentlyWatched': 'normal',
'directedByDirectorRecentlyWatched': 'normal',
'writtenByWriterRecentlyWatched': 'normal',
'becauseYouWatched': 'normal',
'becauseYouRecentlyWatched': 'normal',
'becauseYouLiked': 'normal',
'studioShows': 'normal',
'spotlightNetwork': 'normal'
};
const SECTION_PROFILE_BY_PREFIX = [
{ prefix: 'custom-', profile: 'full' },
{ prefix: 'recently-added-', profile: 'minimal' },
{ prefix: 'popular-genres-', profile: 'minimal' }
];
function isKnownDefaultSection(sectionId) {
if (!sectionId || sectionId.startsWith('custom-')) return false;
if (SECTION_PROFILE_BY_ID[sectionId]) return true;
return SECTION_PROFILE_BY_PREFIX.some((rule) => sectionId.startsWith(rule.prefix));
}
function resolveEditorProfile(section, context = {}) {
const id = section?.id;
if (!id) return 'full';
if (SECTION_PROFILE_BY_ID[id]) {
return SECTION_PROFILE_BY_ID[id];
}
const prefixRule = SECTION_PROFILE_BY_PREFIX.find((rule) => id.startsWith(rule.prefix));
if (prefixRule) {
return prefixRule.profile;
}
if (id === 'collections' && context.groupType === 'DISCOVERY_SECTION_GROUPS') {
return 'normal';
}
if (context.isCustomSection) {
return 'full';
}
console.warn('[KefinHomeScreenEditorProfiles] No profile for section:', id);
return 'normal';
}
function getEditorProfileDefinition(profileKey) {
return EDITOR_PROFILES[profileKey] || EDITOR_PROFILES.full;
}
window.KefinHomeScreenEditorProfiles = {
EDITOR_PROFILES,
SECTION_PROFILE_BY_ID,
SECTION_PROFILE_BY_PREFIX,
isKnownDefaultSection,
resolveEditorProfile,
getEditorProfileDefinition
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+981
View File
@@ -0,0 +1,981 @@
// Home Screen Section Benchmarks — timed query overview for admin config
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinHomeScreenBenchmark]', ...args);
const STORAGE_KEY = 'kefin.homeScreen.benchmark.selectedIds';
const DEFAULT_QUERY_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16;
const CATEGORIES = [
{ id: 'default', label: 'Default', groupsKey: 'HOME_SECTION_GROUPS' },
{ id: 'custom', label: 'Custom', groupsKey: 'CUSTOM_SECTION_GROUPS' },
{ id: 'discovery', label: 'Discovery', groupsKey: 'DISCOVERY_SECTION_GROUPS' },
{ id: 'seasonal', label: 'Seasonal', groupsKey: 'SEASONAL_SECTION_GROUPS' }
];
const MS_BANDS = [
{ max: 500, className: 'kefin-bench-ms-fast' },
{ max: 1000, className: 'kefin-bench-ms-mid' },
{ max: 2000, className: 'kefin-bench-ms-slow' },
{ max: Infinity, className: 'kefin-bench-ms-worst' }
];
/** @type {{ aborted: boolean, controller: AbortController|null, onSkipRemaining?: Function } | null} */
let activeRun = null;
let navCancelBound = false;
/**
* Survives re-renders of the tab so results are not lost when the user
* switches tabs or reopens the config modal.
* @type {{ entries: Array, results: Map<string, Object> } | null}
*/
let lastRun = null;
let lastViewMode = 'picker';
function hasStoredResults() {
return !!lastRun?.entries?.length;
}
function flattenGroups(groups) {
if (!Array.isArray(groups)) return [];
const out = [];
groups.forEach((group) => {
if (group && Array.isArray(group.sections)) {
out.push(...group.sections);
}
});
return out;
}
function getSectionsForCategories(config, enabledCategoryIds) {
const enabled = new Set(enabledCategoryIds);
const sections = [];
CATEGORIES.forEach((cat) => {
if (!enabled.has(cat.id)) return;
const groups = config?.[cat.groupsKey] || [];
flattenGroups(groups).forEach((section) => {
if (!section?.id) return;
sections.push({
section,
category: cat.label,
categoryId: cat.id
});
});
});
return sections;
}
function loadStoredSelectedIds() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : null;
} catch (e) {
return null;
}
}
function saveStoredSelectedIds(ids) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
} catch (e) {
LOG('Could not persist selection', e);
}
}
function msBandClass(ms) {
if (ms == null || Number.isNaN(ms)) return '';
for (const band of MS_BANDS) {
if (ms < band.max) return band.className;
}
return MS_BANDS[MS_BANDS.length - 1].className;
}
function withBenchmarkQueryOptions(query) {
const queryOptions = { ...(query?.queryOptions || {}) };
if (queryOptions.Limit == null || queryOptions.Limit === '') {
queryOptions.Limit = DEFAULT_QUERY_LIMIT;
}
return { ...query, queryOptions };
}
function uniqueItemsById(items) {
const seen = new Set();
const out = [];
(items || []).forEach((item) => {
const id = item?.Id ?? item?.id;
if (id != null) {
if (seen.has(id)) return;
seen.add(id);
}
out.push(item);
});
return out;
}
function analyzeItemFields(items) {
let hasMediaStreams = false;
let hasPeople = false;
let hasStudios = false;
(items || []).forEach((item) => {
if (item?.MediaStreams?.length) hasMediaStreams = true;
if (item?.People?.length) hasPeople = true;
if (item?.Studios?.length) hasStudios = true;
});
return { hasMediaStreams, hasPeople, hasStudios };
}
function jsonByteSize(value) {
try {
const json = typeof value === 'string' ? value : JSON.stringify(value);
return new TextEncoder().encode(json || '').byteLength;
} catch (e) {
return 0;
}
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function getAuthHeaders() {
if (window.apiHelper?.getAuthHeader) {
return {
Authorization: window.apiHelper.getAuthHeader(),
Accept: 'application/json'
};
}
return { Accept: 'application/json' };
}
async function timedFetchJson(url, signal) {
const start = performance.now();
const response = await fetch(url, { headers: getAuthHeaders(), signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const text = await response.text();
const data = JSON.parse(text);
return { data, ms: performance.now() - start, responseBytes: jsonByteSize(text) };
}
async function timedDataSource(dataSource, options) {
const start = performance.now();
const result = await window.apiHelper.fetchFromDataSource(dataSource, options || {}, false);
const ms = performance.now() - start;
const items = result?.Items || result || [];
return {
items: Array.isArray(items) ? items : [],
ms,
responseBytes: jsonByteSize(result)
};
}
async function fetchQueryItems(query, section, signal) {
const userId = window.ApiClient?.getCurrentUserId?.();
const serverUrl = window.ApiClient?.serverAddress?.();
if (!userId || !serverUrl) {
throw new Error('ApiClient not ready');
}
const previewQuery = withBenchmarkQueryOptions(query);
const useSpotlight =
section.renderMode === 'Spotlight' || section.spotlight === true;
if (previewQuery.dataSource) {
return await timedDataSource(previewQuery.dataSource, previewQuery.queryOptions);
}
if (!window.apiHelper?.buildQueryFromSection) {
throw new Error('apiHelper.buildQueryFromSection not available');
}
const built = window.apiHelper.buildQueryFromSection(
previewQuery,
userId,
serverUrl,
useSpotlight
);
if (built && typeof built === 'object' && built.dataSource) {
return await timedDataSource(built.dataSource, built.options);
}
if (!built || typeof built !== 'string') {
throw new Error('Could not build query URL');
}
const { data, ms, responseBytes } = await timedFetchJson(built, signal);
const items = data?.Items || (Array.isArray(data) ? data : []);
return { items, ms, responseBytes };
}
/**
* Discovery sections ship as templates without queries: their source (genre, person,
* studio, collection, ...) is picked at render time. Reuse the home screen resolver so
* the benchmark measures the same queries the home screen would actually issue.
*/
async function resolveSectionQueries(entry) {
const section = entry.section;
const isTemplate = !section.queries?.length && !section.items?.length;
if (!isTemplate || !section.type) return { section, error: null };
const resolve = window.homeScreen3?.resolveDiscoverySection;
if (!resolve) {
return { section, error: 'Discovery resolver unavailable' };
}
try {
const resolved = await resolve(section, {
pairSpotlight: false,
resolveViewMore: false
});
if (!resolved?.queries?.length) {
return { section, error: `Could not resolve ${section.type} source` };
}
return { section: resolved, error: null };
} catch (e) {
return { section, error: `Source resolve failed: ${e?.message || e}` };
}
}
async function benchmarkSection(section, signal) {
if (section.items?.length) {
return {
totalMs: 0,
items: section.items,
responseBytes: 0,
queryMetrics: [],
error: null,
skippedQueries: true
};
}
const queries = section.queries || [];
if (!queries.length) {
return {
totalMs: 0,
items: [],
responseBytes: 0,
queryMetrics: [],
error: 'No queries',
skippedQueries: true
};
}
let totalMs = 0;
let responseBytes = 0;
const allItems = [];
const queryMetrics = [];
for (const query of queries) {
if (signal?.aborted) {
return {
totalMs,
items: allItems,
responseBytes,
queryMetrics,
error: 'Cancelled',
cancelled: true
};
}
const result = await fetchQueryItems(query, section, signal);
const { items, ms } = result;
const queryBytes = result.responseBytes || 0;
totalMs += ms;
responseBytes += queryBytes;
allItems.push(...items);
queryMetrics.push({ ms, itemCount: items.length, responseBytes: queryBytes });
}
return {
totalMs,
items: uniqueItemsById(allItems),
responseBytes,
queryMetrics,
error: null
};
}
function chipHtml(on, label) {
const cls = on ? 'kefin-bench-chip-on' : 'kefin-bench-chip-off';
const text = on ? 'On' : 'Off';
return `<span class="kefin-bench-chip ${cls}" title="${label}">${label}: ${text}</span>`;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function ensureBenchmarkStyles() {
if (document.getElementById('kefin-bench-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-bench-styles';
style.textContent = `
.kefin-bench-wrap { font-size: 0.95em; }
.kefin-bench-cats {
display: flex;
flex-wrap: wrap;
gap: 1em;
margin: 1.25em 0;
}
.kefin-bench-cat-btn {
padding: 0.75em 1.75em;
font-size: 1em;
line-height: 1.2;
border-radius: 999px;
border: 2px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.5);
cursor: pointer;
font-weight: 600;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.kefin-bench-cat-btn:hover:not(:disabled) {
color: rgba(255,255,255,0.85);
border-color: rgba(255,255,255,0.4);
}
.kefin-bench-cat-btn.is-active {
border-color: var(--theme-primary-color, #00a4dc);
color: #fff;
background: var(--theme-primary-color, #00a4dc);
}
.kefin-bench-cat-btn.is-active:hover:not(:disabled) {
border-color: var(--theme-primary-color, #00a4dc);
color: #fff;
filter: brightness(1.1);
}
.kefin-bench-cat-btn:disabled { opacity: 0.5; cursor: default; }
.kefin-bench-actions {
display: flex; gap: 0.75em; align-items: center; flex-wrap: wrap; margin-top: 1em;
}
.kefin-bench-wrap [hidden] { display: none !important; }
.kefin-bench-panel { min-height: 120px; }
.kefin-bench-picker-toolbar {
display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.65em; flex-wrap: wrap;
}
.kefin-bench-picker-count {
font-size: 0.85em; color: rgba(255,255,255,0.55); margin-left: 0.25em;
}
#kefin-bench-picker-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0.4em;
overflow-y: auto;
padding: 0.5em;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
background: rgba(0,0,0,0.15);
}
@media (max-width: 1100px) {
#kefin-bench-picker-grid { grid-template-columns: 1fr 1fr 1fr; }
}
@media (max-width: 800px) {
#kefin-bench-picker-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 520px) {
#kefin-bench-picker-grid { grid-template-columns: 1fr; }
}
.kefin-bench-picker-cell {
height: 24px;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.5em;
padding: 0 0.4em;
border-radius: 4px;
min-width: 0;
cursor: pointer;
}
.kefin-bench-picker-cell:hover { background: rgba(255,255,255,0.07); }
.kefin-bench-picker-cell input { flex-shrink: 0; margin: 0; }
.kefin-bench-picker-label {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 0.4em;
overflow: hidden;
}
.kefin-bench-picker-name {
font-size: 1em;
color: rgba(255,255,255,0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kefin-bench-picker-badge {
flex-shrink: 0;
font-size: 0.65em;
padding: 0.1em 0.4em;
border-radius: 3px;
background: rgba(0,164,220,0.2);
color: rgba(255,255,255,0.7);
}
.kefin-bench-table-wrap { overflow-x: auto; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; }
.kefin-bench-summary-wrap {
overflow-x: auto;
margin-bottom: 1em;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
}
.kefin-bench-summary-table td {
font-size: 1.1em;
font-weight: 600;
text-align: center;
}
.kefin-bench-summary-table th { text-align: center; white-space: nowrap; }
.kefin-bench-summary-table td[title] { cursor: help; }
.kefin-bench-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
/* Fixed layout keeps column widths stable as rows fill in */
table-layout: fixed;
}
.kefin-bench-table:not(.kefin-bench-summary-table) { min-width: 780px; }
.kefin-bench-table th, .kefin-bench-table td {
padding: 0.55em 0.65em; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.08);
overflow: hidden; text-overflow: ellipsis;
}
.kefin-bench-table tbody tr { height: 2.9em; }
.kefin-bench-col-name, .kefin-bench-col-plain { white-space: nowrap; }
.kefin-bench-table th { font-weight: 600; color: rgba(255,255,255,0.85); background: rgba(0,0,0,0.2); }
.kefin-bench-table tr.kefin-bench-row-running td { color: rgba(255,255,255,0.6); }
.kefin-bench-ms-fast { background: rgba(47,158,68,0.35); }
.kefin-bench-ms-mid { background: rgba(255,212,59,0.25); }
.kefin-bench-ms-slow { background: rgba(253,126,20,0.3); }
.kefin-bench-ms-worst { background: rgba(224,49,49,0.35); }
.kefin-bench-chips { display: flex; flex-wrap: nowrap; gap: 0.35em; }
.kefin-bench-chip {
font-size: 0.75em; padding: 0.15em 0.45em; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.2);
white-space: nowrap;
}
.kefin-bench-chip-on { background: rgba(47,158,68,0.25); }
.kefin-bench-chip-off { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); }
`;
document.head.appendChild(style);
}
function buildPickerGridHtml(sections, checkedSet) {
if (!sections.length) {
return '<p class="listItemBodyText secondary" style="padding:1em;text-align:center;">No sections in the selected categories.</p>';
}
return sections
.map((entry) => {
const id = entry.section.id;
const checked = checkedSet ? checkedSet.has(id) : true;
const name = escapeHtml(entry.section.name || id);
const cat = escapeHtml(entry.category);
return `
<label class="kefin-bench-picker-cell">
<input type="checkbox" class="kefin-bench-pick" value="${escapeHtml(id)}" ${checked ? 'checked' : ''} />
<span class="kefin-bench-picker-label">
<span class="kefin-bench-picker-name">${name}</span>
<span class="kefin-bench-picker-badge">${cat}</span>
</span>
</label>
`;
})
.join('');
}
function buildTableShell() {
return `
<div class="kefin-bench-summary-wrap">
<table class="kefin-bench-table kefin-bench-summary-table">
<colgroup>
<col style="width:16.66%" />
<col style="width:16.66%" />
<col style="width:16.66%" />
<col style="width:16.66%" />
<col style="width:16.68%" />
<col style="width:16.68%" />
</colgroup>
<thead>
<tr>
<th>&lt;500ms</th>
<th>500–1000ms</th>
<th>1000–2000ms</th>
<th>2000ms+</th>
<th>&gt;100 items</th>
<th>&gt;5 MB</th>
</tr>
</thead>
<tbody>
<tr class="kefin-bench-summary-row">
<td class="kefin-bench-ms-fast" data-summary="fast">0</td>
<td class="kefin-bench-ms-mid" data-summary="mid">0</td>
<td class="kefin-bench-ms-slow" data-summary="slow">0</td>
<td class="kefin-bench-ms-worst" data-summary="worst">0</td>
<td data-summary="large-items">0</td>
<td data-summary="large-response">0</td>
</tr>
</tbody>
</table>
</div>
<div class="kefin-bench-table-wrap">
<table class="kefin-bench-table">
<colgroup>
<col style="width:24%" />
<col style="width:10%" />
<col style="width:9%" />
<col style="width:12%" />
<col style="width:7%" />
<col style="width:24%" />
<col style="width:14%" />
</colgroup>
<thead>
<tr>
<th>Section</th>
<th>Category</th>
<th>Time (ms)</th>
<th>Response size</th>
<th>Items</th>
<th>Fields</th>
<th>Status</th>
</tr>
</thead>
<tbody class="kefin-bench-tbody"></tbody>
</table>
</div>
`;
}
function rowHtml(entry, result) {
const name = result?.resolvedName || entry.section.name || entry.section.id;
const status = result?.status || 'pending';
const ms = result?.totalMs;
const msClass = status === 'done' ? msBandClass(ms) : '';
const msText =
status === 'running' ? '…' :
status === 'skipped' ? '—' :
status === 'error' ? '—' :
status === 'done' ? Math.round(ms) : '—';
const items = result?.itemCount ?? '—';
const responseSize = status === 'done' ? formatBytes(result.responseBytes) : '—';
const fields =
status === 'done'
? `<div class="kefin-bench-chips">
${chipHtml(result.hasMediaStreams, 'MediaStreams')}
${chipHtml(result.hasPeople, 'People')}
${chipHtml(result.hasStudios, 'Studios')}
</div>`
: '—';
const statusText =
status === 'running' ? 'Running…' :
status === 'skipped' ? 'Skipped' :
status === 'error' ? (result.error || 'Error') :
status === 'done' ? (result.skippedQueries ? 'Static / no query' : 'OK') : 'Pending';
return `
<tr class="kefin-bench-row kefin-bench-row-${status}" data-section-id="${entry.section.id}">
<td class="kefin-bench-col-name" title="${escapeHtml(name)}">${escapeHtml(name)}</td>
<td class="kefin-bench-col-plain">${escapeHtml(entry.category)}</td>
<td class="kefin-bench-col-plain ${msClass}">${msText}</td>
<td class="kefin-bench-col-plain">${responseSize}</td>
<td class="kefin-bench-col-plain">${items}</td>
<td>${fields}</td>
<td class="kefin-bench-col-plain" title="${escapeHtml(statusText)}">${escapeHtml(statusText)}</td>
</tr>
`;
}
function cancelActiveRun(markSkipped) {
if (!activeRun) return;
activeRun.aborted = true;
if (activeRun.controller) {
try {
activeRun.controller.abort();
} catch (e) { /* ignore */ }
}
if (markSkipped && activeRun.onSkipRemaining) {
activeRun.onSkipRemaining();
}
activeRun = null;
}
function bindNavCancel() {
if (navCancelBound) return;
navCancelBound = true;
const onNavigate = () => {
if (!activeRun) return;
if (window.location.hash === activeRun.startHash) return;
cancelActiveRun(true);
};
window.addEventListener('hashchange', onNavigate);
if (window.KefinTweaksUtils?.onViewPage) {
window.KefinTweaksUtils.onViewPage(() => onNavigate());
}
}
function updateSummaryCells(scopeEl, entries, results) {
const buckets = {
fast: new Map(),
mid: new Map(),
slow: new Map(),
worst: new Map(),
'large-items': new Map(),
'large-response': new Map()
};
entries.forEach((entry) => {
const result = results.get(entry.section.id);
if (!result?.queryMetrics?.length) return;
const name = result.resolvedName || entry.section.name || entry.section.id;
const tally = (key) => {
buckets[key].set(name, (buckets[key].get(name) || 0) + 1);
};
result.queryMetrics.forEach((query) => {
if (query.ms < 500) tally('fast');
else if (query.ms < 1000) tally('mid');
else if (query.ms < 2000) tally('slow');
else tally('worst');
if (query.itemCount > 100) tally('large-items');
if (query.responseBytes > 5 * 1024 * 1024) tally('large-response');
});
});
const summary = scopeEl?.querySelector('.kefin-bench-summary-row');
if (!summary) return;
Object.keys(buckets).forEach((key) => {
const cell = summary.querySelector(`[data-summary="${key}"]`);
if (!cell) return;
const tallied = [...buckets[key].entries()];
const total = tallied.reduce((sum, [, count]) => sum + count, 0);
cell.textContent = total;
if (total) {
// One line per section, with a query count when a section contributes more than once
cell.title = tallied
.map(([name, count]) => (count > 1 ? `${name} (${count} queries)` : name))
.join('\n');
} else {
cell.removeAttribute('title');
}
});
}
function renderResultsTable(resultsEl, entries, results) {
resultsEl.innerHTML = buildTableShell();
const tbody = resultsEl.querySelector('.kefin-bench-tbody');
tbody.innerHTML = entries
.map((entry) => rowHtml(entry, results.get(entry.section.id) || { status: 'pending' }))
.join('');
updateSummaryCells(resultsEl, entries, results);
return tbody;
}
async function runBenchmarks(selectedEntries, tbody, ui, results) {
const controller = new AbortController();
const signal = controller.signal;
const updateSummary = () => {
updateSummaryCells(tbody.closest('#kefin-bench-results'), selectedEntries, results);
};
const updateRow = (entry, result) => {
results.set(entry.section.id, result);
const tr = tbody.querySelector(`tr[data-section-id="${entry.section.id}"]`);
if (tr) {
tr.outerHTML = rowHtml(entry, result);
}
updateSummary();
};
const skipRemaining = () => {
selectedEntries.forEach((entry) => {
if (results.get(entry.section.id)?.status === 'running' ||
!results.has(entry.section.id)) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
}
});
ui.setRunning(false);
};
activeRun = {
aborted: false,
controller,
startHash: window.location.hash,
onSkipRemaining: skipRemaining
};
ui.setRunning(true);
for (const entry of selectedEntries) {
if (activeRun?.aborted || signal.aborted) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
continue;
}
updateRow(entry, { status: 'running' });
try {
const resolved = await resolveSectionQueries(entry);
if (resolved.error) {
updateRow(entry, {
status: 'error',
error: resolved.error,
totalMs: null,
itemCount: '—'
});
continue;
}
const bench = await benchmarkSection(resolved.section, signal);
if (activeRun?.aborted || signal.aborted || bench.cancelled) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
continue;
}
const fields = analyzeItemFields(bench.items);
updateRow(entry, {
status: bench.error && !bench.skippedQueries ? 'error' : 'done',
resolvedName: resolved.section !== entry.section ? resolved.section.name : null,
totalMs: bench.totalMs,
itemCount: bench.items.length,
hasMediaStreams: fields.hasMediaStreams,
hasPeople: fields.hasPeople,
hasStudios: fields.hasStudios,
responseBytes: bench.responseBytes,
queryMetrics: bench.queryMetrics,
skippedQueries: bench.skippedQueries,
error: bench.error
});
} catch (e) {
if (signal.aborted || activeRun?.aborted) {
updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
} else {
updateRow(entry, {
status: 'error',
error: e?.message || String(e),
totalMs: null,
itemCount: '—'
});
}
}
await new Promise((r) => setTimeout(r, 0));
}
activeRun = null;
ui.setRunning(false);
}
/**
* Render benchmark UI into host element.
* @param {HTMLElement} hostEl
* @param {{ getConfig?: () => Object }} options
*/
function renderHomeScreenBenchmarkUI(hostEl, options = {}) {
if (!hostEl) return;
cancelActiveRun(true);
const getConfig = options.getConfig || (() => window.KefinHomeScreen?.getConfig?.() || {});
ensureBenchmarkStyles();
hostEl.innerHTML = `
<div class="kefin-bench-wrap">
<div class="listItemBodyText" style="font-weight:600; font-size:1.1em; margin-bottom:0.35em;">
Home Screen Section Benchmarks
</div>
<div class="listItemBodyText secondary" style="margin-bottom:0.5em;">
Test your home sections for slow queries or otherwise unintended results.
Timings reflect server round-trip per query (not full card render).
</div>
<div class="kefin-bench-cats" id="kefin-bench-cats"></div>
<div id="kefin-bench-panel" class="kefin-bench-panel"></div>
<div class="kefin-bench-actions">
<button type="button" class="emby-button raised button-submit kefin-bench-run-btn" id="kefin-bench-run">Run Benchmarks</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-stop" hidden>Stop</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-view-results" hidden>View Results</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-edit" hidden>Edit Selection</button>
</div>
<div id="kefin-bench-results" style="margin-top:1.25em;"></div>
</div>
`;
const catsEl = hostEl.querySelector('#kefin-bench-cats');
const runBtn = hostEl.querySelector('#kefin-bench-run');
const stopBtn = hostEl.querySelector('#kefin-bench-stop');
const viewResultsBtn = hostEl.querySelector('#kefin-bench-view-results');
const editBtn = hostEl.querySelector('#kefin-bench-edit');
const panelEl = hostEl.querySelector('#kefin-bench-panel');
const resultsEl = hostEl.querySelector('#kefin-bench-results');
const enabledCats = new Set(CATEGORIES.map((c) => c.id));
function getCurrentSections() {
const config = typeof getConfig === 'function' ? getConfig() : {};
return getSectionsForCategories(config, [...enabledCats]);
}
function getCheckedIdsFromPanel() {
return Array.from(panelEl.querySelectorAll('.kefin-bench-pick:checked'))
.map((cb) => cb.value);
}
function updatePickerCount() {
const countEl = panelEl.querySelector('.kefin-bench-picker-count');
if (!countEl) return;
const total = panelEl.querySelectorAll('.kefin-bench-pick').length;
const checked = panelEl.querySelectorAll('.kefin-bench-pick:checked').length;
countEl.textContent = `${checked} of ${total} selected`;
}
function bindPickerToolbar() {
panelEl.querySelector('#kefin-bench-run-top')
?.addEventListener('click', () => startRun());
const selectAllBtn = panelEl.querySelector('#kefin-bench-select-all');
const deselectAllBtn = panelEl.querySelector('#kefin-bench-deselect-all');
selectAllBtn?.addEventListener('click', () => {
panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = true; });
updatePickerCount();
});
deselectAllBtn?.addEventListener('click', () => {
panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = false; });
updatePickerCount();
});
panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => {
cb.addEventListener('change', () => updatePickerCount());
});
}
function showPickerPanel(preserveCheckedIds) {
const sections = getCurrentSections();
const stored = loadStoredSelectedIds();
const storedSet = stored ? new Set(stored) : null;
const checkedSet = preserveCheckedIds instanceof Set ? preserveCheckedIds : storedSet;
panelEl.innerHTML = `
<div class="kefin-bench-picker-toolbar">
<button type="button" class="emby-button raised button-submit kefin-bench-run-btn" id="kefin-bench-run-top">Run Benchmarks</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-select-all">Select all</button>
<button type="button" class="emby-button button-flat" id="kefin-bench-deselect-all">Deselect all</button>
<span class="kefin-bench-picker-count"></span>
</div>
<div class="kefin-bench-picker-grid" id="kefin-bench-picker-grid">
${buildPickerGridHtml(sections, checkedSet)}
</div>
`;
bindPickerToolbar();
updatePickerCount();
}
function showPicker() {
cancelActiveRun(true);
lastViewMode = 'picker';
resultsEl.innerHTML = '';
catsEl.hidden = false;
panelEl.hidden = false;
editBtn.hidden = true;
viewResultsBtn.hidden = !hasStoredResults();
ui.setRunning(false);
}
function showResults(entries, results) {
lastViewMode = 'results';
catsEl.hidden = true;
panelEl.hidden = true;
editBtn.hidden = false;
viewResultsBtn.hidden = true;
return renderResultsTable(resultsEl, entries, results);
}
const ui = {
setRunning(running) {
stopBtn.hidden = !running;
hostEl.querySelectorAll('.kefin-bench-run-btn').forEach((btn) => {
btn.disabled = running;
});
}
};
function startRun() {
if (activeRun) return;
const sections = getCurrentSections();
if (!sections.length) {
window.KefinTweaksToaster?.toast?.('No sections in the selected categories.');
return;
}
const selectedIds = getCheckedIdsFromPanel();
if (!selectedIds.length) {
window.KefinTweaksToaster?.toast?.('Select at least one section to benchmark.');
return;
}
saveStoredSelectedIds(selectedIds);
const idSet = new Set(selectedIds);
const selected = sections.filter((e) => idSet.has(e.section.id));
if (!selected.length) return;
lastRun = { entries: selected, results: new Map() };
const tbody = showResults(lastRun.entries, lastRun.results);
runBenchmarks(lastRun.entries, tbody, ui, lastRun.results);
}
CATEGORIES.forEach((cat) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kefin-bench-cat-btn is-active';
btn.dataset.cat = cat.id;
btn.setAttribute('aria-pressed', 'true');
btn.textContent = cat.label;
btn.addEventListener('click', () => {
if (activeRun) return;
const prevChecked = new Set(getCheckedIdsFromPanel());
if (enabledCats.has(cat.id)) {
enabledCats.delete(cat.id);
btn.classList.remove('is-active');
btn.setAttribute('aria-pressed', 'false');
} else {
enabledCats.add(cat.id);
btn.classList.add('is-active');
btn.setAttribute('aria-pressed', 'true');
}
showPickerPanel(prevChecked);
});
catsEl.appendChild(btn);
});
stopBtn.addEventListener('click', () => cancelActiveRun(true));
editBtn.addEventListener('click', () => showPicker());
runBtn.addEventListener('click', () => startRun());
viewResultsBtn.addEventListener('click', () => {
if (!hasStoredResults()) return;
showResults(lastRun.entries, lastRun.results);
});
showPickerPanel();
if (lastViewMode === 'results' && hasStoredResults()) {
showResults(lastRun.entries, lastRun.results);
} else {
showPicker();
}
}
window.KefinHomeScreenBenchmark = {
renderHomeScreenBenchmarkUI,
cancelActiveRun
};
bindNavCancel();
LOG('loaded');
})();
+155
View File
@@ -0,0 +1,155 @@
// 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 Seerr results from JellyfinEnhanced</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests</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
window.ModalSystem.create({
id: MODAL_ID,
title: 'Search Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined,
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.');
}
});
}
}
});
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,156 @@
// 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
window.ModalSystem.create({
id: MODAL_ID,
title: 'Episodes On Series Page Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined,
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.');
}
});
}
}
});
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');
})();
+156
View File
@@ -0,0 +1,156 @@
// 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
window.ModalSystem.create({
id: MODAL_ID,
title: 'Series Info Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined,
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.');
}
});
}
}
});
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');
})();
+699
View File
@@ -0,0 +1,699 @@
// 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 WARN = (...args) => console.warn('[KefinTweaks SkinManager Config]', ...args);
const MODAL_ID = 'kefin-skinmanager-config';
// Store loaded config for use in listeners
let currentLoadedConfig = null;
function hasDefaultSkinsCatalog() {
const skins = window.KefinTweaksDefaultSkinsConfig?.skins;
return Array.isArray(skins) && skins.length > 0;
}
function notifySkinManagerConfig(message) {
if (window.KefinTweaksToaster?.toast) {
window.KefinTweaksToaster.toast(message);
} else {
alert(message);
}
}
// 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 (!hasDefaultSkinsCatalog()) {
WARN('Refusing to save Skin Manager config: default skins catalog not loaded');
notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.');
return false;
}
const existingSkins = window.KefinTweaksConfig?.skins;
const existingNonEmpty = Array.isArray(existingSkins) && existingSkins.length > 0;
const nextSkins = skinManagerConfig?.skins;
const nextEmpty = !Array.isArray(nextSkins) || nextSkins.length === 0;
if (nextEmpty && existingNonEmpty) {
WARN('Refusing to save Skin Manager config: would wipe non-empty skins array');
notifySkinManagerConfig('Save blocked: collected skins list is empty but existing config has skins. Reload and try again.');
return false;
}
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
* @returns {Object|null} Collected config, or null if unsafe to save
*/
function collectConfig() {
if (!hasDefaultSkinsCatalog()) {
WARN('Cannot collect Skin Manager config: default skins catalog not loaded');
notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.');
return null;
}
// Get default skin
let 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);
const existingSkins = window.KefinTweaksConfig?.skins;
const existingNonEmpty = Array.isArray(existingSkins) && existingSkins.length > 0;
if (skins.length === 0 && existingNonEmpty) {
WARN('Cannot collect Skin Manager config: would wipe non-empty skins array');
notifySkinManagerConfig('Save blocked: collected skins list is empty but existing config has skins. Reload and try again.');
return null;
}
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 {
if (!hasDefaultSkinsCatalog()) {
notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.');
return;
}
// 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
window.ModalSystem.create({
id: MODAL_ID,
title: 'Skin Manager Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined,
onOpen: (modal) => {
attachListeners(modal);
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-skinmanager-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const skinManagerConfig = collectConfig();
if (!skinManagerConfig) return;
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.');
}
});
}
}
});
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');
})();
@@ -0,0 +1,294 @@
// KefinTweaks Thumbnail Scrubber Configuration UI
// Modular configuration modal for managing thumbnail scrubber settings
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks ThumbnailScrubber Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ThumbnailScrubber Config]', ...args);
const MODAL_ID = 'kefin-thumbnailscrubber-config';
/**
* Load configuration from window.KefinTweaksConfig
*/
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.thumbnailScrubber || {};
}
/**
* Save configuration to JS Injector
*/
async function saveConfig(thumbnailScrubberConfig) {
try {
// Ensure window.KefinTweaksConfig exists
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update thumbnailScrubber config
window.KefinTweaksConfig.thumbnailScrubber = thumbnailScrubberConfig;
// 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 previewOnHover = config.PreviewOnHover === true;
const useVideoStream = config.UseVideoStream !== false;
let fallbackAfterMs = 500;
if (Number.isFinite(config.FallbackAfterMs * 1) && config.FallbackAfterMs >= 0) {
fallbackAfterMs = config.FallbackAfterMs * 1;
} else if (Number.isFinite(config.FallbackAfterSeconds * 1) && config.FallbackAfterSeconds >= 0) {
fallbackAfterMs = config.FallbackAfterSeconds * 1000;
}
const hoverDelay = Number.isFinite(config.HOVER_PREVIEW_TIMEOUT_MS * 1)
? config.HOVER_PREVIEW_TIMEOUT_MS * 1
: (Number.isFinite(config.HOVER_TIMEOUT * 1) ? config.HOVER_TIMEOUT * 1 : 800);
const hoverSpeed = Number.isFinite(config.HOVER_PREVIEW_FRAME_MS * 1)
? config.HOVER_PREVIEW_FRAME_MS * 1
: 250;
const scrubDelay = Number.isFinite(config.SCRUB_ACTIVATION_DELAY_MS * 1)
? config.SCRUB_ACTIVATION_DELAY_MS * 1
: 300;
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;">Preview On Hover</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
When enabled, hovering over an eligible card will automatically cycle trickplay thumbnails
as the card background after a short delay, without needing to scrub in the progress zone.
</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="thumbnailScrubber_PreviewOnHover" ${previewOnHover ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
</label>
</div>
</div>
<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 Video Stream Preview</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
When enabled, hover preview plays a muted video stream (<code>/Videos/{id}/stream</code>)
instead of cycling trickplay images as the card background.
</div>
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="thumbnailScrubber_UseVideoStream" ${useVideoStream ? 'checked' : ''}>
<span class="listItemBodyText">Enabled</span>
</label>
</div>
</div>
<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;">Fallback After (ms)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
When Use Video Stream is on, how long to wait for the direct stream to start playing
before falling back to trickplay images.
Set to 0 to disable the timeout (play errors still fall back to trickplay).
</div>
<input type="number"
id="thumbnailScrubber_FallbackAfterMs"
class="fld emby-input"
min="0"
step="50"
value="${fallbackAfterMs}"
style="width: 120px;">
</div>
</div>
<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;">Delay Before Previewing On Hover (ms)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
How long the cursor must stay over a card before the passive preview starts.
Set to 0 to start immediately when hovering a card.
</div>
<input type="number"
id="thumbnailScrubber_HOVER_PREVIEW_TIMEOUT_MS"
class="fld emby-input"
min="0"
step="50"
value="${hoverDelay}"
style="width: 120px;">
</div>
</div>
<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;">Hover Preview Speed (ms between frames)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
Interval in milliseconds between each trickplay frame during hover preview.
Leave at 0 to use the default trickplay interval from the server.
</div>
<input type="number"
id="thumbnailScrubber_HOVER_PREVIEW_FRAME_MS"
class="fld emby-input"
min="0"
step="50"
value="${hoverSpeed}"
style="width: 120px;">
</div>
</div>
<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;">Delay Before Activating Scrubbing (ms)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
How long the cursor must stay in the scrub zone at the bottom of the card before
the scrubber UI activates.
</div>
<input type="number"
id="thumbnailScrubber_SCRUB_ACTIVATION_DELAY_MS"
class="fld emby-input"
min="0"
step="50"
value="${scrubDelay}"
style="width: 120px;">
</div>
</div>
</div>
`;
}
/**
* Collect configuration from form
*/
function collectConfig() {
const previewOnHover = document.getElementById('thumbnailScrubber_PreviewOnHover')?.checked === true;
const useVideoStream = document.getElementById('thumbnailScrubber_UseVideoStream')?.checked === true;
const hoverDelayInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_TIMEOUT_MS');
const scrubDelayInput = document.getElementById('thumbnailScrubber_SCRUB_ACTIVATION_DELAY_MS');
const hoverSpeedInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_FRAME_MS');
const fallbackAfterInput = document.getElementById('thumbnailScrubber_FallbackAfterMs');
let hoverDelay = hoverDelayInput ? parseInt(hoverDelayInput.value, 10) : 800;
if (!Number.isFinite(hoverDelay) || hoverDelay < 0) {
hoverDelay = 800;
}
let scrubDelay = scrubDelayInput ? parseInt(scrubDelayInput.value, 10) : 300;
if (!Number.isFinite(scrubDelay) || scrubDelay < 0) {
scrubDelay = 300;
}
let hoverSpeed = hoverSpeedInput ? parseInt(hoverSpeedInput.value, 10) : 0;
if (!Number.isFinite(hoverSpeed) || hoverSpeed < 0) {
hoverSpeed = 0;
}
let fallbackAfterMs = fallbackAfterInput ? parseInt(fallbackAfterInput.value, 10) : 500;
if (!Number.isFinite(fallbackAfterMs) || fallbackAfterMs < 0) {
fallbackAfterMs = 500;
}
return {
PreviewOnHover: previewOnHover,
UseVideoStream: useVideoStream,
FallbackAfterMs: fallbackAfterMs,
// Delay before previewing on hover
HOVER_PREVIEW_TIMEOUT_MS: hoverDelay,
HOVER_TIMEOUT: hoverDelay, // legacy key
// Hover preview speed (ms between frames)
HOVER_PREVIEW_FRAME_MS: hoverSpeed,
// Scrub activation delay
SCRUB_ACTIVATION_DELAY_MS: scrubDelay
};
}
/**
* 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-thumbnailscrubber-config-btn">Save</button>
`;
// Create modal
window.ModalSystem.create({
id: MODAL_ID,
title: 'Thumbnail Scrubber Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '800px', height: 'auto' }
: undefined,
onOpen: (modal) => {
// Save button
const saveBtn = modal.dialogFooter.querySelector('#save-thumbnailscrubber-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const thumbnailScrubberConfig = collectConfig();
const success = await saveConfig(thumbnailScrubberConfig);
if (success) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Thumbnail Scrubber 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.');
}
});
}
}
});
LOG('Thumbnail Scrubber configuration modal opened');
} catch (error) {
ERR('Error opening thumbnail scrubber config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
// Export
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.thumbnailScrubber = { openConfigModal };
console.log('[KefinTweaks ThumbnailScrubber Config] Script loaded');
})();
+868
View File
@@ -0,0 +1,868 @@
// KefinTweaks User Manager Configuration UI — User Templates
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks UserManager Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks UserManager Config]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks UserManager Config]', ...args);
const CONFIG_MODAL_ID = 'kefin-usermanager-config';
const EDITOR_MODAL_ID = 'kefin-usermanager-template-editor';
const TAG_MODAL_ID = 'kefin-usermanager-tag-modal';
const SCHEDULE_MODAL_ID = 'kefin-usermanager-schedule-modal';
const DEFAULT_AUTH = 'Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider';
const DEFAULT_PASSWORD_RESET = 'Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider';
const UNRATED_TYPES = [
{ type: 'Book', label: 'Books' },
{ type: 'ChannelContent', label: 'Channels' },
{ type: 'LiveTvChannel', label: 'Live TV' },
{ type: 'Movie', label: 'Movies' },
{ type: 'Music', label: 'Music' },
{ type: 'Trailer', label: 'Trailers' },
{ type: 'Series', label: 'Shows' }
];
const DAYS = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
'Everyday', 'Weekday', 'Weekend'
];
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function clone(obj) {
return JSON.parse(JSON.stringify(obj ?? {}));
}
function getSeedPolicy() {
return {
IsAdministrator: false,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
BlockedTags: [],
AllowedTags: [],
EnableUserPreferenceAccess: true,
AccessSchedules: [],
BlockUnratedItems: [],
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: true,
EnableRemoteAccess: true,
EnableLiveTvManagement: true,
EnableLiveTvAccess: true,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDeletionFromFolders: [],
EnableContentDownloading: true,
EnableSyncTranscoding: true,
EnableMediaConversion: true,
EnabledDevices: [],
EnableAllDevices: true,
EnabledChannels: [],
EnableAllChannels: true,
EnabledFolders: [],
EnableAllFolders: true,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: -1,
MaxActiveSessions: 0,
BlockedMediaFolders: [],
BlockedChannels: [],
RemoteClientBitrateLimit: 0,
AuthenticationProviderId: DEFAULT_AUTH,
PasswordResetProviderId: DEFAULT_PASSWORD_RESET,
SyncPlayAccess: 'CreateAndJoinGroups'
};
}
function normalizeConfig(raw) {
const cfg = raw && typeof raw === 'object' ? raw : {};
const templates = Array.isArray(cfg.templates)
? cfg.templates.filter((t) => t && typeof t.name === 'string' && t.name.trim())
: [];
let defaultTemplateName = typeof cfg.defaultTemplateName === 'string' ? cfg.defaultTemplateName : '';
if (defaultTemplateName && !templates.some((t) => t.name === defaultTemplateName)) {
defaultTemplateName = '';
}
return { defaultTemplateName, templates };
}
function loadConfig() {
return normalizeConfig(window.KefinTweaksConfig?.userManager);
}
async function saveConfig(next) {
try {
if (!window.KefinTweaksConfig) window.KefinTweaksConfig = {};
window.KefinTweaksConfig.userManager = normalizeConfig(next);
if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved');
return true;
}
if (window.KefinTweaksConfiguration?.findJavaScriptInjectorPlugin) {
const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
if (pluginId) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved (fallback)');
return true;
}
}
ERR('saveConfigToJavaScriptInjector not available');
return false;
} catch (e) {
ERR('Error saving config:', e);
return false;
}
}
async function fetchMediaFolders() {
try {
const url = ApiClient.getUrl('Library/MediaFolders');
const data = await ApiClient.getJSON(url);
return Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
} catch (e) {
WARN('Failed to load media folders', e);
return [];
}
}
function buildCheckbox(id, checked, label, options) {
return window.KefinTweaksUI?.buildCheckbox?.(id, checked, label, options) || '';
}
function hourOptions(selected) {
const opts = [];
for (let h = 0; h <= 24; h += 0.5) {
const hours = Math.floor(h) % 12 || 12;
const mins = h % 1 ? '30' : '00';
const ampm = h < 12 || h === 24 ? 'AM' : 'PM';
const label = h === 24 ? '12:00 AM' : `${hours}:${mins} ${ampm}`;
opts.push(`<option value="${h}" ${Number(selected) === h ? 'selected' : ''}>${label}</option>`);
}
return opts.join('');
}
function formatScheduleTime(h) {
const n = Number(h);
if (!Number.isFinite(n)) return '';
if (n === 24) return '12:00 AM';
const hours = Math.floor(n) % 12 || 12;
const mins = n % 1 ? '30' : '00';
const ampm = n < 12 ? 'AM' : 'PM';
return `${hours}:${mins} ${ampm}`;
}
function bitrateToMbps(bps) {
const n = Number(bps) || 0;
if (n <= 0) return '';
return String(Math.round((n / 1000000) * 100) / 100);
}
function mbpsToBitrate(mbps) {
const n = parseFloat(mbps);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.round(n * 1000000);
}
function buildTagListHTML(tags, listClass) {
if (!tags?.length) {
return `<div class="listItemBodyText secondary ${listClass}-empty">No tags</div>`;
}
return tags.map((tag, i) => `
<div class="listItem paperList" style="margin-bottom:0.35em;">
<div class="listItemBody"><h3 class="listItemBodyText">${escapeHtml(tag)}</h3></div>
<button type="button" class="emby-button raised um-delete-tag" data-list="${listClass}" data-index="${i}" title="Delete">
<span class="material-icons" style="font-size:1.1em;">delete</span>
</button>
</div>`).join('');
}
function buildScheduleListHTML(schedules) {
if (!schedules?.length) {
return `<div class="listItemBodyText secondary um-schedules-empty">No access schedules</div>`;
}
return schedules.map((s, i) => `
<div class="listItem paperList um-schedule-row" style="margin-bottom:0.35em;"
data-day="${escapeHtml(s.DayOfWeek)}" data-start="${s.StartHour}" data-end="${s.EndHour}">
<div class="listItemBody two-line">
<h3 class="listItemBodyText">${escapeHtml(s.DayOfWeek)}</h3>
<div class="listItemBodyText secondary">${formatScheduleTime(s.StartHour)} - ${formatScheduleTime(s.EndHour)}</div>
</div>
<button type="button" class="emby-button raised um-delete-schedule" data-index="${i}" title="Delete">
<span class="material-icons" style="font-size:1.1em;">delete</span>
</button>
</div>`).join('');
}
function buildEditorHTML(template, folders) {
const policy = { ...getSeedPolicy(), ...(template?.policy || {}) };
const name = template?.name || '';
const enabledFolders = new Set(policy.EnabledFolders || []);
const deleteFolders = new Set(policy.EnableContentDeletionFromFolders || []);
const blockUnrated = new Set(policy.BlockUnratedItems || []);
const allowedTags = policy.AllowedTags || [];
const blockedTags = policy.BlockedTags || [];
const schedules = policy.AccessSchedules || [];
const folderChecks = folders.map((f) => {
const id = f.Id || f.id;
const label = f.Name || f.name || id;
return buildCheckbox('', enabledFolders.has(id), label, {
className: 'um-folder',
wrapInSection: true,
dataAttributes: { id }
});
}).join('');
const deleteFolderChecks = folders.map((f) => {
const id = f.Id || f.id;
const label = f.Name || f.name || id;
return buildCheckbox('', deleteFolders.has(id), label, {
className: 'um-delete-folder',
wrapInSection: true,
dataAttributes: { id }
});
}).join('');
const unratedChecks = UNRATED_TYPES.map((u) =>
buildCheckbox('', blockUnrated.has(u.type), u.label, {
className: 'um-unrated',
wrapInSection: true,
dataAttributes: { itemtype: u.type }
})
).join('');
return `
<div class="um-template-editor" style="max-width:100%;">
<div class="inputContainer">
<label class="inputLabel" for="um-template-name">Template name</label>
<input id="um-template-name" class="emby-input" type="text" required value="${escapeHtml(name)}" placeholder="e.g. Standard">
</div>
<div class="verticalSection">
<h2 class="paperListLabel">Permissions</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableRemoteAccess, 'Allow remote connections to this server', { className: 'um-chk-remote', wrapInSection: true })}
${buildCheckbox('', !!policy.IsAdministrator, 'Allow this user to manage the server', { className: 'um-chk-admin', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableCollectionManagement, 'Allow this user to manage collections', { className: 'um-chk-collections', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableSubtitleManagement, 'Allow this user to edit subtitles', { className: 'um-chk-subtitles', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableLyricManagement, 'Allow this user to manage lyrics', { className: 'um-chk-lyrics', wrapInSection: true })}
</div>
</div>
<div class="verticalSection">
<h2 class="paperListLabel">Feature access</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableLiveTvAccess, 'Allow Live TV access', { className: 'um-chk-livetv', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableLiveTvManagement, 'Allow Live TV recording management', { className: 'um-chk-livetv-mgmt', wrapInSection: true })}
</div>
</div>
<div class="verticalSection">
<h2 class="paperListLabel">Media playback</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableMediaPlayback, 'Allow media playback', { className: 'um-chk-playback', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableAudioPlaybackTranscoding, 'Allow audio playback that requires transcoding', { className: 'um-chk-audio-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableVideoPlaybackTranscoding, 'Allow video playback that requires transcoding', { className: 'um-chk-video-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnablePlaybackRemuxing, 'Allow video playback that requires conversion without re-encoding', { className: 'um-chk-remux', wrapInSection: true })}
${buildCheckbox('', !!policy.ForceRemoteSourceTranscoding, 'Force transcoding of remote media sources such as Live TV', { className: 'um-chk-force-remote-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableSyncTranscoding, 'Allow sync transcoding', { className: 'um-chk-sync-tc', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableMediaConversion, 'Allow media conversion', { className: 'um-chk-conversion', wrapInSection: true })}
</div>
<div class="inputContainer" style="margin-top:0.75em;">
<label class="inputLabel" for="um-bitrate">Internet streaming bitrate limit (Mbps)</label>
<input id="um-bitrate" class="emby-input" type="number" min="0" step="0.25" value="${escapeHtml(bitrateToMbps(policy.RemoteClientBitrateLimit))}">
<div class="fieldDescription">Leave empty or 0 for no per-user limit.</div>
</div>
<div class="selectContainer" style="margin-top:0.75em;">
<label class="selectLabel" for="um-syncplay">SyncPlay access</label>
<select id="um-syncplay" class="emby-select-withcolor emby-select">
<option value="CreateAndJoinGroups" ${policy.SyncPlayAccess === 'CreateAndJoinGroups' ? 'selected' : ''}>Allow user to create and join groups</option>
<option value="JoinGroups" ${policy.SyncPlayAccess === 'JoinGroups' ? 'selected' : ''}>Allow user to join groups</option>
<option value="None" ${policy.SyncPlayAccess === 'None' ? 'selected' : ''}>Disabled for this user</option>
</select>
</div>
</div>
<div class="verticalSection">
<h2 class="checkboxListLabel" style="margin-bottom:1em;">Allow media deletion from</h2>
<div class="checkboxList paperList checkboxList-paperList">
${buildCheckbox('', !!policy.EnableContentDeletion, 'All libraries', { className: 'um-chk-delete-all', wrapInSection: true })}
<div class="um-delete-folders checkboxList" style="${policy.EnableContentDeletion ? 'display:none;' : ''}">
${deleteFolderChecks || '<div class="fieldDescription">No libraries found</div>'}
</div>
</div>
</div>
<div class="verticalSection">
<h2 class="checkboxListLabel">Remote Control</h2>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${buildCheckbox('', !!policy.EnableRemoteControlOfOtherUsers, 'Allow remote control of other users', { className: 'um-chk-remote-others', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableSharedDeviceControl, 'Allow remote control of shared devices', { className: 'um-chk-remote-shared', wrapInSection: true })}
</div>
</div>
<div class="verticalSection">
<h2 class="checkboxListLabel">Other</h2>
${buildCheckbox('', !!policy.EnableContentDownloading, 'Allow media downloads', { className: 'um-chk-download', wrapInSection: true })}
${buildCheckbox('', !!policy.EnableUserPreferenceAccess, 'Allow user preference access', { className: 'um-chk-prefs', wrapInSection: true })}
${buildCheckbox('', !!policy.IsDisabled, 'Disable this user', { className: 'um-chk-disabled', wrapInSection: true })}
${buildCheckbox('', !!policy.IsHidden, 'Hide this user from login screens', { className: 'um-chk-hidden', wrapInSection: true })}
<div class="inputContainer" style="margin-top:0.75em;">
<label class="inputLabel" for="um-lockout">Failed login tries before user is locked out</label>
<input id="um-lockout" class="emby-input" type="number" min="-1" step="1" value="${escapeHtml(policy.LoginAttemptsBeforeLockout ?? -1)}">
<div class="fieldDescription">-1 disables lockout. 0 inherits server default.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="um-max-sessions">Maximum number of simultaneous user sessions</label>
<input id="um-max-sessions" class="emby-input" type="number" min="0" step="1" value="${escapeHtml(policy.MaxActiveSessions ?? 0)}">
<div class="fieldDescription">0 disables the limit.</div>
</div>
</div>
<div class="verticalSection folderAccessContainer">
<h2>Library Access</h2>
${buildCheckbox('', !!policy.EnableAllFolders, 'Enable access to all libraries', { className: 'um-chk-all-folders', wrapInSection: true })}
<div class="um-folder-list" style="${policy.EnableAllFolders ? 'display:none;' : ''}">
<h3 class="checkboxListLabel">Libraries</h3>
<div class="checkboxList paperList" style="padding:0.5em 1em;">
${folderChecks || '<div class="fieldDescription">No libraries found</div>'}
</div>
</div>
</div>
<div class="verticalSection">
<h3 class="checkboxListLabel">Block items with no or unrecognized rating information</h3>
<div class="checkboxList paperList" style="padding:0.5em 1em;">${unratedChecks}</div>
</div>
<div class="verticalSection" style="margin-bottom:1.5em;">
<div class="sectionTitleContainer flex align-items-center" style="display:flex;align-items:center;gap:0.5em;">
<h2 class="sectionTitle" style="margin:0;">Allow items with tags</h2>
<button type="button" class="emby-button raised um-add-allowed-tag" title="Add">
<span class="material-icons" style="font-size:1.1em;">add</span>
</button>
</div>
<div class="fieldDescription">Only show media with at least one of the specified tags.</div>
<div class="um-allowed-tags" style="margin-top:0.5em;">${buildTagListHTML(allowedTags, 'allowed')}</div>
</div>
<div class="verticalSection" style="margin-bottom:1.5em;">
<div class="sectionTitleContainer flex align-items-center" style="display:flex;align-items:center;gap:0.5em;">
<h2 class="sectionTitle" style="margin:0;">Block items with tags</h2>
<button type="button" class="emby-button raised um-add-blocked-tag" title="Add">
<span class="material-icons" style="font-size:1.1em;">add</span>
</button>
</div>
<div class="fieldDescription">Hide media with at least one of the specified tags.</div>
<div class="um-blocked-tags" style="margin-top:0.5em;">${buildTagListHTML(blockedTags, 'blocked')}</div>
</div>
<div class="verticalSection" style="margin-bottom:1.5em;">
<div class="sectionTitleContainer flex align-items-center" style="display:flex;align-items:center;gap:0.5em;">
<h2 class="sectionTitle" style="margin:0;">Access Schedule</h2>
<button type="button" class="emby-button raised um-add-schedule" title="Add">
<span class="material-icons" style="font-size:1.1em;">add</span>
</button>
</div>
<p class="fieldDescription">Limit access to certain hours.</p>
<div class="um-schedules">${buildScheduleListHTML(schedules)}</div>
</div>
</div>
`;
}
function collectCheckedIds(root, selector) {
return Array.from(root.querySelectorAll(`${selector}:checked`))
.map((el) => el.getAttribute('data-id'))
.filter(Boolean);
}
function collectPolicyFromEditor(root, state) {
const enableAllFolders = !!root.querySelector('.um-chk-all-folders')?.checked;
const enableContentDeletion = !!root.querySelector('.um-chk-delete-all')?.checked;
return {
...getSeedPolicy(),
IsAdministrator: !!root.querySelector('.um-chk-admin')?.checked,
IsHidden: !!root.querySelector('.um-chk-hidden')?.checked,
EnableCollectionManagement: !!root.querySelector('.um-chk-collections')?.checked,
EnableSubtitleManagement: !!root.querySelector('.um-chk-subtitles')?.checked,
EnableLyricManagement: !!root.querySelector('.um-chk-lyrics')?.checked,
IsDisabled: !!root.querySelector('.um-chk-disabled')?.checked,
BlockedTags: state.blockedTags.slice(),
AllowedTags: state.allowedTags.slice(),
EnableUserPreferenceAccess: !!root.querySelector('.um-chk-prefs')?.checked,
AccessSchedules: state.schedules.map((s) => ({ ...s })),
BlockUnratedItems: Array.from(root.querySelectorAll('.um-unrated:checked'))
.map((el) => el.getAttribute('data-itemtype'))
.filter(Boolean),
EnableRemoteControlOfOtherUsers: !!root.querySelector('.um-chk-remote-others')?.checked,
EnableSharedDeviceControl: !!root.querySelector('.um-chk-remote-shared')?.checked,
EnableRemoteAccess: !!root.querySelector('.um-chk-remote')?.checked,
EnableLiveTvManagement: !!root.querySelector('.um-chk-livetv-mgmt')?.checked,
EnableLiveTvAccess: !!root.querySelector('.um-chk-livetv')?.checked,
EnableMediaPlayback: !!root.querySelector('.um-chk-playback')?.checked,
EnableAudioPlaybackTranscoding: !!root.querySelector('.um-chk-audio-tc')?.checked,
EnableVideoPlaybackTranscoding: !!root.querySelector('.um-chk-video-tc')?.checked,
EnablePlaybackRemuxing: !!root.querySelector('.um-chk-remux')?.checked,
ForceRemoteSourceTranscoding: !!root.querySelector('.um-chk-force-remote-tc')?.checked,
EnableContentDeletion: enableContentDeletion,
EnableContentDeletionFromFolders: enableContentDeletion
? []
: collectCheckedIds(root, '.um-delete-folder'),
EnableContentDownloading: !!root.querySelector('.um-chk-download')?.checked,
EnableSyncTranscoding: !!root.querySelector('.um-chk-sync-tc')?.checked,
EnableMediaConversion: !!root.querySelector('.um-chk-conversion')?.checked,
EnabledDevices: [],
EnableAllDevices: true,
EnabledChannels: [],
EnableAllChannels: true,
EnabledFolders: enableAllFolders ? [] : collectCheckedIds(root, '.um-folder'),
EnableAllFolders: enableAllFolders,
LoginAttemptsBeforeLockout: parseInt(root.querySelector('#um-lockout')?.value ?? '-1', 10),
MaxActiveSessions: parseInt(root.querySelector('#um-max-sessions')?.value ?? '0', 10),
RemoteClientBitrateLimit: mbpsToBitrate(root.querySelector('#um-bitrate')?.value),
AuthenticationProviderId: DEFAULT_AUTH,
PasswordResetProviderId: DEFAULT_PASSWORD_RESET,
SyncPlayAccess: root.querySelector('#um-syncplay')?.value || 'CreateAndJoinGroups'
};
}
function promptText(title, placeholder) {
return new Promise((resolve) => {
const content = document.createElement('div');
content.innerHTML = `
<div class="inputContainer">
<label class="inputLabel" for="um-tag-input">${escapeHtml(title)}</label>
<input id="um-tag-input" class="emby-input" type="text" placeholder="${escapeHtml(placeholder || '')}">
</div>`;
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `
<button type="button" class="emby-button raised" id="um-tag-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-tag-ok">Add</button>`;
const modal = window.ModalSystem.create({
id: TAG_MODAL_ID,
title,
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: { maxWidth: '420px', width: '90vw' },
onOpen: (m) => {
const input = m.dialogContent.querySelector('#um-tag-input');
const finish = (val) => {
window.ModalSystem.close(TAG_MODAL_ID);
resolve(val);
};
m.dialogFooter.querySelector('#um-tag-cancel')?.addEventListener('click', () => finish(null));
m.dialogFooter.querySelector('#um-tag-ok')?.addEventListener('click', () => {
finish(input?.value?.trim() || null);
});
input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
finish(input.value.trim() || null);
}
});
setTimeout(() => input?.focus(), 50);
},
onClose: () => resolve(null)
});
});
}
function promptSchedule() {
return new Promise((resolve) => {
const dayOpts = DAYS.map((d) => `<option value="${d}">${d === 'Everyday' ? 'Every day' : d === 'Weekday' ? 'Weekdays' : d === 'Weekend' ? 'Weekends' : d}</option>`).join('');
const content = document.createElement('div');
content.innerHTML = `
<div class="selectContainer">
<label class="selectLabel" for="um-sched-day">Day of week</label>
<select id="um-sched-day" class="emby-select-withcolor emby-select">${dayOpts}</select>
</div>
<div class="selectContainer">
<label class="selectLabel" for="um-sched-start">Start time</label>
<select id="um-sched-start" class="emby-select-withcolor emby-select">${hourOptions(0)}</select>
</div>
<div class="selectContainer">
<label class="selectLabel" for="um-sched-end">End time</label>
<select id="um-sched-end" class="emby-select-withcolor emby-select">${hourOptions(24)}</select>
</div>`;
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `
<button type="button" class="emby-button raised" id="um-sched-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-sched-ok">Add</button>`;
let settled = false;
const finish = (val) => {
if (settled) return;
settled = true;
window.ModalSystem.close(SCHEDULE_MODAL_ID);
resolve(val);
};
const modal = window.ModalSystem.create({
id: SCHEDULE_MODAL_ID,
title: 'Access Schedule',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: { maxWidth: '420px', width: '90vw' },
onOpen: (m) => {
m.dialogFooter.querySelector('#um-sched-cancel')?.addEventListener('click', () => finish(null));
m.dialogFooter.querySelector('#um-sched-ok')?.addEventListener('click', () => {
const DayOfWeek = m.dialogContent.querySelector('#um-sched-day')?.value;
const StartHour = parseFloat(m.dialogContent.querySelector('#um-sched-start')?.value);
const EndHour = parseFloat(m.dialogContent.querySelector('#um-sched-end')?.value);
if (!DayOfWeek || !Number.isFinite(StartHour) || !Number.isFinite(EndHour) || EndHour <= StartHour) {
window.KefinTweaksToaster?.toast?.('End time must be after start time');
return;
}
finish({ DayOfWeek, StartHour, EndHour });
});
},
onClose: () => finish(null)
});
});
}
function refreshTagLists(root, state) {
const allowedEl = root.querySelector('.um-allowed-tags');
const blockedEl = root.querySelector('.um-blocked-tags');
if (allowedEl) allowedEl.innerHTML = buildTagListHTML(state.allowedTags, 'allowed');
if (blockedEl) blockedEl.innerHTML = buildTagListHTML(state.blockedTags, 'blocked');
}
function refreshSchedules(root, state) {
const el = root.querySelector('.um-schedules');
if (el) el.innerHTML = buildScheduleListHTML(state.schedules);
}
function setCheckboxesChecked(root, selector, checked) {
root.querySelectorAll(selector).forEach((el) => {
el.checked = checked;
if (checked) el.setAttribute('checked', '');
else el.removeAttribute('checked');
});
}
function bindEditorInteractions(root, state) {
const bindAllLibrariesToggle = (chkSel, listSel, itemSel) => {
const chk = root.querySelector(chkSel);
const list = root.querySelector(listSel);
const sync = () => {
const allChecked = !!chk?.checked;
if (list) list.style.display = allChecked ? 'none' : '';
// When leaving "all", select every library by default
if (!allChecked) setCheckboxesChecked(root, itemSel, true);
};
chk?.addEventListener('change', sync);
// Initial visibility only — do not force-select on load (preserve saved template)
if (list) list.style.display = chk?.checked ? 'none' : '';
};
bindAllLibrariesToggle('.um-chk-all-folders', '.um-folder-list', '.um-folder');
bindAllLibrariesToggle('.um-chk-delete-all', '.um-delete-folders', '.um-delete-folder');
root.addEventListener('click', async (e) => {
if (e.target.closest('.um-add-allowed-tag')) {
const tag = await promptText('Allow tag', 'Enter a tag');
if (tag && !state.allowedTags.includes(tag)) {
state.allowedTags.push(tag);
refreshTagLists(root, state);
}
return;
}
if (e.target.closest('.um-add-blocked-tag')) {
const tag = await promptText('Block tag', 'Enter a tag');
if (tag && !state.blockedTags.includes(tag)) {
state.blockedTags.push(tag);
refreshTagLists(root, state);
}
return;
}
if (e.target.closest('.um-add-schedule')) {
const schedule = await promptSchedule();
if (schedule) {
state.schedules.push(schedule);
refreshSchedules(root, state);
}
return;
}
const delTag = e.target.closest('.um-delete-tag');
if (delTag) {
const list = delTag.dataset.list;
const index = parseInt(delTag.dataset.index, 10);
if (list === 'allowed') state.allowedTags.splice(index, 1);
if (list === 'blocked') state.blockedTags.splice(index, 1);
refreshTagLists(root, state);
return;
}
const delSched = e.target.closest('.um-delete-schedule');
if (delSched) {
const index = parseInt(delSched.dataset.index, 10);
state.schedules.splice(index, 1);
refreshSchedules(root, state);
}
});
}
function buildMainConfigHTML(config) {
const options = [`<option value="">None</option>`]
.concat(config.templates.map((t) =>
`<option value="${escapeHtml(t.name)}" ${config.defaultTemplateName === t.name ? 'selected' : ''}>${escapeHtml(t.name)}</option>`
)).join('');
const list = config.templates.length
? config.templates.map((t) => `
<div class="listItem um-template-row" data-name="${escapeHtml(t.name)}"
style="border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:0.65em 0.75em;margin-bottom:0.5em;display:flex;align-items:center;gap:0.75em;cursor:pointer;">
<div class="listItemBody" style="flex:1;min-width:0;">
<div class="listItemBodyText">${escapeHtml(t.name)}</div>
<div class="listItemBodyText secondary" style="font-size:0.85em;">
Modified ${escapeHtml(t.dateModified ? new Date(t.dateModified).toLocaleString() : '—')}
</div>
</div>
<button type="button" class="emby-button raised um-edit-template" data-name="${escapeHtml(t.name)}" title="Edit">
<span class="material-icons" style="font-size:1.1em;">edit</span>
</button>
<button type="button" class="emby-button raised um-delete-template" data-name="${escapeHtml(t.name)}" title="Delete">
<span class="material-icons" style="font-size:1.1em;">delete</span>
</button>
</div>`).join('')
: `<div class="listItemBodyText secondary">No templates yet. Create one to get started.</div>`;
return `
<div class="um-config-root">
<div class="listItemBodyText secondary" style="margin-bottom:1em;">
Create reusable user policy templates and apply them to new or existing accounts.
Import / Export user data will be added here later.
</div>
<div class="selectContainer" style="margin-bottom:1em;">
<label class="selectLabel" for="um-default-template">Default template for new users</label>
<select id="um-default-template" class="emby-select-withcolor emby-select">${options}</select>
<div class="fieldDescription">Used as the initial selection on the Add User page.</div>
</div>
<div style="margin-bottom:0.75em;">
<button type="button" class="emby-button raised button-submit" id="um-create-template">Create Template</button>
</div>
<div class="um-template-list">${list}</div>
</div>`;
}
async function persistTemplates(mutator) {
const config = loadConfig();
mutator(config);
const ok = await saveConfig(config);
if (!ok) {
alert('Error saving configuration. Ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
}
return ok;
}
async function openTemplateEditor(existingTemplate) {
const isEdit = !!(existingTemplate && existingTemplate.name);
const originalName = isEdit ? existingTemplate.name : null;
const folders = await fetchMediaFolders();
const state = {
allowedTags: (existingTemplate?.policy?.AllowedTags || []).slice(),
blockedTags: (existingTemplate?.policy?.BlockedTags || []).slice(),
schedules: clone(existingTemplate?.policy?.AccessSchedules || [])
};
const content = document.createElement('div');
content.innerHTML = buildEditorHTML(
existingTemplate || { name: '', policy: getSeedPolicy() },
folders
);
bindEditorInteractions(content, state);
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `
<button type="button" class="emby-button raised" id="um-editor-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-editor-save">Save Template</button>`;
window.ModalSystem.create({
id: EDITOR_MODAL_ID,
title: isEdit ? `Edit Template: ${existingTemplate.name}` : 'Create Template',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined,
onOpen: (modal) => {
modal.dialogFooter.querySelector('#um-editor-cancel')?.addEventListener('click', () => {
window.ModalSystem.close(EDITOR_MODAL_ID);
});
modal.dialogFooter.querySelector('#um-editor-save')?.addEventListener('click', async () => {
const root = modal.dialogContent;
const name = root.querySelector('#um-template-name')?.value?.trim();
if (!name) {
window.KefinTweaksToaster?.toast?.('Template name is required');
return;
}
const policy = collectPolicyFromEditor(root, state);
const config = loadConfig();
if (!isEdit && config.templates.some((t) => t.name === name)) {
window.KefinTweaksToaster?.toast?.('A template with that name already exists');
return;
}
if (isEdit && name !== originalName && config.templates.some((t) => t.name === name)) {
window.KefinTweaksToaster?.toast?.('A template with that name already exists');
return;
}
const next = {
name,
dateModified: new Date().toISOString(),
policy
};
const ok = await persistTemplates((cfg) => {
if (isEdit) {
const idx = cfg.templates.findIndex((t) => t.name === originalName);
if (idx >= 0) cfg.templates[idx] = next;
else cfg.templates.push(next);
if (cfg.defaultTemplateName === originalName) cfg.defaultTemplateName = name;
} else {
cfg.templates.push(next);
}
});
if (ok) {
window.KefinTweaksToaster?.toast?.('Template saved');
window.ModalSystem.close(EDITOR_MODAL_ID);
refreshMainConfigListIfOpen();
}
});
}
});
}
function refreshMainConfigListIfOpen() {
const listRoot = document.querySelector('.um-config-root');
if (!listRoot) return;
const wrap = listRoot.parentElement;
if (!wrap) return;
wrap.innerHTML = buildMainConfigHTML(loadConfig());
bindMainConfigActions(wrap);
}
function bindMainConfigActions(root) {
if (!root) return;
const scope = root;
scope.querySelector('#um-create-template')?.addEventListener('click', () => {
openTemplateEditor(null);
});
scope.querySelector('#um-default-template')?.addEventListener('change', async (e) => {
const value = e.target.value || '';
await persistTemplates((config) => {
config.defaultTemplateName = value;
});
});
scope.addEventListener('click', async (e) => {
const del = e.target.closest('.um-delete-template');
if (del) {
e.stopPropagation();
const name = del.dataset.name;
if (!name || !confirm(`Delete template "${name}"?`)) return;
const ok = await persistTemplates((config) => {
config.templates = config.templates.filter((t) => t.name !== name);
if (config.defaultTemplateName === name) config.defaultTemplateName = '';
});
if (ok) refreshMainConfigListIfOpen();
return;
}
const editBtn = e.target.closest('.um-edit-template');
const row = e.target.closest('.um-template-row');
const target = editBtn || row;
if (target) {
e.stopPropagation();
const name = target.dataset.name;
const template = loadConfig().templates.find((t) => t.name === name);
if (template) openTemplateEditor(clone(template));
}
});
}
async function openConfigModal() {
try {
const config = loadConfig();
const content = document.createElement('div');
content.innerHTML = buildMainConfigHTML(config);
bindMainConfigActions(content);
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `<button class="emby-button raised" onclick="window.ModalSystem.close('${CONFIG_MODAL_ID}')">Close</button>`;
window.ModalSystem.create({
id: CONFIG_MODAL_ID,
title: 'User Manager',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined
});
LOG('User Manager config opened');
} catch (e) {
ERR('Error opening User Manager config', e);
alert('Error opening configuration: ' + e.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.userManager = {
openConfigModal,
openTemplateEditor,
loadConfig,
getSeedPolicy,
clonePolicy: clone
};
// Shared helpers for runtime script
window.KefinUserManagerConfig = {
loadConfig,
getSeedPolicy,
clonePolicy: clone,
openTemplateEditor,
openConfigModal
};
console.log('[KefinTweaks UserManager Config] Script loaded');
})();
@@ -0,0 +1,485 @@
// KefinTweaks Item Version Preferences Configuration UI
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks VersionPreferences Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks VersionPreferences Config]', ...args);
const MODAL_ID = 'kefin-versionpreferences-config';
const NONE_SENTINEL = 'None';
const DEFAULT_VERSIONS = ['4k', '2160p', '1080p', '720p', '576p', '480p', '360p', NONE_SENTINEL];
const DEFAULT_EDITIONS = ['Theatrical', "Director's Cut", 'Extended Edition', 'Fan Edit', NONE_SENTINEL];
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function isNoneSentinel(term) {
return typeof term === 'string' && term.trim().toLowerCase() === NONE_SENTINEL.toLowerCase();
}
function ensureNoneSentinel(list) {
const out = [];
let noneSeen = false;
(Array.isArray(list) ? list : []).forEach((term) => {
if (typeof term !== 'string' || !term.trim()) return;
if (isNoneSentinel(term)) {
if (!noneSeen) {
out.push(NONE_SENTINEL);
noneSeen = true;
}
return;
}
out.push(term.trim());
});
if (!noneSeen) out.push(NONE_SENTINEL);
return out;
}
function getDefaults() {
return {
versions: DEFAULT_VERSIONS.slice(),
editions: DEFAULT_EDITIONS.slice(),
showVersionBeforeEdition: false
};
}
async function loadConfig() {
const defaults = getDefaults();
const cfg = window.KefinTweaksConfig?.versionPreferences || {};
return {
versions: ensureNoneSentinel(
Array.isArray(cfg.versions) ? cfg.versions : defaults.versions
),
editions: ensureNoneSentinel(
Array.isArray(cfg.editions) ? cfg.editions : defaults.editions
),
showVersionBeforeEdition: cfg.showVersionBeforeEdition === true
};
}
async function saveConfig(versionPreferences) {
try {
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
window.KefinTweaksConfig.versionPreferences = {
versions: ensureNoneSentinel(versionPreferences.versions),
editions: ensureNoneSentinel(versionPreferences.editions),
showVersionBeforeEdition: versionPreferences.showVersionBeforeEdition === true
};
if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
}
if (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;
}
}
function buildTermRowHTML(term, index, listKey) {
const isNone = isNoneSentinel(term);
const termCell = isNone
? `<span class="listItemBodyText kefin-vp-none-label" style="flex: 1; min-width: 0; font-weight: 600;"
data-list="${escapeHtml(listKey)}" data-index="${index}" data-term="${escapeHtml(NONE_SENTINEL)}">
${escapeHtml(NONE_SENTINEL)}
<span class="listItemBodyText secondary" style="font-weight: 400; font-size: 0.85em; margin-left: 0.5em;">(no match)</span>
</span>`
: `<input type="text" class="emby-input kefin-vp-term-input" value="${escapeHtml(term)}"
data-list="${escapeHtml(listKey)}" data-index="${index}"
style="flex: 1; min-width: 0;" aria-label="Term ${index + 1}" />`;
const removeBtn = isNone
? ''
: `<button type="button" class="emby-button raised kefin-vp-remove" data-list="${escapeHtml(listKey)}" data-index="${index}"
title="Remove" style="min-width: auto; padding: 0.35em 0.5em;">
<span class="material-icons" style="font-size: 1.1em; vertical-align: middle;">delete</span>
</button>`;
return `
<div class="listItem kefin-vp-term-row${isNone ? ' kefin-vp-none-row' : ''}"
data-list="${escapeHtml(listKey)}" data-index="${index}" data-is-none="${isNone ? 'true' : 'false'}"
style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.5em 0.65em; margin-bottom: 0.4em; display: flex; align-items: center; gap: 0.5em;${isNone ? ' background: rgba(255,255,255,0.04);' : ''}">
<span class="listItemBodyText secondary" style="min-width: 1.5em; text-align: right;">${index + 1}.</span>
${termCell}
<button type="button" class="emby-button raised kefin-vp-move-up" data-list="${escapeHtml(listKey)}" data-index="${index}"
title="Move up" style="min-width: auto; padding: 0.35em 0.5em;" ${index === 0 ? 'disabled' : ''}>
<span class="material-icons" style="font-size: 1.1em; vertical-align: middle;">arrow_upward</span>
</button>
<button type="button" class="emby-button raised kefin-vp-move-down" data-list="${escapeHtml(listKey)}" data-index="${index}"
title="Move down" style="min-width: auto; padding: 0.35em 0.5em;">
<span class="material-icons" style="font-size: 1.1em; vertical-align: middle;">arrow_downward</span>
</button>
${removeBtn}
</div>
`;
}
function buildListSectionHTML(title, hint, listKey, terms) {
return `
<div class="listItem kefin-vp-list-section" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin: 0; min-width: 0;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.35em; font-weight: 500;">${escapeHtml(title)}</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.5em; font-size: 0.9em;">${escapeHtml(hint)}</div>
<div class="kefin-vp-term-list" data-list="${escapeHtml(listKey)}">
${terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join('')}
</div>
<div style="display: flex; gap: 0.5em; margin-top: 0.65em; flex-wrap: wrap;">
<input type="text" class="emby-input kefin-vp-add-input" data-list="${escapeHtml(listKey)}"
placeholder="Add a term…" style="flex: 1; min-width: 140px;" />
<button type="button" class="emby-button raised button-submit kefin-vp-add-btn" data-list="${escapeHtml(listKey)}">
Add
</button>
</div>
</div>
</div>
`;
}
function ensureLayoutStyles() {
if (document.getElementById('kefin-vp-config-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-vp-config-styles';
style.textContent = `
.kefin-vp-lists-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
align-items: start;
height: calc(100% - 40px);
}
@media (max-width: 899px) {
.kefin-vp-lists-grid {
grid-template-columns: 1fr;
}
}
[data-name="kefin-modal-content"]:has(.kefin-vp-lists-grid) > div {
height: 100% !important;
}
.listItem.kefin-vp-list-section {
align-items: start;
}
.kefin-vp-label-order-card {
display: flex;
align-items: center;
gap: 1em;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 0.85em 1em;
margin-bottom: 1em;
}
.kefin-vp-label-order-text {
flex: 1;
min-width: 0;
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch {
position: relative;
width: 52px;
height: 28px;
border-radius: 14px;
border: none;
background: rgba(158, 158, 158, 0.5);
cursor: pointer;
flex-shrink: 0;
padding: 0;
transition: background-color 0.3s ease;
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"] {
background: rgba(0, 164, 220, 0.8);
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch::after {
content: "";
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
top: 3px;
left: 3px;
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"]::after {
left: calc(100% - 25px);
}
`;
document.head.appendChild(style);
}
function getLabelOrderDescription(showVersionBeforeEdition) {
return showVersionBeforeEdition
? 'Version will be listed before the edition (1080p - Theatrical)'
: 'Edition will be listed before the version (Theatrical - 1080p)';
}
function buildLabelOrderToggleHTML(showVersionBeforeEdition) {
const enabled = showVersionBeforeEdition === true;
return `
<div class="kefin-vp-label-order-card">
<input type="checkbox" id="kefin-vp-show-version-before-edition" style="display: none;"
${enabled ? 'checked' : ''} />
<div class="kefin-vp-label-order-text">
<div class="listItemBodyText" style="font-weight: 500;">List Version Before Edition</div>
<div class="listItemBodyText secondary kefin-vp-label-order-desc" style="margin-top: 0.35em; font-size: 0.9em;">
${escapeHtml(getLabelOrderDescription(enabled))}
</div>
</div>
<button type="button" class="kefin-vp-toggle-switch" data-checkbox-id="kefin-vp-show-version-before-edition"
data-enabled="${enabled}" aria-pressed="${enabled}"></button>
</div>
`;
}
function updateLabelOrderToggleUI(root, checked) {
const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
const toggleBtn = root.querySelector('.kefin-vp-toggle-switch');
const desc = root.querySelector('.kefin-vp-label-order-desc');
if (checkbox) checkbox.checked = checked === true;
if (toggleBtn) {
toggleBtn.dataset.enabled = checked === true;
toggleBtn.setAttribute('aria-pressed', String(checked === true));
}
if (desc) desc.textContent = getLabelOrderDescription(checked === true);
}
function buildConfigHTML(config) {
return `
<div style="max-width: 100%;">
<div class="listItemBodyText secondary" style="margin-bottom: 1em;">
Manage how different versions and editions of your items are displayed.
Terms are matched as case-insensitive substrings of the media source label.
Higher list positions are prioritized first.
</div>
${buildLabelOrderToggleHTML(config.showVersionBeforeEdition)}
<div class="kefin-vp-lists-grid">
${buildListSectionHTML(
'Version Terms',
'Resolution / quality labels (e.g. 4k, 1080p).',
'versions',
config.versions
)}
${buildListSectionHTML(
'Edition Terms',
'Edition labels (e.g. Theatrical, Director\'s Cut).',
'editions',
config.editions
)}
</div>
</div>
`;
}
function readTermsFromDom(root, listKey) {
const rows = root.querySelectorAll(`.kefin-vp-term-row[data-list="${listKey}"]`);
const terms = [];
rows.forEach((row) => {
if (row.dataset.isNone === 'true') {
terms.push(NONE_SENTINEL);
return;
}
const input = row.querySelector('.kefin-vp-term-input');
const value = input?.value?.trim();
if (value) terms.push(value);
});
return ensureNoneSentinel(terms);
}
function syncStateFromDom(root, state) {
state.versions = readTermsFromDom(root, 'versions');
state.editions = readTermsFromDom(root, 'editions');
const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
state.showVersionBeforeEdition = checkbox?.checked === true;
}
function refreshList(root, state, listKey) {
const listEl = root.querySelector(`.kefin-vp-term-list[data-list="${listKey}"]`);
if (!listEl) return;
state[listKey] = ensureNoneSentinel(state[listKey] || []);
const terms = state[listKey];
listEl.innerHTML = terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join('');
const rows = listEl.querySelectorAll('.kefin-vp-term-row');
rows.forEach((row, i) => {
const down = row.querySelector('.kefin-vp-move-down');
if (down) down.disabled = i === rows.length - 1;
const up = row.querySelector('.kefin-vp-move-up');
if (up) up.disabled = i === 0;
});
}
function collectConfig(root) {
return {
versions: readTermsFromDom(root, 'versions'),
editions: readTermsFromDom(root, 'editions'),
showVersionBeforeEdition: root.querySelector('#kefin-vp-show-version-before-edition')?.checked === true
};
}
function bindLabelOrderToggle(root, state) {
root.addEventListener('click', (e) => {
const toggleBtn = e.target.closest('.kefin-vp-toggle-switch');
if (!toggleBtn || !root.contains(toggleBtn)) return;
e.preventDefault();
const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
if (!checkbox) return;
checkbox.checked = !checkbox.checked;
state.showVersionBeforeEdition = checkbox.checked;
updateLabelOrderToggleUI(root, checkbox.checked);
});
}
function bindListActions(root, state) {
root.addEventListener('click', (e) => {
const addBtn = e.target.closest('.kefin-vp-add-btn');
if (addBtn) {
const listKey = addBtn.dataset.list;
const input = root.querySelector(`.kefin-vp-add-input[data-list="${listKey}"]`);
const term = input?.value?.trim();
if (!term) return;
if (isNoneSentinel(term)) {
window.KefinTweaksToaster?.toast?.('"None" is a built-in priority slot and cannot be added as a term.');
if (input) input.value = '';
return;
}
syncStateFromDom(root, state);
state[listKey].push(term);
state[listKey] = ensureNoneSentinel(state[listKey]);
if (input) input.value = '';
refreshList(root, state, listKey);
return;
}
const removeBtn = e.target.closest('.kefin-vp-remove');
if (removeBtn) {
const listKey = removeBtn.dataset.list;
const index = parseInt(removeBtn.dataset.index, 10);
syncStateFromDom(root, state);
if (Number.isNaN(index)) return;
if (isNoneSentinel(state[listKey][index])) return;
state[listKey].splice(index, 1);
state[listKey] = ensureNoneSentinel(state[listKey]);
refreshList(root, state, listKey);
return;
}
const upBtn = e.target.closest('.kefin-vp-move-up');
if (upBtn) {
const listKey = upBtn.dataset.list;
const index = parseInt(upBtn.dataset.index, 10);
syncStateFromDom(root, state);
if (Number.isNaN(index) || index <= 0) return;
const list = state[listKey];
[list[index - 1], list[index]] = [list[index], list[index - 1]];
refreshList(root, state, listKey);
return;
}
const downBtn = e.target.closest('.kefin-vp-move-down');
if (downBtn) {
const listKey = downBtn.dataset.list;
const index = parseInt(downBtn.dataset.index, 10);
syncStateFromDom(root, state);
if (Number.isNaN(index) || index >= state[listKey].length - 1) return;
const list = state[listKey];
[list[index], list[index + 1]] = [list[index + 1], list[index]];
refreshList(root, state, listKey);
}
});
root.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const addInput = e.target.closest('.kefin-vp-add-input');
if (!addInput) return;
e.preventDefault();
const listKey = addInput.dataset.list;
const btn = root.querySelector(`.kefin-vp-add-btn[data-list="${listKey}"]`);
btn?.click();
});
}
async function openConfigModal() {
try {
ensureLayoutStyles();
const config = await loadConfig();
const state = {
versions: config.versions.slice(),
editions: config.editions.slice(),
showVersionBeforeEdition: config.showVersionBeforeEdition === true
};
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(state);
bindLabelOrderToggle(content, state);
bindListActions(content, state);
refreshList(content, state, 'versions');
refreshList(content, state, 'editions');
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-versionpreferences-config-btn">Save</button>
`;
window.ModalSystem.create({
id: MODAL_ID,
title: 'Item Version Preferences',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: window.innerWidth >= 900
? { maxWidth: '90vw', width: '1400px', height: '90vh' }
: undefined,
onOpen: (modal) => {
const saveBtn = modal.dialogFooter.querySelector('#save-versionpreferences-config-btn');
saveBtn?.addEventListener('click', async () => {
const root = modal.dialogContent || content;
syncStateFromDom(root, state);
const toSave = collectConfig(root);
const success = await saveConfig(toSave);
if (success) {
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.');
}
});
}
});
LOG('Version Preferences configuration modal opened');
} catch (error) {
ERR('Error opening version preferences config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.versionPreferences = { openConfigModal };
console.log('[KefinTweaks VersionPreferences Config] Script loaded');
})();
@@ -0,0 +1,127 @@
// KefinTweaks Watch Together Configuration UI
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks WatchTogether Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks WatchTogether Config]', ...args);
const MODAL_ID = 'kefin-watchtogether-config';
const DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return config.watchTogether || {};
}
async function saveConfig(watchTogetherConfig) {
try {
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
window.KefinTweaksConfig.watchTogether = watchTogetherConfig;
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
}
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;
}
}
function buildConfigHTML(config) {
const timeout = Number.isFinite(config.sessionTimeout * 1)
? config.sessionTimeout * 1
: DEFAULT_SESSION_TIMEOUT_MINUTES;
return `
<div style="max-width: 640px;">
<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;">Session timeout (minutes)</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
After selecting who is watching, skip the Watch Together prompt for this many minutes
when starting another item on this device. Playback that continues on the video OSD
(autoplay) never re-prompts.
</div>
<div class="hsae-field-wrap">
<input type="number" id="wt-session-timeout" class="fld emby-input"
min="1" step="1" value="${timeout}">
</div>
</div>
</div>
</div>
`;
}
function collectConfig() {
const raw = parseInt(document.querySelector('#wt-session-timeout')?.value || String(DEFAULT_SESSION_TIMEOUT_MINUTES), 10);
const sessionTimeout = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_TIMEOUT_MINUTES;
return { sessionTimeout };
}
async function openConfigModal() {
try {
const config = await loadConfig();
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
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-watchtogether-config-btn">Save</button>
`;
window.ModalSystem.create({
id: MODAL_ID,
title: 'Watch Together',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: { maxWidth: '90vw', width: '560px' },
onOpen: (modal) => {
const saveBtn = modal.dialogFooter.querySelector('#save-watchtogether-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const next = collectConfig();
const success = await saveConfig(next);
if (success) {
window.KefinTweaksToaster?.toast?.('Watch Together 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.');
}
});
}
}
});
LOG('Watch Together configuration modal opened');
} catch (error) {
ERR('Error opening Watch Together config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.watchTogether = { openConfigModal };
console.log('[KefinTweaks WatchTogether Config] Script loaded');
})();
+203
View File
@@ -0,0 +1,203 @@
// KefinTweaks Watchlist Configuration UI
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Watchlist Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Watchlist Config]', ...args);
const MODAL_ID = 'kefin-watchlist-config';
const DEFAULTS = {
sideMenu: true,
topNavigation: 'main',
userMenu: true,
order: 2
};
function normalizeConfig(raw) {
const cfg = raw && typeof raw === 'object' ? raw : {};
const orderRaw = parseInt(cfg.order, 10);
return {
sideMenu: cfg.sideMenu !== false,
topNavigation: typeof cfg.topNavigation === 'boolean'
? (cfg.topNavigation ? 'main' : 'none')
: cfg.topNavigation === undefined ? 'main' : cfg.topNavigation,
userMenu: cfg.userMenu !== false,
order: Number.isFinite(orderRaw) ? orderRaw : DEFAULTS.order
};
}
async function loadConfig() {
const config = window.KefinTweaksConfig || {};
return normalizeConfig(config.watchlist);
}
async function saveConfig(watchlistConfig) {
try {
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
window.KefinTweaksConfig.watchlist = watchlistConfig;
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
}
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;
}
}
function buildToggleCard(id, checked, label, description) {
if (typeof window.KefinTweaksUI?.buildToggleCard === 'function') {
return window.KefinTweaksUI.buildToggleCard(id, checked, label, description);
}
return `
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 0.75em;">
<label class="listItemBodyText" style="display:flex;align-items:center;gap:0.75em;">
<input type="checkbox" id="${id}" ${checked ? 'checked' : ''}>
<span>${label}</span>
</label>
${description ? `<div class="listItemBodyText secondary" style="margin-top:0.35em;font-size:0.9em;">${description}</div>` : ''}
</div>
`;
}
function buildConfigHTML(config) {
const order = Number.isFinite(config.order) ? config.order : DEFAULTS.order;
return `
<div style="max-width: 640px;">
${buildToggleCard(
'wl-side-menu',
config.sideMenu,
'Side menu',
'Show a Watchlist link in the left navigation drawer.'
)}
${buildToggleCard(
'wl-top-navigation',
config.topNavigation !== false && config.topNavigation !== 'none',
'Top navigation',
'Show Watchlist in the main top navigation bar (or via Custom Tabs when available).'
)}
${buildToggleCard(
'wl-user-menu',
config.userMenu,
'User menu',
'Show a Watchlist link in the user settings menu.'
)}
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 0.75em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Menu order</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">
Lower values appear earlier in the side menu and top navigation.
</div>
<div class="hsae-field-wrap">
<input type="number" id="wl-order" class="fld emby-input"
min="0" step="1" value="${order}">
</div>
</div>
</div>
</div>
`;
}
function collectConfig() {
const side = document.getElementById('wl-side-menu');
const top = document.getElementById('wl-top-navigation');
const user = document.getElementById('wl-user-menu');
const orderRaw = parseInt(document.getElementById('wl-order')?.value || String(DEFAULTS.order), 10);
return {
sideMenu: side ? side.checked : DEFAULTS.sideMenu,
topNavigation: top
? (top.checked ? 'main' : 'none')
: DEFAULTS.topNavigation,
userMenu: user ? user.checked : DEFAULTS.userMenu,
order: Number.isFinite(orderRaw) ? orderRaw : DEFAULTS.order
};
}
function attachToggleHandlers(root) {
if (typeof window.KefinTweaksUI?.bindToggleCards === 'function') {
window.KefinTweaksUI.bindToggleCards(root);
return;
}
root.querySelectorAll('.kefin-toggle-switch').forEach((btn) => {
if (btn.dataset.kefinToggleBound === 'true') return;
btn.dataset.kefinToggleBound = 'true';
btn.addEventListener('click', () => {
const next = btn.dataset.enabled !== 'true';
btn.dataset.enabled = String(next);
btn.setAttribute('aria-pressed', String(next));
const checkboxId = btn.dataset.checkboxId;
const checkbox = checkboxId ? document.getElementById(checkboxId) : null;
if (checkbox) checkbox.checked = next;
});
});
}
async function openConfigModal() {
try {
const config = await loadConfig();
const content = document.createElement('div');
content.innerHTML = buildConfigHTML(config);
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-watchlist-config-btn">Save</button>
`;
window.ModalSystem.create({
id: MODAL_ID,
title: 'Watchlist',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: { maxWidth: '90vw', width: '560px' },
onOpen: (modal) => {
attachToggleHandlers(modal.dialogContent || content);
const saveBtn = modal.dialogFooter.querySelector('#save-watchlist-config-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const next = collectConfig();
const success = await saveConfig(next);
if (success) {
window.KefinTweaksToaster?.toast?.('Watchlist 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.');
}
});
}
}
});
LOG('Watchlist configuration modal opened');
} catch (error) {
ERR('Error opening Watchlist config modal:', error);
alert('Error opening configuration: ' + error.message);
}
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.watchlist = { openConfigModal, DEFAULTS, normalizeConfig };
console.log('[KefinTweaks Watchlist Config] Script loaded');
})();
+292
View File
@@ -0,0 +1,292 @@
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks DataHelper]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks DataHelper]', ...args);
const ERR = (...args) => console.error('[KefinTweaks DataHelper]', ...args);
/**
* Ensure ApiClient is available before using helper methods
*/
function ensureApiClient() {
if (typeof ApiClient === 'undefined' || !ApiClient) {
throw new Error('ApiClient is not available');
}
}
/** @type {Map<string, { Genres: Array, Tags: Array, OfficialRatings: Array, Years: Array }>} */
const filtersMemoryCache = new Map();
function parseMajorVersion(version) {
if (!version || typeof version !== 'string') return 0;
const major = parseInt(version.split('.')[0], 10);
return Number.isNaN(major) ? 0 : major;
}
function getServerMajorVersion() {
ensureApiClient();
const version = ApiClient._serverVersion || ApiClient._appVersion || '';
return parseMajorVersion(version);
}
function normalizeIncludeItemTypesKey(includeItemTypes) {
if (includeItemTypes == null) return 'ALL';
const list = Array.isArray(includeItemTypes)
? includeItemTypes.map(t => String(t).trim()).filter(Boolean)
: String(includeItemTypes).split(',').map(s => s.trim()).filter(Boolean);
if (list.length === 0) return 'ALL';
return [...list].sort().join(',');
}
function buildIncludeItemTypesParam(includeItemTypes) {
const key = normalizeIncludeItemTypesKey(includeItemTypes);
if (key === 'ALL') return '';
return key;
}
async function unwrapGetQueryResult(result) {
if (!result) return null;
if (typeof result.ensureData === 'function') {
return await result.ensureData();
}
if (result.dataPromise != null) {
return await result.dataPromise;
}
return result;
}
function normalizeGenreEntry(entry) {
if (entry == null) return null;
if (typeof entry === 'string') {
const name = entry.trim();
return name ? { id: name, name } : null;
}
const id = entry.Id ?? entry.id;
const name = entry.Name ?? entry.name;
if (id != null && name != null) {
return { id: String(id), name: String(name) };
}
if (name != null) {
const n = String(name);
return { id: n, name: n };
}
return null;
}
function normalizeTagEntry(entry) {
if (entry == null) return null;
if (typeof entry === 'string') {
const name = entry.trim();
return name ? { id: name, name } : null;
}
const name = entry.Name ?? entry.name ?? entry.Id ?? entry.id;
if (name == null) return null;
const n = String(name);
return { id: n, name: n };
}
function normalizeFiltersPayload(raw) {
const genres = (Array.isArray(raw?.Genres) ? raw.Genres : [])
.map(normalizeGenreEntry)
.filter(Boolean);
const tags = (Array.isArray(raw?.Tags) ? raw.Tags : [])
.map(normalizeTagEntry)
.filter(Boolean);
return {
Genres: genres,
Tags: tags,
OfficialRatings: Array.isArray(raw?.OfficialRatings) ? raw.OfficialRatings : [],
Years: Array.isArray(raw?.Years) ? raw.Years : []
};
}
function buildFiltersUrl(serverAddress, userId, includeItemTypes) {
const typesParam = buildIncludeItemTypesParam(includeItemTypes);
const useFilters2 = getServerMajorVersion() >= 12;
const path = useFilters2 ? '/Items/Filters2' : '/Items/Filters';
const params = new URLSearchParams({ UserId: userId });
if (useFilters2) {
params.set('recursive', 'true');
}
if (typesParam) {
params.set('IncludeItemTypes', typesParam);
}
return `${serverAddress}${path}?${params.toString()}`;
}
/**
* API Helper functions for Jellyfin operations
*/
const dataHelper = {
getLibraries: async function() {
ensureApiClient();
if (__libraries?.Items) {
return __libraries.Items;
}
const response = await ApiClient.getItems();
return response?.Items ?? [];
},
/**
* Get filter data (Genres, Tags, OfficialRatings, Years) for specified item types
* @param {string|Array<string>} includeItemTypes - Item types to filter (e.g., 'Movie', ['Movie', 'Episode'])
* @param {boolean} useCache - Whether to use cached data (default: true)
* @param {boolean} forceRefresh - Whether to bypass cache (default: false)
* @returns {Promise<Object>} - Object with Genres, Tags, OfficialRatings, Years arrays
*/
getFilters: async function(includeItemTypes, useCache = true, forceRefresh = false) {
ensureApiClient();
if (!window.apiHelper) {
throw new Error('apiHelper is not available');
}
const userId = ApiClient.getCurrentUserId();
const serverAddress = ApiClient.serverAddress();
if (!userId || !serverAddress) {
throw new Error('User ID or server address not available');
}
const cacheKey = normalizeIncludeItemTypesKey(includeItemTypes);
if (forceRefresh) {
filtersMemoryCache.delete(cacheKey);
}
if (useCache && !forceRefresh && filtersMemoryCache.has(cacheKey)) {
return filtersMemoryCache.get(cacheKey);
}
const url = buildFiltersUrl(serverAddress, userId, includeItemTypes);
const cacheOptions = {
useCache: useCache && !forceRefresh,
ttl: 24 * 60 * 60 * 1000,
forceRefresh: forceRefresh
};
const result = await window.apiHelper.getQuery(url, cacheOptions);
const raw = await unwrapGetQueryResult(result);
const normalized = normalizeFiltersPayload(raw);
filtersMemoryCache.set(cacheKey, normalized);
return normalized;
},
getFilterGenreItems: async function(includeItemTypes, options = {}) {
const { useCache = true, forceRefresh = false } = options;
const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh);
return filters.Genres || [];
},
getFilterTagItems: async function(includeItemTypes, options = {}) {
const { useCache = true, forceRefresh = false } = options;
const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh);
return filters.Tags || [];
},
/**
* Get watched or in progress movies from Jellyfin API
* @returns {Promise<Array>} - Array of movies
*/
getWatchedOrInProgressMovies: async function(options = {}) {
const { watchedMoviesResult, inProgressMoviesResult } = await Promise.all([this.getWatchedMovies(options), this.getInProgressMovies(options)]);
if (options.useCache) {
const watchedMovies = watchedMoviesResult.data.Items;
const inProgressMovies = inProgressMoviesResult.data.Items;
let isStale = Promise.all([watchedMoviesResult.isStalePromise, inProgressMoviesResult.isStalePromise]).then(([isWatchedStale, isInProgressStale]) => {
return isStale = isWatchedStale || isInProgressStale ? true : false;
});
let dataPromise = Promise.all([watchedMoviesResult.dataPromise, inProgressMoviesResult.dataPromise]).then(([watchedMoviesData, inProgressMoviesData]) => {
return {
data: {
Items: [...watchedMoviesData.Items, ...inProgressMoviesData.Items]
}
};
});
return {
data: {
Items: [...watchedMovies, ...inProgressMovies]
},
isStalePromise: isStalePromise,
dataPromise: dataPromise
};
}
// Otherwise, use the fetched data arrays directly
return {
Items: [...watchedMovies.Items, ...inProgressMovies.Items]
};
},
/**
* Get watched movies from Jellyfin API
* @param {Object} options - Options
* @param {boolean} options.useCache - Whether to use cache (default: false)
* @returns {Promise<Array>|Array} - Array of movies
*/
getWatchedMovies: async function(options = {}) {
// default TTL is 1 day
const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options;
const serverUrl = ApiClient.serverAddress();
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
// Use apiHelper.getQuery to get the data
if (!window.apiHelper) {
ERR('apiHelper is not available');
return [];
}
return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl });
},
getInProgressMovies: async function(options = {}) {
// default TTL is 1 day
const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options;
const serverUrl = ApiClient.serverAddress();
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsResumable&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
if (!window.apiHelper) {
ERR('apiHelper is not available');
return [];
}
return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl });
},
getCachedLibraries: function() {
return __libraries?.Items ? __libraries.Items : null;
}
};
async function initialize() {
ensureApiClient();
// Wait for login
if (window.userHelper?.waitForLogin) {
await window.userHelper.waitForLogin();
}
if (__libraries) {
return;
}
const libraries = await ApiClient.getItems();
__libraries = libraries;
}
let __libraries = null;
initialize();
// Expose dataHelper to global window object
window.dataHelper = dataHelper;
console.log('[KefinTweaks DataHelper] Module loaded and available at window.dataHelper');
})();
+265
View File
@@ -0,0 +1,265 @@
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks WebSocketHelper]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks WebSocketHelper]', ...args);
const ERR = (...args) => console.error('[KefinTweaks WebSocketHelper]', ...args);
// Registry of event listeners: MessageType -> array of callbacks
const listeners = {};
// messageType -> unsubscribe from ApiClient.subscribe
const subscribeUnsubs = {};
// messageType -> whether we already attached a subscribe fan-out
const subscribeAttached = {};
let hookedSocket = null;
let hookedOriginalOnMessage = null;
let pollingIntervalId = null;
let deliveryMode = null; // 'subscribe' | 'onmessage' | null
const POLL_INTERVAL_MS = 1500;
function getApiClient() {
return window.ApiClient || null;
}
function getSocket() {
const api = getApiClient();
if (!api) return null;
return api.webSocket || api._webSocket || null;
}
function canSubscribe() {
const api = getApiClient();
return !!(api && typeof api.subscribe === 'function');
}
function dispatchMessage(data) {
if (!data || typeof data !== 'object') return;
const messageType = data.MessageType;
if (!messageType || !listeners[messageType]) return;
listeners[messageType].forEach((callback) => {
try {
callback(data);
} catch (err) {
ERR(`Error in WebSocket listener for ${messageType}:`, err);
}
});
}
function ensureSubscribeForType(messageType) {
if (subscribeAttached[messageType]) return true;
if (!canSubscribe()) return false;
try {
const api = getApiClient();
const unsub = api.subscribe([messageType], (msg) => {
// SDK/subscribe may pass the outbound message object directly
const data = msg?.MessageType ? msg : { MessageType: messageType, Data: msg?.Data ?? msg };
if (!data.MessageType) data.MessageType = messageType;
dispatchMessage(data);
});
subscribeAttached[messageType] = true;
subscribeUnsubs[messageType] = typeof unsub === 'function' ? unsub : null;
deliveryMode = 'subscribe';
// Drop any legacy onmessage hook immediately — do not wait for the poll tick
unhookSocket();
LOG(`Subscribed via ApiClient.subscribe for ${messageType}`);
return true;
} catch (err) {
ERR(`ApiClient.subscribe failed for ${messageType}:`, err);
return false;
}
}
function ensureSubscribeForAllRegisteredTypes() {
if (!canSubscribe()) return false;
let any = false;
Object.keys(listeners).forEach((messageType) => {
if (listeners[messageType]?.length) {
if (ensureSubscribeForType(messageType)) any = true;
}
});
return any;
}
function unhookSocket() {
if (hookedSocket && hookedOriginalOnMessage !== undefined) {
try {
hookedSocket.onmessage = hookedOriginalOnMessage;
} catch (e) {
// ignore
}
}
hookedSocket = null;
hookedOriginalOnMessage = null;
}
/**
* Legacy path: wrap socket.onmessage, re-bind when the socket instance changes.
* Only used when ApiClient.subscribe is unavailable.
*/
function hookSocketIfNeeded() {
if (canSubscribe()) return false;
const socket = getSocket();
if (!socket) return false;
if (hookedSocket === socket) return true;
unhookSocket();
try {
const originalHandler = socket.onmessage;
hookedOriginalOnMessage = originalHandler;
hookedSocket = socket;
socket.onmessage = function(event) {
// Keep JF handler isolated so its throws do not look like parse failures
// and do not block KefinTweaks listener delivery.
if (originalHandler) {
try {
originalHandler.call(this, event);
} catch (err) {
WARN('Original WebSocket onmessage handler error:', err);
}
}
try {
const messageData = event.Data || event.data;
const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
dispatchMessage(data);
} catch (err) {
WARN('WebSocket message parse error:', err);
}
};
deliveryMode = 'onmessage';
LOG('WebSocket onmessage hook attached (legacy fallback)', {
readyState: socket.readyState,
});
return true;
} catch (err) {
ERR('Error hooking WebSocket onmessage:', err);
hookedSocket = null;
hookedOriginalOnMessage = null;
return false;
}
}
function tryInitializeDelivery() {
// Prefer JF-web style subscribe (reconnect-safe). Never wrap onmessage when subscribe exists.
if (canSubscribe()) {
return ensureSubscribeForAllRegisteredTypes();
}
// Fallback: raw socket onmessage with rebind support
return hookSocketIfNeeded();
}
function stopPolling() {
if (pollingIntervalId == null) return;
clearInterval(pollingIntervalId);
pollingIntervalId = null;
}
function startPolling() {
if (pollingIntervalId != null) return;
if (tryInitializeDelivery()) {
// Still poll in legacy mode so we rebind if socket is replaced
if (deliveryMode === 'subscribe') return;
}
pollingIntervalId = setInterval(() => {
if (canSubscribe()) {
if (ensureSubscribeForAllRegisteredTypes() && deliveryMode === 'subscribe') {
unhookSocket();
stopPolling();
LOG('Delivery mode: ApiClient.subscribe');
return;
}
// subscribe exists but types not attached yet — keep polling, never hook onmessage
return;
}
const socket = getSocket();
if (!socket) return;
if (hookedSocket !== socket) {
hookSocketIfNeeded();
}
}, POLL_INTERVAL_MS);
}
/**
* Register a callback to listen for a specific WebSocket message type
* @param {string} messageType - e.g. 'UserDataChanged', 'LibraryChanged'
* @param {Function} callback - Receives the parsed message object
*/
function listen(messageType, callback) {
if (typeof messageType !== 'string' || !messageType) {
ERR('listen() requires a non-empty string messageType');
return;
}
if (typeof callback !== 'function') {
ERR('listen() requires a callback function');
return;
}
if (!listeners[messageType]) {
listeners[messageType] = [];
}
if (listeners[messageType].includes(callback)) {
LOG(`Listener already registered for message type: ${messageType}`);
startPolling();
return;
}
listeners[messageType].push(callback);
LOG(`Registered listener for message type: ${messageType} (${listeners[messageType].length} total)`, {
deliveryMode: deliveryMode || 'pending',
canSubscribe: canSubscribe(),
});
if (canSubscribe()) {
ensureSubscribeForType(messageType);
}
startPolling();
}
/**
* Remove a callback registered with listen().
* @param {string} messageType
* @param {Function} callback
*/
function unlisten(messageType, callback) {
if (!listeners[messageType]) return;
listeners[messageType] = listeners[messageType].filter((cb) => cb !== callback);
LOG(`Unregistered listener for message type: ${messageType} (${listeners[messageType].length} remaining)`);
if (!listeners[messageType].length && subscribeUnsubs[messageType]) {
try {
subscribeUnsubs[messageType]();
} catch (e) {
WARN(`Failed to unsubscribe ${messageType}:`, e);
}
delete subscribeUnsubs[messageType];
delete subscribeAttached[messageType];
}
}
window.websocketHelper = {
listen,
unlisten,
/** @returns {'subscribe'|'onmessage'|null} */
getDeliveryMode() {
return deliveryMode;
},
};
// Do not startPolling() here — wait until the first listen() so we never
// wrap JF's socket before any consumer needs delivery.
})();
+1099 -252
View File
File diff suppressed because it is too large Load Diff
+42 -374
View File
@@ -1,15 +1,20 @@
window.KefinTweaksDefaultConfig = {
"kefinTweaksRoot": "",
"enabled": true,
"scripts": {
"watchlist": true,
"homeScreen": true,
"search": true,
"watchlist": true,
"headerTabs": true,
"customMenuLinks": true,
"hamburgerMenu": false,
"games": true,
"breadcrumbs": true,
"playlist": true,
"itemDetailsCollections": true,
"flattenSingleSeasonShows": true,
"seriesInfo": true,
"versionPreferences": true,
"collections": true,
"exclusiveElsewhere": false,
"backdropLeakFix": true,
@@ -17,378 +22,10 @@ window.KefinTweaksDefaultConfig = {
"infiniteScroll": true,
"removeContinue": true,
"subtitleSearch": true,
"skinManager": true
},
"homeScreen": {
"defaultItemLimit": 16,
"defaultSortOrder": "Random",
"defaultCardFormat": "Poster",
"recentlyAddedInLibrary": {},
"recentlyReleased": {
"enabled": true,
"order": 30,
"movies": {
"name": "Recently Released Movies",
"enabled": true,
"itemLimit": 16,
"sortOrder": "ReleaseDate",
"sortOrderDirection": "Descending",
"cardFormat": "Poster",
"order": 30,
"minAgeInDays": null,
"maxAgeInDays": null
},
"episodes": {
"name": "Recently Aired Episodes",
"enabled": true,
"itemLimit": 16,
"sortOrder": "PremiereDate",
"sortOrderDirection": "Descending",
"cardFormat": "Backdrop",
"order": 31,
"minAgeInDays": null,
"maxAgeInDays": null
}
},
"trending": {
"name": "Trending",
"enabled": false,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 32
},
"popularTVNetworks": {
"name": "Popular TV Networks",
"enabled": true,
"minimumShowsForNetwork": 5,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 61
},
"watchlist": {
"name": "Watchlist",
"enabled": true,
"itemLimit": 16,
"sortOrder": "DateAdded",
"sortOrderDirection": "Descending",
"cardFormat": "Poster",
"order": 60
},
"watchAgain": {
"name": "Watch Again",
"enabled": true,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 62
},
"upcoming": {
"name": "Upcoming",
"enabled": true,
"itemLimit": 48,
"cardFormat": "Thumb",
"order": 20
},
"imdbTop250": {
"name": "IMDb Top 250",
"enabled": true,
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 21
},
"seasonal": {
"enabled": true,
"enableSeasonalAnimations": true,
"enableSeasonalBackground": true,
"defaultItemLimit": 16,
"defaultSortOrder": "Random",
"defaultCardFormat": "Poster",
"seasons": [
{
"id": "halloween",
"name": "Halloween",
"enabled": true,
"startDate": "10-01",
"endDate": "10-31",
"sections": [
{
"id": "halloween-tag",
"enabled": true,
"name": "Halloween Movies",
"type": "Tag",
"source": "halloween",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 50
},
{
"id": "halloween-horror",
"enabled": true,
"name": "Horror Genre",
"type": "Genre",
"source": "Horror",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 51
},
{
"id": "halloween-thriller",
"enabled": true,
"name": "Thriller Genre",
"type": "Genre",
"source": "Thriller",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 52
}
]
},
{
"id": "thanksgiving",
"name": "Thanksgiving",
"enabled": true,
"startDate": "11-20",
"endDate": "11-30",
"order": 50,
"sections": [
{
"id": "seasonal-2-section-0",
"enabled": true,
"name": "Thanksgiving Movies",
"type": "Tag",
"source": "thanksgiving",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 50,
"spotlight": true,
"discoveryEnabled": false,
"searchTerm": "",
"includeItemTypes": [
"Movie"
]
},
{
"id": "seasonal-2-section-1",
"enabled": true,
"name": "Thanksgiving Episodes",
"type": "Parent",
"source": "",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Thumb",
"order": 51,
"spotlight": false,
"discoveryEnabled": false,
"searchTerm": "thanksgiving",
"includeItemTypes": [
"Episode"
]
}
]
},
{
"id": "christmas",
"name": "Christmas",
"enabled": true,
"startDate": "12-01",
"endDate": "12-31",
"sections": [
{
"id": "seasonal-1-section-0",
"enabled": true,
"name": "Christmas Movies",
"type": "Tag",
"source": "christmas",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"order": 50,
"spotlight": true,
"discoveryEnabled": false,
"searchTerm": "",
"includeItemTypes": [
"Movie"
],
"additionalQueryOptions": []
},
{
"id": "seasonal-1-section-1",
"enabled": true,
"name": "Christmas Episodes",
"type": "Parent",
"source": "",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Thumb",
"order": 51,
"spotlight": false,
"discoveryEnabled": false,
"searchTerm": "christmas",
"includeItemTypes": [
"Episode"
],
"additionalQueryOptions": []
}
]
}
]
},
"discovery": {
"enabled": true,
"infiniteScroll": true,
"minPeopleAppearances": 10,
"minGenreMovieCount": 50,
"defaultItemLimit": 16,
"defaultSortOrder": "Random",
"defaultCardFormat": "Poster",
"sectionTypes": {
"spotlightGenre": {
"enabled": true,
"name": "Top Rated [Genre] Movies",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"spotlightNetwork": {
"enabled": true,
"name": "Top Rated Shows from [Studio]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"genreMovies": {
"enabled": true,
"name": "[Genre] Movies",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"studioShows": {
"enabled": true,
"name": "Shows from [Studio]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"collections": {
"enabled": true,
"name": "[Collection Name]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster",
"minimumItems": 10
},
"becauseYouWatched": {
"enabled": true,
"name": "Because you watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"becauseYouLiked": {
"enabled": true,
"name": "Because you liked [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"starringTopActor": {
"enabled": true,
"name": "Starring [Actor]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"directedByTopDirector": {
"enabled": true,
"name": "Directed by [Director]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"writtenByTopWriter": {
"enabled": true,
"name": "Written by [Writer]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"becauseYouRecentlyWatched": {
"enabled": true,
"name": "Because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"starringActorRecentlyWatched": {
"enabled": true,
"name": "Starring [Actor] because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"directedByDirectorRecentlyWatched": {
"enabled": true,
"name": "Directed by [Director] because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
},
"writtenByWriterRecentlyWatched": {
"enabled": true,
"name": "Written by [Writer] because you recently watched [Movie]",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
"cardFormat": "Poster"
}
},
"spotlightDiscoveryChance": 0.5,
"renderSpotlightAboveMatching": false,
"spotlight": {
"autoPlay": true,
"interval": 5000,
"showDots": true,
"showNavButtons": true,
"showClearArt": true
},
"randomizeOrder": false
},
"customSections": []
},
"exclusiveElsewhere": {
"hideServerName": false
"skinManager": true,
"thumbnailScrubber": false,
"watchTogether": false,
"userManager": false
},
"search": {
"enableJellyseerr": false
@@ -396,9 +33,40 @@ window.KefinTweaksDefaultConfig = {
"flattenSingleSeasonShows": {
"hideSingleSeasonContainer": false
},
"seriesInfo": {
"use24HourTimeFormat": false
},
"versionPreferences": {
"versions": ["4k", "2160p", "1080p", "720p", "576p", "480p", "360p", "__none__"],
"editions": ["Theatrical", "Director's Cut", "Extended Edition", "Fan Edit", "__none__"],
"showVersionBeforeEdition": false
},
"thumbnailScrubber": {
"PreviewOnHover": false,
"UseVideoStream": true,
"FallbackAfterMs": 500,
"HOVER_PREVIEW_TIMEOUT_MS": 800,
"HOVER_TIMEOUT": 800,
"HOVER_PREVIEW_FRAME_MS": 250,
"SCRUB_ACTIVATION_DELAY_MS": 300
},
"watchTogether": {
"sessionTimeout": 30
},
"userManager": {
"defaultTemplateName": "",
"templates": []
},
"exclusiveElsewhere": {
"hideServerName": false
},
"skins": [],
"defaultSkin": null,
"themes": [],
"customMenuLinks": [],
"optionalIncludes": []
"optionalIncludes": [],
"updateState": {
"dismissed": true,
"latestVersion": ""
}
};
+517 -82
View File
@@ -33,6 +33,9 @@
// Check if user is admin
async function isAdmin() {
// Wait for user to be logged in
await waitForLoginForInjectorWrite();
try {
if (window.ApiClient && window.ApiClient.getCurrentUser) {
const user = await window.ApiClient.getCurrentUser();
@@ -59,6 +62,11 @@
}
});
// Non-admins cannot list plugins (HTTP 403) — treat as no access, not a hard failure
if (response.status === 403 || response.status === 401) {
return null;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
@@ -84,7 +92,7 @@
// Get current JavaScript Injector configuration
async function getJavaScriptInjectorConfig(pluginId) {
try {
if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
if (!pluginId || !window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
return null;
}
@@ -96,6 +104,10 @@
}
});
if (response.status === 403 || response.status === 401) {
return null;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
@@ -190,7 +202,15 @@
async function saveConfigToJavaScriptInjector(config) {
try {
const pluginId = await findJavaScriptInjectorPlugin();
if (!pluginId) {
console.warn('[KefinTweaks Installer] Cannot save config: JavaScript Injector plugin unavailable (non-admin or missing)');
return false;
}
const injectorConfig = await getJavaScriptInjectorConfig(pluginId);
if (!injectorConfig) {
console.warn('[KefinTweaks Installer] Cannot save config: could not read JS Injector configuration');
return false;
}
if (!injectorConfig.CustomJavaScripts) {
injectorConfig.CustomJavaScripts = [];
@@ -313,6 +333,223 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
}
function normalizeKefinRootUrl(root) {
if (!root || typeof root !== 'string') return '';
return root.endsWith('/') ? root : root + '/';
}
function isFloatingKefinRoot(root) {
return /@(latest|main|master|experimental)(\/|$)/i.test(String(root || ''));
}
/** Human label for a newly pinned resolved root (channel-aware). */
function formatResolvedUpdateLabel(symbolicRoot, resolvedRoot) {
const symbolic = String(symbolicRoot || '');
const resolved = String(resolvedRoot || '');
const verMatch = resolved.match(/@v?(\d+\.\d+\.\d+)/i);
if (verMatch) {
return `v${verMatch[1]}`;
}
const shaMatch = resolved.match(/@([0-9a-f]{7,40})(?:\/|$)/i);
const shortSha = shaMatch ? shaMatch[1].substring(0, 7).toLowerCase() : null;
if (/@experimental(\/|$)/i.test(symbolic)) {
return shortSha ? `Experimental (#${shortSha})` : 'Experimental';
}
if (/@(main|master)(\/|$)/i.test(symbolic)) {
return shortSha ? `Dev (#${shortSha})` : 'Development';
}
if (shortSha) {
return `Dev (#${shortSha})`;
}
return 'a new version';
}
function flushPendingRootUpdateToast() {
const pending = window.__kefinPendingRootUpdateToast;
if (!pending?.message) return false;
if (typeof window.KefinTweaksToaster?.toast !== 'function') return false;
window.__kefinPendingRootUpdateToast = null;
try {
// Sticky when a forced tip-reload countdown is active; otherwise auto-hide.
const sticky = !!pending.sticky || !!window.__kefinTipReloadScheduled;
window.KefinTweaksToaster.toast(pending.message, pending.duration || '5', !sticky);
} catch (e) {
console.warn('[KefinTweaks Installer] Failed to show root-update toast:', e);
return false;
}
return true;
}
function queueRootUpdateToast(message, options = null) {
if (!message) return;
window.__kefinPendingRootUpdateToast = {
message,
sticky: !!(options && options.sticky),
duration: (options && options.duration) || '5'
};
if (flushPendingRootUpdateToast()) return;
const start = Date.now();
const poll = () => {
if (flushPendingRootUpdateToast()) return;
if (Date.now() - start > 30000) return;
setTimeout(poll, 250);
};
setTimeout(poll, 250);
}
/**
* After tip re-pin is persisted to disk, force reload so Config/bake/scripts stay aligned.
* Single-flight per page session. Countdown updates the same toast in place (no reopen).
*/
function scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved }) {
if (window.__kefinTipReloadScheduled) return;
window.__kefinTipReloadScheduled = true;
const label = formatResolvedUpdateLabel(symbolicRoot, nextResolved);
let remaining = 5;
const msg = (n) => `KefinTweaks updated to ${label} — refreshing in ${n}…`;
let toastHandle = null;
const setToastMessage = (el, text) => {
if (!el) return;
const messageEl = el.querySelector('.kefin-toast-message');
if (messageEl) messageEl.textContent = text;
else el.textContent = text;
};
const ensureToast = () => {
if (toastHandle?.element?.isConnected) return toastHandle;
if (typeof window.KefinTweaksToaster?.toast === 'function') {
toastHandle = window.KefinTweaksToaster.toast(msg(remaining), '6', false);
return toastHandle;
}
// Toaster not ready yet — queue once; flush will show sticky toast
queueRootUpdateToast(msg(remaining), { sticky: true, duration: '6' });
return null;
};
const update = (n) => {
const handle = ensureToast();
if (handle?.element?.isConnected) {
setToastMessage(handle.element, msg(n));
return;
}
// Pending toast may have been flushed by toaster init — adopt it if present
const existing = document.querySelector('.toastContainer .toast');
if (existing) {
toastHandle = { element: existing, dismiss: () => {} };
setToastMessage(existing, msg(n));
}
};
update(remaining);
const tick = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(tick);
try {
window.location.reload();
} catch (e) {
console.warn('[KefinTweaks Installer] Reload after tip update failed:', e);
}
return;
}
update(remaining);
}, 1000);
}
/**
* After a floating root's resolved URL was persisted (skip first pin): countdown + reload.
*/
function maybeQueueResolvedRootUpdateToast({ symbolicRoot, previousResolved, nextResolved, persisted }) {
if (!persisted) return;
if (!isFloatingKefinRoot(symbolicRoot)) return;
const previous = normalizeKefinRootUrl(previousResolved);
const next = normalizeKefinRootUrl(nextResolved);
if (!previous || !next || previous === next) return;
scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved: next });
}
/**
* Admin + bake already on page: tip resolve/persist/sync without loading live injector.js.
* Tip advanced → persist Config+bake then forced reload. Tip unchanged → stamp sync only.
*/
async function handleAdminAttachedBakeTipSync(config) {
const symbolic = config?.kefinTweaksRoot || '';
if (!symbolic) return;
await waitForLoginForInjectorWrite();
const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || '');
let tip = normalizeKefinRootUrl(symbolic);
if (isFloatingKefinRoot(symbolic)) {
tip = normalizeKefinRootUrl(await resolveRootVersion(symbolic));
}
if (tip && tip !== current) {
console.log('[KefinTweaks Installer] Tip advanced while bake attached:', current || '(none)', '->', tip);
config.kefinTweaksRootResolved = tip;
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = config;
} else {
window.KefinTweaksConfig.kefinTweaksRootResolved = tip;
}
try {
const persisted = await saveConfigToJavaScriptInjector(config);
if (persisted) {
scheduleForcedTipReloadCountdown({ symbolicRoot: symbolic, nextResolved: tip });
console.log('[KefinTweaks Installer] Tip persist+bake ok; scheduling reload');
} else {
console.warn('[KefinTweaks Installer] Tip advanced but persist failed; not reloading');
}
} catch (e) {
console.error('[KefinTweaks Installer] Tip persist failed; not reloading:', e);
}
return;
}
// Tip unchanged — stamp sync only (no live inject, no reload)
try {
const loadRoot = current || tip;
if (!loadRoot) return;
await loadKefinTweaksLoaderFromRoot(loadRoot);
const Loader = window.KefinTweaksLoader;
if (!Loader || typeof Loader.syncKefinTweaksInjector !== 'function') {
console.log('[KefinTweaks Installer] Loader sync unavailable; bake attached, tip unchanged');
return;
}
Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
let major = null;
try {
major = await window.KefinTweaks.getJellyfinMajorVersion();
} catch (e) { /* ignore */ }
const planRoot = Loader.getResolvedKefinRoot
? Loader.getResolvedKefinRoot(config)
: loadRoot;
const plan = Loader.buildLoadPlan(config, major, {
root: planRoot,
configOnly: config.enabled === false
});
const syncResult = await Loader.syncKefinTweaksInjector(plan);
console.log(
'[KefinTweaks Installer] Attached bake stamp sync:',
syncResult?.synced ? ('synced ' + syncResult.stamp) : (syncResult?.reason || 'done')
);
} catch (e) {
console.warn('[KefinTweaks Installer] Attached bake stamp sync failed:', e);
}
}
function isKefinInjectorAttachedOnPage() {
return !!(
window.KefinTweaksInjectorAttached
|| window.KefinTweaksScriptsPreloaded
|| window.KefinTweaksInjectorStamp
);
}
async function waitForLoginForInjectorWrite(maxWaitMs = 10000, checkInterval = 250) {
if (isLoggedInForInjectorWrite()) return true;
const start = Date.now();
@@ -337,20 +574,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return { ok: false, reason: 'not logged in' };
}
try {
const normalize = (r) => {
if (!r || typeof r !== 'string') return '';
return r.endsWith('/') ? r : r + '/';
};
if (!(await isAdmin())) {
return { ok: false, reason: 'not admin' };
}
const expected = normalize(await resolveRootVersion(rootRaw));
const current = normalize(config.kefinTweaksRootResolved || '');
try {
const expected = normalizeKefinRootUrl(await resolveRootVersion(rootRaw));
const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || '');
const resolvedRootUpdated = !!(current && expected && current !== expected);
if (!current || current !== expected) {
config.kefinTweaksRootResolved = expected;
console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected);
}
const loadRoot = normalize(config.kefinTweaksRootResolved || rootRaw);
const loadRoot = normalizeKefinRootUrl(config.kefinTweaksRootResolved || rootRaw);
await loadKefinTweaksLoaderFromRoot(loadRoot);
const Loader = window.KefinTweaksLoader;
if (!Loader) {
@@ -411,6 +648,15 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
};
}
if (resolvedRootUpdated) {
maybeQueueResolvedRootUpdateToast({
symbolicRoot: rootRaw,
previousResolved: current,
nextResolved: expected,
persisted: true
});
}
console.log('[KefinTweaks Installer] Auto-created KefinTweaks-injector with', plan.assets.length, 'assets');
return { ok: true, plan };
} catch (err) {
@@ -431,8 +677,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const LATEST_RELEASE_NAME = 'Latest';
const DEVELOPMENT_NAME = 'Development';
const EXPERIMENTAL_NAME = 'Experimental';
const SPECIFIC_BUILD_NAME = 'Specific Build';
const SELF_HOSTED_NAME = 'Self Hosted';
function isFullCommitSha(value) {
return typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value.trim());
}
// Parse source URL to determine source type and version
function parseKefinTweaksSource(url) {
if (!url || url === '') {
@@ -452,9 +703,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return { sourceType: 'github', version: DEVELOPMENT_NAME };
} else if (version === 'experimental') {
return { sourceType: 'github', version: EXPERIMENTAL_NAME };
} else if (version && version.length === 40 && /^[0-9a-f]+$/.test(version)) {
// Commit hash URL (e.g. from a previous Experimental selection)
return { sourceType: 'github', version: EXPERIMENTAL_NAME };
} else if (isFullCommitSha(version)) {
return { sourceType: 'github', version: SPECIFIC_BUILD_NAME, commit: version.toLowerCase() };
} else {
return { sourceType: 'github', version: version.replace(/^v/, '') };
}
@@ -477,16 +727,16 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
.map(release => release.tag_name.replace(/^v/, ''))
.filter(tag => tag.match(/^\d+\.\d+\.\d+/)); // Only semantic versions
versionsCache = [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, ...versions];
versionsCache = [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME, ...versions];
return versionsCache;
} catch (error) {
console.warn('[KefinTweaks Installer] Error fetching versions:', error);
return [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME];
return [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME];
}
}
// Build kefinTweaksRoot URL from selections
function buildKefinTweaksRootUrl(sourceType, source) {
function buildKefinTweaksRootUrl(sourceType, source, commitHash) {
if (sourceType === 'custom') {
// Ensure URL ends with /
return source.endsWith('/') ? source : source + '/';
@@ -498,6 +748,12 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
versionTag = 'main';
} else if (source === EXPERIMENTAL_NAME) {
versionTag = 'experimental';
} else if (source === SPECIFIC_BUILD_NAME) {
const sha = String(commitHash || '').trim().toLowerCase();
if (!isFullCommitSha(sha)) {
throw new Error('Specific Build requires a full 40-character commit hash');
}
versionTag = sha;
} else if (source !== LATEST_RELEASE_NAME) {
versionTag = source.startsWith('v') ? source : 'v' + source;
}
@@ -505,6 +761,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return `${JSDELIVR_BASE}@${versionTag}/`;
}
function formatSpecificBuildDisplay(sourceInfo) {
const sha = sourceInfo?.commit || '';
return sha
? `${SPECIFIC_BUILD_NAME} (#${sha.slice(0, 7)})`
: SPECIFIC_BUILD_NAME;
}
// Simple modal creation (doesn't depend on modal.js)
function createSimpleModal(id, title, content, footer) {
// Remove existing modal
@@ -786,7 +1049,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const content = document.createElement('div');
content.style.display = 'flex';
content.style.flexDirection = 'column';
content.style.gap = '1.5em';
content.style.gap = '0.5em';
// Enabled checkbox
const isEnabled = currentConfig?.enabled !== false;
@@ -794,6 +1057,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
enabledContainer.style.display = 'flex';
enabledContainer.style.alignItems = 'center';
enabledContainer.style.gap = '0.75em';
enabledContainer.style.marginBottom = '0.75em';
const enabledCheckbox = document.createElement('input');
enabledCheckbox.type = 'checkbox';
@@ -807,7 +1071,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
enabledLabel.style.margin = '0';
enabledLabel.style.display = 'flex';
enabledLabel.style.alignItems = 'center';
enabledLabel.style.gap = '0.75em';
const enabledText = document.createElement('span');
enabledText.textContent = isEnabled ? 'Enabled' : 'Disabled';
@@ -828,7 +1091,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const sourceTypeLabel = document.createElement('label');
sourceTypeLabel.textContent = 'Install From:';
sourceTypeLabel.style.display = 'block';
sourceTypeLabel.style.marginBottom = '0.5em';
const sourceTypeSelect = document.createElement('select');
sourceTypeSelect.className = 'fld emby-select emby-select-withcolor';
@@ -842,7 +1104,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const sourceLabel = document.createElement('label');
sourceLabel.textContent = 'Version:';
sourceLabel.style.display = 'block';
sourceLabel.style.marginBottom = '0.5em';
const sourceSelect = document.createElement('select');
sourceSelect.className = 'fld emby-select emby-select-withcolor';
@@ -860,6 +1121,86 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
customUrlInput.value = sourceInfo.sourceType === 'custom' ? currentRoot : '';
customUrlInput.style.display = sourceInfo.sourceType === 'custom' ? 'block' : 'none';
// Specific Build commit hash input
const commitHashLabel = document.createElement('label');
commitHashLabel.textContent = 'Commit Hash:';
commitHashLabel.style.display = 'block';
commitHashLabel.style.marginBottom = '0.5em';
commitHashLabel.style.marginTop = '1em';
const commitHashInput = document.createElement('input');
commitHashInput.type = 'text';
commitHashInput.className = 'fld emby-input';
commitHashInput.id = 'kefinTweaksCommitHash';
commitHashInput.placeholder = '40-character git commit SHA';
commitHashInput.spellcheck = false;
commitHashInput.autocomplete = 'off';
commitHashInput.value = sourceInfo.commit || '';
// Resolved hash readout for Development / Experimental
const resolvedHashEl = document.createElement('div');
resolvedHashEl.id = 'kefinTweaksResolvedHash';
resolvedHashEl.className = 'listItemBodyText secondary';
resolvedHashEl.style.display = 'none';
resolvedHashEl.style.marginTop = '0.35em';
resolvedHashEl.style.fontFamily = 'monospace';
resolvedHashEl.style.fontSize = '0.9em';
resolvedHashEl.style.wordBreak = 'break-all';
function extractShaFromRoot(root) {
if (!root || typeof root !== 'string') return null;
const m = root.match(/@([0-9a-f]{7,40})(?:\/|$)/i);
return m ? m[1].toLowerCase() : null;
}
async function updateResolvedHashVisibility() {
const show =
sourceTypeSelect.value === 'github' &&
(sourceSelect.value === DEVELOPMENT_NAME || sourceSelect.value === EXPERIMENTAL_NAME);
if (!show) {
resolvedHashEl.style.display = 'none';
resolvedHashEl.textContent = '';
return;
}
resolvedHashEl.style.display = 'block';
resolvedHashEl.textContent = 'Version: …';
let sha = extractShaFromRoot(currentConfig?.kefinTweaksRootResolved);
const symbolicMatchesSelection =
(sourceSelect.value === DEVELOPMENT_NAME && /@(main|master)(\/|$)/.test(currentRoot || '')) ||
(sourceSelect.value === EXPERIMENTAL_NAME && /@experimental(\/|$)/.test(currentRoot || ''));
// Prefer stored resolved hash when it matches the selected branch; otherwise resolve live
if (!sha || !symbolicMatchesSelection || sourceSelect.value !== sourceInfo.version) {
try {
const branchRoot =
sourceSelect.value === EXPERIMENTAL_NAME
? `${JSDELIVR_BASE}@experimental/`
: `${JSDELIVR_BASE}@main/`;
const resolved = await resolveRootVersion(branchRoot);
sha = extractShaFromRoot(resolved) || sha;
} catch (e) {
console.warn('[KefinTweaks Installer] Could not resolve branch hash:', e);
}
}
if (sha) {
const short = sha.length > 12 ? sha.slice(0, 12) : sha;
resolvedHashEl.textContent = `Version: ${short}`;
} else {
resolvedHashEl.textContent = 'Version: unavailable';
}
}
const updateCommitHashVisibility = () => {
const showCommit = sourceTypeSelect.value === 'github'
&& sourceSelect.value === SPECIFIC_BUILD_NAME;
commitHashLabel.style.display = showCommit ? 'block' : 'none';
commitHashInput.style.display = showCommit ? 'block' : 'none';
updateResolvedHashVisibility();
};
// Set initial visibility based on source type
if (sourceInfo.sourceType === 'custom') {
sourceSelect.style.display = 'none';
@@ -870,6 +1211,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
sourceLabel.style.display = 'block';
customUrlInput.style.display = 'none';
}
updateCommitHashVisibility();
// Toggle visibility based on source type
sourceTypeSelect.onchange = () => {
@@ -882,12 +1224,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
sourceLabel.style.display = 'block';
customUrlInput.style.display = 'none';
}
updateCommitHashVisibility();
};
sourceSelect.onchange = () => {
updateCommitHashVisibility();
};
content.appendChild(sourceTypeLabel);
content.appendChild(sourceTypeSelect);
content.appendChild(sourceLabel);
content.appendChild(sourceSelect);
content.appendChild(resolvedHashEl);
content.appendChild(commitHashLabel);
content.appendChild(commitHashInput);
content.appendChild(customUrlInput);
// Footer with buttons
@@ -914,7 +1264,19 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return;
}
const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source);
let commitHash = '';
if (sourceType === 'github' && source === SPECIFIC_BUILD_NAME) {
commitHash = (commitHashInput.value || '').trim().toLowerCase();
if (!isFullCommitSha(commitHash)) {
showAlertModal(
'Invalid Commit Hash',
'<p>Please enter a full 40-character git commit SHA for Specific Build.</p>'
);
return;
}
}
const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source, commitHash);
const kefinTweaksRootResolvedRaw = await resolveRootVersion(kefinTweaksRoot);
const kefinTweaksRootResolved = kefinTweaksRootResolvedRaw.endsWith('/')
? kefinTweaksRootResolvedRaw
@@ -979,6 +1341,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
let versionDisplay = '';
if (sourceInfo.sourceType === 'custom') {
versionDisplay = SELF_HOSTED_NAME;
} else if (sourceInfo.version === SPECIFIC_BUILD_NAME) {
versionDisplay = formatSpecificBuildDisplay(sourceInfo);
} else if (sourceInfo.version === DEVELOPMENT_NAME) {
versionDisplay = DEVELOPMENT_NAME;
} else if (sourceInfo.version === LATEST_RELEASE_NAME) {
@@ -1171,6 +1535,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
if (root) {
if (sourceInfo.sourceType === 'custom') {
versionDisplay = SELF_HOSTED_NAME;
} else if (sourceInfo.version === SPECIFIC_BUILD_NAME) {
versionDisplay = formatSpecificBuildDisplay(sourceInfo);
} else if (sourceInfo.version === DEVELOPMENT_NAME) {
versionDisplay = DEVELOPMENT_NAME;
} else if (sourceInfo.version === LATEST_RELEASE_NAME) {
@@ -1367,6 +1733,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
if (root) {
if (sourceInfo.sourceType === 'custom') {
versionDisplay = SELF_HOSTED_NAME;
} else if (sourceInfo.version === SPECIFIC_BUILD_NAME) {
versionDisplay = formatSpecificBuildDisplay(sourceInfo);
} else if (sourceInfo.version === DEVELOPMENT_NAME) {
versionDisplay = DEVELOPMENT_NAME;
} else if (sourceInfo.version === LATEST_RELEASE_NAME) {
@@ -1687,25 +2055,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
function injectCustomPageStyles(config) {
if (!shouldInjectCustomPageStyles(config)) return;
if (document.getElementById('kefin-custom-page-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-custom-page-styles';
style.textContent = `
#reactRoot .skinBody:has(.customPage:not(.hide)) #fallbackPage {
const css = `
#reactRoot.kefin-custom-page-active .skinBody #fallbackPage {
display: none;
}
#reactRoot:not(:has(.skinBody .customPage:not(.hide))) .pageTitle {
display: none !important;
#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready]) > * {
display: none;
}
#reactRoot .skinBody:not(:has(.customPage:not(.hide))) #fallbackPage > * {
display: none;
}
#reactRoot:not(:has(.skinBody .customPage:not(.hide))) #fallbackPage::after {
content:'';
#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready])::after {
content: '';
display: inline-block;
width: 20px;
height: 20px;
@@ -1719,11 +2080,46 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
top: 1em;
}
#reactRoot:has(.MuiBox-root) .libraryPage:not(.noSecondaryNavPage).customPage {
padding-top: 0 !important;
#fallbackPage[data-kefin-fallback-ready]::after {
display: none !important;
content: none !important;
}
#reactRoot.kefin-custom-page-active .backdropImage {
background: none !important;
}
header.MuiPaper-root + main.MuiBox-root .customPage {
margin-top: 3rem;
}
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :nth-child(n+2),
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon img {
display: none;
}
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon::after {
content: '\\e88a';
font-family: 'Material Icons';
font-size: 1.2rem;
}
.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child {
font-size: 0px;
}
main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] {
padding-top: 1rem !important;
}
.layout-mobile :not(main.MuiBox-root) > .skinBody > .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] {
padding-top: 5rem !important;
}
`;
(document.head || document.documentElement).appendChild(style);
window.KefinTweaksCustomPageStyles = css;
let style = document.getElementById('kefin-custom-page-styles');
if (!style) {
style = document.createElement('style');
style.id = 'kefin-custom-page-styles';
(document.head || document.documentElement).appendChild(style);
}
style.textContent = css;
}
// Initialize the installer (no login/admin gate — admin is checked when rendering the card)
@@ -1800,46 +2196,76 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return root;
}
// Load injector.js from a specific root URL (resolves floating refs first)
// Load injector.js from a specific root URL (fallback when bake is absent).
// GitHub floating resolve + persist only for admins; non-admins use pinned/symbolic as-is.
async function loadInjectorFromRoot(root) {
if (!root || root === '') {
console.warn('[KefinTweaks Installer] Cannot load injector: kefinTweaksRoot is empty');
return false;
}
// Ensure root ends with /
const rootUrl = root.endsWith('/') ? root : root + '/';
const resolvedRoot = await resolveRootVersion(rootUrl);
const injectorUrl = `${resolvedRoot}injector.js`;
const rootUrl = normalizeKefinRootUrl(root);
const admin = await isAdmin();
let resolvedRoot = rootUrl;
if (isFloatingKefinRoot(rootUrl)) {
if (admin) {
resolvedRoot = normalizeKefinRootUrl(await resolveRootVersion(rootUrl));
} else {
const pinned = normalizeKefinRootUrl(window.KefinTweaksConfig?.kefinTweaksRootResolved || '');
resolvedRoot = pinned || rootUrl;
console.log('[KefinTweaks Installer] Non-admin: skipping GitHub resolve; using', resolvedRoot);
}
}
if (resolvedRoot !== rootUrl) {
console.log('[KefinTweaks Installer] Resolved root from', rootUrl, 'to', resolvedRoot);
}
// Persist commit-SHA root when floating GitHub refs resolve to a new URL
try {
const normalize = (r) => {
if (!r || typeof r !== 'string') return '';
return r.endsWith('/') ? r : r + '/';
};
const expected = normalize(resolvedRoot);
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
const current = normalize(window.KefinTweaksConfig.kefinTweaksRootResolved || '');
if (expected && expected !== current) {
window.KefinTweaksConfig.kefinTweaksRootResolved = expected;
console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected);
if (isLoggedInForInjectorWrite()) {
await saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
} else {
console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist');
// Admin: persist tip move; if replacing a previous pin, countdown reload (no tip injector this session)
if (admin && isFloatingKefinRoot(rootUrl)) {
try {
const expected = normalizeKefinRootUrl(resolvedRoot);
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
const symbolicRoot = window.KefinTweaksConfig.kefinTweaksRoot || rootUrl;
const current = normalizeKefinRootUrl(window.KefinTweaksConfig.kefinTweaksRootResolved || '');
if (expected && expected !== current) {
window.KefinTweaksConfig.kefinTweaksRootResolved = expected;
console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected);
if (isLoggedInForInjectorWrite()) {
const persisted = await saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
if (persisted && current) {
// Prior pin existed — force reload so scripts/Config stay aligned
maybeQueueResolvedRootUpdateToast({
symbolicRoot,
previousResolved: current,
nextResolved: expected,
persisted: true
});
return true;
}
if (persisted && !current) {
// First pin: continue and load injector from the new resolved URL
console.log('[KefinTweaks Installer] First resolved pin persisted; loading injector.js');
}
} else {
console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist');
}
}
} catch (e) {
console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e);
}
} catch (e) {
console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e);
}
if (window.__kefinTipReloadScheduled) {
console.log('[KefinTweaks Installer] Tip reload scheduled; skipping injector.js load');
return true;
}
const injectorUrl = `${resolvedRoot}injector.js`;
// Check if injector is already loaded from this URL
const existingScript = document.querySelector(`script[src="${injectorUrl}"]`);
if (existingScript) {
@@ -1868,41 +2294,45 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
});
}
// Check if config exists; auto-create KefinTweaks-injector when missing; always load live injector.js
// Check if config exists; auto-create bake when missing (admin); never dual-inject when bake attached
async function checkAndLoadInjector() {
try {
const config = await getKefinTweaksConfig();
const root = config?.kefinTweaksRoot || '';
// Prefer in-page Config (already injected). Avoid /Plugins GET on normal page load for non-admins.
let config = window.KefinTweaksConfig || null;
if (!config) {
const admin = await isAdmin();
if (admin) {
await waitForLoginForInjectorWrite();
config = await getKefinTweaksConfig();
}
}
// Before injector/utils: hide fallback page chrome for custom routes
injectCustomPageStyles(config);
const root = config?.kefinTweaksRoot || '';
if (!root || root === '') {
console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.');
return;
}
// Plugin config GET/POST needs a session; wait briefly so admin first-load can auto-create
await waitForLoginForInjectorWrite();
let injectorConfig = null;
try {
const pluginId = await findJavaScriptInjectorPlugin();
if (pluginId) {
injectorConfig = await getJavaScriptInjectorConfig(pluginId);
// Bake already on page: never load live injector.js (prevents dual SHA trees)
if (isKefinInjectorAttachedOnPage()) {
if (!(await isAdmin())) {
console.log('[KefinTweaks Installer] KefinTweaks-injector attached; non-admin skipping live injector.js');
return;
}
} catch (e) {
console.warn('[KefinTweaks Installer] Could not read JS Injector config:', e);
console.log('[KefinTweaks Installer] KefinTweaks-injector attached; admin tip sync without live inject');
await handleAdminAttachedBakeTipSync(config);
return;
}
const hasPreload = !!(injectorConfig && hasEnabledKefinInjectorEntry(injectorConfig.CustomJavaScripts));
if (hasPreload) {
console.log('[KefinTweaks Installer] KefinTweaks-injector present; still loading injector.js for live plan + sync');
} else {
// Entry missing: try to create + apply assets now (admin/logged-in)
if (await isAdmin()) {
await waitForLoginForInjectorWrite();
const ensured = await ensureKefinTweaksInjectorEntry(config);
if (ensured.ok && ensured.plan) {
window.KefinTweaksScriptsPreloaded = true;
window.KefinTweaksInjectorAttached = true;
if (window.KefinTweaksLoader) {
window.KefinTweaksLoader.ensureKefinTweaksApi(window.KefinTweaksLoader.SCRIPT_DEFINITIONS);
window.KefinTweaksLoader.applyAssetsToDocument(ensured.plan.assets);
@@ -1917,9 +2347,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}));
} catch (e) { /* ignore */ }
console.log('[KefinTweaks Installer] Applied KefinTweaks-injector assets after auto-create');
} else {
console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js');
// Tip advance inside ensure may have scheduled reload; never also load tip injector.js
if (window.__kefinTipReloadScheduled) return;
return;
}
console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js');
} else {
console.log('[KefinTweaks Installer] KefinTweaks-injector not on page (non-admin); loading injector.js');
}
await loadInjectorFromRoot(root);
@@ -1938,4 +2372,5 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
console.log('[KefinTweaks Installer] Installer script loaded');
})();
})();
+6 -3
View File
@@ -352,7 +352,8 @@ window.KefinTweaksConfig = {
},
"starringActorRecentlyWatched": {
"enabled": true,
"name": "Starring [Actor] because you recently watched [Movie]",
"name": "Starring {Person}",
"caption": "because you recently watched {Title}",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
@@ -360,7 +361,8 @@ window.KefinTweaksConfig = {
},
"directedByDirectorRecentlyWatched": {
"enabled": true,
"name": "Directed by [Director] because you recently watched [Movie]",
"name": "Directed by {Person}",
"caption": "because you recently watched {Title}",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
@@ -368,7 +370,8 @@ window.KefinTweaksConfig = {
},
"writtenByWriterRecentlyWatched": {
"enabled": true,
"name": "Written by [Writer] because you recently watched [Movie]",
"name": "Written by {Person}",
"caption": "because you recently watched {Title}",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+1063
View File
File diff suppressed because it is too large Load Diff
+894
View File
@@ -0,0 +1,894 @@
// Jellyfin Quiz Game - Enhanced with animations and preloading
(function() {
'use strict';
if (window.__kefinQuizLoaded) {
console.log('[Quiz] Already initialized; skipping re-entry');
return;
}
window.__kefinQuizLoaded = true;
var itemList = [];
var currentItemList = [];
var titlesCorrect = [];
var serverId;
var timer;
var quizScore = 0;
var correctGuesses = 0;
var quickGuesses = 0;
var username = '';
var resetTimer = false;
var preloadedImages = [];
var imagePreloader = null;
var isQuizActive = false;
var rerollCount = 0;
var maxRerolls = 3;
var correctGuessesData = [];
// Initialize UI state
document.getElementById('quizInitializeButton').disabled = true;
document.getElementById("quizStartButton").disabled = true;
document.getElementById('quizInitializeButton').style.display = "none";
document.getElementById('quizStartButton').style.display = "none";
document.getElementById('quizRerollButton').disabled = true;
document.getElementById('quizRerollButton').style.display = "none";
function initializeQuiz(apiClient) {
console.log('initing quiz');
document.getElementById('quizLoading').classList.remove("hide");
apiClient.getItems(apiClient.getCurrentUserId(), {
IncludeItemTypes: 'Movie',
Recursive: true,
Fields: 'PrimaryImageAspectRatio,ImageTags,BackdropImageTags'
}).then((data) => {
itemList = (data && data.Items) || [];
currentItemList = itemList.slice();
document.getElementById('quizLoading').classList.add("hide");
initializeQuizData();
});
apiClient.getCurrentUser().then((user) => { username = user.Name; });
fetch('/files/KefinTweaks/pages/scripts/hiscores.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}
})
.then(response => response.text())
.then(data => {
var hiscores = data.split("\n");
hiscores.sort((s1, s2) => {
var s1Value = parseInt(s1.split(';')[1]);
var s2Value = parseInt(s2.split(';')[1]);
return s1Value < s2Value ? 1 : s1Value > s2Value ? -1 : 0;
});
var topScores = hiscores.slice(0,10);
// Create table HTML
var tableHtml = `
<div class="hiscores-header">
<h3>🏆 Leaderboard</h3>
</div>
<div class="hiscores-table-container">
<table class="hiscores-table">
<thead>
<tr>
<th class="rank-col">#</th>
<th class="name-col">Player</th>
<th class="score-col">Score</th>
<th class="guesses-col">Guessed</th>
<th class="date-col">Date</th>
</tr>
</thead>
<tbody>
`;
topScores.forEach((score, index) => {
if (score.trim()) {
var scoreName = score.split(';')[0];
var scorePoints = score.split(';')[1];
var scoreGuesses = score.split(';')[2];
var scoreDate = score.split(';')[3];
// Format date
var date = new Date(scoreDate);
var formattedDate = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
// Add rank styling
var rankClass = '';
if (index === 0) rankClass = 'rank-gold';
else if (index === 1) rankClass = 'rank-silver';
else if (index === 2) rankClass = 'rank-bronze';
tableHtml += `
<tr class="score-row ${rankClass}">
<td class="rank-cell">${index + 1}</td>
<td class="name-cell">${scoreName}</td>
<td class="score-cell">${parseInt(scorePoints).toLocaleString()}</td>
<td class="guesses-cell">${scoreGuesses}</td>
<td class="date-cell">${formattedDate}</td>
</tr>
`;
}
});
tableHtml += `
</tbody>
</table>
</div>
`;
document.getElementById("quizHiscoresContainer").innerHTML = tableHtml;
})
.catch(error => {
console.log('Error fetching hiscores:', error);
});
}
function initializeQuizData() {
document.getElementById('quizInputContainer').classList.add("hide");
document.getElementById('quizInitializeButton').disabled = true;
document.getElementById('quizInitializeButton').style.display = "none";
document.getElementById("quizStartButton").disabled = true;
document.getElementById('quizContainer').classList.remove("active");
document.getElementById('quizContainer').classList.add("inactive");
document.getElementById('quizContainer').classList.add("hide");
document.getElementById('quizContainer').innerHTML = '';
document.getElementById("quizTimer").textContent = "01:00";
document.getElementById("quizTimer").classList.remove("warning");
document.getElementById('quizScoreDisplay').classList.add("hide");
document.getElementById('quizRerollDisplay').classList.add("hide");
document.getElementById('quizControlButtons').classList.add("hide");
document.getElementById('quizControlButtons').style.display = "none";
quizScore = 0;
correctGuesses = 0;
quickGuesses = 0;
rerollCount = 0;
titlesCorrect = [];
correctGuessesData = [];
resetTimer = true;
isQuizActive = false;
// Clear any existing answer overlays
document.querySelectorAll('.answer-overlay').forEach((overlay) => {
overlay.remove();
});
currentItemList = [...itemList]; // Create a copy
console.log('initing quiz data');
// Preload images and add them to quiz container
preloadImages().then(() => {
// Add the preloaded images to the quiz container
for (var i = 0; i < 9; i++) {
addImageToQuiz(i);
}
document.getElementById("quizStartButton").disabled = false;
document.getElementById('quizStartButton').style.display = "inline-block";
});
}
async function preloadImages() {
console.log('Preloading images...');
preloadedImages = [];
imagePreloader = document.getElementById('imagePreloader');
imagePreloader.innerHTML = ''; // Clear previous images
const preloadPromises = [];
for (let i = 0; i < 9; i++) {
const randomItem = getRandomItem();
const imageUrl = getImageUrl(randomItem);
console.log(`Preloading image ${i}:`, imageUrl);
if (!imageUrl) {
console.warn(`No image URL generated for index ${i}, skipping`);
preloadedImages.push({
item: randomItem,
url: null,
loaded: false
});
continue;
}
const preloadPromise = new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
console.log(`Image ${i} loaded successfully`);
preloadedImages.push({
item: randomItem,
url: imageUrl,
loaded: true
});
resolve();
};
img.onerror = () => {
console.warn('Failed to load image:', imageUrl);
// Try to get a different image
const fallbackItem = getRandomItem();
const fallbackUrl = getImageUrl(fallbackItem);
if (!fallbackUrl) {
console.warn(`No fallback URL for index ${i}`);
preloadedImages.push({
item: randomItem,
url: imageUrl,
loaded: false
});
resolve();
return;
}
const fallbackImg = new Image();
fallbackImg.onload = () => {
console.log(`Fallback image ${i} loaded successfully`);
preloadedImages.push({
item: fallbackItem,
url: fallbackUrl,
loaded: true
});
resolve();
};
fallbackImg.onerror = () => {
console.warn(`Both original and fallback images failed for index ${i}`);
preloadedImages.push({
item: randomItem,
url: imageUrl,
loaded: false
});
resolve(); // Continue even if image fails
};
fallbackImg.src = fallbackUrl;
};
img.src = imageUrl;
});
preloadPromises.push(preloadPromise);
}
await Promise.all(preloadPromises);
console.log('Image preloading complete:', preloadedImages.length, 'images loaded');
console.log('Preloaded images array:', preloadedImages);
}
function getRandomItem() {
if (!itemList || itemList.length === 0) {
return null;
}
if (currentItemList.length === 0) {
currentItemList = itemList.slice();
}
function hasBanner(item) {
return !!(item && item.ImageTags && item.ImageTags.Banner);
}
function hasBackdrop(item) {
return !!(item && item.BackdropImageTags && item.BackdropImageTags.length > 0);
}
// Prefer Banner-tagged titles; fall back to any with Backdrop (Banner is often missing).
function pickFromList(predicate) {
var attempts = 0;
var maxAttempts = currentItemList.length * 2;
while (attempts < maxAttempts && currentItemList.length > 0) {
var randomIndex = Math.floor(Math.random() * currentItemList.length);
var candidateItem = currentItemList[randomIndex];
if (predicate(candidateItem)) {
currentItemList.splice(randomIndex, 1);
return candidateItem;
}
currentItemList.splice(randomIndex, 1);
attempts++;
}
return null;
}
var randomItem = pickFromList(hasBanner);
if (!randomItem) {
currentItemList = itemList.slice();
randomItem = pickFromList(hasBackdrop);
}
if (!randomItem) {
console.warn('[Quiz] No movies with Banner or Backdrop images available');
currentItemList = itemList.slice();
return null;
}
console.log('randomItem', randomItem);
return randomItem;
}
function getImageUrl(item) {
if (!item) return null;
console.log('getting img url for item:', item.Name);
console.log('Item backdrop tags:', item.BackdropImageTags);
var backdropCount = item.BackdropImageTags ? item.BackdropImageTags.length : 0;
if (backdropCount === 0) {
console.warn('No backdrop images for item:', item.Name);
var newItem = getRandomItem();
if (!newItem) {
console.error('No backdrop images available');
return null;
}
backdropCount = newItem.BackdropImageTags ? newItem.BackdropImageTags.length : 0;
if (backdropCount === 0) {
console.error('No backdrop images available');
return null;
}
item = newItem;
}
var imageUrl = ApiClient.getImageUrl(item.Id, { type: 'Backdrop/' + Math.floor(Math.random() * backdropCount) });
console.log('Generated image URL:', imageUrl);
return imageUrl;
}
function addImageToQuiz(index) {
console.log('Adding image to quiz at index:', index);
console.log('Preloaded images:', preloadedImages);
// Use preloaded image if available
if (preloadedImages[index] && preloadedImages[index].loaded && preloadedImages[index].url) {
var imageData = preloadedImages[index];
var imageUrl = imageData.url;
var randomItem = imageData.item;
console.log('Using preloaded image:', imageUrl);
} else {
// Fallback to generating new image
console.log('Using fallback image generation');
var randomItem = getRandomItem();
var imageUrl = getImageUrl(randomItem);
}
if (!randomItem || !imageUrl) {
console.error('No image URL available for index', index);
return;
}
var existingImage = document.getElementById('quizContainer').querySelectorAll('img')[index];
console.log('Existing image at index', index, ':', existingImage);
var timeStarted = new Date();
if (existingImage) {
// Smooth image replacement
console.log('Replacing existing image');
replaceImageWithAnimation(existingImage, imageUrl, randomItem, timeStarted);
return;
}
// Create new image container
console.log('Creating new image container');
var imageContainer = document.createElement("div");
imageContainer.className = "quiz-image-container";
var image = document.createElement("IMG");
image.src = imageUrl;
image.dataset.movieName = randomItem.Name;
image.dataset.timeStarted = timeStarted.toString();
image.className = "fade-in";
imageContainer.appendChild(image);
document.getElementById('quizContainer').appendChild(imageContainer);
console.log('Added image container to quiz container');
// Preload next image for this slot
preloadNextImage(index);
}
function replaceImageWithAnimation(oldImage, newImageUrl, newItem, timeStarted) {
const container = oldImage.parentElement;
// Create new image
const newImage = document.createElement("IMG");
newImage.src = newImageUrl;
newImage.dataset.movieName = newItem.Name;
newImage.dataset.timeStarted = timeStarted.toString();
newImage.className = "fade-in";
newImage.style.position = "absolute";
newImage.style.top = "0";
newImage.style.left = "0";
newImage.style.width = "100%";
newImage.style.height = "100%";
newImage.style.objectFit = "cover";
newImage.style.borderRadius = "8px";
// Add new image behind old one
container.appendChild(newImage);
// Animate old image out
oldImage.classList.add("fade-out");
// Remove old image after animation
setTimeout(() => {
if (oldImage.parentElement) {
oldImage.parentElement.removeChild(oldImage);
}
newImage.style.position = "static";
}, 500);
// Preload next image for this slot
preloadNextImage(Array.from(container.parentElement.children).indexOf(container));
}
function preloadNextImage(slotIndex) {
// Preload a new image for this slot
const randomItem = getRandomItem();
const imageUrl = getImageUrl(randomItem);
if (!randomItem || !imageUrl) return;
const img = new Image();
img.onload = () => {
// Store preloaded image for this slot
if (!preloadedImages[slotIndex]) {
preloadedImages[slotIndex] = [];
}
preloadedImages[slotIndex] = {
item: randomItem,
url: imageUrl,
loaded: true
};
};
img.src = imageUrl;
}
function quizListeners() {
console.log('adding listener');
document.getElementById('quizGuess').addEventListener('input', checkGuess);
document.getElementById('quizStartButton').addEventListener('click', startQuiz);
document.getElementById('quizInitializeButton').addEventListener('click', initializeQuizData);
document.getElementById('quizRerollButton').addEventListener('click', rerollQuiz);
// Modal listeners
document.getElementById('modalCloseButton').addEventListener('click', hideScoreModal);
document.getElementById('scoreModal').addEventListener('click', function(e) {
if (e.target === this) {
hideScoreModal();
}
});
}
function startQuiz() {
resetTimer = false;
isQuizActive = true;
document.getElementById('quizHeaderContainer').classList.add("hide");
document.getElementById('quizContainer').classList.add("active");
document.getElementById('quizContainer').classList.remove("inactive");
document.getElementById('quizContainer').classList.remove("hide");
document.getElementById('quizControlButtons').classList.remove("hide");
document.getElementById('quizTimerContainer').classList.remove("hide");
document.getElementById('quizScoreDisplay').classList.add("show");
document.getElementById('quizRerollDisplay').classList.add("show");
document.getElementById('quizInitializeButton').disabled = false;
document.getElementById('quizStartButton').disabled = false;
document.getElementById('quizRerollButton').disabled = false;
document.getElementById('quizStartButton').style.display = "none";
document.getElementById('quizControlButtons').style.display = "flex";
// Update reroll display and button
updateRerollDisplay();
updateRerollButton();
// Clear input field and focus
document.getElementById('quizGuess').value = '';
document.getElementById('quizGuess').focus();
startTimer(59, document.getElementById("quizTimer"));
var quizStartedTime = new Date();
console.log('setting time started');
document.querySelectorAll('#quizContainer img').forEach((item) => {
item.dataset["timeStarted"] = quizStartedTime.toString();
console.log('setting time started');
});
document.getElementById("quizGuess").focus();
}
function startTimer(duration, display) {
tickTimer(duration, display);
}
function tickTimer(duration, display) {
timer = duration;
var minutes, seconds;
setTimeout(function () {
minutes = parseInt(timer / 60, 10);
seconds = parseInt(timer % 60, 10);
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
display.textContent = minutes + ":" + seconds;
// Add warning animation when time is low
if (timer <= 10 && timer > 0) {
display.classList.add("warning");
}
// Update reroll cost display
if (isQuizActive) {
updateRerollButton();
updateRerollDisplay();
}
if (resetTimer) {
timer = 0;
display.textContent = "01:00";
display.classList.remove("warning");
resetTimer = false;
} else if (--timer < -1) {
timer = 0;
display.classList.remove("warning");
finishQuiz();
} else {
tickTimer(timer, display);
}
}, 1000);
}
function finishQuiz() {
isQuizActive = false;
document.getElementById("quizStartButton").disabled = true;
// Keep control buttons visible so user can reset quiz
// Don't hide the quiz container, just show answers overlay
showAnswers();
console.log('submitting data for ' + username + ' with total ' + correctGuesses + ' correct');
// Show modal immediately
showScoreModal();
const formData = new FormData();
formData.append('username', username);
formData.append('score', Math.round(quizScore));
formData.append('correctItemCount', correctGuesses);
fetch('/files/KefinTweaks/pages/scripts/quiz.php', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(data => {
console.log(data);
// Update modal message with server response
if (quizScore > 0) {
document.getElementById('modalMessage').textContent = data;
} else {
document.getElementById('modalMessage').textContent = 'Try to finish with more than 0 points next time!';
}
})
.catch(error => {
console.log('Error submitting quiz score:', error);
document.getElementById('modalMessage').textContent = 'Error submitting score. Please try again.';
});
document.getElementById("quizTimer").textContent = "01:00";
document.getElementById("quizTimer").classList.remove("warning");
}
function showAnswers() {
// Add answer overlays directly to each image container
document.querySelectorAll('#quizContainer .quiz-image-container').forEach((container) => {
var img = container.querySelector('img');
if (img) {
// Check if answer overlay already exists
var existingAnswer = container.querySelector('.answer-overlay');
if (existingAnswer) {
existingAnswer.remove();
}
// Create answer overlay
var answerOverlay = document.createElement('div');
answerOverlay.className = 'answer-overlay';
answerOverlay.innerHTML = img.dataset.movieName;
// Style the answer overlay
answerOverlay.style.position = 'absolute';
answerOverlay.style.bottom = '0';
answerOverlay.style.left = '0';
answerOverlay.style.right = '0';
answerOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
answerOverlay.style.color = '#4ecdc4';
answerOverlay.style.fontSize = '14px';
answerOverlay.style.fontWeight = 'bold';
answerOverlay.style.textAlign = 'center';
answerOverlay.style.padding = '8px';
answerOverlay.style.borderRadius = '0 0 8px 8px';
answerOverlay.style.zIndex = '10';
answerOverlay.style.textShadow = '2px 2px 4px rgba(0,0,0,0.8)';
answerOverlay.style.lineHeight = '1.2';
answerOverlay.style.wordWrap = 'break-word';
answerOverlay.style.boxSizing = 'border-box';
// Add to container
container.appendChild(answerOverlay);
}
});
}
function checkGuess() {
if (!isQuizActive) return;
var index = 0;
var quizItems = document.querySelectorAll('#quizContainer img');
var foundMatch = false;
quizItems.forEach((item) => {
if (foundMatch) return;
var guessValue = this.value.replace(/[^a-zA-Z ]/g, "");
var movieName = item.dataset.movieName.replace(/[^a-zA-Z ]/g, "");
var similarityScore = similarity(guessValue, movieName);
if (similarityScore > 0.5) {
console.log(`Similarity Score ${similarityScore} for guess ${guessValue} compared to ${movieName}`);
if (similarityScore > 0.95) {
console.log('match based on similarity ' + similarityScore);
var currentTime = new Date();
var timeStarted = new Date(item.dataset.timeStarted);
var timePassed = (currentTime - timeStarted) / 1000;
var timeMultiplier = (120 - timePassed) / 120;
var pointsEarned = 120 * timeMultiplier;
titlesCorrect.push(item.dataset.movieName);
quizScore += pointsEarned;
correctGuesses++;
// Track correct guess data
correctGuessesData.push({
movieName: item.dataset.movieName,
timePassed: timePassed,
pointsEarned: pointsEarned,
timestamp: currentTime
});
console.log('Points earned: ' + pointsEarned + ' for ' + item.dataset.movieName + ' in ' + timePassed.toFixed(2) + ' seconds');
// Show points animation
showPointsAnimation(item, Math.round(pointsEarned));
// Update score display
updateScoreDisplay();
// Replace image
addImageToQuiz(index);
this.value = '';
foundMatch = true;
return;
}
}
index++;
});
}
function showPointsAnimation(imageElement, points) {
const container = imageElement.closest('.quiz-image-container') || imageElement.parentElement;
const rect = container.getBoundingClientRect();
const pointsElement = document.createElement('div');
pointsElement.className = 'points-animation';
pointsElement.textContent = `+${points}`;
pointsElement.style.left = (rect.left + rect.width / 2) + 'px';
pointsElement.style.top = (rect.top + rect.height / 2) + 'px';
document.body.appendChild(pointsElement);
// Remove after animation
setTimeout(() => {
if (pointsElement.parentElement) {
pointsElement.parentElement.removeChild(pointsElement);
}
}, 2000);
}
function showRerollAnimation() {
const remaining = maxRerolls - rerollCount;
const rerollButton = document.getElementById('quizRerollButton');
const rect = rerollButton.getBoundingClientRect();
const rerollElement = document.createElement('div');
rerollElement.className = 'reroll-animation';
rerollElement.textContent = `${remaining} rolls remaining`;
rerollElement.style.left = (rect.left + rect.width / 2) + 'px';
rerollElement.style.top = (rect.top - 20) + 'px';
document.body.appendChild(rerollElement);
// Remove after animation
setTimeout(() => {
if (rerollElement.parentElement) {
rerollElement.parentElement.removeChild(rerollElement);
}
}, 2500);
}
function updateScoreDisplay() {
const scoreDisplay = document.getElementById('quizScoreDisplay');
scoreDisplay.textContent = `Score: ${Math.round(quizScore)}`;
scoreDisplay.classList.add('show');
}
function calculateRerollCost() {
return 50;
// Cost scales from 200 at start to 120 at end (60 second timer)
// Formula: 200 - (timeElapsed / 60) * 80
const timeElapsed = 60 - timer; // timer counts down from 60
const cost = Math.max(50, 200 - (timeElapsed / 60) * 80);
return Math.round(cost);
}
function updateRerollDisplay() {
const rerollDisplay = document.getElementById('quizRerollDisplay');
const remaining = maxRerolls - rerollCount;
const currentCost = calculateRerollCost();
rerollDisplay.textContent = `Re-rolls: ${remaining}/${maxRerolls} (${currentCost} pts)`;
rerollDisplay.classList.add('show');
}
function updateRerollButton() {
const rerollButton = document.getElementById('quizRerollButton');
const currentCost = calculateRerollCost();
rerollButton.textContent = `Re-roll (-${currentCost} pts)`;
}
async function rerollQuiz() {
if (rerollCount >= maxRerolls || !isQuizActive) {
return;
}
// Calculate and deduct dynamic cost
const rerollCost = calculateRerollCost();
quizScore = Math.max(0, quizScore - rerollCost);
rerollCount++;
// Show reroll animation
showRerollAnimation();
// Update displays
updateScoreDisplay();
updateRerollDisplay();
// Clear and refocus input field
document.getElementById('quizGuess').value = '';
document.getElementById('quizGuess').focus();
// Disable button if no rerolls left
if (rerollCount >= maxRerolls) {
document.getElementById('quizRerollButton').disabled = true;
}
// Clear existing answer overlays
document.querySelectorAll('.answer-overlay').forEach((overlay) => {
overlay.remove();
});
// Preload new images
await preloadImages();
// Clear the entire quiz container
document.getElementById('quizContainer').innerHTML = '';
// Add all 9 new images to the grid
for (var i = 0; i < 9; i++) {
addImageToQuiz(i);
}
console.log('Quiz rerolled! All 9 images replaced.');
}
function similarity(s1, s2) {
var longer = s1;
var shorter = s2;
if (s1.length < s2.length) {
longer = s2;
shorter = s1;
}
var longerLength = longer.length;
if (longerLength == 0) {
return 1.0;
}
return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength);
}
function editDistance(s1, s2) {
s1 = s1.toLowerCase();
s2 = s2.toLowerCase();
var costs = new Array();
for (var i = 0; i <= s1.length; i++) {
var lastValue = i;
for (var j = 0; j <= s2.length; j++) {
if (i == 0)
costs[j] = j;
else {
if (j > 0) {
var newValue = costs[j - 1];
if (s1.charAt(i - 1) != s2.charAt(j - 1))
newValue = Math.min(Math.min(newValue, lastValue),
costs[j]) + 1;
costs[j - 1] = lastValue;
lastValue = newValue;
}
}
}
if (i > 0)
costs[s2.length] = lastValue;
}
return costs[s2.length];
}
function showScoreModal() {
// Update modal content
document.getElementById('modalFinalScore').textContent = Math.round(quizScore);
document.getElementById('modalCorrectGuesses').textContent = correctGuesses;
// Update correct guesses list
updateCorrectGuessesList();
// Show modal
document.getElementById('scoreModal').classList.add('show');
}
function updateCorrectGuessesList() {
const guessesList = document.getElementById('modalGuessesList');
if (!guessesList) return;
// Sort guesses by time (fastest first)
const sortedGuesses = correctGuessesData.sort((a, b) => a.timePassed - b.timePassed);
let html = '';
sortedGuesses.forEach((guess, index) => {
const timeFormatted = guess.timePassed < 1 ?
`${Math.round(guess.timePassed * 1000)}ms` :
`${guess.timePassed.toFixed(1)}s`;
html += `
<div class="guess-item">
<span class="guess-number">${index + 1}.</span>
<span class="guess-movie">${guess.movieName}</span>
<span class="guess-time">${timeFormatted}</span>
<span class="guess-points">+${Math.round(guess.pointsEarned)}</span>
</div>
`;
});
guessesList.innerHTML = html;
}
function hideScoreModal() {
document.getElementById('scoreModal').classList.remove('show');
}
// Main initialization function
function initializeQuizApp() {
console.log('Initializing quiz app');
serverId = ApiClient.serverId();
initializeQuiz(ApiClient);
quizListeners();
}
// Initialize the quiz when the script loads
if (typeof ApiClient !== 'undefined') {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeQuizApp);
} else {
initializeQuizApp();
}
} else {
// Wait for ApiClient to be available
const checkApiClient = setInterval(() => {
if (typeof ApiClient !== 'undefined') {
clearInterval(checkApiClient);
initializeQuizApp();
}
}, 100);
}
})();
+107
View File
@@ -0,0 +1,107 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KefinTweaks Games</title>
<style>
:root {
--kefin-games-bg: #1a1b1e;
--kefin-games-card: #25262b;
--kefin-games-text: #f1f3f5;
--kefin-games-muted: #a6a7ab;
--kefin-games-accent: #74c0fc;
--kefin-games-border: rgba(255, 255, 255, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
color: var(--kefin-games-text);
padding: 2rem 1.25rem 3rem;
}
.kefin-games-wrap {
max-width: 40rem;
margin: 0 auto;
}
h1 {
margin: 0 0 0.35em;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.kefin-games-lead {
margin: 0 0 1.75rem;
color: var(--kefin-games-muted);
font-size: 0.95rem;
line-height: 1.45;
}
.kefin-games-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.kefin-games-list a {
display: block;
text-decoration: none;
color: inherit;
background: var(--kefin-games-card);
border: 1px solid var(--kefin-games-border);
border-radius: 12px;
padding: 1.1rem 1.25rem;
transition:
border-color 0.15s ease,
transform 0.15s ease;
}
.kefin-games-list a:hover,
.kefin-games-list a:focus-visible {
border-color: var(--kefin-games-accent);
outline: none;
transform: translateY(-1px);
}
.kefin-games-list strong {
display: block;
font-size: 1.1rem;
margin-bottom: 0.35rem;
color: var(--kefin-games-accent);
}
.kefin-games-list span {
display: block;
font-size: 0.9rem;
color: var(--kefin-games-muted);
line-height: 1.4;
}
</style>
</head>
<body>
<div class="kefin-games-wrap">
<h1>Games</h1>
<p class="kefin-games-lead">Pick a game. Add more entries here when new games ship.</p>
<ul class="kefin-games-list">
<li>
<a class="kefin-games-link" href="#/movieLut">
<strong>Movie LUT Game</strong>
<span>Guess the movie from a color swatch made from still frames of the movie.</span>
</a>
</li>
<li>
<a class="kefin-games-link" href="#/3x3">
<strong>3x3 Game</strong>
<span>From a grid of 9 different movie backdrops, guess the most movies you can in 60 seconds.</span>
</a>
</li>
</ul>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Some files were not shown because too many files have changed in this diff Show More