Merge Experimental branch into main... finally!

This commit is contained in:
HighImKevin
2026-10-18 15:14:38 -04:00
275 changed files with 95675 additions and 7838 deletions
+213
View File
@@ -0,0 +1,213 @@
interface HomeScreenSection {
// Identification
id: string;
name: string;
description?: string; // Description of the section, useful when the Section Name is insufficient at describing the section or for sharing Custom Sections
caption?: string;
enabled: boolean;
order: number;
category?: string; // Stable category id (custom sections); runtime may force discovery/pinned
// 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)
useParentCard?: boolean; // When true, use the parent item will be rendered as the card image and link
// Loads items from an external URL and loads matches from that list from your Jellyfin library
// Currently supports: mdblist.com only
externalListUrls?: string[];
// 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;
backdropUrl?: string;
bannerUrl?: string;
logoUrl?: string;
thumbUrl?: string;
squareUrl?: string;
cardUrl: string;
hidden?: boolean; // Whether to hide the item from the section
}>;
// 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;
discoveryItemType?: string;
discoveryPersonType?: string;
source?: string;
/** Pool query used to resolve a dynamic discovery entity (optional on non-discovery sections). */
discoverySourceQuery?: Query;
/** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */
pageNumber?: number;
// Seasonal Config
startDate?: string;
endDate?: string;
dataMonitors?: string[]; // A list of data monitor names to use for this section
// 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')
/** Args passed to the cache method (e.g. ItemType, MinCount) — not post-processing */
dataSourceOptions?: {
ItemType?: string;
MinCount?: number;
[key: string]: any;
};
minAge?: number; // Minimum age in days
maxAge?: number; // Maximum age in days
queryOptions?: {
// Query parameters for /Items or custom endpoints / Client Cache post-processing
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;renderMode(Normal|Spotlight|Random)
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;
/** User override of the section's render mode. */
renderMode?: "Normal" | "Spotlight" | "Random";
}
+1018 -56
View File
File diff suppressed because it is too large Load Diff
+28 -13
View File
@@ -9,15 +9,11 @@
LOG('Script loaded - JS Injector mode');
// Step 1: watch for .backdropContainer to appear
const waitForContainer = new MutationObserver(() => {
const container = document.querySelector('.backdropContainer');
if (!container) return;
function attachPruneObserver(container) {
if (!container || container.dataset.kefinBackdropPrune === 'true') return;
container.dataset.kefinBackdropPrune = 'true';
LOG('Found .backdropContainer, attaching prune observer');
waitForContainer.disconnect();
// Step 2: prune children as needed
const pruneObserver = new MutationObserver(() => {
LOG('Mutation detected, checking children count:', container.children.length);
while (container.children.length > 2) {
@@ -25,16 +21,35 @@
container.removeChild(container.firstElementChild);
}
});
pruneObserver.observe(container, { childList: true });
LOG('Prune observer is now active');
});
}
waitForContainer.observe(document.body, {
childList: true,
subtree: true
});
function startWait() {
const utils = window.KefinTweaksUtils;
const existing = document.querySelector('.backdropContainer');
if (existing) {
attachPruneObserver(existing);
return;
}
if (utils?.waitForElement) {
utils.waitForElement('.backdropContainer', { timeout: 60000, quiet: true }).then((container) => {
if (container) attachPruneObserver(container);
else WARN_MISSING();
});
return;
}
// Fallback if utils not ready yet
setTimeout(startWait, 500);
}
function WARN_MISSING() {
console.warn('[KefinTweaks BackdropLeakFix] .backdropContainer not found within timeout');
}
startWait();
LOG('Waiting for .backdropContainer to appear...');
LOG('Backdrop leak fix functionality initialized');
})();
+130 -84
View File
@@ -1,103 +1,149 @@
/* 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;
}
.MuiToolbar-root .kefinTweaks-breadcrumbs-wrapper:hover {
background: transparent;
border-radius: 0;
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1001;
}
.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 span {
display: inline;
}
.MuiToolbar-root .kefinTweaks-breadcrumbs,
.kefinTweaks-breadcrumbs-wrapper--modern .kefinTweaks-breadcrumbs {
font-size: 1em;
padding: 0px 12px 8px;
}
.kefinTweaks-breadcrumbs span:hover {
color: rgba(255, 255, 255, 1);
}
.MuiToolbar-root .kefinTweaks-popover {
margin-top: 20px;
}
/* Hover effects for clickable breadcrumbs only */
.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
text-decoration: underline !important;
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1001;
pointer-events: all;
}
/* Popover styles for seasons and albums */
.kefinTweaks-popover {
position: relative;
background-color: rgba(29, 38, 53, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 0;
min-width: 200px;
max-height: 300px;
overflow-y: auto;
z-index: 1001;
display: block;
margin-top: 8px;
}
/* When breadcrumbs are at bottom of page, open popover above */
.kefinTweaks-popover-container.kefinTweaks-popover-container--above {
top: auto;
bottom: 100%;
}
.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.kefinTweaks-popover--above {
margin-top: 0;
margin-bottom: 8px;
}
.kefinTweaks-popover-item:hover {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-breadcrumbs span {
display: inline;
}
.kefinTweaks-popover-item:last-child {
border-bottom: none;
}
.kefinTweaks-breadcrumbs span:hover {
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-popover-item.selected {
color: rgba(255, 255, 255, 1);
font-weight: bold;
}
/* Hover effects for clickable breadcrumbs only */
.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
text-decoration: underline !important;
}
.kefinTweaks-popover-item.selected::before {
content: '>';
position: absolute;
left: 8px;
top: calc(50% - 1px);
transform: translateY(-50%);
font-weight: bold;
}
/* 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;
}
.skinHeader {
contain: none;
}
.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;
}
.headerLeft {
overflow: visible;
}
}
.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.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;
}
.skinHeader {
contain: none;
}
.headerLeft {
overflow: visible;
}
}
.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) {
padding: 0 !important;
overflow-y: auto !important;
backdrop-filter: blur(50px);
margin-top: 0 !important;
}
.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) .kefinTweaks-popover-item.detailsGroupItem {
padding: 0.5em 2em;
margin: 0 !important;
border-radius: 0 !important;
width: initial !important;
max-width: initial !important;
white-space: nowrap;
}
header > .MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) {
flex-wrap: wrap;
}
header > .MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) #kefinTweaks-breadcrumbs-wrapper {
width: 100%;
}
+245 -109
View File
@@ -3,7 +3,7 @@
// Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum, Audio
// Requires: utils.js module to be loaded before this script
(function() {
(function () {
'use strict';
console.log('[KefinTweaks Breadcrumbs] Initializing...');
@@ -72,18 +72,26 @@
async function getTargetBreadcrumbStructure(item) {
const structure = {
elements: [],
item: item
item: item,
};
// Don't append .html for server versions 10.11 and above
const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html'
const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html';
let queryParams = '';
if (item.ParentId) {
// Get the top parent item from the ancestors with a Type = CollectionFolder
const ancestors = await ApiClient.getAncestorItems(item.Id);
const collectionFolder = ancestors.find(ancestor => ancestor.Type === 'CollectionFolder');
const userRootFolder = ancestors.find(ancestor => ancestor.Type === 'UserRootFolder');
// Use apiHelper.getQuery rather than getAncestorItems so we can use the cached data
const ancestorsUrl = `${ApiClient.serverAddress()}/Items/${item.Id}/Ancestors`;
const queryResult = await window.apiHelper.getQuery(ancestorsUrl, { useCache: true });
let ancestors = queryResult.data;
if (!ancestors || !Array.isArray(ancestors)) {
ancestors = await queryResult.dataPromise;
}
const collectionFolder = ancestors.find((ancestor) => ancestor.Type === 'CollectionFolder');
const userRootFolder = ancestors.find((ancestor) => ancestor.Type === 'UserRootFolder');
if (collectionFolder) {
queryParams = `?topParentId=${collectionFolder.Id}&serverId=${ApiClient.serverId()}`;
} else if (userRootFolder) {
@@ -91,21 +99,20 @@
} else {
queryParams = `?serverId=${ApiClient.serverId()}`;
}
}
else if (ApiClient.serverId()) {
} else if (ApiClient.serverId()) {
queryParams = `?serverId=${ApiClient.serverId()}`;
}
if (item.Type === 'Movie') {
structure.elements = [
{ text: 'Movies', url: `#/movies${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: item.Name, url: null, clickable: false }
{ text: item.Name, url: null, clickable: false },
];
} else if (item.Type === 'Series') {
structure.elements = [
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: item.Name, url: null, clickable: false },
{ text: 'All Seasons', url: null, clickable: true, popover: true }
{ text: 'All Seasons', url: null, clickable: true, popover: true },
];
} else if (item.Type === 'Season') {
// Get series details for the series name
@@ -114,8 +121,12 @@
structure.elements = [
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
{ text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true }
{
text: seriesName,
url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`,
clickable: true,
},
{ text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true },
];
} else if (item.Type === 'Episode') {
// Get series details for the series name
@@ -124,26 +135,36 @@
// Get season details for the season name
const seasonDetails = await getItemDetails(item.ParentId);
const seasonName = seasonDetails ? (seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`) : `Season ${item.ParentIndexNumber}`;
const seasonName = seasonDetails
? seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`
: `Season ${item.ParentIndexNumber}`;
structure.elements = [
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true },
{
text: seriesName,
url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`,
clickable: true,
},
{ text: seasonName, url: null, clickable: true, popover: true },
{ text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false }
{ text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false },
];
} else if (item.Type === 'MusicArtist') {
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: item.Name, url: null, clickable: false },
{ text: 'All Albums', url: null, clickable: true, popover: true }
{ text: 'All Albums', url: null, clickable: true, popover: true },
];
} else if (item.Type === 'MusicAlbum') {
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: item.AlbumArtist, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
{
text: item.AlbumArtist,
url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`,
clickable: true,
},
{ text: item.Name, url: null, clickable: true, popover: true },
{ text: 'All Songs', url: null, clickable: true, popover: true }
{ text: 'All Songs', url: null, clickable: true, popover: true },
];
} else if (item.Type === 'Audio') {
// Get album details for the album name and artist info
@@ -154,9 +175,13 @@
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: artistName, url: artistId ? `${ApiClient._serverAddress}/web/#/details?id=${artistId}&serverId=${ApiClient.serverId()}` : null, clickable: !!artistId },
{
text: artistName,
url: artistId ? `${ApiClient._serverAddress}/web/#/details?id=${artistId}&serverId=${ApiClient.serverId()}` : null,
clickable: !!artistId,
},
{ text: albumName, url: null, clickable: true, popover: true },
{ text: item.Name, url: null, clickable: false }
{ text: item.Name, url: null, clickable: false },
];
}
@@ -168,8 +193,8 @@
const differences = [];
// Check if we have the right number of elements
const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
child.classList.contains('kefinTweaks-breadcrumb-element')
const existingElements = Array.from(breadcrumbContainer.children).filter((child) =>
child.classList.contains('kefinTweaks-breadcrumb-element'),
);
log('Comparing breadcrumbs - existing:', existingElements.length, 'target:', target.elements.length);
@@ -192,14 +217,14 @@
const targetClickable = targetElement.clickable;
// Check if popover functionality needs to be updated
const needsPopoverUpdate = targetElement.popover && (
existingText !== targetElement.text ||
existingClickable !== targetClickable ||
// Force update for "All Seasons", "All Albums", and "All Songs" elements since the underlying data changes
targetElement.text === 'All Seasons' ||
targetElement.text === 'All Albums' ||
targetElement.text === 'All Songs'
);
const needsPopoverUpdate =
targetElement.popover &&
(existingText !== targetElement.text ||
existingClickable !== targetClickable ||
// Force update for "All Seasons", "All Albums", and "All Songs" elements since the underlying data changes
targetElement.text === 'All Seasons' ||
targetElement.text === 'All Albums' ||
targetElement.text === 'All Songs');
if (existingText !== targetElement.text || existingClickable !== targetClickable || needsPopoverUpdate) {
differences.push({ action: 'update', index: i, element: targetElement });
@@ -220,8 +245,8 @@
log('Applying breadcrumb differences:', differences.length, 'changes needed');
// Get existing elements
const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
child.classList.contains('kefinTweaks-breadcrumb-element')
const existingElements = Array.from(breadcrumbContainer.children).filter((child) =>
child.classList.contains('kefinTweaks-breadcrumb-element'),
);
// Process differences in reverse order to maintain indices
@@ -248,7 +273,6 @@
}
log('Added new breadcrumb element:', diff.element.text);
} else if (diff.action === 'remove') {
// Remove element and its separator
const elementToRemove = existingElements[diff.index];
@@ -260,7 +284,6 @@
breadcrumbContainer.removeChild(elementToRemove);
log('Removed breadcrumb element at index:', diff.index);
}
} else if (diff.action === 'update') {
// Update existing element
const elementToUpdate = existingElements[diff.index];
@@ -378,7 +401,7 @@
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
const currentSeason = seasons.find(season => season.Id === item.ParentId);
const currentSeason = seasons.find((season) => season.Id === item.ParentId);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, currentSeason, (selectedSeason) => {
@@ -393,14 +416,12 @@
let albumArtists = item.AlbumArtists;
if (!albumArtists || !Array.isArray(albumArtists) || albumArtists.length === 0) {
const albumDetails = await getItemDetails(item.Id);
albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : [];
albumArtists = albumDetails && albumDetails.AlbumArtists ? albumDetails.AlbumArtists : [];
}
const artistIds = (albumArtists || []).map(a => a.Id).filter(Boolean);
const albums = artistIds.length > 0
? await getAlbumsByArtistIds(artistIds)
: await getAlbumsIfNeeded(item.ParentId);
const artistIds = (albumArtists || []).map((a) => a.Id).filter(Boolean);
const albums = artistIds.length > 0 ? await getAlbumsByArtistIds(artistIds) : await getAlbumsIfNeeded(item.ParentId);
if (albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
@@ -417,12 +438,13 @@
} else if (item.Type === 'Audio') {
// Audio (Song) page - clicking album name should show albums from all AlbumArtists of the album
const albumDetails = await getItemDetails(item.ParentId);
const albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : [];
const artistIds = albumArtists.map(a => a.Id).filter(Boolean);
const albums = artistIds.length > 0
? await getAlbumsByArtistIds(artistIds)
: await getAlbumsIfNeeded(albumDetails ? albumDetails.ParentId : null);
const albumArtists = albumDetails && albumDetails.AlbumArtists ? albumDetails.AlbumArtists : [];
const artistIds = albumArtists.map((a) => a.Id).filter(Boolean);
const albums =
artistIds.length > 0
? await getAlbumsByArtistIds(artistIds)
: await getAlbumsIfNeeded(albumDetails ? albumDetails.ParentId : null);
if (albums && albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
@@ -463,15 +485,30 @@
}
// DOM manipulation
function isModernUI() {
if (ApiClient._appVersion && ApiClient._appVersion.startsWith('12') && localStorage.getItem('layout')?.length === 0) {
return true;
}
return !!document.querySelector('main.MuiBox-root');
}
function resolveBreadcrumbMount() {
if (isModernUI()) {
return document.querySelector('.MuiToolbar-root');
}
return document.querySelector('.skinHeader .headerLeft');
}
function createBreadcrumbContainer() {
if (breadcrumbContainer) {
return breadcrumbContainer;
}
// Find the header left container
const headerLeft = document.querySelector('.skinHeader .headerLeft');
if (!headerLeft) {
error('Could not find .skinHeader .headerLeft element');
const isModern = isModernUI();
const mount = resolveBreadcrumbMount();
if (!mount) {
error(isModern ? 'Could not find .MuiToolbar-root for breadcrumbs' : 'Could not find .skinHeader .headerLeft element');
return null;
}
@@ -479,6 +516,9 @@
const wrapper = document.createElement('div');
wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
if (isModern) {
wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern');
}
wrapper.style.cssText = `
display: none;
`;
@@ -501,12 +541,39 @@
wrapper.appendChild(breadcrumbContainer);
wrapper.appendChild(popoverContainer);
headerLeft.appendChild(wrapper);
mount.appendChild(wrapper);
log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
log(
isModern
? 'Created breadcrumb container inside .MuiToolbar-root'
: 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft',
);
return breadcrumbContainer;
}
function ensureBreadcrumbContainer(retries = 20) {
const existing = createBreadcrumbContainer();
if (existing) return Promise.resolve(existing);
return new Promise((resolve) => {
let attempts = 0;
const tryMount = () => {
const container = createBreadcrumbContainer();
if (container) {
resolve(container);
return;
}
attempts += 1;
if (attempts >= retries) {
resolve(null);
return;
}
setTimeout(tryMount, 100);
};
tryMount();
});
}
function showBreadcrumbs() {
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
const wideScreen = isWideScreen();
@@ -538,7 +605,7 @@
}
closePopover();
}
function closePopover() {
if (activePopover) {
activePopover.remove();
@@ -548,16 +615,16 @@
function createPopover(items, currentItem, onItemClick) {
const popover = document.createElement('div');
popover.className = 'kefinTweaks-popover';
popover.className = 'kefinTweaks-popover itemDetailsGroup';
popover.style.display = 'block'; // Ensure it's visible
log('Creating popover with', items.length, 'items');
let selectedItemElement = null;
items.forEach(item => {
items.forEach((item) => {
const itemElement = document.createElement('div');
itemElement.className = 'kefinTweaks-popover-item';
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem';
if (currentItem && item.Id === currentItem.Id) {
itemElement.classList.add('selected');
@@ -590,7 +657,7 @@
selectedItemElement.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest'
inline: 'nearest',
});
log('Auto-scrolled to selected item:', selectedItemElement.textContent);
}, 10);
@@ -610,16 +677,31 @@
const popoverContainer = document.getElementById('kefinTweaks-popover-container');
if (popoverContainer) {
// Calculate the left offset to align with the trigger element
// Since breadcrumbs are now in the header, we need to calculate relative to the page
const triggerRect = triggerElement.getBoundingClientRect();
const popoverContainerRect = popoverContainer.getBoundingClientRect();
// Calculate offset from popover container to trigger element
const leftOffset = triggerRect.left - popoverContainerRect.left - 22;
popover.style.left = `${leftOffset}px`;
// Decide whether to open above or below based on viewport space
// When breadcrumbs are at the bottom of the page, open above
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const showAbove = spaceBelow < spaceAbove;
popoverContainer.classList.toggle('kefinTweaks-popover-container--above', showAbove);
popover.classList.toggle('kefinTweaks-popover--above', showAbove);
// Clear inline top when above so CSS bottom: 100% takes effect; restore when below
if (showAbove) {
popoverContainer.style.top = '';
popoverContainer.style.bottom = '100%';
} else {
popoverContainer.style.top = 'calc(100% - 15px)';
popoverContainer.style.bottom = '';
}
popoverContainer.appendChild(popover);
log('Popover added to popover container with left offset:', leftOffset);
log('Popover added to popover container with left offset:', leftOffset, 'showAbove:', showAbove);
} else {
log('Popover container not found, falling back to trigger element');
triggerElement.style.position = 'relative';
@@ -646,11 +728,17 @@
if (!currentUserId) {
throw new Error('No user ID found');
}
const url = ApiClient.getUrl(`Users/${currentUserId}/Items/${itemId}`);
const response = await ApiClient.getJSON(url);
log('Retrieved item details:', response.Name, response.Type);
return response;
const response = await window.apiHelper.getQuery(url, { useCache: true });
if (!response.data) {
response.data = await response.dataPromise;
}
const item = response.data;
log('Retrieved item details:', item.Name, item.Type);
return item;
} catch (err) {
console.error('Failed to get item details:', err);
return null;
@@ -659,13 +747,14 @@
async function getSeasons(seriesId) {
try {
const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`, {
userId: ApiClient.getCurrentUserId(),
imageTypeLimit: 1
});
const response = await ApiClient.getJSON(url);
log('Retrieved seasons:', response.Items?.length || 0);
return response.Items || [];
const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`);
const response = await window.apiHelper.getQuery(url, { useCache: true });
if (!response.data) {
response.data = await response.dataPromise;
}
const seasons = response.data.Items || response.data || [];
log('Retrieved seasons:', seasons.length);
return seasons;
} catch (err) {
error('Failed to get seasons:', err);
return [];
@@ -683,11 +772,15 @@
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: artistId,
SortBy: 'PremiereDate,ProductionYear,Sortname'
SortBy: 'PremiereDate,ProductionYear,Sortname',
});
const response = await ApiClient.getJSON(url);
log('Retrieved albums:', response.Items?.length || 0);
return response.Items || [];
const response = await window.apiHelper.getQuery(url, { useCache: true });
if (!response.data) {
response.data = await response.dataPromise;
}
const albums = response.data.Items || response.data || [];
log('Retrieved albums:', albums.length);
return albums;
} catch (err) {
error('Failed to get albums:', err);
return [];
@@ -706,10 +799,13 @@
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: idsParam,
SortBy: 'PremiereDate,ProductionYear,Sortname'
SortBy: 'PremiereDate,ProductionYear,Sortname',
});
const response = await ApiClient.getJSON(url);
const items = response.Items || [];
const response = await window.apiHelper.getQuery(url, { useCache: true });
if (!response.data) {
response.data = await response.dataPromise;
}
const items = response.data.Items || response.data || [];
// Deduplicate by Id when multiple artists overlap
const unique = [];
const seen = new Set();
@@ -736,11 +832,15 @@
Fields: 'ParentId',
Limit: 100,
StartIndex: 0,
SortBy: 'IndexNumber,SortName'
SortBy: 'IndexNumber,SortName',
});
const response = await ApiClient.getJSON(url);
log('Retrieved songs:', response.Items?.length || 0);
return response.Items || [];
const response = await window.apiHelper.getQuery(url, { useCache: true });
if (!response.data) {
response.data = await response.dataPromise;
}
const songs = response.data.Items || response.data || [];
log('Retrieved songs:', songs.length);
return songs;
} catch (err) {
error('Failed to get songs:', err);
return [];
@@ -755,6 +855,26 @@
return separator;
}
// Serialize page-change work so overlapping view callbacks cannot race the DOM.
// Latest-wins pending: overwrite the single pending slot; drain runs one at a time.
let pageChangePending = undefined; // undefined = empty; null/item = pending job
let pageChangeDrain = Promise.resolve();
function enqueuePageChange(item = null) {
pageChangePending = item;
pageChangeDrain = pageChangeDrain
.then(async () => {
if (pageChangePending === undefined) return;
const next = pageChangePending;
pageChangePending = undefined;
await handlePageChange(next);
})
.catch((err) => {
error('Breadcrumb page change queue failed:', err);
});
return pageChangeDrain;
}
// Page change handler
// Simplified page change handler
async function handlePageChange(item = null) {
@@ -770,7 +890,7 @@
hideBreadcrumbs();
return;
}
// Check if the user is logged in
if (!ApiClient._loggedIn) {
clearBreadcrumbs();
@@ -779,7 +899,7 @@
}
// Extract item ID
if (!item || !item.Id) {
if (!item || !item.Id || (item.Type !== 'Series' && item.Type !== 'Season' && item.Type !== 'Episode')) {
clearBreadcrumbs();
hideBreadcrumbs();
return;
@@ -794,7 +914,12 @@
// Determine target breadcrumb structure
const targetStructure = await getTargetBreadcrumbStructure(item);
log('Target breadcrumb structure:', targetStructure.elements.map(e => e.text));
log(
'Target breadcrumb structure:',
targetStructure.elements.map((e) => e.text),
);
const breadcrumbContainer = getBreadcrumbContainer();
// Check if we need to create breadcrumbs from scratch
if (!breadcrumbContainer || !currentBreadcrumbs) {
@@ -807,13 +932,16 @@
}
// Update state
currentBreadcrumbs = targetStructure;
// Update the currentBreadcrumbs from the current page
if (breadcrumbContainer && breadcrumbContainer.children) {
currentBreadcrumbs = Array.from(breadcrumbContainer.children);
}
//currentBreadcrumbs = targetStructure;
currentItemId = item.Id;
log('About to show breadcrumbs...');
showBreadcrumbs();
log('Breadcrumbs updated successfully');
} catch (err) {
error('Error handling page change:', err);
clearBreadcrumbs();
@@ -823,9 +951,13 @@
// Create breadcrumbs from scratch
async function createBreadcrumbsFromScratch(targetStructure) {
// Create container if it doesn't exist
// Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready)
if (!breadcrumbContainer) {
breadcrumbContainer = createBreadcrumbContainer();
breadcrumbContainer = await ensureBreadcrumbContainer();
}
if (!breadcrumbContainer) {
error('Breadcrumb container unavailable');
return;
}
// Build all elements first
@@ -846,11 +978,15 @@
breadcrumbContainer.innerHTML = '';
// Append all elements at once
elements.forEach(el => breadcrumbContainer.appendChild(el));
elements.forEach((el) => breadcrumbContainer.appendChild(el));
log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements');
}
function getBreadcrumbContainer() {
return document.getElementById('kefinTweaks-breadcrumbs');
}
// Window resize handler
function handleResize() {
if (currentBreadcrumbs && breadcrumbContainer) {
@@ -871,18 +1007,19 @@
log('Registering breadcrumb handler with KefinTweaksUtils');
// Register handler for all pages (breadcrumbs can appear on any detail page)
window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => {
try {
// Await the item promise to get the actual item data
const item = await itemPromise;
// Run our custom code
handlePageChange(item);
} catch (err) {
error('Breadcrumb page change handler failed:', err);
}
}, {
pages: [] // Empty array means all pages
});
window.KefinTweaksUtils.onViewPage(
async (view, element, hash, itemPromise) => {
try {
const item = await itemPromise;
await enqueuePageChange(item);
} catch (err) {
error('Breadcrumb page change handler failed:', err);
}
},
{
pages: [], // Empty array means all pages
},
);
} else {
error('KefinTweaksUtils not available, breadcrumbs may not work correctly');
}
@@ -891,7 +1028,7 @@
window.addEventListener('resize', handleResize);
// Initial check
handlePageChange();
enqueuePageChange(null);
log('Breadcrumbs initialized successfully');
}
@@ -902,5 +1039,4 @@
} else {
initialize();
}
})();
+4679 -292
View File
File diff suppressed because it is too large Load Diff
+10265 -956
View File
File diff suppressed because it is too large Load Diff
+41 -2
View File
@@ -595,6 +595,44 @@
activePage.dataset.collectionSortingAdded = 'true';
}
/**
* Update collection page by adding the played item count to the page
* @param {Object} item - Collection item (BoxSet)
*/
async function updateCollectionPage(item) {
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) {
return;
}
if (activePage.dataset.kefinCollectionPage === 'true') {
return;
}
const unplayedItemCount = item.UserData?.UnplayedItemCount || 0;
const totalItemCount = item.RecursiveItemCount || item.ChildCount || 0;
const playedItemCount = totalItemCount - unplayedItemCount;
const isVideoCollection = document.querySelector('.libraryPage:not(.hide) .collectionItems:has(.card[data-mediatype="Video"])') !== null;
const progressText = isVideoCollection ? 'watched' : 'complete';
const incompleteText = isVideoCollection ? 'unwatched' : 'incomplete';
const itemMiscInfoPrimary = document.querySelector('.libraryPage:not(.hide) .itemMiscInfo-primary');
if (itemMiscInfoPrimary && unplayedItemCount > 0) {
const mediaInfoItem = document.createElement('div');
mediaInfoItem.className = 'mediaInfoItem';
mediaInfoItem.textContent = `${playedItemCount} ${progressText}`;
itemMiscInfoPrimary.appendChild(mediaInfoItem);
const mediaInfoItemIncomplete = document.createElement('div');
mediaInfoItemIncomplete.className = 'mediaInfoItem';
mediaInfoItemIncomplete.textContent = `${unplayedItemCount} ${incompleteText}`;
itemMiscInfoPrimary.appendChild(mediaInfoItemIncomplete);
}
activePage.dataset.kefinCollectionPage = 'true';
}
/**
* Initialize collections hook
*/
@@ -618,8 +656,9 @@
// Small delay to ensure details DOM is ready
setTimeout(async () => {
if (item && item.Id) {
await addCollectionSorting(item);
if (item && item.Id && item.Type === 'BoxSet') {
addCollectionSorting(item);
updateCollectionPage(item);
}
}, 100);
},
+294 -110
View File
@@ -1,184 +1,368 @@
.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: "close";
font-family: "Material Icons";
font-size: 14px;
line-height: 22px;
text-align: center;
color: rgba(0, 0, 0, 0.55);
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 {
content: "check";
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;
}
/* Only editable feature cards use the pointer; toggles/buttons keep pointer via their own rules */
.kefin-config-wrapper .listItem.feature-card {
cursor: default;
}
.kefin-config-wrapper .listItem.feature-card.feature-card--editable {
cursor: pointer;
}
.kefin-config-wrapper .listItem.feature-card .checkboxContainer,
.kefin-config-wrapper .listItem.feature-card .emby-checkbox-label,
.kefin-config-wrapper .listItem.feature-card .emby-button {
cursor: pointer;
}
#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;
}
.manage-config-tabs .manage-tab-btn.active {
background-color: rgba(82, 181, 255, 0.25);
outline: 1px solid rgba(82, 181, 255, 0.5);
}
[data-modal-id="kefinTweaksManageModal"] [data-name="kefin-modal-content"] {
display: flex;
flex-direction: column;
min-height: 0;
}
.manage-config-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.manage-tab-panels {
position: relative;
flex: 1 1 auto;
min-height: 28rem;
height: 100%;
}
.manage-tab-panel {
position: absolute;
inset: 0;
overflow: auto;
display: none;
}
.manage-tab-panel.is-active {
display: block;
}
.manage-io-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.manage-io-textarea {
width: 100%;
flex: 1 1 auto;
min-height: 16rem;
font-family: monospace;
font-size: 0.9em;
line-height: 1.5;
resize: none;
box-sizing: border-box;
}
+1653 -745
View File
File diff suppressed because it is too large Load Diff
+50 -35
View File
@@ -1,25 +1,36 @@
// Custom Menu Links Script
// Loads custom menu links from configuration and adds them to the custom menu
// Uses utils.addCustomMenuLink to add each configured link
// Loads custom menu links from configuration and adds them via utils.addCustomMenuLink
// Requires: utils.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks CustomMenuLinks]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks CustomMenuLinks]', ...args);
const ERR = (...args) => console.error('[KefinTweaks CustomMenuLinks]', ...args);
LOG('Script loaded - JS Injector mode');
// Get custom menu links configuration
function getCustomMenuLinksConfig() {
return window.KefinTweaksConfig?.customMenuLinks || [];
}
function normalizeTopNavigation(value) {
const v = String(value || 'none').toLowerCase().trim();
if (v === 'main' || v === 'right') return v;
return 'none';
}
// Initialize custom menu links
async function initializeCustomMenuLinks() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
ERR('KefinTweaksUtils.addCustomMenuLink not available');
return;
}
if (typeof window.KefinTweaksUtils.ensureCustomMenuLinkStyles === 'function') {
window.KefinTweaksUtils.ensureCustomMenuLinkStyles();
}
const customMenuLinks = getCustomMenuLinksConfig();
if (!Array.isArray(customMenuLinks) || customMenuLinks.length === 0) {
@@ -29,27 +40,32 @@
LOG(`Loading ${customMenuLinks.length} custom menu links`);
// Check if utils is available
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
ERR('KefinTweaksUtils.addCustomMenuLink not available');
return;
}
// Add each custom menu link
const addPromises = customMenuLinks.map(async (linkConfig, index) => {
// Sequential so drawer insert order matches config order
let successCount = 0;
for (let index = 0; index < customMenuLinks.length; index++) {
const linkConfig = customMenuLinks[index];
try {
// Validate link configuration
if (!linkConfig.name || !linkConfig.url) {
WARN(`Custom menu link at index ${index} is missing required properties (name, url)`);
return false;
const name = linkConfig?.name;
const url = linkConfig?.url != null ? String(linkConfig.url).trim() : '';
const action = linkConfig?.action != null ? String(linkConfig.action).trim() : '';
if (!name || (!url && !action)) {
WARN(`Custom menu link at index ${index} is missing required properties (name, and url or action)`);
continue;
}
const {
name,
icon = 'link', // Default icon
url,
openInNewTab = false
icon = 'link',
openInNewTab = false,
collapsed = false,
sideMenu = true,
userMenu = false,
isAdminLink = false
} = linkConfig;
const topNavigation = normalizeTopNavigation(linkConfig.topNavigation);
const orderNum = Number(linkConfig.order);
const order = Number.isFinite(orderNum) ? orderNum : undefined;
LOG(`Adding custom menu link: ${name}`);
@@ -57,30 +73,32 @@
name,
icon,
url,
openInNewTab
openInNewTab,
{
collapsed: !!collapsed,
sideMenu: sideMenu !== false,
topNavigation,
userMenu: !!userMenu,
isAdminLink: !!isAdminLink,
action,
order
}
);
if (success) {
successCount += 1;
LOG(`Successfully added custom menu link: ${name}`);
} else {
WARN(`Failed to add custom menu link: ${name}`);
}
return success;
} catch (error) {
ERR(`Error adding custom menu link at index ${index}:`, error);
return false;
}
});
// Wait for all links to be processed
const results = await Promise.all(addPromises);
const successCount = results.filter(Boolean).length;
}
LOG(`Custom menu links initialization complete: ${successCount}/${customMenuLinks.length} links added successfully`);
}
// Wait for utils to be available and then initialize
function waitForUtilsAndInitialize() {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
LOG('Utils available, initializing custom menu links');
@@ -90,7 +108,6 @@
LOG('Waiting for KefinTweaksUtils to be available');
// Poll for utils availability
const checkInterval = setInterval(() => {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
clearInterval(checkInterval);
@@ -99,15 +116,13 @@
}
}, 100);
// Fallback timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
WARN('KefinTweaksUtils not available after 10 seconds');
}, 10000);
}
// Start initialization
waitForUtilsAndInitialize();
LOG('Custom menu links functionality initialized');
})();
})();
+44 -14
View File
@@ -4,34 +4,64 @@
// This script sends you to home.html in these scenarios instead of leaving the app
(function () {
function attachHandler() {
const btn = document.querySelector('button.MuiButtonBase-root');
if (!btn) return;
'use strict';
if (btn._backCheckAttached) return;
function attachHandler(btn) {
if (!btn || btn._backCheckAttached) return true;
btn._backCheckAttached = true;
btn.addEventListener('click', function (e) {
if (window.history.state?.idx === 0) {
// Stop normal handlers
e.stopImmediatePropagation();
e.preventDefault();
console.log("[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page.");
console.log('[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page.');
// Changes this to the page you'd like to return to by default
const homeUrl = ApiClient.serverVersion().split('.')[1] > 10 ? 'home' : 'home.html';
Dashboard.navigate(`/${homeUrl}`);
}
}, true);
observer.disconnect();
return true;
}
// Observe DOM in case button is recreated
const observer = new MutationObserver(attachHandler);
observer.observe(document.body, { childList: true, subtree: true });
function tryAttach() {
const btn = document.querySelector('button.MuiButtonBase-root');
return attachHandler(btn);
}
// Initial run
attachHandler();
function armForDashboard() {
if (tryAttach()) return;
const utils = window.KefinTweaksUtils;
if (utils?.waitForElement) {
utils.waitForElement('button.MuiButtonBase-root', {
timeout: 15000,
quiet: true,
predicate: (el) => !el._backCheckAttached
}).then((btn) => {
if (btn) attachHandler(btn);
});
return;
}
if (utils?.pollUntil) {
utils.pollUntil(() => (tryAttach() ? true : null), { intervalMs: 200, timeoutMs: 15000 });
return;
}
setTimeout(armForDashboard, 500);
}
function start() {
const utils = window.KefinTweaksUtils;
if (!utils?.onViewPage) {
setTimeout(start, 500);
return;
}
utils.onViewPage(() => {
armForDashboard();
}, { pages: ['dashboard', 'configurationpage'] });
// Also try immediately (direct load)
armForDashboard();
}
start();
})();
+16 -24
View File
@@ -1,38 +1,30 @@
.docspinner {
contain: layout style size;
top: 6em;
right: calc(1em + 7px);
left: unset;
position: fixed;
width: 3em;
height: 3em;
z-index: 9999999;
margin: 0;
pointer-events: none;
contain: layout style size;
top: 6em;
right: calc(1em + 7px);
left: unset;
position: fixed;
width: 3em;
height: 3em;
z-index: 9999999;
margin: 0;
pointer-events: none;
}
/* Item Details Page */
div#seriesScheduleSection {
order: 2;
order: 2;
}
.detailPageContent>* {
order: 1;
.detailPageContent > * {
order: 1;
}
.commentsSection {
order: 3;
}
@media (max-width: 768px) {
.headerRight .btnRecommendations,
.headerRight .btnAdmin,
.headerRight .headerAppearanceButton {
display: none;
}
order: 3;
}
.itemBackdrop {
pointer-events: none;
}
pointer-events: none;
}
+2 -2
View File
@@ -132,7 +132,7 @@
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Authorization': apiHelper.getAuthHeader()
'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) {
+56 -15
View File
@@ -30,29 +30,70 @@
const config = window.KefinTweaksConfig?.exclusiveElsewhere || {
hideServerName: false // Default fallback
};
const observer = new MutationObserver(() => {
let pollTimer = null;
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function tryApplyExclusive() {
const elsewhereContainer = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div');
if (elsewhereContainer && elsewhereContainer.children && elsewhereContainer.children.length > 2) {
return;
return true;
}
const serverName = config.hideServerName ? '' : ApiClient.serverName();
const serverName = config.hideServerName ? '' : (typeof ApiClient !== 'undefined' ? ApiClient.serverName() : '');
const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a');
if (link && !link.classList.contains('exclusive')) {
LOG('Modifying elsewhere link to show exclusive branding');
link.innerHTML = `Only available on ${serverName}`;
link.classList.add("exclusive");
link.title = "Exclusive";
link.classList.add('exclusive');
link.title = 'Exclusive';
link.disable = true;
return true;
}
});
return false;
}
observer.observe(document.body, {
childList: true,
subtree: true
});
LOG('Exclusive elsewhere functionality initialized');
})();
function armItemDetailPoll() {
stopPoll();
if (tryApplyExclusive()) return;
const started = Date.now();
const TTL_MS = 15000;
pollTimer = setInterval(() => {
if (tryApplyExclusive() || Date.now() - started >= TTL_MS) {
stopPoll();
}
}, 300);
}
function start() {
const utils = window.KefinTweaksUtils;
if (!utils?.onViewPage) {
setTimeout(start, 500);
return;
}
utils.onViewPage((view) => {
stopPoll();
// item / details views
if (view === 'item' || view === 'details' || /item\.html|details/i.test(window.location.hash || '')) {
armItemDetailPoll();
}
});
// Direct load on item page
if (document.querySelector('.itemDetailPage:not(.hide)')) {
armItemDetailPoll();
}
LOG('Exclusive elsewhere functionality initialized (onViewPage + TTL poll)');
}
start();
})();
+114
View File
@@ -0,0 +1,114 @@
// KefinTweaks External List (MDBList) — match provider IDs against library caches
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks ExternalList]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks ExternalList]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ExternalList]', ...args);
function getMdblistApiKey() {
const settings = window.LibraryCacheUtils?.getLibraryCacheSettings?.() || {};
return (settings.mdblistApiKey || '').trim();
}
function appendApiKey(url, apiKey) {
if (!apiKey) return url;
try {
const u = new URL(url);
if (!u.searchParams.has('apikey') && !u.searchParams.has('api_key')) {
u.searchParams.set('apikey', apiKey);
}
return u.toString();
} catch (_) {
const join = url.includes('?') ? '&' : '?';
return `${url}${join}apikey=${encodeURIComponent(apiKey)}`;
}
}
/**
* Normalize mdblist (or similar) JSON into { mediaType, imdb?, tmdb? }[].
*/
function normalizeExternalListPayload(data) {
const rows = [];
const list = Array.isArray(data) ? data
: Array.isArray(data?.movies) || Array.isArray(data?.shows) || Array.isArray(data?.items)
? [...(data.movies || []), ...(data.shows || []), ...(data.items || [])]
: [];
list.forEach((entry) => {
if (!entry || typeof entry !== 'object') return;
const mediaType = /show|series|tv/i.test(String(entry.mediatype || entry.media_type || entry.type || ''))
? 'Series'
: 'Movie';
const imdb = entry.imdb_id || entry.imdbid || entry.ids?.imdb || entry.Imdb || null;
const tmdb = entry.tmdb_id || entry.tmdbid || entry.ids?.tmdb || entry.Tmdb || null;
if (imdb || tmdb) {
rows.push({ mediaType, imdb: imdb ? String(imdb) : undefined, tmdb: tmdb ? String(tmdb) : undefined });
}
});
return rows;
}
async function fetchExternalListUrl(url) {
const apiKey = getMdblistApiKey();
const fetchUrl = appendApiKey(url, apiKey);
const response = await fetch(fetchUrl, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`External list HTTP ${response.status}`);
}
return response.json();
}
async function resolveExternalListItems(sectionConfig) {
const urls = sectionConfig?.externalListUrls || [];
if (!urls.length) return { Items: [] };
if (!getMdblistApiKey()) {
WARN('MDBList API key not configured');
}
const allRows = [];
for (const url of urls) {
if (!url) continue;
try {
const data = await fetchExternalListUrl(url);
allRows.push(...normalizeExternalListPayload(data));
} catch (e) {
ERR('Failed to fetch external list:', url, e);
}
}
const movieIds = [];
const seriesIds = [];
allRows.forEach((row) => {
const keys = [];
if (row.imdb) keys.push(row.imdb);
if (row.tmdb) keys.push(row.tmdb);
if (row.mediaType === 'Series') seriesIds.push(...keys);
else movieIds.push(...keys);
});
const movies = movieIds.length
? await (window.LibraryCache?.getByProviderIds('Movie', movieIds)
|| window.MoviesCache?.getMoviesByProviderIds?.(movieIds)
|| [])
: [];
const series = seriesIds.length
? await (window.LibraryCache?.getByProviderIds('Series', seriesIds)
|| window.SeriesCache?.getSeriesByProviderIds?.(seriesIds)
|| [])
: [];
const items = [...movies, ...series];
LOG(`External list matched ${items.length} library items from ${allRows.length} rows`);
return { Items: items };
}
window.KefinExternalList = {
getMdblistApiKey,
resolveExternalListItems,
normalizeExternalListPayload
};
LOG('Module loaded');
})();
+126
View File
@@ -0,0 +1,126 @@
// KefinTweaks Games
// Hosts Games hub + Movie LUT + 3x3 via addCustomPage
// Requires: utils.js
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Games]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Games]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Games]', ...args);
LOG('Script loaded');
function getRoot() {
const raw = window.KefinTweaksConfig?.kefinTweaksRootResolved;
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();
})();
+758
View File
@@ -0,0 +1,758 @@
// 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;
}
:where(.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 armToolbarPoll() {
ensureHamburgerButton();
let tries = 0;
const poll = setInterval(() => {
tries += 1;
if (ensureHamburgerButton() || tries >= 40) clearInterval(poll);
}, 250);
}
function start() {
ensureStyles();
armToolbarPoll();
const utils = window.KefinTweaksUtils;
if (utils?.onViewPage) {
// Toolbar remounts across navigations — short poll per view, no permanent body MO
utils.onViewPage(() => {
armToolbarPoll();
});
} else if (utils?.onBodyMutation) {
let scheduled = false;
toolbarObserver = utils.onBodyMutation('hamburger-toolbar', () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
ensureHamburgerButton();
});
});
}
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();
}
})();
+96 -19
View File
@@ -93,7 +93,7 @@
`${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`,
{
headers: {
'Authorization': apiHelper.getAuthHeader()
'Authorization': window.apiHelper.getAuthHeader()
}
}
);
@@ -233,9 +233,67 @@
LOG('Tab clicked:', currentPage, tabIndex);
}
}
async function pollForElement(selectorFn, maxAttempts = 50, pollInterval = 100) {
let attempts = 0;
while (attempts < maxAttempts) {
const element = selectorFn();
if (element) {
return element;
}
attempts++;
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
WARN('Element not found after', attempts, 'attempts');
return null;
}
// Synchronize active tab state with URL parameter
async function syncActiveTabState() {
async function syncActiveTabState(currentTabIndex) {
const headerTabButtons = document.querySelectorAll('.headerTabs button');
let targetTabButton = document.querySelector(`.headerTabs button[data-index="${currentTabIndex}"]`);
// If the target tab is a custom home tab, we may need to wait for it to be rendered
if (!targetTabButton && currentTabIndex > 1) {
// Wait for the target tab button to be rendered
targetTabButton = await pollForElement(() => document.querySelector(`.headerTabs button[data-index="${currentTabIndex}"]`), 50, 100);
}
if (targetTabButton && targetTabButton.classList.contains('emby-tab-button-active')) {
LOG('Target tab is already active');
return;
}
const pageTabContents = document.querySelectorAll('.libraryPage:not(.hide) .pageTabContent');
const targetPageTabContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabIndex}"]`);
const sectionsContainer = targetPageTabContent?.querySelector('.sections');
if (sectionsContainer && sectionsContainer.children.length === 0) {
LOG('Sections container is empty, waiting for sections to be rendered');
return;
}
if (headerTabButtons && targetTabButton && pageTabContents && targetPageTabContent) {
// Remove is-active class from all tab buttons
headerTabButtons.forEach(button => {
button.classList.remove('emby-tab-button-active');
});
// Add is-active class to the active tab button
targetTabButton.classList.add('emby-tab-button-active');
LOG('Set active tab button:', currentTabIndex);
// Remove is-active class from all page tab contents
pageTabContents.forEach(content => {
content.classList.remove('is-active');
});
// Add is-active class to the target page tab content
targetPageTabContent.classList.add('is-active');
LOG('Set active page tab content:', currentTabIndex);
}
return;
const headerTabs = document.querySelector('.headerTabs');
if (!headerTabs) return;
@@ -339,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",
"Authorization": apiHelper.getAuthHeader(),
},
});
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) {
@@ -385,7 +453,7 @@
// All custom tabs are now present
obs.disconnect();
LOG('All custom tabs rendered, adding click listeners');
syncActiveTabState();
//syncActiveTabState();
// Add click listeners to all buttons including the new custom tabs
const allButtons = headerTabs.querySelectorAll('.emby-tab-button');
@@ -435,13 +503,22 @@
}
// Register onViewPage handler to sync active tab state
window.KefinTweaksUtils.onViewPage((view, element) => {
window.KefinTweaksUtils.onViewPage((view, element, hash) => {
LOG('onViewPage handler triggered for view:', view);
addClickListeners();
// Get selected tab from hash
const hashParams = hash.includes('?') ? hash.split('?')[1] : '';
const urlParams = new URLSearchParams(hashParams);
const currentTab = urlParams.get('tab');
const currentTabIndex = currentTab ? parseInt(currentTab, 10) : 0;
// Sync active tab state when page view changes
syncActiveTabState().catch(err => {
ERR('Error in syncActiveTabState:', err);
});
// This is only necessary for the home page with custom tabs
if ((view === 'home' || view === 'home.html')) {
syncActiveTabState(currentTabIndex).catch(err => {
ERR('Error in syncActiveTabState:', err);
});
}
}, {
pages: SUPPORTED_PAGES // Only trigger for supported pages
});
+669 -114
View File
@@ -1,102 +1,20 @@
/* Please use the CSS commented below to customize the order of KefinTweaks custom home sections */
/*
[data-custom-section-id="watchlist"] {
order: 60 !important;
}
[data-custom-section-id="popular-tv-networks"] {
order: 61 !important;
}
[data-custom-section-id="new-movies"] {
order: 30 !important;
}
[data-custom-section-id="new-episodes"] {
order: 31 !important;
}
*/
@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;
display: flex;
flex-direction: column;
gap: 1rem;
}
.homeSectionsContainer > .section0 {
order: 1;
}
.homeSectionsContainer > .section1 {
order: 2;
}
.homeSectionsContainer > .section2 {
order: 3;
}
.homeSectionsContainer > .section3 {
order: 4;
}
.homeSectionsContainer > .section4 {
order: 5;
}
.homeSectionsContainer > .section5 {
order: 6;
}
.homeSectionsContainer > .section6 {
order: 7;
}
.homeSectionsContainer > .section7 {
order: 8;
}
.homeSectionsContainer > .section8 {
order: 9;
}
.homeSectionsContainer > .section9 {
order: 10;
}
.homeSectionsContainer > .section10 {
order: 11;
.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;
}
/* Halloween theme */
.home-screen.halloween-theme.with-background #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: -25%;
background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(https://ranaldsgift.github.io/KefinTweaks/pages/images/halloween.jpg);
background-position: 0px -250px;
background-size: auto 102%;
}
/* Christmas theme */
.home-screen.christmas-theme.with-background #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)), url('../pages/images/christmas.jpg');
background-position: center;
background-size: cover;
}
.snowverlay {
display: none;
}
.home-screen.christmas-theme .snowverlay {
display: block;
}
.home-screen h2.sectionTitle {
margin-bottom: .35em;
margin-top: 0;
}
div#discovery-loading-indicator:not(.show)::before {
/* div#discovery-loading-indicator:not(.show)::before {
content: 'Loading...';
position: absolute;
top: 50%;
@@ -107,41 +25,678 @@ div#discovery-loading-indicator:not(.show)::before {
visibility: visible;
text-align: center;
opacity: 1;
} */
#discovery-loading-indicator {
order: 999999999 !important;
width: fit-content;
margin: 0 auto;
}
#indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before {
content: 'Discover More';
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show)::before {
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;
#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::before {
content: "We've run out of suggestions!";
}
.libraryPage:has(.load-more-discovery-btn) div#discovery-loading-indicator {
opacity: 0;
#discovery-loading-indicator:not(.show)::after {
content: "\E313";
font-family: "Material Icons";
font-size: 1.5em;
color: #ffffff;
visibility: visible;
opacity: 1;
}
#indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::after {
content: "";
}
[data-loading-discovery="true"] #discovery-loading-indicator,
.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator,
.sections[data-loading-discovery="true"] #discovery-loading-indicator {
display: flex;
}
/* Idle Discover More / exhausted prompt (chevron mode) */
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
gap: 0;
}
/* Hide Discover More / loading affordance outside Home + Discover categories */
#indexPage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) #discovery-loading-indicator,
.homePage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) #discovery-loading-indicator,
.libraryPage
.homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"])
#discovery-loading-indicator,
#indexPage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) .discovery-loading-indicator,
.homePage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) .discovery-loading-indicator,
.libraryPage
.homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"])
.discovery-loading-indicator {
display: none !important;
visibility: hidden !important;
min-height: 0 !important;
height: 0 !important;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
opacity: 0 !important;
}
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner {
visibility: hidden;
opacity: 0;
display: none;
}
/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
display: none;
}
} */
#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after {
content: '';
#indexPage:not([data-discovery-ready]) #discovery-loading-indicator:not(.show)::after {
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;
}
.sections.loading-discovery .load-more-discovery-btn {
visibility: hidden !important;
}
/* Discovery section loading indicator */
.sections.loading-discovery .discovery-loading-indicator {
visibility: visible !important;
opacity: 1 !important;
}
.discovery-loading-indicator {
display: none;
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
margin: 0;
padding: 0;
width: 100%;
font: inherit;
text-align: center;
color: inherit;
cursor: default;
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;
}
/* Chevron mode: clickable Discover More (desktop optional; TV focus stop) */
#indexPage[data-discovery-ready]:not([data-discovery-exhausted]) #discovery-loading-indicator:not(.show) {
cursor: pointer;
}
/* TV: real button + emby-button for spatial nav; keep Discover More chrome unstyled */
.layout-tv button#discovery-loading-indicator.emby-button,
.layout-tv #discovery-loading-indicator.discovery-load-more-focusable {
outline: none;
min-height: 3em;
padding: 0.5em 1em;
box-sizing: border-box;
background: transparent;
border: 0;
box-shadow: none;
transform: none;
-webkit-appearance: none;
appearance: none;
font: inherit;
color: inherit;
cursor: pointer;
}
.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus,
.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus {
background-color: var(--theme-primary-color, #00a4dc) !important;
background: var(--theme-primary-color, #00a4dc) !important;
color: #fff !important;
outline: none;
box-shadow: none;
border-radius: 8px;
}
.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::before,
.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::after,
.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::before,
.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::after {
color: #fff !important;
}
.discovery-loading-indicator .spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
}
@keyframes discovery-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Hide scroll buttons on mobile using Jellyfin's layout classes */
.layout-mobile .emby-scrollbuttons,
.layout-tv .emby-scrollbuttons {
display: none !important;
}
.load-more-discovery-btn {
display: block;
}
.sections[data-loading-discovery="true"] .load-more-discovery-btn {
display: none !important;
}
.sections[data-loading-discovery="true"] .discovery-loading-indicator,
.sections[data-loading-discovery="true"] #discovery-loading-indicator,
.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator {
display: flex !important;
opacity: 1 !important;
}
.discovery-loading-indicator {
display: none;
opacity: 0;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
padding: 20px;
border-radius: 8px;
font-size: 14px;
color: #666;
transition: opacity 0.3s ease;
position: relative;
justify-content: start;
min-height: 200px;
}
.discovery-loading-indicator .spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--theme-primary-color, #3498db);
border-radius: 50%;
animation: discovery-spin 1s linear infinite;
}
@keyframes discovery-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.homeSectionsContainer [data-section-id="popularTVNetworks"] .card .cardText {
visibility: hidden;
}
/* .homeSectionsContainer:not(.kefinHomeSectionsContainer) {
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;
}
.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="TvChannel"] a.cardImageContainer:not([style])::after {
content: attr(aria-label);
font-size: 2rem;
text-wrap: auto;
text-align: center;
}
.emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer {
gap: 4rem;
}
.layout-mobile .emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer {
gap: 2rem;
}
[data-section-id="upcoming"] .missingIndicator {
display: none;
}
.homePage[data-discovery-ready] #discovery-loading-indicator:not(.show) {
min-height: 50px;
}
@keyframes fadeInDelayed {
0%,
99% {
opacity: 0;
visibility: hidden;
}
100% {
opacity: 1;
visibility: visible;
}
}
[data-section-id="browse-by-studio"] .card {
width: 8vw !important;
}
[data-section-id="browse-by-studio"] .card,
[data-section-id="browse-by-studio"] .card .cardScalable,
[data-section-id="browse-by-studio"] .card .cardImageContainer,
[data-section-id="browse-by-studio"] .card .cardPadder {
border-radius: 0px !important;
}
/* Hide scroll buttons on mobile and tablet layouts */
@media (max-width: 768px) {
.emby-scrollbuttons {
display: none !important;
}
[data-section-id="browse-by-studio"] .card {
width: 30vw !important;
}
}
/* Home screen chrome: create section + category rail */
.kefin-home-chrome {
position: fixed;
z-index: 12;
pointer-events: none;
opacity: 0.18;
transition: opacity 0.2s ease;
}
@media (hover: hover) and (pointer: fine) {
.kefin-home-chrome:hover,
.kefin-home-chrome:focus-within {
opacity: 1;
}
}
/* TV: rail must be fully visible while focused (no hover) */
.layout-tv #kefin-home-category-rail:focus-within,
.layout-tv #kefin-home-category-rail:focus-within .kefin-home-category-rail {
opacity: 1;
}
.kefin-home-chrome.hide,
#kefin-home-category-rail.hide,
#kefin-home-category-rail.hide .kefin-home-category-rail {
display: none !important;
}
/* Hide home chrome off-home via root page key (no :has; JMP-safe) */
html:not([data-active-page="home"]) #kefin-home-category-rail,
html:not([data-active-page="home"]) #kefin-home-create-section-btn {
display: none !important;
}
.kefin-home-chrome button,
#kefin-home-category-rail button {
pointer-events: auto;
}
.kefin-home-create-section {
left: 0;
bottom: 0.25em;
}
.kefin-home-create-section-btn {
padding: 0.25em;
}
.kefin-home-create-section-btn .material-icons {
font-size: 1.75rem;
}
/* Outer sectionTabs wrapper — no native header layout */
#kefin-home-category-rail.sectionTabs {
position: static;
display: contents;
}
/* Inner visual bar — keep our layout over skinHeader / MuiAppBar positioning */
:where(.kefin-home-category-rail) {
--AppBar-background: var(--jf-palette-AppBar-defaultBg);
--AppBar-color: var(--jf-palette-text-primary);
background-color: var(--jf-palette-AppBar-darkBg, var(--AppBar-background));
color: var(--jf-palette-AppBar-darkColor, var(--AppBar-color));
z-index: 10;
gap: 0.25rem;
pointer-events: auto;
display: flex;
}
@media (hover: hover) and (pointer: fine) {
#kefin-home-category-rail .kefin-home-category-rail:hover,
#kefin-home-category-rail .kefin-home-category-rail:focus-within {
opacity: 1;
background: #0f0f0f !important;
border-radius: 0 25px 25px 0;
box-shadow: 1px 0px 5px -1px black;
}
}
.kefin-home-category-item {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
min-width: 0;
}
.kefin-home-category-btn {
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.75;
-webkit-tap-highlight-color: transparent;
}
.kefin-home-category-btn.is-active,
.kefin-home-category-item.is-active .kefin-home-category-btn {
opacity: 1;
color: var(--theme-primary-color, #00a4dc);
background: rgba(0, 164, 220, 0.18);
}
.kefin-home-category-btn .material-icons {
font-size: 1.35rem;
top: unset !important;
}
.kefin-home-category-btn-label {
display: none;
font-size: 0.65rem;
line-height: 1.1;
font-weight: 500;
letter-spacing: 0.01em;
max-width: 4.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
text-align: center;
}
.kefin-home-category-item.is-active .kefin-home-category-btn-label {
color: var(--theme-primary-color, #00a4dc);
}
@media (max-width: 899px) {
#kefin-home-category-rail .kefin-home-category-rail {
top: auto;
left: 0;
right: 0;
bottom: 0;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
gap: 0.15rem;
padding: 0.4rem 0.5rem calc(0.4rem + env(safe-area-inset-bottom, 0px)) !important;
box-shadow: 1px 1px 1px 1px #fff !important;
border-radius: 0;
opacity: 1;
pointer-events: auto;
margin: 0 !important;
border: none !important;
transition:
transform 0.25s ease,
opacity 0.25s ease;
}
:where(#kefin-home-category-rail) .kefin-home-category-rail:hover,
:where(#kefin-home-category-rail) .kefin-home-category-rail:focus-within {
opacity: 1;
}
.kefin-home-category-btn-label {
display: block;
}
.kefin-home-category-btn:hover,
.kefin-home-category-btn:focus,
.kefin-home-category-btn:focus-visible,
.kefin-home-category-btn:active {
opacity: 0.75;
background: transparent;
color: inherit;
}
.kefin-home-category-btn.is-active,
.kefin-home-category-btn.is-active:hover,
.kefin-home-category-btn.is-active:focus,
.kefin-home-category-btn.is-active:focus-visible,
.kefin-home-category-btn.is-active:active,
.kefin-home-category-item.is-active .kefin-home-category-btn,
.kefin-home-category-item.is-active .kefin-home-category-btn:hover,
.kefin-home-category-item.is-active .kefin-home-category-btn:focus,
.kefin-home-category-item.is-active .kefin-home-category-btn:focus-visible,
.kefin-home-category-item.is-active .kefin-home-category-btn:active {
opacity: 1;
color: var(--theme-primary-color, #00a4dc);
background: rgba(0, 164, 220, 0.18);
}
#kefin-home-category-rail.is-scroll-hidden .kefin-home-category-rail {
transform: translateY(100%);
pointer-events: none;
opacity: 0;
}
.kefin-home-create-section,
#kefin-home-create-section-btn {
display: none !important;
}
.homePage:not(.hide) {
padding-bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px));
}
}
.kefin-section-more-toggle {
margin-left: auto !important;
}
@media (min-width: 900px) {
#kefin-home-category-rail .kefin-home-category-rail,
#kefin-home-category-rail .kefin-home-category-rail::before {
background: none !important;
background-color: transparent !important;
}
#kefin-home-category-rail .kefin-home-category-rail {
position: fixed !important;
z-index: 10;
top: 50vh !important;
left: 0 !important;
right: auto !important;
bottom: auto !important;
width: auto !important;
height: auto !important;
max-width: none !important;
flex-direction: column !important;
transform: translateY(-50%);
overflow: visible !important;
padding: 5px 0 !important;
margin: 0 !important;
backdrop-filter: none;
border: none !important;
display: grid !important;
grid-auto-rows: 1fr;
}
}
#kefin-home-category-rail .kefin-home-category-rail {
height: auto !important;
}
.sections.homeSectionsContainer[data-category="discovery"] {
position: relative;
}
.sections.homeSectionsContainer[data-category="discovery"]::before {
content: "\E87A";
font-family: "Material Icons";
padding-left: max(env(safe-area-inset-left), 3.3%);
font-size: 1.5rem;
margin: 0;
padding-bottom: .2em;
padding-top: 1em;
display: block;
line-height: 1.2;
}
.sections.homeSectionsContainer[data-category="discovery"]::after {
content: "Discover Something To Watch";
font-family: inherit;
font-size: 1.5rem;
position: absolute;
left: calc(max(env(safe-area-inset-left), 3.3%) + 1.7em);
top: 1em;
line-height: 1.2;
pointer-events: none;
}
html:not([data-active-page="home"]) .snowverlay {
display: none;
}
// Hide the seasonal animations on mobile by default
@media (max-width: 899px) {
.snowverlay {
display: none !important;
}
}
html[data-active-page="home"] body[data-seasonal-background="true"] #reactRoot > .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;
}
/* Figure out a better way to do this without requiring :has() */
/* .homeSectionsContainer:has(> .verticalSection.section0:empty):has(> .verticalSection.section1:empty):has(
> .verticalSection.section2:empty
):has(> .verticalSection.section3:empty):has(> .verticalSection.section4:empty):has(> .verticalSection.section5:empty):has(
> .verticalSection.section6:empty
):has(> .verticalSection.section7:empty):has(> .verticalSection.section8:empty):has(> .verticalSection.section9:empty):not(
:has(> .section9 ~ :not(.discovery-loading-indicator))
)::after {
content: "Customize your Home Screen from your User Settings > Home in the top right corner.";
text-align: center;
margin: 3em auto;
display: block;
animation: fadeInDelayed 0.1s linear 1s forwards;
opacity: 0;
visibility: hidden;
}
*/
.kefin-home-category-btn span:is(.emby-button-foreground) {
top: 0px !important;
}
#kefin-home-category-rail[data-category]:not([data-category="none"]) ~ #slides-container {
display: none !important;
}
.sections.homeSectionsContainer:not([data-category="none"]),
html.layout-mobile .homePage .sections.homeSectionsContainer:not([data-category="none"]) {
margin-top: 0 !important;
top: 0 !important;
}
html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root,
html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop {
box-shadow: 0 3px 12px rgba(180, 0, 0, 0.7) !important;
border-bottom: 2px solid #8b0000;
}
html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop {
position: relative;
}
html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root::after,
html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop::after {
content: "";
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 14px;
pointer-events: none;
z-index: 9998;
background-color: #8b0000;
-webkit-mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 100" preserveAspectRatio="none"><path d="M0,0 L1000,0 L1000,20 C950,20 940,80 930,80 C920,80 910,20 860,20 C820,20 810,95 800,95 C790,95 780,20 720,20 C680,20 670,60 660,60 C650,60 640,20 580,20 C540,20 530,100 520,100 C510,100 500,20 440,20 C400,20 390,75 380,75 C370,75 360,20 300,20 C260,20 250,50 240,50 C230,50 220,20 160,20 C120,20 110,85 100,85 C90,85 80,20 0,20 Z"/></svg>');
mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 100" preserveAspectRatio="none"><path d="M0,0 L1000,0 L1000,20 C950,20 940,80 930,80 C920,80 910,20 860,20 C820,20 810,95 800,95 C790,95 780,20 720,20 C680,20 670,60 660,60 C650,60 640,20 580,20 C540,20 530,100 520,100 C510,100 500,20 440,20 C400,20 390,75 380,75 C370,75 360,20 300,20 C260,20 250,50 240,50 C230,50 220,20 160,20 C120,20 110,85 100,85 C90,85 80,20 0,20 Z"/></svg>');
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* Procedural blood edge (SVG via --halloween-blood-splatter from homeScreen3.js) */
html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root::before,
html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop::before {
content: "";
position: absolute;
top: auto;
bottom: 0;
left: 0;
width: 100%;
height: var(--halloween-blood-band, 10px);
pointer-events: none;
opacity: var(--halloween-blood-opacity, 0.95);
z-index: 1;
background-image: var(--halloween-blood-splatter, none);
background-repeat: no-repeat !important;
background-size: 100% 100%;
background-position: bottom center;
}
html[data-active-page="home"] body[data-seasonal-background="true"] .backdropContainer .backdropImage {
display: none !important;
}
@media (hover: hover) and (pointer: fine) and (min-width: 900px) {
#kefin-home-category-rail .kefin-home-category-rail.kefin-home-chrome {
pointer-events: auto;
}
}
+15 -15
View File
@@ -1112,7 +1112,7 @@
try {
const response = await fetch(url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -1552,7 +1552,7 @@
try {
const response = await fetch(url, {
headers: {
'Authorization': apiHelper.getAuthHeader()
'Authorization': `MediaBrowser Token="${ApiClient.accessToken()}"`
}
});
@@ -1649,7 +1649,7 @@
try {
const response = await fetch(`${serverUrl}/Genres?IncludeItemTypes=Movie`, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -1745,7 +1745,7 @@
queries.map(async (query) => {
const response = await fetch(query.url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -1854,7 +1854,7 @@
const response = await fetch(url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2214,7 +2214,7 @@
try {
const response = await fetch(url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2251,7 +2251,7 @@
try {
const response = await fetch(url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2565,7 +2565,7 @@
const studiosUrl = `${serverUrl}/Studios?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series&Recursive=true&Fields=DateCreated%2CPrimaryImageAspectRatio&StartIndex=0&ParentId=${library.Id}&userId=${userId}${filterParam}`;
const response = await fetch(studiosUrl, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2646,7 +2646,7 @@
const response = await fetch(url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2680,7 +2680,7 @@
const response = await fetch(url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -3828,7 +3828,7 @@
const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`;
const movieResponse = await fetch(movieUrl, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -3890,7 +3890,7 @@
const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`;
const movieResponse = await fetch(movieUrl, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -3958,7 +3958,7 @@
const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`;
const movieResponse = await fetch(movieUrl, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -4708,7 +4708,7 @@
const response = await fetch(url, {
headers: {
"Authorization": apiHelper.getAuthHeader()
"Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -5391,7 +5391,7 @@
const serverAddress = ApiClient.serverAddress();
const token = ApiClient.accessToken();
const genresResponse = await fetch(`${serverAddress}/Genres?IncludeItemTypes=Movie`, {
headers: { "Authorization": apiHelper.getAuthHeader() }
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!genresResponse.ok) {
File diff suppressed because it is too large Load Diff
+853
View File
@@ -0,0 +1,853 @@
// KefinTweaks Community Home Screen Sections
// This file contains curated collections of home screen sections submitted by the community.
// Submit your own: [URL Placeholder]
(function() {
'use strict';
const COMMUNITY_COLLECTIONS = [
{
"name": "Based on Your Watched History [Discovery]",
"author": "HighImKevin",
"sections": [
{
"name": "Movies You Forgot About",
"enabled": true,
"order": 22,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "PlayCount",
"SortOrder": "Descending",
"Filters": "IsResumable",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1768607068997",
"type": "custom"
},
{
"name": "Best Movies You've Never Seen",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"queryOptions": {
"IsUnplayed": true,
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Descending",
"Limit": 20,
"MinCommunityRating": 9
}
}
],
"flattenSeries": false,
"id": "custom-1769640868950",
"type": "custom"
},
{
"name": "Rewatch One Of Your Favorites",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsFavorite",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1769644637599",
"type": "custom"
}
]
},
{
"name": "Multi Query Examples",
"author": "HighImKevin",
"description": "Custom section examples that use the multi-query picker.",
"sections": [
{
"id": "custom-1791197522382",
"name": "Our Top Movie Choices",
"enabled": true,
"order": 100,
"category": "none",
"cardFormat": "Poster",
"ttl": 86400000,
"queries": [
{
"name": "Best of Action",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"Genres": "Action",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Comedy",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"Genres": "Comedy",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Drama",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"Genres": "Drama",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Horror",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"Genres": "Horror",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Science Fiction",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"Genres": "Science Fiction",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Romance",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"Genres": "Romance",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
}
],
"userConfigurable": true,
"renderMode": "Normal",
"discoveryEnabled": false,
"type": "home",
"useRandomQuery": true,
"useMultiQueryPicker": true,
"isCustom": true,
"description": "Our top offerings by genre",
"useGaplessCards": false,
"useParentCard": false,
"itemLimit": 16,
"useQueryNamesForSection": false
},
{
"id": "custom-1791256768605",
"name": "Movies by the Decade",
"enabled": false,
"order": 100,
"category": "none",
"cardFormat": "Poster",
"ttl": 86400000,
"queries": [
{
"name": "50s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1950",
"1951",
"1952",
"1953",
"1954",
"1955",
"1956",
"1957",
"1958",
"1959"
]
}
},
{
"name": "60s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1960",
"1961",
"1962",
"1963",
"1964",
"1965",
"1966",
"1967",
"1968",
"1969"
]
}
},
{
"name": "70s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1970",
"1971",
"1972",
"1973",
"1974",
"1975",
"1976",
"1977",
"1978",
"1979"
]
}
},
{
"name": "80s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1980",
"1981",
"1982",
"1983",
"1984",
"1985",
"1986",
"1987",
"1988",
"1989"
]
}
},
{
"name": "90s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1990",
"1991",
"1992",
"1993",
"1994",
"1995",
"1996",
"1997",
"1998",
"1999"
]
}
},
{
"name": "2000s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"2000",
"2001",
"2002",
"2003",
"2004",
"2005",
"2006",
"2007",
"2008",
"2009"
]
}
},
{
"name": "2010s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"2010",
"2011",
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
"2018",
"2019"
]
}
},
{
"name": "2020s Movies",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"2020",
"2021",
"2022",
"2023",
"2024",
"2025",
"2026",
"2027",
"2028",
"2029"
]
}
}
],
"caption": "Browse by decade",
"userConfigurable": true,
"renderMode": "Normal",
"discoveryEnabled": false,
"type": "home",
"useRandomQuery": true,
"useMultiQueryPicker": true,
"multiQueryPickerLabel": "Pick a Decade",
"useQueryNamesForSection": true,
"isCustom": true,
"description": "Find something to watch from your favorite decade",
"useGaplessCards": false,
"useParentCard": false
},
{
"id": "custom-1791256768606",
"name": "Our Top Series Choices",
"enabled": true,
"order": 100,
"category": "none",
"cardFormat": "Poster",
"ttl": 86400000,
"queries": [
{
"name": "Best of Action",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"Genres": "Action",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Comedy",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"Genres": "Comedy",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Drama",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"Genres": "Drama",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Horror",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"Genres": "Horror",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Science Fiction",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"Genres": "Science Fiction",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
},
{
"name": "Best of Romance",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"Genres": "Romance",
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 50,
"MinCommunityRating": 7.5
}
}
],
"userConfigurable": true,
"renderMode": "Normal",
"discoveryEnabled": false,
"type": "home",
"useRandomQuery": true,
"useMultiQueryPicker": true,
"isCustom": true,
"description": "Our top series offerings by genre",
"useGaplessCards": false,
"useParentCard": false,
"itemLimit": 16,
"useQueryNamesForSection": false
},
{
"id": "custom-1791256768607",
"name": "Series by the Decade",
"enabled": false,
"order": 100,
"category": "none",
"cardFormat": "Poster",
"ttl": 86400000,
"queries": [
{
"name": "50s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1950",
"1951",
"1952",
"1953",
"1954",
"1955",
"1956",
"1957",
"1958",
"1959"
]
}
},
{
"name": "60s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1960",
"1961",
"1962",
"1963",
"1964",
"1965",
"1966",
"1967",
"1968",
"1969"
]
}
},
{
"name": "70s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1970",
"1971",
"1972",
"1973",
"1974",
"1975",
"1976",
"1977",
"1978",
"1979"
]
}
},
{
"name": "80s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1980",
"1981",
"1982",
"1983",
"1984",
"1985",
"1986",
"1987",
"1988",
"1989"
]
}
},
{
"name": "90s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"1990",
"1991",
"1992",
"1993",
"1994",
"1995",
"1996",
"1997",
"1998",
"1999"
]
}
},
{
"name": "2000s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"2000",
"2001",
"2002",
"2003",
"2004",
"2005",
"2006",
"2007",
"2008",
"2009"
]
}
},
{
"name": "2010s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"2010",
"2011",
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
"2018",
"2019"
]
}
},
{
"name": "2020s Series",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "Random",
"SortOrder": "Ascending",
"Filters": "IsUnplayed",
"Limit": 20,
"Years": [
"2020",
"2021",
"2022",
"2023",
"2024",
"2025",
"2026",
"2027",
"2028",
"2029"
]
}
}
],
"caption": "Browse by decade",
"userConfigurable": true,
"renderMode": "Normal",
"discoveryEnabled": false,
"type": "home",
"useRandomQuery": true,
"useMultiQueryPicker": true,
"multiQueryPickerLabel": "Pick a Decade",
"useQueryNamesForSection": true,
"isCustom": true,
"description": "Find a series from your favorite decade",
"useGaplessCards": false,
"useParentCard": false
}
]
},
{
"name": "Prolific People [Discovery]",
"author": "HighImKevin",
"description": "Discovery sections featuring prolific movie people, plus top series actors.",
"sections": [
{
"name": "Prolific Movie Writers",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"dataSource": "PeopleCache.getTopWriters",
"dataSourceOptions": {
"ItemType": "Movie"
},
"queryOptions": {
"SortBy": "Random",
"SortOrder": "Ascending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1770810264918",
"type": "custom"
},
{
"name": "Prolific Movie Directors",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"dataSource": "PeopleCache.getTopDirectors",
"dataSourceOptions": {
"ItemType": "Movie"
},
"queryOptions": {
"SortBy": "Random",
"SortOrder": "Ascending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1770810234128",
"type": "custom"
},
{
"name": "Prolific Movie Actors",
"enabled": true,
"order": 250,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"dataSource": "PeopleCache.getTopActors",
"dataSourceOptions": {
"ItemType": "Movie"
},
"queryOptions": {
"SortBy": "Random",
"SortOrder": "Ascending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1768973523242",
"type": "custom"
},
{
"name": "Prolific Series Actors",
"enabled": true,
"order": 250,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"dataSource": "PeopleCache.getTopActors",
"dataSourceOptions": {
"ItemType": "Series"
},
"queryOptions": {
"SortBy": "Random",
"SortOrder": "Ascending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1791300000001",
"type": "custom"
}
]
}
// Add more collections here
];
// Expose to window
window.KefinCommunityConfig = {
collections: COMMUNITY_COLLECTIONS
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+36 -17
View File
@@ -53,8 +53,9 @@
return `${this.prefix}${cacheName}_${user}`;
}
// Check if cache is valid (not expired)
async isCacheValid(cacheName, userId = null) {
// Get cached data entry (payload + metadata). Does NOT filter by TTL —
// callers (e.g. apiHelper.getQuery) may intentionally use stale rows.
async getCacheEntry(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
try {
@@ -67,32 +68,31 @@
request.onsuccess = () => {
const data = request.result;
if (!data) {
resolve(false);
resolve(null);
return;
}
const now = Date.now();
const ttl = data.ttl || this.ttl;
resolve((now - data.timestamp) < ttl);
// Return full entry including timestamp and ttl
resolve({
payload: data.payload,
timestamp: data.timestamp,
ttl: data.ttl || this.ttl
});
};
request.onerror = () => {
WARN('Error checking cache validity:', request.error);
resolve(false);
WARN('Error getting cache entry:', request.error);
resolve(null);
};
});
} catch (error) {
ERR('Error in isCacheValid:', error);
return false;
ERR('Error in getCacheEntry:', error);
return null;
}
}
// Get cached data if valid
// Get cached payload if present and not expired (single store.get)
async get(cacheName, userId = null) {
if (!(await this.isCacheValid(cacheName, userId))) {
return null;
}
const key = this.getCacheKey(cacheName, userId);
try {
@@ -104,7 +104,16 @@
return new Promise((resolve) => {
request.onsuccess = () => {
const data = request.result;
resolve(data ? data.payload : null);
if (!data) {
resolve(null);
return;
}
const ttl = data.ttl || this.ttl;
if ((Date.now() - data.timestamp) >= ttl) {
resolve(null);
return;
}
resolve(data.payload);
};
request.onerror = () => {
@@ -220,8 +229,18 @@
}
}
let _instance = null;
function getIndexedDBCache() {
if (!_instance) {
_instance = new IndexedDBCache();
LOG('IndexedDBCache instance created');
}
return _instance;
}
// Create global instance
window.IndexedDBCache = IndexedDBCache;
window.IndexedDBCache = getIndexedDBCache();
LOG('IndexedDBCache manager loaded');
+82 -69
View File
@@ -140,10 +140,7 @@
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
#moviesTab .listPaging>div,
#seriesTab .listPaging>div {
display: none !important;
}
/* Infinite scroll loading indicator */
.infinite-scroll-loading {
@@ -189,8 +186,7 @@
function waitForApiClient() {
return new Promise((resolve) => {
if (typeof ApiClient !== 'undefined' && ApiClient._serverInfo && ApiClient._serverInfo.UserId) {
userId = ApiClient._serverInfo.UserId;
LOG('ApiClient ready, userId:', userId);
LOG('ApiClient ready, userId:', ApiClient._serverInfo.UserId);
resolve();
} else {
LOG('Waiting for ApiClient...');
@@ -225,7 +221,6 @@
for (const match of hashMatches) {
const pageFromHash = match.replace('#/', '');
if (SUPPORTED_PAGES.includes(pageFromHash)) {
LOG('Found supported page:', pageFromHash);
return pageFromHash;
}
}
@@ -346,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 => {
@@ -388,9 +371,9 @@
let activeTab = null;
if (page === 'tv.html' || page === 'tv') {
activeTab = document.querySelector("#seriesTab.is-active");
activeTab = libraryPage.querySelector("#seriesTab.is-active") || document.querySelector("#tvshowsPage:not(.hide)");
} else if (page === 'movies.html' || page === 'movies') {
activeTab = document.querySelector("#moviesTab.is-active");
activeTab = libraryPage.querySelector("#moviesTab.is-active") || document.querySelector("#moviesPage:not(.hide)");
}
if (!activeTab) {
@@ -405,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 });
@@ -522,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 });
@@ -554,11 +562,16 @@
});
// 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);
});
if (typeof window.cardBuilder.registerLazyImagesIn === 'function') {
window.cardBuilder.registerLazyImagesIn(container);
}
LOG('Cards built successfully');
}
@@ -650,13 +663,12 @@
function checkScrollPosition() {
// Don't trigger if already loading, no more items, or request in progress
if (loading || !hasMore || isScrollTriggered || isRequestInProgress) {
LOG('Scroll check blocked:', { loading, hasMore, isScrollTriggered, isRequestInProgress });
return;
}
// Don't trigger when not on a supported page
const container = getContainer();
if (!container) {
LOG('No container found in scroll check');
return;
}
@@ -675,19 +687,6 @@
const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
const triggerThreshold = windowHeight; // One viewport height from bottom
LOG('Scroll check:', {
scrollTop,
windowHeight,
documentHeight,
distanceFromBottom,
triggerThreshold,
existingItemsCount,
loading,
hasMore,
isScrollTriggered,
isRequestInProgress
});
if (distanceFromBottom <= triggerThreshold) {
isScrollTriggered = true;
LOG('Triggering loadMore from scroll (one viewport height from bottom)');
@@ -735,6 +734,8 @@
// Check if we should initialize infinite scroll
function shouldInitialize() {
if (hasInitialized) return false;
const page = getCurrentPage();
const container = getContainer();
@@ -747,26 +748,16 @@
return false;
}
// Double-check that we have the correct active tab
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 (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) {
LOG('Skipping initialization:', { shouldInit: shouldInitialize(), hasInitialized, isInitializing });
if (hasInitialized || isInitializing) {
return;
}
@@ -790,30 +781,52 @@
reset();
//loadMore(); // load first page
setupScrollWatcher();
// Update pagination display with initial state
updatePaginationDisplay();
} finally {
isInitializing = false;
}
}
// Scroll state loading removed - not needed
// Wait for page to mount
const observer = new MutationObserver(() => {
function tryInitializeFromView() {
if (shouldInitialize()) {
initialize().catch(err => ERR('Initialization failed:', err));
return true;
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Also try to initialize immediately if page is already loaded
if (document.readyState === 'complete') {
setTimeout(() => {
initialize().catch(err => ERR('Initial initialization failed:', err));
}, 100);
return false;
}
function armLibraryPageInit() {
if (tryInitializeFromView()) return;
const utils = window.KefinTweaksUtils;
if (utils?.pollUntil) {
utils.pollUntil(() => (tryInitializeFromView() ? true : null), {
intervalMs: 200,
timeoutMs: 15000
});
return;
}
// Fallback brief poll
let tries = 0;
const timer = setInterval(() => {
tries += 1;
if (tryInitializeFromView() || tries >= 75) clearInterval(timer);
}, 200);
}
function startInfiniteScrollHook() {
const utils = window.KefinTweaksUtils;
if (!utils?.onViewPage) {
setTimeout(startInfiniteScrollHook, 500);
return;
}
utils.onViewPage(() => {
armLibraryPageInit();
}, { pages: SUPPORTED_PAGES });
armLibraryPageInit();
}
startInfiniteScrollHook();
// Cleanup function
function cleanup() {
+203 -174
View File
@@ -1,7 +1,4 @@
// KefinTweaks Item Details Collections
// Adds related collections to item details pages
// Requires: indexedDBCache.js, utils.js, cardBuilder.js modules to be loaded before this script
(function() {
'use strict';
@@ -9,170 +6,227 @@
const WARN = (...args) => console.warn('[KefinTweaks ItemDetailsCollections]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ItemDetailsCollections]', ...args);
function getCollectionsChildCountKey() {
const userId = window.ApiClient?.getCurrentUserId?.();
return `kefinTweaks_${userId || 'anonymous'}_allCollectionsChildCount`;
}
const CACHE_NAME = 'collections';
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
const COLLECTION_FETCH_CHUNK_SIZE = 8;
const COLLECTION_LIST_FIELDS = 'ChildCount,PrimaryImageAspectRatio,ImageTags,BackdropImageTags,ParentId';
// 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;
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));
}
if (!window.IndexedDBCache) {
WARN('IndexedDBCache not available');
return;
}
const cache = new window.IndexedDBCache('kefinTweaks_', CACHE_TTL);
// 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 allCollections = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
IncludeItemTypes: 'BoxSet,CollectionFolder',
Recursive: true
});
// Fetch all collection items in parallel
const collectionPromises = 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);
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 = {
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,
ParentId: coll.ParentId,
ParentIndexNumber: coll.ParentIndexNumber,
IndexNumber: coll.IndexNumber,
PrimaryImageAspectRatio: coll.PrimaryImageAspectRatio,
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
});
}
}
},
ItemIds: itemIds
});
}
return cacheData;
}
await cache.set(CACHE_NAME, cacheData);
LOG(`Collections cache populated: ${cacheData.length} collections`);
} catch (error) {
ERR('Error populating collections cache:', error);
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: [] } };
}
}
// Get parent collections for an item ID (returns collection items that can be passed to renderCards)
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;
}
if (!state.allCollections) {
state.allCollections = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
IncludeItemTypes: 'BoxSet,CollectionFolder',
Recursive: true,
Fields: COLLECTION_LIST_FIELDS
});
}
const allCollectionsChildCount = (state.allCollections.Items || [])
.reduce((sum, coll) => sum + (coll.ChildCount || 0), 0);
const collectionsChildCountKey = getCollectionsChildCountKey();
const cachedAllCollectionsChildCount = Number(localStorage.getItem(collectionsChildCountKey));
if (allCollectionsChildCount !== cachedAllCollectionsChildCount) {
localStorage.setItem(collectionsChildCountKey, allCollectionsChildCount);
invalidate = true;
LOG('Collections Child Count has changed, invalidating cache');
}
const currentCacheData = await cache.get(CACHE_NAME);
if (!invalidate && currentCacheData != null) {
LOG('Collections cache is still valid');
return;
}
const hasExistingCache = Array.isArray(currentCacheData) && currentCacheData.length > 0;
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
}
async function getParentCollections(itemId) {
if (!window.IndexedDBCache) {
WARN('IndexedDBCache not available');
return [];
}
const cache = new window.IndexedDBCache('kefinTweaks_', CACHE_TTL);
const cache = window.IndexedDBCache;
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;
function collectionNeedsImageEnrich(coll) {
return !coll?.ImageTags?.Primary;
}
async function enrichCollectionsWithImageFields(collections) {
const items = Array.isArray(collections) ? collections.slice() : [];
const missingIds = items
.filter(collectionNeedsImageEnrich)
.map((c) => c?.Id)
.filter(Boolean);
if (!missingIds.length || !window.ApiClient?.getItems) return items;
try {
const response = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
Ids: missingIds.join(','),
Fields: COLLECTION_LIST_FIELDS
});
const byId = new Map((response?.Items || []).map((it) => [it.Id, it]));
return items.map((coll) => {
if (!collectionNeedsImageEnrich(coll)) return coll;
const enriched = byId.get(coll.Id);
if (!enriched) return coll;
return {
...coll,
ParentId: enriched.ParentId ?? coll.ParentId,
PrimaryImageAspectRatio: enriched.PrimaryImageAspectRatio ?? coll.PrimaryImageAspectRatio,
ImageTags: enriched.ImageTags ?? coll.ImageTags,
BackdropImageTags: enriched.BackdropImageTags ?? coll.BackdropImageTags
};
});
} catch (err) {
WARN('Failed to enrich collection image fields', err);
return items;
}
}
async function renderCollectionsSection(item) {
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';
let parentCollectionItems = await getParentCollections(item.Id);
if (!parentCollectionItems || parentCollectionItems.length === 0) return;
// 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;
}
// Check for cardBuilder
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
if (!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
parentCollectionItems = await enrichCollectionsWithImageFields(parentCollectionItems);
const collectionsSection = window.cardBuilder.renderCards(
parentCollectionItems,
'Included In',
@@ -181,21 +235,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);
@@ -203,13 +251,18 @@
detailPageContent.appendChild(collectionsSection);
}
// Push-register lazy images (no body MutationObserver)
if (typeof window.cardBuilder.registerLazyImagesInSection === 'function') {
window.cardBuilder.registerLazyImagesInSection(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');
@@ -221,68 +274,44 @@
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']
pages: ['details'],
triggerOnSameHash: false
}
);
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');
})();
})();
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
// KefinTweaks Library Cache Facade
// Routes provider-id lookups and readiness checks to MoviesCache / SeriesCache
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks LibraryCache]', ...args);
function normalizeType(type) {
const t = String(type || '').toLowerCase();
if (t === 'movie' || t === 'movies') return 'Movie';
if (t === 'series' || t === 'show' || t === 'shows' || t === 'tv') return 'Series';
return type;
}
async function isComplete(type) {
const t = normalizeType(type);
if (t === 'Movie') return (await window.MoviesCache?.isComplete?.()) === true;
if (t === 'Series') return window.SeriesCache?.isComplete?.() === true;
return false;
}
async function isReady(type) {
return isComplete(type);
}
async function getByProviderIds(type, ids) {
const t = normalizeType(type);
if (t === 'Movie') {
return window.MoviesCache?.getMoviesByProviderIds?.(ids) || [];
}
if (t === 'Series') {
return window.SeriesCache?.getSeriesByProviderIds?.(ids) || [];
}
LOG('Unknown type for getByProviderIds:', type);
return [];
}
/**
* Whether a dataSource string depends on an incomplete Movies/Series/People cache.
*/
async function dataSourceDependsOnIncompleteCache(dataSource) {
const ds = typeof dataSource === 'string' ? dataSource : '';
if (!ds) return false;
if (ds.startsWith('MoviesCache.') && !(await isComplete('Movie'))) return true;
if (ds.startsWith('SeriesCache.') && !(await isComplete('Series'))) return true;
if (ds.startsWith('PeopleCache.') && window.PeopleCache?.isComplete && !window.PeopleCache.isComplete()) {
return true;
}
return false;
}
/**
* Whether a home section depends on a library/people/external cache that is not ready.
*/
async function sectionDependsOnIncompleteCache(section) {
if (!section) return false;
if (Array.isArray(section.externalListUrls) && section.externalListUrls.length) {
return !((await isComplete('Movie')) && (await isComplete('Series')));
}
if (await dataSourceDependsOnIncompleteCache(section.discoverySourceQuery?.dataSource)) {
return true;
}
const queries = section.queries || [];
for (const q of queries) {
if (await dataSourceDependsOnIncompleteCache(q?.dataSource)) return true;
}
return false;
}
window.LibraryCache = {
getByProviderIds,
isReady,
isComplete,
sectionDependsOnIncompleteCache
};
LOG('Module loaded');
})();
+443
View File
@@ -0,0 +1,443 @@
// KefinTweaks Library Cache Utils
// Shared strip / provider-id helpers for moviesCache, seriesCache, peopleCache
(function() {
'use strict';
function stripPerson(person) {
if (!person) return null;
const out = {
Name: person.Name,
Id: person.Id,
Role: person.Role,
Type: person.Type
};
if (person.PrimaryImageTag) out.PrimaryImageTag = person.PrimaryImageTag;
if (person.ImageBlurHashes?.Primary) {
out.ImageBlurHashes = { Primary: person.ImageBlurHashes.Primary };
}
return out;
}
function stripChapter(chapter) {
if (!chapter) return null;
return {
Name: chapter.Name,
ImageTag: chapter.ImageTag
};
}
function stripMediaStream(stream) {
if (!stream) return null;
return {
Height: stream.Height,
Width: stream.Width,
DisplayTitle: stream.DisplayTitle,
Language: stream.Language,
Type: stream.Type
};
}
function stripStudio(studio) {
if (!studio) return null;
return {
Name: studio.Name,
Id: studio.Id
};
}
function stripGenreItem(genre) {
if (!genre) return null;
return {
Name: genre.Name,
Id: genre.Id
};
}
/**
* Strip a movie item to the durable library-cache allowlist (no UserData).
*/
function stripMovieForCache(item) {
if (!item) return null;
return {
Name: item.Name,
Id: item.Id,
HasSubtitles: item.HasSubtitles,
PremiereDate: item.PremiereDate,
CriticRating: item.CriticRating,
OfficialRating: item.OfficialRating,
Overview: item.Overview,
Taglines: item.Taglines,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
ProviderIds: item.ProviderIds || {},
Type: item.Type || 'Movie',
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
MediaStreams: Array.isArray(item.MediaStreams) ? item.MediaStreams.map(stripMediaStream).filter(Boolean) : [],
ImageTags: item.ImageTags || {},
BackdropImageTags: item.BackdropImageTags || [],
Chapters: Array.isArray(item.Chapters) ? item.Chapters.map(stripChapter).filter(Boolean) : [],
Trickplay: item.Trickplay || undefined,
DateCreated: item.DateCreated,
DateLastMediaAdded: item.DateLastMediaAdded,
DateLastSaved: item.DateLastSaved,
UserData: item.UserData || {}
};
}
/**
* Strip a series item (no MediaStreams/Chapters/Trickplay).
*/
function stripSeriesForCache(item) {
if (!item) return null;
return {
Name: item.Name,
Id: item.Id,
PremiereDate: item.PremiereDate,
CriticRating: item.CriticRating,
OfficialRating: item.OfficialRating,
Overview: item.Overview,
Taglines: item.Taglines,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
ProviderIds: item.ProviderIds || {},
Type: item.Type || 'Series',
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
ImageTags: item.ImageTags || {},
BackdropImageTags: item.BackdropImageTags || [],
ChildCount: item.ChildCount,
RecursiveItemCount: item.RecursiveItemCount,
DateCreated: item.DateCreated,
DateLastContentAdded: item.DateLastContentAdded,
DateLastSaved: item.DateLastSaved,
UserData: item.UserData || {}
};
}
function stripItemsForCache(items, kind) {
const strip = kind === 'Series' ? stripSeriesForCache : stripMovieForCache;
return (items || []).map(strip).filter(Boolean);
}
/**
* Normalize provider id keys for matching (Imdb/Tmdb/Tvdb).
* @param {string|Object} idOrObj
* @returns {string[]} normalized keys like "imdb:tt123", "tmdb:456"
*/
function normalizeProviderKeys(idOrObj) {
const keys = [];
if (!idOrObj) return keys;
if (typeof idOrObj === 'string') {
const s = idOrObj.trim();
if (!s) return keys;
if (/^tt\d+$/i.test(s)) keys.push(`imdb:${s.toLowerCase()}`);
else if (/^\d+$/.test(s)) {
keys.push(`tmdb:${s}`);
keys.push(`tvdb:${s}`);
} else keys.push(s.toLowerCase());
return keys;
}
const p = idOrObj.ProviderIds || idOrObj;
if (p.Imdb) keys.push(`imdb:${String(p.Imdb).toLowerCase()}`);
if (p.Tmdb) keys.push(`tmdb:${String(p.Tmdb)}`);
if (p.Tvdb) keys.push(`tvdb:${String(p.Tvdb)}`);
return keys;
}
function itemMatchesProviderIds(item, idSet) {
if (!item || !idSet || idSet.size === 0) return false;
return normalizeProviderKeys(item).some(k => idSet.has(k));
}
function buildProviderIdIndex(items) {
const index = Object.create(null);
(items || []).forEach((item) => {
if (!item?.Id) return;
normalizeProviderKeys(item).forEach((key) => {
if (!index[key]) index[key] = [];
if (!index[key].includes(item.Id)) index[key].push(item.Id);
});
});
return index;
}
function getLibraryCacheSettings() {
const fromHome = window.KefinHomeScreen?.getConfig?.()?.LIBRARY_CACHE;
const fromDefault = window.KefinHomeConfig2?.LIBRARY_CACHE;
return {
movieChunkSize: 1000,
seriesChunkSize: 1000,
mdblistApiKey: '',
CACHE_TTL: 14 * 24 * 60 * 60 * 1000,
...(fromDefault || {}),
...(fromHome || {})
};
}
/**
* Run strip on main thread; yields between microtasks for UI breathing room.
* Worker path: Blob worker when available (same strip logic inlined).
*/
async function stripItemsAsync(items, kind) {
if (!items || !items.length) return [];
if (typeof Worker === 'undefined') {
return stripItemsForCache(items, kind);
}
try {
return await stripItemsInWorker(items, kind);
} catch (e) {
console.warn('[KefinTweaks LibraryCacheUtils] Worker strip failed, using main thread:', e);
return stripItemsForCache(items, kind);
}
}
function stripItemsInWorker(items, kind) {
return new Promise((resolve, reject) => {
const workerSource = `
${stripPerson.toString()}
${stripChapter.toString()}
${stripMediaStream.toString()}
${stripStudio.toString()}
${stripGenreItem.toString()}
${stripMovieForCache.toString()}
${stripSeriesForCache.toString()}
${stripItemsForCache.toString()}
self.onmessage = function(e) {
try {
const { items, kind } = e.data;
const result = stripItemsForCache(items, kind);
self.postMessage({ ok: true, result });
} catch (err) {
self.postMessage({ ok: false, error: String(err && err.message || err) });
}
};
`;
const blob = new Blob([workerSource], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
const timer = setTimeout(() => {
worker.terminate();
URL.revokeObjectURL(url);
reject(new Error('Worker strip timeout'));
}, 30000);
worker.onmessage = (e) => {
clearTimeout(timer);
worker.terminate();
URL.revokeObjectURL(url);
if (e.data?.ok) resolve(e.data.result);
else reject(new Error(e.data?.error || 'Worker strip failed'));
};
worker.onerror = (err) => {
clearTimeout(timer);
worker.terminate();
URL.revokeObjectURL(url);
reject(err);
};
worker.postMessage({ items, kind });
});
}
function upsertById(existingItems, incomingItems) {
const map = new Map();
(existingItems || []).forEach((item) => {
if (item?.Id) map.set(item.Id, item);
});
(incomingItems || []).forEach((item) => {
if (item?.Id) map.set(item.Id, item);
});
return Array.from(map.values());
}
function removeByIds(existingItems, removeIds) {
if (!removeIds || !removeIds.size) return existingItems || [];
return (existingItems || []).filter((item) => item?.Id && !removeIds.has(item.Id));
}
function isVideoPlaybackRoute() {
const hash = String(window.location?.hash || '');
return hash.includes('#/video') || hash.includes('/video');
}
/**
* Wait while Jellyfin video playback is active or the tab is hidden,
* so library cache crawls do not compete for network during playback.
*/
async function waitWhileCacheNetworkPaused() {
while (isVideoPlaybackRoute() || (typeof document !== 'undefined' && document.hidden)) {
await new Promise((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
cleanup();
resolve();
};
const cleanup = () => {
window.removeEventListener('hashchange', onHash);
document.removeEventListener('visibilitychange', onVis);
clearTimeout(timer);
};
const onHash = () => {
if (!isVideoPlaybackRoute() && !(typeof document !== 'undefined' && document.hidden)) {
done();
}
};
const onVis = () => {
if (!document.hidden && !isVideoPlaybackRoute()) done();
};
window.addEventListener('hashchange', onHash);
document.addEventListener('visibilitychange', onVis);
const timer = setTimeout(done, 1000);
});
}
}
const IDLE_HOLD_MS = 15000;
const BOOTSTRAP_FALLBACK_MS = 30000;
const SESSION_READY_FALLBACK_MS = 30000;
function hasMouseIdle() {
return typeof document !== 'undefined'
&& document.body
&& document.body.classList.contains('mouseIdle');
}
/**
* After login (+ home painted or session-ready fallback), fire onReady once when
* the first of these happens: 15s continuous body.mouseIdle, document.hidden,
* or 30s fallback. Background tabs are allowed (and preferred).
*
* @param {{ waitForLogin: () => Promise<boolean>, onReady: () => void }} opts
*/
function scheduleIdleBootstrap(opts) {
const waitForLogin = opts?.waitForLogin;
const onReady = opts?.onReady;
if (typeof onReady !== 'function') return;
let fired = false;
let idleTimer = null;
let fallbackTimer = null;
let sessionReadyTimer = null;
let mo = null;
const cleanup = () => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
if (fallbackTimer) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
if (sessionReadyTimer) {
clearTimeout(sessionReadyTimer);
sessionReadyTimer = null;
}
if (mo) {
mo.disconnect();
mo = null;
}
document.removeEventListener('visibilitychange', onVisibility);
document.removeEventListener('kefinTweaksHomePainted', onHomePainted);
};
const fire = () => {
if (fired) return;
fired = true;
cleanup();
try {
onReady();
} catch (e) {
console.error('[KefinTweaks LibraryCacheUtils] scheduleIdleBootstrap onReady failed:', e);
}
};
const syncIdleHold = () => {
if (fired) return;
if (hasMouseIdle()) {
if (!idleTimer) {
idleTimer = setTimeout(() => {
idleTimer = null;
if (hasMouseIdle()) fire();
}, IDLE_HOLD_MS);
}
} else if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
const onVisibility = () => {
if (document.hidden) fire();
};
const armGates = () => {
if (fired) return;
if (document.hidden) {
fire();
return;
}
fallbackTimer = setTimeout(fire, BOOTSTRAP_FALLBACK_MS);
document.addEventListener('visibilitychange', onVisibility);
if (typeof MutationObserver !== 'undefined' && document.body) {
mo = new MutationObserver(syncIdleHold);
mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
syncIdleHold();
};
const onHomePainted = () => {
document.removeEventListener('kefinTweaksHomePainted', onHomePainted);
if (sessionReadyTimer) {
clearTimeout(sessionReadyTimer);
sessionReadyTimer = null;
}
armGates();
};
Promise.resolve()
.then(() => (typeof waitForLogin === 'function' ? waitForLogin() : true))
.then((ok) => {
if (fired) return;
if (!ok) {
fire();
return;
}
document.addEventListener('kefinTweaksHomePainted', onHomePainted);
sessionReadyTimer = setTimeout(() => {
sessionReadyTimer = null;
document.removeEventListener('kefinTweaksHomePainted', onHomePainted);
armGates();
}, SESSION_READY_FALLBACK_MS);
})
.catch((e) => {
console.error('[KefinTweaks LibraryCacheUtils] scheduleIdleBootstrap login wait failed:', e);
fire();
});
}
window.LibraryCacheUtils = {
stripMovieForCache,
stripSeriesForCache,
stripPerson,
stripItemsForCache,
stripItemsAsync,
normalizeProviderKeys,
itemMatchesProviderIds,
buildProviderIdIndex,
getLibraryCacheSettings,
upsertById,
removeByIds,
isVideoPlaybackRoute,
waitWhileCacheNetworkPaused,
scheduleIdleBootstrap
};
})();
+2 -2
View File
@@ -130,6 +130,6 @@
cursor: auto
}
.dialog[data-footer="true"] [data-name="kefin-modal-content"]>*:last-child {
margin-bottom: 8em !important;
.kefinModalFooter {
position: relative !important;
}
+564 -39
View File
@@ -6,21 +6,439 @@
window.ModalSystem = (function() {
'use strict';
// Store active modals
// Store active modals (insertion order = stack order)
const activeModals = new Map();
// Single shared backdrop for the whole stack (max one in the DOM)
let sharedBackdropEl = null;
let sharedBackdropClickBound = false;
// Body scroll lock while any kefin modal is open (replaces non-passive wheel trap)
let bodyScrollLockCount = 0;
let savedBodyOverflow = null;
let savedHtmlOverflow = null;
const FOCUSABLE_SELECTOR = [
'button:not([disabled]):not([tabindex="-1"])',
'[href]:not([tabindex="-1"])',
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"])',
'[role="button"]:not([tabindex="-1"])',
].join(',');
function isTvLayout() {
return document.documentElement.classList.contains('layout-tv');
}
function lockBodyScroll() {
if (bodyScrollLockCount === 0) {
savedBodyOverflow = document.body.style.overflow;
savedHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
}
bodyScrollLockCount++;
}
function unlockBodyScroll() {
if (bodyScrollLockCount <= 0) return;
bodyScrollLockCount--;
if (bodyScrollLockCount === 0) {
document.body.style.overflow = savedBodyOverflow || '';
document.documentElement.style.overflow = savedHtmlOverflow || '';
savedBodyOverflow = null;
savedHtmlOverflow = null;
}
}
/**
* Apply Jellyfin's scaleup open animation and clear it when finished.
* Clearing avoids a stuck compositor layer that leaves large dialogs soft/blurry.
* @param {HTMLElement} dialog
*/
function applyOpenAnimation(dialog) {
if (!dialog) return;
dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
const onEnd = (e) => {
if (e.target !== dialog) return;
if (e.animationName && e.animationName !== 'scaleup') return;
dialog.style.animation = '';
dialog.removeEventListener('animationend', onEnd);
};
dialog.addEventListener('animationend', onEnd);
}
function getFocusableElements(root) {
if (!root) return [];
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => {
if (el.closest('[hidden], [aria-hidden="true"]')) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (el.disabled) return false;
return true;
});
}
function stampShowFocus(root) {
if (!root || !isTvLayout()) return;
root.querySelectorAll('button, a.emby-button, .emby-button, .paper-icon-button-light, [role="button"]').forEach((el) => {
if (el.getAttribute('tabindex') === '-1') return;
el.classList.add('show-focus');
});
}
function resolveInitialFocus(root, initialFocus) {
if (!root) return null;
if (initialFocus instanceof HTMLElement && root.contains(initialFocus)) {
return initialFocus;
}
if (typeof initialFocus === 'string') {
const bySelector = root.querySelector(initialFocus);
if (bySelector) return bySelector;
}
return (
root.querySelector('[data-autofocus]') ||
root.querySelector('.is-active') ||
root.querySelector('.selected') ||
getFocusableElements(root)[0] ||
null
);
}
let anchoredPopoverStylesInjected = false;
function ensureAnchoredPopoverStyles() {
if (anchoredPopoverStylesInjected) return;
anchoredPopoverStylesInjected = true;
const style = document.createElement('style');
style.id = 'kefinTweaks-anchoredPopover-styles';
style.textContent = `
.dialogContainer.kefin-modal-anchored-container {
contain: none !important;
overflow: visible !important;
}
.dialogContainer.kefin-modal-anchored-container > .dialog.kefin-anchored-popover-dialog {
position: relative !important;
inset: auto !important;
top: auto !important;
right: auto !important;
bottom: auto !important;
left: auto !important;
width: auto !important;
height: auto !important;
max-height: none !important;
}
`;
document.head.appendChild(style);
}
/**
* Document-absolute shell: override Jellyfin fixed full-screen dialogContainer.
* @param {HTMLElement|null|undefined} dialogContainer
*/
function prepareAnchoredDialogContainer(dialogContainer) {
if (!dialogContainer) return;
ensureAnchoredPopoverStyles();
dialogContainer.classList.add('kefin-modal-anchored-container');
dialogContainer.setAttribute('data-kefin-anchored', 'true');
dialogContainer.style.position = 'absolute';
dialogContainer.style.right = 'auto';
dialogContainer.style.bottom = 'auto';
dialogContainer.style.inset = 'auto';
dialogContainer.style.width = 'auto';
dialogContainer.style.height = 'auto';
dialogContainer.style.overflow = 'visible';
dialogContainer.style.contain = 'none';
dialogContainer.style.pointerEvents = 'none';
if (!dialogContainer.style.zIndex) {
dialogContainer.style.zIndex = '1100';
}
}
/**
* Inner dialog is in-flow inside the anchored container (not viewport-fixed / inset fill).
* @param {HTMLElement|null|undefined} dialog
*/
function prepareAnchoredPopoverDialog(dialog) {
if (!dialog) return;
ensureAnchoredPopoverStyles();
dialog.classList.add('kefin-anchored-popover-dialog');
dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
dialog.style.position = 'relative';
dialog.style.inset = 'auto';
dialog.style.top = 'auto';
dialog.style.right = 'auto';
dialog.style.bottom = 'auto';
dialog.style.left = 'auto';
dialog.style.margin = '0';
dialog.style.width = 'auto';
dialog.style.height = 'auto';
dialog.style.maxHeight = 'none';
dialog.style.pointerEvents = 'auto';
dialog.style.overflow = 'visible';
}
/**
* Place anchored popover shell in document space; dialog lays out inside the container.
* @param {HTMLElement|null|undefined} dialogContainer
* @param {HTMLElement|null|undefined} dialog
* @param {number} leftViewport
* @param {number} topViewport
*/
function positionAnchoredPopoverShell(dialogContainer, dialog, leftViewport, topViewport) {
prepareAnchoredDialogContainer(dialogContainer);
prepareAnchoredPopoverDialog(dialog);
if (!dialogContainer) return;
const scrollX = window.scrollX || window.pageXOffset || 0;
const scrollY = window.scrollY || window.pageYOffset || 0;
dialogContainer.style.left = `${leftViewport + scrollX}px`;
dialogContainer.style.top = `${topViewport + scrollY}px`;
}
/**
* Position a dialog near an anchor (document-absolute shell + scroll offsets).
* @param {HTMLElement} dialog
* @param {HTMLElement} anchor
* @param {{ dialogContainer?: HTMLElement|null }} [options]
*/
function positionNearAnchor(dialog, anchor, options = {}) {
if (!dialog || !anchor) return;
const margin = 8;
const gap = 8;
const edgeMargin = 16;
const btnRect = anchor.getBoundingClientRect();
const dialogWidth = dialog.offsetWidth || dialog.getBoundingClientRect().width || 200;
const dialogHeight = dialog.offsetHeight || dialog.getBoundingClientRect().height || 120;
const spaceBelow = window.innerHeight - btnRect.bottom - margin;
const spaceAbove = btnRect.top - margin;
const placeBelow = spaceBelow >= Math.min(spaceAbove, 160) || spaceBelow >= spaceAbove;
let left = btnRect.left;
left = Math.min(
Math.max(edgeMargin, left),
Math.max(edgeMargin, window.innerWidth - dialogWidth - edgeMargin),
);
let top;
if (placeBelow) {
top = btnRect.bottom + gap;
} else {
top = Math.max(margin, btnRect.top - gap - dialogHeight);
}
top = Math.min(Math.max(top, margin), window.innerHeight - Math.min(dialogHeight, window.innerHeight - margin * 2) - margin);
const dialogContainer = options.dialogContainer
|| dialog.closest?.('.dialogContainer')
|| null;
positionAnchoredPopoverShell(dialogContainer, dialog, left, top);
}
function focusElement(el) {
if (!el || typeof el.focus !== 'function') return;
try {
el.focus({ preventScroll: true });
} catch (_) {
el.focus();
}
}
function owningDialog(el) {
return el && typeof el.closest === 'function' ? el.closest('.dialog') : null;
}
function getTopmostModalId() {
const ids = Array.from(activeModals.keys());
return ids.length ? ids[ids.length - 1] : null;
}
/** Backdrop click closes the topmost modal that opted into closeOnBackdrop. */
function onSharedBackdropClick() {
const ids = Array.from(activeModals.keys());
for (let i = ids.length - 1; i >= 0; i--) {
const modal = activeModals.get(ids[i]);
if (modal?.closeOnBackdrop) {
closeModal(ids[i]);
return;
}
}
}
function ensureSharedBackdrop(backdrop) {
const existing = sharedBackdropEl
&& document.body.contains(sharedBackdropEl)
? sharedBackdropEl
: document.body.querySelector('.dialogBackdrop[data-kefin-modal-backdrop="true"]');
if (existing) {
sharedBackdropEl = existing;
if (!sharedBackdropClickBound) {
existing.addEventListener('click', onSharedBackdropClick);
sharedBackdropClickBound = true;
}
return existing;
}
backdrop.setAttribute('data-kefin-modal-backdrop', 'true');
document.body.appendChild(backdrop);
sharedBackdropEl = backdrop;
if (!sharedBackdropClickBound) {
backdrop.addEventListener('click', onSharedBackdropClick);
sharedBackdropClickBound = true;
}
return backdrop;
}
function getFocusRows(dialogEl) {
const rows = Array.from(dialogEl.querySelectorAll('[data-kefin-focus-row]'));
const withItems = rows.map((row) => ({
row,
items: getFocusableElements(row).filter((el) => el.closest('[data-kefin-focus-row]') === row),
})).filter((entry) => entry.items.length);
withItems.sort((a, b) => {
const at = a.row.getBoundingClientRect();
const bt = b.row.getBoundingClientRect();
if (Math.abs(at.top - bt.top) > 4) return at.top - bt.top;
return at.left - bt.left;
});
return withItems;
}
/**
* Attach focus trap to a dialog element. Returns a dispose function.
* @param {HTMLElement} dialogEl
* @param {{ returnFocusEl?: HTMLElement|null, initialFocusEl?: HTMLElement|string|null, onEscape?: Function|null }} options
*/
function attachFocusTrap(dialogEl, options = {}) {
if (!dialogEl) return () => {};
const returnFocusEl = options.returnFocusEl || null;
const onEscape = typeof options.onEscape === 'function' ? options.onEscape : null;
dialogEl.classList.add('focuscontainer');
stampShowFocus(dialogEl);
const focusInitial = () => {
const target = resolveInitialFocus(dialogEl, options.initialFocusEl);
if (target) {
focusElement(target);
} else if (typeof dialogEl.focus === 'function') {
if (!dialogEl.hasAttribute('tabindex')) dialogEl.setAttribute('tabindex', '-1');
focusElement(dialogEl);
}
};
requestAnimationFrame(() => {
requestAnimationFrame(focusInitial);
});
const keydownHandler = (e) => {
if (!dialogEl.isConnected) return;
const active = document.activeElement;
const activeDialog = owningDialog(active);
// Nested/child modal owns keys until it closes
if (activeDialog && activeDialog !== dialogEl) return;
const isNavKey = e.key === 'ArrowDown' || e.key === 'ArrowUp'
|| e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Tab';
if (!dialogEl.contains(active) && e.key !== 'Escape') {
if (isNavKey) {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
focusInitial();
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
if (onEscape) onEscape(e);
return;
}
const focusables = getFocusableElements(dialogEl);
if (!focusables.length) return;
const currentIndex = focusables.indexOf(active);
const at = currentIndex >= 0 ? currentIndex : 0;
const rows = getFocusRows(dialogEl);
const useRows = rows.length >= 2;
const consume = () => {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
};
if (e.key === 'Tab') {
consume();
const next = e.shiftKey
? focusables[(at - 1 + focusables.length) % focusables.length]
: focusables[(at + 1) % focusables.length];
focusElement(next);
return;
}
if (useRows && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
consume();
let rowIndex = rows.findIndex((entry) => entry.items.includes(active));
if (rowIndex < 0) {
rowIndex = rows.findIndex((entry) => entry.row.contains(active));
}
if (rowIndex < 0) rowIndex = 0;
const row = rows[rowIndex];
let col = row.items.indexOf(active);
if (col < 0) col = 0;
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const nextCol = e.key === 'ArrowRight'
? (col + 1) % row.items.length
: (col - 1 + row.items.length) % row.items.length;
focusElement(row.items[nextCol]);
return;
}
const nextRowIndex = e.key === 'ArrowDown'
? (rowIndex + 1) % rows.length
: (rowIndex - 1 + rows.length) % rows.length;
const nextRow = rows[nextRowIndex];
const nextCol = Math.min(col, nextRow.items.length - 1);
focusElement(nextRow.items[nextCol]);
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
consume();
focusElement(focusables[(at + 1) % focusables.length]);
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
consume();
focusElement(focusables[(at - 1 + focusables.length) % focusables.length]);
}
};
// Document capture so Left/Right are not taken by Jellyfin spatial navigation
document.addEventListener('keydown', keydownHandler, true);
return () => {
document.removeEventListener('keydown', keydownHandler, true);
if (returnFocusEl && typeof returnFocusEl.focus === 'function' && document.contains(returnFocusEl)) {
focusElement(returnFocusEl);
}
};
}
/**
* Create a Jellyfin-style modal dialog
* @param {Object} options - Modal configuration
* @param {string} options.id - Unique modal ID
* @param {string} options.title - Modal title
* @param {string|HTMLElement} options.content - HTML content for the modal body
* @param {string|HTMLElement} options.footer - Optional footer HTML content
* @param {Function} options.onClose - Callback when modal closes
* @param {Function} options.onOpen - Callback when modal opens
* @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.lockPageScroll=true] - When true, contain overscroll so wheel on the dialog does not chain to the page. Set false for anchored popovers so short lists allow page scroll.
* @returns {Object} Modal instance
*/
function createModal(options = {}) {
@@ -33,7 +451,17 @@ window.ModalSystem = (function() {
onOpen,
closeOnBackdrop = true,
closeOnEscape = true,
showCloseButton = true
showCloseButton = true,
fixedSize,
dialogStyle,
returnFocus,
initialFocus,
trapFocus,
anchor = null,
dialogClassName = '',
// When true (default), contain overscroll so wheel on the dialog does not chain to the page.
// Set false for anchored popovers (e.g. multi-query) so short lists let the page scroll.
lockPageScroll = true,
} = options;
if (!id) {
@@ -43,6 +471,24 @@ window.ModalSystem = (function() {
// Remove existing modal if it exists
removeModal(id);
const useFixedSize = fixedSize === true
|| (fixedSize !== false && typeof window !== 'undefined' && window.innerWidth < 900);
let _showCloseButton = showCloseButton;
if (title == null && showCloseButton === false) {
_showCloseButton = false;
} else if (window.innerWidth < 900 && useFixedSize && showCloseButton !== false && title) {
_showCloseButton = true;
}
const shouldTrap = trapFocus === true || (trapFocus !== false && isTvLayout());
const returnFocusEl =
returnFocus instanceof HTMLElement
? returnFocus
: document.activeElement instanceof HTMLElement
? document.activeElement
: null;
// Create modal elements
const backdrop = document.createElement('div');
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
@@ -53,19 +499,26 @@ 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';
const extraClasses = dialogClassName ? ` ${dialogClassName}` : '';
dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened${extraClasses}`;
dialog.setAttribute('data-history', 'true');
dialog.setAttribute('data-autofocus', 'true');
dialog.setAttribute('data-removeonclose', 'true');
dialog.setAttribute('data-name', 'kefin-modal');
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';
}
// Final box size before scaleup so the compositor layer is not soft-scaled after open
if (dialogStyle && typeof dialogStyle === 'object') {
Object.assign(dialog.style, dialogStyle);
}
applyOpenAnimation(dialog);
// Create header if title is provided
let dialogHeader = null;
if (title) {
if (title || _showCloseButton) {
dialogHeader = document.createElement('div');
dialogHeader.className = 'formDialogHeader';
dialogHeader.style.display = 'flex';
@@ -75,14 +528,15 @@ window.ModalSystem = (function() {
dialogHeader.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
dialogHeader.style.flexShrink = '0';
const titleElement = document.createElement('h2');
titleElement.style.margin = '0';
titleElement.style.textAlign = 'left';
titleElement.textContent = title;
dialogHeader.appendChild(titleElement);
if (title) {
const titleElement = document.createElement('h2');
titleElement.style.margin = '0';
titleElement.style.textAlign = 'left';
titleElement.textContent = title;
dialogHeader.appendChild(titleElement);
}
// Add close button if enabled
if (showCloseButton) {
if (_showCloseButton) {
const closeButton = document.createElement('button');
closeButton.setAttribute('is', 'paper-icon-button-light');
closeButton.className = 'btnCancel btnClose autoSize paper-icon-button-light';
@@ -98,15 +552,23 @@ window.ModalSystem = (function() {
dialogHeader.appendChild(closeButton);
}
dialog.appendChild(dialogHeader);
if (dialogHeader.childNodes.length > 0) {
dialog.appendChild(dialogHeader);
}
}
// Create scrollable content area
const dialogContent = document.createElement('div');
dialogContent.style.padding = title || footer ? '1.25em 1.5em' : '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';
if (lockPageScroll) {
// Contain overscroll so nested wheel/touch scroll does not chain to the page
// (avoids a non-passive wheel listener that delays input while the main thread is busy)
dialogContent.style.overscrollBehavior = 'contain';
dialog.style.overscrollBehavior = 'contain';
}
// Add content
if (content) {
@@ -121,7 +583,7 @@ window.ModalSystem = (function() {
let dialogFooter = null;
if (footer) {
dialogFooter = document.createElement('div');
dialogFooter.className = 'formDialogFooter';
dialogFooter.className = 'formDialogFooter kefinModalFooter';
dialogFooter.style.padding = '1.25em 1.5em';
dialogFooter.style.borderTop = '1px solid rgba(255,255,255,0.1)';
dialogFooter.style.flexShrink = '0';
@@ -143,33 +605,39 @@ window.ModalSystem = (function() {
}
dialogContainer.appendChild(dialog);
// Add to DOM
document.body.appendChild(backdrop);
// Single shared backdrop for the stack; nested modals reuse the same DOM node
const resolvedBackdrop = ensureSharedBackdrop(backdrop);
document.body.appendChild(dialogContainer);
// Create modal instance
const modalInstance = {
id,
backdrop,
backdrop: resolvedBackdrop,
dialogContainer,
dialog,
dialogContent,
dialogHeader,
dialogFooter,
isOpen: true,
closeOnBackdrop: !!closeOnBackdrop,
lockPageScroll: !!lockPageScroll,
onClose: typeof onClose === 'function' ? onClose : null,
returnFocusEl,
close: () => closeModal(id),
updateContent: (newContent) => updateModalContent(id, newContent),
addEventListener: (event, handler) => {
dialog.addEventListener(event, handler);
}
},
_disposeFocusTrap: null,
_escapeHandler: null,
};
// Store modal instance
activeModals.set(id, modalInstance);
//lockBodyScroll();
// Add event listeners
// dialogContainer click closes this modal; shared backdrop always closes topmost
if (closeOnBackdrop) {
backdrop.addEventListener('click', () => closeModal(id));
dialogContainer.addEventListener('click', (e) => {
if (e.target === dialogContainer) {
closeModal(id);
@@ -177,7 +645,7 @@ window.ModalSystem = (function() {
});
}
if (closeOnEscape) {
if (closeOnEscape && !shouldTrap) {
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal(id);
@@ -187,6 +655,25 @@ window.ModalSystem = (function() {
modalInstance._escapeHandler = escapeHandler;
}
if (anchor instanceof HTMLElement) {
dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
dialog.style.maxHeight = 'none';
prepareAnchoredDialogContainer(dialogContainer);
requestAnimationFrame(() => {
positionNearAnchor(dialog, anchor, { dialogContainer });
});
}
if (shouldTrap) {
modalInstance._disposeFocusTrap = attachFocusTrap(dialog, {
returnFocusEl,
initialFocusEl: initialFocus,
onEscape: closeOnEscape ? () => closeModal(id) : null,
});
} else if (isTvLayout()) {
stampShowFocus(dialog);
}
// Call onOpen callback
if (onOpen && typeof onOpen === 'function') {
onOpen(modalInstance);
@@ -196,29 +683,59 @@ window.ModalSystem = (function() {
}
/**
* Close a modal by ID
* Close a modal by ID. Closes later-opened (nested) modals first.
* @param {string} id - Modal ID
* @param {{ fromCascade?: boolean }} [options]
*/
function closeModal(id) {
function closeModal(id, options = {}) {
const modal = activeModals.get(id);
if (!modal) return;
// Close nested modals opened after this one (reverse stack order)
if (!options.fromCascade) {
const ids = Array.from(activeModals.keys());
const idx = ids.indexOf(id);
if (idx >= 0) {
for (let i = ids.length - 1; i > idx; i--) {
closeModal(ids[i], { fromCascade: true });
}
}
}
// Remove escape handler if it exists
if (modal._escapeHandler) {
document.removeEventListener('keydown', modal._escapeHandler);
modal._escapeHandler = null;
}
// Remove from DOM
if (modal.backdrop && modal.backdrop.parentNode) {
modal.backdrop.parentNode.removeChild(modal.backdrop);
if (typeof modal._disposeFocusTrap === 'function') {
modal._disposeFocusTrap();
modal._disposeFocusTrap = null;
} else if (modal.returnFocusEl && typeof modal.returnFocusEl.focus === 'function' && document.contains(modal.returnFocusEl)) {
try {
modal.returnFocusEl.focus({ preventScroll: true });
} catch (_) {
modal.returnFocusEl.focus();
}
}
// Remove dialog container from DOM (dialog stays a child for anchored popovers)
if (modal.dialogContainer && modal.dialogContainer.parentNode) {
modal.dialogContainer.parentNode.removeChild(modal.dialogContainer);
}
// Remove from active modals
// Remove from active modals before deciding on shared backdrop
activeModals.delete(id);
// Shared backdrop stays while any modal remains; remove only when stack is empty
if (activeModals.size === 0 && sharedBackdropEl && sharedBackdropEl.parentNode) {
sharedBackdropEl.removeEventListener('click', onSharedBackdropClick);
sharedBackdropClickBound = false;
sharedBackdropEl.parentNode.removeChild(sharedBackdropEl);
sharedBackdropEl = null;
}
//unlockBodyScroll();
// Call onClose callback if modal instance has it
if (modal.onClose && typeof modal.onClose === 'function') {
modal.onClose();
@@ -251,6 +768,7 @@ window.ModalSystem = (function() {
modal.dialogContent.innerHTML = '';
modal.dialogContent.appendChild(content);
}
stampShowFocus(modal.dialog);
}
/**
@@ -286,6 +804,13 @@ window.ModalSystem = (function() {
updateContent: updateModalContent,
isOpen: isModalOpen,
getActiveIds: getActiveModalIds,
closeAll: closeAllModals
closeAll: closeAllModals,
applyOpenAnimation,
attachFocusTrap,
prepareAnchoredDialogContainer,
prepareAnchoredPopoverDialog,
positionAnchoredPopoverShell,
positionNearAnchor,
getFocusableElements,
};
})();
+509
View File
@@ -0,0 +1,509 @@
// KefinTweaks Movies Library Cache
// Chunked per-user archive of movies (no UserData). Startup after login + incremental sync.
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args);
const CACHE_NAME = 'movies_library_v2';
const PARTIAL_CACHE_NAME = 'movies_library_partial_v2';
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedMovieCache_';
const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000;
const MOVIE_FIELDS = [
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview', 'PrimaryImageAspectRatio',
'ImageBlurHashes', 'DateCreated', 'DateLastMediaAdded'
].join(',');
let movies = null;
let isComplete = false;
let imdbTop250Movies = null;
let fetchPromise = null;
let providerIndex = null;
function utils() {
return window.LibraryCacheUtils || {};
}
function getTtl() {
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
}
function getChunkSize() {
return utils().getLibraryCacheSettings?.().movieChunkSize || 1000;
}
function lastFetchStorageKey() {
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
}
function getLastDateFetched() {
try {
return localStorage.getItem(lastFetchStorageKey()) || null;
} catch (_) {
return null;
}
}
function setLastDateFetched(iso) {
try {
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
} catch (e) {
WARN('Failed to store lastDateFetchedMovieCache:', e);
}
}
/** Max DateLastMediaAdded || DateCreated among movies; null if none usable. */
function watermarkFromMovies(list) {
let best = null;
for (const m of list || []) {
const raw = m?.DateLastMediaAdded || m?.DateCreated;
if (!raw) continue;
const t = new Date(raw).getTime();
if (!Number.isFinite(t)) continue;
if (best == null || t > best) best = t;
}
return best != null ? new Date(best).toISOString() : null;
}
function setLastDateFetchedFromMovies(list) {
const iso = watermarkFromMovies(list);
if (iso) setLastDateFetched(iso);
}
function getApiHelper() {
return window.apiHelper || window.ApiHelper;
}
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
const server = window.ApiClient.serverAddress();
const params = new URLSearchParams({
IncludeItemTypes: 'Movie',
Recursive: 'true',
Fields: MOVIE_FIELDS,
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: String(startIndex || 0),
Limit: String(limit)
});
Object.entries(extraParams).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
});
return `${server}/Items?${params.toString()}`;
}
async function queryItems(url) {
await window.LibraryCacheUtils?.waitWhileCacheNetworkPaused?.();
const api = getApiHelper();
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
const result = await api.getQuery(url, { useCache: false });
return result || { Items: [], TotalRecordCount: 0 };
}
async function stripChunk(items) {
if (utils().stripItemsAsync) {
return utils().stripItemsAsync(items, 'Movie');
}
return (items || []).map((item) => utils().stripMovieForCache?.(item) || item).filter(Boolean);
}
function rebuildProviderIndex(list) {
providerIndex = utils().buildProviderIdIndex?.(list) || null;
}
/**
* Full crawl with partial resume. Commits to movies_library_v2 only when complete.
*/
async function fetchAndCacheMovies() {
const cache = window.IndexedDBCache;
if (!cache) {
ERR('IndexedDBCache unavailable');
return [];
}
const userId = window.ApiClient.getCurrentUserId();
const complete = await cache.get(CACHE_NAME, userId);
if (complete && Array.isArray(complete)) {
movies = complete;
isComplete = true;
rebuildProviderIndex(movies);
LOG(`Using complete cached movies (${movies.length})`);
return movies;
}
const chunkSize = getChunkSize();
let startIndex = 0;
let collected = [];
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
collected = partial.items;
startIndex = partial.startIndex || collected.length;
LOG(`Resuming movie crawl from index ${startIndex} (${collected.length} items)`);
}
try {
let hasMore = true;
while (hasMore) {
const url = buildItemsUrl({ startIndex, limit: chunkSize });
const data = await queryItems(url);
const batch = data.Items || [];
const stripped = await stripChunk(batch);
collected = collected.concat(stripped);
startIndex += batch.length;
await cache.set(PARTIAL_CACHE_NAME, {
isComplete: false,
startIndex,
items: collected
}, userId, getTtl());
LOG(`Movie crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
if (batch.length < chunkSize) {
hasMore = false;
} else {
await yieldToMain();
}
}
movies = collected;
isComplete = true;
rebuildProviderIndex(movies);
await cache.set(CACHE_NAME, movies, userId, getTtl());
await cache.clear(PARTIAL_CACHE_NAME, userId);
setLastDateFetchedFromMovies(movies);
LOG(`Movie crawl complete: ${movies.length} items`);
return movies;
} catch (err) {
ERR('Error fetching movies:', err);
return movies || collected || [];
}
}
/**
* Incremental sync: upsert by MinDateLastSaved, remove orphans via TotalRecordCount + Id list.
*/
async function syncMoviesCache() {
if (!isComplete || !movies) {
LOG('Skip syncMoviesCache: complete cache not ready');
return movies || [];
}
const lastFetched = getLastDateFetched();
if (!lastFetched) {
LOG('Skip syncMoviesCache: no lastDateFetched');
return movies;
}
const cache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
const chunkSize = getChunkSize();
try {
// Additions / metadata refreshes
let startIndex = 0;
let incoming = [];
let hasMore = true;
while (hasMore) {
const url = buildItemsUrl({
startIndex,
limit: chunkSize,
extraParams: {
MinDateLastSaved: lastFetched,
MinDateLastSavedForUser: lastFetched,
SortBy: 'DateCreated',
SortOrder: 'Descending'
}
});
const data = await queryItems(url);
const batch = data.Items || [];
incoming = incoming.concat(await stripChunk(batch));
startIndex += batch.length;
if (batch.length < chunkSize) hasMore = false;
}
const beforeIds = new Set((movies || []).map((m) => m.Id).filter(Boolean));
let removedIds = [];
if (incoming.length) {
movies = utils().upsertById?.(movies, incoming) || movies;
LOG(`Synced ${incoming.length} movie upserts`);
}
// Count check
const countUrl = buildItemsUrl({
startIndex: 0,
limit: 1,
extraParams: {
Fields: '',
EnableTotalRecordCount: 'true'
}
});
// Rebuild without heavy Fields for count
const server = window.ApiClient.serverAddress();
const countParams = new URLSearchParams({
IncludeItemTypes: 'Movie',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
Limit: '1',
StartIndex: '0'
});
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
const serverCount = countData.TotalRecordCount;
if (typeof serverCount === 'number' && serverCount !== movies.length) {
LOG(`Count mismatch cache=${movies.length} server=${serverCount}; reconciling ids`);
const serverIds = new Set();
let idx = 0;
let more = true;
while (more) {
const idParams = new URLSearchParams({
IncludeItemTypes: 'Movie',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'false',
Limit: String(chunkSize),
StartIndex: String(idx),
Fields: ''
});
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
const batch = idData.Items || [];
batch.forEach((item) => {
if (item?.Id) serverIds.add(item.Id);
});
idx += batch.length;
if (batch.length < chunkSize) more = false;
}
const before = movies.length;
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
movies = movies.filter((m) => m?.Id && serverIds.has(m.Id));
// Add any missing ids not in cache (light fetch would lack fields — full upsert via Id list)
const cachedIds = new Set(movies.map((m) => m.Id));
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
if (missing.length) {
WARN(`${missing.length} movies on server missing from cache after reconcile; fetching by Ids`);
for (let i = 0; i < missing.length; i += chunkSize) {
const slice = missing.slice(i, i + chunkSize);
const byIdParams = new URLSearchParams({
Ids: slice.join(','),
Fields: MOVIE_FIELDS,
EnableTotalRecordCount: 'false'
});
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
const stripped = await stripChunk(byIdData.Items || []);
movies = utils().upsertById?.(movies, stripped) || movies;
incoming = incoming.concat(stripped);
}
}
LOG(`Reconcile complete: ${before} → ${movies.length}`);
}
rebuildProviderIndex(movies);
await cache.set(CACHE_NAME, movies, userId, getTtl());
setLastDateFetchedFromMovies(movies);
// Notify people cache of deltas when available
if ((incoming.length || removedIds.length) && window.PeopleCache?.applyMovieDelta) {
try {
await window.PeopleCache.applyMovieDelta(incoming, removedIds);
} catch (e) {
WARN('PeopleCache.applyMovieDelta failed:', e);
}
}
return movies;
} catch (err) {
ERR('syncMoviesCache failed:', err);
return movies || [];
}
}
async function getMovies() {
if (movies && isComplete) return movies;
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
await fetchAndCacheMovies();
return movies || [];
})().finally(() => {
fetchPromise = null;
});
return fetchPromise;
}
async function isCacheComplete() {
if (isComplete === true && Array.isArray(movies)) return true;
const cache = window.IndexedDBCache;
const userId = window.ApiClient?.getCurrentUserId?.();
if (!cache || !userId) return false;
const complete = await cache.get(CACHE_NAME, userId);
if (complete && Array.isArray(complete)) {
if (!movies) {
movies = complete;
rebuildProviderIndex(movies);
}
isComplete = true;
return true;
}
return false;
}
async function getMoviesByProviderIds(ids) {
const list = await getMovies();
const idSet = new Set();
(ids || []).forEach((id) => {
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
});
if (!idSet.size) return [];
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
}
async function fetchImdbTop250Data() {
const CACHE_KEY_IDS = 'kefinTweaks_imdbTop250_ids';
let imdbIds = null;
try {
const cachedIds = localStorage.getItem(CACHE_KEY_IDS);
if (cachedIds) {
const parsed = JSON.parse(cachedIds);
if (Date.now() - parsed.timestamp < IMDB_TOP_250_CACHE_TTL) {
imdbIds = parsed.ids;
}
}
} catch (e) {
WARN('Error reading IMDb Top 250 cache:', e);
}
if (!imdbIds) {
LOG('Fetching IMDb Top 250 list from GitHub...');
try {
const response = await fetch('https://raw.githubusercontent.com/theapache64/top250/master/top250_min.json');
if (!response.ok) throw new Error('Failed to fetch Top 250 JSON');
const data = await response.json();
imdbIds = data.map((entry) => {
const match = entry.imdb_url && entry.imdb_url.match(/\/title\/(tt\d+)/);
return match ? match[1] : null;
}).filter(Boolean);
localStorage.setItem(CACHE_KEY_IDS, JSON.stringify({
timestamp: Date.now(),
ids: imdbIds
}));
} catch (err) {
ERR('Failed to fetch IMDb Top 250 list:', err);
return [];
}
}
return imdbIds || [];
}
async function getImdbTop250Movies() {
const cacheName = 'imdbTop250Movies';
const cache = window.IndexedDBCache;
const cached = await cache.get(cacheName);
if (cached) return cached;
if (!imdbTop250Movies) {
if (!(await isCacheComplete())) {
LOG('IMDb Top 250 waiting for movie cache…');
}
const imdbIds = await fetchImdbTop250Data();
const allMovies = await getMovies();
const idsSet = new Set(imdbIds);
imdbTop250Movies = allMovies.filter(
(movie) => movie.ProviderIds?.Imdb && idsSet.has(movie.ProviderIds.Imdb)
);
}
const userId = window.ApiClient.getCurrentUserId();
await cache.set(cacheName, imdbTop250Movies, userId, IMDB_TOP_250_CACHE_TTL);
return imdbTop250Movies || [];
}
async function initialize() {
await getMovies();
if ((await isCacheComplete()) && getLastDateFetched()) {
await syncMoviesCache();
}
return movies || [];
}
async function waitForLoginReady(maxWaitMs = 60000) {
const start = Date.now();
while (!window.userHelper?.waitForLogin) {
if (Date.now() - start >= maxWaitMs) {
ERR('Timed out waiting for userHelper');
return false;
}
await new Promise((r) => setTimeout(r, 100));
}
return window.userHelper.waitForLogin(maxWaitMs);
}
function yieldToMain() {
return new Promise((resolve) => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => resolve(), { timeout: 500 });
} else {
requestAnimationFrame(() => setTimeout(resolve, 0));
}
});
}
/** Defer crawl until idle/hidden/fallback after home has a chance to paint. */
let bootstrapStarted = false;
let bootstrapScheduled = false;
async function bootstrap() {
if (bootstrapStarted) return;
bootstrapStarted = true;
try {
await waitForLoginReady();
await initialize();
} catch (e) {
ERR('Bootstrap failed:', e);
}
}
function scheduleBootstrap() {
if (bootstrapScheduled) return;
bootstrapScheduled = true;
const utils = window.LibraryCacheUtils;
if (utils?.scheduleIdleBootstrap) {
utils.scheduleIdleBootstrap({
waitForLogin: waitForLoginReady,
onReady: () => { bootstrap(); }
});
return;
}
// Fallback if LibraryCacheUtils not loaded yet
waitForLoginReady().then(() => bootstrap());
}
window.MoviesCache = {
init: initialize,
getMovies,
getImdbTop250Movies,
getMoviesByProviderIds,
syncMoviesCache,
fetchAndCacheMovies,
isComplete: isCacheComplete,
getLastDateFetched,
bootstrap,
scheduleBootstrap,
refresh: async () => {
const cache = window.IndexedDBCache;
const userId = window.ApiClient?.getCurrentUserId?.();
if (cache) {
await cache.clear(CACHE_NAME, userId);
await cache.clear(PARTIAL_CACHE_NAME, userId);
}
movies = null;
isComplete = false;
providerIndex = null;
imdbTop250Movies = null;
return fetchAndCacheMovies();
}
};
scheduleBootstrap();
LOG('Module loaded');
})();
File diff suppressed because it is too large Load Diff
+975
View File
@@ -0,0 +1,975 @@
// KefinTweaks People Cache
// Built from MoviesCache + SeriesCache; episode credits via global /Items?IncludeItemTypes=Episode pager.
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks PeopleCache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks PeopleCache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks PeopleCache]', ...args);
const CACHE_NAME = 'library_top_people_v3';
const DEFAULT_MIN = 10;
const DEFAULT_PER_TYPE_MAX = 100;
const EPISODE_CHUNK_SIZE = 500;
const EPISODE_APPLY_YIELD_EVERY = 50;
const CREDIT_ONLY_KEYS = new Set(['Type', 'Role']);
let peopleMap = null; // Map<personId, Person>
let filteredLists = null; // { actors, directors, writers }
let isComplete = false;
let episodePeopleComplete = false;
let episodePeopleWatermark = null;
let episodeCrawl = null; // { startIndex, newestAt, oldestApplied } while partial
let lastEpisodeSyncAt = null;
let fetchPromise = null;
let initPromise = null;
let episodeSyncPromise = null;
let episodeSyncPendingForceFull = false;
function getHomeSettings() {
return window.KefinHomeScreen?.getConfig?.()?.HOME_SETTINGS
|| window.KefinHomeConfig2?.HOME_SETTINGS
|| {};
}
function getMins() {
const home = getHomeSettings();
return {
total: home.minPeopleAppearancesTotal ?? DEFAULT_MIN,
movies: home.minPeopleAppearancesMovies ?? DEFAULT_MIN,
series: home.minPeopleAppearancesSeries ?? DEFAULT_MIN,
episodes: home.minPeopleAppearancesEpisodes ?? DEFAULT_MIN
};
}
function getPerTypeMaxCount() {
const max = getHomeSettings().maxPeopleCount;
const n = parseInt(max, 10);
if (Number.isFinite(n) && n > 0) return Math.min(n, DEFAULT_PER_TYPE_MAX);
return DEFAULT_PER_TYPE_MAX;
}
function shouldLoadPeopleEpisodeData() {
return getHomeSettings().loadPeopleEpisodeData === true;
}
function isAggregateItemType(itemType) {
return itemType == null || itemType === '' || itemType === 'All';
}
function emptyMovieCounts() {
return { directorCount: 0, writerCount: 0, actorCount: 0 };
}
function emptySeriesCounts() {
return { count: 0, actorCount: 0, directorCount: 0, writerCount: 0 };
}
function movieRoleCount(person, role) {
const m = person.movies || {};
if (role === 'Actor') return m.actorCount || 0;
if (role === 'Director') return m.directorCount || 0;
if (role === 'Writer') return m.writerCount || 0;
return 0;
}
function seriesRoleCount(person, role) {
const s = person.series || {};
if (role === 'Actor') return s.count || 0;
if (role === 'Director') return episodeRoleCount(person, role);
if (role === 'Writer') return episodeRoleCount(person, role);
return 0;
}
function episodeRoleCount(person, role) {
const s = person.series || {};
if (role === 'Actor') return s.actorCount || 0;
if (role === 'Director') return s.directorCount || 0;
if (role === 'Writer') return s.writerCount || 0;
return 0;
}
function totalAppearances(person) {
const m = person.movies || {};
const s = person.series || {};
return (m.actorCount || 0)
+ (m.directorCount || 0)
+ (m.writerCount || 0)
+ (s.count || 0)
+ (s.actorCount || 0)
+ (s.directorCount || 0)
+ (s.writerCount || 0);
}
function personMeetsMin(person, mins) {
const m = person.movies || {};
const s = person.series || {};
const episodeMax = Math.max(s.actorCount || 0, s.directorCount || 0, s.writerCount || 0);
return (m.actorCount >= mins.movies)
|| (m.directorCount >= mins.movies)
|| (m.writerCount >= mins.movies)
|| ((s.count || 0) >= mins.series)
|| (episodeMax >= mins.episodes)
|| (totalAppearances(person) >= mins.total);
}
function prunePeopleMap(map) {
if (!map) return map;
const mins = getMins();
const beforeMin = map.size;
for (const [id, person] of map) {
if (!personMeetsMin(person, mins)) map.delete(id);
}
if (beforeMin !== map.size) {
LOG(`People prune (mins): ${beforeMin} → ${map.size}`);
}
const maxPerBucket = getPerTypeMaxCount();
const keepIds = new Set();
const itemTypes = ['Movie', 'Series', 'Episode', null];
const roles = ['Actor', 'Director', 'Writer'];
for (const itemType of itemTypes) {
for (const role of roles) {
filterRoleList(role, itemType).slice(0, maxPerBucket).forEach((p) => keepIds.add(p.Id));
}
}
const beforeMax = map.size;
for (const id of Array.from(map.keys())) {
if (!keepIds.has(id)) map.delete(id);
}
if (beforeMax !== map.size) {
LOG(`People prune (per-type max ${maxPerBucket}): ${beforeMax} → ${map.size}`);
}
return map;
}
function personIdentityFields(person) {
const out = {};
if (!person || typeof person !== 'object') return out;
Object.keys(person).forEach((key) => {
if (key === 'movies' || key === 'series') return;
if (CREDIT_ONLY_KEYS.has(key)) return;
out[key] = person[key];
});
return out;
}
function ensurePerson(map, person) {
const key = person?.Id;
if (!key) return null;
if (!map.has(key)) {
map.set(key, {
...personIdentityFields(person),
Id: person.Id,
Name: person.Name,
movies: emptyMovieCounts(),
series: emptySeriesCounts()
});
} else {
const existing = map.get(key);
const incoming = personIdentityFields(person);
Object.keys(incoming).forEach((keyName) => {
if (keyName === 'Id') return;
const value = incoming[keyName];
if (value == null || value === '') return;
if (existing[keyName] == null || existing[keyName] === '') {
existing[keyName] = value;
}
});
}
return map.get(key);
}
function bumpMovieRole(movies, type) {
if (!movies) return;
if (type === 'Director') movies.directorCount = (movies.directorCount || 0) + 1;
else if (type === 'Writer') movies.writerCount = (movies.writerCount || 0) + 1;
else if (type === 'Actor') movies.actorCount = (movies.actorCount || 0) + 1;
}
function bumpEpisodeRole(series, type, delta) {
if (!series || !delta) return;
if (type === 'Director') {
series.directorCount = Math.max(0, (series.directorCount || 0) + delta);
} else if (type === 'Writer') {
series.writerCount = Math.max(0, (series.writerCount || 0) + delta);
} else if (type === 'Actor') {
series.actorCount = Math.max(0, (series.actorCount || 0) + delta);
}
}
function applyMoviePeople(map, movie) {
if (!movie?.People) return;
movie.People.forEach((person) => {
const personData = ensurePerson(map, person);
if (!personData) return;
bumpMovieRole(personData.movies, person.Type);
});
}
function applySeriesPeople(map, seriesItem) {
if (!seriesItem?.People) return;
seriesItem.People.forEach((person) => {
const personData = ensurePerson(map, person);
if (!personData) return;
personData.series.count = (personData.series.count || 0) + 1;
});
}
function applyEpisodePeople(map, episode) {
if (!episode?.People) return;
episode.People.forEach((person) => {
const personData = ensurePerson(map, person);
if (!personData) return;
bumpEpisodeRole(personData.series, person.Type, 1);
});
}
function clearMovieCounts(map) {
if (!map) return;
map.forEach((person) => {
person.movies = emptyMovieCounts();
});
}
function clearSeriesMembershipCounts(map) {
if (!map) return;
map.forEach((person) => {
person.series.count = 0;
});
}
function clearEpisodeRoleCounts(map) {
if (!map) return;
map.forEach((person) => {
person.series.actorCount = 0;
person.series.directorCount = 0;
person.series.writerCount = 0;
});
}
async function recountMoviePeopleFromCache(map) {
clearMovieCounts(map);
const movies = await window.MoviesCache?.getMovies?.() || [];
(movies || []).forEach((movie) => applyMoviePeople(map, movie));
}
async function recountSeriesMembershipFromCache(map) {
clearSeriesMembershipCounts(map);
const seriesList = await window.SeriesCache?.getSeries?.() || [];
(seriesList || []).forEach((s) => applySeriesPeople(map, s));
}
function roleCount(person, role, itemType) {
const movieCount = movieRoleCount(person, role);
const seriesCount = seriesRoleCount(person, role);
const episodeCount = episodeRoleCount(person, role);
if (itemType === 'Movie') return movieCount;
if (itemType === 'Series') return seriesCount;
if (itemType === 'Episode') return episodeCount;
if (isAggregateItemType(itemType)) return movieCount + seriesCount + episodeCount;
return 0;
}
function filterRoleList(role, itemType, minCount) {
if (!peopleMap) return [];
const mins = getMins();
const type = isAggregateItemType(itemType) ? null : itemType;
let threshold = minCount;
if (threshold == null) {
if (type === 'Movie') threshold = mins.movies;
else if (type === 'Series') threshold = mins.series;
else if (type === 'Episode') threshold = mins.episodes;
else threshold = mins.total;
}
return Array.from(peopleMap.values())
.map((person) => {
const count = roleCount(person, role, type);
return {
...person,
count,
movies: person.movies,
series: person.series
};
})
.filter((p) => p.count >= threshold)
.sort((a, b) => b.count - a.count);
}
function rebuildFilteredLists() {
filteredLists = {
actors: filterRoleList('Actor', null),
directors: filterRoleList('Director', null),
writers: filterRoleList('Writer', null),
isComplete: true,
episodePeopleComplete: episodePeopleComplete === true
};
}
function yieldToMain() {
return new Promise((resolve) => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => resolve(), { timeout: 500 });
} else {
requestAnimationFrame(() => setTimeout(resolve, 0));
}
});
}
async function waitWhileCacheNetworkPaused() {
const wait = window.LibraryCacheUtils?.waitWhileCacheNetworkPaused;
if (typeof wait === 'function') {
await wait();
return;
}
// Fallback if utils not loaded yet
while (typeof document !== 'undefined' && document.hidden) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
function getApiHelper() {
return window.apiHelper || window.ApiHelper;
}
function buildEpisodePeopleUrl(startIndex) {
const server = window.ApiClient.serverAddress();
const params = new URLSearchParams({
IncludeItemTypes: 'Episode',
Recursive: 'true',
Fields: 'People',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'false',
SortBy: 'DateCreated',
SortOrder: 'Descending',
StartIndex: String(startIndex || 0),
Limit: String(EPISODE_CHUNK_SIZE)
});
return `${server}/Items?${params.toString()}`;
}
async function fetchEpisodePeoplePage(startIndex) {
const api = getApiHelper();
const url = buildEpisodePeopleUrl(startIndex);
const data = await api.getQuery(url, { useCache: false });
return data?.Items || [];
}
function compareIsoDates(a, b) {
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
if (a === b) return 0;
return a < b ? -1 : 1;
}
async function applyEpisodeBatchWithYield(map, episodes) {
for (let i = 0; i < episodes.length; i++) {
applyEpisodePeople(map, episodes[i]);
if ((i + 1) % EPISODE_APPLY_YIELD_EVERY === 0) {
await yieldToMain();
}
}
}
function clearEpisodeCrawl() {
episodeCrawl = null;
}
function setEpisodeCrawl(startIndex, newestAt, oldestApplied) {
episodeCrawl = {
startIndex: Math.max(0, Number(startIndex) || 0),
newestAt: newestAt || null,
oldestApplied: oldestApplied || null
};
episodePeopleComplete = false;
}
/**
* Apply episodes newer than newestAt from the head of the DateCreated-desc list.
* @returns {{ applied: number, newestAt: string|null }}
*/
async function runEpisodeHeadCatchup(newestAt) {
let startIndex = 0;
let applied = 0;
let page0Newest = null;
while (true) {
await waitWhileCacheNetworkPaused();
let batch = [];
try {
batch = await fetchEpisodePeoplePage(startIndex);
} catch (e) {
ERR('Episode head catchup page failed:', e);
break;
}
if (!batch.length) break;
if (!page0Newest && batch[0]?.DateCreated) {
page0Newest = batch[0].DateCreated;
}
const toApply = [];
let hitBoundary = false;
for (const ep of batch) {
const created = ep?.DateCreated;
if (newestAt && created && compareIsoDates(created, newestAt) <= 0) {
hitBoundary = true;
break;
}
toApply.push(ep);
}
if (toApply.length) {
await applyEpisodeBatchWithYield(peopleMap, toApply);
applied += toApply.length;
}
await yieldToMain();
if (hitBoundary || batch.length < EPISODE_CHUNK_SIZE) break;
startIndex += batch.length;
}
return { applied, newestAt: page0Newest || newestAt || null };
}
/**
* Full descending crawl, or resume from episodeCrawl (after optional head catchup).
*/
async function runEpisodeFullOrResumeCrawl({ resume }) {
let startIndex = 0;
let newestAt = null;
let oldestApplied = null;
let stillSkipping = false;
if (resume && episodeCrawl) {
startIndex = episodeCrawl.startIndex || 0;
newestAt = episodeCrawl.newestAt || null;
oldestApplied = episodeCrawl.oldestApplied || null;
stillSkipping = !!oldestApplied;
LOG(`Episode people sync: resume (startIndex=${startIndex}, newestAt=${newestAt}, oldestApplied=${oldestApplied})`);
if (newestAt) {
const head = await runEpisodeHeadCatchup(newestAt);
if (head.applied > 0) {
startIndex += head.applied;
LOG(`Episode head catchup applied ${head.applied}; startIndex → ${startIndex}`);
}
if (head.newestAt) newestAt = head.newestAt;
setEpisodeCrawl(startIndex, newestAt, oldestApplied);
await persistPeople({ prune: false });
}
} else {
LOG('Episode people sync: full crawl…');
startIndex = 0;
newestAt = null;
oldestApplied = null;
stillSkipping = false;
}
let pages = 0;
let applied = 0;
while (true) {
await waitWhileCacheNetworkPaused();
let batch = [];
try {
batch = await fetchEpisodePeoplePage(startIndex);
} catch (e) {
ERR('Episode people page failed:', e);
setEpisodeCrawl(startIndex, newestAt, oldestApplied);
await persistPeople({ prune: false });
return;
}
pages += 1;
if (!batch.length) break;
if (!newestAt && batch[0]?.DateCreated) {
newestAt = batch[0].DateCreated;
}
const toApply = [];
for (const ep of batch) {
const created = ep?.DateCreated;
if (stillSkipping && oldestApplied && created) {
if (compareIsoDates(created, oldestApplied) >= 0) {
continue;
}
stillSkipping = false;
}
toApply.push(ep);
}
if (toApply.length) {
await applyEpisodeBatchWithYield(peopleMap, toApply);
applied += toApply.length;
const last = toApply[toApply.length - 1];
if (last?.DateCreated) oldestApplied = last.DateCreated;
}
startIndex += batch.length;
setEpisodeCrawl(startIndex, newestAt, oldestApplied);
await persistPeople({ prune: false });
await yieldToMain();
if (pages % 5 === 0) {
LOG(`Episode people progress: applied=${applied}, page=${pages}, startIndex=${startIndex}`);
}
if (batch.length < EPISODE_CHUNK_SIZE) break;
}
episodePeopleWatermark = newestAt;
clearEpisodeCrawl();
episodePeopleComplete = true;
lastEpisodeSyncAt = new Date().toISOString();
prunePeopleMap(peopleMap);
rebuildFilteredLists();
await persistPeople({ prune: true });
LOG(`Episode people sync done: applied=${applied}, pages=${pages}, watermark=${episodePeopleWatermark}`);
}
/** Incremental update when a prior full crawl completed (watermark set, no partial cursor). */
async function runEpisodeIncrementalSync() {
const watermark = episodePeopleWatermark;
LOG(`Episode people sync: incremental (watermark=${watermark})…`);
let startIndex = 0;
let newestSeen = null;
let pages = 0;
let applied = 0;
while (true) {
await waitWhileCacheNetworkPaused();
let batch = [];
try {
batch = await fetchEpisodePeoplePage(startIndex);
} catch (e) {
ERR('Episode people page failed:', e);
break;
}
pages += 1;
if (!batch.length) break;
if (!newestSeen && batch[0]?.DateCreated) {
newestSeen = batch[0].DateCreated;
}
const toApply = [];
let hitWatermark = false;
for (const ep of batch) {
const created = ep?.DateCreated;
if (watermark && created && compareIsoDates(created, watermark) <= 0) {
hitWatermark = true;
break;
}
toApply.push(ep);
}
if (toApply.length) {
await applyEpisodeBatchWithYield(peopleMap, toApply);
applied += toApply.length;
}
await yieldToMain();
if (hitWatermark || batch.length < EPISODE_CHUNK_SIZE) break;
startIndex += batch.length;
}
if (newestSeen) {
episodePeopleWatermark = newestSeen;
}
clearEpisodeCrawl();
episodePeopleComplete = true;
lastEpisodeSyncAt = new Date().toISOString();
prunePeopleMap(peopleMap);
rebuildFilteredLists();
await persistPeople({ prune: true });
LOG(`Episode people incremental done: applied=${applied}, pages=${pages}, watermark=${episodePeopleWatermark}`);
}
async function runEpisodePeopleSync(options = {}) {
if (!shouldLoadPeopleEpisodeData()) {
clearEpisodeRoleCounts(peopleMap);
clearEpisodeCrawl();
episodePeopleComplete = true;
episodePeopleWatermark = null;
lastEpisodeSyncAt = new Date().toISOString();
rebuildFilteredLists();
await persistPeople({ prune: true });
return;
}
if (!peopleMap) return;
const forceFull = options.forceFull === true;
if (forceFull) {
clearEpisodeRoleCounts(peopleMap);
clearEpisodeCrawl();
episodePeopleWatermark = null;
episodePeopleComplete = false;
await runEpisodeFullOrResumeCrawl({ resume: false });
return;
}
if (episodeCrawl && !episodePeopleComplete) {
await runEpisodeFullOrResumeCrawl({ resume: true });
return;
}
if (episodePeopleWatermark) {
await runEpisodeIncrementalSync();
return;
}
// No watermark and no partial cursor: start a fresh full crawl
clearEpisodeRoleCounts(peopleMap);
clearEpisodeCrawl();
episodePeopleComplete = false;
await runEpisodeFullOrResumeCrawl({ resume: false });
}
function scheduleEpisodePeopleSync(options = {}) {
if (!shouldLoadPeopleEpisodeData()) {
if (peopleMap) {
clearEpisodeRoleCounts(peopleMap);
clearEpisodeCrawl();
episodePeopleComplete = true;
episodePeopleWatermark = null;
}
return Promise.resolve();
}
if (options.forceFull) {
episodeSyncPendingForceFull = true;
}
if (episodeSyncPromise) {
return episodeSyncPromise;
}
episodeSyncPromise = (async () => {
try {
do {
const forceFull = episodeSyncPendingForceFull;
episodeSyncPendingForceFull = false;
await runEpisodePeopleSync({ forceFull });
} while (episodeSyncPendingForceFull);
} catch (e) {
ERR('Episode people sync failed:', e);
} finally {
episodeSyncPromise = null;
}
})();
return episodeSyncPromise;
}
async function persistPeople(options = {}) {
const cache = window.IndexedDBCache;
if (!cache || !peopleMap) return;
const doPrune = options.prune !== false;
if (doPrune) {
prunePeopleMap(peopleMap);
}
const userId = window.ApiClient.getCurrentUserId();
const ttl = window.LibraryCacheUtils?.getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
const payload = {
isComplete: true,
loadPeopleEpisodeData: shouldLoadPeopleEpisodeData(),
episodePeopleComplete: episodePeopleComplete === true,
episodePeopleWatermark,
episodeCrawl: episodeCrawl
? {
startIndex: episodeCrawl.startIndex,
newestAt: episodeCrawl.newestAt,
oldestApplied: episodeCrawl.oldestApplied
}
: null,
lastEpisodeSyncAt,
peopleData: Array.from(peopleMap.entries())
};
try {
await cache.set(CACHE_NAME, payload, userId, ttl);
} catch (e) {
WARN('Failed to persist people cache:', e);
}
}
async function fetchAndCachePeople() {
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
try {
LOG('Building people cache from movies + series…');
const movies = await window.MoviesCache.getMovies();
const seriesList = await window.SeriesCache.getSeries();
const map = new Map();
(movies || []).forEach((movie) => applyMoviePeople(map, movie));
(seriesList || []).forEach((s) => applySeriesPeople(map, s));
peopleMap = map;
prunePeopleMap(map);
isComplete = true;
episodePeopleComplete = !shouldLoadPeopleEpisodeData();
episodePeopleWatermark = null;
clearEpisodeCrawl();
lastEpisodeSyncAt = null;
rebuildFilteredLists();
await persistPeople({ prune: true });
LOG(`People cache Phase A complete: ${peopleMap.size} people`);
if (shouldLoadPeopleEpisodeData()) {
scheduleEpisodePeopleSync({ forceFull: true });
} else {
LOG('Skipping episode people fetch (loadPeopleEpisodeData disabled)');
}
return filteredLists;
} catch (err) {
ERR('fetchAndCachePeople failed:', err);
return null;
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
async function initialize() {
if (isComplete && peopleMap) return;
if (initPromise) return initPromise;
initPromise = (async () => {
try {
const cache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
if (cache) {
const cached = await cache.get(CACHE_NAME, userId);
if (cached?.isComplete && Array.isArray(cached.peopleData)) {
const currentMode = shouldLoadPeopleEpisodeData();
const cachedMode = typeof cached.loadPeopleEpisodeData === 'boolean'
? cached.loadPeopleEpisodeData
: false;
if (cachedMode !== currentMode) {
LOG(`People cache episode mode mismatch (cached=${cachedMode}, current=${currentMode}); rebuilding`);
await cache.clear?.(CACHE_NAME, userId);
} else {
peopleMap = new Map(cached.peopleData);
episodePeopleWatermark = cached.episodePeopleWatermark || null;
lastEpisodeSyncAt = cached.lastEpisodeSyncAt || null;
if (cached.episodeCrawl
&& typeof cached.episodeCrawl === 'object'
&& cached.episodePeopleComplete !== true) {
episodeCrawl = {
startIndex: Math.max(0, Number(cached.episodeCrawl.startIndex) || 0),
newestAt: cached.episodeCrawl.newestAt || null,
oldestApplied: cached.episodeCrawl.oldestApplied || null
};
episodePeopleComplete = false;
} else {
clearEpisodeCrawl();
episodePeopleComplete = cached.episodePeopleComplete === true
|| !currentMode;
}
const before = peopleMap.size;
// Avoid pruning away mid-crawl people that only meet mins after more pages
if (episodePeopleComplete) {
prunePeopleMap(peopleMap);
}
isComplete = true;
rebuildFilteredLists();
LOG('Loaded people cache from IndexedDB'
+ (episodeCrawl ? ` (partial episode crawl startIndex=${episodeCrawl.startIndex})` : ''));
if (episodePeopleComplete && peopleMap.size < before) {
await persistPeople({ prune: true });
}
if (currentMode) {
// Resume partial, incremental watermark, or start full — never wipe on missing watermark alone
scheduleEpisodePeopleSync();
}
return;
}
}
}
await fetchAndCachePeople();
} finally {
initPromise = null;
}
})();
return initPromise;
}
async function applyMovieDelta(_changedMovies, _removedIds) {
if (!peopleMap) await initialize();
if (!peopleMap) return;
await recountMoviePeopleFromCache(peopleMap);
prunePeopleMap(peopleMap);
rebuildFilteredLists();
await persistPeople({ prune: true });
}
async function applySeriesDelta(_changedSeries, removedIds) {
if (!peopleMap) await initialize();
if (!peopleMap) return;
const removed = (removedIds || []).filter(Boolean);
await recountSeriesMembershipFromCache(peopleMap);
if (!shouldLoadPeopleEpisodeData()) {
clearEpisodeRoleCounts(peopleMap);
clearEpisodeCrawl();
episodePeopleComplete = true;
episodePeopleWatermark = null;
} else if (removed.length) {
scheduleEpisodePeopleSync({ forceFull: true });
} else {
scheduleEpisodePeopleSync({ forceFull: false });
}
prunePeopleMap(peopleMap);
rebuildFilteredLists();
await persistPeople({ prune: true });
}
async function invalidate() {
LOG('Invalidating people cache (in-memory + IndexedDB)');
peopleMap = null;
filteredLists = null;
isComplete = false;
episodePeopleComplete = false;
episodePeopleWatermark = null;
clearEpisodeCrawl();
lastEpisodeSyncAt = null;
fetchPromise = null;
initPromise = null;
episodeSyncPromise = null;
episodeSyncPendingForceFull = false;
try {
const cache = window.IndexedDBCache;
const userId = window.ApiClient?.getCurrentUserId?.();
if (cache?.clear) {
await cache.clear(CACHE_NAME, userId);
}
} catch (e) {
WARN('Failed to clear people IDB cache:', e);
}
try {
initialize();
} catch (e) {
WARN('People cache rebuild after invalidate failed to start:', e);
}
}
async function getTopPeople() {
if (!filteredLists) await initialize();
return filteredLists;
}
function parseRoleListArgs(itemTypeOrOptions, minCount) {
if (itemTypeOrOptions && typeof itemTypeOrOptions === 'object') {
return {
itemType: itemTypeOrOptions.ItemType || itemTypeOrOptions.itemType || null,
minCount: itemTypeOrOptions.MinCount ?? itemTypeOrOptions.minCount
};
}
return { itemType: itemTypeOrOptions, minCount };
}
async function getTopActors(itemTypeOrOptions = null, minCount) {
if (!peopleMap) await initialize();
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
return filterRoleList('Actor', itemType, min);
}
async function getTopDirectors(itemTypeOrOptions = null, minCount) {
if (!peopleMap) await initialize();
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
return filterRoleList('Director', itemType, min);
}
async function getTopWriters(itemTypeOrOptions = null, minCount) {
if (!peopleMap) await initialize();
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
return filterRoleList('Writer', itemType, min);
}
async function resolveDiscoveryPeople(queryOptions = {}) {
if (!peopleMap) await initialize();
const personType = queryOptions.PersonType || queryOptions.personType || 'Actor';
const itemType = queryOptions.ItemType || queryOptions.itemType || null;
const limit = queryOptions.Limit || 100;
let list = [];
if (personType === 'Director') list = filterRoleList('Director', itemType);
else if (personType === 'Writer') list = filterRoleList('Writer', itemType);
else list = filterRoleList('Actor', itemType);
return list.slice(0, limit).map((p) => ({ Id: p.Id, Name: p.Name, Type: 'Person' }));
}
async function waitForLoginReady(maxWaitMs = 60000) {
const start = Date.now();
while (!window.userHelper?.waitForLogin) {
if (Date.now() - start >= maxWaitMs) {
ERR('Timed out waiting for userHelper');
return false;
}
await new Promise((r) => setTimeout(r, 100));
}
return window.userHelper.waitForLogin(maxWaitMs);
}
/** Defer crawl until idle/hidden/fallback after home has a chance to paint. */
let bootstrapStarted = false;
let bootstrapScheduled = false;
async function bootstrap() {
if (bootstrapStarted) return;
bootstrapStarted = true;
try {
await waitForLoginReady();
if (window.MoviesCache?.getMovies) await window.MoviesCache.getMovies();
if (window.SeriesCache?.getSeries) await window.SeriesCache.getSeries();
await initialize();
} catch (e) {
ERR('Bootstrap failed:', e);
}
}
function scheduleBootstrap() {
if (bootstrapScheduled) return;
bootstrapScheduled = true;
const utils = window.LibraryCacheUtils;
if (utils?.scheduleIdleBootstrap) {
utils.scheduleIdleBootstrap({
waitForLogin: waitForLoginReady,
onReady: () => { bootstrap(); }
});
return;
}
waitForLoginReady().then(() => bootstrap());
}
window.PeopleCache = {
init: initialize,
fetchAndCachePeople,
getTopPeople,
getTopActors,
getTopDirectors,
getTopWriters,
resolveDiscoveryPeople,
applyMovieDelta,
applySeriesDelta,
invalidate,
isComplete: () => isComplete === true,
isEpisodePeopleComplete: () => episodePeopleComplete === true,
scheduleEpisodePeopleSync,
bootstrap,
scheduleBootstrap
};
scheduleBootstrap();
LOG('Module loaded');
})();
+198 -170
View File
@@ -451,6 +451,42 @@
}
}
/**
* Play playlist items in sort order, optionally resuming from first unplayed.
* @param {string} playlistId
* @param {{ resume?: boolean }} [options]
*/
async function playPlaylistItems(playlistId, options = {}) {
const resume = options.resume === true;
const sortedItems = await getSortedPlaylistItems(playlistId, resume);
const sortedIds = sortedItems.map(item => item.Id);
if (sortedIds.length === 0) {
WARN(resume ? 'No items to resume for playlist:' : 'No items to play for playlist:', playlistId);
return;
}
LOG(
resume
? `Resuming ${sortedIds.length} sorted items from playlist:`
: `Playing ${sortedIds.length} items from playlist:`,
playlistId
);
const playOptions = {};
if (resume && sortedItems[0].UserData?.PlaybackPositionTicks > 0) {
playOptions.startPositionTicks = sortedItems[0].UserData.PlaybackPositionTicks;
}
if (window.apiHelper && window.apiHelper.playItem) {
await window.apiHelper.playItem(sortedIds, playOptions);
} else if (window.ApiClient && typeof window.ApiClient.play === 'function') {
await window.ApiClient.play({ ids: sortedIds, ...playOptions });
} else {
ERR('No play method available');
}
}
/**
* Override play button to use sorted playlist items
* @param {HTMLElement} mainDetailButtons - Main detail buttons container
@@ -514,33 +550,10 @@
clonedButton.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation(); // Prevent any other handlers
e.stopImmediatePropagation();
try {
// Get sorted item IDs with resume mode (skip played items from start)
const sortedItems = await getSortedPlaylistItems(playlistId, true);
const sortedIds = sortedItems.map(item => item.Id);
if (sortedIds.length === 0) {
WARN('No items to resume for playlist:', playlistId);
return;
}
LOG(`Resuming ${sortedIds.length} sorted items from playlist:`, playlistId);
let options = {};
// Check if the first item has playback progress
if (sortedItems[0].UserData?.PlaybackPositionTicks > 0) {
// Add the playback position to the options
options.startPositionTicks = sortedItems[0].UserData?.PlaybackPositionTicks;
}
// Use apiHelper if available, otherwise fallback to ApiClient
if (window.apiHelper && window.apiHelper.playItem) {
await window.apiHelper.playItem(sortedIds, options);
} else {
ERR('No play method available');
}
await playPlaylistItems(playlistId, { resume: true });
} catch (error) {
ERR('Error resuming sorted playlist:', error);
}
@@ -693,30 +706,7 @@
e.stopImmediatePropagation();
try {
// Get sorted item IDs without resume mode (play all items from beginning)
const sortedItems = await getSortedPlaylistItems(playlistId, false);
const sortedIds = sortedItems.map(item => item.Id);
if (sortedIds.length === 0) {
WARN('No items to play for playlist:', playlistId);
return;
}
LOG(`Playing from beginning ${sortedIds.length} items from playlist:`, playlistId);
// Use apiHelper if available, otherwise fallback to ApiClient
if (window.apiHelper && window.apiHelper.playItem) {
await window.apiHelper.playItem(sortedIds);
} else if (window.ApiClient && typeof window.ApiClient.play === 'function') {
await window.ApiClient.play({ ids: sortedIds });
} else if (window.PlaybackManager && window.PlaybackManager.play) {
await window.PlaybackManager.play({
ids: sortedIds,
serverId: window.ApiClient.serverId()
});
} else {
ERR('No play method available');
}
await playPlaylistItems(playlistId, { resume: false });
} catch (error) {
ERR('Error playing from beginning:', error);
}
@@ -757,21 +747,13 @@
return;
}
let playlistItemsContainer = activePage.querySelector('.childrenItemsContainer');
let playlistItemsContainer = activePage.querySelector('.childrenItemsContainer') || activePage.querySelector('#listChildrenCollapsible .itemsContainer');
if (!playlistItemsContainer) {
playlistItemsContainer = activePage.querySelector('#listChildrenCollapsible');
if (!playlistItemsContainer) {
WARN('Playlist items container not found');
return;
}
WARN('Playlist items container not found');
return;
}
// Find items container
const itemsContainer = playlistItemsContainer.querySelector('.childrenItemsContainer') ||
playlistItemsContainer.querySelector('#listChildrenCollapsible') ||
playlistItemsContainer;
return itemsContainer;
return playlistItemsContainer;
}
/**
@@ -825,17 +807,7 @@
let debounceTimer = null;
playlistObserver = new MutationObserver((mutations) => {
if (ignoreNextMutation) {
ignoreNextMutation = false;
return;
}
const sortKey = getCurrentSort(playlistId);
if (sortKey === 'default') {
return;
}
let shouldReapply = false;
mutations.forEach((mutation) => {
@@ -846,13 +818,10 @@
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// If listItem was added, we need to reapply
if (node.classList?.contains('listItem') ||
node.classList?.contains('childrenItemsContainer') ||
node.classList?.contains('listChildrenCollapsible') ||
node.querySelector?.('.listItem[data-playlistitemid]') ||
node.querySelector?.('.childrenItemsContainer') ||
node.querySelector?.('#listChildrenCollapsible')) {
shouldReapply = true;
if (node.classList?.contains('listItem')) {
LOG('Playlist container re-rendered, re-applying sort for:', playlistId);
node.setAttribute('data-action', 'link');
shouldReapply = true;
}
}
});
@@ -860,7 +829,7 @@
}
});
if (shouldReapply) {
if (sortKey !== 'default' && shouldReapply) {
// Debounce to avoid multiple rapid re-applications
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
@@ -870,10 +839,12 @@
}
});
// Observe the playlist items container with childList changes
// Observe the playlist items container for childList and data-action attribute changes
playlistObserver.observe(playlistItemsContainer, {
childList: true,
subtree: true
subtree: true,
attributes: true,
attributeFilter: ['data-action']
});
LOG('MutationObserver set up for playlist:', playlistId);
@@ -895,13 +866,10 @@
}
// Find playlist items container
let playlistItemsContainer = activePage.querySelector('.childrenItemsContainer');
let playlistItemsContainer = getPlaylistItemsContainer();
if (!playlistItemsContainer) {
playlistItemsContainer = activePage.querySelector('#listChildrenCollapsible');
if (!playlistItemsContainer) {
WARN('Playlist items container not found');
return;
}
WARN('Playlist items container not found');
return;
}
// Check if already processed (but allow re-processing if button is missing)
@@ -921,16 +889,6 @@
return;
}
// Find items container
const itemsContainer = playlistItemsContainer.querySelector('.childrenItemsContainer') ||
playlistItemsContainer.querySelector('#listChildrenCollapsible') ||
playlistItemsContainer;
if (!itemsContainer) {
WARN('Items container not found');
return;
}
LOG('Adding playlist sorting for:', playlistId);
// Fetch playlist children
@@ -943,9 +901,34 @@
// Store playlist data
playlistData.set(playlistId, {
items: children,
itemsContainer: itemsContainer
itemsContainer: playlistItemsContainer
});
// Updated played item count
const playedItemCount = children.filter(item => item.UserData?.Played === true).length;
const itemMiscInfoPrimary = document.querySelector('.libraryPage:not(.hide) .itemMiscInfo-primary');
if (itemMiscInfoPrimary && playedItemCount > 0) {
const isAudioPlaylist = children.every(item => item.MediaType === 'Audio');
const isBookPlaylist = children.every(item => item.MediaType === 'Book');
const progressText = isAudioPlaylist ? 'played' : isBookPlaylist ? 'read' : 'watched';
const incompleteText = isAudioPlaylist ? 'unplayed' : isBookPlaylist ? 'unread' : 'unwatched';
// Add CSS Var --played-item-count
itemMiscInfoPrimary.dataset.playedItemCount = playedItemCount;
itemMiscInfoPrimary.dataset.totalItemCount = children.length;
const mediaInfoItem = document.createElement('div');
mediaInfoItem.className = 'mediaInfoItem';
mediaInfoItem.textContent = `${playedItemCount} ${progressText}`;
itemMiscInfoPrimary.appendChild(mediaInfoItem);
const mediaInfoItemIncomplete = document.createElement('div');
mediaInfoItemIncomplete.className = 'mediaInfoItem';
mediaInfoItemIncomplete.textContent = `${children.length - playedItemCount} ${incompleteText}`;
itemMiscInfoPrimary.appendChild(mediaInfoItemIncomplete);
}
// Add sort button (only if it doesn't exist)
if (!sortButton) {
addSortButton(detailButtons, playlistId);
@@ -977,68 +960,24 @@
return;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
let childrenItemsContainer = getPlaylistItemsContainer();
if (!childrenItemsContainer) {
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
WARN('Children items container not found');
return;
}
WARN('Children items container not found');
return;
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
LOG(`Found ${playlistItems.length} playlist items`);
playlistItems.forEach((item, index) => {
if (item.dataset.customPlaylistButton === 'true') {
LOG(`Playlist item ${index} already has a custom button, skipping`);
return;
}
const playlistItemId = item.getAttribute('data-playlistitemid');
const serverId = ApiClient.serverId();
if (!playlistItemId) {
WARN(`Playlist item ${index} has no data-playlistitemid`);
return;
}
// Remove existing click handlers
item.removeAttribute('data-action');
item.setAttribute('data-action', 'link');
item.style.cursor = 'pointer';
// Add new click handler to navigate to details page
item.addEventListener('click', (e) => {
handlePlaylistItemClick(e, item, playlistItemId, serverId);
});
item.dataset.customPlaylistButton = 'true';
});
}
function handlePlaylistItemClick(e, item, playlistItemId, serverId) {
// Check if the click target is within the listViewUserDataButtons container
const buttonsContainer = item.querySelector('.listViewUserDataButtons');
if (buttonsContainer && buttonsContainer.contains(e.target)) {
LOG(`Click on button detected, not navigating for item: ${playlistItemId}`);
return; // Don't navigate when clicking on buttons
}
e.preventDefault();
e.stopPropagation();
const detailsUrl = `/details?id=${playlistItemId}&serverId=${serverId}`;
LOG(`Navigating to: ${detailsUrl}`);
// Use Jellyfin's navigation method
if (Dashboard) {
Dashboard.navigate(detailsUrl);
} else {
// Fallback to direct navigation
const fullDetailsUrl = `${ApiClient.serverAddress()}/web/#${detailsUrl}`;
window.location.href = fullDetailsUrl;
}
}
/**
* Adds play button to each playlist item's listViewUserDataButtons container
@@ -1050,13 +989,10 @@
return;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
let childrenItemsContainer = getPlaylistItemsContainer();
if (!childrenItemsContainer) {
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
WARN('Children items container not found for play buttons');
return;
}
WARN('Children items container not found for play buttons');
return;
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
@@ -1185,17 +1121,10 @@
return;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
let childrenItemsContainer = getPlaylistItemsContainer();
if (!childrenItemsContainer) {
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
if (attempts < maxAttempts) {
setTimeout(pollForElements, pollInterval);
} else {
WARN('Children items container not found after 10 seconds');
}
return;
}
WARN('Children items container not found after 10 seconds');
return;
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
@@ -1231,9 +1160,10 @@
const css = `
.itemsContainer:not([data-sort-key="default"]) .listViewDragHandle {
pointer-events: none;
}
[data-kefin-playlist="true"]:has(#listChildrenCollapsible[data-sort-key]:not([data-sort-key="default"])) .listViewDragHandle,
[data-kefin-playlist="true"]:has(.childrenItemsContainer[data-sort-key]:not([data-sort-key="default"])) .listViewDragHandle {
display: none;
}
`;
const style = document.createElement('style');
@@ -1242,6 +1172,102 @@
document.head.appendChild(style);
}
/**
* Override overlay Play on Playlist cards so play resumes mid-playlist.
* @param {HTMLElement} overlayContainer
*/
function overridePlaylistCardPlayButton(overlayContainer) {
if (!overlayContainer) return;
const card = overlayContainer.closest('.card');
if (!card || card.getAttribute('data-type') !== 'Playlist') return;
const playlistId = card.getAttribute('data-id');
if (!playlistId) {
WARN('Playlist card missing data-id');
return;
}
let playButton = overlayContainer.querySelector('.cardOverlayFab-primary') ||
overlayContainer.querySelector('button[data-action="resume"]') ||
overlayContainer.querySelector('button[data-action="play"]');
if (!playButton) {
const buttons = overlayContainer.querySelectorAll('button');
playButton = Array.from(buttons).find(btn => {
const icon = btn.querySelector('.material-icons');
if (!icon) return false;
const iconText = icon.textContent.trim();
return iconText === 'play_arrow' || iconText === 'play';
});
}
if (!playButton) return;
if (playButton.dataset.ktPlaylistCardOverridden === 'true') return;
LOG('Overriding card overlay play for playlist:', playlistId);
const clonedButton = playButton.cloneNode(true);
clonedButton.className = playButton.className;
clonedButton.id = playButton.id;
clonedButton.title = 'Resume';
clonedButton.setAttribute('is', playButton.getAttribute('is') || '');
clonedButton.type = playButton.type || 'button';
clonedButton.removeAttribute('data-action');
clonedButton.removeAttribute('onclick');
clonedButton.onclick = null;
playButton.parentNode.replaceChild(clonedButton, playButton);
clonedButton.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
try {
await playPlaylistItems(playlistId, { resume: true });
} catch (error) {
ERR('Error resuming playlist from card:', error);
}
};
clonedButton.dataset.ktPlaylistCardOverridden = 'true';
}
function processExistingPlaylistCardOverlays() {
document.querySelectorAll('.cardOverlayContainer').forEach((overlayContainer) => {
overridePlaylistCardPlayButton(overlayContainer);
});
}
function setupPlaylistCardPlayObserver() {
const processNode = (node) => {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return;
if (node.classList?.contains('cardOverlayContainer')) {
overridePlaylistCardPlayButton(node);
}
node.querySelectorAll?.('.cardOverlayContainer').forEach(overridePlaylistCardPlayButton);
};
const onMutations = (mutations) => {
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
mutation.addedNodes.forEach(processNode);
}
};
const utils = window.KefinTweaksUtils;
if (utils?.onBodyMutation) {
utils.onBodyMutation('playlist-cardPlay', onMutations);
} else if (typeof MutationObserver !== 'undefined' && document.body) {
const observer = new MutationObserver(onMutations);
observer.observe(document.body, { childList: true, subtree: true });
}
processExistingPlaylistCardOverlays();
}
/**
* Initialize playlist hook using utils
*/
@@ -1255,23 +1281,25 @@
addPlaylistCSS();
LOG('Registering playlist handler with KefinTweaksUtils');
setupPlaylistCardPlayObserver();
// Register handler for details pages
window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => {
// Await the item promise to get the actual item data
const testItem = Emby.Page.promiseShow ? await Emby.Page.promiseShow() : null;
const item = await itemPromise;
if (item && item.Type === 'Playlist') {
// Run both original functionality and sorting functionality
modifyPlaylistPage();
await addPlaylistSorting(item);
// Small delay to ensure details DOM is ready for sorting
setTimeout(async () => {
/* setTimeout(async () => {
if (item && item.Id) {
await addPlaylistSorting(item);
}
}, 100);
return;
return; */
}
}, {
pages: ['details']
+40 -46
View File
@@ -23,7 +23,6 @@
top: 8px !important;
right: 8px !important;
z-index: 10 !important;
background: rgba(0, 0, 0, 0.7) !important;
border: none !important;
border-radius: 50% !important;
width: 32px !important;
@@ -37,13 +36,12 @@
transform: scale(0.8) !important;
}
.cardOverlayContainer:hover .remove-continue-button {
.cardScalable:hover .remove-continue-button {
opacity: 1 !important;
transform: scale(1) !important;
}
.remove-continue-button:hover {
background: rgba(220, 38, 38, 0.9) !important;
transform: scale(1.1) !important;
}
@@ -54,7 +52,6 @@
}
.remove-continue-button .material-icons {
color: white !important;
font-size: 18px !important;
}
`;
@@ -78,7 +75,7 @@
// Only add button to cards with data-positionticks (in progress items)
const positionTicks = card.getAttribute('data-positionticks');
if (!positionTicks) {
if (!positionTicks || positionTicks === '0') {
return; // Not a continue watching item, skip
}
@@ -98,7 +95,7 @@
// Create remove continue watching button
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'remove-continue-button';
removeButton.className = 'remove-continue-button cardOverlayButton cardOverlayButton-hover paper-icon-button-light emby-button button-flat';
removeButton.setAttribute('data-action', 'none');
removeButton.setAttribute('data-id', itemId);
removeButton.title = 'Remove from Continue Watching';
@@ -162,7 +159,7 @@
const response = await fetch(userDataUrl, {
method: 'GET',
headers: {
"Authorization": apiHelper.getAuthHeader(),
"Authorization": window.apiHelper.getAuthHeader(),
"Content-Type": "application/json"
}
});
@@ -196,7 +193,7 @@
const response = await fetch(userDataUrl, {
method: 'POST',
headers: {
"Authorization": apiHelper.getAuthHeader(),
"Authorization": window.apiHelper.getAuthHeader(),
"Content-Type": "application/json"
},
body: JSON.stringify(updatedUserData)
@@ -237,50 +234,47 @@
});
}
// Set up MutationObserver to watch for new overlay containers
// Shared body observer: inject when card overlays appear
function setupRemoveContinueButtonObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check for added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node is an overlay container
if (node.classList && node.classList.contains('cardOverlayContainer')) {
const buttonContainer = node.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addRemoveContinueButton(node);
}
}
// Check for overlay containers within the added node
const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer');
if (overlayContainers && overlayContainers.length > 0) {
overlayContainers.forEach((overlayContainer) => {
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addRemoveContinueButton(overlayContainer);
}
});
}
}
});
const processNode = (node) => {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return;
if (node.classList?.contains('cardOverlayContainer')) {
if (node.querySelector('.cardOverlayButton-br')) addRemoveContinueButton(node);
}
node.querySelectorAll?.('.cardOverlayContainer').forEach((overlayContainer) => {
if (overlayContainer.querySelector('.cardOverlayButton-br')) {
addRemoveContinueButton(overlayContainer);
}
});
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
// Process any existing overlay containers
};
const onMutations = (mutations) => {
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
mutation.addedNodes.forEach(processNode);
}
};
const utils = window.KefinTweaksUtils;
if (utils?.onBodyMutation) {
utils.onBodyMutation('removeContinue-overlays', onMutations);
} else if (typeof MutationObserver !== 'undefined' && document.body) {
const observer = new MutationObserver(onMutations);
observer.observe(document.body, { childList: true, subtree: true });
}
processExistingOverlayContainers();
}
// Initialize the observer
setupRemoveContinueButtonObserver();
function startRemoveContinue() {
if (!window.KefinTweaksUtils?.onBodyMutation && !document.body) {
setTimeout(startRemoveContinue, 500);
return;
}
setupRemoveContinueButtonObserver();
}
startRemoveContinue();
// Debug function for troubleshooting (available in console)
window.debugRemoveContinueButtons = function() {
+49 -41
View File
@@ -1,73 +1,81 @@
.searchFields {
position: relative;
position: relative;
}
.padded-left.padded-right.searchFields {
display: flex;
flex-direction: column-reverse;
align-items: center;
}
.layout-desktop .searchFieldsInner {
width: 100%;
display: flex;
flex-direction: column-reverse;
align-items: center;
}
div#smart-search-wrapper:not([style*="display: none"]) ~ #jellyseerr-search-icon {
display: none;
transform: translateX(calc(100% + 1.5em)) translateY(calc(-50%));
position: absolute;
top: 24px;
right: 0;
}
.smart-search-mode .searchFieldsInner {
position: relative;
div#smart-search-wrapper:not([style*="display: none"]) ~ #jellyseerr-search-icon {
width: 30px;
height: 30px;
}
#searchPage .searchFieldsInner {
position: relative;
}
main.MuiBox-root div#searchPage {
padding-top: 2rem !important;
}
div#searchPage {
padding-top: 3rem !important;
padding-top: 5rem !important;
display: flex;
flex-direction: column;
}
@media (min-width: 600px) {
div#searchPage {
padding-top: 5rem !important;
}
div#searchPage > * {
order: 1;
}
div#searchPage {
display: flex;
flex-direction: column;
}
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;
}
+455 -298
View File
@@ -10,6 +10,37 @@
LOG('Initializing...');
/* const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this.__url = url;
return originalOpen.apply(this, [method, url, ...args]);
};
XMLHttpRequest.prototype.send = function (...args) {
const url = this.__url || '';
const isSearchPage = location.hash.startsWith('#/search');
const isBlockedSearch =
isSearchPage &&
url.includes('/Items?') &&
url.includes('searchTerm=');
if (!isBlockedSearch) {
return originalSend.apply(this, args);
}
LOG('Blocking default Jellyfin search request:', url);
Dashboard.hideLoadingMsg();
// Async success without touching internal state
queueMicrotask(() => {
this.onload?.();
this.onreadystatechange?.();
});
}; */
// Configuration
const CONFIG = window.KefinTweaksConfig?.search || {
enableJellyseerr: false // Toggle for Jellyseerr integration
@@ -20,28 +51,86 @@
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 ensureKefinNoItemsMessage(searchPage) {
if (!searchPage) return null;
let el = searchPage.querySelector('.noItemsMessage.kefin-noItemsMessage');
if (!el) {
// Migrate legacy empty dummy if present
const dummy = searchPage.querySelector('.noItemsMessage.dummy-section');
if (dummy) {
el = dummy;
el.classList.remove('dummy-section');
el.classList.add('kefin-noItemsMessage', 'centerMessage');
} else {
el = document.createElement('div');
el.className = 'noItemsMessage kefin-noItemsMessage centerMessage';
searchPage.appendChild(el);
}
}
return el;
}
function hideNativeNoItemsMessages(searchPage) {
if (!searchPage) return;
searchPage.querySelectorAll('.noItemsMessage:not(.kefin-noItemsMessage)').forEach((el) => {
el.style.display = 'none';
});
}
function setKefinNoItemsVisible(searchPage, { show, searchTerm } = {}) {
if (!searchPage) return;
const el = ensureKefinNoItemsMessage(searchPage);
if (!el) return;
if (show) {
hideNativeNoItemsMessages(searchPage);
const term = String(searchTerm || '').trim();
el.textContent = term
? `Sorry! No results found for "${term}"`
: 'Sorry! No results found';
el.style.display = '';
} else {
el.style.display = 'none';
}
}
async function searchSectionsHaveItems(sections) {
const list = (sections || []).filter(Boolean);
if (!list.length) return false;
const counts = await Promise.all(list.map(async (section) => {
try {
const raw = typeof section.result?.ensureData === 'function'
? await section.result.ensureData()
: await section.result?.dataPromise;
if (Array.isArray(raw)) return raw.length;
if (Array.isArray(raw?.Items)) return raw.Items.length;
return 0;
} catch {
return 0;
}
}));
return counts.some((n) => n > 0);
}
// Function to clear search results except jellyseerr-section
@@ -53,6 +142,147 @@
if (resultsContainer) {
resultsContainer.innerHTML = '';
}
const searchPage = document.getElementById('searchPage');
if (searchPage) setKefinNoItemsVisible(searchPage, { show: false });
}
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
}
};
}
function getSearchCardFormat(itemType) {
if (itemType === 'Episode') return 'Thumb';
if (itemType === 'MusicArtist' || itemType === 'Audio' || itemType === 'MusicAlbum') return 'Square';
return null;
}
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 cardFormat = getSearchCardFormat(itemType);
const sectionConfig = {
id: `search-${itemType}-${termKey}`,
name: getTypeDisplayName(itemType),
enabled: true,
order,
queries: [query],
ttl: SEARCH_CACHE_TTL,
userConfigurable: false,
...(cardFormat ? { cardFormat } : {})
};
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)
@@ -62,20 +292,20 @@
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; }
.smart-search-results { margin-top:12px; }
.smart-search-stats { color:#999; font-size:12px; margin-top:6px; display: none; }
#persistent-toggle-btn { display:none; position:relative; padding:8px 12px; background:#00a4dc; color:#fff; border:none; border-radius:4px; cursor:pointer; }
.smart-search-mode .searchfields-icon {
#searchPage .searchfields-icon {
top: 50%;
transform: translateY(-50%);
position: absolute;
z-index: 1;
right: 0;
cursor: pointer;
font-size: 2em;
}
`;
document.head.appendChild(style);
@@ -102,147 +332,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',
@@ -252,12 +341,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'
};
@@ -283,57 +375,105 @@
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';
}
hideNativeNoItemsMessages(searchPage);
setKefinNoItemsVisible(searchPage, { show: false });
// 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 = '';
setKefinNoItemsVisible(searchPage, { show: false });
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 resolvedSections = sections.filter(Boolean);
const validSections = resolvedSections.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 = '';
setKefinNoItemsVisible(searchPage, { show: false });
}
return;
}
const hasItems = await searchSectionsHaveItems(resolvedSections);
if (!isSearchStillWanted(trimmed, internalType)) return;
setKefinNoItemsVisible(searchPage, { show: !hasItems, searchTerm: trimmed });
} catch (e) {
ERR('performSmartSearch error', e);
}
}
// main init
function initSmartSearch() {
async function initSmartSearch() {
if (typeof ApiClient === 'undefined') { setTimeout(initSmartSearch, 500); return; }
const originalInput = document.getElementById('searchTextInput');
@@ -385,56 +525,84 @@
const btnRow = document.createElement('div');
btnRow.className = 'smart-search-buttons';
const smartSearchButtonClass = 'smart-search-btn emby-button flat';
const btnAll = document.createElement('button');
btnAll.id = 'smart-search-all';
btnAll.className = 'smart-search-btn emby-button';
btnAll.className = smartSearchButtonClass;
btnAll.textContent = 'All';
const btnCore = document.createElement('button');
btnCore.id = 'smart-search-core';
btnCore.className = 'smart-search-btn emby-button raised button-submit active';
btnCore.className = smartSearchButtonClass + ' button-submit active';
btnCore.textContent = 'Movies/TV';
const btnMusic = document.createElement('button');
btnMusic.id = 'smart-search-music';
btnMusic.className = 'smart-search-btn emby-button';
btnMusic.className = smartSearchButtonClass;
btnMusic.textContent = 'Music';
const btnBooks = document.createElement('button');
btnBooks.id = 'smart-search-books';
btnBooks.className = 'smart-search-btn emby-button';
btnBooks.className = smartSearchButtonClass;
btnBooks.textContent = 'Books';
btnRow.appendChild(btnAll);
btnRow.appendChild(btnCore);
btnRow.appendChild(btnMusic);
btnRow.appendChild(btnBooks);
if (CONFIG.enableJellyseerr) {
const btnRequest = document.createElement('button');
btnRequest.id = 'smart-search-request';
btnRequest.className = 'smart-search-btn emby-button';
btnRequest.textContent = 'Request';
btnRow.appendChild(btnRequest);
const libraries = await window.dataHelper.getLibraries();
// React may have remounted search during the await — use a live node
const existingWrapper = document.getElementById('smart-search-wrapper');
if (existingWrapper) {
LOG('Smart search wrapper already present after getLibraries; skipping insert');
wrapper = existingWrapper;
} else {
const liveInput = document.getElementById('searchTextInput');
if (!liveInput?.parentElement) {
WARN('Search input detached after getLibraries; retrying init');
setTimeout(initSmartSearch, 500);
return;
}
const hasVideoLibraries = libraries.some(library => library.CollectionType === 'movies' || library.CollectionType === 'tvshows' || library.CollectionType === 'homevideos');
const hasMusicLibraries = libraries.some(library => library.CollectionType === 'music' || library.CollectionType === 'musicvideos');
const hasBookLibraries = libraries.some(library => library.CollectionType === 'books');
btnRow.appendChild(btnAll);
if (hasVideoLibraries) {
btnRow.appendChild(btnCore);
}
if (hasMusicLibraries) {
btnRow.appendChild(btnMusic);
}
if (hasBookLibraries) {
btnRow.appendChild(btnBooks);
}
if (CONFIG.enableJellyseerr) {
const btnRequest = document.createElement('button');
btnRequest.id = 'smart-search-request';
btnRequest.className = smartSearchButtonClass;
btnRequest.textContent = 'Request';
btnRow.appendChild(btnRequest);
}
const stats = document.createElement('div');
stats.id = 'smart-search-stats';
stats.className = 'smart-search-stats';
// Create input wrapper
const inputWrapper = document.createElement('div');
inputWrapper.className = 'input-wrapper';
inputWrapper.appendChild(searchIcon);
inputWrapper.appendChild(input);
wrapper.appendChild(inputWrapper);
wrapper.appendChild(btnRow);
wrapper.appendChild(stats);
// Insert the wrapper before the live search input
liveInput.parentElement.insertBefore(wrapper, liveInput);
liveInput.remove();
input.focus();
}
const stats = document.createElement('div');
stats.id = 'smart-search-stats';
stats.className = 'smart-search-stats';
// Create input wrapper
const inputWrapper = document.createElement('div');
inputWrapper.className = 'input-wrapper';
inputWrapper.appendChild(searchIcon);
inputWrapper.appendChild(input);
wrapper.appendChild(inputWrapper);
wrapper.appendChild(btnRow);
wrapper.appendChild(stats);
// Insert the wrapper before the original input
originalInput.parentElement.insertBefore(wrapper, originalInput);
originalInput.remove();
input.focus();
}
// Smart results container will be created lazily when needed
@@ -452,26 +620,26 @@
// Function to update button states
function updateButtonStates(activeType) {
// Remove active class from all buttons
smartAllBtn.classList.remove('active','button-submit','raised');
smartCoreBtn.classList.remove('active','button-submit','raised');
smartMusicBtn.classList.remove('active','button-submit','raised');
smartBooksBtn.classList.remove('active','button-submit','raised');
smartAllBtn.classList.remove('active','button-submit');
smartCoreBtn?.classList.remove('active','button-submit');
smartMusicBtn?.classList.remove('active','button-submit');
smartBooksBtn?.classList.remove('active','button-submit');
if (smartRequestBtn) {
smartRequestBtn.classList.remove('active','button-submit','raised');
smartRequestBtn.classList.remove('active','button-submit');
}
// Add active class to the selected button
if (activeType === 'all') {
smartAllBtn.classList.add('active','button-submit','raised');
smartAllBtn.classList.add('active','button-submit');
} else if (activeType === 'videos') {
smartCoreBtn.classList.add('active','button-submit','raised');
smartCoreBtn?.classList.add('active','button-submit');
} else if (activeType === 'music') {
smartMusicBtn.classList.add('active','button-submit','raised');
smartMusicBtn?.classList.add('active','button-submit');
} else if (activeType === 'books') {
smartBooksBtn.classList.add('active','button-submit','raised');
smartBooksBtn?.classList.add('active','button-submit');
} else if (activeType === 'request' && smartRequestBtn) {
smartRequestBtn.classList.add('active','button-submit','raised');
smartRequestBtn?.classList.add('active','button-submit');
}
}
@@ -480,56 +648,49 @@
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 = () => {
const term = (smartInput.value || '').trim();
if (!term) {
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.innerHTML='';
Dashboard.hideLoadingMsg();
return;
}
if (timer) clearTimeout(timer);
const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType;
const term = (smartInput.value || '').trim();
if (!term) {
timer = null;
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.innerHTML = '';
updateSearchUrl('', currentSearchType);
// Show search suggestions
const searchSuggestions = document.querySelector('.searchSuggestions');
if (searchSuggestions) {
searchSuggestions.style.display = 'block';
}
const searchPageEl = document.getElementById('searchPage');
if (searchPageEl) setKefinNoItemsVisible(searchPageEl, { show: false });
return;
}
const internalType = toInternalSearchType(currentSearchType);
timer = setTimeout(() => performSmartSearch(term, internalType), 300);
};
@@ -537,13 +698,14 @@
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);
}
});
smartCoreBtn.addEventListener('click', ()=>setSearchType('videos'));
smartMusicBtn.addEventListener('click', ()=>setSearchType('music'));
smartBooksBtn.addEventListener('click', ()=>setSearchType('books'));
smartCoreBtn?.addEventListener('click', ()=>setSearchType('videos'));
smartMusicBtn?.addEventListener('click', ()=>setSearchType('music'));
smartBooksBtn?.addEventListener('click', ()=>setSearchType('books'));
smartAllBtn.addEventListener('click', ()=>setSearchType('all'));
if (smartRequestBtn) {
smartRequestBtn.addEventListener('click', ()=>setSearchType('request'));
@@ -560,13 +722,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);
}
}
@@ -598,8 +761,6 @@
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = '';
toggleBtn.textContent='Switch to Default Search';
// Add smart-search-mode class to body for CSS targeting
document.body.classList.add('smart-search-mode');
} else {
wrapper.style.display = 'none';
// Show search results containers (be more specific to avoid affecting other pages)
@@ -608,10 +769,6 @@
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = 'none';
toggleBtn.textContent='Switch to Smart Search';
// Remove smart-search-mode class from body
document.body.classList.remove('smart-search-mode');
// Note: We don't restore the original input since we replaced it entirely
}
};
+886
View File
@@ -0,0 +1,886 @@
function addSeasonalOverlay() {
const existing = document.querySelector('.snowverlay');
if (existing) {
return;
}
const SHAPE_GLYPHS = {
bat: '🦇',
snowflake: '❄',
bell: '🔔',
heart: '❤'
};
/** Extensible mode definitions — add entries + handlers to support new styles. */
const ANIMATION_MODES = {
fall: {
id: 'fall',
canvas: { heightPx: 160, fullViewport: false },
poolSize: 15,
fadeStartYRatio: 1,
speed: { min: 0.2, max: 0.7 }
},
fly: {
id: 'fly',
canvas: { heightPx: 75, fullViewport: false },
poolSize: 12,
flockSize: { min: 4, max: 6 },
groupDelayMs: { min: 1000, max: 6000 },
onScreenSec: { min: 2, max: 3 },
topY: { min: 8, max: 68 },
spawnX: -50
},
corners: {
id: 'corners',
canvas: { heightPx: null, fullViewport: false, cornerCanvases: true },
// Larger pool so micro-packs can stream while earlier bats are still flying
poolSize: 48,
// Narrow focused packs (not one wide swarm)
packSize: { min: 5, max: 8 },
// Continuous stream duration at one corner, then pause before next corner
streamDurationMs: { min: 2000, max: 3000 },
streamGapMs: { min: 160, max: 300 },
groupDelayMs: { min: 1000, max: 6000 },
onScreenSec: { min: 1.5, max: 2.5 },
// Chord through the corner quadrant (~sqrt(2) * quadrant)
pathLengthRatio: { min: 0.18, max: 0.28 },
// Corner canvas / chord band as fraction of min(vw, vh)
quadrantFrac: 0.14
}
};
const DEFAULT_MODE_ID = 'fall';
const styleKey = String(document.body.dataset.seasonalAnimationStyle || DEFAULT_MODE_ID).toLowerCase();
const modeDef = ANIMATION_MODES[styleKey] || ANIMATION_MODES[DEFAULT_MODE_ID];
const shapeKey = document.body.dataset.seasonalShape || 'snowflake';
const glyph = SHAPE_GLYPHS[shapeKey] || SHAPE_GLYPHS.snowflake;
const PARTICLE_SIZE = { min: 10, max: 22 };
const TARGET_FPS = 30;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
const BASE_FRAME_MS = 1000 / 60;
const MAX_DT_MS = 50;
const RESIZE_DEBOUNCE_MS = 150;
const SPRITE_BASE_SIZE = 32;
const MAX_DPR = 2;
const FRAMES_PER_SEC = 1000 / BASE_FRAME_MS;
const rand = (min, max) => min + Math.random() * (max - min);
const randInt = (min, max) => min + Math.floor(Math.random() * (max - min + 1));
const useCornerCanvases = modeDef.canvas.cornerCanvases === true;
const CORNER_IDS = ['tl', 'tr', 'bl', 'br'];
const canvasHeightCss = () => {
if (modeDef.canvas.fullViewport) {
return Math.max(1, window.innerHeight || 1);
}
return modeDef.canvas.heightPx || 160;
};
const cornerSizeCss = () => {
const vw = Math.max(1, window.innerWidth || 1);
const vh = Math.max(1, window.innerHeight || 1);
// Keep corner canvases large enough for a readable swarm (~14% min side, min 96px)
return Math.max(96, Math.floor(Math.min(vw, vh) * (modeDef.quadrantFrac || 0.14)));
};
const style = document.createElement('style');
if (useCornerCanvases) {
style.textContent = `
body {
height: 100svh;
margin: 0;
}
.snowverlay.snowverlay-corner {
position: fixed;
width: var(--snowverlay-corner-size, 14vmin);
height: var(--snowverlay-corner-size, 14vmin);
pointer-events: none;
z-index: 1000;
}
.snowverlay-corner-tl { top: 0; left: 0; }
.snowverlay-corner-tr { top: 0; right: 0; }
.snowverlay-corner-bl { bottom: 0; left: 0; }
.snowverlay-corner-br { bottom: 0; right: 0; }
`;
} else {
style.textContent = `
body {
height: 100svh;
margin: 0;
}
.snowverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: ${modeDef.canvas.heightPx || 160}px;
pointer-events: none;
z-index: 1000;
}
`;
}
document.head.appendChild(style);
/** @type {HTMLCanvasElement|null} */
let canvas = null;
/** @type {CanvasRenderingContext2D|null} */
let ctx = null;
/** @type {Record<string, { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D }>} */
const cornerLayers = {};
if (useCornerCanvases) {
CORNER_IDS.forEach((id) => {
const el = document.createElement('canvas');
el.className = `snowverlay snowverlay-corner snowverlay-corner-${id}`;
document.body.appendChild(el);
cornerLayers[id] = {
canvas: el,
ctx: el.getContext('2d', { alpha: true })
};
});
} else {
canvas = document.createElement('canvas');
canvas.className = 'snowverlay';
document.body.appendChild(canvas);
ctx = canvas.getContext('2d', { alpha: true });
}
let cssWidth = window.innerWidth || 1;
let cssHeight = useCornerCanvases
? Math.max(1, window.innerHeight || 1)
: canvasHeightCss();
let cornerSize = useCornerCanvases ? cornerSizeCss() : 0;
const buildGlyphSprite = () => {
const sprite = document.createElement('canvas');
sprite.width = SPRITE_BASE_SIZE;
sprite.height = SPRITE_BASE_SIZE;
const sCtx = sprite.getContext('2d');
sCtx.clearRect(0, 0, SPRITE_BASE_SIZE, SPRITE_BASE_SIZE);
sCtx.font = `${SPRITE_BASE_SIZE * 0.85}px sans-serif`;
sCtx.textAlign = 'center';
sCtx.textBaseline = 'middle';
sCtx.fillText(glyph, SPRITE_BASE_SIZE / 2, SPRITE_BASE_SIZE / 2);
return sprite;
};
const glyphSprite = buildGlyphSprite();
const resizeCanvas = () => {
cssWidth = Math.max(1, window.innerWidth || 1);
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
if (useCornerCanvases) {
cssHeight = Math.max(1, window.innerHeight || 1);
cornerSize = cornerSizeCss();
document.documentElement.style.setProperty('--snowverlay-corner-size', `${cornerSize}px`);
CORNER_IDS.forEach((id) => {
const layer = cornerLayers[id];
if (!layer) return;
layer.canvas.width = Math.round(cornerSize * dpr);
layer.canvas.height = Math.round(cornerSize * dpr);
layer.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
});
return;
}
cssHeight = canvasHeightCss();
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
let resizeTimer = null;
const onResize = () => {
if (resizeTimer != null) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
resizeCanvas();
if (reducedMotionMq.matches && shouldPaintStatic()) {
renderStatic();
}
}, RESIZE_DEBOUNCE_MS);
};
window.addEventListener('resize', onResize);
resizeCanvas();
const burstState = {
nextLaunchAt: 0,
lastCorner: null,
// Active corner stream (corners mode)
streamUntil: 0,
nextStreamPackAt: 0,
streamCorner: null,
streamFromVertical: true,
streamLane: 0.5,
streamSpeed: 0,
activeCornerCanvas: null
};
function getActiveCornerCtx() {
const id = burstState.activeCornerCanvas || burstState.streamCorner;
return id ? cornerLayers[id]?.ctx || null : null;
}
function clearCornerCanvas(id) {
const layer = cornerLayers[id];
if (!layer) return;
layer.ctx.clearRect(0, 0, cornerSize, cornerSize);
}
function clearAllCornerCanvases() {
CORNER_IDS.forEach(clearCornerCanvas);
}
function clearActiveSurface() {
if (useCornerCanvases) {
const id = burstState.activeCornerCanvas || burstState.streamCorner;
if (id) clearCornerCanvas(id);
return;
}
ctx.clearRect(0, 0, cssWidth, cssHeight);
}
/** Map viewport particle coords into the active corner canvas local space. */
function toCornerLocal(x, y, cornerId) {
const size = cornerSize;
if (cornerId === 'tr') return { x: x - (cssWidth - size), y };
if (cornerId === 'bl') return { x, y: y - (cssHeight - size) };
if (cornerId === 'br') return { x: x - (cssWidth - size), y: y - (cssHeight - size) };
return { x, y };
}
function makeBaseParticle() {
return {
x: 0,
y: 0,
vx: 0,
vy: 0,
size: rand(PARTICLE_SIZE.min, PARTICLE_SIZE.max),
opacity: null,
speed: 0,
drift: 0,
rotation: 0,
rotationSpeed: 0,
active: false,
wanderPhase: Math.random() * Math.PI * 2
};
}
function speedForDistance(distPx, secMin, secMax) {
const sec = rand(secMin, secMax);
return distPx / (sec * FRAMES_PER_SEC);
}
function applyFacing(particle, vx, vy) {
particle.rotation = (Math.atan2(vy, vx) * 180) / Math.PI + 90;
}
function applyGentleWander(particle, frameScale) {
particle.wanderPhase += 0.035 * frameScale;
const wanderVy = Math.sin(particle.wanderPhase) * 0.4;
const wanderVx = Math.cos(particle.wanderPhase * 0.7) * 0.08;
return {
vx: particle.vx + wanderVx,
vy: particle.vy + wanderVy
};
}
const fallHandlers = {
createParticle(isAnimated, index) {
const fadeY = cssHeight;
return {
...makeBaseParticle(),
active: true,
x: Math.random() * cssWidth,
y: isAnimated
? -20 - (index * cssHeight) / modeDef.poolSize
: Math.random() * fadeY,
speed: rand(modeDef.speed.min, modeDef.speed.max),
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
drift: Math.random() * 0.4 - 0.2,
rotation: Math.random() * 360,
rotationSpeed: Math.random() * 2 - 1
};
},
opacity(particle) {
if (particle.opacity != null) return particle.opacity;
return Math.max(0, 1 - particle.y / cssHeight);
},
update(particle, frameScale) {
particle.y += particle.speed * frameScale;
particle.x += particle.drift * frameScale;
particle.rotation += particle.rotationSpeed * frameScale;
const offBottom = particle.y > cssHeight + 20;
const offSides = particle.x < -50 || particle.x > cssWidth + 50;
if (offBottom || offSides) {
particle.y = -10 - Math.random() * 30;
particle.x = Math.random() * cssWidth;
particle.speed = rand(modeDef.speed.min, modeDef.speed.max);
particle.drift = Math.random() * 0.4 - 0.2;
particle.rotation = Math.random() * 360;
}
},
tickFrame() {},
paintStatic() {
for (let i = 0; i < modeDef.poolSize; i++) {
drawParticle(fallHandlers.createParticle(false, i));
}
}
};
function parkFlyParticle(p) {
p.active = false;
p.x = -999;
p.y = -999;
}
function initFlyMember(p, memberIndex, baseVx, baseY) {
const spreadX = memberIndex * rand(14, 24);
p.x = modeDef.spawnX - spreadX - Math.random() * 20;
p.y = baseY + (Math.random() * 2 - 1) * 6;
p.vx = baseVx * (0.94 + Math.random() * 0.12);
p.vy = rand(-0.08, 0.08);
p.wanderPhase = Math.random() * Math.PI * 2;
p.active = true;
applyFacing(p, p.vx, p.vy);
}
function launchFlyGroup(particles) {
const flockSize = randInt(modeDef.flockSize.min, modeDef.flockSize.max);
const baseVx = speedForDistance(
cssWidth + 80,
modeDef.onScreenSec.min,
modeDef.onScreenSec.max
);
const baseY = rand(modeDef.topY.min, modeDef.topY.max);
let placed = 0;
for (let i = 0; i < particles.length && placed < flockSize; i++) {
if (!particles[i].active) {
initFlyMember(particles[i], placed, baseVx, baseY);
placed += 1;
}
}
}
function flyGroupActive(particles) {
return particles.some((p) => p.active);
}
const flyHandlers = {
createParticle() {
const p = makeBaseParticle();
parkFlyParticle(p);
return p;
},
opacity(particle) {
if (!particle.active) return 0;
if (particle.opacity != null) return particle.opacity;
const fadeStartX = cssWidth * 0.88;
if (particle.x <= fadeStartX) return 1;
const fadeRange = Math.max(1, cssWidth + 50 - fadeStartX);
return Math.max(0, 1 - (particle.x - fadeStartX) / fadeRange);
},
update(particle, frameScale) {
if (!particle.active) return;
const { vx, vy } = applyGentleWander(particle, frameScale);
particle.x += vx * frameScale;
particle.y += vy * frameScale;
particle.y = Math.max(modeDef.topY.min, Math.min(modeDef.topY.max, particle.y));
applyFacing(particle, vx, vy);
const offRight = particle.x > cssWidth + 60;
const faded = flyHandlers.opacity(particle) <= 0.02;
if (offRight || faded) {
parkFlyParticle(particle);
}
},
tickFrame(particles) {
const now = Date.now();
if (flyGroupActive(particles)) return;
if (burstState.nextLaunchAt === 0) {
burstState.nextLaunchAt = now + randInt(
modeDef.groupDelayMs.min,
modeDef.groupDelayMs.max
);
return;
}
if (now >= burstState.nextLaunchAt) {
launchFlyGroup(particles);
burstState.nextLaunchAt = 0;
}
},
paintStatic() {
for (let i = 0; i < modeDef.poolSize; i++) {
const p = flyHandlers.createParticle();
p.active = true;
p.x = Math.random() * cssWidth;
p.y = rand(modeDef.topY.min, modeDef.topY.max);
applyFacing(p, 1, 0);
drawParticle(p);
}
},
onStart(particles) {
launchFlyGroup(particles);
burstState.nextLaunchAt = 0;
}
};
const CORNER_PICK_IDS = CORNER_IDS;
function pickNextCorner() {
const choices = CORNER_PICK_IDS.filter((c) => c !== burstState.lastCorner);
const corner = choices[Math.floor(Math.random() * choices.length)] || 'tl';
burstState.lastCorner = corner;
const fromVertical = Math.random() < 0.5;
return { corner, fromVertical };
}
/**
* True 45° diagonal across the corner canvas.
* fromVertical: enter the vertical screen edge; else enter the horizontal edge.
* |vx| === |vy| so the path is always 45°.
*
* Spawn is offset along the travel direction by `margin` so that after the
* off-screen approach, particles ENTER the viewport/canvas at `lane` —
* without this, a 45° approach from margin px off-screen only clips the tip.
*/
function cornerBurstVector(corner, fromVertical, speed, laneT = 0.5) {
// Component along each axis for 45° (speed is full diagonal magnitude)
const c = speed / Math.SQRT2;
// Lane within the corner canvas (match drawn canvas size when available)
const band = useCornerCanvases
? cornerSize
: Math.min(cssWidth, cssHeight) * (modeDef.quadrantFrac || 0.14);
// Keep chords inside the canvas with room for particle size / wander
const lane = band * (0.25 + Math.max(0, Math.min(1, laneT)) * 0.55);
const margin = 24;
if (corner === 'tl') {
return fromVertical
? {
// left → top (up-right 45°): enter left edge at y=lane
spawn: () => ({ x: -margin - Math.random() * 12, y: lane + margin }),
vx: c,
vy: -c
}
: {
// top → left (down-left 45°): enter top edge at x=lane
spawn: () => ({ x: lane + margin, y: -margin - Math.random() * 12 }),
vx: -c,
vy: c
};
}
if (corner === 'tr') {
return fromVertical
? {
// right → top (up-left 45°): enter right edge at y=lane
spawn: () => ({ x: cssWidth + margin + Math.random() * 12, y: lane + margin }),
vx: -c,
vy: -c
}
: {
// top → right (down-right 45°): enter top edge at x=cssWidth-lane
spawn: () => ({ x: cssWidth - lane - margin, y: -margin - Math.random() * 12 }),
vx: c,
vy: c
};
}
if (corner === 'bl') {
return fromVertical
? {
// left → bottom (down-right 45°): enter left edge at y=cssHeight-lane
spawn: () => ({ x: -margin - Math.random() * 12, y: cssHeight - lane - margin }),
vx: c,
vy: c
}
: {
// bottom → left (up-left 45°): enter bottom edge at x=lane
spawn: () => ({ x: lane + margin, y: cssHeight + margin + Math.random() * 12 }),
vx: -c,
vy: -c
};
}
// br
return fromVertical
? {
// right → bottom (down-left 45°): enter right edge at y=cssHeight-lane
spawn: () => ({ x: cssWidth + margin + Math.random() * 12, y: cssHeight - lane - margin }),
vx: -c,
vy: c
}
: {
// bottom → right (up-right 45°): enter bottom edge at x=cssWidth-lane
spawn: () => ({ x: cssWidth - lane - margin, y: cssHeight + margin + Math.random() * 12 }),
vx: c,
vy: -c
};
}
function isOffScreen(p) {
if (useCornerCanvases) {
// Deactivate once past the active corner canvas (+ margin for sprite)
const id = burstState.activeCornerCanvas || burstState.streamCorner;
if (!id) return true;
const local = toCornerLocal(p.x, p.y, id);
const m = 48;
return local.x < -m || local.y < -m || local.x > cornerSize + m || local.y > cornerSize + m;
}
const m = 80;
return p.x < -m || p.x > cssWidth + m || p.y < -m || p.y > cssHeight + m;
}
function cornersGroupActive(particles) {
return particles.some((p) => p.active);
}
/** Launch one narrow pack on the current stream lane. */
function launchCornersMicroPack(particles) {
const { streamCorner, streamFromVertical, streamLane, streamSpeed } = burstState;
if (!streamCorner || !streamSpeed) return;
const spec = cornerBurstVector(streamCorner, streamFromVertical, streamSpeed, streamLane);
const packSize = randInt(modeDef.packSize.min, modeDef.packSize.max);
let placed = 0;
for (let i = 0; i < particles.length && placed < packSize; i++) {
const p = particles[i];
if (p.active) continue;
const pos = spec.spawn();
// Narrow corridor: short stagger along travel, ~±4px lateral
const along = placed * rand(6, 12);
const lateral = (Math.random() * 2 - 1) * 4;
if (streamFromVertical) {
p.x = pos.x - Math.sign(spec.vx || 1) * along;
p.y = pos.y + lateral;
} else {
p.x = pos.x + lateral;
p.y = pos.y - Math.sign(spec.vy || 1) * along;
}
const scale = 0.97 + Math.random() * 0.06;
p.vx = spec.vx * scale;
p.vy = spec.vy * scale;
p.wanderPhase = Math.random() * Math.PI * 2;
p.active = true;
applyFacing(p, p.vx, p.vy);
placed += 1;
}
}
/** Begin a 2–3s stream of narrow packs at a new corner. */
function beginCornersStream(particles) {
const { corner, fromVertical } = pickNextCorner();
// Travel distance for speed: roughly the corner diagonal
const pathBase = useCornerCanvases
? cornerSize * Math.SQRT2
: Math.min(cssWidth, cssHeight) * (modeDef.quadrantFrac || 0.14) * Math.SQRT2;
const pathLen = pathBase * rand(0.85, 1.25);
const speed = speedForDistance(
pathLen,
modeDef.onScreenSec.min,
modeDef.onScreenSec.max
);
const now = Date.now();
if (burstState.activeCornerCanvas && burstState.activeCornerCanvas !== corner) {
clearCornerCanvas(burstState.activeCornerCanvas);
}
burstState.streamCorner = corner;
burstState.activeCornerCanvas = corner;
burstState.streamFromVertical = fromVertical;
burstState.streamLane = Math.random();
burstState.streamSpeed = speed;
burstState.streamUntil = now + randInt(
modeDef.streamDurationMs.min,
modeDef.streamDurationMs.max
);
burstState.nextLaunchAt = 0;
launchCornersMicroPack(particles);
burstState.nextStreamPackAt = now + randInt(
modeDef.streamGapMs.min,
modeDef.streamGapMs.max
);
}
const cornersHandlers = {
createParticle() {
const p = makeBaseParticle();
p.active = false;
p.x = -999;
p.y = -999;
return p;
},
opacity(particle) {
if (!particle.active) return 0;
if (particle.opacity != null) return particle.opacity;
return 1;
},
update(particle, frameScale) {
if (!particle.active) return;
particle.wanderPhase += 0.03 * frameScale;
const perp = Math.sin(particle.wanderPhase) * 0.1;
const vx = particle.vx + (-Math.sign(particle.vy) || -1) * perp * 0.45;
const vy = particle.vy + (Math.sign(particle.vx) || 1) * perp * 0.45;
particle.x += vx * frameScale;
particle.y += vy * frameScale;
applyFacing(particle, vx, vy);
if (isOffScreen(particle)) {
particle.active = false;
particle.x = -999;
particle.y = -999;
}
},
tickFrame(particles) {
const now = Date.now();
// Active stream: keep launching narrow packs for 2–3s
if (burstState.streamUntil > 0) {
if (now < burstState.streamUntil) {
if (now >= burstState.nextStreamPackAt) {
launchCornersMicroPack(particles);
burstState.nextStreamPackAt = now + randInt(
modeDef.streamGapMs.min,
modeDef.streamGapMs.max
);
}
return;
}
// Stream window ended — keep drawing on activeCornerCanvas until bats exit
burstState.streamUntil = 0;
burstState.nextStreamPackAt = 0;
burstState.streamCorner = null;
burstState.streamSpeed = 0;
burstState.nextLaunchAt = 0;
}
if (cornersGroupActive(particles)) return;
if (burstState.activeCornerCanvas) {
clearCornerCanvas(burstState.activeCornerCanvas);
burstState.activeCornerCanvas = null;
}
if (burstState.nextLaunchAt === 0) {
burstState.nextLaunchAt = now + randInt(
modeDef.groupDelayMs.min,
modeDef.groupDelayMs.max
);
return;
}
if (now >= burstState.nextLaunchAt) {
beginCornersStream(particles);
}
},
paintStatic() {
const { corner, fromVertical } = pickNextCorner();
const lane = Math.random();
const spec = cornerBurstVector(corner, fromVertical, 8, lane);
burstState.activeCornerCanvas = corner;
clearAllCornerCanvases();
const count = randInt(8, 14);
for (let i = 0; i < count; i++) {
const p = cornersHandlers.createParticle();
const pos = spec.spawn();
p.active = true;
p.x = pos.x + (Math.random() * 2 - 1) * 4;
p.y = pos.y + (Math.random() * 2 - 1) * 4;
applyFacing(p, spec.vx, spec.vy);
drawParticle(p);
}
},
onStart(particles) {
beginCornersStream(particles);
}
};
const MODE_HANDLERS = {
fall: fallHandlers,
fly: flyHandlers,
corners: cornersHandlers
};
const handlers = MODE_HANDLERS[modeDef.id] || fallHandlers;
const particles = Array.from({ length: modeDef.poolSize }, (_, i) => {
if (modeDef.id === 'fall') {
return handlers.createParticle(true, i);
}
return handlers.createParticle();
});
function particleOpacity(particle) {
return handlers.opacity(particle);
}
function drawParticle(particle) {
const opacity = particleOpacity(particle);
if (opacity <= 0.01) return;
if (modeDef.id !== 'fall' && !particle.active) return;
let drawCtx = ctx;
let dx = particle.x;
let dy = particle.y;
if (useCornerCanvases) {
const cornerId = burstState.activeCornerCanvas;
drawCtx = getActiveCornerCtx();
if (!drawCtx || !cornerId) return;
const local = toCornerLocal(particle.x, particle.y, cornerId);
dx = local.x;
dy = local.y;
// Skip if outside this corner canvas (with small margin for sprite)
if (dx < -40 || dy < -40 || dx > cornerSize + 40 || dy > cornerSize + 40) return;
}
if (!drawCtx) return;
const size = particle.size;
drawCtx.save();
drawCtx.translate(dx, dy);
drawCtx.rotate((particle.rotation * Math.PI) / 180);
drawCtx.globalAlpha = opacity;
drawCtx.drawImage(glyphSprite, -size / 2, -size / 2, size, size);
drawCtx.restore();
}
let animationFrame = null;
let lastTime = 0;
let animating = false;
let startedBurst = false;
const stopAnimation = () => {
if (animationFrame != null) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
animating = false;
startedBurst = false;
};
const animate = (currentTime) => {
animationFrame = requestAnimationFrame(animate);
if (!lastTime) {
lastTime = currentTime;
return;
}
const rawDelta = currentTime - lastTime;
if (rawDelta < FRAME_INTERVAL) return;
lastTime = currentTime - (rawDelta % FRAME_INTERVAL);
const dt = Math.min(rawDelta, MAX_DT_MS);
const frameScale = dt / BASE_FRAME_MS;
if (!startedBurst && handlers.onStart) {
handlers.onStart(particles);
startedBurst = true;
}
if (handlers.tickFrame) {
handlers.tickFrame(particles);
}
clearActiveSurface();
for (let i = 0; i < particles.length; i++) {
const particle = particles[i];
handlers.update(particle, frameScale);
drawParticle(particle);
}
};
const renderStatic = () => {
if (useCornerCanvases) {
clearAllCornerCanvases();
} else {
ctx.clearRect(0, 0, cssWidth, cssHeight);
}
handlers.paintStatic();
};
const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
const mobileMq = window.matchMedia('(max-width: 899px)');
const isHomePageActive = () =>
document.documentElement.getAttribute('data-active-page') === 'home';
const shouldRunAnimation = () =>
!document.hidden
&& isHomePageActive()
&& !mobileMq.matches
&& !reducedMotionMq.matches;
const shouldPaintStatic = () =>
!document.hidden
&& isHomePageActive()
&& !mobileMq.matches
&& reducedMotionMq.matches;
const startAnimation = () => {
if (animating) return;
animating = true;
lastTime = 0;
startedBurst = false;
burstState.nextLaunchAt = 0;
burstState.lastCorner = null;
burstState.streamUntil = 0;
burstState.nextStreamPackAt = 0;
burstState.streamCorner = null;
burstState.streamSpeed = 0;
burstState.activeCornerCanvas = null;
if (modeDef.id === 'fly' || modeDef.id === 'corners') {
particles.forEach((p) => {
p.active = false;
p.x = -999;
p.y = -999;
});
}
if (useCornerCanvases) clearAllCornerCanvases();
animationFrame = requestAnimationFrame(animate);
};
const syncAnimationRunning = () => {
if (shouldRunAnimation()) {
if (!animating) startAnimation();
return;
}
stopAnimation();
if (shouldPaintStatic()) {
renderStatic();
} else if (useCornerCanvases) {
clearAllCornerCanvases();
} else {
ctx.clearRect(0, 0, cssWidth, cssHeight);
}
};
const onVisibilityChange = () => syncAnimationRunning();
const onReducedMotionChange = () => syncAnimationRunning();
const onMobileChange = () => syncAnimationRunning();
document.addEventListener('visibilitychange', onVisibilityChange);
reducedMotionMq.addEventListener('change', onReducedMotionChange);
mobileMq.addEventListener('change', onMobileChange);
let pageAttrObserver = null;
if (typeof MutationObserver !== 'undefined') {
pageAttrObserver = new MutationObserver(() => syncAnimationRunning());
pageAttrObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-active-page']
});
}
syncAnimationRunning();
const cleanup = () => {
stopAnimation();
if (resizeTimer != null) clearTimeout(resizeTimer);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibilityChange);
reducedMotionMq.removeEventListener('change', onReducedMotionChange);
mobileMq.removeEventListener('change', onMobileChange);
if (pageAttrObserver) pageAttrObserver.disconnect();
};
window.addEventListener('beforeunload', cleanup);
}
addSeasonalOverlay();
+144
View File
@@ -0,0 +1,144 @@
function addSnowverlay() {
// Check if snowverlay canvas is already added
const snowverlayCanvas = document.querySelector('.snowverlay');
if (snowverlayCanvas) {
return;
}
// Configuration constants
const CANVAS_HEIGHT_PX = 160;
const SNOWFLAKE_AMOUNT = 15;
const SNOWFLAKE_SIZE = { min: 10, max: 22 };
const SNOWFLAKE_SPEED = { min: 0.2, max: 0.7 };
// Dynamically inject styles
const style = document.createElement('style');
style.textContent = `
body {
height: 100svh;
margin: 0;
}
.snowverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: ${CANVAS_HEIGHT_PX}px;
pointer-events: none;
z-index: 1000;
}
`;
document.head.appendChild(style);
// Create and inject canvas
const canvas = document.createElement('canvas');
canvas.className = 'snowverlay';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// Resize handler
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = CANVAS_HEIGHT_PX;
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Snowflake factory
const createSnowflake = (isAnimated = true, index = 0) => ({
x: Math.random() * canvas.width,
y: isAnimated
? -20 - (index * canvas.height) / SNOWFLAKE_AMOUNT
: Math.random() * canvas.height,
size: Math.random() * (SNOWFLAKE_SIZE.max - SNOWFLAKE_SIZE.min) + SNOWFLAKE_SIZE.min,
speed: Math.random() * (SNOWFLAKE_SPEED.max - SNOWFLAKE_SPEED.min) + SNOWFLAKE_SPEED.min,
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
drift: Math.random() * 0.4 - 0.2, // Random horizontal drift (wind)
rotation: Math.random() * 360,
rotationSpeed: Math.random() * 2 - 1 // Degrees per frame
});
// Draw single snowflake
const drawSnowflake = (flake) => {
ctx.save();
ctx.translate(flake.x, flake.y);
ctx.rotate((flake.rotation * Math.PI) / 180);
ctx.font = `${flake.size}px sans-serif`;
ctx.fillStyle = `rgba(255, 255, 255, ${flake.opacity ?? (1 - flake.y / canvas.height)})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('❄', 0, 0);
ctx.restore();
};
// Animation control
let animationFrame;
const stopAnimation = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
};
// Static snow renderer
const renderStaticSnow = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
Array.from({ length: SNOWFLAKE_AMOUNT }, () => createSnowflake(false))
.forEach(drawSnowflake);
};
// Animated snow renderer
const startSnowAnimation = () => {
const snowflakes = Array.from(
{ length: SNOWFLAKE_AMOUNT },
(_, i) => createSnowflake(true, i)
);
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
snowflakes.forEach(flake => {
flake.y += flake.speed;
flake.x += flake.drift;
flake.rotation += flake.rotationSpeed;
if (flake.y > canvas.height) {
flake.y = -10;
flake.x = Math.random() * canvas.width;
}
drawSnowflake(flake);
});
animationFrame = requestAnimationFrame(animate);
};
animate();
return stopAnimation;
};
// Motion preference handler
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let currentAnimation = null;
const handleMotionChange = (e) => {
stopAnimation();
if (currentAnimation) currentAnimation();
if (e.matches) {
renderStaticSnow();
currentAnimation = null;
} else {
currentAnimation = startSnowAnimation();
}
};
mediaQuery.addEventListener('change', handleMotionChange);
handleMotionChange(mediaQuery);
// Cleanup on unload
window.addEventListener('beforeunload', () => {
mediaQuery.removeEventListener('change', handleMotionChange);
stopAnimation();
});
}
addSnowverlay();
+144
View File
@@ -0,0 +1,144 @@
function addSnowverlay() {
// Check if snowverlay canvas is already added
const snowverlayCanvas = document.querySelector('.snowverlay');
if (snowverlayCanvas) {
return;
}
// Configuration constants
const CANVAS_HEIGHT_PX = 160;
const SNOWFLAKE_AMOUNT = 15;
const SNOWFLAKE_SIZE = { min: 10, max: 22 };
const SNOWFLAKE_SPEED = { min: 0.2, max: 0.7 };
// Dynamically inject styles
const style = document.createElement('style');
style.textContent = `
body {
height: 100svh;
margin: 0;
}
.snowverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: ${CANVAS_HEIGHT_PX}px;
pointer-events: none;
z-index: 1000;
}
`;
document.head.appendChild(style);
// Create and inject canvas
const canvas = document.createElement('canvas');
canvas.className = 'snowverlay';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// Resize handler
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = CANVAS_HEIGHT_PX;
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Snowflake factory
const createSnowflake = (isAnimated = true, index = 0) => ({
x: Math.random() * canvas.width,
y: isAnimated
? -20 - (index * canvas.height) / SNOWFLAKE_AMOUNT
: Math.random() * canvas.height,
size: Math.random() * (SNOWFLAKE_SIZE.max - SNOWFLAKE_SIZE.min) + SNOWFLAKE_SIZE.min,
speed: Math.random() * (SNOWFLAKE_SPEED.max - SNOWFLAKE_SPEED.min) + SNOWFLAKE_SPEED.min,
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
drift: Math.random() * 0.4 - 0.2, // Random horizontal drift (wind)
rotation: Math.random() * 360,
rotationSpeed: Math.random() * 2 - 1 // Degrees per frame
});
// Draw single snowflake
const drawSnowflake = (flake) => {
ctx.save();
ctx.translate(flake.x, flake.y);
ctx.rotate((flake.rotation * Math.PI) / 180);
ctx.font = `${flake.size}px sans-serif`;
ctx.fillStyle = `rgba(255, 0, 0, ${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();
+144
View File
@@ -0,0 +1,144 @@
function addSnowverlay() {
// Check if snowverlay canvas is already added
const snowverlayCanvas = document.querySelector('.snowverlay');
if (snowverlayCanvas) {
return;
}
// Configuration constants
const CANVAS_HEIGHT_PX = 160;
const SNOWFLAKE_AMOUNT = 15;
const SNOWFLAKE_SIZE = { min: 10, max: 22 };
const SNOWFLAKE_SPEED = { min: 0.2, max: 0.7 };
// Dynamically inject styles
const style = document.createElement('style');
style.textContent = `
body {
height: 100svh;
margin: 0;
}
.snowverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: ${CANVAS_HEIGHT_PX}px;
pointer-events: none;
z-index: 1000;
}
`;
document.head.appendChild(style);
// Create and inject canvas
const canvas = document.createElement('canvas');
canvas.className = 'snowverlay';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// Resize handler
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = CANVAS_HEIGHT_PX;
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Snowflake factory
const createSnowflake = (isAnimated = true, index = 0) => ({
x: Math.random() * canvas.width,
y: isAnimated
? -20 - (index * canvas.height) / SNOWFLAKE_AMOUNT
: Math.random() * canvas.height,
size: Math.random() * (SNOWFLAKE_SIZE.max - SNOWFLAKE_SIZE.min) + SNOWFLAKE_SIZE.min,
speed: Math.random() * (SNOWFLAKE_SPEED.max - SNOWFLAKE_SPEED.min) + SNOWFLAKE_SPEED.min,
opacity: isAnimated ? null : Math.random() * 0.5 + 0.2,
drift: Math.random() * 0.4 - 0.2, // Random horizontal drift (wind)
rotation: Math.random() * 360,
rotationSpeed: Math.random() * 2 - 1 // Degrees per frame
});
// Draw single snowflake
const drawSnowflake = (flake) => {
ctx.save();
ctx.translate(flake.x, flake.y);
ctx.rotate((flake.rotation * Math.PI) / 180);
ctx.font = `${flake.size}px sans-serif`;
ctx.fillStyle = `rgba(255, 255, 255, ${flake.opacity ?? (1 - flake.y / canvas.height)})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('❤', 0, 0);
ctx.restore();
};
// Animation control
let animationFrame;
const stopAnimation = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
};
// Static snow renderer
const renderStaticSnow = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
Array.from({ length: SNOWFLAKE_AMOUNT }, () => createSnowflake(false))
.forEach(drawSnowflake);
};
// Animated snow renderer
const startSnowAnimation = () => {
const snowflakes = Array.from(
{ length: SNOWFLAKE_AMOUNT },
(_, i) => createSnowflake(true, i)
);
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
snowflakes.forEach(flake => {
flake.y += flake.speed;
flake.x += flake.drift;
flake.rotation += flake.rotationSpeed;
if (flake.y > canvas.height) {
flake.y = -10;
flake.x = Math.random() * canvas.width;
}
drawSnowflake(flake);
});
animationFrame = requestAnimationFrame(animate);
};
animate();
return stopAnimation;
};
// Motion preference handler
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let currentAnimation = null;
const handleMotionChange = (e) => {
stopAnimation();
if (currentAnimation) currentAnimation();
if (e.matches) {
renderStaticSnow();
currentAnimation = null;
} else {
currentAnimation = startSnowAnimation();
}
};
mediaQuery.addEventListener('change', handleMotionChange);
handleMotionChange(mediaQuery);
// Cleanup on unload
window.addEventListener('beforeunload', () => {
mediaQuery.removeEventListener('change', handleMotionChange);
stopAnimation();
});
}
addSnowverlay();
File diff suppressed because it is too large Load Diff
+374
View File
@@ -0,0 +1,374 @@
// KefinTweaks Series Library Cache
// Chunked per-user archive of series (no UserData / MediaStreams / Chapters / Trickplay).
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Series Cache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Series Cache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Series Cache]', ...args);
const CACHE_NAME = 'series_library_v2';
const PARTIAL_CACHE_NAME = 'series_library_partial_v2';
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedSeriesCache_';
const SERIES_FIELDS = [
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview',
'PrimaryImageAspectRatio', 'ImageBlurHashes', 'ChildCount', 'RecursiveItemCount'
].join(',');
let series = null;
let isComplete = false;
let fetchPromise = null;
function utils() {
return window.LibraryCacheUtils || {};
}
function getTtl() {
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
}
function getChunkSize() {
return utils().getLibraryCacheSettings?.().seriesChunkSize || 1000;
}
function lastFetchStorageKey() {
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
}
function getLastDateFetched() {
try {
return localStorage.getItem(lastFetchStorageKey()) || null;
} catch (_) {
return null;
}
}
function setLastDateFetched(iso) {
try {
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
} catch (e) {
WARN('Failed to store lastDateFetchedSeriesCache:', e);
}
}
function getApiHelper() {
return window.apiHelper || window.ApiHelper;
}
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
const server = window.ApiClient.serverAddress();
const params = new URLSearchParams({
IncludeItemTypes: 'Series',
Recursive: 'true',
Fields: SERIES_FIELDS,
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: String(startIndex || 0),
Limit: String(limit)
});
Object.entries(extraParams).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
});
return `${server}/Items?${params.toString()}`;
}
async function queryItems(url) {
await window.LibraryCacheUtils?.waitWhileCacheNetworkPaused?.();
const api = getApiHelper();
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
return (await api.getQuery(url, { useCache: false })) || { Items: [], TotalRecordCount: 0 };
}
async function stripChunk(items) {
if (utils().stripItemsAsync) {
return utils().stripItemsAsync(items, 'Series');
}
return (items || []).map((item) => utils().stripSeriesForCache?.(item) || item).filter(Boolean);
}
async function fetchAndCacheSeries() {
const cache = window.IndexedDBCache;
if (!cache) {
ERR('IndexedDBCache unavailable');
return [];
}
const userId = window.ApiClient.getCurrentUserId();
const complete = await cache.get(CACHE_NAME, userId);
if (complete && Array.isArray(complete)) {
series = complete;
isComplete = true;
LOG(`Using complete cached series (${series.length})`);
return series;
}
const chunkSize = getChunkSize();
let startIndex = 0;
let collected = [];
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
collected = partial.items;
startIndex = partial.startIndex || collected.length;
LOG(`Resuming series crawl from index ${startIndex}`);
}
try {
let hasMore = true;
while (hasMore) {
const data = await queryItems(buildItemsUrl({ startIndex, limit: chunkSize }));
const batch = data.Items || [];
collected = collected.concat(await stripChunk(batch));
startIndex += batch.length;
await cache.set(PARTIAL_CACHE_NAME, {
isComplete: false,
startIndex,
items: collected
}, userId, getTtl());
LOG(`Series crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
if (batch.length < chunkSize) hasMore = false;
else await yieldToMain();
}
series = collected;
isComplete = true;
await cache.set(CACHE_NAME, series, userId, getTtl());
await cache.clear(PARTIAL_CACHE_NAME, userId);
setLastDateFetched(new Date().toISOString());
LOG(`Series crawl complete: ${series.length} items`);
return series;
} catch (err) {
ERR('Error fetching series:', err);
return series || collected || [];
}
}
async function syncSeriesCache() {
if (!isComplete || !series) {
LOG('Skip syncSeriesCache: complete cache not ready');
return series || [];
}
const lastFetched = getLastDateFetched();
if (!lastFetched) return series;
const cache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
const chunkSize = getChunkSize();
const server = window.ApiClient.serverAddress();
try {
let startIndex = 0;
let incoming = [];
let hasMore = true;
while (hasMore) {
const data = await queryItems(buildItemsUrl({
startIndex,
limit: chunkSize,
extraParams: {
MinDateLastSaved: lastFetched,
MinDateLastSavedForUser: lastFetched,
SortBy: 'DateLastContentAdded',
SortOrder: 'Descending'
}
}));
const batch = data.Items || [];
incoming = incoming.concat(await stripChunk(batch));
startIndex += batch.length;
if (batch.length < chunkSize) hasMore = false;
}
const beforeIds = new Set((series || []).map((s) => s.Id).filter(Boolean));
let removedIds = [];
if (incoming.length) {
series = utils().upsertById?.(series, incoming) || series;
LOG(`Synced ${incoming.length} series upserts`);
}
const countParams = new URLSearchParams({
IncludeItemTypes: 'Series',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
Limit: '1',
StartIndex: '0'
});
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
const serverCount = countData.TotalRecordCount;
if (typeof serverCount === 'number' && serverCount !== series.length) {
LOG(`Series count mismatch cache=${series.length} server=${serverCount}`);
const serverIds = new Set();
let idx = 0;
let more = true;
while (more) {
const idParams = new URLSearchParams({
IncludeItemTypes: 'Series',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'false',
Limit: String(chunkSize),
StartIndex: String(idx)
});
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
const batch = idData.Items || [];
batch.forEach((item) => {
if (item?.Id) serverIds.add(item.Id);
});
idx += batch.length;
if (batch.length < chunkSize) more = false;
}
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
series = series.filter((s) => s?.Id && serverIds.has(s.Id));
const cachedIds = new Set(series.map((s) => s.Id));
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
for (let i = 0; i < missing.length; i += chunkSize) {
const slice = missing.slice(i, i + chunkSize);
const byIdParams = new URLSearchParams({
Ids: slice.join(','),
Fields: SERIES_FIELDS,
EnableTotalRecordCount: 'false'
});
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
const stripped = await stripChunk(byIdData.Items || []);
series = utils().upsertById?.(series, stripped) || series;
incoming = incoming.concat(stripped);
}
}
await cache.set(CACHE_NAME, series, userId, getTtl());
setLastDateFetched(new Date().toISOString());
if ((incoming.length || removedIds.length) && window.PeopleCache?.applySeriesDelta) {
try {
await window.PeopleCache.applySeriesDelta(incoming, removedIds);
} catch (e) {
WARN('PeopleCache.applySeriesDelta failed:', e);
}
}
return series;
} catch (err) {
ERR('syncSeriesCache failed:', err);
return series || [];
}
}
async function getSeries() {
if (series && isComplete) return series;
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
await fetchAndCacheSeries();
return series || [];
})().finally(() => {
fetchPromise = null;
});
return fetchPromise;
}
async function getCompletedSeries() {
const list = await getSeries();
return (list || []).filter(item => item?.UserData?.Played === true && Number(item.UserData.PlayedPercentage) === 100);
}
function isCacheComplete() {
return isComplete === true && Array.isArray(series);
}
async function getSeriesByProviderIds(ids) {
const list = await getSeries();
const idSet = new Set();
(ids || []).forEach((id) => {
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
});
if (!idSet.size) return [];
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
}
async function initialize() {
await getSeries();
if (isCacheComplete() && getLastDateFetched()) {
await syncSeriesCache();
}
return series || [];
}
async function waitForLoginReady(maxWaitMs = 60000) {
const start = Date.now();
while (!window.userHelper?.waitForLogin) {
if (Date.now() - start >= maxWaitMs) {
ERR('Timed out waiting for userHelper');
return false;
}
await new Promise((r) => setTimeout(r, 100));
}
return window.userHelper.waitForLogin(maxWaitMs);
}
function yieldToMain() {
return new Promise((resolve) => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => resolve(), { timeout: 500 });
} else {
requestAnimationFrame(() => setTimeout(resolve, 0));
}
});
}
/** Defer crawl until idle/hidden/fallback after home has a chance to paint. */
let bootstrapStarted = false;
let bootstrapScheduled = false;
async function bootstrap() {
if (bootstrapStarted) return;
bootstrapStarted = true;
try {
await waitForLoginReady();
await initialize();
} catch (e) {
ERR('Bootstrap failed:', e);
}
}
function scheduleBootstrap() {
if (bootstrapScheduled) return;
bootstrapScheduled = true;
const utils = window.LibraryCacheUtils;
if (utils?.scheduleIdleBootstrap) {
utils.scheduleIdleBootstrap({
waitForLogin: waitForLoginReady,
onReady: () => { bootstrap(); }
});
return;
}
waitForLoginReady().then(() => bootstrap());
}
window.SeriesCache = {
init: initialize,
getSeries,
getCompletedSeries,
getSeriesByProviderIds,
syncSeriesCache,
fetchAndCacheSeries,
isComplete: isCacheComplete,
getLastDateFetched,
bootstrap,
scheduleBootstrap,
refresh: async () => {
const cache = window.IndexedDBCache;
const userId = window.ApiClient?.getCurrentUserId?.();
if (cache) {
await cache.clear(CACHE_NAME, userId);
await cache.clear(PARTIAL_CACHE_NAME, userId);
}
series = null;
isComplete = false;
return fetchAndCacheSeries();
}
};
scheduleBootstrap();
LOG('Module loaded');
})();
+16 -2
View File
@@ -1,3 +1,17 @@
.nextUpSection {
display: none;
}
display: none;
}
.series-episodes-section .sectionTitleContainer,
.series-episodes-section .emby-scroller {
padding-left: 0;
}
#itemDetailPage[data-series-episodes-item-id] .detailSection {
overflow: hidden;
}
.series-episodes-section {
padding-left: 0 !important;
margin: 0 !important;
}
+571 -505
View File
File diff suppressed because it is too large Load Diff
+104 -25
View File
@@ -9,6 +9,10 @@
const WARN = (...args) => console.warn('[KefinTweaks SeriesInfo]', ...args);
const ERR = (...args) => console.error('[KefinTweaks SeriesInfo]', ...args);
const state = {
use24HourTimeFormat: false,
}
/**
* Parses a runtime string (e.g., "42m", "1h 5m") into total minutes
* @param {string} runtimeStr - Runtime string like "42m" or "1h 5m"
@@ -147,7 +151,7 @@
const timeStr = endTime.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: !state.use24HourTimeFormat
});
// Check if end date is the same as current day
@@ -186,7 +190,7 @@
const timeStr = endTime.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: !state.use24HourTimeFormat
});
// Check if end date is the same as current day
@@ -206,30 +210,94 @@
}
}
const seriesSeasonsCountCache = new Map();
const seriesSeasonEpisodeCountCache = new Map();
async function getSeasonsCount(item, includeSpecials = false) {
if (!seriesSeasonsCountCache.has(item.Id)) {
// Get Seasons from apiHelper.getQuery
const response = await apiHelper.getQuery(`${ApiClient.serverAddress()}/Shows/${item.Id}/Seasons`, { useCache: true });
if (!response.data) {
response.data = await response.dataPromise;
}
const data = response.data;
let seasons = data.Items || data ||[];
if (!includeSpecials) {
seasons = seasons.filter(season => season.IndexNumber !== 0);
}
seriesSeasonsCountCache.set(item.Id, seasons.length);
}
return seriesSeasonsCountCache.get(item.Id);
}
async function getEpisodesCount(seriesId, seasonIndex = null, includeSpecials = false) {
if (!seriesSeasonEpisodeCountCache.has(seriesId)) {
const response = await apiHelper.getQuery(`${ApiClient.serverAddress()}/Shows/${seriesId}/Episodes`, { useCache: true });
if (!response.data) {
response.data = await response.dataPromise;
}
const data = response.data;
let episodes = data.Items || data || [];
// Create a Season:EpisodesCount map
const seasonEpisodeCountMap = new Map();
episodes.forEach(episode => {
const seasonIndex = episode.ParentIndexNumber;
if (!seasonEpisodeCountMap.has(seasonIndex)) {
seasonEpisodeCountMap.set(seasonIndex, 0);
}
seasonEpisodeCountMap.set(seasonIndex, seasonEpisodeCountMap.get(seasonIndex) + 1);
});
seriesSeasonEpisodeCountCache.set(seriesId, seasonEpisodeCountMap);
}
// Return the episodes count for the season index
if (seasonIndex !== null && !isNaN(seasonIndex)) {
return seriesSeasonEpisodeCountCache.get(seriesId).get(seasonIndex) || 0;
}
// Return the total episodes count for all seasons
if (!includeSpecials) {
// Ignore season 0
const specialsCount = seriesSeasonEpisodeCountCache.get(seriesId).get(0) || 0;
return seriesSeasonEpisodeCountCache.get(seriesId).values().reduce((a, b) => a + b, 0) - specialsCount;
}
return seriesSeasonEpisodeCountCache.get(seriesId).values().reduce((a, b) => a + b, 0);
}
/**
* Adds series info to the itemMiscInfo container
* @param {Object} item - Series item object
* @param {HTMLElement} miscInfoContainer - The .itemMiscInfo container
*/
function addSeriesInfo(item, miscInfoContainer) {
async function addSeriesInfo(item, miscInfoContainer) {
// Check if already added
if (miscInfoContainer.querySelector('.kt-series-info')) {
if (miscInfoContainer.dataset.seriesInfo) {
LOG('Series info already added, skipping');
return;
}
const infoContainer = document.createElement('div');
infoContainer.className = 'kt-series-info';
infoContainer.style.display = 'flex';
infoContainer.style.margin = '0 1em 0 0';
miscInfoContainer.dataset.seriesInfo = 'true';
const seasonsCount = await getSeasonsCount(item);
const episodesCount = await getEpisodesCount(item.Id);
let childCount = item.ChildCount || 0;
// Seasons count (only show if more than 1 season)
const childCount = item.ChildCount || 0;
if (childCount > 1) {
const seasonsDiv = document.createElement('div');
seasonsDiv.className = 'mediaInfoItem';
seasonsDiv.textContent = `${childCount} Seasons`;
infoContainer.appendChild(seasonsDiv);
seasonsDiv.textContent = `${seasonsCount} Seasons`;
miscInfoContainer.appendChild(seasonsDiv);
}
// Episodes count
@@ -237,8 +305,8 @@
if (RecursiveItemCount > 0) {
const episodesDiv = document.createElement('div');
episodesDiv.className = 'mediaInfoItem';
episodesDiv.textContent = `${RecursiveItemCount} ${RecursiveItemCount === 1 ? 'Episode' : 'Episodes'}`;
infoContainer.appendChild(episodesDiv);
episodesDiv.textContent = `${episodesCount} ${episodesCount === 1 ? 'Episode' : 'Episodes'}`;
miscInfoContainer.appendChild(episodesDiv);
}
// End time - RunTimeTicks is for first episode, multiply by episode count for total
@@ -248,16 +316,12 @@
if (endTimeStr) {
const endTimeDiv = document.createElement('div');
endTimeDiv.className = 'mediaInfoItem';
endTimeDiv.textContent = `Ends at: ${endTimeStr}`;
infoContainer.appendChild(endTimeDiv);
endTimeDiv.textContent = `Ends at ${endTimeStr}`;
miscInfoContainer.appendChild(endTimeDiv);
}
}
// Append to miscInfoContainer
if (infoContainer.children.length > 0) {
miscInfoContainer.appendChild(infoContainer);
LOG('Added series info to itemMiscInfo container');
}
LOG('Added series info to itemMiscInfo container');
}
/**
@@ -274,17 +338,19 @@
}
// Check if already added
if (miscInfoContainer.querySelector('.kt-season-info')) {
if (miscInfoContainer.dataset.seasonInfo) {
LOG('Season info already added, skipping');
return;
}
miscInfoContainer.dataset.seasonInfo = 'true';
const infoContainer = document.createElement('div');
infoContainer.className = 'kt-season-info';
infoContainer.style.display = 'flex';
// Episodes count
const episodeCount = item.ChildCount || 0;
const episodeCount = await getEpisodesCount(item.SeriesId, item.IndexNumber);
if (episodeCount > 0) {
const episodesDiv = document.createElement('div');
episodesDiv.className = 'mediaInfoItem';
@@ -299,7 +365,7 @@
if (endTimeStr) {
const endTimeDiv = document.createElement('div');
endTimeDiv.className = 'mediaInfoItem';
endTimeDiv.textContent = `Ends at: ${endTimeStr}`;
endTimeDiv.textContent = `Ends at ${endTimeStr}`;
infoContainer.appendChild(endTimeDiv);
}
}
@@ -338,7 +404,7 @@
if (item.Type === 'Series') {
addSeriesInfo(item, miscInfoContainer);
} else if (item.Type === 'Season') {
await addSeasonInfo(item, miscInfoContainer, activePage);
addSeasonInfo(item, miscInfoContainer, activePage);
}
}
@@ -352,6 +418,19 @@
return;
}
// Get the use24HourTime setting from the configuration
const config = window.KefinTweaksConfig || {};
let seriesInfoConfig = config.seriesInfo || null;
if (seriesInfoConfig === null) {
// Use the browser's default time format
seriesInfoConfig = {
use24HourTimeFormat: Intl.DateTimeFormat().resolvedOptions().timeStyle === 'short'
};
}
state.use24HourTimeFormat = seriesInfoConfig.use24HourTimeFormat === true;
LOG('Registering series info handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
+512 -64
View File
@@ -75,6 +75,91 @@
name: 'Animated Overlay',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-AnimatedOverlay.css',
enabled: false
},
{
name: 'Left Aligned Font / Poster Spacing / Watched Indicators',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-FontTweaks.css',
enabled: false
},
{
name: 'Wrap Items In Home Sections',
url: `${getKefinTweaksRoot()}skins/optional/global/wrapHomeScrollerItems.css`,
enabled: false
},
{
name: 'Genre Thumbnails',
url: `${getKefinTweaksRoot()}skins/optional/global/genreThumbnails.css`,
enabled: false
},
{
name: 'Upcoming Calendar View',
url: `${getKefinTweaksRoot()}skins/optional/global/upcomingCalendar.css`,
enabled: false
},
{
name: 'Hide Title If Logo Present',
url: `${getKefinTweaksRoot()}skins/optional/global/hideTitleIfLogoPresent.css`,
enabled: false
},
{
name: 'Sort People With Images First',
url: `${getKefinTweaksRoot()}skins/optional/global/sortPeopleWithImagesFirst.css`,
enabled: false
},
{
name: 'No Clear Art on Spotlights',
url: `${getKefinTweaksRoot()}skins/optional/global/noClearArtOnSpotlights.css`,
enabled: false
},
{
name: 'Show Section Controls Always',
url: `${getKefinTweaksRoot()}skins/optional/global/sectionControlsAlways.css`,
enabled: false
},
{
name: 'Studio Thumbnails',
url: `${getKefinTweaksRoot()}skins/optional/global/studioThumbnails.css`,
enabled: false
},
{
name: 'Spotlight Controls On Hover Only',
url: `${getKefinTweaksRoot()}skins/optional/global/spotlightControlsOnHover.css`,
enabled: false
},
{
name: 'Darken Background Images',
url: `${getKefinTweaksRoot()}skins/optional/global/darkenBackgroundImages.css`,
enabled: false
},
{
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
},
{
name: 'Hide Seasonal Background Images',
url: `${getKefinTweaksRoot()}skins/optional/global/seasonalBackgroundHide.css`,
enabled: false
},
{
name: 'Disable Seasonal Animations',
url: `${getKefinTweaksRoot()}skins/optional/global/seasonalAnimationDisable.css`,
enabled: false
},
{
name: 'Unhide Native User Home Controls on User > Home settings page',
url: `${getKefinTweaksRoot()}skins/optional/global/unhideNativeUserHomeControls.css`,
enabled: false
}
],
@@ -90,7 +175,7 @@
// Each skin should have an author field indicating who created the skin
skins: [
{
name: 'Default',
name: 'Jellyfin Default',
author: 'Jellyfin',
url: null, // null means no additional CSS (default Jellyfin theme)
colorSchemes: []
@@ -103,14 +188,16 @@
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css',
`${getKefinTweaksRoot()}skins/elegant-kefin.css`
`${getKefinTweaksRoot()}skins/elegant-kefin.css`,
`${getKefinTweaksRoot()}skins/fin-kefin-10.10.css`
]
},
{
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`,
`${getKefinTweaksRoot()}skins/elegant-kefin.css`,,
`${getKefinTweaksRoot()}skins/elegant-kefin-10.11.css`,
`${getKefinTweaksRoot()}skins/fin-kefin-10.11.css`
]
}
@@ -159,11 +246,19 @@
author: 'KartoffelChipss',
url: [
{
majorServerVersions: [10, 11, 12],
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@latest/theme/neutralfin-minified.css',
'https://cdn.jsdelivr.net/gh/KartoffelChipss/Jellyfin-Lucide@main/theme/jellyfin-lucide.css',
`${getKefinTweaksRoot()}skins/neutralfin-kefin.css`
`${getKefinTweaksRoot()}skins/neutralfin-kefin.css`,
`${getKefinTweaksRoot()}skins/fin-kefin-10.10.css`
]
},
{
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@latest/theme/neutralfin-minified.css',
`${getKefinTweaksRoot()}skins/neutralfin-kefin.css`,
`${getKefinTweaksRoot()}skins/fin-kefin-10.11.css`
]
}
],
@@ -203,6 +298,16 @@
name: 'Solid App Bar',
url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/solidAppBar.css`,
enabled: false
},
{
name: 'Jellyfin Lucide Icons (enable with Lucide Icons for KefinTweaks)',
url: 'https://cdn.jsdelivr.net/gh/KartoffelChipss/Jellyfin-Lucide@main/theme/jellyfin-lucide.css',
enabled: false
},
{
name: 'Lucide Icons for KefinTweaks (enable with Jellyfin Lucide Icons)',
url: `${getKefinTweaksRoot()}skins/optional/NeutralFin/lucide-icons-kefin.css`,
enabled: false
}
]
},
@@ -211,11 +316,21 @@
author: 'KBH-Reeper',
url: [
{
majorServerVersions: [10, 11, 12],
majorServerVersions: [10],
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',
`${getKefinTweaksRoot()}skins/glassfin-kefin.css`
`${getKefinTweaksRoot()}skins/glassfin-kefin.css`,
`${getKefinTweaksRoot()}skins/fin-kefin-10.10.css`
]
},
{
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',
`${getKefinTweaksRoot()}skins/glassfin-kefin.css`,
`${getKefinTweaksRoot()}skins/fin-kefin-10.11.css`
]
}
],
@@ -287,32 +402,91 @@
}
]
},
{
name: 'NetFin',
author: 'ya0903',
url: [
{
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/ya0903/NetFin@main/netfin.css',
`${getKefinTweaksRoot()}skins/netfin-kefin.css`,
]
},
],
colorSchemes: [],
optionalIncludes: [
{
name: 'Mobile Buttons Size Adjustment',
url: `${getKefinTweaksRoot()}skins/optional/NetFin/mobileButtonsSizeAdjustment.css`,
enabled: false
},
{
name: 'Disable Card Hover Effect',
url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/cardHoverEffect.css`,
enabled: false
},
{
name: 'Center Overlay Play Button',
url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/centerPlayButton.css`,
enabled: false
},
{
name: 'Extra Overlay Buttons',
url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/extraOverlayButtons.css`,
enabled: false
},
{
name: 'Library Label Visibility',
url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/libraryLabelVisibility.css`,
enabled: false
},
{
name: 'Solid App Bar',
url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/solidAppBar.css`,
enabled: false
}
]
},
{
name: 'Jellypane',
author: 'tedhinklater',
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, 12],
urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane.css']
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']
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism7.css',
`${getKefinTweaksRoot()}skins/finimalism-kefin.css`,
`${getKefinTweaksRoot()}skins/finimalism-7-kefin.css`
]
},
{
majorServerVersions: [11, 12],
urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism10.11.css']
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism10.11.css',
`${getKefinTweaksRoot()}skins/finimalism-kefin.css`,
`${getKefinTweaksRoot()}skins/finimalism-7-kefin.css`
]
}
],
colorSchemes: [],
@@ -329,19 +503,56 @@
}
]
},
{
name: 'Finimalism 11',
author: 'tedhinklater',
url: [
{
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/Finimalism11.css',
`${getKefinTweaksRoot()}skins/finimalism-11-kefin.css`
]
}
],
colorSchemes: [],
optionalIncludes: [
{
name: 'Horizontal Scrolling on Home Page',
url: 'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/scrollers-11.css',
enabled: false
}
]
},
{
name: 'Finimalism 12',
author: 'tedhinklater',
url: [
{
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/Finimalism12.css',
`${getKefinTweaksRoot()}skins/finimalism-12-kefin.css`
]
}
],
},
{
name: 'Jellyfish',
author: 'n00bcodr',
url: [
{
majorServerVersions: [10],
urls: ['https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css']
urls: ['https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css',
`${getKefinTweaksRoot()}skins/jellyfish-kefin.css`
]
},
{
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'
'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/10.11_fixes.css',
`${getKefinTweaksRoot()}skins/jellyfish-kefin.css`
]
}
],
@@ -429,7 +640,10 @@
url: [
{
majorServerVersions: [10, 11, 12],
urls: ['https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css']
urls: [
'https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css',
`${getKefinTweaksRoot()}skins/glassmorphism-kefin.css`
]
}
],
colorSchemes: []
@@ -440,6 +654,10 @@
url: [
{
majorServerVersions: [10],
urls: ['https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/graveyard/zombie-min-git.css']
},
{
majorServerVersions: [12],
urls: ['https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/zombie_revived.css']
}
],
@@ -491,7 +709,7 @@
]
},
{
majorServerVersions: [11, 12],
majorServerVersions: [11],
urls: [
'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css',
`${getKefinTweaksRoot()}skins/jamfin-kefin.css`
@@ -664,11 +882,6 @@
name: 'Nyan Progress Bar',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/NyanProgress.css',
enabled: false
},
{
name: 'Left Aligned Font / Poster Spacing / Watched Indicators',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-FontTweaks.css',
enabled: false
}
],
colorSchemes: [
@@ -748,113 +961,348 @@
},
{
name: 'Round UI Corners',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/rounding.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/rounding.css'
},
{
name: 'Round UI with circle accent on hover',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/rounding_circlehover.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/rounding_circlehover.css'
},
{
name: 'Login Screen Minimal',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/login/login_minimalistic.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/login/login_minimalistic.css'
},
{
name: 'Login Screen Full',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/login/login_frame.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/login/login_frame.css'
},
{
name: 'Input Fields with Border',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/fields/fields_border.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/fields/fields_border.css'
},
{
name: 'Input Fields without Border',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/fields/fields_noborder.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/fields/fields_noborder.css'
},
{
name: 'Floating Watched Indicators',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/cornerindicator/indicator_floating.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/cornerindicator/indicator_floating.css'
},
{
name: 'Corner Watched Indicators',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/cornerindicator/indicator_corner.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/cornerindicator/indicator_corner.css'
},
{
name: 'Simple Titles',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_simple.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_simple.css'
},
{
name: 'Logo Titles',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_simple-logo.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_simple-logo.css'
},
{
name: 'Banner Titles',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_banner.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_banner.css'
},
{
name: 'Banner Logo Titles',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_banner-logo.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_banner-logo.css'
},
{
name: 'Progress Bar',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/overlayprogress.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/overlayprogress.css'
},
{
name: 'Bottom Progress Bar',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/bottombarprogress.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/bottombarprogress.css'
},
{
name: 'Floating Progress Bar',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/progress/floating.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/progress/floating.css'
},
{
name: 'Hoverglow Effect',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/effects/hoverglow.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/effects/hoverglow.css'
},
{
name: 'Glassy Effect',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/effects/glassy.css',
enabled: false
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/effects/glassy.css'
},
{
name: 'Mobile Backdrop "Hack"',
url: 'https://ctalvio.github.io/Monochromic/backdrop-hack_style.css',
url: 'https://ctalvio.github.io/Monochromic/backdrop-hack_style.css'
}
],
colorSchemes: [
{
name: 'Default',
url: '',
},
{
name: 'Monochromic',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/monochromic_preset.css'
},
{
name: 'Kaleidochromic',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/kaleidochromic_preset.css'
},
{
name: 'Novachromic',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/novachromic_preset.css'
}
]
},
{
name: 'Evergreen',
author: 'everviolet',
url: [
{
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/everviolet/jellyfin@latest/themes/evergarden-winter.css',
]
},
],
colorSchemes: [
{
name: 'Winter',
url: ''
},
{
name: 'Fall',
url: 'https://cdn.jsdelivr.net/gh/everviolet/jellyfin@latest/themes/evergarden-fall.css'
},
{
name: 'Spring',
url: 'https://cdn.jsdelivr.net/gh/everviolet/jellyfin@latest/themes/evergarden-spring.css'
},
{
name: 'Summer',
url: 'https://cdn.jsdelivr.net/gh/everviolet/jellyfin@latest/themes/evergarden-summer.css'
},
]
},
{
name: 'Finity',
author: 'prism2001',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/prism2001/finity@main/minimal/finity-minimal.css',
]
},
],
},
{
name: 'infinitv',
author: 'buesche87',
url: [
{
majorServerVersions: [10, 11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/buesche87/infinitv@latest/infinitv.css',
`${getKefinTweaksRoot()}skins/infinitv-kefin.css`
]
},
],
},
{
name: 'JellySkin',
author: 'prayag17',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/main.css',
]
},
],
optionalIncludes: [
{
name: 'Logos',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/logo.css',
enabled: false
},
{
name: 'Remove Backdrop Filter',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/addons/improvePerformance/removeBackdropFilter.css',
enabled: false
},
{
name: 'Remove Fading Scroll',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/addons/improvePerformance/removeFadingScroll.css',
enabled: false
},
{
name: 'Horizontal My Media',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/addons/horizontalMyMedia.css',
enabled: false
},
{
name: 'Video Theme Blur',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/addons/videoThemeBlur.css',
enabled: false
}
],
colorSchemes: [
{
name: 'Monochromic',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/monochromic_preset.css',
name: 'Default',
url: ''
},
{
name: 'Mauve',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/addons/gradients/mauve.css'
},
{
name: 'Night Sky',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/addons/gradients/nightSky.css'
},
{
name: 'Sea',
url: 'https://cdn.jsdelivr.net/npm/jellyskin@latest/dist/addons/gradients/sea.css'
},
]
},
{
name: 'Better Jellyfin UI',
author: 'tromoSM',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/theme.css',
]
},
{
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/theme.css',
]
},
],
optionalIncludes: [
{
name: 'Floating Header',
url: 'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/options/Floating-header.css',
enabled: false
},
{
name: 'Kaleidochromic',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/kaleidochromic_preset.css',
name: 'High Contrast Interface',
url: 'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/options/High-contrast-interface.css',
enabled: false
},
{
name: 'Scale up animation on hover card',
url: 'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/options/Scale-up-animation-on-hover-card.css',
enabled: false
},
{
name: 'Novachromic',
url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/novachromic_preset.css',
name: 'No liquid glass borders',
url: 'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/options/no-liquid-glass-borders.css',
enabled: false
},
{
name: 'Alternative navigation bar',
url: 'https://cdn.jsdelivr.net/gh/tromoSM/better-jellyfin-ui@main/options/alternative-navigation-bar.css',
enabled: false
},
],
},
{
name: 'Jellyfin Better Styles',
author: 'Tetrax-10',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/Tetrax-10/jellyfin-better-styles@dist/theme.css',
]
},
{
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/Tetrax-10/jellyfin-better-styles@dist/theme.css',
`${getKefinTweaksRoot()}skins/better-styles-kefin-11.css`
]
},
],
},
{
name: 'Zesty Theme',
author: 'stpnwf',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@legacy/v10.10/theme.css',
`${getKefinTweaksRoot()}skins/zesty-kefin.css`
]
},
{
majorServerVersions: [11, 12],
urls: [
'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/theme.css',
`${getKefinTweaksRoot()}skins/zesty-kefin.css`
]
},
],
optionalIncludes: [
{
name: 'Alternative Login Background',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/login-alt.css',
enabled: false
}
],
colorSchemes: [
{
name: 'Cyan',
url: ''
},
{
name: 'Fall',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/colorschemes/blue.css'
},
{
name: 'Spring',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/colorschemes/coral.css'
},
{
name: 'Summer',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/colorschemes/gray.css'
},
{
name: 'Green',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/colorschemes/green.css'
},
{
name: 'Purple',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/colorschemes/purple.css'
},
{
name: 'Red',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/colorschemes/red.css'
},
{
name: 'Yellow',
url: 'https://cdn.jsdelivr.net/gh/stpnwf/ZestyTheme@latest/colorschemes/yellow.css'
}
]
}
},
{
//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',
`${getKefinTweaksRoot()}skins/abyss-kefin.css`
]
},
],
},
// Add more skins here as needed
]
};
+231 -270
View File
@@ -19,61 +19,37 @@
const STORAGE_KEY = 'kefinTweaks_selectedSkin';
const THEME_STORAGE_KEY = 'kefinTweaks_selectedTheme';
const COLOR_SCHEMES_STORAGE_KEY = 'kefinTweaks_selectedColorScheme';
// Cached server version for synchronous access
let cachedServerVersion = null;
let versionPollingStarted = false;
/** Built-in no-CSS skin in skinConfig.js (was formerly named "Default"). */
const BUILTIN_DEFAULT_SKIN_NAME = 'Jellyfin Default';
const LEGACY_DEFAULT_SKIN_NAME = 'Default';
function getMajorServerVersion(version) {
const versionParts = version.split('.');
const firstPartInt = parseInt(versionParts[0], 10);
if (firstPartInt >= 12) {
return 12;
}
function normalizeSkinName(name) {
if (!name) return name;
return name === LEGACY_DEFAULT_SKIN_NAME ? BUILTIN_DEFAULT_SKIN_NAME : name;
}
if (versionParts.length >= 2) {
const majorVersion = parseInt(versionParts[1], 10);
if (!isNaN(majorVersion)) {
return majorVersion;
}
/** localStorage selection, migrating legacy "Default" → "Jellyfin Default". */
function getStoredOrDefaultSkinName() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return getDefaultSkinName();
const normalized = normalizeSkinName(raw);
if (normalized !== raw) {
try {
localStorage.setItem(STORAGE_KEY, normalized);
} catch (_) { /* ignore */ }
}
return null;
return normalized;
}
/**
* Get the current major server 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
*/
// Cached server version for synchronous access — prefer window.KefinTweaks
let cachedServerVersion = null;
async function getCurrentMajorServerVersion() {
try {
// If we have a cached value, return it
if (cachedServerVersion !== null) {
try {
if (window.KefinTweaks?.getJellyfinMajorVersion) {
cachedServerVersion = await window.KefinTweaks.getJellyfinMajorVersion();
return cachedServerVersion;
}
if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) {
return null;
}
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion);
return 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));
}
}
cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion);
return cachedServerVersion;
} catch (error) {
WARN('Error getting server version:', error);
@@ -96,12 +72,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) {
@@ -151,11 +130,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(', ')})`);
}
@@ -168,12 +154,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}`);
@@ -183,7 +166,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(', ')})`);
}
@@ -359,43 +342,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: { 'Authorization': apiHelper.getAuthHeader() }
});
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: { 'Authorization': apiHelper.getAuthHeader() }
});
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) {
@@ -426,7 +389,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
const saveResponse = await fetch(configUrl, {
method: 'POST',
headers: {
'Authorization': apiHelper.getAuthHeader(),
'Authorization': window.apiHelper.getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify(injectorConfig)
@@ -445,6 +408,10 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
}
}
// Legacy: strip exact default duplicates / old Jellypane URLs from admin skins and persist.
// Disabled — can wipe enable/disable stubs. Keep helpers + body for possible restore.
const RUN_LEGACY_SKIN_DEDUP_CLEANUP = false;
// Load and merge skin configurations
async function loadSkinConfig() {
// Load default skins from skinConfig.js (for merging)
@@ -455,73 +422,75 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
// Load admin-configured skins from window.KefinTweaksConfig (from JS Injector)
let adminSkins = window.KefinTweaksConfig?.skins || [];
// Clean up admin skins: remove exact duplicates of legacy defaults and old Jellypane format
const cleanedAdminSkins = [];
const skinsToRemove = new Set();
adminSkins.forEach(adminSkin => {
// Check for exact match with any legacy default skin (canonical comparison)
const matchingLegacyDefault = legacyDefaults.find(legacySkin => {
if (adminSkin.name !== legacySkin.name) {
return false;
if (RUN_LEGACY_SKIN_DEDUP_CLEANUP) {
// Clean up admin skins: remove exact duplicates of legacy defaults and old Jellypane format
const cleanedAdminSkins = [];
const skinsToRemove = new Set();
adminSkins.forEach(adminSkin => {
// Check for exact match with any legacy default skin (canonical comparison)
const matchingLegacyDefault = legacyDefaults.find(legacySkin => {
if (adminSkin.name !== legacySkin.name) {
return false;
}
const matches = skinsMatchExactly(adminSkin, legacySkin);
if (!matches) {
// Debug: Log why skins with same name don't match
LOG(`[Skin Match Debug] Skin '${adminSkin.name}' found in both configs but URLs/colorSchemes differ. Admin: ${JSON.stringify({url: adminSkin.url, colorSchemes: adminSkin.colorSchemes})}, Legacy Default: ${JSON.stringify({url: legacySkin.url, colorSchemes: legacySkin.colorSchemes})}`);
}
return matches;
});
if (matchingLegacyDefault) {
// Exact match found - remove from admin config
LOG(`Removing exact duplicate of legacy default skin '${adminSkin.name}' from admin config`);
skinsToRemove.add(adminSkin.name);
return; // Skip adding to cleanedAdminSkins
}
const matches = skinsMatchExactly(adminSkin, legacySkin);
if (!matches) {
// Debug: Log why skins with same name don't match
LOG(`[Skin Match Debug] Skin '${adminSkin.name}' found in both configs but URLs/colorSchemes differ. Admin: ${JSON.stringify({url: adminSkin.url, colorSchemes: adminSkin.colorSchemes})}, Legacy Default: ${JSON.stringify({url: legacySkin.url, colorSchemes: legacySkin.colorSchemes})}`);
// Special case: Jellypane with old URL format
if (isJellypaneWithOldUrlFormat(adminSkin)) {
LOG(`Removing Jellypane with old URL format from admin config`);
skinsToRemove.add(adminSkin.name);
return; // Skip adding to cleanedAdminSkins
}
return matches;
// Keep this admin skin
cleanedAdminSkins.push(adminSkin);
});
if (matchingLegacyDefault) {
// Exact match found - remove from admin config
LOG(`Removing exact duplicate of legacy default skin '${adminSkin.name}' from admin config`);
skinsToRemove.add(adminSkin.name);
return; // Skip adding to cleanedAdminSkins
}
// Special case: Jellypane with old URL format
if (isJellypaneWithOldUrlFormat(adminSkin)) {
LOG(`Removing Jellypane with old URL format from admin config`);
skinsToRemove.add(adminSkin.name);
return; // Skip adding to cleanedAdminSkins
}
// Keep this admin skin
cleanedAdminSkins.push(adminSkin);
});
// If we removed any skins, create backup and save cleaned config
if (skinsToRemove.size > 0 && window.KefinTweaksConfig) {
// Create a deep copy of the original config for backup
const originalConfig = JSON.parse(JSON.stringify(window.KefinTweaksConfig));
// Create backup before modifying
const backupCreated = await createConfigBackup(originalConfig);
if (!backupCreated) {
WARN('Failed to create backup, but continuing with cleanup');
}
// Update KefinTweaksConfig with cleaned skins
window.KefinTweaksConfig.skins = cleanedAdminSkins;
// Save the cleaned config back to JS Injector
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
const saveSuccess = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(window.KefinTweaksConfig, { waitForLogin: false });
if (saveSuccess) {
LOG(`Successfully saved cleaned config (removed ${skinsToRemove.size} duplicate/old-format skin(s): ${Array.from(skinsToRemove).join(', ')})`);
} else {
WARN('Failed to save cleaned config to JS Injector');
// If we removed any skins, create backup and save cleaned config
if (skinsToRemove.size > 0 && window.KefinTweaksConfig) {
// Create a deep copy of the original config for backup
const originalConfig = JSON.parse(JSON.stringify(window.KefinTweaksConfig));
// Create backup before modifying
const backupCreated = await createConfigBackup(originalConfig);
if (!backupCreated) {
WARN('Failed to create backup, but continuing with cleanup');
}
} else {
WARN('KefinTweaksUtils.saveConfigToJavaScriptInjector not available, cannot save cleaned config');
// Update KefinTweaksConfig with cleaned skins
window.KefinTweaksConfig.skins = cleanedAdminSkins;
// Save the cleaned config back to JS Injector
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
const saveSuccess = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(window.KefinTweaksConfig, { waitForLogin: false });
if (saveSuccess) {
LOG(`Successfully saved cleaned config (removed ${skinsToRemove.size} duplicate/old-format skin(s): ${Array.from(skinsToRemove).join(', ')})`);
} else {
WARN('Failed to save cleaned config to JS Injector');
}
} else {
WARN('KefinTweaksUtils.saveConfigToJavaScriptInjector not available, cannot save cleaned config');
}
LOG(`Cleaned up ${skinsToRemove.size} duplicate/old-format skin(s) from admin config: ${Array.from(skinsToRemove).join(', ')}`);
}
LOG(`Cleaned up ${skinsToRemove.size} duplicate/old-format skin(s) from admin config: ${Array.from(skinsToRemove).join(', ')}`);
adminSkins = cleanedAdminSkins;
}
adminSkins = cleanedAdminSkins;
// Merge: Start with defaults, then override/add from admin config
// Admin config takes precedence for duplicate names
@@ -557,7 +526,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
if (SKINS_CONFIG.length === 0) {
SKINS_CONFIG = [
{
name: 'Default',
name: BUILTIN_DEFAULT_SKIN_NAME,
url: null
}
];
@@ -624,6 +593,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() {
@@ -741,9 +711,11 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
* @param {boolean} defaultEnabled - Default enabled state from config
* @returns {boolean} Whether the include is enabled
*/
function isOptionalIncludeEnabled(key, defaultEnabled = false) {
function isOptionalIncludeEnabled(key, defaultEnabled = false, userConfig = null) {
// Check user localStorage first (highest priority)
const userConfig = getUserConfig();
if (!userConfig) {
userConfig = getUserConfig();
}
const userOptionalIncludes = userConfig.optionalIncludes || [];
// Check if user has explicitly set this key
@@ -932,6 +904,8 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
// Get merged global optional includes
const globalOptionalIncludes = getMergedGlobalOptionalIncludes();
const userConfig = getUserConfig();
// Load enabled global optional includes
// This explicitly checks user localStorage config first via isOptionalIncludeEnabled
@@ -940,7 +914,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
const key = generateOptionalIncludeKey('global', author, include.url);
// Check enabled state (User Config > Admin Config > Default)
const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false);
const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false, userConfig);
if (isEnabled && include.url) {
loadOptionalIncludeCSS(include.url, 'global');
@@ -954,7 +928,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
const key = generateOptionalIncludeKey(skin.name, author, include.url);
// Check enabled state (User Config > Admin Config > Default)
const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false);
const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false, userConfig);
if (isEnabled && include.url) {
loadOptionalIncludeCSS(include.url, skin.name);
@@ -1000,8 +974,9 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
* @param {string} skinName - The skin name
*/
function loadOptionalIncludeCSS(url, skinName) {
// Check if this CSS is already loaded
if (loadedOptionalIncludesUrls.has(url)) {
// Check if this CSS is already loaded in the DOM
const existingLink = document.querySelector(`link[href="${url}"]`);
if (existingLink) {
LOG(`Optional include CSS already loaded: ${url}`);
return;
}
@@ -1044,7 +1019,6 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
// Store unregister functions for handlers
let unregisterDisplayPreferencesHandler = null;
let unregisterAnyPageHandler = null;
/**
* Create and show a tooltip
@@ -1142,7 +1116,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
* @returns {boolean} True if verification passes, false otherwise
*/
function verifySkinApplication() {
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
if (!selectedSkin) {
@@ -1198,16 +1172,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();
@@ -1243,7 +1237,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
@@ -1256,11 +1250,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');
}
@@ -1283,72 +1274,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);
}
}
@@ -1356,8 +1311,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');
@@ -1365,9 +1322,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);
}
/**
@@ -1405,7 +1368,11 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
dialog.style.position = 'absolute';
dialog.style.minWidth = '280px';
dialog.style.pointerEvents = 'auto';
dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
if (typeof window.ModalSystem?.applyOpenAnimation === 'function') {
window.ModalSystem.applyOpenAnimation(dialog);
} else {
dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
}
// Create dialog content
const dialogContent = document.createElement('div');
@@ -1455,7 +1422,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
const colorSchemesSelect = document.getElementById('selectColorSchemesPopover');
if (skinSelect) {
const currentSkin = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const currentSkin = getStoredOrDefaultSkinName();
skinSelect.value = currentSkin;
}
@@ -1653,7 +1620,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
}
const skinSelect = document.getElementById('selectSkin');
const selectedSkinName = skinSelect?.value || localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = skinSelect?.value || getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
// Find existing gear icon and remove it
@@ -1708,7 +1675,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
const colorSchemesSelect = document.getElementById('selectColorSchemesPopover');
if (!colorSchemesSelect) return;
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
// Get the color schemes container to show/hide it
@@ -2041,7 +2008,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
saveOptionalIncludeState(key, enabled);
// Apply changes immediately
const currentSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const currentSkinName = getStoredOrDefaultSkinName();
const currentSkin = SKINS_CONFIG.find(s => s.name === currentSkinName);
if (currentSkin) {
@@ -2063,7 +2030,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
saveOptionalIncludeState(key, enabled);
// Check if this is the currently active skin
const currentSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const currentSkinName = getStoredOrDefaultSkinName();
if (currentSkinName === skin.name) {
// Reload skin-specific optional includes based on updated userConfig
// First, unload current skin-specific optional includes
@@ -2091,7 +2058,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
}
const skinSelect = document.getElementById('selectSkinPopover');
const selectedSkinName = skinSelect?.value || localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = skinSelect?.value || getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
labelElement.innerHTML = '';
@@ -2187,7 +2154,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) {
@@ -2217,7 +2184,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;
@@ -2254,7 +2222,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
displayModeField.parentNode.insertBefore(skinContainer, displayModeField.nextSibling);
// Set the current selection
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const select = document.getElementById('selectSkin');
if (select) {
select.value = selectedSkinName;
@@ -2359,7 +2327,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
* This ensures both popover and display page dropdowns stay in sync
*/
function syncAllColorSchemeDropdowns() {
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
// Get all color scheme dropdowns
@@ -2685,7 +2653,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
}
// Find the scheme by URL and update localStorage
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
if (selectedSkin && selectedSkin.colorSchemes) {
@@ -2728,7 +2696,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
if (selectedColorSchemeName) {
// Get the currently selected skin
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
if (selectedSkin && selectedSkin.colorSchemes) {
@@ -2755,7 +2723,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
}
} else {
// No color scheme stored, use default for current skin
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
if (selectedSkin) {
@@ -2795,19 +2763,19 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
// Check if a default skin is specified in the main configuration
const configDefaultSkin = window.KefinTweaksConfig?.defaultSkin;
if (configDefaultSkin) {
LOG(`Using configured default skin: ${configDefaultSkin}`);
return configDefaultSkin;
const normalized = normalizeSkinName(configDefaultSkin);
LOG(`Using configured default skin: ${normalized}`);
return normalized;
}
// Fall back to 'Default' if no configuration is specified
return 'Default';
return BUILTIN_DEFAULT_SKIN_NAME;
}
/**
* Load the currently selected skin
*/
function loadSelectedSkin() {
const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
const selectedSkinName = getStoredOrDefaultSkinName();
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
if (selectedSkin) {
@@ -2847,36 +2815,29 @@ 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) {
LOG(`No CSS URLs found for skin: ${skin.name}`);
return;
if (cssUrls && cssUrls.length > 0) {
LOG(`Loading skin with ${cssUrls.length} CSS files: ${skin.name}`);
cssUrls.forEach(url => {
if (url) { // Skip null/empty URLs
loadSkinCSS(url, skin.name);
}
});
}
LOG(`Loading skin with ${cssUrls.length} CSS files: ${skin.name}`);
cssUrls.forEach(url => {
if (url) { // Skip null/empty URLs
loadSkinCSS(url, skin.name);
}
});
// Load optional includes for this skin
loadOptionalIncludes(skin);
window.cardBuilder?.invalidateCardRadiusCache?.();
requestAnimationFrame(() => window.cardBuilder?.ensureCardRadiusCssVar?.());
});
}
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
// KefinTweaks Studios Cache Manager
// Handles fetching, processing, and caching of Series Studios
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks StudiosCache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks StudiosCache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks StudiosCache]', ...args);
// State
let _seriesStudios = null;
/**
* Fetches and caches Series Studios using apiHelper.getQuery
* Uses progressive loading: returns immediate cached data if available,
* then updates in-memory cache when fresh data arrives
*/
async function fetchAndCacheSeriesStudios() {
if (!window.apiHelper) {
ERR('apiHelper is not available');
return [];
}
try {
const apiClient = window.ApiClient;
if (!apiClient) {
ERR('ApiClient is not available');
return [];
}
const serverUrl = apiClient.serverAddress();
const url = `${serverUrl}/Studios?IncludeItemTypes=Series`;
LOG('Fetching Series Studios...');
const result = await window.apiHelper.getQuery(url, { useCache: true });
// Extract immediate data (may be null/empty if uncached)
const immediateData = result.data;
const studios = immediateData?.Items ?? (await result.dataPromise)?.Items ?? [];
// When dataPromise resolves, update in-memory cache
result.dataPromise.then(freshData => {
const freshStudios = freshData?.Items || freshData || [];
_seriesStudios = freshStudios;
LOG(`Updated in-memory cache with ${freshStudios.length} Series Studios`);
}).catch(err => {
WARN('Error updating in-memory cache from dataPromise:', err);
});
return studios;
} catch (err) {
ERR('Error fetching Series Studios:', err);
return [];
}
}
/**
* Gets all Series Studios, using in-memory cache if available
* Otherwise fetches and caches the data
*/
async function getSeriesStudios() {
if (_seriesStudios !== null) {
return _seriesStudios;
}
// Fetch and cache, which will return immediate data and update cache when promise resolves
const studios = await fetchAndCacheSeriesStudios();
// Update in-memory cache with immediate data (even if empty) to avoid repeated calls
if (_seriesStudios === null) {
_seriesStudios = studios;
}
return studios;
}
/**
* Initializes the cache
*/
async function initialize() {
await getSeriesStudios();
}
/**
* Gets popular TV networks (studios) filtered by minimum series count
* @param {number|{ MinCount?: number, minCount?: number, minimumSeries?: number }} minOrOptions
* Minimum series count, or queryOptions bag from fetchFromDataSource
* @returns {Promise<Array>} - Array of studios meeting the minimum threshold
*/
async function getPopularTVNetworks(minOrOptions) {
let minimumSeries;
if (minOrOptions && typeof minOrOptions === 'object') {
minimumSeries = minOrOptions.MinCount ?? minOrOptions.minCount ?? minOrOptions.minimumSeries;
} else {
minimumSeries = minOrOptions;
}
if (minimumSeries == null || minimumSeries === '') {
minimumSeries = window.KefinTweaksConfig?.HOME_SETTINGS?.minimumSeriesForPopularTVNetworks
?? window.KefinHomeConfig2?.HOME_SETTINGS?.minimumSeriesForPopularTVNetworks;
}
const studios = await getSeriesStudios();
const threshold = Number(minimumSeries);
if (Number.isFinite(threshold) && threshold > 0) {
return studios.filter(studio =>
(studio.SeriesCount || studio.ChildCount || 0) >= threshold
);
}
return studios;
}
// Expose API
window.StudiosCache = {
init: initialize,
getPopularTVNetworks: getPopularTVNetworks,
getSeriesStudios: getSeriesStudios
};
LOG('Module loaded');
})();
+12 -18
View File
@@ -277,7 +277,6 @@
data-history="true" data-removeonclose="true"
style="animation: 140ms ease-out 0s 1 normal both running scaleup; position: fixed; margin: 0px;">
<div class="actionSheetContent">
<span style="padding: 10px 20px;margin: -6px auto 10px;max-width: 450px;background: #c1c1c1;border-radius: 0 0 10px 10px;color: #000000;">If you have ${serverName} open in multiple tabs the subtitle will be downloaded, but the player will not switch to the subtitle and it will appear as if the download is hanging.</span>
<h1 class="actionSheetTitle">Subtitle Search Results</h1>
<div class="actionSheetScroller scrollY" id="subtitleSearchResults">
<div class="listItem">
@@ -331,14 +330,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': apiHelper.getAuthHeader()
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -418,14 +416,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': apiHelper.getAuthHeader()
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -505,14 +502,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': apiHelper.getAuthHeader()
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -569,7 +565,6 @@
throw new Error('ApiClient not available');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const deviceId = ApiClient.deviceId();
@@ -588,7 +583,7 @@
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': apiHelper.getAuthHeader()
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -625,7 +620,6 @@
throw new Error('ApiClient not available');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const userId = ApiClient.getCurrentUserId();
@@ -638,7 +632,7 @@
const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Command`, {
method: 'POST',
headers: {
'Authorization': apiHelper.getAuthHeader(),
'Authorization': window.apiHelper.getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -758,7 +752,6 @@
throw new Error('No play state found in session');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
// Get current item ID
@@ -788,7 +781,7 @@
const commandResponse = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
method: 'POST',
headers: {
'Authorization': apiHelper.getAuthHeader()
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -897,13 +890,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': apiHelper.getAuthHeader()
'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -1097,11 +1089,13 @@
}
try {
// Check if ApiClient is available first
if (!isApiClientAvailable()) {
LOG('ApiClient not available, skipping initialization');
// Wait for login to complete
if (!window.userHelper?.waitForLogin) {
LOG('userHelper.waitForLogin not available, waiting');
setTimeout(initialize, 1000);
return;
}
await window.userHelper.waitForLogin();
// Check permissions first
const hasPermission = await checkSubtitlePermissions();
-16
View File
@@ -1,16 +0,0 @@
/**
* Flurry jQuery Plugin
*
* Flurry is an easy-to-use animated snow plugin for jQuery. It takes advantage
* of CSS transforms, CSS transitions and requestAnimationFrame to provide
* smooth animation for modern browsers. Props to Jonathan Nicol @f6design
* for boilerplate code
* (see http://jonathannicol.com/blog/2012/05/06/a-jquery-plugin-boilerplate/)
*
* @link https://github.com/joshmcrty/Flurry
* @version 1.2.0
* @author Josh McCarty <josh@joshmccarty.com>
* @copyright 2016 Josh McCarty
* @license https://github.com/joshmcrty/Flurry/blob/master/LICENSE GPLv2
*/
!function(n,e){!function(){for(var n=0,e=["ms","moz","webkit","o"],a=0;a<e.length&&!window.requestAnimationFrame;++a)window.requestAnimationFrame=window[e[a]+"RequestAnimationFrame"],window.cancelAnimationFrame=window[e[a]+"CancelAnimationFrame"]||window[e[a]+"CancelRequestAnimationFrame"];window.requestAnimationFrame||(window.requestAnimationFrame=function(e,a){var t=(new Date).getTime(),i=Math.max(0,16-Math.abs(t-n)),r=window.setTimeout(function(){e(t+i)},i);return n=t+i,r}),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(n){clearTimeout(n)})}();var a="flurry";function t(n,e){return Math.floor(Math.random()*(e-n+1)+n)}function i(a,i,r){var o=1===a.character.length?Array.isArray(a.character)?a.character[0]:a.character:Array.isArray(a.character)?a.character[t(0,a.character.length-1)]:Array.from(a.character)[t(0,a.character.length-1)];"string"==typeof a.character&&console&&console.warn&&console.warn("You are using a string for the Flurry character options. Please use an array of string(s) instead.");var s=t(-Math.abs(a.wind),r+Math.abs(a.wind)),c=s+t(a.wind-a.windVariance,a.wind+a.windVariance),l=t(a.small,a.large),u=a.speed/((t(1.2*l,.8*l)-a.small)/(a.large-a.small)+.5),m=a.height-l,w=t(a.rotation-a.rotationVariance,a.rotation+a.rotationVariance),d=Array.isArray(a.color)?a.color[Math.floor(Math.random()*a.color.length)]:a.color,h={transform:"translateX("+c+"px) translateY("+m+"px) rotateZ("+w+"deg)",opacity:0},f=n("<span></span>");f.html(o).css({color:a.blur&&l<(a.large+a.small)/2?"transparent":d,"text-shadow":a.blur&&l<(a.large+a.small)/2?"0 0 1px "+d:"none",display:"inline-block","line-height":1,margin:0,padding:"2px","pointer-events":"none","font-size":l+"px",opacity:a.startTransparency,position:"absolute",top:"-"+1.2*a.large+"px",transform:"translateX("+s+"px) translateY(0px) rotateZ("+a.startRotation+"deg)",transition:"transform "+u/1e3+"s linear, opacity "+u/1e3+"s "+a.opacityEasing,"z-index":a.zIndex}),a.onFlake!==e&&a.onFlake.call(f[0]),f.appendTo(i),!function(){var n=(document.body||document.documentElement).style;return n.transition!==e||n.WebkitTransition!==e||n.MozTransition!==e||n.MsTransition!==e||n.OTransition!==e}?f.animate(h,u,"linear",function(){n(this).remove()}):(f.on("transitionend.flurry",function(e){n(e.target).remove()}),window.requestAnimationFrame(function(){f.css(h)}))}function r(t,r){var o=this,s=t,c=n(t);function l(n){r[n]!==e&&r[n].call(s)}return r=n.extend({height:c.height()>200?200:c.height(),useRelative:!c.is("body")},n.fn[a].defaults,r),n.each(r,function(n,e){parseInt(e)&&(r[n]=parseInt(e))}),function(){!0===r.useRelative&&"static"===c.css("position")&&c.css({position:"relative"});var e,a,t,s,u=n(document.createElement("div")).addClass("flurry-container").css({margin:0,padding:0,position:"absolute",top:0,right:0,left:0,height:r.height,overflow:r.overflow,"pointer-events":"none"}).prependTo(c),m=u.width();n(window).resize(function(){m=u.width()}),o.flakeInterval=(e=function(){i(r,u,m)},a=r.frequency,t=(new Date).getTime(),(s=new Object).value=window.requestAnimationFrame(function n(){(new Date).getTime()-t>=a&&(e.call(),t=(new Date).getTime()),s.value=window.requestAnimationFrame(n)}),s),l("onInit")}(),{option:function(n,e){if(!e)return r[n];r[n]=parseInt(e)||e},destroy:function(){c.each(function(){var e,t=n(this);e=o.flakeInterval,window.cancelAnimationFrame?window.cancelAnimationFrame(e.value):window.webkitCancelAnimationFrame?window.webkitCancelAnimationFrame(e.value):window.webkitCancelRequestAnimationFrame?window.webkitCancelRequestAnimationFrame(e.value):window.mozCancelRequestAnimationFrame?window.mozCancelRequestAnimationFrame(e.value):window.oCancelRequestAnimationFrame?window.oCancelRequestAnimationFrame(e.value):window.msCancelRequestAnimationFrame?window.msCancelRequestAnimationFrame(e.value):clearInterval(e),t.find(".flurry-container").remove(),l("onDestroy"),t.removeData("plugin_"+a)})}}}n.fn[a]=function(t){if("string"==typeof arguments[0]){var i,o=arguments[0],s=Array.prototype.slice.call(arguments,1);return this.each(function(){if(!n.data(this,"plugin_"+a)||"function"!=typeof n.data(this,"plugin_"+a)[o])throw new Error("Method "+o+" does not exist on jQuery."+a);i=n.data(this,"plugin_"+a)[o].apply(this,s)}),i!==e?i:this}if("object"==typeof t||!t)return this.each(function(){n.data(this,"plugin_"+a)||n.data(this,"plugin_"+a,new r(this,t))})},n.fn[a].defaults={onInit:e,onDestroy:e,onFlake:e,character:["❄"],color:"white",frequency:100,speed:3e3,small:8,large:28,wind:40,windVariance:20,rotation:90,rotationVariance:180,startRotation:0,startOpacity:1,endOpacity:0,opacityEasing:"cubic-bezier(1,.3,.6,.74)",blur:!1,overflow:"hidden",zIndex:9999}}(jQuery);
+9
View File
@@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2014-2024 Chart.js Contributors
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.
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 - present Simon Reinisch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+227 -20
View File
@@ -1,6 +1,6 @@
// KefinTweaks Toaster
// Provides toast notification functionality using Jellyfin's existing toast system
// Usage: window.KefinTweaksToaster.toast(message, duration)
// Usage: window.KefinTweaksToaster.toast(message, duration, autoHide, options)
(function() {
'use strict';
@@ -11,6 +11,83 @@
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;
}
.toast.kefin-toast-rich.kefin-toast-with-buttons {
flex-wrap: wrap;
align-items: flex-start;
}
.toast.kefin-toast-rich .kefin-toast-buttons {
flex: 1 1 100%;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin-top: 0.15em;
}
.toast.kefin-toast-rich .kefin-toast-text-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35em 0.9em;
margin: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: inherit;
font: inherit;
font-weight: 500;
cursor: pointer;
}
.toast.kefin-toast-rich .kefin-toast-text-btn:hover {
background: rgba(255, 255, 255, 0.16);
}
`;
document.head.appendChild(style);
}
/**
* Get the toast container
@@ -54,14 +131,145 @@
// 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 buttons = Array.isArray(options?.buttons)
? options.buttons.filter((b) => b && typeof b.label === 'string' && typeof b.onClick === 'function')
: [];
const rich = !!(link || action || buttons.length);
if (!rich) {
toastElement.textContent = message;
return;
}
ensureToastStyles();
toastElement.classList.add('kefin-toast-rich');
if (buttons.length) {
toastElement.classList.add('kefin-toast-with-buttons');
}
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);
}
if (buttons.length) {
const row = document.createElement('div');
row.className = 'kefin-toast-buttons';
buttons.forEach((spec) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kefin-toast-text-btn';
btn.textContent = spec.label;
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
spec.onClick();
} catch (e) {
ERR('Toast button action failed:', e);
}
});
row.appendChild(btn);
});
toastElement.appendChild(row);
}
}
/**
* 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 }, buttons?: Array<{ 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 +296,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 +308,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,31 +318,28 @@
// 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
window.KefinTweaksToaster = {
toast
};
// Drain deferred root-update toast queued by kefinTweaks-plugin before toaster loaded
try {
const pending = window.__kefinPendingRootUpdateToast;
if (pending?.message) {
window.__kefinPendingRootUpdateToast = null;
toast(pending.message, '5');
}
} catch (e) {
WARN('Failed to flush pending root-update toast:', e);
}
LOG('Initialized successfully');
LOG('Available at window.KefinTweaksToaster');
+2042
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -29,11 +29,11 @@
async function fetchItemDetails(itemId) {
console.log('Fetching item details for itemId:', itemId);
try {
const url = `${serverUrl}/Items/${itemId}`;
const url = `${serverUrl}/Items/${itemId}?api_key=${apiKey}`; // Fixed typo from original: 'melalui' to 'apiKey'
console.log('Requesting:', url);
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': apiHelper.getAuthHeader() }
headers: { 'X-Emby-Token': apiKey }
});
if (!response.ok) {
console.error('Fetch item details failed:', `HTTP ${response.status}`);
+377
View File
@@ -0,0 +1,377 @@
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks APIHelper]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks APIHelper]', ...args);
const ERR = (...args) => console.error('[KefinTweaks APIHelper]', ...args);
/**
* Ensure ApiClient is available before using helper methods
*/
function ensureApiClient() {
if (typeof ApiClient === 'undefined' || !ApiClient) {
throw new Error('ApiClient is not available');
}
}
function ensureLoggedIn() {
if (typeof ApiClient === 'undefined' || !ApiClient) {
throw new Error('ApiClient is not available');
}
if (!ApiClient._loggedIn) {
throw new Error('User is not logged in');
}
}
// Poll until ApiClient reports logged in. Singleton in-flight Promise.
// Prefer localusersignedin; fall back to setInterval (no requestIdleCallback).
let loginWaitPromise = null;
function isUserLoggedIn() {
try {
return !!(window.ApiClient && window.ApiClient._loggedIn);
} catch (_) {
return false;
}
}
function getConnectionManager() {
return window.ServerConnections || window.ConnectionManager || null;
}
function getEventsApi() {
return window.Events || window.events || null;
}
async function waitForLogin(maxWaitMs = 60000) {
if (isUserLoggedIn()) {
return true;
}
if (loginWaitPromise) {
return loginWaitPromise;
}
loginWaitPromise = new Promise((resolve) => {
let settled = false;
let intervalId = null;
const startTime = Date.now();
const conn = getConnectionManager();
const eventsApi = getEventsApi();
const cleanup = () => {
if (intervalId != null) {
clearInterval(intervalId);
intervalId = null;
}
if (conn && eventsApi?.off) {
try {
eventsApi.off(conn, 'localusersignedin', onSignedIn);
} catch (_) { /* ignore */ }
}
};
const done = (ok) => {
if (settled) return;
settled = true;
cleanup();
loginWaitPromise = null;
resolve(ok);
};
const onSignedIn = () => {
LOG('waitForLogin: localusersignedin');
done(true);
};
if (conn && eventsApi?.on) {
try {
eventsApi.on(conn, 'localusersignedin', onSignedIn);
} catch (e) {
WARN('waitForLogin: could not subscribe localusersignedin', e);
}
}
intervalId = setInterval(() => {
if (isUserLoggedIn()) {
LOG('waitForLogin: logged in');
done(true);
return;
}
if (Date.now() - startTime >= maxWaitMs) {
WARN('waitForLogin: timed out after', maxWaitMs, 'ms');
done(false);
}
}, 200);
});
return loginWaitPromise;
}
/**
* API Helper functions for Jellyfin operations
*/
const userHelper = {
/**
* Get current-user display preferences.
* @param {{ cacheOnly?: boolean }} [options]
* @returns {Promise<{ promise: Promise<object>, cached: object|null }>}
*/
getUserDisplayPreferences: async function(options = {}) {
const cacheOnly = options.cacheOnly === true;
let cachedDisplayPreferences = null;
// Cache hits must not wait on login (home paint can race signed-in)
if (__userDisplayPreferences) {
cachedDisplayPreferences = __userDisplayPreferences;
} else if (window.LocalStorageCache) {
const cache = new window.LocalStorageCache();
const displayPreferencesData = cache.get('userDisplayPreferences');
if (displayPreferencesData) {
cachedDisplayPreferences = displayPreferencesData;
// Hydrate memory so later cacheOnly calls skip localStorage too
__userDisplayPreferences = displayPreferencesData;
}
}
if (cacheOnly && cachedDisplayPreferences) {
return {
promise: Promise.resolve(cachedDisplayPreferences),
cached: cachedDisplayPreferences
};
}
// Network path: wait for login instead of hard-throwing ensureLoggedIn
ensureApiClient();
const loggedIn = await waitForLogin();
if (!loggedIn) {
if (cachedDisplayPreferences) {
WARN('getUserDisplayPreferences: login wait timed out; returning cache only');
return {
promise: Promise.resolve(cachedDisplayPreferences),
cached: cachedDisplayPreferences
};
}
throw new Error('User is not logged in');
}
// Wrap this in a promise to return with the cached data
const userDisplayPreferencesPromise = new Promise(async (resolve, reject) => {
try {
// Fetch the user display preferences
LOG('Fetching user display preferences');
const response = await fetch(`${ApiClient.serverAddress()}/DisplayPreferences/usersettings?userId=${ApiClient.getCurrentUserId()}&client=emby`, {
headers: {
'Authorization': window.apiHelper.getAuthHeader()
}
});
if (!response.ok) {
reject(new Error(`Failed to fetch display preferences: ${response.status}`));
return;
}
const data = await response.json();
__userDisplayPreferences = data;
// Save the display preferences to localStorage
if (window.LocalStorageCache) {
const cache = new window.LocalStorageCache();
cache.set('userDisplayPreferences', data, ApiClient.getCurrentUserId(), 24 * 60 * 60 * 1000);
}
resolve(data);
} catch (error) {
reject(error);
}
});
return { promise: userDisplayPreferencesPromise, cached: cachedDisplayPreferences };
},
useEpisodeImages: async function() {
try {
ensureApiClient();
ensureLoggedIn();
const { promise, cached } = await this.getUserDisplayPreferences();
const userDisplayPreferences = await promise;
return userDisplayPreferences.CustomPrefs?.useEpisodeImagesInNextUpAndResume === 'true';
} catch (error) {
WARN('Failed to load user display preferences for useEpisodeImages:', error);
return false;
}
},
updateCustomPrefsByKey: async function(key, value) {
const { promise, cached } = await this.getUserDisplayPreferences();
const userDisplayPreferences = await promise;
if (!userDisplayPreferences) {
LOG('No user display preferences found');
return false;
}
userDisplayPreferences.CustomPrefs[key] = value;
return await this.updateDisplayPreferences(userDisplayPreferences);
},
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 url = `${serverAddress}/DisplayPreferences/usersettings?userId=${encodeURIComponent(userId)}&client=emby`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'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
};
// Updated by getUserDisplayPreferences / updateDisplayPreferencesForUser({ updateCache: true })
let __userDisplayPreferences = null;
// Expose userHelper to global window object
window.userHelper = userHelper;
console.log('[KefinTweaks UserHelper] Module loaded and available at window.userHelper');
})();
+120
View File
@@ -0,0 +1,120 @@
/* User Manager — template UI on dashboard user pages */
#newUserPage:not([data-user-template="None"]) .folderAccessContainer {
display: none !important;
}
#newUserPage[data-user-template="None"] .um-kefin-save,
#newUserPage:not([data-user-template]) .um-kefin-save {
display: none !important;
}
#newUserPage:not([data-user-template="None"]) .newUserProfileForm .emby-button.button-submit:not(.um-kefin-save) {
display: none !important;
}
.um-template-field {
margin: 1em 0;
}
.um-template-field-row {
display: flex;
align-items: flex-end;
gap: 0.75em;
flex-wrap: wrap;
}
.um-template-field-row .selectContainer {
flex: 1 1 220px;
margin: 0;
}
.um-bulk-root {
display: flex;
flex-direction: column;
gap: 1em;
padding: 0.25em 0;
}
.um-bulk-user-list {
max-height: 50vh;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.5em;
}
.um-bulk-user-item {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.45em 0.35em;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.um-bulk-user-item:last-child {
border-bottom: none;
}
.um-bulk-toolbar {
display: grid;
gap: 0.5em;
grid-template-columns: 1fr 1fr;
align-items: center;
}
.um-bulk-status {
font-size: 0.9em;
opacity: 0.85;
min-height: 1.2em;
}
.um-bulk-fab {
margin-left: 0.25em;
}
.um-config-root .um-template-list {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-top: 1em;
}
.um-config-root .um-template-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
padding: 0.75em 1em;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
}
.um-config-root .um-template-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.um-editor-root .um-section {
margin: 1.25em 0;
padding-bottom: 1em;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.um-editor-root .um-section-title {
margin: 0 0 0.75em;
font-size: 1.1em;
font-weight: 500;
}
.um-editor-root .checkboxList {
display: flex;
flex-direction: column;
gap: 0.35em;
}
.um-bulk-root .selectContainer,
.um-bulk-toolbar button {
margin: 0;
}
+565
View File
@@ -0,0 +1,565 @@
// KefinTweaks User Manager — dashboard hooks for user templates
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks UserManager]', ...args);
const ERR = (...args) => console.error('[KefinTweaks UserManager]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks UserManager]', ...args);
const BULK_MODAL_ID = 'kefin-usermanager-bulk';
const NONE = 'None';
const MARKER_ADD = 'data-um-add-injected';
const MARKER_BULK = 'data-um-bulk-injected';
function clonePolicy(policy) {
if (window.KefinUserManagerConfig?.clonePolicy) {
return window.KefinUserManagerConfig.clonePolicy(policy);
}
return JSON.parse(JSON.stringify(policy ?? {}));
}
function loadConfig() {
if (window.KefinUserManagerConfig?.loadConfig) {
return window.KefinUserManagerConfig.loadConfig();
}
const raw = window.KefinTweaksConfig?.userManager || {};
return {
defaultTemplateName: raw.defaultTemplateName || '',
templates: Array.isArray(raw.templates) ? raw.templates : []
};
}
function getAuthHeader() {
return window.apiHelper?.getAuthHeader?.() || '';
}
async function apiJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
Authorization: getAuthHeader(),
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {})
}
});
if (!response.ok) {
let detail = `${response.status}`;
try {
const body = await response.json();
detail = body?.message || body?.Message || detail;
} catch (_) {
try {
const text = await response.text();
if (text) detail = text;
} catch (__) { /* ignore */ }
}
throw new Error(detail);
}
if (response.status === 204) return null;
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch (_) {
return text;
}
}
async function createUser(name, password) {
const url = ApiClient.getUrl('Users/New');
return apiJson(url, {
method: 'POST',
body: JSON.stringify({ Name: name, Password: password })
});
}
async function applyPolicy(userId, policy) {
const url = ApiClient.getUrl(`Users/${userId}/Policy`);
return apiJson(url, {
method: 'POST',
body: JSON.stringify(policy)
});
}
async function fetchUsers() {
if (typeof ApiClient?.getUsers === 'function') {
const users = await ApiClient.getUsers();
return Array.isArray(users) ? users : [];
}
const users = await apiJson(ApiClient.getUrl('Users'), { method: 'GET' });
return Array.isArray(users) ? users : [];
}
function toast(msg) {
window.KefinTweaksToaster?.toast?.(msg);
}
function isAddUserPage(hash) {
const h = hash || window.location.hash || '';
return /\/dashboard\/users\/usernew\b/i.test(h) || /\/dashboard\/users\/add\b/i.test(h);
}
function isUsersListPage(hash) {
const h = hash || window.location.hash || '';
if (isAddUserPage(h)) return false;
if (/\/dashboard\/users\/user\b/i.test(h)) return false;
return /\/dashboard\/users\/?(\?|$|#|$)/i.test(h) || /#\/dashboard\/users\/?$/i.test(h);
}
function getNewUserPage() {
return document.querySelector('#newUserPage')
|| document.querySelector('.newUserPage')
|| document.querySelector('[data-role="page"].newUserPage');
}
function waitForElement(getter, timeoutMs = 8000, intervalMs = 100) {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
const el = getter();
if (el) {
resolve(el);
return;
}
if (Date.now() - start >= timeoutMs) {
resolve(null);
return;
}
setTimeout(tick, intervalMs);
};
tick();
});
}
function getTemplateOptionsHtml(config, includeNone) {
const names = (config.templates || []).map((t) => t.name);
const options = [];
if (includeNone) {
options.push(`<option value="${NONE}">${NONE}</option>`);
}
names.forEach((name) => {
options.push(`<option value="${escapeAttr(name)}">${escapeHtml(name)}</option>`);
});
return options.join('');
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return escapeHtml(str).replace(/'/g, '&#39;');
}
function findNativeSaveButton(page) {
return page.querySelector('.newUserProfileForm .emby-button.button-submit:not(.um-kefin-save)')
|| page.querySelector('form .emby-button.button-submit:not(.um-kefin-save)')
|| page.querySelector('.button-submit:not(.um-kefin-save)');
}
function updateSaveVisibility(page, templateName) {
const useTemplate = templateName && templateName !== NONE;
page.setAttribute('data-user-template', useTemplate ? templateName : NONE);
const nativeSave = findNativeSaveButton(page);
const kefinSave = page.querySelector('.um-kefin-save');
if (nativeSave) {
nativeSave.style.display = useTemplate ? 'none' : '';
}
if (kefinSave) {
kefinSave.style.display = useTemplate ? '' : 'none';
}
}
async function handleKefinSave(page) {
const select = page.querySelector('#um-template-select');
const templateName = select?.value || NONE;
if (templateName === NONE) return;
const config = loadConfig();
const template = config.templates.find((t) => t.name === templateName);
if (!template?.policy) {
toast('Selected template was not found');
return;
}
const nameInput = page.querySelector('#txtUsername')
|| page.querySelector('input[type="text"]');
const passwordInput = page.querySelector('#txtPassword')
|| page.querySelector('input[type="password"]');
const name = (nameInput?.value || '').trim();
const password = passwordInput?.value || '';
if (!name) {
toast('Username is required');
nameInput?.focus();
return;
}
const kefinSave = page.querySelector('.um-kefin-save');
if (kefinSave) kefinSave.disabled = true;
try {
const created = await createUser(name, password);
const userId = created?.Id || created?.id;
if (!userId) throw new Error('User created but no Id returned');
await applyPolicy(userId, clonePolicy(template.policy));
toast(`User "${name}" created with template "${templateName}"`);
window.location.hash = '#/dashboard/users';
} catch (e) {
ERR('Failed to create user with template', e);
toast(`Failed to create user: ${e.message || e}`);
} finally {
if (kefinSave) kefinSave.disabled = false;
}
}
async function injectAddUserPage() {
const page = await waitForElement(getNewUserPage);
if (!page) {
WARN('New user page not found');
return;
}
if (page.getAttribute(MARKER_ADD) === '1') {
// Refresh template options in case config changed
refreshAddUserTemplateSelect(page);
return;
}
const passwordField = await waitForElement(() =>
page.querySelector('#txtPassword')
|| page.querySelector('input[type="password"]')
);
if (!passwordField) {
WARN('Password field not found on new user page');
return;
}
const config = loadConfig();
const defaultName = config.defaultTemplateName && config.templates.some((t) => t.name === config.defaultTemplateName)
? config.defaultTemplateName
: NONE;
const templatesContainer = document.createElement('div');
templatesContainer.className = 'um-template-field inputContainer';
templatesContainer.innerHTML = `
<div class="um-template-field-row">
<div class="selectContainer">
<label class="selectLabel" for="um-template-select">Template</label>
<select is="emby-select" id="um-template-select" class="emby-select-withcolor emby-select">
${getTemplateOptionsHtml(config, true)}
</select>
</div>
</div>
`;
const editTemplatesContainer = document.createElement('div');
editTemplatesContainer.className = 'um-template-field inputContainer';
editTemplatesContainer.innerHTML = `
<div class="um-template-field-row">
<button type="button" class="emby-button raised um-edit-templates-btn block" title="Edit Templates">
Edit Templates
</button>
</div>
`;
const passwordContainer = passwordField.closest('.inputContainer') || passwordField.parentElement;
if (passwordContainer?.parentElement) {
passwordContainer.parentElement.insertBefore(templatesContainer, passwordContainer.nextSibling);
passwordContainer.parentElement.insertBefore(editTemplatesContainer, templatesContainer.nextSibling);
} else {
passwordField.insertAdjacentElement('afterend', templatesContainer);
passwordField.insertAdjacentElement('afterend', editTemplatesContainer);
}
const select = templatesContainer.querySelector('#um-template-select');
if (select) {
select.value = defaultName;
if (![...select.options].some((o) => o.value === defaultName)) {
select.value = NONE;
}
}
editTemplatesContainer.querySelector('.um-edit-templates-btn')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (window.KefinUserManagerConfig?.openConfigModal) {
window.KefinUserManagerConfig.openConfigModal();
} else if (window.KefinTweaksFeatureConfigs?.userManager?.openConfigModal) {
window.KefinTweaksFeatureConfigs.userManager.openConfigModal();
} else {
toast('User Manager configuration is not available');
}
});
select?.addEventListener('change', () => {
updateSaveVisibility(page, select.value || NONE);
});
// Kefin Save button next to native submit
const nativeSave = await waitForElement(() => findNativeSaveButton(page), 5000);
const kefinSave = document.createElement('button');
kefinSave.type = 'button';
kefinSave.className = 'raised button-submit block emby-button um-kefin-save';
kefinSave.textContent = 'Save';
kefinSave.style.display = 'none';
kefinSave.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
handleKefinSave(page);
});
if (nativeSave?.parentElement) {
nativeSave.parentElement.insertBefore(kefinSave, nativeSave.nextSibling);
} else {
const form = page.querySelector('form') || page;
form.appendChild(kefinSave);
}
page.setAttribute(MARKER_ADD, '1');
updateSaveVisibility(page, select?.value || NONE);
LOG('Injected template controls on add-user page');
}
function refreshAddUserTemplateSelect(page) {
const select = page.querySelector('#um-template-select');
if (!select) return;
const prev = select.value;
const config = loadConfig();
select.innerHTML = getTemplateOptionsHtml(config, true);
if ([...select.options].some((o) => o.value === prev)) {
select.value = prev;
} else if (config.defaultTemplateName && [...select.options].some((o) => o.value === config.defaultTemplateName)) {
select.value = config.defaultTemplateName;
} else {
select.value = NONE;
}
updateSaveVisibility(page, select.value || NONE);
}
async function openBulkEditor() {
if (!window.ModalSystem?.create) {
toast('Modal system not available');
return;
}
const config = loadConfig();
if (!config.templates.length) {
toast('Create a user template first in Features → User Manager');
return;
}
let users = [];
try {
users = await fetchUsers();
} catch (e) {
ERR('Failed to load users', e);
toast(`Failed to load users: ${e.message || e}`);
return;
}
users = users
.filter((u) => u?.Id && u.Name)
.sort((a, b) => String(a.Name).localeCompare(String(b.Name), undefined, { sensitivity: 'base' }));
const defaultTemplate = config.defaultTemplateName && config.templates.some((t) => t.name === config.defaultTemplateName)
? config.defaultTemplateName
: config.templates[0].name;
const content = document.createElement('div');
content.className = 'um-bulk-root';
content.innerHTML = `
<div class="selectContainer">
<label class="selectLabel" for="um-bulk-template">Template</label>
<select is="emby-select" id="um-bulk-template" class="emby-select-withcolor emby-select">
${getTemplateOptionsHtml(config, false)}
</select>
</div>
<div class="um-bulk-toolbar">
<button type="button" class="emby-button raised" id="um-bulk-select-all">Select all</button>
<button type="button" class="emby-button raised" id="um-bulk-select-none">Select none</button>
</div>
<div class="um-bulk-user-list">
${users.map((u) => `
<label class="um-bulk-user-item">
<input type="checkbox" class="um-bulk-user-check" value="${escapeAttr(u.Id)}" data-name="${escapeAttr(u.Name)}" />
<span>${escapeHtml(u.Name)}</span>
</label>
`).join('') || '<div style="padding:0.75em;opacity:0.7;">No users found</div>'}
</div>
<div class="um-bulk-status" id="um-bulk-status"></div>
`;
const templateSelect = content.querySelector('#um-bulk-template');
if (templateSelect) templateSelect.value = defaultTemplate;
content.querySelector('#um-bulk-select-all')?.addEventListener('click', () => {
content.querySelectorAll('.um-bulk-user-check').forEach((cb) => { cb.checked = true; });
syncApplyEnabled();
});
content.querySelector('#um-bulk-select-none')?.addEventListener('click', () => {
content.querySelectorAll('.um-bulk-user-check').forEach((cb) => { cb.checked = false; });
syncApplyEnabled();
});
content.querySelectorAll('.um-bulk-user-check').forEach((cb) => {
cb.addEventListener('change', syncApplyEnabled);
});
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
footer.innerHTML = `
<button type="button" class="emby-button raised" id="um-bulk-cancel">Cancel</button>
<button type="button" class="emby-button raised button-submit" id="um-bulk-apply" disabled>Apply</button>
`;
function syncApplyEnabled() {
const any = !!content.querySelector('.um-bulk-user-check:checked');
const applyBtn = footer.querySelector('#um-bulk-apply');
if (applyBtn) applyBtn.disabled = !any;
}
footer.querySelector('#um-bulk-cancel')?.addEventListener('click', () => {
window.ModalSystem.close(BULK_MODAL_ID);
});
footer.querySelector('#um-bulk-apply')?.addEventListener('click', async () => {
const applyBtn = footer.querySelector('#um-bulk-apply');
const statusEl = content.querySelector('#um-bulk-status');
const templateName = templateSelect?.value;
const template = loadConfig().templates.find((t) => t.name === templateName);
if (!template?.policy) {
toast('Selected template was not found');
return;
}
const selected = [...content.querySelectorAll('.um-bulk-user-check:checked')].map((cb) => ({
id: cb.value,
name: cb.dataset.name || cb.value
}));
if (!selected.length) return;
if (applyBtn) applyBtn.disabled = true;
let ok = 0;
let fail = 0;
for (let i = 0; i < selected.length; i++) {
const user = selected[i];
if (statusEl) {
statusEl.textContent = `Applying (${i + 1}/${selected.length}): ${user.name}`;
}
try {
await applyPolicy(user.id, clonePolicy(template.policy));
ok++;
} catch (e) {
fail++;
ERR(`Failed policy apply for ${user.name}`, e);
}
}
const summary = fail
? `Applied template to ${ok} user(s); ${fail} failed`
: `Applied template to ${ok} user(s)`;
if (statusEl) statusEl.textContent = summary;
toast(summary);
if (applyBtn) applyBtn.disabled = false;
if (!fail) {
window.ModalSystem.close(BULK_MODAL_ID);
}
});
const modal = window.ModalSystem.create({
id: BULK_MODAL_ID,
title: 'Bulk edit user accounts',
content,
footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
dialogStyle: { maxWidth: '720px', 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();
}
})();
+3326 -145
View File
File diff suppressed because it is too large Load Diff
+501
View File
@@ -0,0 +1,501 @@
// 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 PAGE_SELECTOR = '#itemDetailPage:not(.hide)';
const FORM_SELECTOR = 'form.trackSelections';
const SELECTOR = `${PAGE_SELECTOR} ${FORM_SELECTOR} select.detailTrackSelect`;
const SELECT_FALLBACK = `${PAGE_SELECTOR} select.detailTrackSelect`;
const ORIGINAL_ATTR = 'data-kefin-original-label';
const SELECTED_INDEX_ATTR = 'data-selected-index';
const CHANGE_BOUND_ATTR = 'data-kefin-vp-change-bound';
const APPLIED_ATTR = 'data-kefin-vp-applied';
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));
});
}
/** True after Kefin has completed one full apply for this select / its track form. */
function selectAlreadyKefinModified(select) {
if (!select) return false;
if (select.hasAttribute(APPLIED_ATTR)) return true;
const form = select.closest?.(FORM_SELECTOR);
return !!form?.hasAttribute?.(APPLIED_ATTR);
}
function markApplied(select) {
if (!select) return;
select.setAttribute(APPLIED_ATTR, 'true');
const form = select.closest?.(FORM_SELECTOR);
if (form) form.setAttribute(APPLIED_ATTR, 'true');
}
/**
* After the first full apply: only refresh option labels when Jellyfin resets them.
* Never reorder options, never change selection, never dispatch change.
*/
function syncLabelsOnly(select) {
if (!select?.options?.length) return false;
const prefs = getPreferences();
let updated = 0;
isApplying = true;
try {
Array.from(select.options).forEach((option) => {
// Fresh/replaced options have no stamp — capture Jellyfin's native label first.
// Existing stamps keep the original native text even after we clean textContent.
if (!option.hasAttribute(ORIGINAL_ATTR)) {
option.setAttribute(ORIGINAL_ATTR, option.textContent || '');
}
const originalLabel = option.getAttribute(ORIGINAL_ATTR) || '';
const editionMatch = findBestTerm(originalLabel, prefs.editions);
const versionMatch = findBestTerm(originalLabel, prefs.versions);
const hasMatch = !!(editionMatch || versionMatch);
const desc = {
originalLabel,
hasMatch,
editionTerm: editionMatch?.term || null,
versionTerm: versionMatch?.term || null
};
const label = desiredLabel(desc);
if (option.textContent !== label) {
option.textContent = label;
updated += 1;
}
});
if (updated > 0) {
LOG('Synced version preference labels (selection unchanged)', {
updated,
selectedIndex: select.selectedIndex,
selected: select.value
});
}
return updated > 0;
} finally {
isApplying = false;
}
}
function applyToSelect(select) {
if (!select || !select.options?.length) return false;
// Subsequent passes: labels only — Jellyfin may reset native text after track changes.
if (selectAlreadyKefinModified(select)) {
return syncLabelsOnly(select);
}
bindSelectedIndexTracker(select);
const prefs = getPreferences();
const previousValue = select.value;
const savedIndex = parseSelectedIndexAttr(select);
const ranked = rankOptions(select.options, prefs);
if (isSelectInExpectedState(select, ranked)) {
markApplied(select);
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));
// Stamp before any Jellyfin rebuild so later passes stay label-only.
markApplied(select);
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) || document.querySelector(SELECT_FALLBACK);
}
function findDetailsPage() {
return document.querySelector(PAGE_SELECTOR);
}
function findTrackSelectionsForm(page) {
const root = page || findDetailsPage();
return root?.querySelector?.(FORM_SELECTOR) || null;
}
let observer = null;
/** @type {Element|null} */
let observedRoot = null;
/** True while observing the details page until form.trackSelections mounts. */
let waitingForTrackForm = false;
let applyScheduled = false;
let isApplying = false;
function disconnectObserver() {
if (observer) {
observer.disconnect();
observer = null;
}
observedRoot = null;
waitingForTrackForm = false;
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;
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 === 'characterData') {
const parent = mutation.target?.parentElement;
if (parent?.tagName === 'OPTION') return true;
if (parent?.closest?.('select.detailTrackSelect')) return true;
continue;
}
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.matches?.(FORM_SELECTOR)) return true;
if (node.querySelector?.('select.detailTrackSelect, option, form.trackSelections')) return true;
}
}
return false;
}
function mutationIntroducesTrackForm(mutations) {
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.(FORM_SELECTOR)) return true;
if (node.querySelector?.(FORM_SELECTOR)) return true;
}
}
return false;
}
/**
* Observe a narrow root (prefer form.trackSelections; fall back to visible details page).
* If observing the page and the form appears, promote to the form.
*/
function observeRoot(root, pendingForm) {
if (!root) return;
disconnectObserver();
waitingForTrackForm = !!pendingForm;
observedRoot = root;
observer = new MutationObserver((mutations) => {
if (isApplying) return;
if (waitingForTrackForm) {
const form =
(mutationIntroducesTrackForm(mutations) && findTrackSelectionsForm(observedRoot)) ||
findTrackSelectionsForm(observedRoot);
if (form) {
observeRoot(form, false);
tryApply();
return;
}
}
if (!isRelevantMutation(mutations)) return;
scheduleTryApply();
});
observer.observe(root, { childList: true, subtree: true, characterData: true });
}
function attachDetailsObserver() {
disconnectObserver();
const page = findDetailsPage();
if (!page) return;
const form = findTrackSelectionsForm(page);
if (form) {
observeRoot(form, false);
} else {
observeRoot(page, true);
}
tryApply();
}
function onDetailsPage() {
attachDetailsObserver();
}
function onAnyPage(view, _doc, hash) {
if (isDetailsHash(hash) || (typeof view === 'string' && /details/i.test(view))) {
return;
}
disconnectObserver();
}
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, doc, hash) => {
try {
onAnyPage(view, doc, hash);
} catch (err) {
WARN('Failed to clean up version preferences observer', err);
}
});
if (isDetailsHash()) {
attachDetailsObserver();
}
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
+1580 -1185
View File
File diff suppressed because it is too large Load Diff
+1479 -988
View File
File diff suppressed because it is too large Load Diff