Improve onViewPage util, improve top person caching for home page, improve watchlist caching, fixes for mobile UI and Flatten Shows improvements

This commit is contained in:
HighImKevin committed 2025-11-09 18:56:05 -05:00
1 parent a4c492728e
commit f95c9abd8c
18 files changed
+1055 -478

No files matched your search

+8 -5
View File
@@ -6,6 +6,8 @@ While working on these scripts to meet my personal needs, I noticed that there w
There is no configuration required, but if you wish to customize some of the features, there are options to do so.
I think it's also worth mentioning that before the 1.0 release, you can expect to potentially encounter some issues or bugs. I try to address these as quickly as I can, and I appreciate your time for raising the issues in the first place!
## Table of Contents
- [Installation](#-installation)
@@ -46,17 +48,18 @@ There is no configuration required, but if you wish to customize some of the fea
- [Planned Features](#planned-features)
- [Version History](#version-history)
## 📦 Installation
## Installation
### Prerequisites
- Jellyfin 10.10.7 or later
- Jellyfin 10.10.7 or earlier (10.11.X is untested and "unsupported" but may be mostly functional)
- [JS Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector) plugin installed and configured
- [Custom Tabs](https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs) plugin for watchlist functionality
- [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) plugin for Jellyseerr search functionality and ExclusiveElsewhere
### Setup Instructions
0. **Install Prerequisites listed above**
0a. **Install Prerequisites listed above**
0b. **If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings**
1. **Add a new script to your JS Injector Plugin**
2. **Copy this entire contents of kefinTweaks.js into the new script**
@@ -98,8 +101,8 @@ The features in KefinTweaks which use local data caching are listed below, along
- **Top People** [24h]:
In order to populate the "Top People" in your movie library, we fetch the Person data for every movie in your library. We build a list of all the People who appear in at least X number of items based on their type (Actor/Director/Writer). This is an expensive operation, and is longer as the size of your library grows.
- Watchlist
- **Watchlist Items** [24h]:
This is mostly done to provide a more responsive UX, as typically the API call to retreive these items should not be very expensive. The Watchlist cache is also updated any time there is a change to an item's Play State or Watchlist status. Keep in mind that the cache can only be updated locally, so if you watch a movie from your watchlist on Device X, and then you browse your Watchlist on Device Y, the Watchlist will still show the watched item until the cache is manually refreshed or it expires.
- **Watchlist Items** [5m]:
This is mostly done to provide a more responsive UX, as typically the API call to retreive these items should not be very expensive. The Watchlist cache is also updated any time there is a change to an item's Play State or Watchlist status.
- **Series Progress and Movie History** [24h]:
These are both more expensive operations, especially if you have watched a very high number of Movies or Shows. It's not practical to fetch this data on demand so we cache it to improve UX.
- Collections on Details Page
+88 -89
View File
@@ -1,104 +1,103 @@
/* KefinTweaks Breadcrumbs Styles */
.kefinTweaks-breadcrumbs-wrapper {
margin-left: -.5em;
transition: background 0.2s ease;
display: none;
}
.kefinTweaks-breadcrumbs-wrapper:hover {
background: rgba(0, 0, 0, 0.5);
border-radius: 0.5em;
}
.kefinTweaks-breadcrumbs {
color: rgba(255, 255, 255, 0.8);
font-size: 1.17em;
font-family: inherit;
pointer-events: auto;
position: relative;
padding: 8px 12px;
border-radius: 4px;
word-wrap: break-word;
display: inline-block;
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1001;
}
.kefinTweaks-breadcrumbs span {
display: inline;
}
.kefinTweaks-breadcrumbs span:hover {
color: rgba(255, 255, 255, 1);
}
/* Hover effects for clickable breadcrumbs only */
.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
text-decoration: underline !important;
}
/* Responsive behavior */
@media (max-width: 768px) {
@media (min-width: 768px) {
.kefinTweaks-breadcrumbs-wrapper {
display: none !important;
display: block;
margin-left: -.5em;
transition: background 0.2s ease;
}
}
/* 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;
}
.kefinTweaks-breadcrumbs-wrapper:hover {
background: rgba(0, 0, 0, 0.5);
border-radius: 0.5em;
}
.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-breadcrumbs {
color: rgba(255, 255, 255, 0.8);
font-size: 1.17em;
font-family: inherit;
pointer-events: auto;
position: relative;
padding: 8px 12px;
border-radius: 4px;
word-wrap: break-word;
display: inline-block;
}
.kefinTweaks-popover-item:hover {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
left: 0;
z-index: 1001;
}
.kefinTweaks-popover-item:last-child {
border-bottom: none;
}
.kefinTweaks-breadcrumbs span {
display: inline;
}
.kefinTweaks-popover-item.selected {
color: rgba(255, 255, 255, 1);
font-weight: bold;
}
.kefinTweaks-breadcrumbs span:hover {
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-popover-item.selected::before {
content: '>';
position: absolute;
left: 8px;
top: calc(50% - 1px);
transform: translateY(-50%);
font-weight: bold;
}
/* Hover effects for clickable breadcrumbs only */
.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
text-decoration: underline !important;
}
.skinHeader {
contain: none;
}
/* 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;
}
.headerLeft {
overflow: visible;
.kefinTweaks-popover-item {
padding: 8px 16px 8px 24px;
cursor: pointer;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
.kefinTweaks-popover-item:hover {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 1);
}
.kefinTweaks-popover-item: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;
}
}
+1 -4
View File
@@ -515,8 +515,6 @@
const currentPath = Emby.Page.lastPath || '';
const isSupportedPage = currentPath.startsWith('/details?');
log('showBreadcrumbs called - wrapper exists:', !!wrapper, 'isWideScreen:', wideScreen, 'isSupportedPage:', isSupportedPage, 'currentPath:', currentPath);
if (wrapper && wideScreen && isSupportedPage) {
wrapper.style.display = 'block';
log('Breadcrumbs shown successfully');
@@ -878,7 +876,7 @@
log('Registering breadcrumb handler with KefinTweaksUtils');
// Register handler for all pages (breadcrumbs can appear on any detail page)
window.KefinTweaksUtils.onViewPage(async (view, element, itemPromise) => {
window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => {
try {
// Await the item promise to get the actual item data
const item = await itemPromise;
@@ -888,7 +886,6 @@
error('Breadcrumb page change handler failed:', err);
}
}, {
immediate: true,
pages: [] // Empty array means all pages
});
} else {
+8
View File
@@ -23,4 +23,12 @@ div#seriesScheduleSection {
.commentsSection {
order: 3;
}
@media (max-width: 768px) {
.headerRight .btnRecommendations,
.headerRight .btnAdmin,
.headerRight .headerAppearanceButton {
display: none;
}
}
+156 -7
View File
@@ -9,6 +9,51 @@
const WARN = (...args) => console.warn('[KefinTweaks FlattenSingleSeasonShows]', ...args);
const ERR = (...args) => console.error('[KefinTweaks FlattenSingleSeasonShows]', ...args);
/**
* Fetches the Next Up episode for a given series
* @param {string} seriesId - The series ID
* @returns {Promise<Object|null>} - Episode number object with season and episode, or null
*/
async function fetchNextUpEpisode(seriesId) {
try {
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching Next Up episode for series: ${seriesId}`);
const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`;
const nextUpRes = await fetch(nextUpUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!nextUpRes.ok) {
throw new Error(`HTTP ${nextUpRes.status}: ${nextUpRes.statusText}`);
}
const nextUpData = await nextUpRes.json();
const items = nextUpData.Items || [];
if (items.length > 0) {
const item = items[0];
if (item.IndexNumber && item.ParentIndexNumber) {
const episodeNumber = {
season: item.ParentIndexNumber,
episode: item.IndexNumber
};
LOG(`Fetched Next Up episode from API: S${episodeNumber.season}:E${episodeNumber.episode}`);
return episodeNumber;
}
}
LOG('No Next Up episode found in API response');
return null;
} catch (error) {
ERR(`Failed to fetch Next Up episode for series ${seriesId}:`, error);
return null;
}
}
/**
* Fetches all episodes for a given series
* @param {string} seriesId - The series ID
@@ -22,7 +67,7 @@
const token = apiClient.accessToken();
LOG(`Fetching episodes for series: ${seriesId}`);
const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData&EnableImageTypes=Primary`;
const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`;
const episodesRes = await fetch(episodesUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
@@ -104,12 +149,51 @@
WARN('Could not determine season ID from episodes');
}
// Render Season 1 section using renderCards
// Check if Next Up section exists
const nextUpSection = activePage?.querySelector('.nextUpSection');
const nextUpItemsContainer = nextUpSection?.querySelector('.itemsContainer');
// Extract episode info from Next Up if it exists
let targetEpisodeNumber = null;
if (nextUpSection && nextUpItemsContainer) {
LOG('Next Up section found, hiding nextUpSection');
// Get the existing card from Next Up and extract episode info
const existingCard = nextUpItemsContainer.querySelector('.card');
if (existingCard) {
// Find the .cardText a element that contains the episode number
const cardTextLinks = existingCard.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
// Match SX:EY pattern (may be part of longer text like "S1:E5 - Episode Name")
const match = linkText.match(/S(\d+):E(\d+)/);
if (match) {
targetEpisodeNumber = {
season: parseInt(match[1], 10),
episode: parseInt(match[2], 10)
};
LOG(`Found Next Up episode from DOM: S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
break;
}
}
}
// If we couldn't find it from the DOM, fetch from API
if (!targetEpisodeNumber) {
LOG('Could not find episode number from DOM, fetching from API');
targetEpisodeNumber = await fetchNextUpEpisode(seriesId);
}
// Hide the entire Next Up section
nextUpSection.style.display = 'none';
}
// Render Season 1 section using renderCards (default behavior)
const seasonSection = window.cardBuilder.renderCards(
episodes,
'Season 1',
viewMoreUrl,
false, // overflowCard
true, // overflowCard
null // cardFormat (use default)
);
@@ -130,6 +214,72 @@
// Prepend the Season 1 section right before #childrenCollapsible
childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible);
// Check if childrenCollapsible has only one child in its itemsContainer, and hide it if so
const childrenItemsContainer = childrenCollapsible.querySelector('.itemsContainer');
if (childrenItemsContainer) {
LOG('childrenCollapsible contains only one child, hiding it');
childrenCollapsible.style.display = 'none';
}
// If we found a target episode from Next Up, scroll to it
if (targetEpisodeNumber && scrollerContainer) {
// Wait for cards to be rendered and positioned
setTimeout(() => {
const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer');
if (!scrollerItemsContainer) {
WARN('Could not find itemsContainer in scroller');
return;
}
// Find the card that matches the target episode number
const cards = scrollerItemsContainer.querySelectorAll('.card');
let targetCard = null;
for (const card of cards) {
const cardTextLinks = card.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
const match = linkText.match(/S(\d+):E(\d+)/);
if (match &&
parseInt(match[1], 10) === targetEpisodeNumber.season &&
parseInt(match[2], 10) === targetEpisodeNumber.episode) {
targetCard = card;
break;
}
}
if (targetCard) break;
}
// Scroll to make the target card the leftmost visible element
if (targetCard) {
const itemsContainer = scrollerItemsContainer;
const cardOffset = targetCard.offsetLeft;
const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0;
// Calculate the translateX value (negative to move left)
// The card offset minus padding gives us how much to translate
const translateX = -(cardOffset - scrollerPadding);
// Update the transform property on the itemsContainer
itemsContainer.style.transform = `translateX(${translateX}px)`;
// Enable the left scroll button so users can scroll back
const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons');
if (scrollButtons) {
const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
if (leftButton) {
leftButton.removeAttribute('disabled');
LOG('Enabled left scroll button');
}
}
LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode} using translateX(${translateX}px)`);
} else {
WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
}
}, 100);
}
// Mark as flattened for this series to avoid duplicates
if (activePage) {
activePage.dataset.flattenedSeriesId = seriesId;
@@ -154,7 +304,7 @@
LOG('Registering flatten single season shows handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
async (view, element, itemPromise) => {
async (view, element, hash, itemPromise) => {
// Only handle details pages
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) return;
@@ -162,9 +312,9 @@
// Remove any existing flattened section and reset flag when page changes
const existingSection = activePage.querySelector('.flattened-season-section');
if (existingSection) {
existingSection.remove();
LOG('Flattened section already exists, skipping');
return;
}
delete activePage.dataset.flattenedSeriesId;
// Await the item promise to get the actual item data
const item = await itemPromise;
@@ -180,7 +330,6 @@
}
},
{
immediate: true,
pages: ['details']
}
);
+2 -3
View File
@@ -89,7 +89,7 @@
}
// Manually notify handlers for Header Tab navigation because it doesn't trigger normal Jellyfin navigation
window.KefinTweaksUtils.notifyHandlers(currentPage, document);
window.KefinTweaksUtils.notifyHandlers(currentPage, document, newHash);
// Replace history entry instead of adding new one
const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1);
@@ -343,8 +343,7 @@
// Sync active tab state when page view changes
syncActiveTabState();
}, {
pages: SUPPORTED_PAGES, // Only trigger for supported pages
immediate: false
pages: SUPPORTED_PAGES // Only trigger for supported pages
});
LOG('Registered onViewPage handler for syncActiveTabState');
} else {
+6 -1
View File
@@ -16,7 +16,7 @@
order: 1;
}
.sections.homeSectionsContainer>div:has(div[data-positionticks]) {
.sections.homeSectionsContainer>div:has(>.emby-scroller div[data-positionticks]) {
order: 2;
}
@@ -86,4 +86,9 @@ div#discovery-loading-indicator:not(.show)::after {
#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after {
content: '';
}
/* Ensure Editor's Choice Banner appears at top of home screen if enabled */
.editorsChoiceContainer {
order: 1 !important;
}
+227 -175
View File
@@ -177,7 +177,6 @@
// Set up page view monitoring
if (window.KefinTweaksUtils) {
window.KefinTweaksUtils.onViewPage(manageBodyClasses, {
immediate: true,
pages: []
});
LOG('Registered body class management for home page');
@@ -771,22 +770,25 @@
const token = apiClient.accessToken();
const userId = apiClient.getCurrentUserId();
const cache = new window.LocalStorageCache();
const indexedDBCache = new window.IndexedDBCache();
try {
LOG('Starting paginated fetch of movies for people data processing...');
let allMovies = [];
let startIndex = 0;
const limit = 500;
let hasMoreData = true;
let peopleMap = null; // Will hold raw (unfiltered) people data
// Load existing partial data if available
const existingData = cache.get('movies_top_people');
if (existingData && !existingData.isComplete) {
const moviesProcessed = countMoviesFromPeopleData(existingData);
// Load existing raw people data if available (for resuming) - use IndexedDB
const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId);
if (existingRawData && !existingRawData.isComplete) {
const moviesProcessed = existingRawData.moviesProcessedCount;
if (moviesProcessed > 0) {
startIndex = moviesProcessed;
LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`);
// Reconstruct Map from cached array
peopleMap = new Map(existingRawData.peopleData || []);
LOG(`Resuming from existing data: ${moviesProcessed} movies already processed, ${peopleMap.size} people in cache`);
}
}
@@ -813,26 +815,29 @@
hasMoreData = false;
LOG('No more movies to fetch');
} else {
allMovies = allMovies.concat(movies);
// Process new movies and merge with existing people data
peopleMap = processPeopleDataRaw(movies, peopleMap);
startIndex += movies.length;
LOG(`Fetched ${movies.length} movies, total: ${allMovies.length}`);
LOG(`Fetched ${movies.length} movies, total processed: ${startIndex}, people tracked: ${peopleMap ? peopleMap.size : 0}`);
// Process and cache partial data after each chunk
const partialPeopleData = processPeopleData(allMovies);
if (partialPeopleData) {
// Mark as incomplete and store movie count for resuming
partialPeopleData.isComplete = movies.length < limit; // Complete if we got less than limit
partialPeopleData.moviesProcessedCount = allMovies.length;
// Filter for display (but cache raw data)
const filteredData = filterPeopleData(peopleMap);
if (filteredData) {
// Update the global moviesTopPeople immediately so discovery sections can use it
moviesTopPeople = partialPeopleData;
// Cache partial data
cache.set('movies_top_people', partialPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
LOG(`Updated moviesTopPeople with partial data: ${partialPeopleData.actors.length} actors, ${partialPeopleData.directors.length} directors, ${partialPeopleData.writers.length} writers (${allMovies.length} movies processed)`);
moviesTopPeople = filteredData;
LOG(`Updated moviesTopPeople with partial data: ${filteredData.actors.length} actors, ${filteredData.directors.length} directors, ${filteredData.writers.length} writers (${startIndex} movies processed)`);
}
// Cache RAW (unfiltered) data for resuming - use IndexedDB for large data
// Convert Map to array for JSON serialization
const rawDataToCache = {
isComplete: movies.length < limit,
moviesProcessedCount: startIndex,
peopleData: Array.from(peopleMap.entries())
};
await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000);
// If we got less than the limit, we've reached the end
if (movies.length < limit) {
hasMoreData = false;
@@ -842,20 +847,23 @@
}
}
// Final processing of all data
LOG(`Final processing of ${allMovies.length} movies...`);
const finalPeopleData = processPeopleData(allMovies);
// Final processing: filter the complete raw data
LOG(`Final filtering of people data from ${startIndex} movies...`);
const finalPeopleData = filterPeopleData(peopleMap);
if (finalPeopleData) {
// Mark as complete and remove movies count to save space
// Mark as complete
finalPeopleData.isComplete = true;
delete finalPeopleData.moviesProcessedCount;
// Update global variable
moviesTopPeople = finalPeopleData;
// Cache final data
cache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
// Cache final filtered data (for quick loading) - use IndexedDBCache for larger data with items
await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
// Also remove raw cache since we're complete (optional, saves space)
await indexedDBCache.clear('movies_top_people_raw', userId);
LOG(`Final cached people data: ${finalPeopleData.actors.length} actors, ${finalPeopleData.directors.length} directors, ${finalPeopleData.writers.length} writers`);
}
@@ -868,53 +876,109 @@
}
/**
* Processes people data from movies array
* @param {Array} movies - Array of movie objects with People data
* @returns {Object|null} - Processed people data or null if failed
* Extracts necessary item data for caching (similar to watchlist format)
* @param {Object} item - Full Jellyfin item object
* @returns {Object} - Optimized item data for storage
*/
function processPeopleData(movies) {
try {
const peopleMap = new Map();
movies.forEach(movie => {
if (movie.People) {
movie.People.forEach(person => {
const key = person.Id;
if (!peopleMap.has(key)) {
peopleMap.set(key, {
id: person.Id,
name: person.Name,
directorCount: 0,
writerCount: 0,
actorCount: 0,
directorItemIds: [],
writerItemIds: [],
actorItemIds: []
});
}
const personData = peopleMap.get(key);
if (person.Type === 'Director') {
personData.directorCount++;
if (!personData.directorItemIds.includes(movie.Id)) {
personData.directorItemIds.push(movie.Id);
}
} else if (person.Type === 'Writer') {
personData.writerCount++;
if (!personData.writerItemIds.includes(movie.Id)) {
personData.writerItemIds.push(movie.Id);
}
} else if (person.Type === 'Actor') {
personData.actorCount++;
if (!personData.actorItemIds.includes(movie.Id)) {
personData.actorItemIds.push(movie.Id);
}
}
});
function extractItemDataForCache(item) {
return {
Id: item.Id,
Type: item.Type,
MediaType: item.MediaType,
Name: item.Name,
SeriesId: item.SeriesId,
SeriesName: item.SeriesName,
SeriesPrimaryImageTag: item.SeriesPrimaryImageTag,
ParentBackdropItemId: item.ParentBackdropItemId,
ParentBackdropImageTags: item.ParentBackdropImageTags,
ParentThumbImageTag: item.ParentPrimaryImageTags,
IndexNumber: item.IndexNumber,
ParentIndexNumber: item.ParentIndexNumber,
ImageTags: item.ImageTags,
BackdropImageTags: item.BackdropImageTags,
PremiereDate: item.PremiereDate,
ProductionYear: item.ProductionYear,
UserData: {
IsFavorite: item.UserData?.IsFavorite || false,
Played: item.UserData?.Played || false,
LastPlayedDate: item.UserData?.LastPlayedDate || null,
PlayCount: item.UserData?.PlayCount || 0,
Likes: item.UserData?.Likes || false
}
});
// Filter and sort people by count, taking top 100 per category
};
}
/**
* Processes people data from movies array and returns RAW data (all people, unfiltered)
* @param {Array} movies - Array of movie objects with People data
* @param {Map} existingPeopleMap - Optional existing people map to merge with
* @returns {Map|null} - Map of all people data (unfiltered) or null if failed
*/
function processPeopleDataRaw(movies, existingPeopleMap = null) {
try {
const peopleMap = existingPeopleMap ? new Map(existingPeopleMap) : new Map();
movies.forEach(movie => {
// Extract item data for caching
const itemData = extractItemDataForCache(movie);
if (movie.People) {
movie.People.forEach(person => {
const key = person.Id;
if (!peopleMap.has(key)) {
peopleMap.set(key, {
id: person.Id,
name: person.Name,
directorCount: 0,
writerCount: 0,
actorCount: 0,
directorItems: [],
writerItems: [],
actorItems: []
});
}
const personData = peopleMap.get(key);
if (person.Type === 'Director') {
personData.directorCount++;
// Check if item already exists by Id
if (!personData.directorItems.some(item => item.Id === movie.Id)) {
personData.directorItems.push(itemData);
}
} else if (person.Type === 'Writer') {
personData.writerCount++;
if (!personData.writerItems.some(item => item.Id === movie.Id)) {
personData.writerItems.push(itemData);
}
} else if (person.Type === 'Actor') {
personData.actorCount++;
if (!personData.actorItems.some(item => item.Id === movie.Id)) {
personData.actorItems.push(itemData);
}
}
});
}
});
return peopleMap;
} catch (err) {
ERR('Failed to process people data:', err);
return null;
}
}
/**
* Filters and sorts raw people data by minPeopleAppearances, returning top 100 per category
* @param {Map} peopleMap - Map of all people data (unfiltered)
* @returns {Object|null} - Filtered and sorted people data or null if failed
*/
function filterPeopleData(peopleMap) {
try {
if (!peopleMap) {
return null;
}
const allPeople = Array.from(peopleMap.values());
const topDirectors = allPeople
@@ -925,7 +989,7 @@
id: person.id,
name: person.name,
count: person.directorCount,
itemIds: person.directorItemIds
items: person.directorItems || []
}));
const topWriters = allPeople
@@ -936,7 +1000,7 @@
id: person.id,
name: person.name,
count: person.writerCount,
itemIds: person.writerItemIds
items: person.writerItems || []
}));
const topActors = allPeople
@@ -947,7 +1011,7 @@
id: person.id,
name: person.name,
count: person.actorCount,
itemIds: person.actorItemIds
items: person.actorItems || []
}));
return {
@@ -957,11 +1021,21 @@
};
} catch (err) {
ERR('Failed to process people data:', err);
ERR('Failed to filter people data:', err);
return null;
}
}
/**
* Processes people data from movies array (legacy function, now uses raw processing + filtering)
* @param {Array} movies - Array of movie objects with People data
* @returns {Object|null} - Processed people data or null if failed
*/
function processPeopleData(movies) {
const peopleMap = processPeopleDataRaw(movies);
return filterPeopleData(peopleMap);
}
/**
* Initializes the people cache in the background
@@ -979,43 +1053,48 @@
return;
}
const cache = new window.LocalStorageCache();
const indexedDBCache = new window.IndexedDBCache();
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
// Check if we have valid cached data
if (cache.isCacheValid('movies_top_people')) {
const cachedData = cache.get('movies_top_people');
// First, check if we have valid complete filtered data (in IndexedDBCache)
if (await indexedDBCache.isCacheValid('movies_top_people', userId)) {
const cachedData = await indexedDBCache.get('movies_top_people', userId);
// Check if data is complete
if (cachedData.isComplete) {
if (cachedData && cachedData.isComplete) {
moviesTopPeople = cachedData;
isPeopleCacheComplete = true;
LOG('Loaded complete top people data from cache');
LOG('Loaded complete top people data from IndexedDB cache');
return;
} else if (cachedData.moviesProcessedCount) {
// Use the stored count for resuming
const moviesProcessed = cachedData.moviesProcessedCount;
if (moviesProcessed > 0 && moviesProcessed % 500 === 0) {
// We have a complete set of 500-item chunks, continue loading
moviesTopPeople = cachedData;
isPeopleCacheComplete = false;
LOG(`Loaded partial top people data from cache (${moviesProcessed} movies), will continue fetching...`);
} else if (moviesProcessed > 0) {
// We have incomplete data, use what we have but don't continue loading
moviesTopPeople = cachedData;
isPeopleCacheComplete = true;
LOG(`Loaded incomplete top people data from cache (${moviesProcessed} movies), not divisible by 500, using as-is`);
return;
} else {
// We have partial data without count info, load it and continue fetching
moviesTopPeople = cachedData;
isPeopleCacheComplete = false;
LOG('Loaded partial top people data from cache, will continue fetching...');
}
}
// If no complete data, check for raw partial data (in IndexedDBCache)
if (await indexedDBCache.isCacheValid('movies_top_people_raw', userId)) {
const rawCachedData = await indexedDBCache.get('movies_top_people_raw', userId);
if (rawCachedData && rawCachedData.peopleData) {
// Reconstruct Map and filter for immediate display
const peopleMap = new Map(rawCachedData.peopleData);
const filteredData = filterPeopleData(peopleMap);
if (filteredData) {
moviesTopPeople = filteredData;
isPeopleCacheComplete = rawCachedData.isComplete || false;
LOG(`Loaded partial top people data from raw cache (${rawCachedData.moviesProcessedCount || 0} movies), ${filteredData.actors.length} actors, ${filteredData.directors.length} directors, ${filteredData.writers.length} writers`);
// If incomplete, we'll continue fetching in background
if (!isPeopleCacheComplete) {
// Continue in background
} else {
// It's marked complete but wasn't in filtered cache, update filtered cache
filteredData.isComplete = true;
await indexedDBCache.set('movies_top_people', filteredData, userId, 7 * 24 * 60 * 60 * 1000);
await indexedDBCache.clear('movies_top_people_raw', userId);
return;
}
}
} else {
// No moviesProcessedCount stored, treat as incomplete and continue fetching
moviesTopPeople = cachedData;
isPeopleCacheComplete = false;
LOG('Loaded partial top people data from cache (no count stored), will continue fetching...');
}
}
@@ -1745,7 +1824,7 @@
// Render the scrollable container
const scrollableContainer = window.cardBuilder.renderCards(
movies,
'New Movies',
'Recently Released Movies',
null,
true
);
@@ -1788,7 +1867,7 @@
// Render the scrollable container
const scrollableContainer = window.cardBuilder.renderCards(
episodes,
'New Episodes',
'Recently Aired Episodes',
null,
true
);
@@ -1908,7 +1987,7 @@
// Render the scrollable container with the network/studio items
const scrollableContainer = window.cardBuilder.renderCards(
limitedNetworks,
'Popular TV Networks',
'Popular TV Studios',
null,
true,
'backdrop'
@@ -1984,78 +2063,63 @@
return null;
})(),
// 2. Directed by [Director] (async - fetch actual items)
// 2. Directed by [Director] (use cached items directly)
(async () => {
if (moviesTopPeople && moviesTopPeople.directors.length > 0) {
const randomDirector = getRandomItem(moviesTopPeople.directors, renderedDirectors);
if (randomDirector && randomDirector.itemIds && randomDirector.itemIds.length > 0) {
// Validate that itemIds contain actual valid IDs (not undefined/null)
const validItemIds = randomDirector.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== '');
if (validItemIds.length > 0) {
// Fetch actual items from API
const shuffledIds = shuffleArray(validItemIds).slice(0, 16);
const items = await fetchItemsByIds(shuffledIds);
if (items && items.length > 0) {
return {
type: 'director',
data: randomDirector,
items: items,
viewMoreUrl: `#/details?id=${randomDirector.id}&serverId=${ApiClient.serverId()}`
};
}
if (randomDirector && randomDirector.items && randomDirector.items.length > 0) {
// Use cached items directly - no need to fetch from API
const shuffledItems = shuffleArray(randomDirector.items).slice(0, 16);
if (shuffledItems && shuffledItems.length > 0) {
return {
type: 'director',
data: randomDirector,
items: shuffledItems,
viewMoreUrl: `#/details?id=${randomDirector.id}&serverId=${ApiClient.serverId()}`
};
}
}
}
return null;
})(),
// 3. Written by [Writer] (async - fetch actual items)
// 3. Written by [Writer] (use cached items directly)
(async () => {
if (moviesTopPeople && moviesTopPeople.writers.length > 0) {
const randomWriter = getRandomItem(moviesTopPeople.writers, renderedWriters);
if (randomWriter && randomWriter.itemIds && randomWriter.itemIds.length > 0) {
// Validate that itemIds contain actual valid IDs (not undefined/null)
const validItemIds = randomWriter.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== '');
if (validItemIds.length > 0) {
// Fetch actual items from API
const shuffledIds = shuffleArray(validItemIds).slice(0, 16);
const items = await fetchItemsByIds(shuffledIds);
if (items && items.length > 0) {
return {
type: 'writer',
data: randomWriter,
items: items,
viewMoreUrl: `#/details?id=${randomWriter.id}&serverId=${ApiClient.serverId()}`
};
}
if (randomWriter && randomWriter.items && randomWriter.items.length > 0) {
// Use cached items directly - no need to fetch from API
const shuffledItems = shuffleArray(randomWriter.items).slice(0, 16);
if (shuffledItems && shuffledItems.length > 0) {
return {
type: 'writer',
data: randomWriter,
items: shuffledItems,
viewMoreUrl: `#/details?id=${randomWriter.id}&serverId=${ApiClient.serverId()}`
};
}
}
}
return null;
})(),
// 4. Starring [Actor] (async - fetch actual items)
// 4. Starring [Actor] (use cached items directly)
(async () => {
if (moviesTopPeople && moviesTopPeople.actors.length > 0) {
const randomActor = getRandomItem(moviesTopPeople.actors, renderedActors);
if (randomActor && randomActor.itemIds && randomActor.itemIds.length > 0) {
// Validate that itemIds contain actual valid IDs (not undefined/null)
const validItemIds = randomActor.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== '');
if (validItemIds.length > 0) {
// Fetch actual items from API
const shuffledIds = shuffleArray(validItemIds).slice(0, 16);
const items = await fetchItemsByIds(shuffledIds);
if (items && items.length > 0) {
return {
type: 'actor',
data: randomActor,
items: items,
viewMoreUrl: `#/details?id=${randomActor.id}&serverId=${ApiClient.serverId()}`
};
}
if (randomActor && randomActor.items && randomActor.items.length > 0) {
// Use cached items directly - no need to fetch from API
const shuffledItems = shuffleArray(randomActor.items).slice(0, 16);
if (shuffledItems && shuffledItems.length > 0) {
return {
type: 'actor',
data: randomActor,
items: shuffledItems,
viewMoreUrl: `#/details?id=${randomActor.id}&serverId=${ApiClient.serverId()}`
};
}
}
}
@@ -3526,19 +3590,7 @@
}
// Check if sections are already rendered
const hasRenderedCustomSections = customHomeSections.some(section =>
homeSectionsContainer.querySelector(`[data-custom-section-id="${section.id}"]`)
);
const hasRenderedNewAndTrendingSections = (enableNewMovies && homeSectionsContainer.querySelector('[data-custom-section-id="new-movies"]')) ||
(enableNewEpisodes && homeSectionsContainer.querySelector('[data-custom-section-id="new-episodes"]')) ||
(enableTrending && homeSectionsContainer.querySelector('[data-custom-section-id="trending-movies"]'));
const hasRenderedDiscoverySections = (enableWatchlist && homeSectionsContainer.querySelector('[data-custom-section-id="watchlist"]')) ||
(enableDiscovery && homeSectionsContainer.querySelector('[data-custom-section-id="watched-movie"]')) ||
(enableSeasonal && homeSectionsContainer.querySelector('[data-custom-section-id="halloween-movies"]'));
if (hasRenderedCustomSections || hasRenderedNewAndTrendingSections || hasRenderedDiscoverySections) {
if (homeSectionsContainer.dataset.customSectionsRendered === 'true') {
return;
}
@@ -3546,6 +3598,7 @@
// Set processing flag to prevent parallel execution
isProcessing = true;
homeSectionsContainer.dataset.customSectionsRendered = 'true';
try {
LOG('Starting parallel initialization of home screen sections...');
@@ -3605,7 +3658,6 @@
ERR('Home screen page change handler failed:', err);
}
}, {
immediate: true,
pages: ['home', 'home.html']
});
} else {
+1 -2
View File
@@ -194,7 +194,7 @@
LOG('Registering collections handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
async (view, element, itemPromise) => {
async (view, element, hash, itemPromise) => {
// Only handle details pages
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) return;
@@ -210,7 +210,6 @@
}, 100);
},
{
immediate: true,
pages: ['details']
}
);
+3 -1
View File
@@ -18,7 +18,8 @@
//
// INSTALLATION:
// ============
// 0. Install required prerequisites listed above
// 0a. Install required prerequisites listed above
// 0b. If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings
// 1. Add a new script to your JS Injector plugin
// 2. Copy this entire file into the new script
// (Optional) Enable/disable features, or customize their configuration by editing the script below
@@ -61,6 +62,7 @@
homeScreen: true, // Custom home screen sections and discovery engine
search: true, // Enhanced search with better UI and Jellyseerr integration
// REQUIREMENTS: Watchlist requires Custom Tabs plugin https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs
// If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings
watchlist: true, // Complete watchlist management with progress tracking
// UI and navigation improvements
+1 -2
View File
@@ -227,7 +227,7 @@
LOG('Registering playlist handler with KefinTweaksUtils');
// Register handler for details pages
window.KefinTweaksUtils.onViewPage(async (view, element, itemPromise) => {
window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => {
// Await the item promise to get the actual item data
const item = await itemPromise;
if (item && item.Type === 'Playlist') {
@@ -235,7 +235,6 @@
return;
}
}, {
immediate: true,
pages: ['details']
});
+1 -2
View File
@@ -59,7 +59,7 @@
// config
const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person'];
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist'];
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist', 'MusicVideo'];
const BOOKS_TYPES = ['Book', 'AudioBook'];
const OTHER_TYPES = ['Playlist', 'Photo', 'PhotoAlbum', 'TvChannel', 'LiveTvProgram', 'BoxSet'];
@@ -677,7 +677,6 @@
}
}, 100);
}, {
immediate: true,
pages: ['search']
});
+8 -4
View File
@@ -282,15 +282,19 @@
// Register for the display preferences page
unregisterDisplayPreferencesHandler = window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, {
pages: ['mypreferencesdisplay'],
immediate: true
pages: ['mypreferencesdisplay']
});
// Register for all pages to add the appearance button
unregisterAnyPageHandler = window.KefinTweaksUtils.onViewPage(handleAnyPage, {
pages: [],
immediate: true
pages: []
});
/* if (window.location.hash.includes('mypreferencesdisplay')) {
handleDisplayPreferencesPage();
}
handleAnyPage(); */
LOG('SkinManager handlers registered');
}
+73
View File
@@ -0,0 +1,73 @@
.material-icons.book:before {
content: "\e062";
}
.material-icons.newspaper:before {
content: "\e34c";
}
.material-icons.quiz:before {
content: "\e34b";
}
.material-icons.person_add:before {
content: '\e1a2';
}
.material-icons.help:before {
content: '\e0ff';
}
.material-icons.bookmark:before, .material-icons.bookmark_border:before, .material-icons.watchlist:before {
content: '\e064';
}
.material-icons.bookmark_border:before {
content: '\e064';
}
.material-icons.thumb_up:before {
content: '\e18a';
}
.material-icons.palette:before {
content: '\e1dd';
}
.material-icons.star:before {
content: '\e17a';
}
.material-icons.sort:before {
content: '\e04b';
}
.material-icons.arrow_drop_down:before {
content: '\e071';
}
.material-icons.play_circle:before {
content: '\e084';
}
.material-icons.check_circle_outline:before {
content: '\e226';
}
.material-icons.favorite_border:before {
content: '\e0f6';
}
/* Lazy workaround for the pause button on the media bar plugin */
.pause-button .material-icons {
font-family: var(--iconPack) !important;
}
/* Jellyfin Enhanced Random Item Button */
button#randomItemButton>i {
display: none;
}
button#randomItemButton:before {
content: '\e28c';
font-family: lucide !important;
font-weight: 400;
font-style: normal;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "liga";
}
+10
View File
@@ -38,6 +38,16 @@
],
colorSchemes: []
},
{
name: 'NeutralFin',
author: 'KartoffelChipss',
url: [
'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',
`${KefinTweaksConfig.scriptRoot}skins/neutralfin-kefin.css`
],
colorSchemes: []
},
{
name: 'Jellypane',
author: 'tedhinklater',
+12 -16
View File
@@ -36,7 +36,7 @@
}
// Call our registered handlers
notifyHandlers(args[0], args[1]);
notifyHandlers(args[0], args[1], window.location.hash);
};
LOG('Hooked into Emby.Page.onViewShow');
@@ -68,7 +68,6 @@
const handlerConfig = {
callback,
options: {
immediate: false, // Call immediately if on matching page
pages: [], // Specific pages to watch (empty = all pages)
...options
}
@@ -77,18 +76,15 @@
handlers.push(handlerConfig);
LOG(`Registered onViewPage handler (total: ${handlers.length})`);
// Call immediately if requested and we're on a matching page
// Test if this is causing issues
if (handlerConfig.options.immediate && false) {
const currentView = getCurrentView();
if (currentView && shouldCallHandler(handlerConfig, currentView)) {
try {
// Pass the promise (not awaited) for consistency with notifyHandlers
const itemPromise = getCurrentItem();
callback(currentView, document, itemPromise);
} catch (err) {
ERR('Error in immediate handler call:', err);
}
const currentView = getCurrentView();
if (currentView && shouldCallHandler(handlerConfig, currentView)) {
try {
// Pass the promise (not awaited) for consistency with notifyHandlers
const itemPromise = getCurrentItem();
callback(currentView, document, window.location.hash, itemPromise);
} catch (err) {
ERR('Error in immediate handler call:', err);
}
}
@@ -122,7 +118,7 @@
* @returns {Promise<Object|null>} The item object or null if not found
*/
async function fetchItemById(itemId) {
if (!itemId || !window.ApiClient || !window.ApiClient.getItem || !window.ApiClient.getCurrentUserId) {
if (!itemId || !window.ApiClient || !window.ApiClient.getItem || !window.ApiClient.getCurrentUserId || !window.ApiClient._loggedIn) {
return null;
}
@@ -186,7 +182,7 @@
* @param {string} view - The view name
* @param {Element} element - The view element
*/
function notifyHandlers(view, element) {
function notifyHandlers(view, element, hash) {
// Clear cache when view changes to ensure fresh data
const currentItemId = getItemIdFromUrl();
if (cachedItemId !== currentItemId) {
@@ -206,7 +202,7 @@
if (shouldCallHandler(config, view)) {
try {
// Pass the promise as third parameter - handlers can await if needed
config.callback(view, element, itemPromise);
config.callback(view, element, hash, itemPromise);
} catch (err) {
ERR('Error in onViewPage handler:', err);
}
-4
View File
@@ -1858,10 +1858,6 @@ div[data-tab="watchlist"] .loading-message {
-webkit-tap-highlight-color: #fff0;
}
.mainDetailButtons:has(.btnPlay.hide) .watchlist-icon, .mainDetailButtons:has(.btnPlay.hide) .btnRecommend {
display: none;
}
.card:has(button[data-played="true"]) .watchlist-button,
.card:not(:has(button[data-played])) .watchlist-button,
.mainDetailButtons:has(button[data-played="true"]) .watchlist-icon,
+450 -163
View File
File diff suppressed because it is too large. Load diff