Pray for me

This commit is contained in:
HighImKevin committed 2026-09-10 07:25:06 -04:00
1 parent bd45be2fd6
commit 1dbb3b8518
98 files changed
+47471 -10558

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
interface HomeScreenSection {
// Identification
id: string;
name: string;
caption?: string;
enabled: boolean;
order: number;
// Display Options
cardFormat?: "Poster" | "Thumb" | "Backdrop" | "Random";
spotlight?: boolean; // Legacy: Use renderMode instead. Kept for backward compatibility.
renderMode?: "Normal" | "Spotlight" | "Random"; // Preferred: Controls rendering mode. "Spotlight" = spotlight section, "Random" = random spotlight/cards, "Normal" = cards only.
spotlightConfig?: object;
discoveryEnabled?: boolean;
flattenSeries?: boolean;
minimumItems?: number;
viewMoreUrl?: string;
captionUrl?: string;
hidden?: boolean; // legacy, use userConfigurable instead
hideName?: boolean;
hideCardTitles?: boolean; // Legacy: use cardTitleVisibility instead
borderStyle?: string; // Applied as data-border on the section container
borderColor?: string; // CSS color for card borders (--kefin-card-border-color)
sectionCssClass?: string; // Extra CSS classes on the section container
itemsLayout?: "row" | "grid";
cardTitleVisibility?: "visible" | "hidden";
cardTitlePosition?: string;
cardTitleCapitalization?: "normal" | "uppercase" | "lowercase" | "capitalize";
cardTitleFontFamily?: string;
cardTitleFontSize?: "small" | "normal" | "large";
userConfigurable?: boolean; // Whether users can toggle/reorder this section
useGaplessCards?: boolean; // When true, remove gaps between cards (row or grid)
// Multi-Query (only used when queries.length > 1)
sortBy?: string; // Field to sort merged results by (e.g., 'DatePlayed', 'DateCreated')
sortOrder?: "Ascending" | "Descending"; // Sort direction for merged results
minPlayCount?: number; // Minimum play count to include in the section
maxPlayCount?: number; // Maximum play count to include in the section
useRandomQuery?: boolean; // Pick one query at load instead of merging; with useMultiQueryPicker, randomizes the initial picker selection
useMultiQueryPicker?: boolean; // Show a control to pick which query the section displays
multiQueryPickerLabel?: string; // Fixed label for the picker button; when unset, button shows the selected query name
useQueryNamesForSection?: boolean; // Use the selected query name in place of the section title
// Optionally provide pre-defined items to display in the section, instead of using queries
items?: Array<{
Name: string;
Id?: string;
Type?: string;
cardFooter?: string;
imageUrl: string;
cardUrl: string;
}>;
// Query Construction
queries: Array<Query>;
// Surface / page kind: home | seasonal | discovery on the home catalog;
// other pages use series-episodes, search, watchlist, etc. Never "custom".
type?: string;
/** True for user-created sections stored in custom groups (surface type still home/seasonal/discovery). */
isCustom?: boolean;
/** How a discovery section resolves dynamically: Person, Genre, Studio, Similar, Collection, … */
discoveryType?: string;
source?: string;
/** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */
pageNumber?: number;
// Seasonal Config
startDate?: string;
endDate?: string;
// Cache Settings
ttl?: number;
}
interface Query {
name?: string; // Optional display name for multi-query picker and section title overrides
viewMoreUrl?: string; // Optional per-query title link when using multi-query picker
_sourceType?: "jellyfin" | "cache" | "static";
path?: string; // Custom endpoint path (e.g., '/Shows/NextUp', '/Shows/Upcoming')
dataSource?: string; // Cache/custom data source (e.g., 'MoviesCache.getImdbTop250Movies')
minAge?: number; // Minimum age in days
maxAge?: number; // Maximum age in days
queryOptions?: {
// Query parameters for /Items or custom endpoints
Ids?: string[];
IncludeItemTypes?: string[];
SearchTerm?: string;
SortBy?: string;
SortOrder?: "Ascending" | "Descending";
Limit?: number;
Filters?: string;
Genres?: string;
GenreIds?: string;
Tags?: string;
PersonIds?: string;
Studios?: string;
StudioIds?: string;
ParentId?: string;
ExcludeItemIds?: string;
Fields?: string;
[key: string]: any; // Allow additional query params
};
}
interface HomeScreenSectionGroup {
author?: string;
name?: string;
description?: string;
sections: Array<HomeScreenSection>;
}
interface SpotlightConfig {
spotlightLayout: string;
spotlightSize: string;
tileCount: number;
autoPlay: boolean;
interval: number;
showDots: boolean;
showNavButtons: boolean;
showClearArt: boolean;
panAnimation: boolean;
entranceAnimationFirst: string;
entranceAnimationSecond: string;
entranceAnimationThird: string;
slideAnimationFirst: string;
slideAnimationSecond: string;
slideAnimationThird: string;
cycleBackdrops: boolean;
cycleBackdropsTime?: number; // Legacy: use backdropsCount + interval instead
backdropsCount?: number;
}
// Used for the customPrefs.kefinTweaks.homeScreen property
// All values are arrays of semi-colon separated strings
interface KefinHomeScreen {
sections: Array<string>; // id;enabled;order;ttl;cardFormat;animationEnabled;hideName;hideCardTitles;cardTitlePosition;borderStyle;spotlightLayout;spotlightSize;spotlightTileCount;itemsLayout(row|grid; legacy true→grid);cardTitleCapitalization;cardTitleFontFamily;cardTitleFontSize;borderColor;cardTitleColor;useGaplessCards
pinnedLists: Array<string>; // name;id1;id2;id3;...
pinnedParents: Array<string>; // name;id;type
}
/** Non-home layout/appearance prefs (same semicolon section strings as homeScreen.sections). */
interface KefinSectionState {
sections: Array<string>;
}
/*
User configuration for KefinTweaks Home Screen sections will be stored in the user's custom display preferences as kefinHomeScreen
Additionally, kefinPinnedItems and kefinPinnedParents will store an array of item IDs that the user has pinned to the home screen.
The kefinPinnedItems are individual items intended to appear in a Home Screen Section called "Pinned"
The kefinPinnedParents are parent items that should each have their own Home Screen Section which loads their child items.
For example, a user could pin an MCU collection to their home screen as an item, and it would appear in the Pinned row with the MCU Collection poster.
By contrast, the user could choose to pin the MCU collection as a parent item, which would make the child items of the MCU Collection appear in a section called "MCU Collection".
*/
// An array of these sections is saved to the user's preferences as kefinHomeScreen
// These can be mapped by ID to override the section configuration provided by the server
interface KefinHomeScreenSectionUserConfig {
id: string;
enabled?: boolean;
order?: number;
ttl?: number;
cardFormat?: string;
animationEnabled?: boolean;
hideName?: boolean;
hideCardTitles?: boolean;
cardTitlePosition?: string;
borderStyle?: string;
borderColor?: string;
spotlightLayout?: string;
spotlightSize?: string;
spotlightTileCount?: number;
/** Items layout: row (default) or grid. Replaces legacy gridExpanded. */
itemsLayout?: "row" | "grid";
/** @deprecated Prefer itemsLayout; true maps to itemsLayout 'grid' */
gridExpanded?: boolean;
/** When true, remove gaps between cards (row or grid). */
useGaplessCards?: boolean;
cardTitleCapitalization?: string;
cardTitleFontFamily?: string;
cardTitleFontSize?: string;
}
+319 -243
View File
@@ -13,6 +13,87 @@
throw new Error('ApiClient is not available');
}
}
const DEFAULT_ITEM_FIELDS = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount';
const SPOTLIGHT_ITEM_FIELDS = `${DEFAULT_ITEM_FIELDS},People,Genres,ParentBackdropImageTags,Studios`;
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
function getDefaultFields(isSpotlight) {
return isSpotlight ? SPOTLIGHT_ITEM_FIELDS : DEFAULT_ITEM_FIELDS;
}
/**
* Resolve Fields param for a query based on section type and explicit queryOptions.Fields.
* @param {Object} queryOptions
* @param {{ isSpotlight?: boolean, sectionType?: string }} context
* @returns {string}
*/
function resolveQueryFields(queryOptions, { isSpotlight = false, sectionType = '' } = {}) {
const explicit = queryOptions?.Fields;
if (!explicit) {
return getDefaultFields(isSpotlight);
}
const isDefaultHome = sectionType === 'home';
if (isDefaultHome && !isSpotlight) {
return mergeFields(null, explicit);
}
if (isDefaultHome && isSpotlight) {
return mergeFields(SPOTLIGHT_ITEM_FIELDS, explicit);
}
return mergeFields(getDefaultFields(isSpotlight), explicit);
}
function getNextUpDateCutoff(userId) {
const raw = userId ? localStorage.getItem(`${userId}-maxDaysForNextUp`) : null;
const days = raw != null && raw !== '' ? parseInt(raw, 10) : 365;
const maxDays = Number.isFinite(days) && days >= 0 ? days : 365;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - maxDays);
return cutoff.toISOString().split('T')[0];
}
function getEnableRewatchingInNextUp(userId) {
if (!userId) return false;
return localStorage.getItem(`${userId}-enableRewatchingInNextUp`) === 'true';
}
function mergeFields(base, extra) {
const seen = new Set();
const result = [];
const add = (value) => {
if (value == null || value === '') return;
const parts = Array.isArray(value) ? value : String(value).split(',');
for (const part of parts) {
const field = String(part).trim();
if (!field) continue;
const key = field.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(field);
}
};
add(base);
add(extra);
return result.join(',');
}
function applyQueryOptionsToParams(params, queryOptions, { skipFields = false } = {}) {
Object.entries(queryOptions || {}).forEach(([key, value]) => {
if (value === undefined || value === null) return;
if (key === 'Limit' && value === 0) return;
if (key === 'Fields') {
if (skipFields) return;
params.set('Fields', mergeFields(params.get('Fields'), value));
return;
}
if (Array.isArray(value)) {
const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ',';
params.set(key, value.join(delimiter));
return;
}
params.set(key, value);
});
}
/**
* Retrieves the active playback session for this device
@@ -21,7 +102,6 @@
async function getActiveSession() {
ensureApiClient();
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const deviceId = ApiClient.deviceId();
@@ -39,7 +119,7 @@
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': getAuthHeader()
}
});
@@ -71,7 +151,6 @@
const session = await getActiveSession();
const sessionId = session.Id;
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const limitedIds = ids.slice(0, MAX_PLAY_NOW_IDS);
@@ -102,7 +181,7 @@
const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
method: 'POST',
headers: {
'X-Emby-Token': token,
'Authorization': getAuthHeader(),
}
});
@@ -115,7 +194,6 @@
const session = await getActiveSession();
const sessionId = session.Id;
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const params = new URLSearchParams({
@@ -131,7 +209,7 @@
const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
method: 'POST',
headers: {
'X-Emby-Token': token
'Authorization': getAuthHeader()
}
});
@@ -157,11 +235,12 @@
}
/**
* Build MediaBrowser Authorization header
* @returns {string} - Authorization header
* Build MediaBrowser Authorization header for an explicit access token.
* When token is empty/missing, Token is omitted (required for AuthenticateByName).
* @param {string} [token]
* @returns {string}
*/
function getAuthHeader() {
const token = ApiClient.accessToken();
function getAuthHeaderForToken(token) {
const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web';
const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser');
const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : '';
@@ -170,11 +249,23 @@
`Client="${encodeURIComponent(client)}"`,
`Device="${encodeURIComponent(device)}"`,
`DeviceId="${encodeURIComponent(deviceId)}"`,
`Version="${encodeURIComponent(version)}"`,
`Token="${encodeURIComponent(token)}"`
`Version="${encodeURIComponent(version)}"`
];
if (token) {
parts.push(`Token="${encodeURIComponent(token)}"`);
}
return `MediaBrowser ${parts.join(', ')}`;
}
/**
* Build MediaBrowser Authorization header for the current ApiClient session.
* @returns {string} - Authorization header
*/
function getAuthHeader() {
return getAuthHeaderForToken(typeof ApiClient !== 'undefined' && ApiClient.accessToken
? ApiClient.accessToken()
: '');
}
/**
* Query cache for API responses
@@ -251,11 +342,36 @@
const state = {
cachedServerVersion: null
}
/**
* Build a single top-level Likes query def (shared by getWatchlistItems / getWatchlistQuery).
* Callers pass IncludeItemTypes (and other options) to narrow results.
* @param {Object} options
* @returns {{ queryOptions: Object }}
*/
function buildWatchlistQueryDef(options = {}) {
return {
queryOptions: {
Recursive: true,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
...options,
Filters: 'Likes'
}
};
}
/**
* API Helper functions for Jellyfin operations
*/
const apiHelper = {
/**
* Get the MediaBrowser Authorization header for an explicit access token
* @param {string} [token]
* @returns {string}
*/
getAuthHeaderForToken: getAuthHeaderForToken,
/**
* Get the MediaBrowser Authorization header
* @returns {string} - Authorization header
@@ -274,7 +390,7 @@
ensureApiClient();
const useCache = options.useCache || false;
let ttl = 300000;
let ttl = 60000;
if (Number(options.ttl) >= 0) {
ttl = Number(options.ttl);
}
@@ -284,10 +400,9 @@
// Standard fetch function
const fetchData = async () => {
try {
const token = ApiClient.accessToken();
const response = await fetch(url, {
headers: {
'X-Emby-Token': token,
'Authorization': getAuthHeader(),
'Accept': 'application/json'
}
});
@@ -382,8 +497,11 @@
LOG(`[StaleCheck] Cache valid (${(age/1000).toFixed(1)}s old) -> Not Stale`);
return false; // Valid
}
LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Refreshing...`);
return true;
LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`);
/* LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`);
// Cache is expired, check if we can validate it
// Logic: Check SortBy
@@ -403,10 +521,9 @@
checkUrl.searchParams.set('Limit', '3');
//checkUrl.searchParams.set('StartIndex', '0'); // Ensure start
const token = ApiClient.accessToken();
const checkResponse = await fetch(checkUrl.toString(), {
headers: {
'X-Emby-Token': token,
'Authorization': getAuthHeader(),
'Accept': 'application/json'
}
});
@@ -474,17 +591,28 @@
} catch (e) {
WARN('Error validating cache:', e);
return true; // Assume stale on error
}
} */
});
// 3. Define data promise
result.dataPromise = result.isStalePromise.then(isStale => {
if (isStale) {
LOG(`[Fetch] Refreshing data for: ${url.substring(0, 60)}...`);
return fetchData();
} else {
LOG(`[Fetch] Using cached data for: ${url.substring(0, 60)}...`);
return result.data; // Should be set by isStalePromise logic if valid
// 3. Lazy data promise — do not start fetchData until ensureData()/dataPromise is used
result.ensureData = function() {
if (!result._dataPromise) {
result._dataPromise = result.isStalePromise.then(isStale => {
if (isStale) {
LOG(`[Fetch] Refreshing data for: ${url}...`);
return fetchData();
}
LOG(`[Fetch] Using cached data for: ${url}...`);
return result.data;
});
}
return result._dataPromise;
};
Object.defineProperty(result, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return result.ensureData();
}
});
@@ -492,6 +620,7 @@
// Initial check for cached data
const entry = await cachePromise;
result.data = entry ? entry.payload : null;
result.isStale = forceRefresh || !entry || (Date.now() - entry.timestamp > ttl);
return result;
})();
},
@@ -530,10 +659,9 @@
// Fetch from API
try {
const token = ApiClient.accessToken();
const response = await fetch(url, {
headers: {
'X-Emby-Token': token,
'Authorization': getAuthHeader(),
'Accept': 'application/json'
}
});
@@ -558,60 +686,94 @@
},
getWatchlistItems: async function(options = {}, useCache = false) {
// First fetch the Library Items that contain items of the supported types from ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders"))
const libraryItems = await window.dataHelper.getLibraries();
// We now want to include the library items with ColletionType "tvshows", or "movies"
const supportedCollectionTypes = ['tvshows', 'movies', 'homevideos', 'boxsets', 'playlists'];
const supportedLibraryItems = libraryItems.filter(item => supportedCollectionTypes.includes(item.CollectionType) || !item.CollectionType);
// Now use these support library items as the parent ids for the Items query, since the Items endpoint doesn't support ParentIds as an array we need to make a different call for each library. Querying with the parent id will dramatically reduce the time for the request to return so this is fine
let watchlistItems = [];
for (const libraryItem of supportedLibraryItems.reverse()) {
// Get the item types to include based on the library type. if its a Movies library only use the Movie type. If it's tvshows then use Series, Season and Episode
let itemTypes = options.IncludeItemTypes.split(',');
if (itemTypes.length > 1) {
if (libraryItem.CollectionType === 'movies') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie');
} else if (libraryItem.CollectionType === 'tvshows') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Series' || item === 'Season' || item === 'Episode');
} else if (libraryItem.CollectionType === 'boxsets') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'BoxSet');
} else if (libraryItem.CollectionType === 'playlists') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Playlist');
} else if (libraryItem.CollectionType === 'homevideos') {
//itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Video');
} else if (!libraryItem.CollectionType) {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie' || item === 'Series' || item === 'Season' || item === 'Episode' || item === 'Video');
}
}
// Use getItems to fetch the items for the library item
const data = await this.getItems({
ParentId: libraryItem.Id,
Filters: 'Likes',
IncludeItemTypes: itemTypes.join(','),
Recursive: true,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
...options
}, useCache);
//const url = `${ApiClient.serverAddress()}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${ApiClient.getCurrentUserId()}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb&ParentId=${libraryItem.Id}`;
//const data = await this.getData(url, useCache);
watchlistItems.push(...data.Items);
}
// Deuplicate any duplicate Id's in the watchlistItems array
watchlistItems = watchlistItems.filter((item, index, self) =>
index === self.findIndex((t) => t.Id === item.Id)
);
const query = buildWatchlistQueryDef(options);
const data = await this.getItems({ ...query.queryOptions }, useCache);
const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
return {
Items: watchlistItems,
TotalRecordCount: watchlistItems.length
Items: items,
TotalRecordCount: data?.TotalRecordCount ?? items.length
};
},
/**
* Progressive watchlist query (single top-level Likes getQuery).
* @param {Object} options - Same shape as getWatchlistItems (IncludeItemTypes, Fields, ...)
* @param {Object} [cacheOptions]
* @param {number} [cacheOptions.ttl=300000]
* @param {boolean} [cacheOptions.forceRefresh=false]
* @returns {Promise<Object>} Progressive result: { data, isStale, isStalePromise, ensureData, dataPromise, urls }
*/
getWatchlistQuery: async function(options = {}, cacheOptions = {}) {
ensureApiClient();
let ttl = 300000;
if (Number(cacheOptions.ttl) >= 0) {
ttl = Number(cacheOptions.ttl);
}
const forceRefresh = !!cacheOptions.forceRefresh;
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const query = buildWatchlistQueryDef(options);
const url = this.buildQueryFromSection(query, userId, serverUrl);
if (typeof url !== 'string') {
throw new Error('Invalid watchlist query URL');
}
const queryResult = await this.getQuery(url, {
useCache: true,
ttl,
forceRefresh
});
const normalize = (data) => {
const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
return { Items: items, TotalRecordCount: data?.TotalRecordCount ?? items.length };
};
let mappedDataPromise = null;
const ensureData = () => {
if (!mappedDataPromise) {
const raw = typeof queryResult.ensureData === 'function'
? queryResult.ensureData()
: queryResult.dataPromise;
mappedDataPromise = Promise.resolve(raw).then(normalize);
}
return mappedDataPromise;
};
const result = {
data: normalize(queryResult.data),
isStale: queryResult.isStale === true,
isStalePromise: queryResult.isStalePromise || Promise.resolve(false),
ensureData,
urls: [url]
};
Object.defineProperty(result, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return ensureData();
}
});
return result;
},
/**
* Invalidate cached watchlist query URL for the given IncludeItemTypes options.
* @param {Object} [options]
*/
invalidateWatchlistQueries: async function(options = {}) {
ensureApiClient();
const query = buildWatchlistQueryDef(options);
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const url = this.buildQueryFromSection(query, userId, serverUrl);
if (typeof url === 'string') {
this.invalidateCache(url);
}
},
/**
* Get items from Jellyfin API with optional caching
@@ -688,7 +850,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': ApiClient.accessToken()
'Authorization': getAuthHeader()
},
body: JSON.stringify({
Rating: rating,
@@ -764,7 +926,6 @@
throw new Error('No active session found');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
// Build NowPlayingQueue array - each item needs at least an Id
@@ -784,7 +945,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': getAuthHeader()
},
body: JSON.stringify(payload)
});
@@ -822,9 +983,10 @@
* @param {string} serverUrl - Server URL
* @returns {string|Object} - Query URL string or dataSource marker object
*/
buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false) {
buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false, options = {}) {
ensureApiClient();
const sectionType = options.sectionType || '';
const queryOptions = { ...(query.queryOptions || {}) };
// Convert minAge/maxAge to premiere date range
@@ -832,31 +994,29 @@
const now = new Date();
if (query.maxAge !== undefined) {
// maxAge days ago = minimum premiere date (oldest content)
const minDate = new Date(now);
minDate.setDate(minDate.getDate() - query.maxAge);
queryOptions.MinPremiereDate = minDate.toISOString().split('T')[0];
}
if (query.minAge !== undefined) {
// minAge days ago = maximum premiere date (newest content)
const maxDate = new Date(now);
maxDate.setDate(maxDate.getDate() - query.minAge);
queryOptions.MaxPremiereDate = maxDate.toISOString().split('T')[0];
}
}
// Build query based on type
const buildContext = { isSpotlight, sectionType };
if (query.path) {
return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl);
return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl, buildContext);
}
if (query.dataSource) {
return { dataSource: query.dataSource, options: queryOptions };
}
// Standard /Items query
return this.buildStandardQuery(queryOptions, userId, serverUrl, isSpotlight);
return this.buildStandardQuery(queryOptions, userId, serverUrl, buildContext);
},
/**
@@ -864,48 +1024,21 @@
* @param {Object} queryOptions - Query options
* @param {string} userId - User ID
* @param {string} serverUrl - Server URL
* @param {boolean} isSpotlight - Whether the query is for a spotlight section
* @param {{ isSpotlight?: boolean, sectionType?: string }} context
* @returns {string} - Query URL
*/
buildStandardQuery: function(queryOptions, userId, serverUrl, isSpotlight = false) {
// Fields that require pipe delimiter instead of comma
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
buildStandardQuery: function(queryOptions, userId, serverUrl, context = {}) {
if (context === true || context === false) {
context = { isSpotlight: context === true };
}
const params = new URLSearchParams({
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData',
UserId: userId
Fields: resolveQueryFields(queryOptions, context),
});
if (isSpotlight) {
params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios');
}
// Map all queryOptions to params
Object.entries(queryOptions || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
// Use pipe delimiter for specific fields, comma for others
const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ',';
params.set(key, value.join(delimiter));
} else {
// If the key is Limit and the value is 0, do not set the param
if (key === 'Limit' && value === 0) {
return;
}
// If the key is Fields and this is a spotlight query, merge the fields with the existing fields to ensure we always pull People/Genres/ParentBackdropImageTags/Studios
if (key === 'Fields' && isSpotlight) {
const newValue = params.get('Fields') + ',' + value;
params.set(key, newValue);
return;
}
params.set(key, value);
}
}
});
return `${serverUrl}/Items?${params.toString()}`;
applyQueryOptionsToParams(params, queryOptions, { skipFields: true });
return userId ? `${serverUrl}/Users/${userId}/Items?${params.toString()}` : `${serverUrl}/Items?${params.toString()}`;
},
/**
@@ -914,41 +1047,41 @@
* @param {Object} queryOptions - Query options
* @param {string} userId - User ID
* @param {string} serverUrl - Server URL
* @param {{ isSpotlight?: boolean, sectionType?: string }} context
* @returns {string} - Query URL
*/
buildCustomEndpoint: function(path, queryOptions, userId, serverUrl) {
// Fields that require pipe delimiter instead of comma
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
const params = new URLSearchParams({
UserId: userId
});
// Add query options to params
Object.entries(queryOptions || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
// Use pipe delimiter for specific fields, comma for others
const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ',';
params.set(key, value.join(delimiter));
} else {
// If the key is Limit and the value is 0, do not set the param
if (key === 'Limit' && value === 0) {
return;
}
params.set(key, value);
}
}
});
// Add default fields for custom endpoints if not specified
if (!params.has('Fields')) {
params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,MediaSourceCount,UserData');
buildCustomEndpoint: function(path, queryOptions, userId, serverUrl, context = {}) {
if (context === true || context === false) {
context = { isSpotlight: context === true };
}
const params = new URLSearchParams({
UserId: userId,
Fields: resolveQueryFields(queryOptions, context),
});
applyQueryOptionsToParams(params, queryOptions, { skipFields: true });
if (path === '/Shows/NextUp') {
// If NextUpDateCutoff is not set, set it to the current date
if (!params.has('NextUpDateCutoff') && queryOptions.NextUpDateCutoff === undefined) {
params.set('NextUpDateCutoff', getNextUpDateCutoff(userId));
}
// If EnableRewatching is not set, set it to the current value
if (!params.has('EnableRewatching') && queryOptions.EnableRewatching === undefined) {
params.set('EnableRewatching', getEnableRewatchingInNextUp(userId) ? 'true' : 'false');
}
// If EnableResumable is not set, set it to false
if (!params.has('EnableResumable') && queryOptions.EnableResumable === undefined) {
params.set('EnableResumable', 'false');
}
}
if (!params.has('EnableTotalRecordCount')) {
params.set('EnableTotalRecordCount', 'false');
}
return `${serverUrl}${path}?${params.toString()}`;
},
@@ -1033,16 +1166,12 @@
// Combine all items from all queries
const allItems = [];
const itemsByQuery = [];
const allPromises = [];
const promisesByQuery = [];
results.forEach(result => {
if (result.data?.Items) {
allItems.push(...result.data.Items);
itemsByQuery.push(result.data.Items);
}
allPromises.push(result.dataPromise);
promisesByQuery.push(result.dataPromise);
});
// Sort merged items if sortBy specified
@@ -1064,99 +1193,46 @@
});
} */
// Create merged data promise
const dataPromisesByQuery = Promise.all(allPromises).then(allData => {
const dataPromises = [];
allData.forEach(data => {
if (data?.Items) {
dataPromises.push(data.Items);
}
});
// Sort merged results
/* if (sectionConfig.sortBy) {
mergedItems.sort((a, b) => {
const aVal = a[sectionConfig.sortBy] || (a.UserData?.[sectionConfig.sortBy] ? new Date(a.UserData[sectionConfig.sortBy]).getTime() : 0);
const bVal = b[sectionConfig.sortBy] || (b.UserData?.[sectionConfig.sortBy] ? new Date(b.UserData[sectionConfig.sortBy]).getTime() : 0);
if (sectionConfig.sortBy === 'DatePlayed') {
const aDate = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0;
const bDate = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0;
const comparison = aDate > bDate ? 1 : aDate < bDate ? -1 : 0;
return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison;
}
const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison;
let dataPromise = null;
const ensureData = function() {
if (!dataPromise) {
dataPromise = Promise.all(results.map((r) => (
typeof r.ensureData === 'function' ? r.ensureData() : r.dataPromise
))).then((allData) => {
const itemsByFreshQuery = [];
allData.forEach((data) => {
if (data?.Items) {
itemsByFreshQuery.push(data.Items);
}
});
return window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByFreshQuery);
});
} */
return dataPromises;
});
}
return dataPromise;
};
/* const dataPromisesByQuery = Promise.all(promisesByQuery).then(allData => {
const dataPromises = [];
allData.forEach(data => {
if (data?.Items) {
dataPromises.push(data.Items);
}
});
return dataPromises;
}); */
const mergedResult = {
data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) },
isStale: results.some((r) => r.isStale),
isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false)))
.then(staleFlags => staleFlags.some(s => s)),
ensureData
};
Object.defineProperty(mergedResult, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return ensureData();
}
});
return {
config: sectionConfig,
result: {
data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) },
dataPromise: dataPromisesByQuery.then(data => window.cardBuilder.postProcessItemsByQuery(sectionConfig, data)),
isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false)))
.then(staleFlags => staleFlags.some(s => s))
}
result: mergedResult
};
},
isAdmin: isAdmin,
/**
* Get the current Jellyfin version from ApiClient
* Polls every 500ms for up to 5 seconds in the background if not immediately available
* @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable
*/
getJellyfinVersion: async function() {
try {
// If we have a cached value, return it
if (state.cachedServerVersion !== null) {
return state.cachedServerVersion;
}
if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) {
return null;
}
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
state.cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion);
return state.cachedServerVersion;
}
// Check the server version instead of app version
if (!window.ApiClient._serverVersion) {
// Wait 10s to see if it becomes ready, check every 500ms
const startTime = Date.now();
while (Date.now() - startTime < 10000) {
if (window.ApiClient._serverVersion) {
break;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
state.cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion);
return state.cachedServerVersion;
} catch (error) {
WARN('Error getting server version:', error);
return null;
}
},
isAdmin: isAdmin,
/**
* Get the list of plugins from the server
+101 -100
View File
@@ -1,126 +1,127 @@
/* KefinTweaks Breadcrumbs Styles */
.kefinTweaks-breadcrumbs-wrapper {
display: none;
display: none;
}
@media (min-width: 768px) {
.kefinTweaks-breadcrumbs-wrapper {
display: block;
margin-left: -.5em;
transition: background 0.2s ease;
}
.kefinTweaks-breadcrumbs-wrapper {
display: block;
margin-left: -0.5em;
transition: background 0.2s ease;
}
.kefinTweaks-breadcrumbs-wrapper:hover {
background: rgba(0, 0, 0, 0.5);
border-radius: 0.5em;
}
.kefinTweaks-breadcrumbs-wrapper:hover {
background: rgba(0, 0, 0, 0.5);
border-radius: 0.5em;
}
.kefinTweaks-breadcrumbs {
color: rgba(255, 255, 255, 0.8);
font-size: 1.17em;
font-family: inherit;
pointer-events: auto;
position: relative;
padding: 8px 12px;
border-radius: 4px;
word-wrap: break-word;
display: inline-block;
}
.kefinTweaks-breadcrumbs {
color: rgba(255, 255, 255, 0.8);
font-size: 1.17em;
font-family: inherit;
pointer-events: auto;
position: relative;
padding: 8px 12px;
border-radius: 4px;
word-wrap: break-word;
display: inline-block;
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1001;
pointer-events: all;
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1001;
pointer-events: all;
}
/* When breadcrumbs are at bottom of page, open popover above */
.kefinTweaks-popover-container.kefinTweaks-popover-container--above {
top: auto;
bottom: 100%;
}
/* When breadcrumbs are at bottom of page, open popover above */
.kefinTweaks-popover-container.kefinTweaks-popover-container--above {
top: auto;
bottom: 100%;
}
.kefinTweaks-popover.kefinTweaks-popover--above {
margin-top: 0;
margin-bottom: 8px;
}
.kefinTweaks-popover.kefinTweaks-popover--above {
margin-top: 0;
margin-bottom: 8px;
}
.kefinTweaks-breadcrumbs span {
display: inline;
}
.kefinTweaks-breadcrumbs span {
display: inline;
}
.kefinTweaks-breadcrumbs span:hover {
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-breadcrumbs span:hover {
color: rgba(255, 255, 255, 1);
}
/* Hover effects for clickable breadcrumbs only */
.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
text-decoration: underline !important;
}
/* Hover effects for clickable breadcrumbs only */
.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
text-decoration: underline !important;
}
/* Popover styles for seasons and albums */
.kefinTweaks-popover {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 0;
max-height: 300px;
overflow-y: auto !important;
z-index: 1001;
display: block;
margin-top: 8px;
}
/* Popover styles for seasons and albums */
.kefinTweaks-popover {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 0;
max-height: 300px;
overflow-y: auto !important;
z-index: 1001;
display: block;
margin-top: 8px;
}
.kefinTweaks-popover-item {
padding: 8px 16px 8px 24px;
cursor: pointer;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
.kefinTweaks-popover-item {
padding: 8px 16px 8px 24px;
cursor: pointer;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
.kefinTweaks-popover-item:hover {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-popover-item:hover {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-popover-item:last-child {
border-bottom: none;
}
.kefinTweaks-popover-item:last-child {
border-bottom: none;
}
.kefinTweaks-popover-item.selected {
color: rgba(255, 255, 255, 1);
font-weight: bold;
}
.kefinTweaks-popover-item.selected {
color: rgba(255, 255, 255, 1);
font-weight: bold;
}
.kefinTweaks-popover-item.selected::before {
content: '>';
position: absolute;
left: 8px;
top: calc(50% - 1px);
transform: translateY(-50%);
font-weight: bold;
}
.kefinTweaks-popover-item.selected::before {
content: ">";
position: absolute;
left: 8px;
top: calc(50% - 1px);
transform: translateY(-50%);
font-weight: bold;
}
.skinHeader {
contain: none;
}
.skinHeader {
contain: none;
}
.headerLeft {
overflow: visible;
}
.headerLeft {
overflow: visible;
}
}
.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) {
padding: 0 !important;
overflow-y: auto !important;
backdrop-filter: blur(50px);
padding: 0 !important;
overflow-y: auto !important;
backdrop-filter: blur(50px);
}
.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) .kefinTweaks-popover-item.detailsGroupItem {
padding: .5em 2em;
margin: 0 !important;
border-radius: 0 !important;
width: initial !important;
max-width: initial !important;
}
padding: 0.5em 2em;
margin: 0 !important;
border-radius: 0 !important;
width: initial !important;
max-width: initial !important;
white-space: nowrap;
}
+3181 -1000
View File
File diff suppressed because it is too large. Load diff
+3261 -860
View File
File diff suppressed because it is too large. Load diff
+218 -110
View File
@@ -1,184 +1,292 @@
.kefin-config-button {
-webkit-tap-highlight-color: transparent;
background-color: transparent;
cursor: pointer;
user-select: none;
vertical-align: middle;
appearance: none;
color: inherit;
display: flex;
-webkit-box-flex: 1;
flex-grow: 1;
-webkit-box-pack: start;
justify-content: flex-start;
-webkit-box-align: center;
align-items: center;
position: relative;
min-width: 0px;
box-sizing: border-box;
text-align: left;
padding-top: 8px;
padding-bottom: 8px;
padding-left: 16px;
padding-right: 16px;
outline: 0px;
border-width: 0px;
border-style: initial;
border-color: initial;
border-image: initial;
margin: 0px;
border-radius: 0px;
text-decoration: none;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
background-color: transparent;
cursor: pointer;
user-select: none;
vertical-align: middle;
appearance: none;
color: inherit;
display: flex;
-webkit-box-flex: 1;
flex-grow: 1;
-webkit-box-pack: start;
justify-content: flex-start;
-webkit-box-align: center;
align-items: center;
position: relative;
min-width: 0px;
box-sizing: border-box;
text-align: left;
padding-top: 8px;
padding-bottom: 8px;
padding-left: 16px;
padding-right: 16px;
outline: 0px;
border-width: 0px;
border-style: initial;
border-color: initial;
border-image: initial;
margin: 0px;
border-radius: 0px;
text-decoration: none;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.kefin-config-button:hover {
background-color: var(--jf-palette-action-hover);
background-color: var(--jf-palette-action-hover);
}
[data-kefintweaks-dashboard-config-button] span.material-icons.MuiSvgIcon-root {
display: block;
color: transparent;
background: var(--kefinTweaksLogo);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
width: 100%;
height: 36px;
}
[data-kefintweaks-user-menu-config-button] span.material-icons.MuiSvgIcon-root {
display: block;
color: transparent;
background: var(--kefinTweaksLogo);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
width: 24px;
height: 24px;
}
[data-kefintweaks-config-button] span.material-icons.listItemIcon {
display: block;
color: transparent;
background: var(--kefinTweaksLogo) !important;
background-size: contain !important;
background-position: center !important;
background-repeat: no-repeat !important;
width: 100%;
height: 36px;
}
.customMenuOptions {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
.customMenuOptions > * {
order: 1;
order: 1;
}
/* Generic toggle card / switch (shared across config modals) */
.kefin-toggle-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
padding: 0.85em 1em;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
}
.kefin-toggle-card-text {
flex: 1;
min-width: 0;
}
.kefin-toggle-card-label {
font-weight: 500;
}
.kefin-toggle-card-desc {
font-size: 0.8125rem;
margin-top: 0.25em;
line-height: 1.45;
}
.kefin-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-toggle-switch[data-enabled="true"] {
background: rgba(0, 164, 220, 0.8);
}
.kefin-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-toggle-switch[data-enabled="true"]::after {
left: calc(100% - 25px);
}
.navMenuOption[data-name="configure"] {
order: 9999;
margin-top: 3em !important;
position: relative;
order: 9999;
margin-top: 3em !important;
position: relative;
}
.navMenuOption[data-name="configure"]>.material-icons {
position: relative;
.navMenuOption[data-name="configure"] > .material-icons {
position: relative;
}
.navMenuOption[data-name="configure"]>.material-icons::after {
content: 'KefinTweaks';
position: absolute;
top: -2.5em;
left: -1.25em;
font-family: 'Noto Sans';
font-size: 14.88px;
.navMenuOption[data-name="configure"] > .material-icons::after {
content: "KefinTweaks";
position: absolute;
top: -2.5em;
left: -1.25em;
font-family: "Noto Sans";
font-size: 14.88px;
}
.listItemContent {
width: 100%;
width: 100%;
}
#kefinTweaksConfigModal .listItem {
align-items: start;
overflow: visible;
align-items: start;
overflow: visible;
}
[dir="ltr"]
.kefin-config-wrapper
.listItem.feature-card:not(.actionSheetMenuItem, .childrenItemsContainer .listItem, .activityLogListWidget .listItem) {
padding: 0.75em !important;
}
#customSections_list .listItem {
overflow: visible;
overflow: visible;
}
/* Autocomplete suggestions styling */
.autocomplete-suggestions {
background: #1a1a1a !important;
color: rgba(255,255,255,0.87) !important;
box-shadow: 0 4px 8px rgba(0,0,0,0.5) !important;
opacity: 1 !important;
z-index: 10000 !important;
background: #1a1a1a !important;
color: rgba(255, 255, 255, 0.87) !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5) !important;
opacity: 1 !important;
z-index: 10000 !important;
}
.autocomplete-suggestion {
color: rgba(255,255,255,0.87) !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.87) !important;
background: transparent !important;
}
.autocomplete-suggestion:hover {
background: rgba(255,255,255,0.1) !important;
background: rgba(255, 255, 255, 0.1) !important;
}
#kefinTweaksConfigModal .checkboxContainer, #kefinTweaksConfigModal .checkboxListContainer {
margin-bottom: 0 !important;
#kefinTweaksConfigModal .checkboxContainer,
#kefinTweaksConfigModal .checkboxListContainer {
margin-bottom: 0 !important;
}
#kefinTweaksConfigModal .info-tooltip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 50%;
cursor: help;
color: rgba(255,255,255,0.8);
transition: border-color 0.15s ease, color 0.15s ease;
outline: none;
font-family: 'Material Icons';
font-style: normal;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 50%;
cursor: help;
color: rgba(255, 255, 255, 0.8);
transition:
border-color 0.15s ease,
color 0.15s ease;
outline: none;
font-family: "Material Icons";
font-style: normal;
}
#kefinTweaksConfigModal .info-tooltip:hover,
#kefinTweaksConfigModal .info-tooltip:focus-visible {
border-color: rgba(255,255,255,0.8);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.8);
color: #ffffff;
}
#kefinTweaksConfigModal .info-tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 180%;
left: 50%;
transform: translate(-50%, 0);
background: rgba(0,0,0,0.9);
color: #fff;
padding: 0.4em 0.6em;
border-radius: 4px;
font-size: 0.9rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
z-index: 20;
font-family: 'Noto Sans';
content: attr(data-tooltip);
position: absolute;
bottom: 180%;
left: 50%;
transform: translate(-50%, 0);
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 0.4em 0.6em;
border-radius: 4px;
font-size: 0.9rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition:
opacity 0.15s ease,
transform 0.15s ease;
z-index: 20;
font-family: "Noto Sans";
}
#kefinTweaksConfigModal .info-tooltip:hover::after,
#kefinTweaksConfigModal .info-tooltip:focus-visible::after {
opacity: 1;
transform: translate(-50%, 6px);
opacity: 1;
transform: translate(-50%, 6px);
}
details[open] summary.listItemBodyText::after {
content: '▲';
content: "▲";
}
summary.listItemBodyText::after {
content: '▼';
position: absolute;
bottom: 0px;
left: 0;
right: 0;
text-align: center;
font-size: 0.8rem;
content: "▼";
position: absolute;
bottom: 0px;
left: 0;
right: 0;
text-align: center;
font-size: 0.8rem;
}
summary.listItemBodyText {
position: relative;
position: relative;
}
.kefin-config-button:hover {
background-color: rgba(255, 255, 255, 0.08);
background-color: rgba(255, 255, 255, 0.08);
}
.preview-section-container .custom-scroller-container {
max-width: calc(100% - 4rem);
max-width: calc(100% - 4rem);
}
.navMenuOption[data-name="configure"]:hover:not(.navMenuOption-selected) {
transform: none !important;
margin-top: 3em !important;
transform: none !important;
margin-top: 3em !important;
}
div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_movies_sortOrder),
/* div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_movies_sortOrder),
div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_episodes_sortOrder) {
display: none;
}
} */
.emby-scroller-container[data-section-id].hideIndicators .cardIndicators {
display: none;
}
+250 -136
View File
@@ -218,7 +218,6 @@
kefinTweaksRoot: '',
scripts: {},
homeScreen: {},
exclusiveElsewhere: {},
search: {},
skins: [],
defaultSkin: null,
@@ -663,7 +662,7 @@
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
#${MODAL_ID} .modal-content-wrapper {
:not(.layout-mobile) #${MODAL_ID} .modal-content-wrapper {
max-width: 90vw;
width: 1400px;
max-height: 90vh;
@@ -1178,7 +1177,7 @@
// Build script toggle switches
function buildScriptToggles(scripts) {
// Features that have configuration options (not just toggles)
const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'thumbnailScrubber']);
const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', 'userManager']);
const scriptNames = [
{ key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' },
@@ -1189,21 +1188,44 @@
{ key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' },
{ key: 'headerTabs', label: 'Header Tabs', desc: 'Allows direct navigation to specific Tab sections of a given library page. Full support for default Jellyfin landing pages.' },
{ key: 'customMenuLinks', label: 'Custom Menu Links', desc: 'Add custom menu links to the left side navigation menu' },
{ key: 'hamburgerMenu', label: 'Desktop Hamburger Menu', desc: 'Shows an Open Menu button on desktop (900px+) that opens a left navigation drawer like the mobile layout, including libraries and custom menu links.' },
{ key: 'games', label: 'Games', desc: 'Add a Games link to the side menu for Movie LUT and other library games' },
{ key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' },
{ key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' },
{ key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' },
{ key: 'flattenSingleSeasonShows', label: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' },
{ key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' },
{ key: 'versionPreferences', label: 'Item Version Preferences', desc: 'Manage how different versions and editions of your items are displayed. Reorders and sanitizes the media source dropdown on item details pages by prioritized version and edition terms.' },
{ key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' },
{ key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' },
{ key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' },
{ key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' },
{ key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' },
{ key: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' }
{ key: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' },
{ key: 'watchTogether', label: 'Watch Together', desc: 'Link additional Jellyfin accounts to a Watch Together group, then sync watched / in-progress playstate to selected accounts on this device.' },
{ key: 'userManager', label: 'User Manager', desc: 'Create reusable user policy templates and apply them when adding users or bulk-editing existing accounts. Import/export comes later.' }
];
return scriptNames.map(script => {
const isEnabled = scripts[script.key] !== false; // Default to true if not set
const defsByName = new Map(
(window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def])
);
const major = window.KefinTweaks?._jellyfinMajorVersion
?? (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function'
? window.KefinTweaks.getJellyfinMajorVersion()
: null);
const visibleScripts = scriptNames.filter((script) => {
const def = defsByName.get(script.key);
if (!def?.versions?.length) return true;
if (major == null) return false;
return def.versions.includes(major);
});
return visibleScripts.map(script => {
// Match injector defaults: most scripts on unless set false; opt-in scripts require true
const defaultDisabled = script.key === 'thumbnailScrubber' || script.key === 'watchTogether' || script.key === 'userManager';
const isEnabled = defaultDisabled
? scripts[script.key] === true
: scripts[script.key] !== false;
const hasConfig = featuresWithConfig.has(script.key);
const configKey = script.key === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : script.key;
@@ -2390,8 +2412,8 @@
return `
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Enable Jellyseerr</div>
<div class="listItemBodyText secondary" style="margin-bottom: 0.75em; font-size: 0.9em;">Enable Jellyseerr integration for request functionality</div>
<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>
${buildJellyfinCheckbox('search_enableJellyseerr', search.enableJellyseerr === true, 'Enabled')}
</div>
</div>
@@ -2455,6 +2477,16 @@
currentLoadedConfig = config;
window.KefinTweaksCurrentConfig = config;
// Ensure Jellyfin major is cached so version-gated feature cards filter correctly
try {
if (typeof window.KefinTweaks?.getCurrentMajorServerVersion === 'function') {
const major = await window.KefinTweaks.getCurrentMajorServerVersion();
window.KefinTweaks._jellyfinMajorVersion = major;
}
} catch (e) {
console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e);
}
// Build the content (without title, as ModalSystem adds it)
const content = document.createElement('div');
content.className = 'modal-content-wrapper';
@@ -2494,8 +2526,11 @@
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modalInstance) => {
// Check if window is less than 900px
const layoutMobile = window.innerWidth < 900;
// Update modal dialog styling for large size
if (modalInstance && modalInstance.dialog) {
if (modalInstance && modalInstance.dialog && !layoutMobile) {
modalInstance.dialog.style.maxWidth = '90vw';
modalInstance.dialog.style.width = '1400px';
modalInstance.dialog.style.maxHeight = '90vh';
@@ -4080,7 +4115,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, {
headers: { 'X-Emby-Token': token }
headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -4200,7 +4235,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, {
headers: { 'X-Emby-Token': token }
headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -4320,7 +4355,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, {
headers: { 'X-Emby-Token': token }
headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -4458,7 +4493,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, {
headers: { 'X-Emby-Token': token }
headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -5096,57 +5131,36 @@
}, 500);
}
// Find JavaScript Injector plugin
const JS_INJECTOR_ALIASES = ['JavaScript Injector', 'JS Injector'];
// Find JavaScript Injector plugin (delegates to shared utils cache)
async function findJavaScriptInjectorPlugin() {
try {
if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
throw new Error('ApiClient not available');
if (!window.KefinTweaksUtils?.resolvePluginId) {
throw new Error('KefinTweaksUtils.resolvePluginId is not available');
}
const plugins = await aoiHelper.getPlugins();
console.log('[KefinTweaks Configuration] Active Plugins:', plugins);
// Handle both array and object with Items property
const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []);
// Find JavaScript Injector plugin
const injectorPlugin = pluginsList.find(plugin =>
plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector'
);
if (!injectorPlugin) {
const pluginId = await window.KefinTweaksUtils.resolvePluginId(JS_INJECTOR_ALIASES);
if (!pluginId) {
throw new Error('JavaScript Injector plugin not found. Please ensure it is installed.');
}
console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', injectorPlugin);
return injectorPlugin.Id;
console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', pluginId);
return pluginId;
} catch (error) {
console.error('[KefinTweaks Configuration] Error finding JavaScript Injector plugin:', error);
throw error;
}
}
// Get current JavaScript Injector configuration
// Get current JavaScript Injector configuration (delegates to shared utils)
async function getJavaScriptInjectorConfig(pluginId) {
try {
if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
throw new Error('ApiClient not available');
if (!window.KefinTweaksUtils?.getPluginConfiguration) {
throw new Error('KefinTweaksUtils.getPluginConfiguration is not available');
}
const server = ApiClient._serverAddress;
const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
headers: {
'X-Emby-Token': token
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const config = await response.json();
const config = await window.KefinTweaksUtils.getPluginConfiguration(pluginId || JS_INJECTOR_ALIASES);
console.log('[KefinTweaks Configuration] Current JS Injector config:', config);
return config;
} catch (error) {
@@ -5155,63 +5169,16 @@
}
}
// Save configuration to JavaScript Injector plugin
// Save configuration to JavaScript Injector plugin (delegates to shared utils)
async function saveConfigToJavaScriptInjector(config) {
try {
// Find the plugin
const pluginId = await findJavaScriptInjectorPlugin();
// Get fresh configuration data
const injectorConfig = await getJavaScriptInjectorConfig(pluginId);
// Ensure CustomJavaScripts array exists
if (!injectorConfig.CustomJavaScripts) {
injectorConfig.CustomJavaScripts = [];
if (!window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
throw new Error('KefinTweaksUtils.saveConfigToJavaScriptInjector is not available');
}
// Create the script content
const scriptContent = `// KefinTweaks Configuration
// This file is automatically generated by KefinTweaks Configuration UI
// Do not edit manually unless you know what you're doing
window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
// Check if KefinTweaks-Config already exists
const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
script => script.Name === 'KefinTweaks-Config'
);
if (existingScriptIndex !== -1) {
// Update existing script
console.log('[KefinTweaks Configuration] Updating existing KefinTweaks-Config script');
injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent;
// Keep existing Enabled and RequiresAuthentication settings
} else {
// Add new script
console.log('[KefinTweaks Configuration] Adding new KefinTweaks-Config script');
injectorConfig.CustomJavaScripts.push({
Name: 'KefinTweaks-Config',
Script: scriptContent,
Enabled: true,
RequiresAuthentication: false
});
}
// POST the updated configuration back
const server = ApiClient._serverAddress;
const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
method: 'POST',
headers: {
'X-Emby-Token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify(injectorConfig)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const success = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(config);
if (!success) {
throw new Error('Failed to save configuration to JavaScript Injector');
}
console.log('[KefinTweaks Configuration] Configuration saved successfully');
@@ -5222,6 +5189,14 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
}
// Expose injector helpers for feature-config fallbacks
window.KefinTweaksConfiguration = Object.assign(window.KefinTweaksConfiguration || {}, {
findJavaScriptInjectorPlugin,
getJavaScriptInjectorConfig,
saveConfigToJavaScriptInjector,
openConfigurationModal
});
function getSectionConfigFromUI(rootElement, prefix, sectionIndex) {
const scope = rootElement || document;
const sectionItem = scope.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`);
@@ -5552,8 +5527,158 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
}
function getKefinTweaksRoot() {
const raw = window.KefinTweaksConfig?.kefinTweaksRoot || '';
return raw.endsWith('/') ? raw : `${raw}/`;
}
function getKefinTweaksLogoCssValue() {
const root = getKefinTweaksRoot();
if (!root) return 'none';
return `url('${root}logo.png')`;
}
function ensureKefinTweaksLogoVar() {
document.documentElement.style.setProperty(
'--kefinTweaksLogo',
getKefinTweaksLogoCssValue()
);
}
function closeAppUserMenu() {
const menu = document.getElementById('app-user-menu');
if (!menu) return;
const backdrop = menu.querySelector('.MuiBackdrop-root, .MuiModal-backdrop');
if (backdrop) {
backdrop.click();
return;
}
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true
}));
}
/**
* Create KefinTweaks menu item for Jellyfin v12 #app-user-menu
* @returns {HTMLAnchorElement}
*/
function createAppUserMenuConfigButton() {
ensureKefinTweaksLogoVar();
const link = document.createElement('a');
link.className = 'MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root MuiMenuItem-gutters css-mbeig7';
link.setAttribute('tabindex', '-1');
link.setAttribute('role', 'menuitem');
link.href = '#';
link.setAttribute('data-kefintweaks-user-menu-config-button', 'true');
const icon = document.createElement('div');
icon.className = 'MuiListItemIcon-root';
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
iconSpan.setAttribute('aria-hidden', 'true');
icon.appendChild(iconSpan);
const text = document.createElement('div');
text.className = 'MuiListItemText-root';
const span = document.createElement('span');
span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary';
span.textContent = 'KefinTweaks';
text.appendChild(span);
const ripple = document.createElement('span');
ripple.className = 'MuiTouchRipple-root';
link.appendChild(icon);
link.appendChild(text);
link.appendChild(ripple);
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
closeAppUserMenu();
openConfigurationModal();
});
return link;
}
/**
* Insert KefinTweaks into #app-user-menu before the first HR after Dashboard.
* @returns {boolean} true if button is present after this call
*/
function addConfigButtonToAppUserMenu() {
if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) {
return true;
}
const appUserMenu = document.getElementById('app-user-menu');
if (!appUserMenu) {
return false;
}
const menuList = appUserMenu.querySelector('ul[role="menu"]');
if (!menuList) {
return false;
}
const dashboardLink = menuList.querySelector('a[href="#/dashboard"]')
|| menuList.querySelector('a[href*="#/dashboard"]');
if (!dashboardLink) {
console.log('[KefinTweaks Configuration] #app-user-menu Dashboard link not found');
return false;
}
let divider = dashboardLink.nextElementSibling;
while (divider && !(divider.matches('hr') || divider.classList.contains('MuiDivider-root'))) {
divider = divider.nextElementSibling;
}
if (!divider) {
console.log('[KefinTweaks Configuration] #app-user-menu divider after Dashboard not found');
return false;
}
const button = createAppUserMenuConfigButton();
divider.parentNode.insertBefore(button, divider);
console.log('[KefinTweaks Configuration] Configuration button added to #app-user-menu');
return true;
}
let appUserMenuConfigObserver = null;
/**
* Permanent observer: keep re-injecting if #app-user-menu remounts and our button is lost.
*/
function setupAppUserMenuConfigButtonObserver() {
addConfigButtonToAppUserMenu();
if (appUserMenuConfigObserver) {
return;
}
if (typeof MutationObserver === 'undefined' || !document.body) {
console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)');
return;
}
appUserMenuConfigObserver = new MutationObserver(() => {
addConfigButtonToAppUserMenu();
});
appUserMenuConfigObserver.observe(document.body, {
childList: true,
subtree: true
});
}
// Create configuration button for Administration section
function createConfigButton() {
ensureKefinTweaksLogoVar();
const button = document.createElement('div');
button.style.display = 'block';
button.style.padding = '0';
@@ -5567,9 +5692,9 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
button.innerHTML = `
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent build" aria-hidden="true"></span>
<span class="material-icons listItemIcon listItemIcon-transparent" aria-hidden="true"></span>
<div class="listItemBody">
<div class="listItemBodyText">Configure KefinTweaks</div>
<div class="listItemBodyText">KefinTweaks</div>
</div>
</div>
`;
@@ -5579,13 +5704,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
// Create dashboard side menu button
function createDashboardConfigButton() {
ensureKefinTweaksLogoVar();
// Check if button already exists
if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) {
return null;
}
// Get an existing native button
const nativeButton = document.querySelector('.MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters.MuiListItemButton-root.MuiListItemButton-gutters');
const div = document.createElement('div');
div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters kefin-config-button';
div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp';
div.setAttribute('tabindex', '0');
div.setAttribute('data-kefintweaks-dashboard-config-button', 'true');
div.onclick = () => {
@@ -5594,24 +5724,24 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
// Create icon with Material icon
const icon = document.createElement('div');
icon.className = 'MuiListItemIcon-root css-37qkju';
icon.className = 'MuiListItemIcon-root css-37qkju css-5pks8q';
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons build MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
iconSpan.style.marginRight = '12px';
iconSpan.setAttribute('aria-hidden', 'true');
icon.appendChild(iconSpan);
// Create text
const text = document.createElement('div');
text.className = 'MuiListItemText-root css-f696uz';
text.className = 'MuiListItemText-root css-f696uz css-t3p1pa';
const span = document.createElement('span');
span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz';
span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz css-pl8nxc';
span.textContent = 'KefinTweaks';
text.appendChild(span);
// Create ripple effect span
const ripple = document.createElement('span');
ripple.className = 'MuiTouchRipple-root css-w0pj6f';
ripple.className = 'MuiTouchRipple-root css-w0pj6f css-4mb1j7';
div.appendChild(icon);
div.appendChild(text);
@@ -5649,6 +5779,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
// Add configuration button to Administration section
function addConfigButtonToAdminSection() {
return;
// Check if button already exists (check for admin section button specifically)
if (document.querySelector('.adminSection [data-kefintweaks-config-button]')) {
return;
@@ -5724,43 +5855,24 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return;
}
// Use a special URL that we can intercept
const configUrl = '#';
// Add the menu link
// Admin-only: user menu admin section + default side menu; open via JS action
const success = await window.KefinTweaksUtils.addCustomMenuLink(
'Configure',
'KefinTweaks',
'build',
configUrl,
'',
false,
'.adminMenuOptions'
{
userMenu: true,
isAdminLink: true,
action: 'KefinTweaksConfiguration.openConfigurationModal'
}
);
if (success) {
console.log('[KefinTweaks Configuration] Configuration link added to custom menu');
// Set up click handler to intercept navigation and open modal instead
setupConfigLinkClickHandler();
}
}
// Set up click handler for configuration menu link
function setupConfigLinkClickHandler() {
// Find the config button
const configButton = document.querySelector('.navMenuOption[data-name="configure"]');
if (!configButton) {
console.log('[KefinTweaks Configuration] Configuration button not found, retrying...');
setTimeout(setupConfigLinkClickHandler, 1000);
return;
}
// Add click handler to the config button
configButton.addEventListener('click', (e) => {
e.preventDefault();
openConfigurationModal();
});
}
// Handle direct navigation to config URL
function handleDirectConfigNavigation() {
if (window.location.hash === '#kefintweaks-config') {
@@ -5817,6 +5929,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const userIsAdmin = await isAdmin();
if (userIsAdmin) {
// User-menu KefinTweaks entry is owned by addCustomMenuLink(userMenu + isAdminLink + action)
if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) {
setTimeout(() => {
addConfigButtonToAdminSection();
+50 -35
View File
@@ -1,25 +1,36 @@
// Custom Menu Links Script
// Loads custom menu links from configuration and adds them to the custom menu
// Uses utils.addCustomMenuLink to add each configured link
// Loads custom menu links from configuration and adds them via utils.addCustomMenuLink
// Requires: utils.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks CustomMenuLinks]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks CustomMenuLinks]', ...args);
const ERR = (...args) => console.error('[KefinTweaks CustomMenuLinks]', ...args);
LOG('Script loaded - JS Injector mode');
// Get custom menu links configuration
function getCustomMenuLinksConfig() {
return window.KefinTweaksConfig?.customMenuLinks || [];
}
function normalizeTopNavigation(value) {
const v = String(value || 'none').toLowerCase().trim();
if (v === 'main' || v === 'right') return v;
return 'none';
}
// Initialize custom menu links
async function initializeCustomMenuLinks() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
ERR('KefinTweaksUtils.addCustomMenuLink not available');
return;
}
if (typeof window.KefinTweaksUtils.ensureCustomMenuLinkStyles === 'function') {
window.KefinTweaksUtils.ensureCustomMenuLinkStyles();
}
const customMenuLinks = getCustomMenuLinksConfig();
if (!Array.isArray(customMenuLinks) || customMenuLinks.length === 0) {
@@ -29,27 +40,32 @@
LOG(`Loading ${customMenuLinks.length} custom menu links`);
// Check if utils is available
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
ERR('KefinTweaksUtils.addCustomMenuLink not available');
return;
}
// Add each custom menu link
const addPromises = customMenuLinks.map(async (linkConfig, index) => {
// Sequential so drawer insert order matches config order
let successCount = 0;
for (let index = 0; index < customMenuLinks.length; index++) {
const linkConfig = customMenuLinks[index];
try {
// Validate link configuration
if (!linkConfig.name || !linkConfig.url) {
WARN(`Custom menu link at index ${index} is missing required properties (name, url)`);
return false;
const name = linkConfig?.name;
const url = linkConfig?.url != null ? String(linkConfig.url).trim() : '';
const action = linkConfig?.action != null ? String(linkConfig.action).trim() : '';
if (!name || (!url && !action)) {
WARN(`Custom menu link at index ${index} is missing required properties (name, and url or action)`);
continue;
}
const {
name,
icon = 'link', // Default icon
url,
openInNewTab = false
icon = 'link',
openInNewTab = false,
collapsed = false,
sideMenu = true,
userMenu = false,
isAdminLink = false
} = linkConfig;
const topNavigation = normalizeTopNavigation(linkConfig.topNavigation);
const orderNum = Number(linkConfig.order);
const order = Number.isFinite(orderNum) ? orderNum : undefined;
LOG(`Adding custom menu link: ${name}`);
@@ -57,30 +73,32 @@
name,
icon,
url,
openInNewTab
openInNewTab,
{
collapsed: !!collapsed,
sideMenu: sideMenu !== false,
topNavigation,
userMenu: !!userMenu,
isAdminLink: !!isAdminLink,
action,
order
}
);
if (success) {
successCount += 1;
LOG(`Successfully added custom menu link: ${name}`);
} else {
WARN(`Failed to add custom menu link: ${name}`);
}
return success;
} catch (error) {
ERR(`Error adding custom menu link at index ${index}:`, error);
return false;
}
});
// Wait for all links to be processed
const results = await Promise.all(addPromises);
const successCount = results.filter(Boolean).length;
}
LOG(`Custom menu links initialization complete: ${successCount}/${customMenuLinks.length} links added successfully`);
}
// Wait for utils to be available and then initialize
function waitForUtilsAndInitialize() {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
LOG('Utils available, initializing custom menu links');
@@ -90,7 +108,6 @@
LOG('Waiting for KefinTweaksUtils to be available');
// Poll for utils availability
const checkInterval = setInterval(() => {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
clearInterval(checkInterval);
@@ -99,15 +116,13 @@
}
}, 100);
// Fallback timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
WARN('KefinTweaksUtils not available after 10 seconds');
}, 10000);
}
// Start initialization
waitForUtilsAndInitialize();
LOG('Custom menu links functionality initialized');
})();
})();
+2 -1
View File
@@ -28,7 +28,8 @@ div#seriesScheduleSection {
@media (max-width: 768px) {
.headerRight .btnRecommendations,
.headerRight .btnAdmin,
.headerRight .headerAppearanceButton {
.headerRight .headerAppearanceButton,
.headerRight [data-kefin-custom-menu-top-right] {
display: none;
}
}
+2 -2
View File
@@ -132,7 +132,7 @@
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -266,7 +266,7 @@
return;
}
const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession');
const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession, .MuiStack-root > .MuiPaper-root.MuiPaper-elevation.MuiPaper-rounded.MuiPaper-elevation1.MuiCard-root');
LOG(`Found ${activeSessions.length} active session containers`);
if (activeSessions.length === 0) {
-368
View File
@@ -1,368 +0,0 @@
// KefinTweaks Flatten Single Season Shows
// Flattens series with only 1 season to show episodes directly on the series details page
// Requires: cardBuilder.js, utils.js modules to be loaded before this script
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks FlattenSingleSeasonShows]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks FlattenSingleSeasonShows]', ...args);
const ERR = (...args) => console.error('[KefinTweaks FlattenSingleSeasonShows]', ...args);
/**
* Fetches the Next Up episode for a given series
* @param {string} seriesId - The series ID
* @returns {Promise<Object|null>} - Episode number object with season and episode, or null
*/
async function fetchNextUpEpisode(seriesId) {
try {
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching Next Up episode for series: ${seriesId}`);
const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`;
const nextUpRes = await fetch(nextUpUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!nextUpRes.ok) {
throw new Error(`HTTP ${nextUpRes.status}: ${nextUpRes.statusText}`);
}
const nextUpData = await nextUpRes.json();
const items = nextUpData.Items || [];
if (items.length > 0) {
const item = items[0];
if (item.IndexNumber && item.ParentIndexNumber) {
const episodeNumber = {
season: item.ParentIndexNumber,
episode: item.IndexNumber
};
LOG(`Fetched Next Up episode from API: S${episodeNumber.season}:E${episodeNumber.episode}`);
return episodeNumber;
}
}
LOG('No Next Up episode found in API response');
return null;
} catch (error) {
ERR(`Failed to fetch Next Up episode for series ${seriesId}:`, error);
return null;
}
}
/**
* Fetches all episodes for a given series
* @param {string} seriesId - The series ID
* @returns {Promise<Array>} - Array of episode items
*/
async function fetchEpisodesForSeries(seriesId) {
try {
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching episodes for series: ${seriesId}`);
const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`;
const episodesRes = await fetch(episodesUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!episodesRes.ok) {
throw new Error(`HTTP ${episodesRes.status}: ${episodesRes.statusText}`);
}
const episodesData = await episodesRes.json();
const episodes = episodesData.Items || [];
LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}`);
return episodes;
} catch (error) {
ERR(`Failed to fetch episodes for series ${seriesId}:`, error);
return [];
}
}
/**
* Flattens a single-season show by creating a "Season 1" section with all episodes
* @param {string} seriesId - The series ID
*/
async function flattenShow(seriesId) {
LOG(`Flattening show: ${seriesId}`);
// Check if already flattened for this series (avoid duplicates)
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (activePage && activePage.dataset.flattenedSeriesId === seriesId) {
LOG(`Show ${seriesId} already flattened, skipping`);
return;
}
// Check if flattened section already exists
const existingSection = activePage?.querySelector('.flattened-season-section');
if (existingSection) {
LOG(`Flattened section already exists, skipping`);
return;
}
// Get all episodes for the series
const episodes = await fetchEpisodesForSeries(seriesId);
if (episodes.length === 0) {
WARN(`No episodes found for series: ${seriesId}`);
return;
}
// Check for cardBuilder
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
WARN('cardBuilder.renderCards not available');
return;
}
// Find the detailPageContent
const detailPageContent = activePage?.querySelector('.detailPageContent');
if (!detailPageContent) {
WARN('detailPageContent not found');
return;
}
// Find the #childrenCollapsible div
let childrenCollapsible = detailPageContent.querySelector('#childrenCollapsible');
if (!childrenCollapsible) {
WARN('#childrenCollapsible not found');
return;
}
try {
// Get the season ID from the first episode's ParentId
const seasonId = episodes[0]?.SeasonId;
let viewMoreUrl = null;
if (seasonId) {
const apiClient = window.ApiClient;
const serverId = apiClient.serverId();
viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${seasonId}&serverId=${serverId}`;
LOG(`Setting viewMoreUrl to season details: ${viewMoreUrl}`);
} else {
WARN('Could not determine season ID from episodes');
}
// Check if Next Up section exists
const nextUpSection = activePage?.querySelector('.nextUpSection');
const nextUpItemsContainer = nextUpSection?.querySelector('.itemsContainer');
// Extract episode info from Next Up if it exists
let targetEpisodeNumber = null;
if (nextUpSection && nextUpItemsContainer) {
LOG('Next Up section found, hiding nextUpSection');
// Get the existing card from Next Up and extract episode info
const existingCard = nextUpItemsContainer.querySelector('.card');
if (existingCard) {
// Find the .cardText a element that contains the episode number
const cardTextLinks = existingCard.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
// Match SX:EY pattern (may be part of longer text like "S1:E5 - Episode Name")
const match = linkText.match(/S(\d+):E(\d+)/);
if (match) {
targetEpisodeNumber = {
season: parseInt(match[1], 10),
episode: parseInt(match[2], 10)
};
LOG(`Found Next Up episode from DOM: S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
break;
}
}
}
// If we couldn't find it from the DOM, fetch from API
if (!targetEpisodeNumber) {
LOG('Could not find episode number from DOM, fetching from API');
targetEpisodeNumber = await fetchNextUpEpisode(seriesId);
}
// Hide the entire Next Up section
nextUpSection.style.display = 'none';
}
const seasonName = episodes[0]?.SeasonName || `Season ${episodes[0]?.ParentIndexNumber}`;
// Render Season 1 section using renderCards (default behavior)
const seasonSection = window.cardBuilder.renderCards(
episodes,
`${seasonName}`,
viewMoreUrl,
true, // overflowCard
null // cardFormat (use default)
);
// Mark as flattened section
seasonSection.classList.add('flattened-season-section');
seasonSection.setAttribute('data-flattened-section', 'true');
const scrollerContainer = seasonSection.querySelector('.emby-scroller');
if (scrollerContainer) {
scrollerContainer.classList.add('no-padding');
}
const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer');
if (sectionTitleContainer) {
sectionTitleContainer.classList.add('no-padding');
}
// Prepend the Season 1 section right before #childrenCollapsible
childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible);
let childrenItemsContainer = childrenCollapsible.querySelector('.itemsContainer');
if (!childrenItemsContainer || childrenItemsContainer.children.length === 0) {
// Check for 10.11.X children container
childrenCollapsible = activePage.querySelector('#listChildrenCollapsible');
childrenItemsContainer = childrenCollapsible ? childrenCollapsible.querySelector('.itemsContainer') : null;
}
// Check if childrenCollapsible has only one child in its itemsContainer, and hide it if so
if (childrenItemsContainer && childrenItemsContainer.children.length === 1) {
LOG('childrenCollapsible contains only one child, hiding it');
childrenCollapsible.style.display = 'none';
}
// If we found a target episode from Next Up, scroll to it
if (targetEpisodeNumber && scrollerContainer) {
// Wait for cards to be rendered and positioned
setTimeout(() => {
const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer');
if (!scrollerItemsContainer) {
WARN('Could not find itemsContainer in scroller');
return;
}
// Find the card that matches the target episode number
const cards = scrollerItemsContainer.querySelectorAll('.card');
let targetCard = null;
for (const card of cards) {
const cardTextLinks = card.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
const match = linkText.match(/S(\d+):E(\d+)/);
if (match &&
parseInt(match[1], 10) === targetEpisodeNumber.season &&
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
targetCard = card;
break;
}
}
if (targetCard) break;
}
// Scroll to make the target card the leftmost visible element
if (targetCard) {
const itemsContainer = scrollerItemsContainer;
const cardOffset = targetCard.offsetLeft;
const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0;
// Calculate the scrollToPosition value
// The card offset minus padding gives us how much to scroll
const translateX = (cardOffset - scrollerPadding);
scrollerContainer.scrollToPosition(translateX);
// Enable the left scroll button so users can scroll back
const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons');
if (scrollButtons) {
const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
if (leftButton) {
leftButton.removeAttribute('disabled');
LOG('Enabled left scroll button');
}
}
LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode} using translateX(${translateX}px)`);
} else {
WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
}
}, 100);
}
// Mark as flattened for this series to avoid duplicates
if (activePage) {
activePage.dataset.flattenedSeriesId = seriesId;
}
LOG(`Successfully flattened show ${seriesId} with ${episodes.length} episodes`);
} catch (error) {
ERR('Error rendering flattened season section:', error);
}
}
/**
* Initialize the flatten single season shows hook
*/
function initializeFlattenHook() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second');
setTimeout(initializeFlattenHook, 1000);
return;
}
LOG('Registering flatten single season shows handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
async (view, element, hash, itemPromise) => {
// Only handle details pages
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) return;
// Remove any existing flattened section and reset flag when page changes
const existingSection = activePage.querySelector('.flattened-season-section');
if (existingSection) {
LOG('Flattened section already exists, skipping');
return;
}
// Await the item promise to get the actual item data
const item = await itemPromise;
// Check if item is a Series and has only 1 ChildCount
if (item && item.Type === 'Series' && item.ChildCount === 1) {
LOG(`Found single-season series: ${item.Id} (${item.Name})`);
// Small delay to ensure details DOM is ready
setTimeout(async () => {
await flattenShow(item.Id);
}, 100);
}
},
{
pages: ['details']
}
);
LOG('Flatten single season shows hook initialized');
}
// Initialize the hook when the script loads
function initialize() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils not available, retrying in 1 second');
setTimeout(initialize, 1000);
return;
}
initializeFlattenHook();
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
LOG('Script loaded successfully');
})();
+126
View File
@@ -0,0 +1,126 @@
// KefinTweaks Games
// Hosts Games hub + Movie LUT + 3x3 via addCustomPage
// Requires: utils.js
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Games]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Games]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Games]', ...args);
LOG('Script loaded');
function getRoot() {
const raw = window.KefinTweaksConfig?.kefinTweaksRoot;
if (!raw) return null;
return raw.endsWith('/') ? raw : `${raw}/`;
}
/**
* Build custom-page HTML from a full document: styles + head scripts + body
* (scripts kept). Rewrites `body {` to a scope class for non-full-page host.
*/
function buildContentHtmlFromDocument(htmlString, scopeClass) {
const doc = new DOMParser().parseFromString(htmlString, 'text/html');
const chunks = [];
doc.querySelectorAll('style').forEach((el) => {
let css = el.textContent || '';
if (scopeClass) {
css = css.replace(/(^|})\s*body\s*\{/g, `$1.${scopeClass}{`);
}
chunks.push(`<style>${css}</style>`);
});
doc.head.querySelectorAll('script').forEach((el) => {
chunks.push(el.outerHTML);
});
const wrap = document.createElement('div');
const bodyClass = (doc.body && doc.body.className) || '';
wrap.className = [scopeClass, bodyClass].filter(Boolean).join(' ').trim();
Array.from(doc.body.childNodes).forEach((node) => {
if (node.nodeName === 'STYLE') return;
wrap.appendChild(node.cloneNode(true));
});
chunks.push(wrap.outerHTML);
return chunks.join('\n');
}
async function fetchPageHtml(root, relativePath) {
const url = `${root}${relativePath.replace(/^\//, '')}`;
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status}`);
}
return res.text();
}
async function registerGamesPages() {
const utils = window.KefinTweaksUtils;
if (!utils?.addCustomMenuLink || !utils?.addCustomPage) {
ERR('addCustomMenuLink / addCustomPage not available');
return false;
}
const root = getRoot();
if (!root) {
WARN('kefinTweaksRoot not configured; cannot register Games pages');
return false;
}
try {
const [gamesRaw, movieLutRaw, threeRaw] = await Promise.all([
fetchPageHtml(root, 'pages/games.html'),
fetchPageHtml(root, 'pages/movieLut.html'),
fetchPageHtml(root, 'pages/3x3.html'),
]);
const gamesHtml = buildContentHtmlFromDocument(gamesRaw, 'kefin-custom-page-games');
const movieLutHtml = buildContentHtmlFromDocument(movieLutRaw, 'kefin-custom-page-movielut');
const threeByThreeHtml = buildContentHtmlFromDocument(threeRaw, 'kefin-custom-page-3x3');
utils.addCustomPage('#/games', 'Games', gamesHtml);
utils.addCustomPage('#/movieLut', 'Movie LUT', movieLutHtml);
utils.addCustomPage('#/3x3', '3x3', threeByThreeHtml);
const ok = await utils.addCustomMenuLink('Games', 'sports_esports', '#/games', false);
if (ok) LOG('Games menu link + custom pages registered');
else WARN('Custom pages registered but menu link failed');
return !!ok;
} catch (e) {
ERR('Error registering Games pages:', e);
return false;
}
}
function waitForUtilsAndInitialize() {
const ready = () =>
window.KefinTweaksUtils?.addCustomMenuLink && window.KefinTweaksUtils?.addCustomPage;
if (ready()) {
registerGamesPages();
return;
}
LOG('Waiting for KefinTweaksUtils…');
const checkInterval = setInterval(() => {
if (ready()) {
clearInterval(checkInterval);
registerGamesPages();
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
if (!ready()) {
WARN('KefinTweaksUtils not available after 10 seconds');
}
}, 10000);
}
waitForUtilsAndInitialize();
})();
+751
View File
@@ -0,0 +1,751 @@
// KefinTweaks Desktop Hamburger Menu (Jellyfin v12)
// Exposes a desktop Open Menu button + left drawer matching the native mobile nav.
// Requires: utils.js
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks HamburgerMenu]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HamburgerMenu]', ...args);
const ERR = (...args) => console.error('[KefinTweaks HamburgerMenu]', ...args);
const FAVORITES_HREF = '#/home?tab=1';
const STYLE_ID = 'kefin-hamburger-menu-styles';
const BTN_ATTR = 'data-kefin-desktop-hamburger';
const DRAWER_ATTR = 'data-kefin-desktop-drawer';
const FALLBACK_BTN_CLASS =
'MuiButtonBase-root MuiIconButton-root MuiIconButton-colorInherit MuiIconButton-sizeLarge css-i2hxb6';
const FALLBACK_SVG_CLASS = 'MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-iguwhy';
const FALLBACK_LI_CLASS = 'MuiListItem-root MuiListItem-gutters css-1ohqk82';
const FALLBACK_A_CLASS =
'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp';
const FALLBACK_ICON_WRAP = 'MuiListItemIcon-root css-5pks8q';
const FALLBACK_TEXT_WRAP = 'MuiListItemText-root css-t3p1a1';
const FALLBACK_TEXT_PRIMARY =
'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-pl8nxc';
const FALLBACK_BRAND_ICON_WRAP = 'MuiListItemIcon-root css-c9a5tz';
const FALLBACK_BRAND_TEXT_WRAP =
'MuiListItemText-root MuiListItemText-multiline css-9ac84v';
const FALLBACK_BRAND_PRIMARY =
'MuiTypography-root MuiTypography-h6 MuiListItemText-primary css-1y7q0ly';
const FALLBACK_BRAND_SECONDARY =
'MuiTypography-root MuiTypography-body2 MuiListItemText-secondary css-xxlzys';
let toolbarObserver = null;
let openDrawerEl = null;
let escapeHandler = null;
let viewsCache = null;
let viewsCacheAt = 0;
LOG('Script loaded');
function ensureStyles() {
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
(document.head || document.documentElement).appendChild(style);
}
style.textContent = `
[${BTN_ATTR}] {
position: fixed !important;
left: 8px;
top: 1px;
z-index: 1200;
}
@media (max-width: 899px) {
[${BTN_ATTR}] {
display: none !important;
}
}
.MuiDrawer-root[${DRAWER_ATTR}] {
z-index: 1300;
position: fixed;
inset: 0px;
z-index: var(--jf-zIndex-drawer);
}
.MuiDrawer-root[${DRAWER_ATTR}] .MuiBackdrop-root {
opacity: 0;
transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1);
}
.MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiBackdrop-root {
opacity: 1;
transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1);
position: fixed;
display: flex;
-moz-box-align: center;
align-items: center;
-moz-box-pack: center;
justify-content: center;
inset: 0px;
background-color: rgba(0, 0, 0, 0.5);
z-index: -1;
}
.MuiDrawer-root[${DRAWER_ATTR}] .MuiDrawer-paper {
transform: translateX(-100%);
transition: transform 225ms cubic-bezier(0, 0, 0.2, 1);
outline: 0px;
background-color: var(--jf-palette-background-paper);
color: var(--jf-palette-text-primary);
box-shadow: var(--Paper-shadow);
background-image: var(--Paper-overlay);
overflow-y: auto;
display: flex;
flex-direction: column;
height: 100%;
width: 280px;
max-width: 100%;
flex: 1 0 auto;
z-index: var(--jf-zIndex-drawer);
position: fixed;
top: 0px;
left: 0px;
}
.MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiDrawer-paper {
transform: none;
}
[${DRAWER_ATTR}] .MuiListItem-root.MuiListItem-gutters.css-1ohqk82 {
display: flex;
-moz-box-pack: start;
justify-content: flex-start;
-moz-box-align: center;
align-items: center;
position: relative;
text-decoration: none;
width: 100%;
box-sizing: border-box;
text-align: left;
}
[${DRAWER_ATTR}] .MuiList-root {
list-style: none;
margin: 0px;
padding-right: 0px;
padding-left: 0px;
position: relative;
padding-bottom: 8px;
padding-top: calc(0 * var(--jf-spacing));
}
[${DRAWER_ATTR}] .MuiTypography-root {
margin: 0px;
font-family: "Noto Sans", sans-serif;
font-weight: 400;
font-size: 1rem;
line-height: 1.5;
color: var(--jf-palette-text-primary);
}
[${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters {
background-color: transparent;
outline: 0px;
border: 0px;
margin: 0px;
border-radius: 0px;
cursor: pointer;
user-select: none;
vertical-align: middle;
appearance: none;
color: inherit;
display: flex;
-moz-box-flex: 1;
flex-grow: 1;
-moz-box-pack: start;
justify-content: flex-start;
-moz-box-align: center;
align-items: center;
position: relative;
text-decoration: none;
min-width: 0px;
box-sizing: border-box;
text-align: left;
padding: 8px 16px;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
[${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.Mui-selected {
background-color: var(--jf-palette-action-selected, rgba(255, 255, 255, 0.08));
}
[${DRAWER_ATTR}] .MuiButtonBase-root:first-child {
color: var(--jf-palette-action-active);
flex-shrink: 0;
display: inline-flex;
min-width: 36px;
}
[${DRAWER_ATTR}] .MuiBox-root {
height: 2.5rem;
}
[${DRAWER_ATTR}] .MuiListItemIcon-root:last-child,
[${DRAWER_ATTR}] .MuiListItemIcon-root.css-c9a5tz {
color: var(--jf-palette-action-active);
flex-shrink: 0;
display: inline-flex;
min-width: 56px;
}
[${DRAWER_ATTR}] a:hover {
background: #4e4e4e !important;
}
`;
}
function findDesktopToolbar() {
const toolbars = document.querySelectorAll('.MuiToolbar-root.MuiToolbar-dense');
for (const toolbar of toolbars) {
if (toolbar.querySelector(`:scope > button[aria-label="Open Menu"]`)) continue;
if (toolbar.querySelector(`:scope > [${BTN_ATTR}]`)) return toolbar;
const hasFavorites = toolbar.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`);
if (hasFavorites) return toolbar;
}
return null;
}
function findNativeOpenMenuButton() {
return document.querySelector('button[aria-label="Open Menu"]:not([data-kefin-desktop-hamburger])');
}
function menuIconSvg(className) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', className || FALLBACK_SVG_CLASS);
svg.setAttribute('focusable', 'false');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('data-testid', 'MenuIcon');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z');
svg.appendChild(path);
return svg;
}
function materialSvg(testId, pathD, className) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', className || FALLBACK_SVG_CLASS);
svg.setAttribute('focusable', 'false');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('viewBox', '0 0 24 24');
if (testId) svg.setAttribute('data-testid', testId);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', pathD);
svg.appendChild(path);
return svg;
}
const ICONS = {
home: {
testId: 'HomeIcon',
d: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z'
},
favorite: {
testId: 'FavoriteIcon',
d: 'm12 21.35-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54z'
},
tv: {
testId: 'TvIcon',
d: 'M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2m0 14H3V5h18z'
},
folder: {
testId: 'FolderIcon',
d: 'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8z'
},
queue: {
testId: 'QueueIcon',
d: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-1 9h-4v4h-2v-4H9V9h4V5h2v4h4z'
},
movie: {
testId: 'MovieIcon',
d: 'm18 4 2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4zm-6.75 11.25L10 18l-1.25-2.75L6 14l2.75-1.25L10 10l1.25 2.75L14 14z'
},
music: {
testId: 'LibraryMusicIcon',
d: 'M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-2 5h-3v5.5c0 1.38-1.12 2.5-2.5 2.5S10 13.88 10 12.5s1.12-2.5 2.5-2.5c.57 0 1.08.19 1.5.5V5h4zM2 6v14c0 1.1.9 2 2 2h14v-2H4V6z'
},
livetv: {
testId: 'LiveTvIcon',
d: 'M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3c-1.1 0-2 .89-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.11-.9-2-2-2m0 14H3V8h18zM9 10v8l7-4z'
}
};
function createHamburgerButton() {
const native = findNativeOpenMenuButton();
let btn;
if (native) {
btn = native.cloneNode(true);
btn.querySelectorAll('.MuiTouchRipple-root').forEach((el) => el.remove());
} else {
btn = document.createElement('button');
btn.type = 'button';
btn.className = FALLBACK_BTN_CLASS;
btn.tabIndex = 0;
btn.setAttribute('aria-label', 'Open Menu');
btn.appendChild(menuIconSvg());
}
btn.setAttribute(BTN_ATTR, 'true');
btn.type = 'button';
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openDrawer();
});
return btn;
}
function ensureHamburgerButton() {
ensureStyles();
const toolbar = findDesktopToolbar();
if (!toolbar) return false;
let btn = toolbar.querySelector(`[${BTN_ATTR}]`);
if (btn) return true;
btn = createHamburgerButton();
toolbar.insertBefore(btn, toolbar.firstChild);
return true;
}
function getServerDisplayName() {
try {
const brand = document.querySelector(
`.MuiToolbar-root .MuiStack-root > a[href="#/"], .MuiToolbar-root .MuiStack-root > a[href="#/home"]`
);
if (brand) {
const text = Array.from(brand.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent.trim())
.filter(Boolean)
.join(' ');
if (text) return text;
}
} catch (_) { /* ignore */ }
try {
return window.ApiClient?.serverName?.() || window.ApiClient?._serverInfo?.Name || 'Jellyfin';
} catch (_) {
return 'Jellyfin';
}
}
function getServerVersionLabel() {
try {
const v = window.ApiClient?._serverVersion || window.ApiClient?.serverVersion?.();
if (v) {
const m = String(v).match(/^(\d+\.\d+)/);
return m ? m[1] : String(v);
}
} catch (_) { /* ignore */ }
return '';
}
function getBrandIconSrc() {
const img = document.querySelector(
`.MuiToolbar-root .MuiStack-root a[href="#/"] img, .MuiToolbar-root .MuiStack-root a[href="#/home"] img`
);
return img?.getAttribute('src') || null;
}
function libraryHref(item) {
const serverId = window.ApiClient?.serverId?.() || '';
const id = item.Id;
const type = String(item.CollectionType || '').toLowerCase();
switch (type) {
case 'movies':
return `#/movies?topParentId=${id}&collectionType=movies`;
case 'tvshows':
return `#/tv?topParentId=${id}&collectionType=tvshows`;
case 'music':
return `#/music?topParentId=${id}&collectionType=music`;
case 'playlists':
return `#/playlists?topParentId=${id}&collectionType=playlists`;
case 'livetv':
return `#/livetv?collectionType=livetv`;
case 'boxsets':
return `#/list?parentId=${id}&serverId=${serverId}`;
default:
return `#/list?parentId=${id}&serverId=${serverId}`;
}
}
function libraryIconKey(item) {
switch (String(item.CollectionType || '').toLowerCase()) {
case 'movies':
return 'movie';
case 'tvshows':
return 'tv';
case 'music':
return 'music';
case 'playlists':
return 'queue';
case 'livetv':
return 'livetv';
default:
return 'folder';
}
}
async function fetchUserViews() {
const now = Date.now();
if (viewsCache && now - viewsCacheAt < 60000) return viewsCache;
if (!window.ApiClient?.getUserViews) {
WARN('ApiClient.getUserViews not available');
return [];
}
try {
const userId = window.ApiClient.getCurrentUserId();
const result = await window.ApiClient.getUserViews({}, userId);
viewsCache = Array.isArray(result?.Items) ? result.Items : [];
viewsCacheAt = now;
return viewsCache;
} catch (e) {
ERR('Failed to fetch UserViews', e);
return [];
}
}
function getCurrentNavHash() {
const hash = window.location.hash || '#/';
return hash.startsWith('#') ? hash : `#${hash}`;
}
function parseHashParts(hash) {
const raw = String(hash || '#/').replace(/^#/, '') || '/';
const qIndex = raw.indexOf('?');
const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || '/';
const query = qIndex >= 0 ? raw.slice(qIndex + 1) : '';
const params = new URLSearchParams(query);
return { path: path.startsWith('/') ? path : `/${path}`, params };
}
function hrefMatchScore(href, currentHash) {
if (!href || /^https?:/i.test(href)) return -1;
const targetHash = href.startsWith('#') ? href : `#${href}`;
const current = parseHashParts(currentHash);
const target = parseHashParts(targetHash);
if (
targetHash === currentHash
|| decodeURIComponent(targetHash) === decodeURIComponent(currentHash)
) {
return 1000 + targetHash.length;
}
if (target.path !== current.path) return -1;
for (const [key, value] of target.params.entries()) {
if (current.params.get(key) !== value) return -1;
}
// Prefer more specific links (more query params / longer href)
let score = 100 + targetHash.length;
score += [...target.params.keys()].length * 10;
// Home without tab should not win over Favorites (?tab=1)
if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) {
return -1;
}
return score;
}
function applySelectedState(drawerRoot) {
if (!drawerRoot) return;
const currentHash = getCurrentNavHash();
const links = Array.from(drawerRoot.querySelectorAll('.MuiDrawer-paper a[href]'));
let best = null;
let bestScore = -1;
links.forEach((a) => {
a.classList.remove('Mui-selected');
a.removeAttribute('aria-current');
const score = hrefMatchScore(a.getAttribute('href') || '', currentHash);
if (score > bestScore) {
bestScore = score;
best = a;
}
});
if (best && bestScore >= 0) {
best.classList.add('Mui-selected');
best.setAttribute('aria-current', 'page');
}
}
function createListItem({ href, label, iconKey, brand, secondary, imgSrc }) {
const li = document.createElement('li');
li.className = FALLBACK_LI_CLASS;
const a = document.createElement('a');
a.className = FALLBACK_A_CLASS;
a.tabIndex = 0;
a.href = href;
const iconWrap = document.createElement('div');
iconWrap.className = brand ? FALLBACK_BRAND_ICON_WRAP : FALLBACK_ICON_WRAP;
if (imgSrc) {
const img = document.createElement('img');
img.className = 'MuiBox-root css-1j3tjfb';
img.src = imgSrc;
img.alt = '';
iconWrap.appendChild(img);
} else if (iconKey && ICONS[iconKey]) {
iconWrap.appendChild(materialSvg(ICONS[iconKey].testId, ICONS[iconKey].d));
}
const textWrap = document.createElement('div');
if (brand) {
textWrap.className = FALLBACK_BRAND_TEXT_WRAP;
const h6 = document.createElement('h6');
h6.className = FALLBACK_BRAND_PRIMARY;
h6.textContent = label;
textWrap.appendChild(h6);
if (secondary) {
const p = document.createElement('p');
p.className = FALLBACK_BRAND_SECONDARY;
p.textContent = secondary;
textWrap.appendChild(p);
}
} else {
textWrap.className = FALLBACK_TEXT_WRAP;
const span = document.createElement('span');
span.className = FALLBACK_TEXT_PRIMARY;
span.textContent = label;
textWrap.appendChild(span);
}
a.appendChild(iconWrap);
a.appendChild(textWrap);
a.addEventListener('click', () => {
// Allow navigation; close drawer shortly after
setTimeout(closeDrawer, 0);
});
li.appendChild(a);
return li;
}
function buildDrawerShell(mainList, librariesList) {
const root = document.createElement('div');
root.setAttribute('role', 'presentation');
root.className = 'MuiDrawer-root MuiDrawer-anchorLeft MuiDrawer-modal MuiModal-root css-k3j2mn';
root.setAttribute(DRAWER_ATTR, 'true');
const backdrop = document.createElement('div');
backdrop.setAttribute('aria-hidden', 'true');
backdrop.className = 'MuiBackdrop-root MuiModal-backdrop css-14dl35y';
backdrop.addEventListener('click', closeDrawer);
const sentinelStart = document.createElement('div');
sentinelStart.tabIndex = 0;
sentinelStart.setAttribute('data-testid', 'sentinelStart');
const paper = document.createElement('div');
paper.className =
'MuiPaper-root MuiPaper-elevation MuiPaper-elevation16 MuiDrawer-paper MuiDrawer-paperAnchorLeft css-epnusv';
paper.tabIndex = -1;
paper.style.setProperty('--Paper-shadow', 'var(--jf-shadows-16)');
paper.style.setProperty('--Paper-overlay', 'var(--jf-overlays-16)');
const box = document.createElement('div');
box.className = 'MuiBox-root css-0';
box.setAttribute('role', 'presentation');
box.appendChild(mainList);
const hr = document.createElement('hr');
hr.className = 'MuiDivider-root MuiDivider-fullWidth css-14093q0';
box.appendChild(hr);
box.appendChild(librariesList);
paper.appendChild(box);
const sentinelEnd = document.createElement('div');
sentinelEnd.tabIndex = 0;
sentinelEnd.setAttribute('data-testid', 'sentinelEnd');
root.appendChild(backdrop);
root.appendChild(sentinelStart);
root.appendChild(paper);
root.appendChild(sentinelEnd);
return root;
}
async function buildDrawer() {
const mainList = document.createElement('ul');
mainList.className = 'MuiList-root MuiList-padding css-hbss32';
const serverName = getServerDisplayName();
const version = getServerVersionLabel();
const brandSrc = getBrandIconSrc();
mainList.appendChild(
createListItem({
href: '#/',
label: serverName,
brand: true,
secondary: version || undefined,
imgSrc: brandSrc
})
);
mainList.appendChild(
createListItem({ href: '#/home', label: 'Home', iconKey: 'home' })
);
mainList.appendChild(
createListItem({ href: FAVORITES_HREF, label: 'Favorites', iconKey: 'favorite' })
);
const librariesList = document.createElement('ul');
librariesList.className = 'MuiList-root MuiList-padding MuiList-subheader css-q9gyaw';
librariesList.setAttribute('aria-labelledby', 'libraries-subheader');
const subheader = document.createElement('div');
subheader.className =
'MuiListSubheader-root MuiListSubheader-gutters MuiListSubheader-sticky css-18qy2xf';
subheader.id = 'libraries-subheader';
subheader.textContent = 'Libraries';
librariesList.appendChild(subheader);
const views = await fetchUserViews();
views.forEach((item) => {
if (!item?.Id || !item?.Name) return;
librariesList.appendChild(
createListItem({
href: libraryHref(item),
label: item.Name,
iconKey: libraryIconKey(item)
})
);
});
return buildDrawerShell(mainList, librariesList);
}
function closeDrawer() {
if (escapeHandler) {
document.removeEventListener('keydown', escapeHandler);
escapeHandler = null;
}
const drawer = openDrawerEl;
openDrawerEl = null;
document.body.style.removeProperty('overflow');
if (!drawer) return;
drawer.removeAttribute('data-kefin-drawer-open');
const paper = drawer.querySelector('.MuiDrawer-paper');
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
drawer.remove();
};
if (paper) {
paper.addEventListener('transitionend', (e) => {
if (e.target === paper && e.propertyName === 'transform') finish();
}, { once: true });
}
setTimeout(finish, 280);
}
async function openDrawer() {
if (openDrawerEl) {
closeDrawer();
return;
}
try {
const drawer = await buildDrawer();
drawer.removeAttribute('data-kefin-drawer-open');
document.body.appendChild(drawer);
openDrawerEl = drawer;
escapeHandler = (e) => {
if (e.key === 'Escape') closeDrawer();
};
document.addEventListener('keydown', escapeHandler);
applySelectedState(drawer);
// Force closed paint, then open so translateX animates
drawer.offsetWidth;
requestAnimationFrame(() => {
drawer.setAttribute('data-kefin-drawer-open', 'true');
applySelectedState(drawer);
setTimeout(() => applySelectedState(drawer), 100);
});
} catch (e) {
ERR('Failed to open drawer', e);
closeDrawer();
}
}
function parseVersionParts(version) {
return String(version || '')
.split(/[.+-]/)
.filter(Boolean)
.map((p) => {
const n = parseInt(p, 10);
return Number.isFinite(n) ? n : 0;
});
}
/** @returns {boolean|null} true if >= 12.0.0, false if older, null if unknown */
function isAppVersionAtLeast12() {
const version = window.ApiClient?._appVersion;
if (!version) return null;
const parts = parseVersionParts(version);
const major = parts[0] ?? 0;
const minor = parts[1] ?? 0;
const patch = parts[2] ?? 0;
if (major > 12) return true;
if (major < 12) return false;
if (minor > 0) return true;
if (minor < 0) return false;
return patch >= 0;
}
async function waitForAppVersion(timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const ready = isAppVersionAtLeast12();
if (ready !== null) return ready;
await new Promise((r) => setTimeout(r, 250));
}
return isAppVersionAtLeast12();
}
function ensureToolbarObserver() {
if (toolbarObserver || typeof MutationObserver === 'undefined' || !document.body) return;
let scheduled = false;
toolbarObserver = new MutationObserver(() => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
ensureHamburgerButton();
});
});
toolbarObserver.observe(document.body, { childList: true, subtree: true });
}
function start() {
ensureStyles();
ensureHamburgerButton();
ensureToolbarObserver();
// Retry briefly for late toolbar mount
let tries = 0;
const poll = setInterval(() => {
tries += 1;
if (ensureHamburgerButton() || tries >= 40) clearInterval(poll);
}, 250);
LOG('Desktop hamburger menu initialized');
}
async function boot() {
const ok = await waitForAppVersion();
// v12+ modern desktop UI only
if (ok !== true) {
LOG(
'Skipping desktop hamburger menu; requires ApiClient._appVersion >= 12.0.0',
window.ApiClient?._appVersion || '(unavailable)'
);
return;
}
start();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
boot();
});
} else {
boot();
}
})();
+22 -12
View File
@@ -93,7 +93,7 @@
`${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`,
{
headers: {
'X-Emby-Token': accessToken
'Authorization': window.apiHelper.getAuthHeader()
}
}
);
@@ -397,17 +397,27 @@
const view = window.KefinTweaksUtils.getCurrentView();
if (view === 'home' || view === 'home.html') {
// Check for custom tabs
const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Emby-Token": ApiClient._serverInfo.AccessToken || ApiClient.accessToken(),
},
});
const customTabs = await response.json();
const customTabsCount = customTabs?.length || 0;
// Check for custom tabs (plugin may be missing / return empty body)
let customTabs = [];
try {
const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": window.apiHelper.getAuthHeader(),
},
});
if (response.ok) {
const text = await response.text();
if (text && text.trim()) {
const parsed = JSON.parse(text);
customTabs = Array.isArray(parsed) ? parsed : [];
}
}
} catch (e) {
WARN('CustomTabs/Config unavailable, skipping custom tab listeners:', e?.message || e);
}
const customTabsCount = customTabs.length;
totalButtons += customTabsCount;
if (totalButtons > buttons.length && customTabsCount > 0) {
+153 -156
View File
@@ -1,36 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Oswald:wght@200..700&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Anton&family=Changa+One:ital@0;1&family=Exo:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&family=Oswald:wght@200..700&family=Prata&family=Roboto:ital,wght@0,100..900;1,100..900&family=Unbounded:wght@200..900&family=VT323&display=swap");
.sections.homeSectionsContainer {
display: flex;
flex-direction: column;
padding-bottom: 100px;
gap: 1rem;
}
body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0%;
background: linear-gradient(rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.85)), var(--seasonal-background-image);
background-position: center;
background-size: cover;
display: flex;
flex-direction: column;
padding-bottom: 100px;
gap: 1rem;
}
.snowverlay {
display: none;
display: none;
}
body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay {
display: block;
}
body:has(.homePage:not(.hide)) [data-section-id] h2.sectionTitle,
body:has(.homePage:not(.hide)) [data-section-id] .sectionTitleTextButton>.material-icons {
margin-bottom: 0 !important;
margin-top: 0 !important;
.homePage [data-section-id] h2.sectionTitle,
.homePage [data-section-id] .sectionTitleTextButton > .material-icons {
margin-bottom: 0 !important;
margin-top: 0 !important;
padding-bottom: 0 !important;
padding-top: 0 !important;
}
/* div#discovery-loading-indicator:not(.show)::before {
@@ -47,24 +33,24 @@ body:has(.homePage:not(.hide)) [data-section-id] .sectionTitleTextButton>.materi
} */
#indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before {
content: 'Discover More';
content: "Discover More";
}
div#discovery-loading-indicator:not(.show)::after {
content: '\E313';
font-family: 'Material Icons';
position: absolute;
top: calc(50% + 0.4em);
left: 50%;
transform: translateX(-50%) translateY(-50%);
font-size: 1.5em;
color: #ffffff;
visibility: visible;
opacity: 1;
content: "\E313";
font-family: "Material Icons";
position: absolute;
top: calc(50% + 0.4em);
left: 50%;
transform: translateX(-50%) translateY(-50%);
font-size: 1.5em;
color: #ffffff;
visibility: visible;
opacity: 1;
}
[data-loading-discovery="true"] div#discovery-loading-indicator {
display: flex;
display: flex;
}
/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
@@ -72,66 +58,70 @@ div#discovery-loading-indicator:not(.show)::after {
} */
#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after {
content: '';
content: "";
}
/* Ensure Editor's Choice Banner appears at top of home screen if enabled */
.editorsChoiceContainer {
order: 1 !important;
order: 1 !important;
}
.spotlight-section {
width: calc(100% - (2 * max(env(safe-area-inset-left),3.3%)));
width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%)));
}
.load-more-discovery-btn {
display: block;
visibility: visible;
order: 99998 !important;
margin: 2em auto;
display: block;
visibility: visible;
order: 99998 !important;
margin: 2em auto;
}
.sections.loading-discovery .load-more-discovery-btn {
visibility: hidden !important;
visibility: hidden !important;
}
/* Discovery section loading indicator */
.sections.loading-discovery .discovery-loading-indicator {
visibility: visible !important;
opacity: 1 !important;
visibility: visible !important;
opacity: 1 !important;
}
.discovery-loading-indicator, .load-more-discovery-btn {
height: 50px;
.discovery-loading-indicator,
.load-more-discovery-btn {
height: 50px;
}
.discovery-loading-indicator {
display: none;
opacity: 1;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
border-radius: 8px;
font-size: 14px;
color: #666;
transition: opacity 0.3s ease;
position: relative;
justify-content: start;
display: none;
opacity: 1;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
border-radius: 8px;
font-size: 14px;
color: #666;
transition: opacity 0.3s ease;
position: relative;
justify-content: start;
}
.discovery-loading-indicator .spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
width: 30px;
height: 30px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
}
@keyframes discovery-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Hide next up section if next up if KefinTweaks next up section is present */
@@ -152,124 +142,107 @@ div#discovery-loading-indicator:not(.show)::after {
/* Hide scroll buttons on mobile and tablet layouts */
@media (max-width: 768px) {
.emby-scrollbuttons {
display: none !important;
}
.emby-scrollbuttons {
display: none !important;
}
}
/* Hide scroll buttons on mobile using Jellyfin's layout classes */
.layout-mobile .emby-scrollbuttons,
.layout-tv .emby-scrollbuttons {
display: none !important;
display: none !important;
}
.load-more-discovery-btn {
display: block;
display: block;
}
.sections[data-loading-discovery="true"] .load-more-discovery-btn {
display: none !important;
display: none !important;
}
.sections[data-loading-discovery="true"] .discovery-loading-indicator {
display: flex !important;
opacity: 1 !important;
display: flex !important;
opacity: 1 !important;
}
.discovery-loading-indicator {
display: none;
opacity: 0;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
padding: 20px;
border-radius: 8px;
font-size: 14px;
color: #666;
transition: opacity 0.3s ease;
position: relative;
justify-content: start;
min-height: 200px;
display: none;
opacity: 0;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
padding: 20px;
border-radius: 8px;
font-size: 14px;
color: #666;
transition: opacity 0.3s ease;
position: relative;
justify-content: start;
min-height: 200px;
}
.discovery-loading-indicator .spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
width: 30px;
height: 30px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
}
@keyframes discovery-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.homeSectionsContainer [data-section-id="popularTVNetworks"] .card .cardText {
visibility: hidden;
visibility: hidden;
}
/* .homeSectionsContainer:not(.kefinHomeSectionsContainer) {
display: none !important;
} */
.pageTabContent:has(.kefinHomeSectionsContainer[data-kefin-home-screen]) .homeSectionsContainer:not([data-kefin-home-screen]) {
display: none !important;
.homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] .cardScalable > a.cardImageContainer::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(#000000, rgb(0 0 0 / 0%) 50%);
box-shadow: inset 0px 0px 20px 5px black;
}
.homeSectionsContainer .card[data-type="Folder"] .cardScalable > a.cardImageContainer::after {
content: attr(aria-label);
font-family: 'Oswald';
font-size: 1.5rem;
line-height: 1.8rem;
max-width: calc(100% - 2rem);
white-space: pre-wrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
position: absolute;
top: 1rem;
left: 1rem;
background: none;
.layout-mobile .homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] a.cardImageContainer::after {
top: 0.5rem;
left: 0.5rem;
font-size: 1.5rem !important;
line-height: 1.8rem !important;
}
.homeSectionsContainer .card[data-type="Folder"] .cardScalable > a.cardImageContainer::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(#000000, rgb(0 0 0 / 0%) 50%);
box-shadow: inset 0px 0px 20px 5px black;
/* .homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] .cardText {
display: none;
}
.layout-mobile .homeSectionsContainer .card[data-type="Folder"] a.cardImageContainer::after {
top: 0.5rem;
left: 0.5rem;
font-size: 1.5rem !important;
line-height: 1.8rem !important;
}
.homeSectionsContainer .card[data-type="Folder"] .cardText {
display: none;
}
[data-card-format="Square"] [data-type="Person"] .cardScalable,
[data-card-format="Square"] [data-type="Actor"] .cardScalable,
[data-card-format="Square"] [data-type="Writer"] .cardScalable,
[data-card-format="Square"] [data-type="Person"] .cardScalable,
[data-card-format="Square"] [data-type="Actor"] .cardScalable,
[data-card-format="Square"] [data-type="Writer"] .cardScalable,
[data-card-format="Square"] [data-type="Director"] .cardScalable {
border-radius: 50% !important;
overflow: hidden;
border-radius: 50% !important;
overflow: hidden;
}
[data-card-format="Square"] [data-type="Actor"] .cardText {
display: flex;
justify-content: center;
}
display: flex;
justify-content: center;
} */
.homeSectionsContainer .section0,
.homeSectionsContainer .section1,
@@ -281,23 +254,47 @@ div#discovery-loading-indicator:not(.show)::after {
.homeSectionsContainer .section7,
.homeSectionsContainer .section8,
.homeSectionsContainer .section9 {
display: none !important;
display: none !important;
}
.homeSectionsContainer .card[data-type="TvChannel"] a.cardImageContainer:not([style])::after {
content: attr(aria-label);
font-size: 2rem;
text-wrap: auto;
text-align: center;
content: attr(aria-label);
font-size: 2rem;
text-wrap: auto;
text-align: center;
}
.emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer {
gap: 4rem
gap: 4rem;
}
.layout-mobile .emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer {
gap: 2rem;
gap: 2rem;
}
[data-section-id="upcoming"] .missingIndicator {
display: none;
}
display: none;
}
.homeSectionsContainer .section9 + .custom-scroller-container {
margin-top: 1em;
}
/* .pageTabContent:has(.kefinHomeSectionsContainer[data-kefin-home-screen]) .homeSectionsContainer:not([data-kefin-home-screen]) {
display: none !important;
}
body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0%;
background: linear-gradient(rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.85)), var(--seasonal-background-image);
background-position: center;
background-size: cover;
}
body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay {
display: block;
} */
+4 -3
View File
@@ -2596,9 +2596,10 @@
uniqueStudios.sort((a, b) => (b.ChildCount || 0) - (a.ChildCount || 0));
// Filter by minimum shows
const filteredStudios = uniqueStudios.filter(studio =>
(studio.ChildCount || 0) >= minimumShowsForNetwork
);
const filteredStudios = uniqueStudios.filter(studio => {
const itemCount = studio.ChildCount || studio.SeriesCount || 0;
return itemCount >= minimumShowsForNetwork;
});
LOG(`Fetched ${filteredStudios.length} Popular TV Networks (filtered from ${uniqueStudios.length})`);
+399 -253
View File
@@ -336,7 +336,7 @@
if (nextupSectionConfig && nextupSectionConfig.enabled && nextupSectionConfig.queries) {
for (const query of nextupSectionConfig.queries) {
if (query.path || !query.dataSource) {
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), nextupSectionConfig.renderMode === 'Spotlight');
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), nextupSectionConfig.renderMode === 'Spotlight', { sectionType: nextupSectionConfig.type });
if (queryUrl && typeof queryUrl === 'string') {
ApiHelper.invalidateCache(queryUrl);
}
@@ -348,7 +348,7 @@
if (continueWatchingSectionConfig && continueWatchingSectionConfig.enabled && continueWatchingSectionConfig.queries) {
for (const query of continueWatchingSectionConfig.queries) {
if (query.path || !query.dataSource) {
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingSectionConfig.renderMode === 'Spotlight');
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingSectionConfig.renderMode === 'Spotlight', { sectionType: continueWatchingSectionConfig.type });
if (queryUrl && typeof queryUrl === 'string') {
ApiHelper.invalidateCache(queryUrl);
}
@@ -360,7 +360,7 @@
if (continueWatchingAndNextUpSectionConfig && continueWatchingAndNextUpSectionConfig.enabled && continueWatchingAndNextUpSectionConfig.queries) {
for (const query of continueWatchingAndNextUpSectionConfig.queries) {
if (query.path || !query.dataSource) {
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingAndNextUpSectionConfig.renderMode === 'Spotlight');
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingAndNextUpSectionConfig.renderMode === 'Spotlight', { sectionType: continueWatchingAndNextUpSectionConfig.type });
if (queryUrl && typeof queryUrl === 'string') {
ApiHelper.invalidateCache(queryUrl);
}
@@ -427,24 +427,46 @@
let performanceDuration = performanceEndTime - performanceStartTime;
LOG(`Home Screen v3 Fetch display preferences initialization time: ${performanceDuration.toFixed(2)}ms`);
// Load user preferences and apply filtering/ordering
performanceStartTime = performance.now();
// Merge configs - flatten groups first
//const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []);
//const mergedHomeSections = await mergeHomeSectionConfigs(defaultHomeSections);
const mergedHomeSections = await mergeHomeSectionConfigs();
const userHomeConfig = window.KefinUserHomeScreenConfig;
let filteredHomeSections;
let homeScreen;
let userDisplayPreferences = null;
if (typeof userHomeConfig?.getConfig === 'function') {
const userConfig = await userHomeConfig.getConfig();
filteredHomeSections = userConfig.sections || [];
homeScreen = userConfig.homeScreen;
userDisplayPreferences = await state.getDisplayPrefernces();
//addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen);
} else {
const mergedHomeSections = await mergeHomeSectionConfigs();
userDisplayPreferences = await state.getDisplayPrefernces();
const customPrefs = userDisplayPreferences?.CustomPrefs || {};
homeScreen = userHomeConfig.parseKefinTweaksHomeScreen(customPrefs);
const pinnedSections = userHomeConfig.buildPinnedSectionConfigs(homeScreen);
//addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen);
const serverSectionsById = new Map();
mergedHomeSections.forEach(section => {
if (section?.id) serverSectionsById.set(section.id, section);
});
filteredHomeSections = [...mergedHomeSections, ...pinnedSections];
filteredHomeSections = userHomeConfig.applyUserSectionOverrides(filteredHomeSections, homeScreen, { serverSectionsById });
filteredHomeSections = filteredHomeSections.filter(section => section.enabled !== false);
}
performanceEndTime = performance.now();
performanceDuration = performanceEndTime - performanceStartTime;
LOG(`Home Screen v3 Merge home section configs initialization time: ${performanceDuration.toFixed(2)}ms`);
LOG(`Home Screen v3 Load user home screen sections initialization time: ${performanceDuration.toFixed(2)}ms`);
performanceStartTime = performance.now();
const nextupSectionConfig = mergedHomeSections.find(s => s.id === 'nextUp');
const continueWatchingSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatching');
const continueWatchingAndNextUpSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatchingAndNextUp');
const recentlyAddedSectionConfig = mergedHomeSections.find(s => s.id.startsWith('recently-added-') && s.enabled === true);
const nextupSectionConfig = filteredHomeSections.find(s => s.id === 'nextUp');
const continueWatchingSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatching');
const continueWatchingAndNextUpSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatchingAndNextUp');
const recentlyAddedSectionConfig = filteredHomeSections.find(s => s.id?.startsWith?.('recently-added-') && s.enabled === true);
state.kefinNextUp = nextupSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true;
state.kefinContinueWatching = continueWatchingSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true;
state.kefinLatestMedia = recentlyAddedSectionConfig?.enabled === true;
@@ -456,30 +478,6 @@
});
}
performanceEndTime = performance.now();
performanceDuration = performanceEndTime - performanceStartTime;
LOG(`Home Screen v3 Setup event listener initialization time: ${performanceDuration.toFixed(2)}ms`);
// Load user preferences and apply filtering/ordering
performanceStartTime = performance.now();
const userDisplayPreferences = await state.getDisplayPrefernces();
let userHomeScreenSections = loadUserHomeScreenSections(userDisplayPreferences);
addUserHomeScreenOrderCSS(userDisplayPreferences);
let filteredHomeSections = mergedHomeSections.map(section => {
const userSection = userHomeScreenSections.find(s => s.id === section.id);
if (userSection) {
section.enabled = userSection.enabled;
section.order = userSection.order;
}
return section;
});
performanceEndTime = performance.now();
performanceDuration = performanceEndTime - performanceStartTime;
LOG(`Home Screen v3 Load user home screen sections initialization time: ${performanceDuration.toFixed(2)}ms`);
// Set up mutation observer BEFORE rendering, so we can react when Jellyfin renders home sections
/* const handleJellyfinRender = async () => {
const jellyHome = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer');
@@ -721,6 +719,7 @@
enabled: true,
order: libConfig.order || 61,
cardFormat: libConfig.cardFormat || 'Poster',
jellyfinId: 'latestmedia',
viewMoreUrl: viewMoreUrl,
queries: [{
path: `/Items/Latest`,
@@ -815,7 +814,7 @@
return filteredMerged; */
}
function getDiscoverySections() {
async function getDiscoverySections() {
const settings = getDiscoverySettings();
if (!settings || settings.enabled === false) {
@@ -823,6 +822,18 @@
return [];
}
const page = state.discoveryGroupIndex + 1;
const filterByDiscoveryPage = (sections) => (sections || []).filter((section) => {
if (section.pageNumber == null) return true; // native discovery templates
return Number(section.pageNumber) === page;
});
const userHomeScreenConfig = await window.KefinUserHomeScreenConfig.getConfig();
if (userHomeScreenConfig) {
return filterByDiscoveryPage(userHomeScreenConfig.enabledDiscoverySections);
}
const homeScreenConfig = window.KefinHomeScreen.getConfig();
// Get default discovery sections
@@ -844,30 +855,35 @@
return section;
});
// Get custom discovery sections from CUSTOM_SECTION_GROUPS
// Filter for sections that have discovery-related properties (type, source: 'Dynamic', etc.)
const customSections = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.CUSTOM_SECTION_GROUPS || []) : [];
const customDiscoverySections = customSections.filter(section => {
return section.discoveryEnabled === true && section.enabled === true;
});
const isInSeasonalPeriod = (startDate, endDate) => {
const currentDate = new Date();
const start = new Date(startDate);
const end = new Date(endDate);
start.setFullYear(currentDate.getFullYear());
end.setFullYear(currentDate.getFullYear());
return currentDate >= start && currentDate <= end;
};
// Add one additional custom discovery section that hasn't been rendered yet
if (customDiscoverySections.length > 0) {
const availableSections = customDiscoverySections.filter(section => !state.renderedDiscoveryIds.customDiscoverySections.has(section.id));
if (availableSections.length > 0) {
const selectedSection = availableSections[Math.floor(Math.random() * availableSections.length)];
if (selectedSection) {
mergedSections.push(selectedSection);
state.renderedDiscoveryIds.customDiscoverySections.add(selectedSection.id);
}
}
}
const customGroups = homeScreenConfig?.CUSTOM_SECTION_GROUPS
|| window.KefinTweaksConfig?.homeScreenConfig?.CUSTOM_SECTION_GROUPS
|| [];
const collect = window.KefinHomeScreen?.collectCustomDiscoverySections;
const customDiscoverySections = typeof collect === 'function'
? collect(customGroups, {
isSectionActive: (s) => s.enabled === true,
isInSeasonalPeriod
})
: [];
mergedSections.push(...filterByDiscoveryPage(customDiscoverySections));
// Legacy support: Also check old customSections location
const legacyCustomSections = window.KefinTweaksConfig?.homeScreen?.customSections || [];
if (legacyCustomSections.length > 0) {
legacyCustomSections.forEach(section => {
if (section.enabled && section.discoveryEnabled) {
const legacyPage = section.pageNumber == null ? 1 : Number(section.pageNumber);
if (legacyPage !== page) return;
if (state.renderedDiscoveryIds.customDiscoverySections.has(section.id)) {
return;
}
@@ -982,9 +998,10 @@
/**
* Add CSS for user-defined section ordering
*/
function addUserHomeScreenOrderCSS(userDisplayPreferences) {
function addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen = null) {
const customPrefs = userDisplayPreferences?.CustomPrefs || {};
const kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen || '[]');
const parsedHomeScreen = homeScreen || window.KefinUserHomeScreenConfig.parseKefinTweaksHomeScreen(customPrefs);
const kefinHomeScreen = window.KefinUserHomeScreenConfig.homeScreenToLegacyArray(parsedHomeScreen);
// Create a homesectionN : order value mapping for the custom CSS orders to be applied
const jellyfinOrders = {};
@@ -1183,10 +1200,17 @@
sectionCache.renderedSections = [...sectionsToRender];
}
const homeConfig = await window.KefinHomeScreen.getConfig();
const showStaleDataBeforeRefresh = homeConfig?.HOME_SETTINGS?.SHOW_STALE_DATA_BEFORE_REFRESH === true;
const targetContainer = container;
const renderStartTime = performance.now();
await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, { waitForContainerClass: 'homeSectionsContainer' });
await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, {
waitForContainerClass: 'homeSectionsContainer',
enhanceOnVisible: true,
showStaleDataBeforeRefresh
});
const renderEndTime = performance.now();
const renderDuration = renderEndTime - renderStartTime;
LOG(`Home Screen v3 Render progressive sections initialization time: ${renderDuration.toFixed(2)}ms`);
@@ -1282,19 +1306,6 @@
sectionConfig.name = sectionName;
}
// Check for forced image type preference
let forcedImageType = null;
if ((sectionConfig.id === 'nextUp' || sectionConfig.id === 'continueWatching' || sectionConfig.id === 'continueWatchingAndNextUp')) {
const userDisplayPreferences = await state.getDisplayPrefernces();
if (userDisplayPreferences && userDisplayPreferences.CustomPrefs && userDisplayPreferences.CustomPrefs.useEpisodeImagesInNextUpAndResume === 'true') {
forcedImageType = 'Primary';
}
}
if (forcedImageType) {
sectionConfig.forcedImageType = forcedImageType;
}
// Check renderMode first (preferred), fallback to spotlight boolean for backward compatibility
const shouldRenderSpotlight = sectionConfig.renderMode === 'Spotlight' || sectionConfig.renderMode === 'Random' || sectionConfig.spotlight === true;
if (shouldRenderSpotlight) {
@@ -1320,6 +1331,9 @@
const squareUrl = normalizeTemplate(item.squareUrl);
const imageUrl = normalizeTemplate(item.imageUrl);
const cardUrl = normalizeTemplate(item.cardUrl);
const backdropUrl = normalizeTemplate(item.backdropUrl);
const bannerUrl = normalizeTemplate(item.bannerUrl);
const logoUrl = normalizeTemplate(item.logoUrl);
return {
Name: item.Name,
@@ -1330,6 +1344,9 @@
squareUrl: squareUrl,
imageUrl: imageUrl,
cardUrl: cardUrl,
backdropUrl: backdropUrl,
bannerUrl: bannerUrl,
logoUrl: logoUrl,
CustomFooterText: item.cardFooter || undefined
};
});
@@ -1357,8 +1374,28 @@
const serverUrl = ApiClient.serverAddress();
const results = [];
const resolveQueries = window.cardBuilder?.resolveQueriesToLoad;
const queriesToLoad = typeof resolveQueries === 'function'
? resolveQueries(sectionConfig, { initialize: true })
: (() => {
if (sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) {
const randomIndex = Math.floor(Math.random() * sectionConfig.queries.length);
LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${randomIndex}`);
return [sectionConfig.queries[randomIndex]];
}
return sectionConfig.queries;
})();
if (sectionConfig.useMultiQueryPicker === true && sectionConfig.queries.length > 1) {
const index = sectionConfig._selectedQueryIndex;
LOG(`Section ${sectionConfig.id}: useMultiQueryPicker — selected query index ${index}`);
} else if (queriesToLoad.length === 1 && sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) {
const index = sectionConfig.queries.indexOf(queriesToLoad[0]);
LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${index}`);
}
// Process each query in the queries array
for (const query of sectionConfig.queries) {
for (const query of queriesToLoad) {
let queryResult;
if (query.dataSource) {
@@ -1366,7 +1403,7 @@
queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {});
} else {
// Build and execute query
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight');
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', { sectionType: sectionConfig.type });
if (typeof queryUrl === 'string') {
// Standard query
@@ -1405,21 +1442,73 @@
if (window.cardBuilder.postProcessItems) {
postProcessedItems = window.cardBuilder.postProcessItems(sectionConfig, items);
}
return postProcessedItems;
}
return sortItemsByConfiguredIds(sectionConfig, postProcessedItems);
};
const queryResult = results[0];
let mappedDataPromise = null;
const ensureData = () => {
if (!mappedDataPromise) {
const raw = typeof queryResult.ensureData === 'function'
? queryResult.ensureData()
: queryResult.dataPromise;
mappedDataPromise = Promise.resolve(raw).then((data) => postProcess(sectionConfig, data));
}
return mappedDataPromise;
};
const result = {
data: postProcess(sectionConfig, queryResult.data),
isStale: queryResult.isStale === true,
isStalePromise: queryResult.isStalePromise,
ensureData
};
Object.defineProperty(result, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return ensureData();
}
});
// Single query result
return {
config: sectionConfig,
queryUrl: null,
result: {
data: postProcess(sectionConfig, results[0].data),
dataPromise: results[0].dataPromise.then(data => postProcess(sectionConfig, data)),
isStalePromise: results[0].isStalePromise
}
result
};
}
/**
* Jellyfin /Items?Ids= does not reliably return items in request order.
* When queryOptions.Ids is set, re-sort the item list to match that sequence.
*/
function sortItemsByConfiguredIds(sectionConfig, items) {
const list = Array.isArray(items) ? items : (items?.Items || []);
if (!Array.isArray(list) || list.length === 0) return items;
const rawIds = sectionConfig?.queries?.[0]?.queryOptions?.Ids;
if (!rawIds) return items;
const ids = Array.isArray(rawIds)
? rawIds.filter(Boolean)
: String(rawIds).split(',').map((s) => s.trim()).filter(Boolean);
if (!ids.length) return items;
const order = new Map(ids.map((id, i) => [String(id), i]));
const sorted = [...list].sort((a, b) => {
const ai = order.has(String(a?.Id)) ? order.get(String(a.Id)) : 1e9;
const bi = order.has(String(b?.Id)) ? order.get(String(b.Id)) : 1e9;
return ai - bi;
});
if (Array.isArray(items)) return sorted;
if (items && typeof items === 'object' && Array.isArray(items.Items)) {
return { ...items, Items: sorted };
}
return sorted;
}
function deduplicateItems(items) {
const deduplicated = [];
const ids = new Set();
@@ -1444,36 +1533,7 @@
let _getWatchlistUrlPromise = null;
async function getWatchlistUrl() {
const watchlistTabIndex = localStorage.getItem(`kefinTweaks_watchlistTabIndex_${ApiClient.serverId()}`);
if (watchlistTabIndex) {
return `#/home.html?tab=${watchlistTabIndex}`;
}
LOG('Watchlist tab index not found in local storage');
// Fetch the tab in the background so that next time it's available
window.KefinTweaksUtils.getWatchlistTabIndex();
return null;
let homePageSuffix = '.html';
if (window.ApiClient && window.ApiClient.serverInfo && window.ApiClient.serverInfo().Version?.split('.')[1] > 10) {
homePageSuffix = '';
}
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.getWatchlistTabIndex) {
WARN('KefinTweaksUtils not available');
return null;
}
const visibleWatchlistTabIndex = await window.KefinTweaksUtils.getWatchlistTabIndex();
if (visibleWatchlistTabIndex) {
return `#/home${homePageSuffix}?tab=${visibleWatchlistTabIndex}`;
} else {
WARN('Watchlist tab not found');
return null;
}
return '#/watchlist';
}
/**
@@ -1509,7 +1569,7 @@
const watchlistUrl = await getWatchlistUrl();
if (watchlistUrl) {
return `${watchlistUrl}&pageTab=history`;
return `${watchlistUrl}?pageTab=history`;
}
return null;
}
@@ -1559,7 +1619,7 @@
// Check for ParentId
if (queryOptions.ParentId) {
if (sectionConfig.type === 'Collection' || sectionConfig.type === 'Playlist') {
if (sectionConfig.discoveryType === 'Collection' || sectionConfig.discoveryType === 'Playlist') {
return `#/details?id=${queryOptions.ParentId}&serverId=${serverId}`;
}
@@ -1636,7 +1696,7 @@
* Returns { id, name, metadata: {} }
*/
async function resolveDynamicSource(config) {
switch (config.type) {
switch (config.discoveryType) {
case 'Genre':
const genre = await getRandomGenre();
return genre ? { id: genre.Id, name: genre.Name, metadata: { Genre: genre.Name } } : null;
@@ -1736,6 +1796,163 @@
});
}
/**
* Resolves a dynamic discovery template into a concrete section config with queries.
* Returns null when no source could be resolved (e.g. no eligible genre/person left).
* @param {Object} template - Discovery section template
* @param {Object} [options]
* @param {boolean} [options.pairSpotlight] - Reuse the paired spotlight source (home render behaviour)
* @param {boolean} [options.resolveViewMore] - Resolve the "view more" URL (may issue extra requests)
*/
async function buildDiscoverySectionInstance(template, options = {}) {
const pairSpotlight = options.pairSpotlight !== false;
const resolveViewMore = options.resolveViewMore !== false;
const instanceConfig = {
...template,
id: `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'discovery',
};
if (!instanceConfig.discoveryType && template.type
&& !['home', 'seasonal', 'discovery'].includes(String(template.type).toLowerCase())) {
instanceConfig.discoveryType = template.type;
}
const dynamicResult = await resolveDynamicSource(instanceConfig);
if (!dynamicResult) return null;
instanceConfig.source = dynamicResult.id || dynamicResult.name;
instanceConfig.metadata = dynamicResult.metadata;
instanceConfig.excludeItemId = dynamicResult.excludeItemId;
if (pairSpotlight && getDiscoverySettings().renderSpotlightAboveMatching) {
const configId = instanceConfig.id.split('-')[0];
if (configId === 'genreMovies' || configId === 'spotlightGenre') {
if (state.currentDiscoveryGenre) {
instanceConfig.source = state.currentDiscoveryGenre.id;
dynamicResult.metadata = state.currentDiscoveryGenre.metadata;
state.currentDiscoveryGenre = null;
}
else {
state.currentDiscoveryGenre = dynamicResult;
}
}
if (configId === 'studioShows' || configId === 'spotlightNetwork') {
if (state.currentDiscoveryStudio) {
instanceConfig.source = state.currentDiscoveryStudio.id;
dynamicResult.metadata = state.currentDiscoveryStudio.metadata;
state.currentDiscoveryStudio = null;
}
else {
state.currentDiscoveryStudio = dynamicResult;
}
}
}
// Create queries array with proper queryOptions
const queryOptions = {
Recursive: 'true',
Limit: instanceConfig.itemLimit || 20
};
// Add IncludeItemTypes if specified
if (instanceConfig.includeItemTypes && instanceConfig.includeItemTypes.length) {
queryOptions.IncludeItemTypes = instanceConfig.includeItemTypes;
}
// Add SortBy and SortOrder if specified
if (instanceConfig.sortOrder) {
queryOptions.SortBy = instanceConfig.sortOrder;
}
if (instanceConfig.sortOrderDirection) {
queryOptions.SortOrder = instanceConfig.sortOrderDirection;
}
// Handle discoveryType-specific query options
const resolvedSource = instanceConfig.source;
const discoveryKind = instanceConfig.discoveryType || instanceConfig.type;
if (discoveryKind === 'Similar') {
// Similar type uses custom path
instanceConfig.queries = [{
path: `/Items/${resolvedSource}/Similar`,
queryOptions: {
Limit: instanceConfig.itemLimit || 20,
Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'
}
}];
} else {
// Standard query types
switch (discoveryKind) {
case 'Genre':
if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) {
queryOptions.GenreIds = [resolvedSource];
} else if (resolvedSource) {
queryOptions.Genres = resolvedSource;
}
break;
case 'Tag':
if (resolvedSource) {
queryOptions.Tags = resolvedSource;
}
break;
case 'Person':
if (resolvedSource) {
queryOptions.PersonIds = [resolvedSource];
}
if (instanceConfig.excludeItemId) {
queryOptions.ExcludeItemIds = Array.isArray(instanceConfig.excludeItemId)
? instanceConfig.excludeItemId
: [instanceConfig.excludeItemId];
}
break;
case 'Studio':
if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) {
queryOptions.StudioIds = [resolvedSource];
} else if (resolvedSource) {
queryOptions.Studios = resolvedSource;
}
break;
case 'Collection':
case 'Parent':
if (resolvedSource) {
queryOptions.ParentId = resolvedSource;
}
break;
}
// Add spotlight fields if needed
if (instanceConfig.spotlight || instanceConfig.renderMode === 'Spotlight') {
queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios';
const spotlightConfig = instanceConfig.spotlightConfig || {};
const adminSpotlightConfig = window.KefinHomeScreen.getConfig()?.SPOTLIGHT_SETTINGS || {};
instanceConfig.spotlightConfig = { ...adminSpotlightConfig, ...spotlightConfig };
} else {
queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData';
}
instanceConfig.queries = [{
queryOptions: queryOptions
}];
}
instanceConfig.name = fillTemplate(instanceConfig.name, dynamicResult.metadata);
if (instanceConfig.caption) {
instanceConfig.caption = fillTemplate(instanceConfig.caption, dynamicResult.metadata);
if (dynamicResult.excludeItemId) {
instanceConfig.captionUrl = `#/details?id=${dynamicResult.excludeItemId}&serverId=${ApiClient.serverId()}`;
}
}
instanceConfig.cardFormat = resolveCardFormat(instanceConfig);
if (resolveViewMore) {
instanceConfig.viewMoreUrl = await resolveViewMoreUrl(instanceConfig);
}
return instanceConfig;
}
function disconnectDiscoveryInteraction() {
// If we are using infinite scroll, disconnect the scroll event listener
if (state.infiniteScrollHandler) {
@@ -1814,7 +2031,7 @@
}
// Ensure at least 1 discovery section is enabled
const discoverySections = getDiscoverySections();
const discoverySections = await getDiscoverySections();
if (!discoverySections || discoverySections.length === 0 || discoverySections.every(section => section.enabled === false)) {
LOG('No discovery sections enabled in config.');
state.discoveryEnabled = false;
@@ -1869,7 +2086,11 @@
const config = await window.KefinHomeScreen.getConfig();
const revealSectionsSequentially = config.DISCOVERY_SETTINGS?.fadeInSections === true;
await window.cardBuilder.renderProgressiveSections(container, bufferedSections, { revealSectionsSequentially });
await window.cardBuilder.renderProgressiveSections(container, bufferedSections, {
revealSectionsSequentially,
enhanceOnVisible: true,
showStaleDataBeforeRefresh: config.HOME_SETTINGS?.SHOW_STALE_DATA_BEFORE_REFRESH === true
});
if (!getDiscoverySettings().infiniteScroll) {
setupLoadMoreButton(container);
@@ -1926,7 +2147,7 @@
const selectedConfigs = [];
let discoveryTemplates = getDiscoverySections();
let discoveryTemplates = await getDiscoverySections();
if (getDiscoverySettings().randomizeOrder) {
discoveryTemplates = [...discoveryTemplates].sort(() => 0.5 - Math.random());
@@ -1940,137 +2161,8 @@
continue;
}
const instanceConfig = {
...template,
id: `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
const dynamicResult = await resolveDynamicSource(instanceConfig);
if (dynamicResult) {
instanceConfig.source = dynamicResult.id || dynamicResult.name;
instanceConfig.metadata = dynamicResult.metadata;
instanceConfig.excludeItemId = dynamicResult.excludeItemId;
if (getDiscoverySettings().renderSpotlightAboveMatching) {
const configId = instanceConfig.id.split('-')[0];
if (configId === 'genreMovies' || configId === 'spotlightGenre') {
if (state.currentDiscoveryGenre) {
instanceConfig.source = state.currentDiscoveryGenre.id;
dynamicResult.metadata = state.currentDiscoveryGenre.metadata;
state.currentDiscoveryGenre = null;
}
else {
state.currentDiscoveryGenre = dynamicResult;
}
}
if (configId === 'studioShows' || configId === 'spotlightNetwork') {
if (state.currentDiscoveryStudio) {
instanceConfig.source = state.currentDiscoveryStudio.id;
dynamicResult.metadata = state.currentDiscoveryStudio.metadata;
state.currentDiscoveryStudio = null;
}
else {
state.currentDiscoveryStudio = dynamicResult;
}
}
}
// Create queries array with proper queryOptions
const queryOptions = {
Recursive: 'true',
Limit: instanceConfig.itemLimit || 20
};
// Add IncludeItemTypes if specified
if (instanceConfig.includeItemTypes && instanceConfig.includeItemTypes.length) {
queryOptions.IncludeItemTypes = instanceConfig.includeItemTypes;
}
// Add SortBy and SortOrder if specified
if (instanceConfig.sortOrder) {
queryOptions.SortBy = instanceConfig.sortOrder;
}
if (instanceConfig.sortOrderDirection) {
queryOptions.SortOrder = instanceConfig.sortOrderDirection;
}
// Handle type-specific query options
const resolvedSource = instanceConfig.source;
if (instanceConfig.type === 'Similar') {
// Similar type uses custom path
instanceConfig.queries = [{
path: `/Items/${resolvedSource}/Similar`,
queryOptions: {
Limit: instanceConfig.itemLimit || 20,
Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'
}
}];
} else {
// Standard query types
switch (instanceConfig.type) {
case 'Genre':
if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) {
queryOptions.GenreIds = [resolvedSource];
} else if (resolvedSource) {
queryOptions.Genres = resolvedSource;
}
break;
case 'Tag':
if (resolvedSource) {
queryOptions.Tags = resolvedSource;
}
break;
case 'Person':
if (resolvedSource) {
queryOptions.PersonIds = [resolvedSource];
}
if (instanceConfig.excludeItemId) {
queryOptions.ExcludeItemIds = Array.isArray(instanceConfig.excludeItemId)
? instanceConfig.excludeItemId
: [instanceConfig.excludeItemId];
}
break;
case 'Studio':
if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) {
queryOptions.StudioIds = [resolvedSource];
} else if (resolvedSource) {
queryOptions.Studios = resolvedSource;
}
break;
case 'Collection':
case 'Parent':
if (resolvedSource) {
queryOptions.ParentId = resolvedSource;
}
break;
}
// Add spotlight fields if needed
if (instanceConfig.spotlight || instanceConfig.renderMode === 'Spotlight') {
queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios';
const spotlightConfig = instanceConfig.spotlightConfig || {};
const adminSpotlightConfig = window.KefinHomeScreen.getConfig()?.SPOTLIGHT_SETTINGS || {};
instanceConfig.spotlightConfig = { ...adminSpotlightConfig, ...spotlightConfig };
} else {
queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData';
}
instanceConfig.queries = [{
queryOptions: queryOptions
}];
}
const sectionName = fillTemplate(instanceConfig.name, dynamicResult.metadata);
const cardFormat = resolveCardFormat(instanceConfig);
const viewMoreUrl = await resolveViewMoreUrl(instanceConfig);
instanceConfig.name = sectionName;
instanceConfig.cardFormat = cardFormat;
instanceConfig.viewMoreUrl = viewMoreUrl;
const instanceConfig = await buildDiscoverySectionInstance(template);
if (instanceConfig) {
selectedConfigs.push(instanceConfig);
}
}
@@ -2110,8 +2202,40 @@
}
}
// Apply user prefs keyed by template prefix for non-custom discovery variants
try {
const userHomeConfig = window.KefinUserHomeScreenConfig;
if (userHomeConfig?.applyUserSectionOverrides && selectedConfigs.length) {
const prefs = state.getDisplayPrefernces
? await state.getDisplayPrefernces()
: null;
const homeScreen = userHomeConfig.parseKefinTweaksHomeScreen?.(prefs?.CustomPrefs)
|| userHomeConfig.createEmptyHomeScreen?.()
|| { sections: [] };
const serverSectionsById = new Map();
selectedConfigs.forEach((section) => {
const storedId = userHomeConfig.getStoredSectionPrefId?.(section) || section.id;
if (storedId && !serverSectionsById.has(storedId)) {
serverSectionsById.set(storedId, { ...section, id: storedId });
}
});
const overridden = userHomeConfig.applyUserSectionOverrides(
selectedConfigs,
homeScreen,
{ serverSectionsById }
);
selectedConfigs.length = 0;
selectedConfigs.push(...overridden.filter(s => s.enabled !== false));
}
} catch (e) {
WARN('Failed to apply user overrides to discovery sections:', e);
}
selectedConfigs.forEach((section) => {
section.order = state.discoverySectionOrder++;
// Mark discovery render set so section roots get data-discovery-section
// (templated discovery rows use Genre/Studio/etc. as type, not "discovery").
section.discoverySection = true;
});
//localCache.set('discoveryBuffer', selectedConfigs);
@@ -2238,7 +2362,7 @@
try {
const userId = ApiClient.getCurrentUserId();
const response = await fetch(`${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`, {
headers: { 'X-Emby-Token': ApiClient.accessToken() }
headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
const data = await response.json();
const genres = (data.Items || []).map(g => ({ Id: g.Id, Name: g.Name, MovieCount: g.MovieCount }));
@@ -2420,7 +2544,29 @@
return StudiosCache.getPopularTVNetworks();
}
window.homeScreen3 = { init: enhanceHomeScreen };
async function refreshHomeSections() {
const container = document.querySelector('.homePage:not(.hide) #homeTab .sections');
if (!container) {
LOG('refreshHomeSections: container not found');
return;
}
container.querySelectorAll('[data-section-id]').forEach(el => el.remove());
delete container.dataset.sectionsRendered;
delete container.dataset.kefinHomeScreen;
sectionCache.renderedSections = [];
sectionCache.fragmentCache = null;
sectionCache.isInitialRender = true;
await enhanceHomeScreen();
}
window.homeScreen3 = {
init: enhanceHomeScreen,
refreshHomeSections,
resolveDiscoverySection: buildDiscoverySectionInstance
};
//enhanceHomeScreen();
+176 -36
View File
@@ -26,10 +26,12 @@
{
id: 'my-media',
name: 'My Media',
jellyfinId: 'smalllibrarytiles',
enabled: false,
order: 5,
cardFormat: 'Thumb',
ttl: CACHE_CONFIG.LONG_TTL,
userConfigurable: true,
queries: [
{
path: '/UserViews',
@@ -44,10 +46,12 @@
{
id: 'my-media-small',
name: 'My Media (Small)',
jellyfinId: 'librarybuttons',
enabled: false,
order: 6,
cardFormat: 'Button',
ttl: CACHE_CONFIG.LONG_TTL,
userConfigurable: true,
queries: [
{
path: '/UserViews',
@@ -68,20 +72,24 @@
sections: [
{
id: 'continueWatching',
name: 'Resume',
name: 'Continue Watching',
jellyfinId: 'resume',
enabled: false,
hidden: false,
order: 10,
cardFormat: 'Thumb',
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
userConfigurable: true,
queries: [
{
path: '/UserItems/Resume',
queryOptions: {
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Filters: 'IsResumable',
IncludeItemTypes: ['Episode', 'Movie'],
Limit: 24
MediaTypes: 'Video',
Fields: 'PrimaryImageAspectRatio',
Limit: 12,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: 'false',
}
}
]
@@ -89,24 +97,31 @@
{
id: 'nextUp',
name: 'Next Up',
jellyfinId: 'nextup',
enabled: false,
hidden: false,
order: 11,
cardFormat: 'Thumb',
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
userConfigurable: true,
queries: [
{
path: '/Shows/NextUp',
queryOptions: {
Fields: 'PrimaryImageAspectRatio,DateCreated,Path,MediaSourceCount',
Limit: 24,
EnableResumable: 'false'
EnableResumable: 'false',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: 'false',
}
}
]
},
{
id: 'continueWatchingAndNextUp',
name: 'Continue Watching',
name: 'Continue Watching and Next Up',
jellyfinId: 'resume',
enabled: false, // Disabled by default - only used when merge setting is enabled
order: 10,
hidden: true,
@@ -114,23 +129,29 @@
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
sortBy: 'DatePlayed', // Used to sort merged results from both queries
sortOrder: 'Descending', // Most recent first
userConfigurable: true,
queries: [
{
// Continue Watching (Movies only when merged)
path: '/UserItems/Resume',
queryOptions: {
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Filters: 'IsResumable',
MediaTypes: 'Video',
IncludeItemTypes: ['Movie'],
Limit: 24
Fields: 'PrimaryImageAspectRatio',
Limit: 12,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: 'false',
}
},
{
// Next Up (Episodes)
path: '/Shows/NextUp',
queryOptions: {
Fields: 'PrimaryImageAspectRatio,DateCreated,Path,MediaSourceCount',
Limit: 24,
EnableResumable: 'true'
EnableResumable: 'true',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: 'false',
}
}
]
@@ -138,17 +159,23 @@
{
id: 'continueListening',
name: 'Continue Listening',
jellyfinId: 'resumeaudio',
enabled: false,
hidden: false,
order: 10,
cardFormat: 'Square',
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
userConfigurable: true,
queries: [
{
path: '/UserItems/Resume',
queryOptions: {
MediaTypes: 'Audio',
Limit: 16
Fields: 'PrimaryImageAspectRatio',
Limit: 12,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: 'false',
}
}
]
@@ -156,17 +183,23 @@
{
id: 'continueReading',
name: 'Continue Reading',
jellyfinId: 'resumebook',
enabled: false,
hidden: false,
order: 10,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.FORCE_REFRESH_TTL,
userConfigurable: true,
queries: [
{
path: '/UserItems/Resume',
queryOptions: {
MediaTypes: 'Book',
Limit: 16
Fields: 'PrimaryImageAspectRatio',
Limit: 12,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: 'false',
}
}
]
@@ -186,6 +219,8 @@
cardFormat: 'Thumb',
flattenSeries: true,
ttl: CACHE_CONFIG.LONG_TTL,
userConfigurable: true,
sectionCssClass: 'hideIndicators',
queries: [
{
path: '/Shows/Upcoming',
@@ -209,6 +244,7 @@
order: 20,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
minAge: 0,
@@ -231,6 +267,7 @@
cardFormat: 'Thumb',
flattenSeries: true,
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
minAge: 0,
@@ -261,15 +298,20 @@
{
id: 'liveTv-recordings',
name: 'Recordings',
jellyfinId: 'activerecordings',
enabled: false,
order: 90,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Recordings',
queryOptions: {
Limit: 20,
IsInProgress: true,
Fields: 'PrimaryImageAspectRatio',
Limit: 12,
EnableTotalRecordCount: 'false',
}
}
]
@@ -281,6 +323,7 @@
order: 91,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Timers',
@@ -295,9 +338,11 @@
{
id: 'liveTv-browse',
name: 'Live TV',
jellyfinId: 'livetv',
enabled: false,
order: 91,
cardFormat: 'Button',
userConfigurable: true,
items: [
{
Name: 'Programs',
@@ -328,16 +373,22 @@
{
id: 'liveTv-on-now',
name: 'On Now',
jellyfinId: 'livetv',
enabled: false,
order: 92,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.VERY_SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Programs/Recommended',
queryOptions: {
IsAiring: true,
Limit: 24,
Fields: 'ChannelInfo,PrimaryImageAspectRatio',
Limit: 1,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
EnableTotalRecordCount: 'false',
}
}
]
@@ -349,6 +400,7 @@
order: 92,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.VERY_SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Programs',
@@ -368,6 +420,7 @@
cardFormat: 'Thumb',
hideCardFooter: true,
ttl: CACHE_CONFIG.LONG_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Channels',
@@ -385,6 +438,7 @@
order: 92,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.VERY_SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Programs',
@@ -408,6 +462,7 @@
order: 92,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.VERY_SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Programs',
@@ -427,6 +482,7 @@
order: 92,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.VERY_SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Programs',
@@ -446,6 +502,7 @@
order: 92,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.VERY_SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Programs',
@@ -465,6 +522,7 @@
order: 92,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.VERY_SHORT_TTL,
userConfigurable: true,
queries: [
{
path: '/LiveTv/Programs',
@@ -491,6 +549,7 @@
order: 70,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.LONG_TTL,
userConfigurable: true,
queries: [
{
dataSource: 'MoviesCache.getImdbTop250Movies',
@@ -520,6 +579,7 @@
sortBy: 'Random',
limitBeforeSort: true,
hideCardFooter: true,
userConfigurable: true,
queries: [
{
path: '/Studios',
@@ -543,6 +603,7 @@
sortBy: 'Random',
limitBeforeSort: true,
hideCardFooter: true,
userConfigurable: true,
queries: [
{
path: '/Studios',
@@ -566,6 +627,7 @@
sortBy: 'Random',
limitBeforeSort: true,
hideCardFooter: true,
userConfigurable: true,
queries: [
{
path: '/Studios',
@@ -598,6 +660,7 @@
order: 73,
cardFormat: 'Random',
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
queryOptions: {
@@ -623,6 +686,7 @@
order: 71,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
queryOptions: {
@@ -648,6 +712,7 @@
order: 74,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
queryOptions: {
@@ -667,6 +732,7 @@
order: 75,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
queryOptions: {
@@ -685,6 +751,7 @@
order: 76,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
queryOptions: {
@@ -702,6 +769,7 @@
order: 77,
cardFormat: 'Poster',
ttl: CACHE_CONFIG.DEFAULT_TTL,
userConfigurable: true,
queries: [
{
queryOptions: {
@@ -723,6 +791,7 @@
id: 'thematic-story-origins',
name: 'Story Origins',
enabled: false,
userConfigurable: true,
order: 81,
cardFormat: 'Poster',
sortBy: 'Random',
@@ -741,6 +810,7 @@
id: 'thematic-core-concepts',
name: 'Core Concepts',
enabled: false,
userConfigurable: true,
order: 82,
cardFormat: 'Poster',
sortBy: 'Random',
@@ -772,6 +842,7 @@
id: 'thematic-crime-stories',
name: 'Crime Stories',
enabled: false,
userConfigurable: true,
order: 83,
cardFormat: 'Poster',
sortBy: 'Random',
@@ -795,6 +866,7 @@
id: 'thematic-character-perspective',
name: 'Character Perspective',
enabled: false,
userConfigurable: true,
order: 84,
cardFormat: 'Poster',
sortBy: 'Random',
@@ -815,6 +887,7 @@
id: 'thematic-settings',
name: 'Settings',
enabled: false,
userConfigurable: true,
order: 85,
cardFormat: 'Poster',
sortBy: 'Random',
@@ -836,6 +909,7 @@
id: 'thematic-tone-style',
name: 'Tone & Style',
enabled: false,
userConfigurable: true,
order: 86,
cardFormat: 'Poster',
sortBy: 'Random',
@@ -854,6 +928,7 @@
id: 'thematic-themes-topics',
name: 'Themes & Topics',
enabled: false,
userConfigurable: true,
order: 87,
cardFormat: 'Poster',
sortBy: 'Random',
@@ -886,6 +961,7 @@
id: 'seasonal.halloween.halloween-tag',
name: 'Halloween Movies',
enabled: true,
userConfigurable: true,
startDate: '10-01',
endDate: '10-31',
order: 50,
@@ -905,6 +981,7 @@
id: 'seasonal.halloween.halloween-horror',
name: 'Horror Movies',
enabled: true,
userConfigurable: true,
startDate: '10-01',
endDate: '10-31',
order: 51,
@@ -924,6 +1001,7 @@
id: 'seasonal.halloween.halloween-thriller',
name: 'Thriller Movies',
enabled: true,
userConfigurable: true,
startDate: '10-01',
endDate: '10-31',
order: 52,
@@ -950,6 +1028,7 @@
id: 'seasonal.christmas.christmas-movies',
name: 'Christmas Movies',
enabled: true,
userConfigurable: true,
startDate: '12-01',
endDate: '12-31',
order: 50,
@@ -970,6 +1049,7 @@
id: 'seasonal.christmas.christmas-episodes',
name: 'Christmas Episodes',
enabled: true,
userConfigurable: true,
startDate: '12-01',
endDate: '12-31',
order: 51,
@@ -996,6 +1076,7 @@
id: 'seasonal.new-years.new-years-movies',
name: 'New Year\'s Movies',
enabled: true,
userConfigurable: true,
startDate: '01-01',
endDate: '01-02',
order: 50,
@@ -1015,6 +1096,7 @@
id: 'seasonal.new-years.new-years-episodes',
name: 'New Year\'s Episodes',
enabled: true,
userConfigurable: true,
startDate: '01-01',
endDate: '01-02',
order: 51,
@@ -1041,6 +1123,7 @@
id: 'seasonal.valentines.valentines-movies',
name: 'Valentine\'s Movies',
enabled: true,
userConfigurable: true,
startDate: '02-07',
endDate: '02-14',
order: 50,
@@ -1060,6 +1143,7 @@
id: 'seasonal.valentines.valentines-episodes',
name: 'Valentine\'s Episodes',
enabled: true,
userConfigurable: true,
startDate: '02-07',
endDate: '02-14',
order: 51,
@@ -1086,6 +1170,7 @@
id: 'seasonal.thanksgiving.thanksgiving-movies',
name: 'Thanksgiving Movies',
enabled: true,
userConfigurable: true,
startDate: '11-20',
endDate: '11-30',
order: 50,
@@ -1105,6 +1190,7 @@
id: 'seasonal.thanksgiving.thanksgiving-episodes',
name: 'Thanksgiving Episodes',
enabled: true,
userConfigurable: true,
startDate: '11-20',
endDate: '11-30',
order: 51,
@@ -1135,10 +1221,12 @@
sections: [
{
id: 'genreMovies',
type: 'Genre',
type: 'discovery',
discoveryType: 'Genre',
source: 'Dynamic', // Will be filled with a random Genre ID/Name
name: '{Genre} Movies',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1147,10 +1235,12 @@
},
{
id: 'spotlightGenre',
type: 'Genre',
type: 'discovery',
discoveryType: 'Genre',
source: 'Dynamic',
name: 'Top Rated {Genre}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'CommunityRating',
sortOrderDirection: 'Descending',
@@ -1167,11 +1257,13 @@
sections: [
{
id: 'directedByTopDirector',
type: 'Person',
type: 'discovery',
discoveryType: 'Person',
source: 'Dynamic', // Random Director ID
personType: 'Director', // Extra metadata for dynamic logic
name: 'Directed by {Person}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1180,11 +1272,13 @@
},
{
id: 'writtenByTopWriter',
type: 'Person',
type: 'discovery',
discoveryType: 'Person',
source: 'Dynamic', // Random Writer ID
personType: 'Writer',
name: 'Written by {Person}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1193,11 +1287,13 @@
},
{
id: 'starringTopActor',
type: 'Person',
type: 'discovery',
discoveryType: 'Person',
source: 'Dynamic', // Random Actor ID
personType: 'Actor',
name: 'Starring {Person}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1213,12 +1309,15 @@
sections: [
{
id: 'starringActorRecentlyWatched',
type: 'Person',
type: 'discovery',
discoveryType: 'Person',
source: 'Dynamic', // Actor from Recently Watched
personType: 'Actor',
sourceType: 'watched-recent', // Hint for source generator
name: 'Starring {Person} since you watched {Title}',
name: 'Starring {Person}',
caption: 'because you recently watched {Title}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1227,12 +1326,15 @@
},
{
id: 'directedByDirectorRecentlyWatched',
type: 'Person',
type: 'discovery',
discoveryType: 'Person',
source: 'Dynamic', // Director from Recently Watched
personType: 'Director',
sourceType: 'watched-recent',
name: 'Directed by {Person} since you watched {Title}',
name: 'Directed by {Person}',
caption: 'because you recently watched {Title}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1241,12 +1343,15 @@
},
{
id: 'writtenByWriterRecentlyWatched',
type: 'Person',
type: 'discovery',
discoveryType: 'Person',
source: 'Dynamic',
personType: 'Writer',
sourceType: 'watched-recent',
name: 'Written by {Person} since you watched {Title}',
name: 'Written by {Person}',
caption: 'because you recently watched {Title}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1262,11 +1367,13 @@
sections: [
{
id: 'becauseYouWatched',
type: 'Similar',
type: 'discovery',
discoveryType: 'Similar',
source: 'Dynamic', // Random Watched Item ID
sourceType: 'watched',
name: 'Because you watched {Title}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random', // Typically 'Similarity' but usually returned sorted by API
includeItemTypes: ['Movie'],
@@ -1274,11 +1381,13 @@
},
{
id: 'becauseYouRecentlyWatched',
type: 'Similar',
type: 'discovery',
discoveryType: 'Similar',
source: 'Dynamic', // Random Recently Watched ID
sourceType: 'watched-recent',
name: 'Because you recently watched {Title}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1294,11 +1403,13 @@
sections: [
{
id: 'becauseYouLiked',
type: 'Similar',
type: 'discovery',
discoveryType: 'Similar',
source: 'Dynamic', // Random Favorite Item ID
sourceType: 'liked',
name: 'Because you liked {Title}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1314,10 +1425,12 @@
sections: [
{
id: 'studioShows',
type: 'Studio',
type: 'discovery',
discoveryType: 'Studio',
source: 'Dynamic', // Random Studio ID
name: 'Shows from {Studio}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random',
cardFormat: 'Poster',
@@ -1326,10 +1439,12 @@
},
{
id: 'spotlightNetwork',
type: 'Studio',
type: 'discovery',
discoveryType: 'Studio',
source: 'Dynamic',
name: 'Top Rated from {Studio}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'CommunityRating',
sortOrderDirection: 'Descending',
@@ -1346,10 +1461,12 @@
sections: [
{
id: 'collections',
type: 'Collection', // Standard type, but source is dynamic here
type: 'discovery',
discoveryType: 'Collection',
source: 'Dynamic',
name: '{Collection}',
enabled: true,
userConfigurable: true,
itemLimit: 20,
sortOrder: 'Random', // Order within the collection
includeItemTypes: ['Movie', 'Series'],
@@ -1400,6 +1517,12 @@
minimumSeriesForPopularTVNetworks: 10,
fadeInSections: false,
ensureThumbsForPopularTVNetworks: false,
SHOW_STALE_DATA_BEFORE_REFRESH: false,
}
const USER_HOME_SCREEN_SETTINGS = {
inlineConfigure: true,
pinning: true
}
const SPOTLIGHT_SETTINGS = {
@@ -1421,11 +1544,28 @@
REMOVE_CONFLICTING_SECTIONS: true,
MERGE_NEXT_UP: false,
HOME_SETTINGS: HOME_SETTINGS,
USER_HOME_SCREEN_SETTINGS: USER_HOME_SCREEN_SETTINGS,
DISCOVERY_SETTINGS: DISCOVERY_SETTINGS,
SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS,
SPOTLIGHT_SETTINGS: SPOTLIGHT_SETTINGS
};
/*
window.KefinHomeUserConfig = {
CACHE: CACHE_CONFIG,
HOME_SECTION_GROUPS: KEFINTWEAKS_HOME_SECTION_GROUPS, // modified by user configuration
SEASONAL_SECTION_GROUPS: KEFINTWEAKS_SEASONAL_SECTION_GROUPS, // modified by user configuration`
DISCOVERY_SECTION_GROUPS: KEFINTWEAKS_DISCOVERY_SECTION_GROUPS, // modified by user configuration
CUSTOM_SECTION_GROUPS: [], // Empty by default - user-created/imported sections // modified by user configuration
REMOVE_CONFLICTING_SECTIONS: true,
MERGE_NEXT_UP: false,
HOME_SETTINGS: HOME_SETTINGS,
DISCOVERY_SETTINGS: DISCOVERY_SETTINGS,
SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS,
SPOTLIGHT_SETTINGS: SPOTLIGHT_SETTINGS
}
*/
console.log('[KefinTweaks HomeConfig2] Configuration loaded');
})();
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+42 -41
View File
@@ -140,10 +140,7 @@
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
#moviesTab .listPaging>div,
#seriesTab .listPaging>div {
display: none !important;
}
/* Infinite scroll loading indicator */
.infinite-scroll-loading {
@@ -189,8 +186,7 @@
function waitForApiClient() {
return new Promise((resolve) => {
if (typeof ApiClient !== 'undefined' && ApiClient._serverInfo && ApiClient._serverInfo.UserId) {
userId = ApiClient._serverInfo.UserId;
LOG('ApiClient ready, userId:', userId);
LOG('ApiClient ready, userId:', ApiClient._serverInfo.UserId);
resolve();
} else {
LOG('Waiting for ApiClient...');
@@ -345,20 +341,8 @@
}
// Update pagination display to show current range
function updatePaginationDisplay() {
const page = getCurrentPage();
if (!page) return;
let activeTab = null;
if (page === 'tv.html') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html') {
activeTab = document.querySelector("#moviesTab.is-active");
}
if (!activeTab) return;
const listPagings = activeTab.querySelectorAll(".listPaging");
function updatePaginationDisplay() {
const listPagings = document.querySelectorAll(".page:not(.hide) .pageTabContent.is-active .listPaging");
if (!listPagings.length) return;
listPagings.forEach(listPaging => {
@@ -387,9 +371,9 @@
let activeTab = null;
if (page === 'tv.html' || page === 'tv') {
activeTab = libraryPage.querySelector("#seriesTab.is-active");
activeTab = libraryPage.querySelector("#seriesTab.is-active") || document.querySelector("#tvshowsPage:not(.hide)");
} else if (page === 'movies.html' || page === 'movies') {
activeTab = libraryPage.querySelector("#moviesTab.is-active");
activeTab = libraryPage.querySelector("#moviesTab.is-active") || document.querySelector("#moviesPage:not(.hide)");
}
if (!activeTab) {
@@ -404,6 +388,31 @@
return container;
}
/**
* Infer card format from existing cards in the items container.
* @param {Element} container
* @returns {string|null} 'backdrop'|'square'|'banner'|'portrait'|null
*/
function inferCardFormatFromContainer(container) {
if (!container) return null;
const existing = container.querySelector('.card');
if (!existing) return null;
if (existing.classList.contains('backdropCard') || existing.classList.contains('overflowBackdropCard')) {
return 'backdrop';
}
if (existing.classList.contains('squareCard') || existing.classList.contains('overflowSquareCard')) {
return 'square';
}
if (existing.classList.contains('bannerCard') || existing.classList.contains('overflowBannerCard')) {
return 'banner';
}
if (existing.classList.contains('portraitCard') || existing.classList.contains('overflowPortraitCard')) {
return 'portrait';
}
return null;
}
async function loadMore() {
LOG('LoadMore called:', { loading, hasMore, isRequestInProgress, isScrollTriggered });
@@ -521,7 +530,7 @@
LOG('Added alpha picker filter:', alphaPicker);
}
const url = ApiClient.getUrl("Users/" + userId + "/Items", apiParams);
const url = ApiClient.getUrl("Users/" + ApiClient.getCurrentUserId() + "/Items", apiParams);
LOG('Loading more items:', { page, tab, startIndex, limit, url });
@@ -553,8 +562,10 @@
});
// Build each card individually and append to container
const cardFormat = inferCardFormatFromContainer(container);
LOG('Inferred card format from container:', cardFormat);
result.Items.forEach((item, index) => {
const card = window.cardBuilder.buildCard(item);
const card = window.cardBuilder.buildCard(item, false, cardFormat);
card.setAttribute('data-index', startIndex + index);
container.appendChild(card);
});
@@ -734,6 +745,8 @@
// Check if we should initialize infinite scroll
function shouldInitialize() {
if (hasInitialized) return false;
const page = getCurrentPage();
const container = getContainer();
@@ -746,25 +759,16 @@
return false;
}
// Double-check that we have the correct active tab
let activeTab = null;
if (page === 'tv.html' || page === 'tv') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html' || page === 'movies') {
activeTab = document.querySelector("#moviesTab.is-active");
if (page === 'tv.html' || page === 'tv' || page === 'movies.html' || page === 'movies') {
return true;
}
if (!activeTab) {
LOG('No active tab found for page, skipping initialization:', page);
return false;
}
return true;
return false;
}
// Initialize infinite scroll
async function initialize() {
if (!shouldInitialize() || hasInitialized || isInitializing) {
if (hasInitialized || isInitializing) {
return;
}
@@ -788,9 +792,6 @@
reset();
//loadMore(); // load first page
setupScrollWatcher();
// Update pagination display with initial state
updatePaginationDisplay();
} finally {
isInitializing = false;
}
+124 -188
View File
@@ -1,7 +1,4 @@
// KefinTweaks Item Details Collections
// Adds related collections to item details pages
// Requires: indexedDBCache.js, utils.js, cardBuilder.js modules to be loaded before this script
(function() {
'use strict';
@@ -11,20 +8,90 @@
const COLLECTIONS_CHILD_COUNT_KEY = `kefinTweaks_${ApiClient.getCurrentUserId()}_allCollectionsChildCount`;
const CACHE_NAME = 'collections';
const COLLECTION_FETCH_CHUNK_SIZE = 8;
const state = {
allCollections: null,
populateInFlight: null
};
/**
* Yields execution to allow the UI to repaint and respond to user inputs.
* Prefers the modern scheduler.yield API when available.
*/
function yieldToMain() {
if ('scheduler' in window && typeof window.scheduler.yield === 'function') {
return window.scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
// Populate collections cache
async function populateCollectionsCache(invalidate = false) {
// Check if user is logged in and poll for 5 seconds if not
if (ApiClient._loggedIn === false) {
return;
function buildCacheEntries(results) {
const cacheData = [];
for (const { collection: coll, items } of results) {
const itemIds = (items?.Items || []).map((item) => item.Id);
if (itemIds.length === 0) continue;
cacheData.push({
CollectionItem: {
Id: coll.Id,
Type: coll.Type,
Name: coll.Name,
ParentIndexNumber: coll.ParentIndexNumber,
IndexNumber: coll.IndexNumber,
ImageTags: coll.ImageTags,
BackdropImageTags: coll.BackdropImageTags,
ProductionYear: coll.ProductionYear,
PremiereDate: coll.PremiereDate
},
ItemIds: itemIds
});
}
return cacheData;
}
async function fetchCollectionChildren(coll) {
try {
const items = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
ParentId: coll.Id
});
return { collection: coll, items };
} catch (err) {
WARN(`Failed to fetch items for collection ${coll.Id}`, err);
return { collection: coll, items: { Items: [] } };
}
}
async function fetchCollectionChildrenBatched(collections) {
const results = [];
const items = collections || [];
for (let i = 0; i < items.length; i += COLLECTION_FETCH_CHUNK_SIZE) {
const chunk = items.slice(i, i + COLLECTION_FETCH_CHUNK_SIZE);
const chunkResults = await Promise.all(chunk.map(fetchCollectionChildren));
results.push(...chunkResults);
// Yield back to the browser to paint frames & maintain high FPS
if (i + COLLECTION_FETCH_CHUNK_SIZE < items.length) {
await yieldToMain();
}
}
return results;
}
async function writeCollectionsCache(cache, collections) {
const startTime = performance.now();
const collectionResults = await fetchCollectionChildrenBatched(collections);
const cacheData = buildCacheEntries(collectionResults);
await cache.set(CACHE_NAME, cacheData);
const duration = performance.now() - startTime;
LOG(`Collections cache populated: ${cacheData.length} collections in ${duration.toFixed(2)}ms`);
return cacheData;
}
async function populateCollectionsCache(invalidate = false) {
if (ApiClient._loggedIn === false) return;
const cache = window.IndexedDBCache;
if (!cache) {
WARN('IndexedDBCache instance not available');
return;
@@ -38,9 +105,9 @@
});
}
const allCollectionsChildCount = state.allCollections.Items.map(collection => collection.ChildCount).reduce((a, b) => a + b, 0);
const allCollectionsChildCount = (state.allCollections.Items || [])
.reduce((sum, coll) => sum + (coll.ChildCount || 0), 0);
// Get saved Collections Child Count
const cachedAllCollectionsChildCount = Number(localStorage.getItem(COLLECTIONS_CHILD_COUNT_KEY));
if (allCollectionsChildCount !== cachedAllCollectionsChildCount) {
@@ -49,173 +116,73 @@
LOG('Collections Child Count has changed, invalidating cache');
}
// Check if cache is valid
if (!invalidate && await cache.isCacheValid(CACHE_NAME)) {
LOG('Collections cache is still valid');
return;
}
LOG('Populating collections cache...');
try {
const currentCacheData = await cache.get(CACHE_NAME);
const hasExistingCache = Array.isArray(currentCacheData) && currentCacheData.length > 0;
// Fetch all collection items in parallel
const collectionPromises = state.allCollections.Items.map(coll =>
ApiClient.getItems(ApiClient.getCurrentUserId(), {
ParentId: coll.Id,
}).then(items => ({
collection: coll,
items: items
}))
);
// Check if cache already contains data
// If so, process the cache refresh in parallel and return the currently cached data instead of waiting
// If no cache exists, wait for all fetches to complete
const currentCacheData = await cache.get(CACHE_NAME);
if (currentCacheData && currentCacheData.length > 0) {
// Refreshing the cache Collections cache in the background
LOG('Refreshing collections cache in the background');
Promise.all(collectionPromises).then(async (results) => {
LOG('Collections cache refreshed');
const cacheData = [];
for (const { collection: coll, items } of results) {
const collectionItemData = {
Id: coll.Id,
Type: coll.Type,
Name: coll.Name,
ParentIndexNumber: coll.ParentIndexNumber,
IndexNumber: coll.IndexNumber,
ImageTags: coll.ImageTags,
BackdropImageTags: coll.BackdropImageTags,
ProductionYear: coll.ProductionYear,
PremiereDate: coll.PremiereDate
};
const itemIds = items.Items.map(item => item.Id);
if (itemIds.length > 0) {
cacheData.push({
CollectionItem: collectionItemData,
ItemIds: itemIds
});
}
}
await cache.set(CACHE_NAME, cacheData);
LOG(`Collections cache populated: ${cacheData.length} collections`);
});
return;
}
// Setup performance timer
const startTime = performance.now();
// Wait for all fetches to complete
const collectionResults = await Promise.all(collectionPromises);
const endTime = performance.now();
const duration = endTime - startTime;
LOG(`Collections cache populated in ${duration.toFixed(2)}ms`);
const cacheData = [];
// Process all results
for (const { collection: coll, items } of collectionResults) {
// Store collection item data needed for renderCards
const collectionItemData = {
Id: coll.Id,
Type: coll.Type,
Name: coll.Name,
ParentIndexNumber: coll.ParentIndexNumber,
IndexNumber: coll.IndexNumber,
ImageTags: coll.ImageTags,
BackdropImageTags: coll.BackdropImageTags,
ProductionYear: coll.ProductionYear,
PremiereDate: coll.PremiereDate
};
// Store item IDs in this collection for lookup
const itemIds = items.Items.map(item => item.Id);
if (itemIds.length > 0) {
cacheData.push({
CollectionItem: collectionItemData,
ItemIds: itemIds
});
}
}
await cache.set(CACHE_NAME, cacheData);
LOG(`Collections cache populated: ${cacheData.length} collections`);
} catch (error) {
ERR('Error populating collections cache:', error);
if (state.populateInFlight) {
if (hasExistingCache) return;
return state.populateInFlight;
}
LOG(hasExistingCache
? 'Refreshing collections cache in background'
: 'Populating collections cache (First run)...');
const work = writeCollectionsCache(cache, state.allCollections.Items)
.catch((error) => {
ERR('Error populating collections cache:', error);
})
.finally(() => {
state.populateInFlight = null;
});
state.populateInFlight = work;
if (hasExistingCache) return;
await work; // Wait on initial load so caller can render right after
}
// Get parent collections for an item ID (returns collection items that can be passed to renderCards)
async function getParentCollections(itemId) {
const cache = window.IndexedDBCache;
if (!cache) {
WARN('IndexedDBCache instance not available');
return [];
}
if (!cache) return [];
const cacheData = await cache.get(CACHE_NAME);
if (!cacheData || !Array.isArray(cacheData)) return [];
if (!cacheData || !Array.isArray(cacheData)) {
return [];
}
// Find collections that contain this item and return the collection item objects
return cacheData
.filter(entry => entry.ItemIds && entry.ItemIds.includes(itemId))
.map(entry => entry.CollectionItem);
}
// Render collections section on details page
async function renderCollectionsSection(item) {
if (!item || !item.Id) {
return;
}
if (!item || !item.Id) return;
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) {
return;
}
if (!activePage) return;
if (activePage.dataset.collectionsChecked === 'true') {
return;
}
// Prevent redundant runs on the same page element
if (activePage.dataset.collectionsChecked === 'true') return;
activePage.dataset.collectionsChecked = 'true';
// Check if section already rendered
const existingSection = activePage.querySelector('.collections-section');
if (existingSection) {
return;
}
// Get parent collections (returns collection item objects)
const parentCollectionItems = await getParentCollections(item.Id);
if (!parentCollectionItems || parentCollectionItems.length === 0) {
return;
}
if (!parentCollectionItems || parentCollectionItems.length === 0) return;
// Check for cardBuilder
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
WARN('cardBuilder.renderCards not available');
return;
}
// Find detailPageContent
const detailPageContent = activePage.querySelector('.detailPageContent');
if (!detailPageContent) {
WARN('detailPageContent not found');
return;
}
if (!detailPageContent) return;
// Ensure we don't render duplicate sections
if (activePage.querySelector('.collections-section')) return;
try {
// Render collections section using renderCards with cached item data
const collectionsSection = window.cardBuilder.renderCards(
parentCollectionItems,
'Included In',
@@ -224,21 +191,15 @@
null
);
// Mark as collections section
collectionsSection.classList.add('collections-section');
collectionsSection.setAttribute('data-collections-section', 'true');
const scrollerContainer = collectionsSection.querySelector('.emby-scroller');
if (scrollerContainer) {
scrollerContainer.classList.add('no-padding');
}
if (scrollerContainer) scrollerContainer.classList.add('no-padding');
const sectionTitleContainer = collectionsSection.querySelector('.sectionTitleContainer');
if (sectionTitleContainer) {
sectionTitleContainer.classList.add('no-padding');
}
if (sectionTitleContainer) sectionTitleContainer.classList.add('no-padding');
// Add to detailPageContent
const similarSection = detailPageContent.querySelector('#similarCollapsible');
if (similarSection) {
similarSection.before(collectionsSection);
@@ -246,13 +207,13 @@
detailPageContent.appendChild(collectionsSection);
}
activePage.dataset.collectionsChecked = 'true';
LOG(`Rendered ${parentCollectionItems.length} collection(s) for item ${item.Id}`);
} catch (error) {
ERR('Error rendering collections section:', error);
}
}
// Initialize onViewPage hook
function initializeCollectionsHook() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second');
@@ -264,32 +225,23 @@
window.KefinTweaksUtils.onViewPage(
async (view, element, hash, itemPromise) => {
if (ApiClient._loggedIn === false) {
return;
}
if (ApiClient._loggedIn === false) return;
// Only handle details pages
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) return;
// Await the item promise to get the actual item data
const item = await itemPromise;
const supportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'Book', 'AudioBook', 'MusicVideo'];
const supportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'Book', 'AudioBook', 'MusicVideo' ];
if (!supportedTypes.includes(item.Type)) {
LOG(`Item type ${item.Type} not supported for collections, skipping...`);
return;
}
if (!supportedTypes.includes(item?.Type)) return;
// 1. Populate/Update cache (waits on first load, runs in background on updates)
await populateCollectionsCache();
// Small delay to ensure details DOM is ready
setTimeout(async () => {
if (item && item.Id) {
await renderCollectionsSection(item);
}
}, 100);
// 2. Render cards using populated IndexedDB cache
if (item && item.Id) {
await renderCollectionsSection(item);
}
},
{
pages: ['details'],
@@ -300,33 +252,17 @@
LOG('Collections hook initialized');
}
const maxInitAttempts = 10;
let initAttempts = 0;
// Initialize cache population on script load
function initialize() {
if (!window.ApiClient) {
WARN('Dependencies not available, retrying in 1 second');
if (initAttempts < maxInitAttempts) {
setTimeout(initialize, 1000);
initAttempts++;
} else {
ERR('Dependencies not available after 10 seconds, giving up');
}
setTimeout(initialize, 1000);
return;
}
// Initialize collections hook
initializeCollectionsHook();
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
LOG('Script loaded successfully');
})();
})();
+15 -5
View File
@@ -21,6 +21,7 @@ window.ModalSystem = (function() {
* @param {boolean} options.closeOnBackdrop - Whether to close when clicking backdrop (default: true)
* @param {boolean} options.closeOnEscape - Whether to close on Escape key (default: true)
* @param {boolean} options.showCloseButton - Whether to show close button in header (default: true if title exists)
* @param {boolean} [options.fixedSize] - Use dialog-fixedSize. If undefined, enable when window width < 900.
* @returns {Object} Modal instance
*/
function createModal(options = {}) {
@@ -33,7 +34,8 @@ window.ModalSystem = (function() {
onOpen,
closeOnBackdrop = true,
closeOnEscape = true,
showCloseButton = true
showCloseButton = true,
fixedSize
} = options;
if (!id) {
@@ -43,6 +45,9 @@ window.ModalSystem = (function() {
// Remove existing modal if it exists
removeModal(id);
const useFixedSize = fixedSize === true
|| (fixedSize !== false && typeof window !== 'undefined' && window.innerWidth < 900);
// Create modal elements
const backdrop = document.createElement('div');
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
@@ -53,7 +58,7 @@ window.ModalSystem = (function() {
dialogContainer.setAttribute('data-modal-id', id);
const dialog = document.createElement('div');
dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened';
dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened`;
dialog.setAttribute('data-history', 'true');
dialog.setAttribute('data-autofocus', 'true');
dialog.setAttribute('data-removeonclose', 'true');
@@ -61,7 +66,9 @@ window.ModalSystem = (function() {
dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.maxHeight = '90vh';
if (!useFixedSize) {
dialog.style.maxHeight = '90vh';
}
// Create header if title is provided
let dialogHeader = null;
@@ -103,7 +110,7 @@ window.ModalSystem = (function() {
// Create scrollable content area
const dialogContent = document.createElement('div');
dialogContent.style.padding = title || footer ? '1.25em 1.5em 0' : '1.25em 1.5em 1.5em';
dialogContent.style.padding = '1.25em 1.5em';
dialogContent.style.overflowY = 'auto';
dialogContent.style.flex = '1';
dialogContent.style.minHeight = '0';
@@ -227,7 +234,10 @@ window.ModalSystem = (function() {
dialogContainer.appendChild(dialog);
// Add to DOM
document.body.appendChild(backdrop);
// Check if the modal backdrop is already in the DOM
if (!document.body.querySelector('.dialogBackdrop')) {
document.body.appendChild(backdrop);
}
document.body.appendChild(dialogContainer);
// Create modal instance
+13 -1
View File
@@ -16,6 +16,12 @@
let imdbTop250Movies = null;
let isInitializing = false;
/**
* Fetches and caches all movies across all libraries.
* You should not use this for anything related to UserData
* The cache for this is long, and this is intended to simply be an "archive" of all movies across all libraries.
* @returns {Promise<Array>} - Array of movie objects
*/
async function fetchAndCacheMovies() {
// IndexedDBCache
const cacheName = 'movies_library';
@@ -29,8 +35,14 @@
}
try {
LOG('Fetching Movies...');
LOG('Fetching Movies...');
// We need to refactor this to pull the data in chunks of 100 items at a time in order to backfill the cache
// We will populate 100 items at a time into the library cache.
// Each group will contain the index of the last item in the group so we can resume from there
// When resuming, we should first verify if the previous item based on the same sorting and startindex/limit is still the same
// We just check the last item in the indexed db cache and make the same query starting at that index
// If it's the same then we can resume from there, if not then we need to start from the beginning
const allMoviesQuery = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds`;
const movieResult = await apiHelper.getQuery(allMoviesQuery);
File diff suppressed because it is too large. Load diff
+129 -104
View File
@@ -12,9 +12,11 @@
// State
let moviesTopPeople = null;
let isInitializing = false;
let isComplete = false;
let isFetchingTopPeople = false;
/** @type {Promise<*>|null} Shared so concurrent callers await the same paginated fetch */
let fetchPromise = null;
/** @type {Promise<void>|null} Shared so concurrent callers await the same build */
let initPromise = null;
/**
* Extracts necessary item data for caching (similar to watchlist format)
@@ -149,121 +151,144 @@
* Fetches and processes top people data from all movies using pagination
*/
async function fetchTopPeople() {
if (isFetchingTopPeople) return;
isFetchingTopPeople = true;
try {
LOG('Starting paginated fetch of movies for people data processing...');
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
const userId = apiClient.getCurrentUserId();
const indexedDBCache = window.IndexedDBCache;
let startIndex = 0;
const limit = 500;
let hasMoreData = true;
let peopleMap = null;
// Load existing raw people data if available (for resuming)
const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId);
if (existingRawData && !existingRawData.isComplete) {
const moviesProcessed = existingRawData.moviesProcessedCount;
if (moviesProcessed > 0) {
startIndex = moviesProcessed;
peopleMap = new Map(existingRawData.peopleData || []);
LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`);
}
}
while (hasMoreData) {
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`;
LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`);
const data = await window.apiHelper.getQuery(url);
const movies = data.Items || [];
if (movies.length === 0) {
hasMoreData = false;
} else {
peopleMap = processPeopleDataRaw(movies, peopleMap);
startIndex += movies.length;
// Update global state with partial results for immediate use
const filteredData = filterPeopleData(peopleMap);
if (filteredData) {
moviesTopPeople = filteredData;
if (fetchPromise) {
return fetchPromise;
}
fetchPromise = (async () => {
try {
LOG('Starting paginated fetch of movies for people data processing...');
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const userId = apiClient.getCurrentUserId();
const indexedDBCache = window.IndexedDBCache;
let startIndex = 0;
const limit = 500;
let hasMoreData = true;
let peopleMap = null;
// Load existing raw people data if available (for resuming)
const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId);
if (existingRawData && !existingRawData.isComplete) {
const moviesProcessed = existingRawData.moviesProcessedCount;
if (moviesProcessed > 0) {
startIndex = moviesProcessed;
peopleMap = new Map(existingRawData.peopleData || []);
LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`);
}
// Checkpoint raw data
const rawDataToCache = {
isComplete: movies.length < limit,
moviesProcessedCount: startIndex,
peopleData: Array.from(peopleMap.entries())
};
await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000);
if (movies.length < limit) {
}
while (hasMoreData) {
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`;
LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`);
const data = await window.apiHelper.getQuery(url);
const movies = (data && data.Items) || [];
if (movies.length === 0) {
hasMoreData = false;
isComplete = true;
} else {
peopleMap = processPeopleDataRaw(movies, peopleMap);
if (!peopleMap) {
throw new Error('processPeopleDataRaw failed');
}
startIndex += movies.length;
// Update global state with partial results for immediate use
const filteredData = filterPeopleData(peopleMap);
if (filteredData) {
moviesTopPeople = filteredData;
}
// Checkpoint raw data (non-fatal if IndexedDB rejects large payloads)
const rawDataToCache = {
isComplete: movies.length < limit,
moviesProcessedCount: startIndex,
peopleData: Array.from(peopleMap.entries())
};
try {
await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000);
} catch (checkpointErr) {
WARN('Failed to checkpoint raw people data (continuing):', checkpointErr);
}
if (movies.length < limit) {
hasMoreData = false;
isComplete = true;
}
}
}
}
const finalPeopleData = filterPeopleData(peopleMap);
if (finalPeopleData) {
const finalPeopleData = filterPeopleData(peopleMap) || {
actors: [],
directors: [],
writers: []
};
finalPeopleData.isComplete = true;
moviesTopPeople = finalPeopleData;
await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
await indexedDBCache.clear('movies_top_people_raw', userId);
LOG('People cache complete and saved');
isComplete = true;
try {
await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
await indexedDBCache.clear('movies_top_people_raw', userId);
} catch (saveErr) {
WARN('Failed to persist complete people cache (in-memory still available):', saveErr);
}
LOG(peopleMap ? 'People cache complete and saved' : 'People cache complete (empty library)');
return finalPeopleData;
} catch (err) {
ERR('Failed to fetch top people data:', err);
return null;
} finally {
fetchPromise = null;
}
return finalPeopleData;
} catch (err) {
ERR('Failed to fetch top people data:', err);
return null;
} finally {
isFetchingTopPeople = false;
}
})();
return fetchPromise;
}
/**
* Initializes the people cache in the background
* Initializes the people cache (awaits build; concurrent callers share one promise)
*/
async function initialize() {
if (isInitializing) return;
// Initialize background fetch
isInitializing = true;
try {
if (moviesTopPeople !== null && isComplete) {
return;
}
const indexedDBCache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
// Check for valid complete filtered data
const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId);
if (validComplete) {
const cachedData = await indexedDBCache.get('movies_top_people', userId);
if (cachedData && cachedData.isComplete) {
moviesTopPeople = cachedData;
isComplete = true;
LOG('Loaded complete top people data from IndexedDB');
if (moviesTopPeople !== null && isComplete) {
return;
}
if (initPromise) {
return initPromise;
}
initPromise = (async () => {
try {
if (moviesTopPeople !== null && isComplete) {
return;
}
const indexedDBCache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
// Check for valid complete filtered data
const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId);
if (validComplete) {
const cachedData = await indexedDBCache.get('movies_top_people', userId);
if (cachedData && cachedData.isComplete) {
moviesTopPeople = cachedData;
isComplete = true;
LOG('Loaded complete top people data from IndexedDB');
return;
}
}
await fetchTopPeople();
} finally {
initPromise = null;
}
fetchTopPeople();
} finally {
isInitializing = false;
}
})();
return initPromise;
}
async function getTopPeople() {
@@ -283,21 +308,21 @@
if (!moviesTopPeople) {
await initialize();
}
return moviesTopPeople.actors;
return moviesTopPeople?.actors ?? [];
}
async function getTopDirectors() {
if (!moviesTopPeople) {
await initialize();
}
return moviesTopPeople.directors;
return moviesTopPeople?.directors ?? [];
}
async function getTopWriters() {
if (!moviesTopPeople) {
await initialize();
}
return moviesTopPeople.writers;
return moviesTopPeople?.writers ?? [];
}
function isCacheComplete() {
+2 -2
View File
@@ -162,7 +162,7 @@
const response = await fetch(userDataUrl, {
method: 'GET',
headers: {
"Authorization": `MediaBrowser Token="${token}"`,
"Authorization": window.apiHelper.getAuthHeader(),
"Content-Type": "application/json"
}
});
@@ -196,7 +196,7 @@
const response = await fetch(userDataUrl, {
method: 'POST',
headers: {
"Authorization": `MediaBrowser Token="${token}"`,
"Authorization": window.apiHelper.getAuthHeader(),
"Content-Type": "application/json"
},
body: JSON.stringify(updatedUserData)
+49 -31
View File
@@ -1,73 +1,91 @@
.searchFields {
position: relative;
position: relative;
}
.padded-left.padded-right.searchFields {
display: flex;
flex-direction: column-reverse;
align-items: center;
display: flex;
flex-direction: column-reverse;
align-items: center;
}
.layout-desktop .searchFieldsInner {
width: 100%;
padding-left: calc(30px + 1em);
}
div#smart-search-wrapper:not([style*="display: none"]) ~ #jellyseerr-search-icon {
display: none;
transform: unset;
position: relative;
top: 0.4em;
right: unset;
}
.inputContainer.flex-grow:has(.smart-search-wrapper) {
display: grid;
grid-template-columns: 1fr 30px;
gap: 1em;
}
.smart-search-mode .searchFieldsInner {
position: relative;
position: relative;
}
div#searchPage {
padding-top: 3rem !important;
padding-top: 3rem !important;
}
@media (min-width: 600px) {
div#searchPage {
padding-top: 5rem !important;
}
div#searchPage {
padding-top: 5rem !important;
}
}
div#searchPage {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
div#searchPage>* {
order: 1;
div#searchPage > * {
order: 1;
}
div#searchPage>.jellyseerr-section {
order: 2;
padding-left: max(env(safe-area-inset-left), 3.3%);
div#searchPage > .jellyseerr-section {
order: 2;
padding-left: max(env(safe-area-inset-left), 3.3%);
}
div#smart-search-results {
padding-bottom: 0 !important;
padding-right: 0 !important;
padding-bottom: 0 !important;
padding-right: 0 !important;
}
label[for="searchTextInput"] {
display: none;
display: none;
}
.searchFieldsInner>.searchfields-icon {
display: none;
.searchfields-icon {
-webkit-align-self: flex-end;
align-self: flex-end;
font-size: 2em;
margin-bottom: 0.1em;
margin-right: 0.25em;
}
.searchFieldsInner > .searchfields-icon {
display: none;
}
.input-wrapper {
position: relative;
position: relative;
}
span.searchfields-icon {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.searchResults\,:not(.smart-search-results), .searchResults:not(.smart-search-results) {
display: none;
}
.searchResults\,:not(.smart-search-results),
.searchResults:not(.smart-search-results) {
display: none;
}
+261 -240
View File
@@ -51,28 +51,26 @@
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'MusicArtist', 'MusicVideo'];
const BOOKS_TYPES = ['Book', 'AudioBook'];
const OTHER_TYPES = ['Playlist', 'Photo', 'PhotoAlbum', 'LiveTvChannel', 'LiveTvProgram', 'TvChannel', 'TvProgram', 'BoxSet'];
const SEARCH_CACHE_TTL = 60000;
const SEARCH_ITEM_FIELDS = 'PrimaryImageAspectRatio,DateCreated,ProductionYear,RecursiveItemCount,ChildCount,ProviderIds,MediaSourceCount';
// Cache for search results by search term and type
const searchCache = new Map();
// Live UI search type for stale-paint guards (`videos` maps to internal `core`)
let activeSearchType = 'videos';
// Function to generate cache key
function getCacheKey(searchTerm, searchType) {
return `${searchTerm.toLowerCase().trim()}_${searchType}`;
function toInternalSearchType(uiOrInternalType) {
if (!uiOrInternalType || uiOrInternalType === 'videos') return 'core';
return uiOrInternalType;
}
// Function to check cache and return cached results if available
function getCachedResults(searchTerm, searchType) {
const cacheKey = getCacheKey(searchTerm, searchType);
const cached = searchCache.get(cacheKey);
LOG('Cache check:', { searchTerm, searchType, cacheKey, cached: !!cached });
return cached;
function getLiveSearchTerm() {
return (document.getElementById('searchTextInput')?.value || '').trim();
}
// Function to cache results
function cacheResults(searchTerm, searchType, results) {
const cacheKey = getCacheKey(searchTerm, searchType);
searchCache.set(cacheKey, results);
LOG('Cached results:', { searchTerm, searchType, cacheKey, resultCount: results.total });
/** True when this run's term+type still match what the user has selected. */
function isSearchStillWanted(term, searchType) {
const liveTerm = getLiveSearchTerm();
if (!term || !liveTerm || term !== liveTerm) return false;
return toInternalSearchType(searchType) === toInternalSearchType(activeSearchType);
}
// Function to clear search results except jellyseerr-section
@@ -86,6 +84,136 @@
}
}
function getTypeGroups(searchType) {
if (searchType === 'music') return MUSIC_TYPES;
if (searchType === 'books') return BOOKS_TYPES;
if (searchType === 'all') return [...CORE_TYPES, ...MUSIC_TYPES, ...BOOKS_TYPES, ...OTHER_TYPES];
return CORE_TYPES;
}
function postProcessSearchItems(itemType, data) {
const items = Array.isArray(data)
? data.slice()
: (Array.isArray(data?.Items) ? data.Items.slice() : []);
if (!items.length) return items;
if (itemType === 'MusicArtist') {
return items.sort((a, b) => {
const aIsFolder = a.IsFolder ? 1 : 0;
const bIsFolder = b.IsFolder ? 1 : 0;
return bIsFolder - aIsFolder;
});
}
return items.sort((a, b) => {
const aHasImage = a.ImageTags?.Primary ? 1 : 0;
const bHasImage = b.ImageTags?.Primary ? 1 : 0;
return bHasImage - aHasImage;
});
}
function buildSearchQueryDef(itemType, searchTerm) {
const term = searchTerm.trim();
if (itemType === 'MusicArtist') {
return {
path: '/Artists',
queryOptions: {
Limit: 100,
SearchTerm: term,
Fields: 'PrimaryImageAspectRatio',
ImageTypeLimit: 1,
EnableTotalRecordCount: false
}
};
}
if (itemType === 'Person') {
return {
path: '/Persons',
queryOptions: {
Limit: 100,
SearchTerm: term,
Fields: 'PrimaryImageAspectRatio',
ImageTypeLimit: 1,
EnableTotalRecordCount: false
}
};
}
return {
queryOptions: {
SearchTerm: term,
IncludeItemTypes: itemType,
Recursive: true,
Limit: 100,
Fields: SEARCH_ITEM_FIELDS,
ImageTypeLimit: 1,
EnableTotalRecordCount: false
}
};
}
async function buildSearchSection(itemType, searchTerm, order) {
const apiHelper = window.apiHelper;
if (!apiHelper?.getQuery || !apiHelper?.buildQueryFromSection) {
throw new Error('apiHelper.getQuery / buildQueryFromSection unavailable');
}
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const query = buildSearchQueryDef(itemType, searchTerm);
const url = apiHelper.buildQueryFromSection(query, userId, serverUrl);
if (typeof url !== 'string') {
throw new Error(`Invalid search query URL for ${itemType}`);
}
const queryResult = await apiHelper.getQuery(url, {
useCache: true,
ttl: SEARCH_CACHE_TTL
});
const termKey = searchTerm.toLowerCase().trim().replace(/[^a-z0-9]+/gi, '-').slice(0, 48);
const sectionConfig = {
id: `search-${itemType}-${termKey}`,
name: getTypeDisplayName(itemType),
enabled: true,
order,
queries: [query],
ttl: SEARCH_CACHE_TTL,
userConfigurable: false
};
let mappedDataPromise = null;
const ensureData = () => {
if (!mappedDataPromise) {
const raw = typeof queryResult.ensureData === 'function'
? queryResult.ensureData()
: queryResult.dataPromise;
mappedDataPromise = Promise.resolve(raw)
.then((data) => postProcessSearchItems(itemType, data))
.catch((err) => {
WARN('search ensureData failed for', itemType, err);
return [];
});
}
return mappedDataPromise;
};
const result = {
data: postProcessSearchItems(itemType, queryResult.data),
isStale: queryResult.isStale === true,
isStalePromise: queryResult.isStalePromise,
ensureData
};
Object.defineProperty(result, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return ensureData();
}
});
return { config: sectionConfig, result };
}
// styles (dedupe by id)
function addCustomStyles() {
if (document.getElementById('smart-search-styles')) return;
@@ -93,7 +221,6 @@
const style = document.createElement('style');
style.id = 'smart-search-styles';
style.textContent = `
.smart-search-wrapper { margin: 20px 0; }
.smart-search-input { width:100%; padding:8px; border-radius:4px; box-sizing:border-box; }
.smart-search-buttons { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; justify-content: center; }
.smart-search-btn { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#444; color:#fff; }
@@ -107,6 +234,7 @@
z-index: 1;
right: 0;
cursor: pointer;
font-size: 2em;
}
`;
document.head.appendChild(style);
@@ -133,147 +261,6 @@
}
}
// build URL helper
function buildSearchUrl(searchTerm, itemTypes, additionalParams = {}) {
try {
const userId = ApiClient.getCurrentUserId();
const baseUrl = ApiClient.serverAddress();
const params = new URLSearchParams({
userId,
limit: '100',
recursive: 'true',
searchTerm: searchTerm.trim(),
fields: 'PrimaryImageAspectRatio,CanDelete',
imageTypeLimit: '1',
enableTotalRecordCount: 'false',
...additionalParams
});
itemTypes.forEach(t => params.append('includeItemTypes', t));
const url = `${baseUrl}/Items?${params.toString()}`;
LOG('buildSearchUrl ->', url);
return url;
} catch (e) {
ERR('buildSearchUrl error', e);
throw e;
}
}
// perform smart search
async function performSmartSearch(searchTerm, searchType = 'core') {
LOG('performSmartSearch()', { searchTerm, searchType });
let results = { groupedItems: {}, total: 0 };
const searchPage = document.getElementById('searchPage');
if (!searchPage) {
ERR('searchPage not found');
return results;
}
// Hide search suggestions
const searchSuggestions = searchPage.querySelector('.searchSuggestions');
if (searchSuggestions) {
searchSuggestions.style.display = 'none';
}
const noItemsMessage = searchPage.querySelector('.noItemsMessage.dummy-section');
if (!noItemsMessage) {
const noItemsMessage = document.createElement('div');
noItemsMessage.className = 'noItemsMessage dummy-section';
noItemsMessage.style.display = 'none';
searchPage.appendChild(noItemsMessage);
}
// Update URL with search query and type
const urlType = searchType === 'core' ? 'videos' : searchType;
updateSearchUrl(searchTerm, urlType);
// Handle Jellyseerr-only search
if (searchType === 'request') {
return results;
}
// Show loading spinner
Dashboard.showLoadingMsg();
try {
const typeGroups = searchType === 'music'
? MUSIC_TYPES
: (searchType === 'books'
? BOOKS_TYPES
: (searchType === 'all' ? [...CORE_TYPES, ...MUSIC_TYPES, ...BOOKS_TYPES, ...OTHER_TYPES] : CORE_TYPES));
const promises = typeGroups.map(type => {
let url;
const userId = ApiClient.getCurrentUserId();
const baseUrl = ApiClient.serverAddress();
if (type === 'MusicArtist') {
// Use dedicated Artists endpoint for better results
url = `${baseUrl}/Artists?limit=100&searchTerm=${encodeURIComponent(searchTerm)}&fields=PrimaryImageAspectRatio&fields=CanDelete&imageTypeLimit=1&userId=${userId}&enableTotalRecordCount=false`;
} else if (type === 'Person') {
url = `${baseUrl}/Persons?limit=100&searchTerm=${encodeURIComponent(searchTerm)}&fields=PrimaryImageAspectRatio&fields=CanDelete&&imageTypeLimit=1&userId=${userId}&enableTotalRecordCount=false`;
} else {
url = buildSearchUrl(searchTerm, [type]);
}
return ApiClient.fetch({ url, method: 'GET' })
.then(resp => resp.json())
.catch(err => {
WARN('fetch failed for', type, err);
return { Items: [] };
});
});
const start = performance.now();
const responses = await Promise.all(promises);
const end = performance.now();
// Group results by type
responses.forEach(async (r, idx) => {
if (idx < typeGroups.length) {
// Regular Jellyfin results
const type = typeGroups[idx];
if (r && r.Items && r.Items.length > 0) {
// For Artists, use all items (they often don't have primary images)
// For other types, filter to show only those with images
let filteredItems;
if (type === 'MusicArtist') {
// Artists: show all items, they often don't have primary images
// Instead, sort the artists by IsFolder
filteredItems = r.Items.sort((a, b) => {
const aIsFolder = a.IsFolder ? 1 : 0;
const bIsFolder = b.IsFolder ? 1 : 0;
return bIsFolder - aIsFolder;
});
} else {
// Other types: sort so items with images appear first
filteredItems = r.Items.sort((a, b) => {
const aHasImage = a.ImageTags?.Primary ? 1 : 0;
const bHasImage = b.ImageTags?.Primary ? 1 : 0;
return bHasImage - aHasImage; // Items with images first
});
}
results.groupedItems[type] = filteredItems;
results.total += filteredItems.length;
}
}
});
displaySmartResults(results, Math.round(end - start));
// Cache the results
cacheResults(searchTerm, searchType, results);
return results;
} catch (e) {
ERR('performSmartSearch error', e);
return results;
} finally {
// Hide loading spinner
Dashboard.hideLoadingMsg()
}
}
function getTypeDisplayName(itemType) {
const typeMap = {
'Movie': 'Movies',
@@ -283,12 +270,15 @@
'MusicAlbum': 'Albums',
'Audio': 'Songs',
'MusicArtist': 'Artists',
'MusicVideo': 'Music Videos',
'Playlist': 'Playlists',
'Book': 'Books',
'AudioBook': 'Audiobooks',
'Photo': 'Photos',
'PhotoAlbum': 'Photo Albums',
'TvChannel': 'TV Channels',
'TvProgram': 'TV Programs',
'LiveTvChannel': 'Live TV Channels',
'LiveTvProgram': 'Live TV',
'BoxSet': 'Collections'
};
@@ -314,53 +304,98 @@
return smartResults;
}
function displaySmartResults(results, ms) {
const resultsContainer = ensureSmartResultsContainer();
const stats = document.getElementById('smart-search-stats');
// perform smart search via progressive sections (Request/Jellyseerr stays separate)
async function performSmartSearch(searchTerm, searchType = 'core') {
LOG('performSmartSearch()', { searchTerm, searchType });
if (!stats) return;
resultsContainer.innerHTML = '';
// Check if we have any grouped results
if (!results.groupedItems || Object.keys(results.groupedItems).length === 0) {
resultsContainer.innerHTML = '<p style="color:#999;text-align:center;">No results found</p>';
stats.textContent = `Search completed in ${ms}ms - 0 results`;
const searchPage = document.getElementById('searchPage');
if (!searchPage) {
ERR('searchPage not found');
return;
}
// Create sections for each item type that has results
const frag = document.createDocumentFragment();
let totalShown = 0;
// Add hidden dummy section for testing/placeholder
const dummySection = document.createElement('div');
dummySection.className = 'verticalSection emby-scroller-container';
dummySection.style.display = 'none'; // Hidden dummy section
dummySection.setAttribute('data-dummy', 'true');
const dummyTitle = document.createElement('h2');
dummyTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right';
dummyTitle.textContent = 'Hidden Dummy Section';
dummySection.appendChild(dummyTitle);
frag.appendChild(dummySection);
// Define the order to display sections
const sectionOrder = ['Movie', 'Series', 'Episode', 'Person', 'MusicArtist', 'MusicAlbum', 'Audio', 'Book', 'AudioBook', 'Photo', 'PhotoAlbum', 'BoxSet', 'Playlist', 'TvChannel', 'TvProgram', 'LiveTvChannel', 'LiveTvProgram'];
sectionOrder.forEach((itemType) => {
if (results.groupedItems[itemType] && results.groupedItems[itemType].length > 0) {
const items = results.groupedItems[itemType];
const title = getTypeDisplayName(itemType);
const section = window.cardBuilder.renderCards(items, title, null);
frag.appendChild(section);
totalShown += items.length;
}
});
const trimmed = (searchTerm || '').trim();
const internalType = toInternalSearchType(searchType);
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.appendChild(frag);
stats.textContent = `Search completed in ${ms}ms - ${results.total} results found`;
// Hide search suggestions while searching
const searchSuggestions = searchPage.querySelector('.searchSuggestions');
if (searchSuggestions) {
searchSuggestions.style.display = trimmed ? 'none' : 'block';
}
let noItemsMessage = searchPage.querySelector('.noItemsMessage.dummy-section');
if (!noItemsMessage) {
noItemsMessage = document.createElement('div');
noItemsMessage.className = 'noItemsMessage dummy-section';
noItemsMessage.style.display = 'none';
searchPage.appendChild(noItemsMessage);
}
// Update URL with search query and type
const urlType = internalType === 'core' ? 'videos' : internalType;
updateSearchUrl(trimmed, urlType);
// Handle Jellyseerr-only search
if (internalType === 'request' || searchType === 'request') {
return;
}
if (!trimmed) {
resultsContainer.innerHTML = '';
return;
}
// Drop previous results as soon as a new search is committed
resultsContainer.innerHTML = '';
if (!window.cardBuilder?.renderProgressiveSections) {
ERR('cardBuilder.renderProgressiveSections unavailable');
return;
}
if (!isSearchStillWanted(trimmed, internalType)) {
LOG('Skipping search; UI already changed', { trimmed, internalType });
return;
}
try {
const typeGroups = getTypeGroups(internalType);
const sectionPromises = typeGroups.map((itemType, index) =>
buildSearchSection(itemType, trimmed, index).catch((err) => {
WARN('Failed to build search section for', itemType, err);
return null;
})
);
// Resolve section setup (cache lookup / getQuery shell) before paint
const sections = await Promise.all(sectionPromises);
if (!isSearchStillWanted(trimmed, internalType)) {
LOG('Skipping stale search render after fetch', { trimmed, internalType });
return;
}
const validSections = sections.filter(Boolean).map((section) => Promise.resolve(section));
if (!isSearchStillWanted(trimmed, internalType)) {
LOG('Skipping stale search paint', { trimmed, internalType });
return;
}
resultsContainer.innerHTML = '';
await window.cardBuilder.renderProgressiveSections(resultsContainer, validSections, {
showStaleDataBeforeRefresh: true
});
if (!isSearchStillWanted(trimmed, internalType)) {
LOG('Search superseded after paint; leaving newer owner in charge', { trimmed, internalType });
// Empty clear owns the DOM — remove anything this run may have painted
if (!getLiveSearchTerm()) {
resultsContainer.innerHTML = '';
}
}
} catch (e) {
ERR('performSmartSearch error', e);
}
}
// main init
@@ -416,7 +451,7 @@
const btnRow = document.createElement('div');
btnRow.className = 'smart-search-buttons';
const smartSearchButtonClass = 'smart-search-btn emby-button raised';
const smartSearchButtonClass = 'smart-search-btn emby-button flat';
const btnAll = document.createElement('button');
btnAll.id = 'smart-search-all';
btnAll.className = smartSearchButtonClass;
@@ -525,52 +560,37 @@
function setSearchType(type) {
LOG('setSearchType called:', { type, currentSearchType });
currentSearchType = type;
activeSearchType = type;
updateButtonStates(type);
// If there's a search term, check cache first
const searchTerm = smartInput.value.trim();
LOG('Search term from input:', searchTerm);
if (searchTerm) {
// Map URL type to internal type
const internalType = type === 'videos' ? 'core' : type;
LOG('Internal type mapping:', { type, internalType });
// For request type, don't use cache and always perform fresh search
if (type === 'request') {
LOG('Request type - clearing results and performing fresh Jellyseerr search');
// Clear existing search results except jellyseerr-section
clearSearchResultsExceptJellyseerr();
performSmartSearch(searchTerm, 'request');
return;
}
// Check if we have cached results for this search term and type
const cachedResults = getCachedResults(searchTerm, internalType);
if (cachedResults) {
LOG('Using cached results for', searchTerm, internalType);
displaySmartResults(cachedResults, 0); // 0ms since it's cached
// Update URL with search query and type even for cached results
const urlType = type === 'videos' ? 'videos' : type;
updateSearchUrl(searchTerm, urlType);
return;
}
// No cache hit, perform new search
LOG('No cache hit, performing new search');
performSmartSearch(searchTerm, internalType);
if (!searchTerm) return;
const internalType = toInternalSearchType(type);
LOG('Internal type mapping:', { type, internalType });
if (type === 'request') {
LOG('Request type - clearing results and performing fresh Jellyseerr search');
clearSearchResultsExceptJellyseerr();
performSmartSearch(searchTerm, 'request');
return;
}
performSmartSearch(searchTerm, internalType);
}
if (!wrapper.dataset.initialized) {
wrapper.dataset.initialized = 'true';
let timer = null;
const doSmartSearchDebounced = () => {
if (timer) clearTimeout(timer);
const term = (smartInput.value || '').trim();
if (!term) {
if (!term) {
timer = null;
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.innerHTML='';
Dashboard.hideLoadingMsg();
resultsContainer.innerHTML = '';
updateSearchUrl('', currentSearchType);
// Show search suggestions
const searchSuggestions = document.querySelector('.searchSuggestions');
@@ -578,10 +598,9 @@
searchSuggestions.style.display = 'block';
}
return;
return;
}
if (timer) clearTimeout(timer);
const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType;
const internalType = toInternalSearchType(currentSearchType);
timer = setTimeout(() => performSmartSearch(term, internalType), 300);
};
@@ -589,7 +608,8 @@
smartInput.addEventListener('keydown', (e)=>{
if(e.key==='Enter'){
e.preventDefault();
const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType;
if (timer) clearTimeout(timer);
const internalType = toInternalSearchType(currentSearchType);
performSmartSearch((smartInput.value||'').trim(), internalType);
}
});
@@ -612,13 +632,14 @@
// Set initial search type and update UI
currentSearchType = typeFromUrl;
activeSearchType = typeFromUrl;
updateButtonStates(typeFromUrl);
// Handle URL query parameter - populate smart search and trigger search
if (queryFromUrl) {
smartInput.value = queryFromUrl;
// Trigger immediate search with the URL query
const internalType = typeFromUrl === 'videos' ? 'core' : typeFromUrl;
const internalType = toInternalSearchType(typeFromUrl);
performSmartSearch(queryFromUrl, internalType);
}
}
+144
View File
@@ -0,0 +1,144 @@
function addSnowverlay() {
// Check if snowverlay canvas is already added
const snowverlayCanvas = document.querySelector('.snowverlay');
if (snowverlayCanvas) {
return;
}
// Configuration constants
const CANVAS_HEIGHT_PX = 160;
const SNOWFLAKE_AMOUNT = 15;
const SNOWFLAKE_SIZE = { min: 10, max: 22 };
const SNOWFLAKE_SPEED = { min: 0.2, max: 0.7 };
// Dynamically inject styles
const style = document.createElement('style');
style.textContent = `
body {
height: 100svh;
margin: 0;
}
.snowverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: ${CANVAS_HEIGHT_PX}px;
pointer-events: none;
z-index: 1000;
}
`;
document.head.appendChild(style);
// Create and inject canvas
const canvas = document.createElement('canvas');
canvas.className = 'snowverlay';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// Resize handler
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = CANVAS_HEIGHT_PX;
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Snowflake factory
const createSnowflake = (isAnimated = true, index = 0) => ({
x: Math.random() * canvas.width,
y: isAnimated
? -20 - (index * canvas.height) / SNOWFLAKE_AMOUNT
: Math.random() * canvas.height,
size: Math.random() * (SNOWFLAKE_SIZE.max - SNOWFLAKE_SIZE.min) + SNOWFLAKE_SIZE.min,
speed: Math.random() * (SNOWFLAKE_SPEED.max - SNOWFLAKE_SPEED.min) + SNOWFLAKE_SPEED.min,
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
drift: Math.random() * 0.4 - 0.2, // Random horizontal drift (wind)
rotation: Math.random() * 360,
rotationSpeed: Math.random() * 2 - 1 // Degrees per frame
});
// Draw single snowflake
const drawSnowflake = (flake) => {
ctx.save();
ctx.translate(flake.x, flake.y);
ctx.rotate((flake.rotation * Math.PI) / 180);
ctx.font = `${flake.size}px sans-serif`;
ctx.fillStyle = `rgba(255, 255, 255, ${flake.opacity ?? (1 - flake.y / canvas.height)})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('❤', 0, 0);
ctx.restore();
};
// Animation control
let animationFrame;
const stopAnimation = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
};
// Static snow renderer
const renderStaticSnow = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
Array.from({ length: SNOWFLAKE_AMOUNT }, () => createSnowflake(false))
.forEach(drawSnowflake);
};
// Animated snow renderer
const startSnowAnimation = () => {
const snowflakes = Array.from(
{ length: SNOWFLAKE_AMOUNT },
(_, i) => createSnowflake(true, i)
);
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
snowflakes.forEach(flake => {
flake.y += flake.speed;
flake.x += flake.drift;
flake.rotation += flake.rotationSpeed;
if (flake.y > canvas.height) {
flake.y = -10;
flake.x = Math.random() * canvas.width;
}
drawSnowflake(flake);
});
animationFrame = requestAnimationFrame(animate);
};
animate();
return stopAnimation;
};
// Motion preference handler
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let currentAnimation = null;
const handleMotionChange = (e) => {
stopAnimation();
if (currentAnimation) currentAnimation();
if (e.matches) {
renderStaticSnow();
currentAnimation = null;
} else {
currentAnimation = startSnowAnimation();
}
};
mediaQuery.addEventListener('change', handleMotionChange);
handleMotionChange(mediaQuery);
// Cleanup on unload
window.addEventListener('beforeunload', () => {
mediaQuery.removeEventListener('change', handleMotionChange);
stopAnimation();
});
}
addSnowverlay();
+15 -2
View File
@@ -1,3 +1,16 @@
.nextUpSection {
display: none;
}
display: none;
}
.series-episodes-section .sectionTitleContainer,
.series-episodes-section .emby-scroller {
padding-left: 0;
}
.detailSection:has(.series-episodes-section) {
overflow: hidden;
}
.series-episodes-section {
padding-left: 0 !important;
margin: 0 !important;
}
+442 -470
View File
@@ -9,6 +9,333 @@
const WARN = (...args) => console.warn('[KefinTweaks SeriesEpisodes]', ...args);
const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes]', ...args);
const EPISODES_CACHE_TTL = 60000;
const episodeHookStateByHost = new WeakMap();
function getEpisodesMount(host) {
return host?.querySelector('.series-episodes-mount');
}
function setEpisodeHookState(host, state) {
if (host) episodeHookStateByHost.set(host, state);
}
function getEpisodeHookState(host) {
return host ? episodeHookStateByHost.get(host) : null;
}
function findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber) {
if (!scrollerContainer || !targetEpisodeNumber) return null;
const itemsContainer = scrollerContainer.querySelector('.itemsContainer');
if (!itemsContainer) return null;
if (targetEpisodeNumber.id) {
const byId = itemsContainer.querySelector(`.card[data-id="${targetEpisodeNumber.id}"]`);
if (byId && !byId.hasAttribute('data-skeleton') && !byId.classList.contains('skeleton-card')) {
return byId;
}
}
for (const card of itemsContainer.querySelectorAll('.card')) {
if (card.hasAttribute('data-skeleton') || card.classList.contains('skeleton-card')) {
continue;
}
for (const link of card.querySelectorAll('.cardText a')) {
const linkText = link.innerText || link.textContent;
const match = linkText.match(/S(\d+):E(\d+)/);
if (match
&& parseInt(match[1], 10) === targetEpisodeNumber.season
&& parseInt(match[2], 10) === targetEpisodeNumber.episode) {
return card;
}
}
}
return null;
}
function seasonId(season) {
return season?.Id ?? season?.id;
}
function seasonName(season) {
return season?.Name ?? season?.name;
}
function seasonIndex(season) {
return season?.IndexNumber ?? season?.indexNumber;
}
function buildEpisodesQueryDef(seriesId, seasonIdValue) {
return {
path: `/Shows/${seriesId}/Episodes`,
queryOptions: {
SeasonId: seasonIdValue,
SortBy: 'IndexNumber',
SortOrder: 'Ascending',
Fields: 'UserData,MediaSourceCount,PrimaryImageAspectRatio',
ImageTypeLimit: 1,
EnableTotalRecordCount: false
}
};
}
function postProcessEpisodes(data) {
const items = Array.isArray(data)
? data.slice()
: (Array.isArray(data?.Items) ? data.Items.slice() : []);
return items.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
}
function formatSeasonName(seasonNameValue, seasonNumber) {
if (!seasonNameValue) {
return `Season ${seasonNumber}`;
}
return seasonNameValue;
}
function buildSeasonViewMoreUrl(season) {
const apiClient = window.ApiClient;
const serverId = apiClient.serverId();
const sid = seasonId(season);
return `/web/#/details?id=${sid}&serverId=${serverId}`;
}
async function buildSeriesEpisodesSection(seriesId, seasons, initialSeason, onMultiQueryChange) {
const apiHelper = window.apiHelper;
if (!apiHelper?.getQuery || !apiHelper?.buildQueryFromSection) {
throw new Error('apiHelper.getQuery / buildQueryFromSection unavailable');
}
const initialIndex = Math.max(0, seasons.findIndex((season) => seasonId(season) === seasonId(initialSeason)));
const hasMultiple = seasons.length > 1;
const queries = seasons.map((season) => {
const sid = seasonId(season);
return {
name: formatSeasonName(seasonName(season), seasonIndex(season)),
viewMoreUrl: buildSeasonViewMoreUrl(season),
...buildEpisodesQueryDef(seriesId, sid)
};
});
const initialSeasonObj = seasons[initialIndex];
const sectionConfig = {
id: `series-episodes-${seriesId}`,
type: 'series-episodes',
name: formatSeasonName(seasonName(initialSeasonObj), seasonIndex(initialSeasonObj)),
enabled: true,
order: 0,
viewMoreUrl: buildSeasonViewMoreUrl(initialSeasonObj),
overflowCard: true,
ttl: EPISODES_CACHE_TTL,
userConfigurable: false,
queries,
_selectedQueryIndex: initialIndex
};
try {
const storedLayout = await window.KefinUserHomeScreenConfig?.loadSectionItemsLayout?.(
sectionConfig.id,
{ type: sectionConfig.type }
);
if (storedLayout === 'grid' || storedLayout === 'row') {
sectionConfig.itemsLayout = storedLayout;
}
} catch (e) {
WARN('Failed to restore series-episodes itemsLayout from sectionState', e);
}
if (hasMultiple) {
sectionConfig.useMultiQueryPicker = true;
sectionConfig.useQueryNamesForSection = true;
sectionConfig.multiQueryPickerLabel = 'Select Season';
}
if (typeof onMultiQueryChange === 'function') {
sectionConfig._onMultiQueryChange = onMultiQueryChange;
}
const initialQuery = queries[initialIndex];
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const url = apiHelper.buildQueryFromSection(initialQuery, userId, serverUrl);
if (typeof url !== 'string') {
throw new Error(`Invalid episodes query URL for season ${seasonId(initialSeasonObj)}`);
}
const queryResult = await apiHelper.getQuery(url, {
useCache: true,
ttl: EPISODES_CACHE_TTL
});
let mappedDataPromise = null;
const ensureData = () => {
if (!mappedDataPromise) {
const raw = typeof queryResult.ensureData === 'function'
? queryResult.ensureData()
: queryResult.dataPromise;
mappedDataPromise = Promise.resolve(raw)
.then((data) => postProcessEpisodes(data))
.catch((err) => {
WARN('episodes ensureData failed for season', seasonId(initialSeasonObj), err);
return [];
});
}
return mappedDataPromise;
};
const result = {
data: postProcessEpisodes(queryResult.data),
isStale: queryResult.isStale === true,
isStalePromise: queryResult.isStalePromise,
ensureData
};
Object.defineProperty(result, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return ensureData();
}
});
return { config: sectionConfig, result };
}
function getSectionTitleContainer(host) {
return host?.querySelector('.series-episodes-section .sectionTitleContainer')
|| host?.querySelector('.sectionTitleContainer');
}
async function renderEpisodesProgressive(seriesId, seasons, initialSeason, host, onMultiQueryChange, configureSection) {
if (!window.cardBuilder?.renderProgressiveSections) {
WARN('cardBuilder.renderProgressiveSections unavailable');
return null;
}
const mount = getEpisodesMount(host);
if (!mount) {
WARN('Episodes mount not found in host');
return null;
}
const section = await buildSeriesEpisodesSection(seriesId, seasons, initialSeason, onMultiQueryChange);
if (typeof configureSection === 'function') {
configureSection(section.config);
}
const stagingMount = document.createElement('div');
await window.cardBuilder.renderProgressiveSections(
stagingMount,
[Promise.resolve(section)],
{ showStaleDataBeforeRefresh: true }
);
const seasonSection = stagingMount.querySelector('.emby-scroller-container') || stagingMount.firstElementChild;
if (!seasonSection) {
WARN('Progressive episodes section did not render');
return null;
}
seasonSection.classList.add('series-episodes-section');
const scrollerContainer = seasonSection.querySelector('.emby-scroller');
if (scrollerContainer) {
scrollerContainer.classList.add('no-padding');
}
const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer');
if (sectionTitleContainer) {
sectionTitleContainer.classList.add('no-padding');
}
mount.innerHTML = '';
mount.appendChild(seasonSection);
return { sectionEl: seasonSection, section };
}
function applyEpisodeScrollAndBadge(host, sectionEl, targetEpisodeNumber, activeSeasonIndex, nextUpHeaderText) {
const scrollerContainer = sectionEl?.querySelector('.emby-scroller')
|| host?.querySelector('.series-episodes-section .emby-scroller');
if (!scrollerContainer) return;
if (targetEpisodeNumber && activeSeasonIndex !== undefined
&& targetEpisodeNumber.season === activeSeasonIndex) {
ensureNextUpEpisodeStyle(nextUpHeaderText);
updateNextUpItem(targetEpisodeNumber, scrollerContainer);
return;
}
window.cardBuilder.setScrollerPosition(scrollerContainer, 0, false);
LOG('Scrolled to beginning of season (not NextUp season)');
}
function ensureNextUpEpisodeStyle(nextUpHeaderText) {
if (document.getElementById('kefinTweaks-nextUpEpisode-style')) {
return;
}
const style = document.createElement('style');
style.id = 'kefinTweaks-nextUpEpisode-style';
const escapedHeaderText = (nextUpHeaderText || 'Next Up').replace(/'/g, "\\'").replace(/"/g, '\\"');
style.textContent = `
.nextUpEpisode:not(.nextUpEpisode ~ .nextUpEpisode) .cardScalable::after {
content: '${escapedHeaderText}';
position: absolute;
transform: translateY(0);
top: 0.5em;
left: 0.5em;
padding: 0.25em 0.5em;
text-align: center;
background: rgb(0 0 0 / 85%);
font-size: 1.1em;
border: 1px solid rgb(255 255 255 / 40%);
border-radius: 5px;
pointer-events: none;
}
`;
document.head.appendChild(style);
LOG('Added nextUpEpisode CSS with header text:', nextUpHeaderText);
}
function insertEpisodesHost(activePage) {
const nextUpSection = activePage.querySelector('.nextUpSection');
const detailPageContent = activePage.querySelector('.detailPageContent');
let host = activePage.querySelector('.series-episodes-section');
if (host) {
if (!getEpisodesMount(host)) {
const mount = document.createElement('div');
mount.className = 'series-episodes-mount';
while (host.firstChild) {
mount.appendChild(host.firstChild);
}
host.appendChild(mount);
}
return host;
}
host = document.createElement('div');
host.className = 'series-episodes-section';
const mount = document.createElement('div');
mount.className = 'series-episodes-mount';
host.appendChild(mount);
if (nextUpSection && nextUpSection.parentNode) {
nextUpSection.parentNode.insertBefore(host, nextUpSection.nextSibling);
} else if (detailPageContent) {
const childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible')
|| activePage.querySelector('#childrenCollapsible');
if (childrenCollapsible && childrenCollapsible.parentNode) {
childrenCollapsible.parentNode.insertBefore(host, childrenCollapsible);
} else {
detailPageContent.insertBefore(host, detailPageContent.firstChild);
}
}
return host;
}
/**
* Fetches the Next Up episode for a given series
* @param {string} seriesId - The series ID
@@ -19,12 +346,10 @@
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching Next Up episode for series: ${seriesId}`);
const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`;
const nextUpRes = await fetch(nextUpUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
headers: { "Authorization": window.apiHelper.getAuthHeader() }
});
if (!nextUpRes.ok) {
@@ -58,12 +383,10 @@
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching season data for season ID: ${seasonId}`);
const seasonUrl = `${serverUrl}/Items/${seasonId}?UserId=${userId}`;
const seasonRes = await fetch(seasonUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
headers: { "Authorization": window.apiHelper.getAuthHeader() }
});
if (!seasonRes.ok) {
@@ -79,45 +402,6 @@
}
}
/**
* Fetches all episodes for a given series
* @param {string} seriesId - The series ID
* @param {string} [seasonId] - Optional season ID to filter episodes
* @returns {Promise<Array>} - Array of episode items
*/
async function fetchEpisodesForSeries(seriesId, seasonId = null) {
try {
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`);
const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`;
const episodesRes = await fetch(episodesUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!episodesRes.ok) {
throw new Error(`HTTP ${episodesRes.status}: ${episodesRes.statusText}`);
}
const episodesData = await episodesRes.json();
let episodes = episodesData.Items || [];
// Filter by season ID if specified
if (seasonId !== null) {
episodes = episodes.filter(ep => ep.SeasonId === seasonId);
}
LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`);
return episodes;
} catch (error) {
ERR(`Failed to fetch episodes for series ${seriesId}:`, error);
return [];
}
}
/**
* Extracts season data from the rendered DOM
* @param {HTMLElement} activePage - The active page element
@@ -197,213 +481,44 @@
return seasons;
}
/**
* Formats a season name, adding season number in parentheses if not in "Season XX" format
* @param {string} seasonName - The season name
* @param {number} seasonNumber - The season number
* @returns {string} - Formatted season name
*/
function formatSeasonName(seasonName, seasonNumber) {
if (!seasonName) {
return `Season ${seasonNumber}`;
}
return seasonName;
}
/**
* Creates a season selector button with popover for multi-season shows
* @param {HTMLElement} sectionTitleContainer - The section title container element
* @param {Array} seasons - Array of season objects
* @param {Object} currentSeason - The currently selected season
* @param {Function} onSeasonSelect - Callback when a season is selected
*/
function createSeasonSelector(sectionTitleContainer, seasons, currentSeason, onSeasonSelect) {
// Create select season button
const selectButton = document.createElement('button');
selectButton.className = 'emby-button raised season-selector-button';
selectButton.textContent = 'Select Season';
selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;';
selectButton.setAttribute('aria-label', 'Select season');
// Find the <a> tag link (titleLink) and append button after it
const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link');
if (titleLink) {
titleLink.parentNode.insertBefore(selectButton, titleLink.nextSibling);
} else {
const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle');
if (sectionTitle) {
sectionTitle.parentNode.insertBefore(selectButton, sectionTitle.nextSibling);
} else {
sectionTitleContainer.appendChild(selectButton);
}
}
let activePopover = null;
const closePopover = () => {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
};
const createPopover = () => {
const popover = document.createElement('div');
popover.className = 'kefinTweaks-popover seasonPopover itemDetailsGroup';
const seasonPopoverCss = `
.seasonPopover {
background-color: rgba(0, 0, 0, 0.95);
position: absolute !important;
z-index: 1001;
display: block;
}
`;
const styleElement = document.createElement('style');
styleElement.textContent = seasonPopoverCss;
document.head.appendChild(styleElement);
// Calculate left position based on right edge of the <a> tag link
const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link');
if (titleLink) {
const titleLinkRect = titleLink.getBoundingClientRect();
const sectionTitleContainerRect = sectionTitleContainer.getBoundingClientRect();
const leftOffset = titleLinkRect.right - sectionTitleContainerRect.left;
popover.style.left = `${leftOffset}px`;
} else {
popover.style.left = '0';
}
// Support both API shape (Id, Name, IndexNumber) and constructed shape (id, name, indexNumber)
const seasonId = (s) => s.Id != null ? s.Id : s.id;
const seasonName = (s) => s.Name != null ? s.Name : s.name;
const seasonIndex = (s) => s.IndexNumber != null ? s.IndexNumber : s.indexNumber;
let selectedItemElement = null;
seasons.forEach(season => {
const itemElement = document.createElement('div');
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem';
const isSelected = currentSeason && (seasonId(season) === seasonId(currentSeason));
if (isSelected) {
itemElement.classList.add('selected');
selectedItemElement = itemElement;
}
const displayText = formatSeasonName(`${seasonName(season)}`, seasonIndex(season));
itemElement.textContent = displayText;
itemElement.addEventListener('click', () => {
LOG(`Season selected: ${displayText}`);
onSeasonSelect(season);
closePopover();
});
popover.appendChild(itemElement);
});
// Store reference to selected item for scrolling
popover._selectedItem = selectedItemElement;
return popover;
};
selectButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (activePopover) {
closePopover();
} else {
const popover = createPopover();
sectionTitleContainer.style.position = 'relative';
selectButton.parentNode.insertBefore(popover, selectButton.nextSibling);
popover.style.top = '100%';
popover.style.bottom = 'auto';
popover.style.marginTop = '8px';
popover.style.marginBottom = '0';
activePopover = popover;
// Scroll to selected season if it exists
if (popover._selectedItem) {
setTimeout(() => {
popover._selectedItem.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest'
});
LOG('Auto-scrolled to selected season:', popover._selectedItem.textContent);
}, 10);
}
const closeHandler = (e) => {
if (!popover.contains(e.target) && !selectButton.contains(e.target)) {
closePopover();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
}, 100);
}
});
}
/**
* Scrolls to a specific episode in the scroller container
* @param {HTMLElement} scrollerContainer - The scroller container element
* @param {Object} targetEpisodeNumber - Object with season and episode numbers
*/
function scrollToEpisode(scrollerContainer, targetEpisodeNumber) {
if (!scrollerContainer || !targetEpisodeNumber) return;
setTimeout(() => {
const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer');
if (!scrollerItemsContainer) {
WARN('Could not find itemsContainer in scroller');
const doScroll = () => {
let targetCard = findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber);
if (!targetCard) {
requestAnimationFrame(() => {
targetCard = findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber);
if (targetCard) {
performEpisodeScroll(scrollerContainer, targetCard, targetEpisodeNumber);
} else {
WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
}
});
return;
}
const cards = scrollerItemsContainer.querySelectorAll('.card');
let targetCard = null;
for (const card of cards) {
const cardTextLinks = card.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
const match = linkText.match(/S(\d+):E(\d+)/);
if (match &&
parseInt(match[1], 10) === targetEpisodeNumber.season &&
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
targetCard = card;
break;
}
}
if (targetCard) break;
}
performEpisodeScroll(scrollerContainer, targetCard, targetEpisodeNumber);
};
if (targetCard) {
const itemsContainer = scrollerItemsContainer;
const cardOffset = targetCard.offsetLeft;
const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0;
const translateX = (cardOffset - scrollerPadding);
scrollerContainer.scrollToPosition(translateX);
const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons');
if (scrollButtons) {
const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
if (leftButton) {
leftButton.removeAttribute('disabled');
}
}
LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
} else {
WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
requestAnimationFrame(doScroll);
}
function performEpisodeScroll(scrollerContainer, targetCard, targetEpisodeNumber) {
const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0;
const translateX = targetCard.offsetLeft - scrollerPadding;
window.cardBuilder.setScrollerPosition(scrollerContainer, translateX, false);
const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons');
if (scrollButtons) {
const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
if (leftButton) {
leftButton.removeAttribute('disabled');
}
}, 100);
}
LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
}
/**
@@ -465,265 +580,122 @@
return;
}
// Check if already rendered
if (activePage.querySelector('.series-episodes-section')) {
LOG('Episodes section already rendered, skipping');
return;
}
// Determine target season parameters
let targetSeasonId, targetSeasonIndex, targetSeasonName;
let targetSeason = null;
if (targetEpisode) {
targetSeasonId = targetEpisode.SeasonId;
targetSeasonIndex = targetEpisode.ParentIndexNumber;
// Try to find matching season in seasons list for consistent naming, otherwise use API data
const matchingSeason = seasons.find(s => s.Id === targetSeasonId);
targetSeasonName = matchingSeason ? matchingSeason.Name : (targetEpisode.SeasonName || `Season ${targetSeasonIndex}`);
} else {
// Fallback to first season
if (seasons.length > 0) {
targetSeasonId = seasons[0].Id;
targetSeasonIndex = seasons[0].IndexNumber;
targetSeasonName = seasons[0].Name;
} else {
WARN('No seasons available to render');
return;
if (targetEpisode.SeasonId) {
targetSeason = seasons.find(s => s.Id === targetEpisode.SeasonId);
}
if (!targetSeason && targetEpisode.ParentIndexNumber !== undefined) {
targetSeason = seasons.find(s => s.IndexNumber === targetEpisode.ParentIndexNumber);
}
}
// Construct targetSeason object for selector
const targetSeason = {
id: targetSeasonId,
name: targetSeasonName,
indexNumber: targetSeasonIndex
};
// Fetch episodes for the target season
const episodes = await fetchEpisodesForSeries(seriesId, targetSeasonId);
if (episodes.length === 0) {
WARN(`No episodes found for season ${targetSeasonId}`);
if (!targetSeason) {
targetSeason = seasons[0];
}
if (!targetSeason) {
WARN('No seasons available to render');
return;
}
// Check for cardBuilder
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
WARN('cardBuilder.renderCards not available');
return;
}
// Format season name
const seasonName = formatSeasonName(targetSeasonName, targetSeasonIndex);
// Build viewMoreUrl
const apiClient = window.ApiClient;
const serverId = apiClient.serverId();
const viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${targetSeasonId}&serverId=${serverId}`;
// Render season section using renderCards
const seasonSection = window.cardBuilder.renderCards(
episodes,
seasonName,
viewMoreUrl,
true, // overflowCard
null // cardFormat (use default)
);
// Define targetEpisodeNumber for internal usage (scrolling/highlighting)
const targetEpisodeNumber = targetEpisode ? {
season: targetEpisode.ParentIndexNumber,
episode: targetEpisode.IndexNumber,
id: targetEpisode.Id
} : null;
// Mark as series episodes section
seasonSection.classList.add('series-episodes-section');
const hideSingleSeasonContainer = window.KefinTweaksConfig?.flattenSingleSeasonShows?.hideSingleSeasonContainer === true;
const scrollerContainer = seasonSection.querySelector('.emby-scroller');
if (scrollerContainer) {
scrollerContainer.classList.add('no-padding');
}
const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer');
if (sectionTitleContainer) {
sectionTitleContainer.classList.add('no-padding');
}
// Hide NextUp section if it exists
const nextUpSection = activePage.querySelector('.nextUpSection');
if (nextUpSection) {
nextUpSection.style.display = 'none';
}
// Insert episodes section right after NextUp section (or at the beginning if no NextUp)
const detailPageContent = activePage.querySelector('.detailPageContent');
if (detailPageContent) {
if (nextUpSection && nextUpSection.parentNode) {
nextUpSection.parentNode.insertBefore(seasonSection, nextUpSection.nextSibling);
} else {
// Find a good insertion point - after NextUp or before childrenCollapsible
const childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible') ||
activePage.querySelector('#childrenCollapsible');
if (childrenCollapsible && childrenCollapsible.parentNode) {
childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible);
} else {
detailPageContent.insertBefore(seasonSection, detailPageContent.firstChild);
}
}
const host = insertEpisodesHost(activePage);
if (!host || !host.parentNode) {
WARN('Could not insert episodes host');
return;
}
// Add season selector if more than one season
if (seasons.length > 1) {
const handleSeasonChange = async (selectedSeason) => {
LOG(`Switching to season: ${selectedSeason.Name}`);
const newEpisodes = await fetchEpisodesForSeries(seriesId, selectedSeason.Id);
if (newEpisodes.length === 0) {
WARN(`No episodes found for Season ID: ${selectedSeason.Id}`);
return;
}
// Update section title
const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle');
if (sectionTitle) {
const newSeasonName = formatSeasonName(selectedSeason.Name, selectedSeason.IndexNumber);
sectionTitle.textContent = newSeasonName;
}
// Update viewMoreUrl
const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link');
if (titleLink && selectedSeason.id) {
const newViewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${selectedSeason.id}&serverId=${serverId}`;
titleLink.href = newViewMoreUrl;
LOG(`Updated viewMoreUrl to: ${newViewMoreUrl}`);
}
// Update season selector button
const oldButton = sectionTitleContainer.querySelector('.season-selector-button');
if (oldButton) {
oldButton.remove();
}
createSeasonSelector(sectionTitleContainer, seasons, selectedSeason, handleSeasonChange);
// Replace episodes
const itemsContainer = scrollerContainer?.querySelector('.itemsContainer');
if (itemsContainer && window.cardBuilder && window.cardBuilder.buildCard) {
itemsContainer.innerHTML = '';
newEpisodes.forEach(episode => {
const card = window.cardBuilder.buildCard(episode, true);
if (card) {
itemsContainer.appendChild(card);
}
});
// Check if NextUp is in this season
if (targetEpisodeNumber && targetEpisodeNumber.season === selectedSeason.indexNumber) {
if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) {
setTimeout(() => {
const cards = itemsContainer.querySelectorAll('.card');
for (const card of cards) {
const cardTextLinks = card.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
const match = linkText.match(/S(\d+):E(\d+)/);
if (match &&
parseInt(match[1], 10) === targetEpisodeNumber.season &&
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
card.classList.add('nextUpEpisode');
LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
break;
}
}
}
}, 50);
}
scrollToEpisode(scrollerContainer, targetEpisodeNumber);
} else {
// Scroll to beginning if this season doesn't have NextUp
if (scrollerContainer) {
setTimeout(() => {
scrollerContainer.scrollToPosition(0);
LOG('Scrolled to beginning of season (not NextUp season)');
}, 100);
}
}
}
const hookState = {
targetEpisodeNumber,
nextUpHeaderText,
activeSeasonIndex: seasonIndex(targetSeason)
};
setEpisodeHookState(host, hookState);
const configureSection = (sectionConfig) => {
sectionConfig._onSectionEnhanced = (sectionEl) => {
const state = getEpisodeHookState(host);
if (!state) return;
const liveSection = sectionEl?.isConnected
? sectionEl
: (host.querySelector('.emby-scroller-container') || host.querySelector('[data-section-id]'));
applyEpisodeScrollAndBadge(
host,
liveSection,
state.targetEpisodeNumber,
state.activeSeasonIndex,
state.nextUpHeaderText
);
};
createSeasonSelector(sectionTitleContainer, seasons, targetSeason, handleSeasonChange);
};
const onMultiQueryChange = (queryIndex, sectionConfig, newContent) => {
const sectionEl = newContent
|| host.querySelector('.emby-scroller-container')
|| host.querySelector('[data-section-id]');
const activeSeason = seasons[queryIndex];
if (!sectionEl || !activeSeason) return;
LOG(`Season switched via query picker: ${seasonName(activeSeason)}`);
applyEpisodeScrollAndBadge(
host,
sectionEl,
hookState.targetEpisodeNumber,
seasonIndex(activeSeason),
hookState.nextUpHeaderText
);
};
let renderResult;
try {
renderResult = await renderEpisodesProgressive(
seriesId,
seasons,
targetSeason,
host,
onMultiQueryChange,
configureSection
);
} catch (err) {
ERR('Failed to render progressive episodes section', err);
return;
}
if (!renderResult) {
return;
}
// Add NextUp episode styling and class if needed
if (targetEpisodeNumber && scrollerContainer) {
if (targetEpisodeNumber.season === targetSeason.indexNumber) {
// Add CSS for nextUpEpisode class if not already added
if (!document.getElementById('kefinTweaks-nextUpEpisode-style')) {
const style = document.createElement('style');
style.id = 'kefinTweaks-nextUpEpisode-style';
const escapedHeaderText = nextUpHeaderText.replace(/'/g, "\\'").replace(/"/g, '\\"');
style.textContent = `
.nextUpEpisode:not(.nextUpEpisode ~ .nextUpEpisode) .cardScalable::after {
content: '${escapedHeaderText}';
position: absolute;
transform: translateY(0);
top: 0.5em;
left: 0.5em;
padding: 0.25em 0.5em;
text-align: center;
background: rgb(0 0 0 / 85%);
font-size: 1.1em;
border: 1px solid rgb(255 255 255 / 40%);
border-radius: 5px;
pointer-events: none;
}
`;
document.head.appendChild(style);
LOG('Added nextUpEpisode CSS with header text:', nextUpHeaderText);
}
// Add nextUpEpisode class if not S1E1
updateNextUpItem(targetEpisodeNumber, scrollerContainer);
}
}
LOG(`Successfully rendered episodes section for season ${targetSeason.indexNumber} with ${episodes.length} episodes`);
LOG(`Successfully rendered progressive episodes section for season ${seasonIndex(targetSeason)}`);
}
function updateNextUpItem(targetEpisodeNumber, scrollerContainer) {
// Remove existing nextUpEpisode class
function updateNextUpItem(targetEpisodeNumber, scrollerContainer) {
const existingNextUpEpisodes = scrollerContainer.querySelectorAll('.nextUpEpisode');
if (existingNextUpEpisodes.length > 0) {
for (const existingNextUpEpisode of existingNextUpEpisodes) {
existingNextUpEpisode.classList.remove('nextUpEpisode');
LOG(`Removed nextUpEpisode class from card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
}
}
// Add nextUpEpisode class if not S1E1
for (const existingNextUpEpisode of existingNextUpEpisodes) {
existingNextUpEpisode.classList.remove('nextUpEpisode');
}
if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) {
const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer');
if (scrollerItemsContainer) {
const cards = scrollerItemsContainer.querySelectorAll('.card');
for (const card of cards) {
const cardTextLinks = card.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
const match = linkText.match(/S(\d+):E(\d+)/);
if (match &&
parseInt(match[1], 10) === targetEpisodeNumber.season &&
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
card.classList.add('nextUpEpisode');
LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
break;
}
}
}
const targetCard = findTargetEpisodeCard(scrollerContainer, targetEpisodeNumber);
if (targetCard) {
targetCard.classList.add('nextUpEpisode');
LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
}
}
scrollToEpisode(scrollerContainer, targetEpisodeNumber);
}
+57 -26
View File
@@ -112,8 +112,8 @@
enabled: false
},
{
name: 'Show Section Controls On Hover Only',
url: `${getKefinTweaksRoot()}skins/optional/global/sectionControlsOnHover.css`,
name: 'Show Section Controls Always',
url: `${getKefinTweaksRoot()}skins/optional/global/sectionControlsAlways.css`,
enabled: false
},
{
@@ -135,6 +135,16 @@
name: 'Left Aligned Card Footer Text',
url: `${getKefinTweaksRoot()}skins/optional/global/leftAlignCardFooterText.css`,
enabled: false
},
{
name: '16:9 Episode Thumbnails on Season Page',
url: `${getKefinTweaksRoot()}skins/optional/global/episodeThumbnailSize.css`,
enabled: false
},
{
name: 'Hide Missing And Unaired Indicators On Home Screen',
url: `${getKefinTweaksRoot()}skins/optional/global/hideMissingAndUnairedIndicatorsOnHomeScreen.css`,
enabled: false
}
],
@@ -168,7 +178,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css',
`${getKefinTweaksRoot()}skins/elegant-kefin.css`,,
@@ -229,7 +239,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@latest/theme/neutralfin-minified.css',
`${getKefinTweaksRoot()}skins/neutralfin-kefin.css`,
@@ -300,7 +310,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@latest/Theme/GlassFin-Theme-latest-stable.css',
'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@latest/Theme/PluginStyling/MediaBar-Plugin-latest.css',
@@ -382,7 +392,7 @@
author: 'ya0903',
url: [
{
majorServerVersions: [10, 11],
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/ya0903/NetFin@main/netfin.css',
]
@@ -428,31 +438,38 @@
url: [ // TODO: Update this when we get the specific 10.10.X / 10.11.X versions
{
majorServerVersions: [10],
urls: ['https://cdn.jsdelivr.net/gh/ranaldsgift/Jellypane@main/jellypane.css']
urls: [
'https://cdn.jsdelivr.net/gh/ranaldsgift/Jellypane@main/jellypane.css',
`${getKefinTweaksRoot()}skins/jellypane-kefin.css`
]
},
{
majorServerVersions: [11],
urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane.css']
majorServerVersions: [11, 12],
urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane.css',
`${getKefinTweaksRoot()}skins/jellypane-kefin.css`
]
},
],
colorSchemes: []
},
{
name: 'Finimalism',
name: 'Finimalism 7',
author: 'tedhinklater',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism7.css',
`${getKefinTweaksRoot()}skins/finimalism-kefin.css`
`${getKefinTweaksRoot()}skins/finimalism-kefin.css`,
`${getKefinTweaksRoot()}skins/finimalism-7-kefin.css`
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism10.11.css',
`${getKefinTweaksRoot()}skins/finimalism-kefin.css`
`${getKefinTweaksRoot()}skins/finimalism-kefin.css`,
`${getKefinTweaksRoot()}skins/finimalism-7-kefin.css`
]
}
],
@@ -475,7 +492,7 @@
author: 'tedhinklater',
url: [
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/Finimalism11.css',
`${getKefinTweaksRoot()}skins/finimalism-11-kefin.css`
@@ -496,7 +513,7 @@
author: 'tedhinklater',
url: [
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/Finimalism12.css',
`${getKefinTweaksRoot()}skins/finimalism-12-kefin.css`
@@ -513,7 +530,7 @@
urls: ['https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css']
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css',
'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/10.11_fixes.css'
@@ -603,7 +620,7 @@
author: 'alexyle',
url: [
{
majorServerVersions: [10, 11],
majorServerVersions: [10, 11, 12],
urls: ['https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css']
}
],
@@ -714,7 +731,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css',
`${getKefinTweaksRoot()}skins/scyfin-kefin.css`
@@ -756,7 +773,7 @@
author: 'Catppuccin',
url: [
{
majorServerVersions: [10, 11],
majorServerVersions: [10, 11, 12],
urls: ['https://jellyfin.catppuccin.com/theme.css']
}
],
@@ -791,7 +808,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css',
'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Compatibility.css',
@@ -889,7 +906,7 @@
author: 'CTalvio',
url: [
{
majorServerVersions: [10, 11],
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/base.css',
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/accentlist.css',
@@ -1030,7 +1047,7 @@
author: 'everviolet',
url: [
{
majorServerVersions: [10, 11],
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/everviolet/jellyfin@latest/themes/evergarden-winter.css',
]
@@ -1072,7 +1089,7 @@
author: 'buesche87',
url: [
{
majorServerVersions: [10, 11],
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/buesche87/infinitv@latest/infinitv.css',
`${getKefinTweaksRoot()}skins/infinitv-kefin.css`
@@ -1148,7 +1165,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/theme.css',
]
@@ -1193,7 +1210,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/Tetrax-10/jellyfin-better-styles@dist/theme.css',
`${getKefinTweaksRoot()}skins/better-styles-kefin-11.css`
@@ -1213,7 +1230,7 @@
]
},
{
majorServerVersions: [11],
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/theme.css',
`${getKefinTweaksRoot()}skins/zesty-kefin.css`
@@ -1262,6 +1279,20 @@
}
]
},
{
//Abyss by ArumGupta
//@import url('https://cdn.jsdelivr.net/gh/AumGupta/abyss-jellyfin@main/abyss.css');
name: 'Abyss',
author: 'ArumGupta',
url: [
{
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/AumGupta/abyss-jellyfin@main/abyss.css',
]
},
],
},
// Add more skins here as needed
]
};
+104 -125
View File
@@ -25,6 +25,12 @@
function getMajorServerVersion(version) {
const versionParts = version.split('.');
if (versionParts[0] !== '10') {
const majorVersion = parseInt(versionParts[0], 10);
return majorVersion;
}
if (versionParts.length >= 2) {
const majorVersion = parseInt(versionParts[1], 10);
if (!isNaN(majorVersion)) {
@@ -90,12 +96,15 @@
// New structure: array of objects with majorServerVersions and urls
if (Array.isArray(skin.url) && skin.url.length > 0 && typeof skin.url[0] === 'object' && skin.url[0].majorServerVersions) {
// Find all URL objects that match the current server version
// Find all URL objects that match the current server version.
// majorServerVersions including 12 means any major version >= 12.
const matchingUrlObjects = skin.url.filter(urlObj => {
if (!urlObj.majorServerVersions || !Array.isArray(urlObj.majorServerVersions)) {
return false;
}
return currentMajorVersion !== null && urlObj.majorServerVersions.includes(currentMajorVersion);
if (currentMajorVersion === null) return false;
if (urlObj.majorServerVersions.includes(currentMajorVersion)) return true;
return urlObj.majorServerVersions.includes(12) && currentMajorVersion >= 12;
});
if (matchingUrlObjects.length === 0) {
@@ -145,11 +154,18 @@
}
return skins.filter(skin => {
const matchesMajorVersions = (versions) => {
if (!versions || !Array.isArray(versions)) return false;
if (versions.includes(currentMajorVersion)) return true;
// 12 is a floor: configs that include 12 apply to any major >= 12
return versions.includes(12) && currentMajorVersion >= 12;
};
// Check if skin has URL configuration
if (!skin.url) {
// Skin with no URL (like Default) - check old majorServerVersions field for backward compatibility
if (skin.majorServerVersions && Array.isArray(skin.majorServerVersions)) {
const isSupported = skin.majorServerVersions.includes(currentMajorVersion);
const isSupported = matchesMajorVersions(skin.majorServerVersions);
if (!isSupported) {
LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion} (supports: ${skin.majorServerVersions.join(', ')})`);
}
@@ -162,12 +178,9 @@
// New structure: check majorServerVersions in URL objects
if (Array.isArray(skin.url) && skin.url.length > 0 && typeof skin.url[0] === 'object' && skin.url[0].majorServerVersions) {
// Check if any URL object supports the current version
const hasMatchingVersion = skin.url.some(urlObj => {
if (!urlObj.majorServerVersions || !Array.isArray(urlObj.majorServerVersions)) {
return false;
}
return urlObj.majorServerVersions.includes(currentMajorVersion);
});
const hasMatchingVersion = skin.url.some(urlObj =>
matchesMajorVersions(urlObj.majorServerVersions)
);
if (!hasMatchingVersion) {
LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion}`);
@@ -177,7 +190,7 @@
// Old structure: check top-level majorServerVersions field (backward compatibility)
if (skin.majorServerVersions && Array.isArray(skin.majorServerVersions)) {
const isSupported = skin.majorServerVersions.includes(currentMajorVersion);
const isSupported = matchesMajorVersions(skin.majorServerVersions);
if (!isSupported) {
LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion} (supports: ${skin.majorServerVersions.join(', ')})`);
}
@@ -353,43 +366,23 @@
WARN('ApiClient not available, cannot create backup');
return false;
}
const server = window.ApiClient._serverAddress;
const token = window.ApiClient.accessToken();
// Find JavaScript Injector plugin
const pluginsResponse = await fetch(`${server}/Plugins`, {
headers: { 'X-Emby-Token': token }
});
if (!pluginsResponse.ok) {
WARN(`Failed to get plugins for backup: ${pluginsResponse.statusText}`);
if (!window.KefinTweaksUtils?.resolvePluginId || !window.KefinTweaksUtils?.getPluginConfiguration) {
WARN('KefinTweaksUtils plugin helpers not available, cannot create backup');
return false;
}
const pluginsData = await pluginsResponse.json();
const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []);
const plugin = pluginsList.find(p => p.Name === 'JavaScript Injector' || p.Name === 'JS Injector');
if (!plugin) {
const server = window.ApiClient._serverAddress;
const injectorAliases = ['JavaScript Injector', 'JS Injector'];
const pluginId = await window.KefinTweaksUtils.resolvePluginId(injectorAliases);
if (!pluginId) {
WARN('JavaScript Injector plugin not found, cannot create backup');
return false;
}
const pluginId = plugin.Id;
// Get current injector config
const injectorConfig = await window.KefinTweaksUtils.getPluginConfiguration(injectorAliases);
const configUrl = `${server}/Plugins/${pluginId}/Configuration`;
const configResponse = await fetch(configUrl, {
headers: { 'X-Emby-Token': token }
});
if (!configResponse.ok) {
WARN(`Failed to get plugin config for backup: ${configResponse.statusText}`);
return false;
}
const injectorConfig = await configResponse.json();
// Ensure CustomJavaScripts array exists
if (!injectorConfig.CustomJavaScripts) {
@@ -420,7 +413,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
const saveResponse = await fetch(configUrl, {
method: 'POST',
headers: {
'X-Emby-Token': token,
'Authorization': window.apiHelper.getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify(injectorConfig)
@@ -618,6 +611,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
window.KefinTweaksSkinManager = window.KefinTweaksSkinManager || {};
window.KefinTweaksSkinManager.getSkinSourceInfo = getSkinSourceInfo;
window.KefinTweaksSkinManager.getAllSkinSources = getAllSkinSources;
window.KefinTweaksSkinManager.toggleAppearancePopover = toggleAppearancePopover;
// Load and merge theme configurations
async function loadThemeConfig() {
@@ -1043,7 +1037,6 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
// Store unregister functions for handlers
let unregisterDisplayPreferencesHandler = null;
let unregisterAnyPageHandler = null;
/**
* Create and show a tooltip
@@ -1197,16 +1190,36 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
return true;
}
/**
* Inject one-off skin manager UI styles.
*/
function ensureSkinManagerStyles() {
if (document.getElementById('kefin-skin-manager-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-skin-manager-styles';
style.textContent = `
#displayPreferencesPage:has(.MuiStack-root) .fldSkin {
margin-top: 2em;
margin-bottom: -1.8em;
}
`;
(document.head || document.documentElement).appendChild(style);
}
/**
* Initialize the skin manager with verification and retry logic
*/
function initialize(retryCount = 0) {
async function initialize(retryCount = 0) {
const MAX_RETRIES = 10;
if (!window.KefinTweaksUtils) {
ERR('KefinTweaksUtils not available - skin manager cannot initialize');
return;
}
ensureSkinManagerStyles();
await window.KefinTweaksUtils.waitForApiClient();
// Load and merge skin configurations
loadSkinConfig();
@@ -1242,7 +1255,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
}
/**
* Register the onViewPage handlers
* Register the onViewPage handlers and Appearance header control
*/
function registerHandlers() {
// Only register if not already registered
@@ -1255,11 +1268,8 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
unregisterDisplayPreferencesHandler = window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, {
pages: ['mypreferencesdisplay']
});
// Register for all pages to add the appearance button
unregisterAnyPageHandler = window.KefinTweaksUtils.onViewPage(handleAnyPage, {
pages: []
});
registerAppearanceMenuLink();
LOG('SkinManager handlers registered');
}
@@ -1282,72 +1292,36 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
loadSelectedTheme();
}, 100);
}
/**
* Handle any page to add the appearance button
* @param {string} view - The view name
* @param {Element} element - The view element
* Register Appearance in header-right via custom menu links
*/
function handleAnyPage(view, element) {
// Skip the display preferences page as it's handled separately
if (view === 'mypreferencesdisplay') {
async function registerAppearanceMenuLink() {
if (!window.KefinTweaksUtils?.addCustomMenuLink) {
WARN('addCustomMenuLink not available; Appearance button not registered');
return;
}
// Wait for the page to be fully loaded
setTimeout(() => {
addAppearanceButton();
}, 100);
}
/**
* Add the appearance button to the header
*/
function addAppearanceButton() {
const headerRight = document.querySelector('.headerRight');
if (!headerRight) {
WARN('Header right section not found');
return;
}
// Check if appearance button already exists
if (headerRight.querySelector('.headerAppearanceButton')) {
LOG('Appearance button already exists');
return;
}
// Create the appearance button
const appearanceButton = document.createElement('button');
appearanceButton.type = 'button';
appearanceButton.setAttribute('is', 'paper-icon-button-light');
appearanceButton.className = 'headerButton headerButtonRight headerAppearanceButton paper-icon-button-light';
appearanceButton.title = 'Appearance';
// Create the icon span
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons palette';
iconSpan.setAttribute('aria-hidden', 'true');
appearanceButton.appendChild(iconSpan);
// Add click event listener
appearanceButton.addEventListener('click', toggleAppearancePopover);
// Insert before the search button (or at the end if no search button)
const searchButton = headerRight.querySelector('.headerSearchButton');
if (searchButton) {
headerRight.insertBefore(appearanceButton, searchButton);
} else {
headerRight.appendChild(appearanceButton);
}
LOG('Appearance button added successfully');
// Unregister the handleAnyPage handler since button is now added and persistent
if (unregisterAnyPageHandler) {
unregisterAnyPageHandler();
unregisterAnyPageHandler = null;
LOG('Unregistered handleAnyPage handler - button is now persistent');
window.KefinTweaksSkinManager = window.KefinTweaksSkinManager || {};
window.KefinTweaksSkinManager.toggleAppearancePopover = toggleAppearancePopover;
try {
await window.KefinTweaksUtils.addCustomMenuLink(
'Appearance',
'palette',
'',
false,
{
sideMenu: false,
topNavigation: 'right',
userMenu: false,
action: 'KefinTweaksSkinManager.toggleAppearancePopover',
order: 1
}
);
LOG('Appearance menu link registered (topNavigation: right)');
} catch (err) {
ERR('Failed to register Appearance menu link:', err);
}
}
@@ -1355,8 +1329,10 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
* Toggle the appearance popover
*/
function toggleAppearancePopover(event) {
event.preventDefault();
event.stopPropagation();
if (event) {
event.preventDefault();
event.stopPropagation();
}
// Remove existing popover if it exists
const existingPopover = document.querySelector('.kefin-appearance-popover');
@@ -1364,9 +1340,15 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
existingPopover.remove();
return;
}
const anchor = (event && (
event.currentTarget
|| event.target?.closest?.('[data-kefin-custom-menu-top-right], .headerAppearanceButton')
|| event.target
)) || document.querySelector('[data-kefin-custom-menu-top-right], .headerAppearanceButton');
// Create the popover
createAppearancePopover(event.target);
createAppearancePopover(anchor);
}
/**
@@ -2186,7 +2168,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
if (backdrop && dialogContainer &&
!dialogContainer.contains(event.target) &&
!event.target.closest('.headerAppearanceButton')) {
!event.target.closest('.headerAppearanceButton, [data-kefin-custom-menu-top-right]')) {
// Remove backdrop and dialog container
if (backdrop.parentNode) {
@@ -2216,7 +2198,8 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
return;
}
const displayModeField = form.querySelector('.fldDisplayMode');
// .fldDisplayMode or #display-settings-layout-description
const displayModeField = form.querySelector('.fldDisplayMode') || form.querySelector('#display-settings-layout-description');
if (!displayModeField) {
WARN('Display mode field not found');
return;
@@ -2846,20 +2829,13 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
return;
}
// Use requestAnimationFrame to avoid blocking
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
removeAllSkinCSS();
// Unload skin-specific optional includes for the current skin (if switching)
if (currentSkinName) {
unloadOptionalIncludesForSkin(currentSkinName);
}
});
// Performance optimization: Load new CSS first, then remove old CSS
// This prevents the double-reflow/recalc that causes UI freezing
// Step 1: Load the new CSS first (gets cached and starts loading)
requestAnimationFrame(async () => {
const cssUrls = await getSkinUrlsForCurrentVersion(skin);
if (cssUrls && cssUrls.length > 0) {
@@ -2873,6 +2849,9 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
// Load optional includes for this skin
loadOptionalIncludes(skin);
window.cardBuilder?.invalidateCardRadiusCache?.();
requestAnimationFrame(() => window.cardBuilder?.ensureCardRadiusCssVar?.());
});
}
+7 -14
View File
@@ -331,14 +331,13 @@
throw new Error('ApiClient not available for authorization');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${language}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -418,14 +417,13 @@
throw new Error('ApiClient not available');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -505,14 +503,13 @@
throw new Error('ApiClient not available');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -569,7 +566,6 @@
throw new Error('ApiClient not available');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const deviceId = ApiClient.deviceId();
@@ -588,7 +584,7 @@
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -625,7 +621,6 @@
throw new Error('ApiClient not available');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const userId = ApiClient.getCurrentUserId();
@@ -638,7 +633,7 @@
const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Command`, {
method: 'POST',
headers: {
'X-Emby-Token': token,
'Authorization': window.apiHelper.getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -758,7 +753,6 @@
throw new Error('No play state found in session');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
// Get current item ID
@@ -788,7 +782,7 @@
const commandResponse = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
method: 'POST',
headers: {
'X-Emby-Token': token
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -897,13 +891,12 @@
// Get existing subtitle streams before download
const existingStreams = await getExistingSubtitleStreams(itemId);
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Items/${itemId}/RemoteSearch/Subtitles/${subId}`, {
method: 'POST',
headers: {
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': window.apiHelper.getAuthHeader()
}
});
-16
View File
@@ -1,16 +0,0 @@
/**
* Flurry jQuery Plugin
*
* Flurry is an easy-to-use animated snow plugin for jQuery. It takes advantage
* of CSS transforms, CSS transitions and requestAnimationFrame to provide
* smooth animation for modern browsers. Props to Jonathan Nicol @f6design
* for boilerplate code
* (see http://jonathannicol.com/blog/2012/05/06/a-jquery-plugin-boilerplate/)
*
* @link https://github.com/joshmcrty/Flurry
* @version 1.2.0
* @author Josh McCarty <josh@joshmccarty.com>
* @copyright 2016 Josh McCarty
* @license https://github.com/joshmcrty/Flurry/blob/master/LICENSE GPLv2
*/
!function(n,e){!function(){for(var n=0,e=["ms","moz","webkit","o"],a=0;a<e.length&&!window.requestAnimationFrame;++a)window.requestAnimationFrame=window[e[a]+"RequestAnimationFrame"],window.cancelAnimationFrame=window[e[a]+"CancelAnimationFrame"]||window[e[a]+"CancelRequestAnimationFrame"];window.requestAnimationFrame||(window.requestAnimationFrame=function(e,a){var t=(new Date).getTime(),i=Math.max(0,16-Math.abs(t-n)),r=window.setTimeout(function(){e(t+i)},i);return n=t+i,r}),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(n){clearTimeout(n)})}();var a="flurry";function t(n,e){return Math.floor(Math.random()*(e-n+1)+n)}function i(a,i,r){var o=1===a.character.length?Array.isArray(a.character)?a.character[0]:a.character:Array.isArray(a.character)?a.character[t(0,a.character.length-1)]:Array.from(a.character)[t(0,a.character.length-1)];"string"==typeof a.character&&console&&console.warn&&console.warn("You are using a string for the Flurry character options. Please use an array of string(s) instead.");var s=t(-Math.abs(a.wind),r+Math.abs(a.wind)),c=s+t(a.wind-a.windVariance,a.wind+a.windVariance),l=t(a.small,a.large),u=a.speed/((t(1.2*l,.8*l)-a.small)/(a.large-a.small)+.5),m=a.height-l,w=t(a.rotation-a.rotationVariance,a.rotation+a.rotationVariance),d=Array.isArray(a.color)?a.color[Math.floor(Math.random()*a.color.length)]:a.color,h={transform:"translateX("+c+"px) translateY("+m+"px) rotateZ("+w+"deg)",opacity:0},f=n("<span></span>");f.html(o).css({color:a.blur&&l<(a.large+a.small)/2?"transparent":d,"text-shadow":a.blur&&l<(a.large+a.small)/2?"0 0 1px "+d:"none",display:"inline-block","line-height":1,margin:0,padding:"2px","pointer-events":"none","font-size":l+"px",opacity:a.startTransparency,position:"absolute",top:"-"+1.2*a.large+"px",transform:"translateX("+s+"px) translateY(0px) rotateZ("+a.startRotation+"deg)",transition:"transform "+u/1e3+"s linear, opacity "+u/1e3+"s "+a.opacityEasing,"z-index":a.zIndex}),a.onFlake!==e&&a.onFlake.call(f[0]),f.appendTo(i),!function(){var n=(document.body||document.documentElement).style;return n.transition!==e||n.WebkitTransition!==e||n.MozTransition!==e||n.MsTransition!==e||n.OTransition!==e}?f.animate(h,u,"linear",function(){n(this).remove()}):(f.on("transitionend.flurry",function(e){n(e.target).remove()}),window.requestAnimationFrame(function(){f.css(h)}))}function r(t,r){var o=this,s=t,c=n(t);function l(n){r[n]!==e&&r[n].call(s)}return r=n.extend({height:c.height()>200?200:c.height(),useRelative:!c.is("body")},n.fn[a].defaults,r),n.each(r,function(n,e){parseInt(e)&&(r[n]=parseInt(e))}),function(){!0===r.useRelative&&"static"===c.css("position")&&c.css({position:"relative"});var e,a,t,s,u=n(document.createElement("div")).addClass("flurry-container").css({margin:0,padding:0,position:"absolute",top:0,right:0,left:0,height:r.height,overflow:r.overflow,"pointer-events":"none"}).prependTo(c),m=u.width();n(window).resize(function(){m=u.width()}),o.flakeInterval=(e=function(){i(r,u,m)},a=r.frequency,t=(new Date).getTime(),(s=new Object).value=window.requestAnimationFrame(function n(){(new Date).getTime()-t>=a&&(e.call(),t=(new Date).getTime()),s.value=window.requestAnimationFrame(n)}),s),l("onInit")}(),{option:function(n,e){if(!e)return r[n];r[n]=parseInt(e)||e},destroy:function(){c.each(function(){var e,t=n(this);e=o.flakeInterval,window.cancelAnimationFrame?window.cancelAnimationFrame(e.value):window.webkitCancelAnimationFrame?window.webkitCancelAnimationFrame(e.value):window.webkitCancelRequestAnimationFrame?window.webkitCancelRequestAnimationFrame(e.value):window.mozCancelRequestAnimationFrame?window.mozCancelRequestAnimationFrame(e.value):window.oCancelRequestAnimationFrame?window.oCancelRequestAnimationFrame(e.value):window.msCancelRequestAnimationFrame?window.msCancelRequestAnimationFrame(e.value):clearInterval(e),t.find(".flurry-container").remove(),l("onDestroy"),t.removeData("plugin_"+a)})}}}n.fn[a]=function(t){if("string"==typeof arguments[0]){var i,o=arguments[0],s=Array.prototype.slice.call(arguments,1);return this.each(function(){if(!n.data(this,"plugin_"+a)||"function"!=typeof n.data(this,"plugin_"+a)[o])throw new Error("Method "+o+" does not exist on jQuery."+a);i=n.data(this,"plugin_"+a)[o].apply(this,s)}),i!==e?i:this}if("object"==typeof t||!t)return this.each(function(){n.data(this,"plugin_"+a)||n.data(this,"plugin_"+a,new r(this,t))})},n.fn[a].defaults={onInit:e,onDestroy:e,onFlake:e,character:["❄"],color:"white",frequency:100,speed:3e3,small:8,large:28,wind:40,windVariance:20,rotation:90,rotationVariance:180,startRotation:0,startOpacity:1,endOpacity:0,opacityEasing:"cubic-bezier(1,.3,.6,.74)",blur:!1,overflow:"hidden",zIndex:9999}}(jQuery);
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 - present Simon Reinisch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -0,0 +1,2 @@
/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */
.pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;border-radius:.15em;background:url("data:image/svg+xml;utf8, <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 50 50\" stroke=\"%2342445A\" stroke-width=\"5px\" stroke-linecap=\"round\"><path d=\"M45,45L5,5\"></path><path d=\"M45,5L5,45\"></path></svg>") no-repeat center;background-size:0;transition:all .3s}.pickr .pcr-button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 2 2\"><path fill=\"white\" d=\"M1,0H2V1H1V0ZM0,1H1V2H0V1Z\"/><path fill=\"gray\" d=\"M0,0H1V1H0V0ZM1,1H2V2H1V1Z\"/></svg>");background-size:.5em;border-radius:.15em;z-index:-1}.pickr .pcr-button::before{z-index:initial}.pickr .pcr-button::after{position:absolute;content:"";top:0;left:0;height:100%;width:100%;transition:background .3s;background:var(--pcr-color);border-radius:.15em}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear::before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-button.disabled{cursor:not-allowed}.pickr *,.pcr-app *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr input:focus,.pickr input.pcr-active,.pickr button:focus,.pickr button.pcr-active,.pcr-app input:focus,.pcr-app input.pcr-active,.pcr-app button:focus,.pcr-app button.pcr-active{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-palette,.pickr .pcr-slider,.pcr-app .pcr-palette,.pcr-app .pcr-slider{transition:box-shadow .3s}.pickr .pcr-palette:focus,.pickr .pcr-slider:focus,.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:flex;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports(display: grid){.pcr-app .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit, 1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;transition:all .15s;overflow:hidden;background:rgba(0,0,0,0);z-index:1}.pcr-app .pcr-swatches>button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 2 2\"><path fill=\"white\" d=\"M1,0H2V1H1V0ZM0,1H1V2H0V1Z\"/><path fill=\"gray\" d=\"M0,0H1V1H0V0ZM1,1H2V2H1V1Z\"/></svg>");background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button::after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--pcr-color);border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app .pcr-swatches>button:not(.pcr-active){box-shadow:none}.pcr-app .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -0.2em 0 -0.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{filter:brightness(0.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff;width:auto}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff}.pcr-app .pcr-interaction .pcr-save:hover,.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-inLine truncated
+2
View File
@@ -0,0 +1,2 @@
/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pickr=e():t.Pickr=e()}(self,(()=>(()=>{"use strict";var t={d:(e,o)=>{for(var n in o)t.o(o,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:o[n]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};t.d(e,{default:()=>E});var o={};function n(t,e,o,n,i={}){e instanceof HTMLCollection||e instanceof NodeList?e=Array.from(e):Array.isArray(e)||(e=[e]),Array.isArray(o)||(o=[o]);for(const s of e)for(const e of o)s[t](e,n,{capture:!1,...i});return Array.prototype.slice.call(arguments,1)}t.r(o),t.d(o,{adjustableInputNumbers:()=>p,createElementFromString:()=>r,createFromTemplate:()=>a,eventPath:()=>l,off:()=>s,on:()=>i,resolveElement:()=>c});const i=n.bind(null,"addEventListener"),s=n.bind(null,"removeEventListener");function r(t){const e=document.createElement("div");return e.innerHTML=t.trim(),e.firstElementChild}function a(t){const e=(t,e)=>{const o=t.getAttribute(e);return t.removeAttribute(e),o},o=(t,n={})=>{const i=e(t,":obj"),s=e(t,":ref"),r=i?n[i]={}:n;s&&(n[s]=t);for(const n of Array.from(t.children)){const t=e(n,":arr"),i=o(n,t?{}:r);t&&(r[t]||(r[t]=[])).push(Object.keys(i).length?i:n)}return n};return o(r(t))}function l(t){let e=t.path||t.composedPath&&t.composedPath();if(e)return e;let o=t.target.parentElement;for(e=[t.target,o];o=o.parentElement;)e.push(o);return e.push(document,window),e}function c(t){return t instanceof Element?t:"string"==typeof t?t.split(/>>/g).reduce(((t,e,o,n)=>(t=t.querySelector(e),o<n.length-1?t.shadowRoot:t)),document):null}function p(t,e=(t=>t)){function o(o){const n=[.001,.01,.1][Number(o.shiftKey||2*o.ctrlKey)]*(o.deltaY<0?1:-1);let i=0,s=t.selectionStart;t.value=t.value.replace(/[\d.]+/g,((t,o)=>o<=s&&o+t.length>=s?(s=o,e(Number(t),n,i)):(i++,t))),t.focus(),t.setSelectionRange(s,s),o.preventDefault(),t.dispatchEvent(new Event("input"))}i(t,"focus",(()=>i(window,"wheel",o,{passive:!1}))),i(t,"blur",(()=>s(window,"wheel",o)))}const{min:u,max:h,floor:d,round:m}=Math;function f(t,e,o){e/=100,o/=100;const n=d(t=t/360*6),i=t-n,s=o*(1-e),r=o*(1-i*e),a=o*(1-(1-i)*e),l=n%6;return[255*[o,r,s,s,a,o][l],255*[a,o,o,r,s,s][l],255*[s,s,a,o,o,r][l]]}function v(t,e,o){const n=(2-(e/=100))*(o/=100)/2;return 0!==n&&(e=1===n?0:n<.5?e*o/(2*n):e*o/(2-2*n)),[t,100*e,100*n]}function b(t,e,o){const n=u(t/=255,e/=255,o/=255),i=h(t,e,o),s=i-n;let r,a;if(0===s)r=a=0;else{a=s/i;const n=((i-t)/6+s/2)/s,l=((i-e)/6+s/2)/s,c=((i-o)/6+s/2)/s;t===i?r=c-l:e===i?r=1/3+n-c:o===i&&(r=2/3+l-n),r<0?r+=1:r>1&&(r-=1)}return[360*r,100*a,100*i]}function y(t,e,o,n){e/=100,o/=100;return[...b(255*(1-u(1,(t/=100)*(1-(n/=100))+n)),255*(1-u(1,e*(1-n)+n)),255*(1-u(1,o*(1-n)+n)))]}function g(t,e,o){e/=100;const n=2*(e*=(o/=100)<.5?o:1-o)/(o+e)*100,i=100*(o+e);return[t,isNaN(n)?0:n,i]}function _(t){return b(...t.match(/.{2}/g).map((t=>parseInt(t,16))))}function w(t){t=t.match(/^[a-zA-Z]+$/)?function(t){if("black"===t.toLowerCase())return"#000";const e=document.createElement("canvas").getContext("2d");return e.fillStyle=t,"#000"===e.fillStyle?null:e.fillStyle}(t):t;const e={cmyk:/^cmyk\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)/i,rgba:/^rgba?\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D*?(([\d.]+)(%?)|$)/i,hsla:/^hsla?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hsva:/^hsva?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hexa:/^#?(([\dA-Fa-f]{3,4})|([\dA-Fa-f]{6})|([\dA-Fa-f]{8}))$/i},o=t=>t.map((t=>/^(|\d+)\.\d+|\d+$/.test(t)?Number(t):void 0));let n;t:for(const i in e)if(n=e[i].exec(t))switch(i){case"cmyk":{const[,t,e,s,r]=o(n);if(t>100||e>100||s>100||r>100)break t;return{values:y(t,e,s,r),type:i}}case"rgba":{let[,t,,e,,s,,,r]=o(n);if(t="%"===n[2]?t/100*255:t,e="%"===n[4]?e/100*255:e,s="%"===n[6]?s/100*255:s,r="%"===n[9]?r/100:r,t>255||e>255||s>255||r<0||r>1)break t;return{values:[...b(t,e,s),r],a:r,type:i}}case"hexa":{let[,t]=n;4!==t.length&&3!==t.length||(t=t.split("").map((t=>t+t)).join(""));const e=t.substring(0,6);let o=t.substring(6);return o=o?parseInt(o,16)/255:void 0,{values:[..._(e),o],a:o,type:i}}case"hsla":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[...g(t,e,s),r],a:r,type:i}}case"hsva":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[t,e,s,r],a:r,type:i}}}return{values:null,type:null}}function A(t=0,e=0,o=0,n=1){const i=(t,e)=>(o=-1)=>e(~o?t.map((t=>Number(t.toFixed(o)))):t),s={h:t,s:e,v:o,a:n,toHSVA(){const t=[s.h,s.s,s.v,s.a];return t.toString=i(t,(t=>`hsva(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toHSLA(){const t=[...v(s.h,s.s,s.v),s.a];return t.toString=i(t,(t=>`hsla(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toRGBA(){const t=[...f(s.h,s.s,s.Line truncated
+378 -15
View File
@@ -19,6 +19,12 @@
const VIDEO_TYPES = ['Movie', 'Episode'];
/** When true (default), hover preview uses video stream instead of trickplay images. */
const USE_VIDEO_STREAM_DEFAULT = true;
/** Ms to wait for static stream start before fallback (0 = timeout disabled). */
const FALLBACK_AFTER_MS_DEFAULT = 500;
/** Ticks are 100-nanosecond units; 10_000 ticks = 1 ms */
const TICKS_PER_MS = 10_000;
@@ -115,7 +121,7 @@
box-shadow: 0 2px 12px rgba(0,0,0,0.5);
background: #000;
background-repeat: no-repeat;
border: 1px solid rgba(255, 255, 255, 0.85);
border: 3px solid rgba(255, 255, 255, 0.85);
}
.kefin-scrubber-timestamp {
margin-top: 4px; padding: 2px 8px;
@@ -161,18 +167,69 @@
pointer-events: none;
z-index: 18;
}
/* Video stream preview: sibling before cardImageContainer, overlays image area */
.kefin-hover-preview-sibling {
position: absolute;
overflow: hidden;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.kefin-hover-preview-sibling .kefin-hover-preview-box {
position: relative;
left: auto;
right: auto;
top: auto;
transform: none;
margin: 0;
}
.kefin-hover-preview-sibling video.kefin-hover-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
display: block;
}
.cardImageContainer .kefin-hover-preview-frame {
width: 100%;
height: 100%;
background-repeat: no-repeat;
}
.cardImageContainer .kefin-hover-preview-box video.kefin-hover-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
display: block;
}
.cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayContainer,
.cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayContainer {
background: none !important;
pointer-events: none;
transition: background 0.3s ease-in-out !important;
}
.cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayButton[data-action="resume"],
.cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayButton[data-action="resume"] {
display: none !important;
}
.cardScalable:has(.kefin-hover-preview-sibling) .cardOverlayButton[data-action="resume"],
.cardScalable:has(.kefin-hover-preview-frame[style]:not([style=""])) .cardOverlayButton[data-action="resume"] {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
}
`;
document.head.appendChild(style);
}
/**
* Read thumbnail scrubber configuration from global config.
* Returns { previewOnHover: boolean, hoverTimeoutMs: number, scrubActivationDelayMs: number, hoverPreviewFrameMs?: number }.
* Returns { previewOnHover, hoverTimeoutMs, scrubActivationDelayMs, hoverPreviewFrameMs?, useVideoStream, fallbackAfterMs }.
*/
function getThumbnailScrubberConfig() {
const root = (window.KefinTweaksConfig && window.KefinTweaksConfig.thumbnailScrubber) || {};
@@ -195,11 +252,24 @@
? root.HOVER_PREVIEW_FRAME_MS * 1
: null;
// UseVideoStream defaults to true when unset.
const useVideoStream = root.UseVideoStream !== false;
// Prefer FallbackAfterMs; migrate legacy FallbackAfterSeconds if present.
let fallbackAfterMs = FALLBACK_AFTER_MS_DEFAULT;
if (Number.isFinite(root.FallbackAfterMs * 1) && root.FallbackAfterMs >= 0) {
fallbackAfterMs = root.FallbackAfterMs * 1;
} else if (Number.isFinite(root.FallbackAfterSeconds * 1) && root.FallbackAfterSeconds >= 0) {
fallbackAfterMs = root.FallbackAfterSeconds * 1000;
}
return {
previewOnHover: previewOnHover ?? HOVER_PREVIEW_DEFAULT_ENABLED,
hoverTimeoutMs,
scrubActivationDelayMs,
hoverPreviewFrameMs
hoverPreviewFrameMs,
useVideoStream: useVideoStream ?? USE_VIDEO_STREAM_DEFAULT,
fallbackAfterMs
};
}
@@ -212,6 +282,26 @@
}
}
function isUseVideoStreamEnabled() {
try {
return !!getThumbnailScrubberConfig().useVideoStream;
} catch (e) {
WARN('Error reading use video stream config', e);
return USE_VIDEO_STREAM_DEFAULT;
}
}
function getFallbackAfterMs() {
try {
const ms = getThumbnailScrubberConfig().fallbackAfterMs;
if (!Number.isFinite(ms) || ms <= 0) return 0;
return Math.round(ms);
} catch (e) {
WARN('Error reading fallback after config', e);
return FALLBACK_AFTER_MS_DEFAULT;
}
}
function getHoverPreviewTimeoutMs() {
try {
return getThumbnailScrubberConfig().hoverTimeoutMs || HOVER_PREVIEW_DEFAULT_TIMEOUT_MS;
@@ -241,7 +331,73 @@
}
/**
* Format positionTicks as running time (MM:SS or HH:MM:SS).
* Get Jellyfin API token for stream URLs. Uses ApiClient when available, else localStorage credentials.
*/
function getJellyfinToken() {
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken) {
return ApiClient.accessToken() || null;
}
try {
const creds = localStorage.getItem('jellyfin_credentials');
if (!creds) return null;
const parsed = JSON.parse(creds);
const server = parsed.Servers && parsed.Servers[0];
return server ? server.AccessToken || null : null;
} catch {
return null;
}
}
/**
* Resolve playable video id for Series/Season (first episode). Returns itemId for Movie/Episode.
*/
async function getFirstEpisodeId(itemId, itemType) {
if (itemType !== 'Series' && itemType !== 'Season') return itemId;
const token = getJellyfinToken();
if (!token) return null;
const baseUrl = typeof ApiClient !== 'undefined' && ApiClient.serverAddress
? ApiClient.serverAddress()
: window.location.origin;
try {
let seriesId = itemId;
let seasonId = itemId;
if (itemType === 'Series') {
const seasonsResp = await fetch(`${baseUrl}/Shows/${itemId}/Seasons`, {
headers: {
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!seasonsResp.ok) return null;
const seasons = await seasonsResp.json();
const firstSeason = (seasons.Items || []).find(s => s.IndexNumber === 1) || (seasons.Items || [])[0];
if (!firstSeason) return null;
seasonId = firstSeason.Id;
} else {
const itemResp = await fetch(`${baseUrl}/Items/${itemId}`, {
headers: {
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!itemResp.ok) return null;
const item = await itemResp.json();
seriesId = item.SeriesId || item.ParentId || itemId;
}
const episodesResp = await fetch(`${baseUrl}/Shows/${seriesId}/Episodes?seasonId=${seasonId}`, {
headers: {
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!episodesResp.ok) return null;
const episodes = await episodesResp.json();
const firstEpisode = (episodes.Items || []).find(e => e.IndexNumber === 1) || (episodes.Items || [])[0];
return firstEpisode ? firstEpisode.Id : null;
} catch (e) {
WARN('getFirstEpisodeId failed', itemId, e);
return null;
}
}
/**
* Uses Jellyfin's datetime.getDisplayRunningTime if available.
*/
function formatRunningTime(positionTicks) {
@@ -692,9 +848,58 @@
}
}
function clearPreviewFallbackTimer(state) {
if (!state) return;
if (state._previewFallbackTimer) {
clearTimeout(state._previewFallbackTimer);
state._previewFallbackTimer = null;
}
}
async function fallbackVideoPreviewToTrickplay(state, startTimeTicks) {
if (!state || !currentCardState || currentCardState !== state) return;
clearPreviewFallbackTimer(state);
try {
if (state._videoPreviewEl) {
try {
state._videoPreviewEl.pause();
state._videoPreviewEl.removeAttribute('src');
state._videoPreviewEl.load();
} catch (e) { /* ignore */ }
if (state._videoPreviewEl.parentNode) {
state._videoPreviewEl.parentNode.removeChild(state._videoPreviewEl);
}
}
} catch (e) { /* ignore */ }
state._videoPreviewEl = null;
if (state._videoPreviewWrapper && state._videoPreviewWrapper.parentNode) {
try {
state._videoPreviewWrapper.parentNode.removeChild(state._videoPreviewWrapper);
} catch (e) { /* ignore */ }
}
state._videoPreviewWrapper = null;
await ensureScrubberDataForHover(state);
if (!currentCardState || currentCardState !== state) return;
if (!state.scrubberData) {
// No trickplay: scrub stays disabled via data-no-trickplay, but video hover can retry next time.
state.hoverPreviewRunning = false;
return;
}
state.hoverPreviewPositionTicks = startTimeTicks || 0;
state.hoverPreviewFrameAccumMs = 0;
// hoverPreviewRunning was set before calling startVideoStreamPreview
startHoverPreviewLoop();
}
function stopHoverPreview() {
if (!currentCardState) return;
const state = currentCardState;
clearPreviewFallbackTimer(state);
if (state.hoverPreviewTimer) {
clearTimeout(state.hoverPreviewTimer);
state.hoverPreviewTimer = null;
@@ -708,6 +913,22 @@
clearInterval(hoverPreviewIntervalId);
hoverPreviewIntervalId = null;
}
if (state._videoPreviewEl) {
try {
state._videoPreviewEl.pause();
state._videoPreviewEl.removeAttribute('src');
state._videoPreviewEl.load();
} catch (e) { /* ignore */ }
state._videoPreviewEl = null;
}
if (state._videoPreviewWrapper) {
try {
if (state._videoPreviewWrapper.parentNode) {
state._videoPreviewWrapper.parentNode.removeChild(state._videoPreviewWrapper);
}
} catch (e) { /* ignore */ }
state._videoPreviewWrapper = null;
}
if (state.cardImageContainer) {
const box = state.cardImageContainer.querySelector('.kefin-hover-preview-box');
if (box && box._frame) {
@@ -725,10 +946,15 @@
function teardown() {
if (!currentCardState) return;
const state = currentCardState;
if (state._cardMouseLeaveHandler && state.card) {
state.card.removeEventListener('mouseleave', state._cardMouseLeaveHandler);
state._cardMouseLeaveHandler = null;
}
clearActivationTimer();
stopHoverPreview();
if (currentCardState.overlay) {
const overlay = currentCardState.overlay;
if (state.overlay) {
const overlay = state.overlay;
overlay.classList.remove('is-visible', 'is-zone-active', 'is-popover-open');
if (overlay._progressFill) overlay._progressFill.style.width = '0%';
overlay._popoverOpen = false;
@@ -741,6 +967,111 @@
}
}
/**
* When video stream preview is enabled: show live video stream in a sibling div immediately before cardImageContainer.
* Starts with static=true; on timeout or play() failure, falls back to trickplay.
*/
async function startVideoStreamPreview(state, videoId, startTimeTicks) {
if (!state || !state.cardImageContainer || currentCardState !== state) return;
clearPreviewFallbackTimer(state);
const token = getJellyfinToken();
if (!token) {
WARN('startVideoStreamPreview: no API token');
return;
}
const baseUrl = typeof ApiClient !== 'undefined' && ApiClient.serverAddress
? ApiClient.serverAddress()
: window.location.origin;
const container = state.cardImageContainer;
const parent = container.parentNode;
if (!parent) return;
const rect = container.getBoundingClientRect();
const cw = rect.width || 0;
const ch = rect.height || 0;
if (cw <= 0 || ch <= 0) return;
// Fit 16:9 box inside container so video never overflows the card
let boxWidth = cw;
let boxHeight = Math.round(cw * 9 / 16);
if (boxHeight > ch) {
boxHeight = ch;
boxWidth = Math.round(ch * 16 / 9);
}
const wrapper = document.createElement('div');
wrapper.className = 'kefin-hover-preview-sibling';
const parentRect = parent.getBoundingClientRect();
const style = wrapper.style;
style.position = 'absolute';
style.left = (rect.left - parentRect.left) + 'px';
style.top = (rect.top - parentRect.top) + 'px';
style.width = rect.width + 'px';
style.height = rect.height + 'px';
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
const box = document.createElement('div');
box.className = 'kefin-hover-preview-box';
box.style.width = boxWidth + 'px';
box.style.height = boxHeight + 'px';
const frame = document.createElement('div');
frame.className = 'kefin-hover-preview-frame';
box.appendChild(frame);
box._frame = frame;
wrapper.appendChild(box);
container.after(wrapper);
state._videoPreviewWrapper = wrapper;
const videoUrl = `${baseUrl}/Videos/${videoId}/stream?static=true&startTimeTicks=${startTimeTicks}&ApiKey=${token}`;
const video = document.createElement('video');
video.className = 'kefin-hover-preview-video';
video.muted = true;
video.playsInline = true;
video.setAttribute('playsinline', '');
video.controls = false;
video.src = videoUrl;
box.appendChild(video);
state._videoPreviewEl = video;
let fallbackStarted = false;
const clearStartupWatch = () => {
clearPreviewFallbackTimer(state);
video.removeEventListener('playing', onPlaybackStarted);
video.removeEventListener('loadeddata', onPlaybackStarted);
};
const onPlaybackStarted = () => {
clearStartupWatch();
};
const switchToTrickplay = async (reason) => {
if (fallbackStarted) return;
if (!currentCardState || currentCardState !== state) return;
if (state._videoPreviewEl !== video) return;
fallbackStarted = true;
clearStartupWatch();
WARN('Video stream preview ' + reason + ', falling back to trickplay', videoId);
await fallbackVideoPreviewToTrickplay(state, startTimeTicks);
};
video.addEventListener('playing', onPlaybackStarted);
video.addEventListener('loadeddata', onPlaybackStarted);
const fallbackAfterMs = getFallbackAfterMs();
if (fallbackAfterMs > 0) {
state._previewFallbackTimer = setTimeout(() => {
state._previewFallbackTimer = null;
switchToTrickplay('timed out');
}, fallbackAfterMs);
}
video.play().catch(() => {
switchToTrickplay('play() failed');
});
}
function startHoverPreviewLoop() {
if (!currentCardState || !currentCardState.hoverPreviewRunning || !currentCardState.scrubberData) {
hoverPreviewRafId = null;
@@ -860,14 +1191,40 @@
const state = currentCardState;
if (state.hoverPreviewRunning || state.hoverPreviewTimer) return;
// Without trickplay, only video-stream hover is possible.
if (!isUseVideoStreamEnabled() && state.overlay && state.overlay.hasAttribute('data-no-trickplay')) {
return;
}
const timeoutMs = getHoverPreviewTimeoutMs();
const timeout = timeoutMs < 0 ? 0 : timeoutMs;
state.hoverPreviewTimer = setTimeout(async () => {
state.hoverPreviewTimer = null;
if (!currentCardState || currentCardState.card !== state.card) return;
if (isUseVideoStreamEnabled()) {
const token = getJellyfinToken();
if (!token) return;
let videoId = state.itemId;
// Start from card's resume position (data-positionticks) when present
let startTimeTicks = 0;
const posAttr = state.card && state.card.getAttribute ? state.card.getAttribute('data-positionticks') : null;
if (posAttr != null && posAttr !== '' && !Number.isNaN(Number(posAttr))) {
startTimeTicks = Math.floor(Number(posAttr) / 10000);
}
state.hoverPreviewRunning = true;
await startVideoStreamPreview(state, videoId, startTimeTicks);
return;
}
await ensureScrubberDataForHover(state);
if (!currentCardState || currentCardState.card !== state.card) return;
if (!state.scrubberData) {
state.hoverPreviewRunning = false;
return;
}
state.hoverPreviewRunning = true;
state.hoverPreviewLastTs = performance.now();
// Start from card's data-positionticks when available, otherwise from 0.
@@ -886,9 +1243,14 @@
function attachCard(ctx) {
if (!ctx) return;
const overlay = getOrCreateScrubberOverlay(ctx.cardImageContainer);
if (overlay.hasAttribute('data-no-trickplay')) return;
const noTrickplay = overlay.hasAttribute('data-no-trickplay');
const card = ctx.card;
function handleCardMouseLeave() {
teardown();
}
card.addEventListener('mouseleave', handleCardMouseLeave);
currentCardState = {
card: ctx.card,
card: card,
cardImageContainer: ctx.cardImageContainer,
itemId: ctx.itemId,
overlay: overlay,
@@ -900,9 +1262,13 @@
hoverPreviewRunning: false,
hoverPreviewLastTs: 0,
hoverPreviewPositionTicks: 0,
hoverPreviewFrameAccumMs: 0
hoverPreviewFrameAccumMs: 0,
_cardMouseLeaveHandler: handleCardMouseLeave
};
overlay.classList.add('is-visible');
// Scrub overlay stays hidden when there is no trickplay; video hover still attaches.
if (!noTrickplay) {
overlay.classList.add('is-visible');
}
if (isHoverPreviewEnabled()) {
maybeStartHoverPreview();
}
@@ -986,10 +1352,7 @@
const overlay = state.overlay;
if (inZone) {
// When user actively scrubs, stop passive hover preview
if (isHoverPreviewEnabled()) {
stopHoverPreview();
}
// Keep hover preview running while scrubbing; it stops only when the cursor leaves the card
if (overlay.hasAttribute('data-no-trickplay')) return;
if (!state.activationTimer) {
const delayMs = getScrubActivationDelayMs();
@@ -1075,7 +1438,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': ApiClient.accessToken()
'Authorization': window.apiHelper.getAuthHeader()
},
body: JSON.stringify({ PlaybackPositionTicks: pop._positionTicks })
}).then(r => {
+159 -20
View File
@@ -1,6 +1,6 @@
// KefinTweaks Toaster
// Provides toast notification functionality using Jellyfin's existing toast system
// Usage: window.KefinTweaksToaster.toast(message, duration)
// Usage: window.KefinTweaksToaster.toast(message, duration, autoHide, options)
(function() {
'use strict';
@@ -11,6 +11,54 @@
const ERR = (...args) => console.error('[KefinTweaks Toaster]', ...args);
LOG('Initializing');
let stylesInjected = false;
function ensureToastStyles() {
if (stylesInjected) return;
stylesInjected = true;
const style = document.createElement('style');
style.textContent = `
.toast.kefin-toast-rich {
display: flex;
align-items: center;
gap: 0.75em;
max-width: min(28rem, calc(100vw - 2rem));
}
.toast.kefin-toast-rich .kefin-toast-message {
flex: 1 1 auto;
min-width: 0;
line-height: 1.35;
}
.toast.kefin-toast-rich .kefin-toast-link {
color: inherit;
text-decoration: underline;
cursor: pointer;
}
.toast.kefin-toast-rich .kefin-toast-action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
}
.toast.kefin-toast-rich .kefin-toast-action:hover {
background: rgba(255, 255, 255, 0.12);
}
.toast.kefin-toast-rich .kefin-toast-action .material-icons {
font-size: 1.25rem;
}
`;
document.head.appendChild(style);
}
/**
* Get the toast container
@@ -54,14 +102,117 @@
// Default to 3 seconds
return 3000;
}
function dismissToastElement(toastElement, container, customContainer) {
if (!toastElement?.parentNode) return;
toastElement.classList.add('toastHide');
setTimeout(() => {
if (toastElement.parentNode) {
toastElement.parentNode.removeChild(toastElement);
}
if (customContainer && container && !container.querySelector('.toast')) {
container.remove();
}
}, 3000);
}
function buildToastContent(toastElement, message, options) {
const link = options?.link;
const action = options?.action;
const rich = !!(link || action);
if (!rich) {
toastElement.textContent = message;
return;
}
ensureToastStyles();
toastElement.classList.add('kefin-toast-rich');
const messageEl = document.createElement('span');
messageEl.className = 'kefin-toast-message';
if (link?.label && typeof message === 'string' && message.includes(link.label)) {
const idx = message.indexOf(link.label);
if (idx > 0) {
messageEl.appendChild(document.createTextNode(message.slice(0, idx)));
}
const anchor = document.createElement('a');
anchor.className = 'kefin-toast-link';
anchor.href = link.href || '#';
anchor.textContent = link.label;
anchor.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (typeof link.onClick === 'function') {
link.onClick();
} else if (link.href) {
if (window.Dashboard?.navigate) {
window.Dashboard.navigate(link.href);
} else {
window.location.hash = link.href.startsWith('#') ? link.href : `#/${link.href}`;
}
}
});
messageEl.appendChild(anchor);
const after = message.slice(idx + link.label.length);
if (after) {
messageEl.appendChild(document.createTextNode(after));
}
} else {
messageEl.textContent = message;
if (link?.label) {
messageEl.appendChild(document.createTextNode(' '));
const anchor = document.createElement('a');
anchor.className = 'kefin-toast-link';
anchor.href = link.href || '#';
anchor.textContent = link.label;
anchor.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (typeof link.onClick === 'function') link.onClick();
else if (link.href && window.Dashboard?.navigate) {
window.Dashboard.navigate(link.href);
}
});
messageEl.appendChild(anchor);
}
}
toastElement.appendChild(messageEl);
if (action && typeof action.onClick === 'function') {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kefin-toast-action';
btn.title = action.label || 'Undo';
btn.setAttribute('aria-label', action.label || 'Undo');
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = action.icon || 'undo';
btn.appendChild(icon);
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
action.onClick();
} catch (e) {
ERR('Toast action failed:', e);
}
});
toastElement.appendChild(btn);
}
}
/**
* Display a toast message
* @param {string} message - The message to display
* @param {string|number} duration - Duration in seconds
* @param {boolean} autoHide - Whether to automatically hide the toast after duration (default: true)
* @param {{ link?: { label: string, href?: string, onClick?: Function }, action?: { icon?: string, label?: string, onClick: Function } }} [options]
* @returns {{ dismiss: Function, element: HTMLElement }|undefined}
*/
function toast(message, duration = '3', autoHide = true) {
function toast(message, duration = '3', autoHide = true, options = null) {
if (!message) {
WARN('Toast message is required');
return;
@@ -88,7 +239,7 @@
// Create the toast element with just 'toast' class initially
const toastElement = document.createElement('div');
toastElement.className = 'toast';
toastElement.textContent = message;
buildToastContent(toastElement, message, options || {});
// Add to container first
container.appendChild(toastElement);
@@ -100,6 +251,8 @@
// Now add toastVisible class - the browser will see the state change and apply the transition
toastElement.classList.add('toastVisible');
const dismiss = () => dismissToastElement(toastElement, container, customContainer);
// Only set up auto-hide if enabled
if (autoHide) {
@@ -108,25 +261,11 @@
// After the duration, start the fade-out
setTimeout(() => {
if (!toastElement.parentNode) {
return; // Already removed
}
// Add toastHide class to trigger fade-out
toastElement.classList.add('toastHide');
// Wait 1 second for Jellyfin's CSS to handle the fade-out, then remove
setTimeout(() => {
if (toastElement.parentNode) {
toastElement.parentNode.removeChild(toastElement);
LOG(`Removed toast: "${message}"`);
if (customContainer) {
container.remove();
}
}
}, 3000);
dismiss();
}, durationMs);
}
return { dismiss, element: toastElement };
}
// Expose toaster to global scope
+364 -73
View File
@@ -18,10 +18,9 @@
*/
createBadge(value, displayText) {
const badge = document.createElement('span');
badge.className = 'tag-badge';
badge.className = 'tag-badge kt-autocomplete-badge';
badge.setAttribute('data-value', value);
badge.style.cssText = 'display: inline-flex; align-items: center; gap: 0.25em; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; margin: 0.25em; font-size: 0.9em;';
badge.innerHTML = `${displayText}<button type="button" class="tag-badge-remove" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0; margin-left: 0.25em; font-size: 1.2em; line-height: 1; display: flex; align-items: center;" title="Remove">×</button>`;
badge.innerHTML = `${displayText}<button type="button" class="tag-badge-remove kt-autocomplete-badge-remove" title="Remove"><span class="material-icons">close</span></button>`;
return badge;
},
@@ -127,6 +126,7 @@
sectionIndex,
container,
label = type,
description = '',
currentValuesArray = [],
availableTypes = null,
includeItemTypes = null,
@@ -163,9 +163,9 @@
const displayText = (type === 'Collection' || type === 'Playlist') && typeof val === 'object' ? val.name : val;
const value = (type === 'Collection' || type === 'Playlist') && typeof val === 'object' ? val.id : val;
return `
<span class="tag-badge" data-value="${value}" ${(type === 'Collection' || type === 'Playlist') ? 'data-loading="true"' : ''} style="display: inline-flex; align-items: center; gap: 0.25em; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; margin: 0.25em; font-size: 0.9em;">
<span class="tag-badge kt-autocomplete-badge" data-value="${value}" ${(type === 'Collection' || type === 'Playlist') ? 'data-loading="true"' : ''}>
${displayText}
<button type="button" class="tag-badge-remove" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0; margin-left: 0.25em; font-size: 1.2em; line-height: 1; display: flex; align-items: center;" title="Remove">×</button>
<button type="button" class="tag-badge-remove kt-autocomplete-badge-remove" title="Remove"><span class="material-icons">close</span></button>
</span>
`;
}).join('');
@@ -173,37 +173,31 @@
const typeKey = type === 'IncludeItemTypes' ? 'includeItemTypes' : type.toLowerCase();
const placeholderText = type === 'IncludeItemTypes' ? 'Type to add item type...' : `Type to add ${type.toLowerCase()}...`;
// Determine if refresh button should be shown (for cached data types)
const showRefresh = (type === 'Tag' || type === 'Genre' || type === 'Collection' || type === 'Playlist');
// Determine if badges should appear below input (for IncludeItemTypes, Genres, Tags)
// Check for both singular and plural forms since type may be normalized
const badgesBelowInput = (type === 'IncludeItemTypes' || type === 'Genres' || type === 'Tags' || type === 'Genre' || type === 'Tag');
html = `
<div class="${prefix}_${typeKey}_container" data-section-index="${sectionIndex}" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; position: static;">
<div style="position: relative;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5em;">
<div class="listItemBodyText">${label}</div>
<div style="display: flex; gap: 0.5em;">
${showRefresh ? `<button type="button" class="badge-refresh" data-section-index="${sectionIndex}" data-type="${type}" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; color: rgba(255,255,255,0.87); cursor: pointer; font-size: 0.85em;" title="Refresh Data">🔄</button>` : ''}
<button type="button" class="badge-clear-all" data-section-index="${sectionIndex}" data-type="${type}" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; color: rgba(255,255,255,0.87); cursor: pointer; font-size: 0.85em;" title="Clear All">Clear All</button>
</div>
<div class="kt-autocomplete-control tag-badge-container ${prefix}_${typeKey}_container" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" ${type === 'IncludeItemTypes' ? `data-available-types="${allAvailableItemTypes.join(',')}"` : ''}>
<div class="kt-autocomplete-header">
<div class="kt-autocomplete-text">
<div class="listItemBodyText kt-autocomplete-label">${label}</div>
${description ? `<div class="listItemBodyText secondary kt-autocomplete-desc">${description}</div>` : ''}
</div>
<div class="tag-badge-container" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" ${type === 'IncludeItemTypes' ? `data-available-types="${allAvailableItemTypes.join(',')}"` : ''} style="min-height: 2.5em; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.5em; margin-bottom: 0.5em; background: rgba(0,0,0,0.2); ${badgesBelowInput ? 'display: block;' : 'display: flex; gap: 0.5em; align-items: flex-start;'} overflow: hidden; position: relative;">
<div style="display: flex; align-items: center; gap: 0.25em; ${badgesBelowInput ? 'width: 100%; margin-bottom: 0.5em;' : 'flex: 0 0 auto; width: 200px; min-width: 200px;'}">
<input type="text" class="${prefix}_${typeKey} fld emby-input autocomplete-input" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" placeholder="${placeholderText}" autocomplete="off" style="flex: 1; border: none; background: transparent; outline: none; color: rgba(255,255,255,0.87); padding: 0.25em;">
<button type="button" class="autocomplete-chevron" data-section-index="${sectionIndex}" data-type="${type}" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em; color: rgba(255,255,255,0.87); cursor: pointer; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px;" title="Show All Options">
<span class="material-icons" style="font-size: 18px;">keyboard_arrow_down</span>
</button>
</div>
<div class="tag-badge-wrapper" style="${badgesBelowInput ? 'width: 100%;' : 'flex: 1; min-width: 0;'} display: flex; flex-wrap: wrap; gap: 0.25em; align-items: flex-start; overflow-wrap: break-word;">
${badgesHtml}
</div>
<div class="kt-autocomplete-actions">
${showRefresh ? `<button type="button" class="kt-autocomplete-icon-btn badge-refresh" data-section-index="${sectionIndex}" data-type="${type}" title="Refresh data"><span class="material-icons">refresh</span></button>` : ''}
<button type="button" class="kt-autocomplete-icon-btn badge-clear-all" data-section-index="${sectionIndex}" data-type="${type}" title="Clear all"><span class="material-icons">clear_all</span></button>
</div>
<input type="hidden" class="${prefix}_${typeKey}_hidden" data-section-index="${sectionIndex}" value="${currentValues.map(v => typeof v === 'object' ? v.id : v).join(', ')}">
<div class="autocomplete-suggestions" data-section-index="${sectionIndex}" style="display: none; position: absolute; z-index: 10000; background: #1a1a1a !important; color: rgba(255,255,255,0.87) !important; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; max-height: 200px; overflow-y: auto; margin-top: 2px; width: 100%; box-sizing: border-box; opacity: 1 !important; top: 100%; left: 0;"></div>
</div>
<div class="kt-autocomplete-field">
<div class="kt-autocomplete-input-row">
<input type="text" class="${prefix}_${typeKey} fld emby-input autocomplete-input" data-section-index="${sectionIndex}" data-prefix="${prefix}" data-type="${type}" placeholder="${placeholderText}" autocomplete="off">
<button type="button" class="kt-autocomplete-icon-btn autocomplete-chevron" data-section-index="${sectionIndex}" data-type="${type}" title="Show all options">
<span class="material-icons">keyboard_arrow_down</span>
</button>
</div>
<div class="autocomplete-suggestions" data-section-index="${sectionIndex}"></div>
</div>
<div class="tag-badge-wrapper kt-autocomplete-badges">${badgesHtml}</div>
<input type="hidden" class="${prefix}_${typeKey}_hidden" data-section-index="${sectionIndex}" value="${currentValues.map(v => typeof v === 'object' ? v.id : v).join(', ')}">
</div>
`;
@@ -826,41 +820,6 @@
`;
}
/**
* Build toggle slider HTML (simplified version for user editor)
*/
function buildToggleSliderHTML(checked, sectionId, onToggle) {
const isEnabled = checked !== false;
return `
<button type="button" class="toggle-slider section-toggle-switch" data-section-id="${sectionId}" data-enabled="${isEnabled}" style="
position: relative;
width: 60px;
height: 28px;
border-radius: 14px;
border: none;
background: ${isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'};
cursor: pointer;
transition: background-color 0.3s ease;
flex-shrink: 0;
padding: 0;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
">
<span style="
position: absolute;
left: ${isEnabled ? '32px' : '2px'};
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
"></span>
</button>
`;
}
/**
* Build section row HTML for order editor
*/
@@ -889,7 +848,11 @@
return `
<div class="listItem viewItem section-row" data-section-id="${sectionId}" data-section-type="${sectionType}" ${draggableAttr} ${dataAttrsStr}>
<span class="material-icons drag_handle"></span>
${buildToggleSliderHTML(isEnabled, sectionId)}
${buildToggleSlider('', isEnabled, '', {
wrapInLabel: false,
cssClass: 'toggle-slider section-toggle-switch',
dataAttributes: { 'section-id': sectionId }
})}
<div class="listItemBody">
<div style="display: flex; align-items: center; gap: 0.5em;">
<span>${sectionName.replace(/"/g, '&quot;')}</span>
@@ -1432,17 +1395,28 @@
const currentState = sectionsContainer.getAttribute(attrName);
const newState = currentState === 'false' ? 'true' : 'false';
const newStateBool = newState === 'true';
// Update container attribute
// Keep at least one section type visible — do not turn off the last active type
if (!newStateBool && currentState === 'true') {
let enabledTypeCount = 0;
for (const attr of sectionsContainer.attributes) {
if (attr.name.startsWith('data-section-type-') && attr.value === 'true') {
enabledTypeCount++;
}
}
if (enabledTypeCount <= 1) {
return;
}
}
sectionsContainer.setAttribute(attrName, newState);
// Update button appearance
if (newStateBool) {
toggleBtn.classList.add('button-submit');
} else {
toggleBtn.classList.remove('button-submit');
}
// Save the new state to localStorage
const savedToggleStates = getSectionTypeToggleStates();
savedToggleStates[sectionTypeLower] = newStateBool;
@@ -1553,12 +1527,329 @@
}
}
/**
* Escape HTML special characters for safe attribute/text insertion.
*/
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Build Jellyfin select/dropdown HTML
*/
function buildSelect(id, options, selectedValue, label) {
const optionsHTML = options.map(opt => {
const value = typeof opt === 'string' ? opt : opt.value;
const optLabel = typeof opt === 'string' ? opt : opt.label;
const selected = value === selectedValue ? 'selected' : '';
return `<option value="${value}" ${selected}>${optLabel}</option>`;
}).join('');
return `
<div class="hsae-field-wrap">
<label class="listItemBodyText" for="${id}" style="display: block; margin-bottom: 0.25em;">${label}</label>
<select id="${id}" class="fld emby-select emby-select-withcolor">
${optionsHTML}
</select>
</div>
`;
}
/**
* Build Jellyfin text input HTML
* @param {Record<string, string|number|boolean>} [attrs] Extra HTML attributes (e.g. { min: 0, step: 1 })
*/
function buildTextInput(id, value, label, type = 'text', placeholder = '', attrs = {}) {
const extraAttrs = Object.entries(attrs || {})
.filter(([, v]) => v != null && v !== false)
.map(([k, v]) => (v === true
? ` ${k}`
: ` ${k}="${String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"`))
.join('');
return `
<div class="hsae-field-wrap">
<label class="listItemBodyText" for="${id}" style="display: block; margin-bottom: 0.25em;">${label}</label>
<input type="${type}" id="${id}" class="fld emby-input" value="${value || ''}" placeholder="${placeholder || ''}"${extraAttrs}>
</div>
`;
}
/**
* Build checkbox control HTML (Jellyfin emby-checkbox markup).
* @param {string} [id] - Optional input id (omit/empty for class-based inputs)
* @param {boolean} checked
* @param {string} label
* @param {object} [options]
* @param {string} [options.className] - Extra classes on the input
* @param {object} [options.dataAttributes] - data-* attrs, e.g. { id: 'x' } → data-id="x"
* @param {boolean} [options.wrapInSection] - Wrap in .sectioncheckbox
*/
function buildCheckbox(id, checked, label, options = {}) {
const {
className = '',
dataAttributes = {},
wrapInSection = false
} = options;
const safeLabel = escapeHtml(label);
const idAttr = id ? ` id="${escapeHtml(id)}"` : '';
const extraClass = className ? ` ${escapeHtml(className)}` : '';
const dataAttrs = Object.entries(dataAttributes)
.map(([key, value]) => ` data-${escapeHtml(key)}="${escapeHtml(String(value))}"`)
.join('');
const control = `
<label class="checkboxContainer emby-checkbox-label">
<input is="emby-checkbox" type="checkbox"${idAttr}
class="emby-checkbox${extraClass}" data-embycheckbox="true" ${checked ? 'checked' : ''}${dataAttrs}>
<span class="checkboxLabel">${safeLabel}</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>
`;
if (wrapInSection) {
return `<div class="sectioncheckbox">${control}</div>`;
}
return control;
}
/**
* Build toggle card with label, description, and pill switch (via buildToggleSlider).
*/
function buildToggleCard(id, checked, label, description, options = {}) {
const isEnabled = checked !== false;
const {
hintKey,
checkboxClass = '',
checkboxDataAttributes = {}
} = options;
const hintAttr = hintKey ? ` data-hsae-hint="${escapeHtml(hintKey)}"` : '';
const checkboxClassAttr = checkboxClass ? ` class="${escapeHtml(checkboxClass)}"` : '';
const checkboxDataAttrs = Object.entries(checkboxDataAttributes)
.map(([key, value]) => ` data-${escapeHtml(key)}="${escapeHtml(String(value))}"`)
.join(' ');
const toggleBtn = buildToggleSlider(id, checked, '', {
includeHiddenCheckbox: false,
wrapInLabel: false,
cssClass: 'kefin-toggle-switch',
knob: 'css',
dataAttributes: checkboxDataAttributes
});
return `
<div class="kefin-toggle-card">
<input type="checkbox" id="${escapeHtml(id)}"${checkboxClassAttr}${checkboxDataAttrs} ${isEnabled ? 'checked' : ''} style="display: none;">
<div class="kefin-toggle-card-text">
<div class="listItemBodyText kefin-toggle-card-label">${escapeHtml(label)}</div>
${description ? `<div class="listItemBodyText secondary kefin-toggle-card-desc"${hintAttr}>${escapeHtml(description)}</div>` : ''}
</div>
${toggleBtn}
</div>
`;
}
/**
* Update minimal pill toggle switch UI (no ON/OFF text).
*/
function updateToggleSwitchUI(btn, checked) {
if (!btn) return;
const isEnabled = checked === true;
btn.dataset.enabled = isEnabled;
btn.setAttribute('aria-pressed', String(isEnabled));
const checkboxId = btn.dataset.checkboxId;
if (checkboxId) {
const checkbox = document.getElementById(checkboxId);
if (checkbox) checkbox.checked = isEnabled;
}
}
/**
* Wire kefin-toggle-switch buttons under root (idempotent).
* @param {ParentNode} root
*/
function bindToggleCards(root) {
if (!root || typeof root.querySelectorAll !== 'function') 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';
updateToggleSwitchUI(btn, next);
});
});
}
/**
* Build enabled toggle buttons (ENABLED/DISABLED)
*/
function buildEnabledToggleButtons(id, enabled) {
const isEnabled = enabled !== false;
return `
<div style="margin-bottom: 1.5em; display: flex; gap: 0.5em;">
<button type="button" class="enabled-toggle-btn ${isEnabled ? 'active' : ''}" data-enabled="true" style="
flex: 1;
padding: 0.75em 1.5em;
border: 2px solid ${isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(255,255,255,0.2)'};
border-radius: 4px;
background: ${isEnabled ? 'rgba(0, 164, 220, 0.2)' : 'transparent'};
color: ${isEnabled ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'};
font-weight: ${isEnabled ? '600' : '400'};
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
">
Enabled
</button>
<button type="button" class="enabled-toggle-btn ${!isEnabled ? 'active' : ''}" data-enabled="false" style="
flex: 1;
padding: 0.75em 1.5em;
border: 2px solid ${!isEnabled ? 'rgba(158, 158, 158, 0.8)' : 'rgba(255,255,255,0.2)'};
border-radius: 4px;
background: ${!isEnabled ? 'rgba(158, 158, 158, 0.2)' : 'transparent'};
color: ${!isEnabled ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.7)'};
font-weight: ${!isEnabled ? '600' : '400'};
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
">
Disabled
</button>
</div>
`;
}
/**
* Build toggle slider (compact thumb switch, no ON/OFF text).
* @param {string} id
* @param {boolean} checked
* @param {string} label
* @param {Object} options
* @param {boolean} [options.includeHiddenCheckbox]
* @param {Object} [options.dataAttributes]
* @param {string} [options.cssClass='toggle-slider']
* @param {boolean} [options.wrapInLabel=true]
* @param {'element'|'css'} [options.knob] - element = inline thumb span; css = ::after (e.g. kefin-toggle-switch)
* @param {string} [options.checkboxClass]
*/
function buildToggleSlider(id, checked, label, options = {}) {
const {
includeHiddenCheckbox = false,
dataAttributes = {},
cssClass = 'toggle-slider',
wrapInLabel = true,
checkboxClass = '',
knob
} = options;
const isEnabled = checked !== false;
const useCssKnob = knob === 'css' || (knob == null && String(cssClass).includes('kefin-toggle-switch'));
const dataAttrsString = Object.entries(dataAttributes)
.map(([key, value]) => `data-${escapeHtml(key)}="${escapeHtml(String(value))}"`)
.join(' ');
const checkboxClassAttr = checkboxClass ? ` class="${escapeHtml(checkboxClass)}"` : '';
const inlineStyle = useCssKnob ? '' : ` style="
position: relative;
width: 52px;
height: 28px;
border-radius: 14px;
border: none;
background: ${isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'};
cursor: pointer;
transition: background-color 0.3s ease;
flex-shrink: 0;
padding: 0;
display: inline-flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
"`;
const knobHtml = useCssKnob ? '' : `
<span class="toggle-slider-thumb" style="
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
left: ${isEnabled ? 'calc(100% - 25px)' : '3px'};
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
pointer-events: none;
"></span>`;
const toggleButton = `
<button type="button" class="${escapeHtml(cssClass)}" ${dataAttrsString} ${id ? `data-checkbox-id="${escapeHtml(id)}"` : ''} data-enabled="${isEnabled}" aria-pressed="${isEnabled}"${inlineStyle}>
${knobHtml}
</button>
`;
const hiddenCheckbox = includeHiddenCheckbox && id ? `
<input type="checkbox" id="${escapeHtml(id)}"${checkboxClassAttr} ${isEnabled ? 'checked' : ''} style="display: none;">
` : '';
if (wrapInLabel) {
return `
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
${hiddenCheckbox}
${toggleButton}
<span class="listItemBodyText">${escapeHtml(label || '')}</span>
</label>
`;
}
return hiddenCheckbox + toggleButton;
}
/**
* Update toggle slider UI programmatically
*/
function updateToggleSliderUI(toggleButton, isEnabled) {
if (!toggleButton) return;
const enabled = !!isEnabled;
toggleButton.dataset.enabled = enabled;
toggleButton.setAttribute('aria-pressed', String(enabled));
if (toggleButton.style && toggleButton.getAttribute('style')) {
toggleButton.style.background = enabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)';
}
const knob = toggleButton.querySelector('.toggle-slider-thumb');
if (knob) {
knob.style.left = enabled ? 'calc(100% - 25px)' : '3px';
}
const checkboxId = toggleButton.dataset.checkboxId;
if (checkboxId) {
const checkbox = document.getElementById(checkboxId);
if (checkbox) checkbox.checked = enabled;
}
}
// Expose to global scope
window.KefinTweaksUI = {
createAutoCompleteInput,
BadgeSystem,
renderHomeSectionsOrderEditor,
setupOrderEditorListeners
setupOrderEditorListeners,
escapeHtml,
buildSelect,
buildTextInput,
buildCheckbox,
buildToggleCard,
buildToggleSlider,
buildEnabledToggleButtons,
updateToggleSwitchUI,
updateToggleSliderUI,
bindToggleCards
};
LOG('UI utilities loaded');
+170 -10
View File
@@ -23,16 +23,33 @@
}
}
// Run this on a separate thread to avoid blocking the main thread with requestIdleCallback
async function waitForLogin() {
// Poll until ApiClient reports logged in. Uses one Promise so callers always settle.
// requestIdleCallback (with setTimeout fallback) avoids busy-looping the main thread.
async function waitForLogin(maxWaitMs = 60000) {
const startTime = Date.now();
const schedule = (fn) => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(fn);
} else {
setTimeout(fn, 0);
}
};
return new Promise((resolve) => {
requestIdleCallback(() => {
const tick = () => {
if (window.ApiClient && window.ApiClient._loggedIn) {
LOG('waitForLogin: logged in');
resolve(true);
} else {
setTimeout(() => waitForLogin(), 100);
return;
}
});
if (Date.now() - startTime >= maxWaitMs) {
WARN('waitForLogin: timed out after', maxWaitMs, 'ms');
resolve(false);
return;
}
setTimeout(() => schedule(tick), 100);
};
schedule(tick);
});
}
@@ -63,7 +80,7 @@
LOG('Fetching user display preferences');
const response = await fetch(`${ApiClient.serverAddress()}/DisplayPreferences/usersettings?userId=${ApiClient.getCurrentUserId()}&client=emby`, {
headers: {
'X-Emby-Token': ApiClient.accessToken()
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!response.ok) {
@@ -110,19 +127,162 @@
updateDisplayPreferences: async function(prefs) {
ensureApiClient();
const userId = ApiClient.getCurrentUserId();
return await this.updateDisplayPreferencesForUser(userId, prefs, { updateCache: true });
},
/**
* Fetch display preferences for an arbitrary user (admin).
* @param {string} userId
* @returns {Promise<object>}
*/
getUserDisplayPreferencesForUser: async function(userId) {
ensureApiClient();
if (!userId) throw new Error('userId is required');
const response = await fetch(
`${ApiClient.serverAddress()}/DisplayPreferences/usersettings?userId=${encodeURIComponent(userId)}&client=emby`,
{ headers: { 'Authorization': window.apiHelper.getAuthHeader() } }
);
if (!response.ok) {
throw new Error(`Failed to fetch display preferences for ${userId}: ${response.status}`);
}
return response.json();
},
/**
* Persist display preferences for an arbitrary user (admin).
* @param {string} userId
* @param {object} prefs
* @param {{ updateCache?: boolean }} [options]
* @returns {Promise<boolean>}
*/
updateDisplayPreferencesForUser: async function(userId, prefs, options = {}) {
ensureApiClient();
if (!userId) throw new Error('userId is required');
const serverAddress = ApiClient.serverAddress();
const accessToken = ApiClient.accessToken();
const url = `${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`;
const url = `${serverAddress}/DisplayPreferences/usersettings?userId=${encodeURIComponent(userId)}&client=emby`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': accessToken
'Authorization': window.apiHelper.getAuthHeader()
},
body: JSON.stringify(prefs)
});
if (response.ok && options.updateCache && userId === ApiClient.getCurrentUserId()) {
__userDisplayPreferences = prefs;
if (window.LocalStorageCache) {
try {
const cache = new window.LocalStorageCache();
cache.set('userDisplayPreferences', prefs, userId, 24 * 60 * 60 * 1000);
} catch (_) { /* ignore */ }
}
}
return response.ok;
},
/**
* Parse CustomPrefs.kefinTweaks (JSON string or object) into a plain object.
* @param {object|string|null} customPrefsOrPrefs - CustomPrefs object, full display prefs, or raw kefinTweaks value
* @returns {object}
*/
parseKefinTweaks: function(customPrefsOrPrefs) {
if (!customPrefsOrPrefs) return {};
let raw = customPrefsOrPrefs;
if (customPrefsOrPrefs.CustomPrefs) {
raw = customPrefsOrPrefs.CustomPrefs.kefinTweaks;
} else if (customPrefsOrPrefs.kefinTweaks !== undefined) {
raw = customPrefsOrPrefs.kefinTweaks;
}
if (!raw) return {};
if (typeof raw === 'object') return { ...raw };
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (e) {
WARN('Failed to parse CustomPrefs.kefinTweaks:', e);
return {};
}
},
/**
* Load a single feature blob from CustomPrefs.kefinTweaks.<featureKey>.
* @param {string} featureKey
* @param {*} [defaultValue=null]
* @returns {Promise<*>}
*/
getKefinTweaksFeature: async function(featureKey, defaultValue = null) {
if (!featureKey) return defaultValue;
try {
const { promise } = await this.getUserDisplayPreferences();
const prefs = await promise;
const kefin = this.parseKefinTweaks(prefs);
return Object.prototype.hasOwnProperty.call(kefin, featureKey)
? kefin[featureKey]
: defaultValue;
} catch (e) {
WARN(`getKefinTweaksFeature(${featureKey}) failed:`, e);
return defaultValue;
}
},
/**
* Replace CustomPrefs.kefinTweaks.<featureKey> while preserving sibling keys.
* @param {string} featureKey
* @param {*} value
* @returns {Promise<boolean>}
*/
setKefinTweaksFeature: async function(featureKey, value) {
if (!featureKey) return false;
try {
const { promise } = await this.getUserDisplayPreferences();
const prefs = await promise;
if (!prefs) return false;
if (!prefs.CustomPrefs) prefs.CustomPrefs = {};
const kefin = this.parseKefinTweaks(prefs);
kefin[featureKey] = value;
prefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin);
return await this.updateDisplayPreferences(prefs);
} catch (e) {
ERR(`setKefinTweaksFeature(${featureKey}) failed:`, e);
return false;
}
},
/**
* Update CustomPrefs.kefinTweaks.<featureKey> via object merge or updater function.
* @param {string} featureKey
* @param {object|Function} patchOrFn - Partial object to merge, or (current) => nextValue
* @returns {Promise<boolean>}
*/
updateKefinTweaksFeature: async function(featureKey, patchOrFn) {
if (!featureKey) return false;
try {
const { promise } = await this.getUserDisplayPreferences();
const prefs = await promise;
if (!prefs) return false;
if (!prefs.CustomPrefs) prefs.CustomPrefs = {};
const kefin = this.parseKefinTweaks(prefs);
const current = Object.prototype.hasOwnProperty.call(kefin, featureKey)
? kefin[featureKey]
: null;
let next;
if (typeof patchOrFn === 'function') {
next = patchOrFn(current);
} else if (patchOrFn && typeof patchOrFn === 'object' && !Array.isArray(patchOrFn)
&& current && typeof current === 'object' && !Array.isArray(current)) {
next = { ...current, ...patchOrFn };
} else {
next = patchOrFn;
}
kefin[featureKey] = next;
prefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin);
return await this.updateDisplayPreferences(prefs);
} catch (e) {
ERR(`updateKefinTweaksFeature(${featureKey}) failed:`, e);
return false;
}
},
waitForLogin: waitForLogin
};
+120
View File
@@ -0,0 +1,120 @@
/* User Manager — template UI on dashboard user pages */
#newUserPage:not([data-user-template="None"]) .folderAccessContainer {
display: none !important;
}
#newUserPage[data-user-template="None"] .um-kefin-save,
#newUserPage:not([data-user-template]) .um-kefin-save {
display: none !important;
}
#newUserPage:not([data-user-template="None"]) .newUserProfileForm .emby-button.button-submit:not(.um-kefin-save) {
display: none !important;
}
.um-template-field {
margin: 1em 0;
}
.um-template-field-row {
display: flex;
align-items: flex-end;
gap: 0.75em;
flex-wrap: wrap;
}
.um-template-field-row .selectContainer {
flex: 1 1 220px;
margin: 0;
}
.um-bulk-root {
display: flex;
flex-direction: column;
gap: 1em;
padding: 0.25em 0;
}
.um-bulk-user-list {
max-height: 50vh;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.5em;
}
.um-bulk-user-item {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.45em 0.35em;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.um-bulk-user-item:last-child {
border-bottom: none;
}
.um-bulk-toolbar {
display: grid;
gap: 0.5em;
grid-template-columns: 1fr 1fr;
align-items: center;
}
.um-bulk-status {
font-size: 0.9em;
opacity: 0.85;
min-height: 1.2em;
}
.um-bulk-fab {
margin-left: 0.25em;
}
.um-config-root .um-template-list {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-top: 1em;
}
.um-config-root .um-template-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
padding: 0.75em 1em;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
}
.um-config-root .um-template-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.um-editor-root .um-section {
margin: 1.25em 0;
padding-bottom: 1em;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.um-editor-root .um-section-title {
margin: 0 0 0.75em;
font-size: 1.1em;
font-weight: 500;
}
.um-editor-root .checkboxList {
display: flex;
flex-direction: column;
gap: 0.35em;
}
.um-bulk-root .selectContainer,
.um-bulk-toolbar button {
margin: 0;
}
+568
View File
@@ -0,0 +1,568 @@
// KefinTweaks User Manager — dashboard hooks for user templates
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks UserManager]', ...args);
const ERR = (...args) => console.error('[KefinTweaks UserManager]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks UserManager]', ...args);
const BULK_MODAL_ID = 'kefin-usermanager-bulk';
const NONE = 'None';
const MARKER_ADD = 'data-um-add-injected';
const MARKER_BULK = 'data-um-bulk-injected';
function clonePolicy(policy) {
if (window.KefinUserManagerConfig?.clonePolicy) {
return window.KefinUserManagerConfig.clonePolicy(policy);
}
return JSON.parse(JSON.stringify(policy ?? {}));
}
function loadConfig() {
if (window.KefinUserManagerConfig?.loadConfig) {
return window.KefinUserManagerConfig.loadConfig();
}
const raw = window.KefinTweaksConfig?.userManager || {};
return {
defaultTemplateName: raw.defaultTemplateName || '',
templates: Array.isArray(raw.templates) ? raw.templates : []
};
}
function getAuthHeader() {
return window.apiHelper?.getAuthHeader?.() || '';
}
async function apiJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
Authorization: getAuthHeader(),
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {})
}
});
if (!response.ok) {
let detail = `${response.status}`;
try {
const body = await response.json();
detail = body?.message || body?.Message || detail;
} catch (_) {
try {
const text = await response.text();
if (text) detail = text;
} catch (__) { /* ignore */ }
}
throw new Error(detail);
}
if (response.status === 204) return null;
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch (_) {
return text;
}
}
async function createUser(name, password) {
const url = ApiClient.getUrl('Users/New');
return apiJson(url, {
method: 'POST',
body: JSON.stringify({ Name: name, Password: password })
});
}
async function applyPolicy(userId, policy) {
const url = ApiClient.getUrl(`Users/${userId}/Policy`);
return apiJson(url, {
method: 'POST',
body: JSON.stringify(policy)
});
}
async function fetchUsers() {
if (typeof ApiClient?.getUsers === 'function') {
const users = await ApiClient.getUsers();
return Array.isArray(users) ? users : [];
}
const users = await apiJson(ApiClient.getUrl('Users'), { method: 'GET' });
return Array.isArray(users) ? users : [];
}
function toast(msg) {
window.KefinTweaksToaster?.toast?.(msg);
}
function isAddUserPage(hash) {
const h = hash || window.location.hash || '';
return /\/dashboard\/users\/usernew\b/i.test(h) || /\/dashboard\/users\/add\b/i.test(h);
}
function isUsersListPage(hash) {
const h = hash || window.location.hash || '';
if (isAddUserPage(h)) return false;
if (/\/dashboard\/users\/user\b/i.test(h)) return false;
return /\/dashboard\/users\/?(\?|$|#|$)/i.test(h) || /#\/dashboard\/users\/?$/i.test(h);
}
function getNewUserPage() {
return document.querySelector('#newUserPage')
|| document.querySelector('.newUserPage')
|| document.querySelector('[data-role="page"].newUserPage');
}
function waitForElement(getter, timeoutMs = 8000, intervalMs = 100) {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
const el = getter();
if (el) {
resolve(el);
return;
}
if (Date.now() - start >= timeoutMs) {
resolve(null);
return;
}
setTimeout(tick, intervalMs);
};
tick();
});
}
function getTemplateOptionsHtml(config, includeNone) {
const names = (config.templates || []).map((t) => t.name);
const options = [];
if (includeNone) {
options.push(`<option value="${NONE}">${NONE}</option>`);
}
names.forEach((name) => {
options.push(`<option value="${escapeAttr(name)}">${escapeHtml(name)}</option>`);
});
return options.join('');
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return escapeHtml(str).replace(/'/g, '&#39;');
}
function findNativeSaveButton(page) {
return page.querySelector('.newUserProfileForm .emby-button.button-submit:not(.um-kefin-save)')
|| page.querySelector('form .emby-button.button-submit:not(.um-kefin-save)')
|| page.querySelector('.button-submit:not(.um-kefin-save)');
}
function updateSaveVisibility(page, templateName) {
const useTemplate = templateName && templateName !== NONE;
page.setAttribute('data-user-template', useTemplate ? templateName : NONE);
const nativeSave = findNativeSaveButton(page);
const kefinSave = page.querySelector('.um-kefin-save');
if (nativeSave) {
nativeSave.style.display = useTemplate ? 'none' : '';
}
if (kefinSave) {
kefinSave.style.display = useTemplate ? '' : 'none';
}
}
async function handleKefinSave(page) {
const select = page.querySelector('#um-template-select');
const templateName = select?.value || NONE;
if (templateName === NONE) return;
const config = loadConfig();
const template = config.templates.find((t) => t.name === templateName);
if (!template?.policy) {
toast('Selected template was not found');
return;
}
const nameInput = page.querySelector('#txtUsername')
|| page.querySelector('input[type="text"]');
const passwordInput = page.querySelector('#txtPassword')
|| page.querySelector('input[type="password"]');
const name = (nameInput?.value || '').trim();
const password = passwordInput?.value || '';
if (!name) {
toast('Username is required');
nameInput?.focus();
return;
}
const kefinSave = page.querySelector('.um-kefin-save');
if (kefinSave) kefinSave.disabled = true;
try {
const created = await createUser(name, password);
const userId = created?.Id || created?.id;
if (!userId) throw new Error('User created but no Id returned');
await applyPolicy(userId, clonePolicy(template.policy));
toast(`User "${name}" created with template "${templateName}"`);
window.location.hash = '#/dashboard/users';
} catch (e) {
ERR('Failed to create user with template', e);
toast(`Failed to create user: ${e.message || e}`);
} finally {
if (kefinSave) kefinSave.disabled = false;
}
}
async function injectAddUserPage() {
const page = await waitForElement(getNewUserPage);
if (!page) {
WARN('New user page not found');
return;
}
if (page.getAttribute(MARKER_ADD) === '1') {
// Refresh template options in case config changed
refreshAddUserTemplateSelect(page);
return;
}
const passwordField = await waitForElement(() =>
page.querySelector('#txtPassword')
|| page.querySelector('input[type="password"]')
);
if (!passwordField) {
WARN('Password field not found on new user page');
return;
}
const config = loadConfig();
const defaultName = config.defaultTemplateName && config.templates.some((t) => t.name === config.defaultTemplateName)
? config.defaultTemplateName
: NONE;
const templatesContainer = document.createElement('div');
templatesContainer.className = 'um-template-field inputContainer';
templatesContainer.innerHTML = `
<div class="um-template-field-row">
<div class="selectContainer">
<label class="selectLabel" for="um-template-select">Template</label>
<select is="emby-select" id="um-template-select" class="emby-select-withcolor emby-select">
${getTemplateOptionsHtml(config, true)}
</select>
</div>
</div>
`;
const editTemplatesContainer = document.createElement('div');
editTemplatesContainer.className = 'um-template-field inputContainer';
editTemplatesContainer.innerHTML = `
<div class="um-template-field-row">
<button type="button" class="emby-button raised um-edit-templates-btn block" title="Edit Templates">
Edit Templates
</button>
</div>
`;
const passwordContainer = passwordField.closest('.inputContainer') || passwordField.parentElement;
if (passwordContainer?.parentElement) {
passwordContainer.parentElement.insertBefore(templatesContainer, passwordContainer.nextSibling);
passwordContainer.parentElement.insertBefore(editTemplatesContainer, templatesContainer.nextSibling);
} else {
passwordField.insertAdjacentElement('afterend', templatesContainer);
passwordField.insertAdjacentElement('afterend', editTemplatesContainer);
}
const select = templatesContainer.querySelector('#um-template-select');
if (select) {
select.value = defaultName;
if (![...select.options].some((o) => o.value === defaultName)) {
select.value = NONE;
}
}
editTemplatesContainer.querySelector('.um-edit-templates-btn')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (window.KefinUserManagerConfig?.openConfigModal) {
window.KefinUserManagerConfig.openConfigModal();
} else if (window.KefinTweaksFeatureConfigs?.userManager?.openConfigModal) {
window.KefinTweaksFeatureConfigs.userManager.openConfigModal();
} else {
toast('User Manager configuration is not available');
}
});
select?.addEventListener('change', () => {
updateSaveVisibility(page, select.value || NONE);
});
// Kefin Save button next to native submit
const nativeSave = await waitForElement(() => findNativeSaveButton(page), 5000);
const kefinSave = document.createElement('button');
kefinSave.type = 'button';
kefinSave.className = 'raised button-submit block emby-button um-kefin-save';
kefinSave.textContent = 'Save';
kefinSave.style.display = 'none';
kefinSave.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
handleKefinSave(page);
});
if (nativeSave?.parentElement) {
nativeSave.parentElement.insertBefore(kefinSave, nativeSave.nextSibling);
} else {
const form = page.querySelector('form') || page;
form.appendChild(kefinSave);
}
page.setAttribute(MARKER_ADD, '1');
updateSaveVisibility(page, select?.value || NONE);
LOG('Injected template controls on add-user page');
}
function refreshAddUserTemplateSelect(page) {
const select = page.querySelector('#um-template-select');
if (!select) return;
const prev = select.value;
const config = loadConfig();
select.innerHTML = getTemplateOptionsHtml(config, true);
if ([...select.options].some((o) => o.value === prev)) {
select.value = prev;
} else if (config.defaultTemplateName && [...select.options].some((o) => o.value === config.defaultTemplateName)) {
select.value = config.defaultTemplateName;
} else {
select.value = NONE;
}
updateSaveVisibility(page, select.value || NONE);
}
async function openBulkEditor() {
if (!window.ModalSystem?.create) {
toast('Modal system not available');
return;
}
const config = loadConfig();
if (!config.templates.length) {
toast('Create a user template first in Features → User Manager');
return;
}
let users = [];
try {
users = await fetchUsers();
} catch (e) {
ERR('Failed to load users', e);
toast(`Failed to load users: ${e.message || e}`);
return;
}
users = users
.filter((u) => u?.Id && u.Name)
.sort((a, b) => String(a.Name).localeCompare(String(b.Name), undefined, { sensitivity: 'base' }));
const defaultTemplate = config.defaultTemplateName && config.templates.some((t) => t.name === config.defaultTemplateName)
? config.defaultTemplateName
: config.templates[0].name;
const content = document.createElement('div');
content.className = 'um-bulk-root';
content.innerHTML = `
<div class="selectContainer">
<label class="selectLabel" for="um-bulk-template">Template</label>
<select is="emby-select" id="um-bulk-template" class="emby-select-withcolor emby-select">
${getTemplateOptionsHtml(config, false)}
</select>
</div>
<div class="um-bulk-toolbar">
<button type="button" class="emby-button raised" id="um-bulk-select-all">Select all</button>
<button type="button" class="emby-button raised" id="um-bulk-select-none">Select none</button>
</div>
<div class="um-bulk-user-list">
${users.map((u) => `
<label class="um-bulk-user-item">
<input type="checkbox" class="um-bulk-user-check" value="${escapeAttr(u.Id)}" data-name="${escapeAttr(u.Name)}" />
<span>${escapeHtml(u.Name)}</span>
</label>
`).join('') || '<div style="padding:0.75em;opacity:0.7;">No users found</div>'}
</div>
<div class="um-bulk-status" id="um-bulk-status"></div>
`;
const templateSelect = content.querySelector('#um-bulk-template');
if (templateSelect) templateSelect.value = defaultTemplate;
content.querySelector('#um-bulk-select-all')?.addEventListener('click', () => {
content.querySelectorAll('.um-bulk-user-check').forEach((cb) => { cb.checked = true; });
syncApplyEnabled();
});
content.querySelector('#um-bulk-select-none')?.addEventListener('click', () => {
content.querySelectorAll('.um-bulk-user-check').forEach((cb) => { cb.checked = false; });
syncApplyEnabled();
});
content.querySelectorAll('.um-bulk-user-check').forEach((cb) => {
cb.addEventListener('change', syncApplyEnabled);
});
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-bulk-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-bulk-apply" disabled>Apply</button>
`;
function syncApplyEnabled() {
const any = !!content.querySelector('.um-bulk-user-check:checked');
const applyBtn = footer.querySelector('#um-bulk-apply');
if (applyBtn) applyBtn.disabled = !any;
}
footer.querySelector('#um-bulk-cancel')?.addEventListener('click', () => {
window.ModalSystem.close(BULK_MODAL_ID);
});
footer.querySelector('#um-bulk-apply')?.addEventListener('click', async () => {
const applyBtn = footer.querySelector('#um-bulk-apply');
const statusEl = content.querySelector('#um-bulk-status');
const templateName = templateSelect?.value;
const template = loadConfig().templates.find((t) => t.name === templateName);
if (!template?.policy) {
toast('Selected template was not found');
return;
}
const selected = [...content.querySelectorAll('.um-bulk-user-check:checked')].map((cb) => ({
id: cb.value,
name: cb.dataset.name || cb.value
}));
if (!selected.length) return;
if (applyBtn) applyBtn.disabled = true;
let ok = 0;
let fail = 0;
for (let i = 0; i < selected.length; i++) {
const user = selected[i];
if (statusEl) {
statusEl.textContent = `Applying (${i + 1}/${selected.length}): ${user.name}`;
}
try {
await applyPolicy(user.id, clonePolicy(template.policy));
ok++;
} catch (e) {
fail++;
ERR(`Failed policy apply for ${user.name}`, e);
}
}
const summary = fail
? `Applied template to ${ok} user(s); ${fail} failed`
: `Applied template to ${ok} user(s)`;
if (statusEl) statusEl.textContent = summary;
toast(summary);
if (applyBtn) applyBtn.disabled = false;
if (!fail) {
window.ModalSystem.close(BULK_MODAL_ID);
}
});
const modal = window.ModalSystem.create({
id: BULK_MODAL_ID,
title: 'Bulk edit user accounts',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
});
if (modal?.dialog) {
modal.dialog.style.maxWidth = '720px';
modal.dialog.style.width = '90vw';
}
syncApplyEnabled();
}
async function injectUsersListPage() {
const addBtn = await waitForElement(() =>
document.querySelector('#btnAddUser')
|| document.querySelector('button[title*="Add user" i]')
|| document.querySelector('.btnAddUser')
);
if (!addBtn) {
WARN('Add user button not found on users list');
return;
}
const parent = addBtn.parentElement || addBtn.closest('.fabSection') || addBtn.closest('.sectionTitleContainer');
if (!parent) return;
if (parent.querySelector('.um-bulk-fab') || parent.getAttribute(MARKER_BULK) === '1') return;
const fab = document.createElement('button');
fab.type = 'button';
fab.className = 'fab btnFloating um-bulk-fab sectionTitleButton';
fab.title = 'Bulk edit user accounts';
fab.setAttribute('is', 'emby-button');
fab.innerHTML = '<span class="material-icons edit" aria-hidden="true"></span>';
fab.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openBulkEditor();
});
// Place next to add-user fab
if (addBtn.nextSibling) {
parent.insertBefore(fab, addBtn.nextSibling);
} else {
parent.appendChild(fab);
}
parent.setAttribute(MARKER_BULK, '1');
LOG('Injected bulk-edit fab on users list');
}
async function handleView(hash) {
try {
const admin = !!(await window.apiHelper?.isAdmin?.());
if (!admin) return;
if (isAddUserPage(hash)) {
await injectAddUserPage();
} else if (isUsersListPage(hash)) {
await injectUsersListPage();
}
} catch (e) {
ERR('onViewPage handler error', e);
}
}
function register() {
if (!window.KefinTweaksUtils?.onViewPage) {
WARN('onViewPage unavailable, retrying...');
setTimeout(register, 500);
return;
}
window.KefinTweaksUtils.onViewPage((view, element, hash) => {
// Defer slightly so Jellyfin finishes painting the form
setTimeout(() => handleView(hash || window.location.hash), 250);
}, {
pages: ['dashboard/users']
});
// Immediate check for already-open page
setTimeout(() => handleView(window.location.hash), 400);
LOG('User Manager handlers registered');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', register);
} else {
register();
}
})();
+2062 -118
View File
File diff suppressed because it is too large. Load diff
+351
View File
@@ -0,0 +1,351 @@
// KefinTweaks Item Version Preferences
// Reorders and sanitizes media source options on item details pages
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks VersionPreferences]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks VersionPreferences]', ...args);
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];
const SELECTOR = '.itemDetailPage:not(.hide) .selectSourceContainer select.detailTrackSelect';
const CONTAINER_SELECTOR = '.itemDetailPage:not(.hide) .selectSourceContainer';
const ORIGINAL_ATTR = 'data-kefin-original-label';
const SELECTED_INDEX_ATTR = 'data-selected-index';
const CHANGE_BOUND_ATTR = 'data-kefin-vp-change-bound';
function isNoneSentinel(term) {
return typeof term === 'string' && term.trim().toLowerCase() === NONE_SENTINEL.toLowerCase();
}
/**
* Keep exactly one "None" sentinel (canonical casing). Missing → append at end.
*/
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
};
}
function getPreferences() {
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
};
}
function indexOfNone(terms) {
const idx = (terms || []).findIndex(isNoneSentinel);
return idx >= 0 ? idx : (terms || []).length;
}
/**
* Highest-priority (earliest array index) case-insensitive substring match.
* Skips the "None" sentinel — it is not a text match.
* @returns {{ term: string, index: number } | null}
*/
function findBestTerm(label, terms) {
if (!label || !Array.isArray(terms) || !terms.length) return null;
const haystack = String(label).toLowerCase();
for (let i = 0; i < terms.length; i++) {
const term = terms[i];
if (typeof term !== 'string' || !term.trim()) continue;
if (isNoneSentinel(term)) continue;
if (haystack.includes(term.toLowerCase())) {
return { term, index: i };
}
}
return null;
}
function getOriginalLabel(option) {
if (!option) return '';
if (option.hasAttribute(ORIGINAL_ATTR)) {
return option.getAttribute(ORIGINAL_ATTR) || '';
}
const label = option.textContent || '';
option.setAttribute(ORIGINAL_ATTR, label);
return label;
}
function sanitizeLabel(editionTerm, versionTerm, showVersionBeforeEdition = false) {
if (editionTerm && versionTerm) {
return showVersionBeforeEdition
? `${versionTerm} - ${editionTerm}`
: `${editionTerm} - ${versionTerm}`;
}
if (editionTerm) return editionTerm;
if (versionTerm) return versionTerm;
return null;
}
function desiredLabel(desc) {
if (desc.hasMatch) {
const { showVersionBeforeEdition } = getPreferences();
return sanitizeLabel(desc.editionTerm, desc.versionTerm, showVersionBeforeEdition) ?? desc.originalLabel;
}
return desc.originalLabel;
}
function rankOptions(options, prefs) {
const noneEditionIndex = indexOfNone(prefs.editions);
const noneVersionIndex = indexOfNone(prefs.versions);
const descriptors = Array.from(options).map((option, originalIndex) => {
const originalLabel = getOriginalLabel(option);
const editionMatch = findBestTerm(originalLabel, prefs.editions);
const versionMatch = findBestTerm(originalLabel, prefs.versions);
const hasMatch = !!(editionMatch || versionMatch);
return {
option,
originalIndex,
originalLabel,
hasMatch,
editionIndex: editionMatch ? editionMatch.index : noneEditionIndex,
versionIndex: versionMatch ? versionMatch.index : noneVersionIndex,
editionTerm: editionMatch?.term || null,
versionTerm: versionMatch?.term || null
};
});
descriptors.sort((a, b) => {
if (a.editionIndex !== b.editionIndex) return a.editionIndex - b.editionIndex;
if (a.versionIndex !== b.versionIndex) return a.versionIndex - b.versionIndex;
return a.originalIndex - b.originalIndex;
});
return descriptors;
}
function isSelectInExpectedState(select, ranked) {
const options = Array.from(select.options);
if (options.length !== ranked.length) return false;
for (let i = 0; i < ranked.length; i++) {
if (options[i] !== ranked[i].option) return false;
if (options[i].textContent !== desiredLabel(ranked[i])) return false;
}
return true;
}
function parseSelectedIndexAttr(select) {
if (!select?.hasAttribute(SELECTED_INDEX_ATTR)) return null;
const raw = select.getAttribute(SELECTED_INDEX_ATTR);
const idx = parseInt(raw, 10);
return Number.isFinite(idx) ? idx : null;
}
function bindSelectedIndexTracker(select) {
if (!select || select.hasAttribute(CHANGE_BOUND_ATTR)) return;
select.setAttribute(CHANGE_BOUND_ATTR, 'true');
select.addEventListener('change', () => {
select.setAttribute(SELECTED_INDEX_ATTR, String(select.selectedIndex));
});
}
function applyToSelect(select) {
if (!select || !select.options?.length) return false;
bindSelectedIndexTracker(select);
const prefs = getPreferences();
const previousValue = select.value;
const savedIndex = parseSelectedIndexAttr(select);
const ranked = rankOptions(select.options, prefs);
if (isSelectInExpectedState(select, ranked)) {
return false;
}
isApplying = true;
try {
ranked.forEach((desc, i) => {
const label = desiredLabel(desc);
if (desc.option.textContent !== label) {
desc.option.textContent = label;
}
if (select.options[i] !== desc.option) {
select.appendChild(desc.option);
}
});
const maxIndex = select.options.length - 1;
let targetIndex = 0;
if (savedIndex != null && savedIndex >= 0 && savedIndex <= maxIndex) {
targetIndex = savedIndex;
}
const targetValue = select.options[targetIndex]?.value;
if (targetValue != null && targetValue !== '') {
if (select.selectedIndex !== targetIndex) {
select.selectedIndex = targetIndex;
}
if (select.value !== targetValue) {
select.value = targetValue;
}
if (targetValue !== previousValue) {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}
select.setAttribute(SELECTED_INDEX_ATTR, String(select.selectedIndex));
LOG('Applied version preferences to detailTrackSelect', {
count: ranked.length,
selectedIndex: select.selectedIndex,
selected: select.value,
restored: savedIndex != null
});
return true;
} finally {
isApplying = false;
}
}
function findSourceSelect() {
return document.querySelector(SELECTOR);
}
let observer = null;
let applyScheduled = false;
let isApplying = false;
function disconnectObserver() {
if (observer) {
observer.disconnect();
observer = null;
}
applyScheduled = false;
}
function tryApply() {
if (isApplying) return false;
const select = findSourceSelect();
if (!select || !select.options?.length) return false;
return applyToSelect(select);
}
function scheduleTryApply() {
if (isApplying || applyScheduled) return;
applyScheduled = true;
requestAnimationFrame(() => {
applyScheduled = false;
if (!observer) return;
tryApply();
});
}
function isDetailsHash(hash) {
const h = hash || window.location.hash || '';
return h.includes('details.html') || h.includes('/details');
}
function isRelevantMutation(mutations) {
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
const target = mutation.target;
if (target?.matches?.('select.detailTrackSelect')) return true;
if (target?.tagName === 'OPTION' && !target.hasAttribute(ORIGINAL_ATTR)) return true;
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.('select.detailTrackSelect')) return true;
if (node.matches?.('option') && !node.hasAttribute(ORIGINAL_ATTR)) return true;
if (node.querySelector?.('select.detailTrackSelect, option')) return true;
}
}
return false;
}
function observeForSelect() {
disconnectObserver();
tryApply();
const container = document.querySelector(CONTAINER_SELECTOR);
if (!container) {
return;
}
observer = new MutationObserver((mutations) => {
if (isApplying) return;
if (!isRelevantMutation(mutations)) return;
scheduleTryApply();
});
observer.observe(container, { childList: true, subtree: true });
}
function onDetailsPage() {
observeForSelect();
}
function init() {
if (!window.KefinTweaksUtils?.onViewPage) {
WARN('KefinTweaksUtils.onViewPage unavailable');
return;
}
window.KefinTweaksUtils.onViewPage(() => {
try {
onDetailsPage();
} catch (err) {
WARN('Failed to apply version preferences', err);
}
}, { pages: ['details'] });
window.KefinTweaksUtils.onViewPage((_view, _element, hash) => {
if (!isDetailsHash(hash)) {
disconnectObserver();
}
});
LOG('Initialized');
}
window.KefinVersionPreferences = {
NONE_SENTINEL,
getDefaults,
getPreferences,
ensureNoneSentinel,
isNoneSentinel,
findBestTerm,
isSelectInExpectedState,
applyToSelect
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
File diff suppressed because it is too large. Load diff
+1214 -1181
View File
File diff suppressed because it is too large. Load diff
+475 -476
View File
File diff suppressed because it is too large. Load diff