mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Pray for me
This commit is contained in:
1 parent
bd45be2fd6
commit
1dbb3b8518
98 files changed
+47471
-10558
No files matched your search
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+3261
-860
File diff suppressed because it is too large.
Load diff
+218
-110
@@ -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
@@ -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
@@ -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');
|
||||
})();
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
} */
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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
@@ -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?.());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
Vendored
+21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, '"')}</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '&').replace(/"/g, '"')}"`))
|
||||
.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
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return escapeHtml(str).replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+475
-476
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user