Move scripts to /scripts folder

This commit is contained in:
HighImKevin
2025-11-13 06:42:46 -05:00
parent 584e299219
commit 216be4502a
38 changed files with 490 additions and 112 deletions
+40
View File
@@ -0,0 +1,40 @@
// Backdrop Leak Fix Script
// Fixes issue that causes backdrop images to be continuously added to the page if the tab isn't focused
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks BackdropLeakFix]', ...args);
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;
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) {
LOG('Too many children (' + container.children.length + '), removing oldest');
container.removeChild(container.firstElementChild);
}
});
pruneObserver.observe(container, { childList: true });
LOG('Prune observer is now active');
});
waitForContainer.observe(document.body, {
childList: true,
subtree: true
});
LOG('Waiting for .backdropContainer to appear...');
LOG('Backdrop leak fix functionality initialized');
})();
+103
View File
@@ -0,0 +1,103 @@
/* KefinTweaks Breadcrumbs Styles */
.kefinTweaks-breadcrumbs-wrapper {
display: none;
}
@media (min-width: 768px) {
.kefinTweaks-breadcrumbs-wrapper {
display: block;
margin-left: -.5em;
transition: background 0.2s ease;
}
.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;
}
/* 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-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;
}
}
+906
View File
@@ -0,0 +1,906 @@
// KefinTweaks Breadcrumbs
// Adds breadcrumb navigation to item detail pages
// Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum, Audio
// Requires: utils.js module to be loaded before this script
(function() {
'use strict';
console.log('[KefinTweaks Breadcrumbs] Initializing...');
// Configuration
const CONFIG = {
supportedTypes: ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio'],
minWidth: 768, // Only show on screens wider than 768px
};
// State management
let currentBreadcrumbs = null;
let breadcrumbContainer = null;
let activePopover = null;
let currentItemId = null;
// Track parent IDs to avoid unnecessary API calls
let lastSeasonsParentId = null;
let lastAlbumsParentId = null;
let lastSongsParentId = null;
let cachedSeasonsData = null;
let cachedAlbumsData = null;
let cachedSongsData = null;
// Smart API call functions that check parent ID
async function getSeasonsIfNeeded(parentId) {
if (lastSeasonsParentId === parentId && cachedSeasonsData) {
log('Using existing seasons data for parent:', parentId);
return cachedSeasonsData;
}
log('Fetching seasons for parent:', parentId);
const seasons = await getSeasons(parentId);
lastSeasonsParentId = parentId;
cachedSeasonsData = seasons;
return seasons;
}
async function getAlbumsIfNeeded(parentId) {
if (lastAlbumsParentId === parentId && cachedAlbumsData) {
log('Using existing albums data for parent:', parentId);
return cachedAlbumsData;
}
log('Fetching albums for parent:', parentId);
const albums = await getAlbums(parentId);
lastAlbumsParentId = parentId;
cachedAlbumsData = albums;
return albums;
}
async function getSongsIfNeeded(parentId) {
if (lastSongsParentId === parentId && cachedSongsData) {
log('Using existing songs data for parent:', parentId);
return cachedSongsData;
}
log('Fetching songs for parent:', parentId);
const songs = await getSongs(parentId);
lastSongsParentId = parentId;
cachedSongsData = songs;
return songs;
}
// Simplified breadcrumb structure definition
async function getTargetBreadcrumbStructure(item) {
const structure = {
elements: [],
item: item
};
// Don't append .html for server versions 10.11 and above
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');
if (collectionFolder) {
queryParams = `?topParentId=${collectionFolder.Id}&serverId=${ApiClient.serverId()}`;
} else if (userRootFolder) {
queryParams = `?topParentId=${userRootFolder.Id}&serverId=${ApiClient.serverId()}`;
} else {
queryParams = `?serverId=${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 }
];
} 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 }
];
} else if (item.Type === 'Season') {
// Get series details for the series name
const seriesDetails = await getItemDetails(item.ParentId);
const seriesName = seriesDetails ? seriesDetails.Name : 'Unknown Series';
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 }
];
} else if (item.Type === 'Episode') {
// Get series details for the series name
const seriesDetails = await getItemDetails(item.SeriesId);
const seriesName = seriesDetails ? seriesDetails.Name : 'Unknown Series';
// Get season details for the season name
const seasonDetails = await getItemDetails(item.ParentId);
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: seasonName, url: null, clickable: true, popover: true },
{ 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 }
];
} 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.Name, 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
const albumDetails = await getItemDetails(item.ParentId);
const albumName = albumDetails ? albumDetails.Name : 'Unknown Album';
const artistName = albumDetails ? albumDetails.AlbumArtist : 'Unknown Artist';
const artistId = albumDetails ? albumDetails.ParentId : null;
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: albumName, url: null, clickable: true, popover: true },
{ text: item.Name, url: null, clickable: false }
];
}
return structure;
}
// Compare existing breadcrumbs with target structure
function compareBreadcrumbStructures(existing, target) {
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')
);
log('Comparing breadcrumbs - existing:', existingElements.length, 'target:', target.elements.length);
// Compare each element
for (let i = 0; i < Math.max(existingElements.length, target.elements.length); i++) {
const existingElement = existingElements[i];
const targetElement = target.elements[i];
if (!existingElement && targetElement) {
// Need to add new element
differences.push({ action: 'add', index: i, element: targetElement });
} else if (existingElement && !targetElement) {
// Need to remove element
differences.push({ action: 'remove', index: i });
} else if (existingElement && targetElement) {
// Compare existing vs target
const existingText = existingElement.textContent;
const existingClickable = existingElement.style.cursor === 'pointer';
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'
);
if (existingText !== targetElement.text || existingClickable !== targetClickable || needsPopoverUpdate) {
differences.push({ action: 'update', index: i, element: targetElement });
}
}
}
return differences;
}
// Apply breadcrumb differences intelligently
async function applyBreadcrumbDifferences(differences, targetStructure) {
if (differences.length === 0) {
log('No breadcrumb differences found, keeping existing structure');
return;
}
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')
);
// Process differences in reverse order to maintain indices
for (let i = differences.length - 1; i >= 0; i--) {
const diff = differences[i];
if (diff.action === 'add') {
// Add new element at the specified index
const newElement = await createBreadcrumbElementFromStructure(diff.element, targetStructure.item);
const separator = createSeparator();
if (diff.index === 0) {
breadcrumbContainer.insertBefore(newElement, breadcrumbContainer.firstChild);
breadcrumbContainer.insertBefore(separator, newElement.nextSibling);
} else {
const insertAfter = existingElements[diff.index - 1];
if (insertAfter) {
breadcrumbContainer.insertBefore(separator, insertAfter.nextSibling);
breadcrumbContainer.insertBefore(newElement, separator.nextSibling);
} else {
breadcrumbContainer.appendChild(separator);
breadcrumbContainer.appendChild(newElement);
}
}
log('Added new breadcrumb element:', diff.element.text);
} else if (diff.action === 'remove') {
// Remove element and its separator
const elementToRemove = existingElements[diff.index];
if (elementToRemove) {
const separatorToRemove = elementToRemove.previousSibling;
if (separatorToRemove && separatorToRemove.classList.contains('kefinTweaks-breadcrumb-separator')) {
breadcrumbContainer.removeChild(separatorToRemove);
}
breadcrumbContainer.removeChild(elementToRemove);
log('Removed breadcrumb element at index:', diff.index);
}
} else if (diff.action === 'update') {
// Update existing element
const elementToUpdate = existingElements[diff.index];
if (elementToUpdate) {
await updateBreadcrumbElement(elementToUpdate, diff.element, targetStructure.item);
log('Updated breadcrumb element:', diff.element.text);
}
}
}
}
// Create breadcrumb element from structure definition
async function createBreadcrumbElementFromStructure(elementDef, item) {
const element = document.createElement('span');
element.className = 'kefinTweaks-breadcrumb-element';
element.textContent = elementDef.text;
if (elementDef.clickable) {
element.style.cursor = 'pointer';
if (elementDef.url) {
// Direct link
element.addEventListener('click', () => {
window.location.href = elementDef.url;
});
} else if (elementDef.popover) {
// Popover functionality
await setupPopoverForElement(element, elementDef, item);
}
} else {
element.style.cursor = 'default';
}
return element;
}
// Update existing breadcrumb element
async function updateBreadcrumbElement(element, elementDef, item) {
// Update text content
element.textContent = elementDef.text;
// Remove old event listeners by cloning
const newElement = element.cloneNode(true);
element.parentNode.replaceChild(newElement, element);
// Set cursor style
if (elementDef.clickable) {
newElement.style.cursor = 'pointer';
if (elementDef.url) {
// Direct link
newElement.addEventListener('click', () => {
window.location.href = elementDef.url;
});
} else if (elementDef.popover) {
// Popover functionality
await setupPopoverForElement(newElement, elementDef, item);
}
} else {
newElement.style.cursor = 'default';
}
}
// Setup popover functionality for an element
async function setupPopoverForElement(element, elementDef, item) {
if (elementDef.text === 'All Seasons') {
// Series page - always show popover
const seasons = await getSeasonsIfNeeded(item.Id);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, null, (selectedSeason) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
} else if (elementDef.text === 'All Albums') {
// Music Artist page - always show popover
const albums = await getAlbumsIfNeeded(item.Id);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(albums, null, (selectedAlbum) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
} else if (elementDef.text === 'All Songs') {
// Music Album page - always show popover
const songs = await getSongsIfNeeded(item.Id);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(songs, null, (selectedSong) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSong.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
} else if (item.Type === 'Season') {
// Season page - check if single season
const seasons = await getSeasonsIfNeeded(item.ParentId);
if (seasons.length === 1) {
element.style.cursor = 'default';
} else {
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, item, (selectedSeason) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
}
} else if (item.Type === 'Episode') {
// Episode page - check if single season
const seasons = await getSeasonsIfNeeded(item.SeriesId);
if (seasons.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
const currentSeason = seasons.find(season => season.Id === item.ParentId);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, currentSeason, (selectedSeason) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
}
} else if (item.Type === 'MusicAlbum') {
// Music album page - clicking album name should show albums from all AlbumArtists
// Prefer AlbumArtists on the item, fall back to fetching details
let albumArtists = item.AlbumArtists;
if (!albumArtists || !Array.isArray(albumArtists) || albumArtists.length === 0) {
const albumDetails = await getItemDetails(item.Id);
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);
if (albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(albums, item, (selectedAlbum) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
}
} 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);
if (albums && albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
element.addEventListener('click', (e) => {
e.stopPropagation();
const currentAlbum = albumDetails || null;
const popover = createPopover(albums || [], currentAlbum, (selectedAlbum) => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`;
});
showPopover(element, popover);
});
}
}
}
// Utility functions
function log(message, ...args) {
console.log(`[KefinTweaks Breadcrumbs] ${message}`, ...args);
}
function error(message, ...args) {
console.error(`[KefinTweaks Breadcrumbs] ${message}`, ...args);
}
function isWideScreen() {
return window.innerWidth > CONFIG.minWidth;
}
function getItemIdFromPath(path) {
const match = path.match(/[?&]id=([^&]+)/);
return match ? match[1] : null;
}
function padNumber(num, length = 2) {
return num.toString().padStart(length, '0');
}
// DOM manipulation
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');
return null;
}
// Create main wrapper
const wrapper = document.createElement('div');
wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
wrapper.style.cssText = `
display: none;
`;
// Create breadcrumb container (for background styling)
breadcrumbContainer = document.createElement('div');
breadcrumbContainer.id = 'kefinTweaks-breadcrumbs';
breadcrumbContainer.className = 'kefinTweaks-breadcrumbs';
// Create popover container
const popoverContainer = document.createElement('div');
popoverContainer.id = 'kefinTweaks-popover-container';
popoverContainer.className = 'kefinTweaks-popover-container';
popoverContainer.style.cssText = `
position: absolute;
top: calc(100% - 15px);
left: 0;
z-index: 1001;
`;
wrapper.appendChild(breadcrumbContainer);
wrapper.appendChild(popoverContainer);
headerLeft.appendChild(wrapper);
log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
return breadcrumbContainer;
}
function showBreadcrumbs() {
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
const wideScreen = isWideScreen();
// Race condition protection: verify we're still on a supported page
const currentPath = Emby.Page.lastPath || '';
const isSupportedPage = currentPath.startsWith('/details?');
if (wrapper && wideScreen && isSupportedPage) {
wrapper.style.display = 'block';
log('Breadcrumbs shown successfully');
} else {
log('Breadcrumbs not shown - wrapper:', !!wrapper, 'wideScreen:', wideScreen, 'isSupportedPage:', isSupportedPage);
}
}
function hideBreadcrumbs() {
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
if (wrapper) {
wrapper.style.display = 'none';
}
}
function clearBreadcrumbs() {
if (breadcrumbContainer) {
breadcrumbContainer.innerHTML = '';
currentBreadcrumbs = null;
currentItemId = null;
}
closePopover();
}
function closePopover() {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
}
function createPopover(items, currentItem, onItemClick) {
const popover = document.createElement('div');
popover.className = 'kefinTweaks-popover';
popover.style.display = 'block'; // Ensure it's visible
log('Creating popover with', items.length, 'items');
let selectedItemElement = null;
items.forEach(item => {
const itemElement = document.createElement('div');
itemElement.className = 'kefinTweaks-popover-item';
if (currentItem && item.Id === currentItem.Id) {
itemElement.classList.add('selected');
selectedItemElement = itemElement; // Store reference to selected item
}
// Format display text based on item type
let displayText = item.Name;
if (item.Type === 'Season' || item.Type === 'Episode') {
displayText = item.Name || `Season ${item.IndexNumber}`;
} else if (item.Type === 'Audio') {
// For songs, show track number if available
displayText = item.IndexNumber ? `${padNumber(item.IndexNumber)}. ${item.Name}` : item.Name;
}
itemElement.textContent = displayText;
itemElement.addEventListener('click', () => {
log('Popover item clicked:', item.Name);
onItemClick(item);
closePopover();
});
popover.appendChild(itemElement);
});
// Add auto-scroll functionality
if (selectedItemElement) {
// Use setTimeout to ensure the popover is rendered before scrolling
setTimeout(() => {
selectedItemElement.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest'
});
log('Auto-scrolled to selected item:', selectedItemElement.textContent);
}, 10);
}
return popover;
}
function showPopover(triggerElement, popover) {
closePopover();
activePopover = popover;
log('Showing popover for element:', triggerElement.textContent);
// Add popover to the dedicated popover container
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`;
popoverContainer.appendChild(popover);
log('Popover added to popover container with left offset:', leftOffset);
} else {
log('Popover container not found, falling back to trigger element');
triggerElement.style.position = 'relative';
triggerElement.appendChild(popover);
}
// Close popover when clicking outside
const closeHandler = (e) => {
if (!popover.contains(e.target) && !triggerElement.contains(e.target)) {
closePopover();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
}, 100);
}
// API functions
async function getItemDetails(itemId) {
try {
const currentUserId = ApiClient.getCurrentUserId();
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;
} catch (err) {
console.error('Failed to get item details:', err);
return null;
}
}
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 || [];
} catch (err) {
error('Failed to get seasons:', err);
return [];
}
}
async function getAlbums(artistId) {
try {
const url = ApiClient.getUrl('Items', {
SortOrder: 'Descending',
IncludeItemTypes: 'MusicAlbum',
Recursive: true,
Fields: 'ParentId',
Limit: 100,
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: artistId,
SortBy: 'PremiereDate,ProductionYear,Sortname'
});
const response = await ApiClient.getJSON(url);
log('Retrieved albums:', response.Items?.length || 0);
return response.Items || [];
} catch (err) {
error('Failed to get albums:', err);
return [];
}
}
async function getAlbumsByArtistIds(artistIds) {
try {
const idsParam = Array.isArray(artistIds) ? artistIds.join(',') : String(artistIds || '');
const url = ApiClient.getUrl('Items', {
SortOrder: 'Descending',
IncludeItemTypes: 'MusicAlbum',
Recursive: true,
Fields: 'ParentId',
Limit: 200,
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: idsParam,
SortBy: 'PremiereDate,ProductionYear,Sortname'
});
const response = await ApiClient.getJSON(url);
const items = response.Items || [];
// Deduplicate by Id when multiple artists overlap
const unique = [];
const seen = new Set();
for (const it of items) {
if (it && it.Id && !seen.has(it.Id)) {
seen.add(it.Id);
unique.push(it);
}
}
log('Retrieved albums by artist IDs:', unique.length);
return unique;
} catch (err) {
error('Failed to get albums by artist IDs:', err);
return [];
}
}
async function getSongs(albumId) {
try {
const url = ApiClient.getUrl('Items', {
ParentId: albumId,
IncludeItemTypes: 'Audio',
Recursive: false,
Fields: 'ParentId',
Limit: 100,
StartIndex: 0,
SortBy: 'IndexNumber,SortName'
});
const response = await ApiClient.getJSON(url);
log('Retrieved songs:', response.Items?.length || 0);
return response.Items || [];
} catch (err) {
error('Failed to get songs:', err);
return [];
}
}
function createSeparator() {
const separator = document.createElement('span');
separator.className = 'kefinTweaks-breadcrumb-separator';
separator.textContent = ' / ';
separator.style.margin = '0 8px';
return separator;
}
// Page change handler
// Simplified page change handler
async function handlePageChange(item = null) {
try {
// Close any active popovers first
closePopover();
const currentPath = Emby.Page.lastPath;
// Check if we're on a details page
if (!currentPath || !currentPath.startsWith('/details?')) {
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Check if the user is logged in
if (!ApiClient._loggedIn) {
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Extract item ID
if (!item || !item.Id) {
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Check if item type is supported
if (!CONFIG.supportedTypes.includes(item.Type)) {
clearBreadcrumbs();
hideBreadcrumbs();
return;
}
// Determine target breadcrumb structure
const targetStructure = await getTargetBreadcrumbStructure(item);
log('Target breadcrumb structure:', targetStructure.elements.map(e => e.text));
// Check if we need to create breadcrumbs from scratch
if (!breadcrumbContainer || !currentBreadcrumbs) {
log('Creating breadcrumbs from scratch');
await createBreadcrumbsFromScratch(targetStructure);
} else {
// Compare existing with target and apply differences
const differences = compareBreadcrumbStructures(currentBreadcrumbs, targetStructure);
await applyBreadcrumbDifferences(differences, targetStructure);
}
// Update state
currentBreadcrumbs = targetStructure;
currentItemId = item.Id;
log('About to show breadcrumbs...');
showBreadcrumbs();
log('Breadcrumbs updated successfully');
} catch (err) {
error('Error handling page change:', err);
clearBreadcrumbs();
hideBreadcrumbs();
}
}
// Create breadcrumbs from scratch
async function createBreadcrumbsFromScratch(targetStructure) {
// Create container if it doesn't exist
if (!breadcrumbContainer) {
breadcrumbContainer = createBreadcrumbContainer();
}
// Build all elements first
const elements = [];
for (let i = 0; i < targetStructure.elements.length; i++) {
const elementDef = targetStructure.elements[i];
const element = await createBreadcrumbElementFromStructure(elementDef, targetStructure.item);
elements.push(element);
// Add separator after each element except the last
if (i < targetStructure.elements.length - 1) {
const separator = createSeparator();
elements.push(separator);
}
}
// Replace the innerHTML entirely to avoid any duplicate appending issues
breadcrumbContainer.innerHTML = '';
// Append all elements at once
elements.forEach(el => breadcrumbContainer.appendChild(el));
log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements');
}
// Window resize handler
function handleResize() {
if (currentBreadcrumbs && breadcrumbContainer) {
if (isWideScreen()) {
showBreadcrumbs();
} else {
hideBreadcrumbs();
}
}
}
// Initialize breadcrumbs
function initialize() {
log('Initializing breadcrumbs...');
// Use utils for page change detection
if (window.KefinTweaksUtils) {
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
});
} else {
error('KefinTweaksUtils not available, breadcrumbs may not work correctly');
}
// Add resize listener
window.addEventListener('resize', handleResize);
// Initial check
handlePageChange();
log('Breadcrumbs initialized successfully');
}
// Start initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();
+587
View File
@@ -0,0 +1,587 @@
// Jellyfin Card Builder
// This module provides a main entry point function to build Jellyfin cards
// Usage: window.cardBuilder.buildCard(jellyfinItem)
(function() {
'use strict';
// Main card builder object
const cardBuilder = {
/**
* Main entry point function to build a Jellyfin card
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed card element
*/
buildCard: function(item, overflowCard = false, cardFormat = null) {
return createJellyfinCardElement(item, overflowCard, cardFormat);
},
/**
* Renders a scrollable container with cards
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the scrollable container
* @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed scrollable container
*/
renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat);
},
/**
* Renders a scrollable container with cards from item IDs
* @param {Array} itemIds - Array of Jellyfin item IDs
* @param {string} title - Title for the scrollable container
* @param {string} viewMoreUrl - Optional URL to make title clickable
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {Promise<HTMLElement>} - The constructed scrollable container
*/
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
const LOG = (...args) => console.log('[KefinTweaks CardBuilder]', ...args);
if (!itemIds || itemIds.length === 0) {
LOG(`No item IDs provided for section: ${title}`);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
try {
// Fetch all items at once
const response = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
Ids: itemIds.join(','),
Recursive: false
});
const items = response.Items;
LOG(`Fetched ${items.length} items for section: ${title}`);
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat);
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error fetching items:', error);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
}
};
/**
* Creates a Jellyfin card element from an item
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed card element
*/
function createJellyfinCardElement(item, overflowCard = false, cardFormat = null) {
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
// Determine card type based on cardFormat override or item type
let cardClass, padderClass, imageParams;
if (cardFormat) {
// Use specified cardFormat
if (cardFormat === 'backdrop' || cardFormat === 'thumb') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (cardFormat === 'square') {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else {
// portrait (default)
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
} else {
// Use item type to determine card type
if (item.Type === 'Episode' || item.Type === 'TvChannel') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else {
// Default poster style for Movies, Series, etc.
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
}
// Create the main card container
const card = document.createElement('div');
card.className = `card ${cardClass} card-hoverable card-withuserdata`;
card.setAttribute('data-index', '0');
card.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' ? 'true' : 'false');
card.setAttribute('data-serverid', serverId);
card.setAttribute('data-id', item.Id);
card.setAttribute('data-type', item.Type);
card.setAttribute('data-mediatype', item.MediaType || 'Video');
card.setAttribute('data-prefix', item.Name?.startsWith('The ') ? 'THE' : '');
// Card box container
const cardBox = document.createElement('div');
cardBox.className = 'cardBox cardBox-bottompadded';
// Card scalable container
const cardScalable = document.createElement('div');
cardScalable.className = 'cardScalable';
// Card padder with icon
const cardPadder = document.createElement('div');
cardPadder.className = `cardPadder ${padderClass} lazy-hidden-children`;
const cardIcon = document.createElement('span');
cardIcon.className = 'cardImageIcon material-icons';
cardIcon.setAttribute('aria-hidden', 'true');
// Set icon based on item type
if (item.Type === 'Movie') {
cardIcon.textContent = 'movie';
} else if (item.Type === 'Series') {
cardIcon.textContent = 'tv';
} else if (item.Type === 'Episode') {
cardIcon.textContent = 'play_circle';
} else if (item.Type === 'MusicAlbum') {
cardIcon.textContent = 'album';
} else if (item.Type === 'Audio') {
cardIcon.textContent = 'music_note';
} else if (item.Type === 'Artist') {
cardIcon.textContent = 'person';
} else {
cardIcon.textContent = 'folder';
}
cardPadder.appendChild(cardIcon);
// Blurhash canvas (placeholder)
const blurhashCanvas = document.createElement('canvas');
blurhashCanvas.setAttribute('aria-hidden', 'true');
blurhashCanvas.width = 20;
blurhashCanvas.height = 20;
blurhashCanvas.className = 'blurhash-canvas lazy-hidden';
// Card image container
const cardImageContainer = document.createElement('a');
cardImageContainer.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast';
cardImageContainer.setAttribute('data-action', 'link');
cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown');
// Force specific image if card format is specified
if (cardFormat === 'backdrop') {
let imageUrl = '';
if (item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (cardFormat === 'square') {
if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
const imageUrl = item.BackdropImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else {
const imageUrl = item.PrimaryImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
}
} else if (cardFormat === 'portrait') {
if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) {
const imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else {
const imageUrl = item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
}
} else if (cardFormat === 'thumb') {
let imageUrl = '';
if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else if (item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.ImageTags?.Primary) {
let imageUrl;
if (item.IsJellyseerr) {
// Jellyseerr items use external image URLs
imageUrl = item.ImageTags.Primary.startsWith('http')
? item.ImageTags.Primary
: `https://image.tmdb.org/t/p/w500${item.ImageTags.Primary}`;
} else {
// Regular Jellyfin items
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.ImageTags?.Thumb) {
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if ((item.Type === 'Season') && item.SeriesPrimaryImageTag) {
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if ((item.Type === 'Episode') && item.ParentThumbImageTag) {
const imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
const imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else {
// No image - add icon as inner element
const iconSpan = document.createElement('span');
iconSpan.className = 'cardImageIcon material-icons';
iconSpan.setAttribute('aria-hidden', 'true');
// Set icon based on item type
if (item.Type === 'Movie') {
iconSpan.textContent = 'movie';
} else if (item.Type === 'Series') {
iconSpan.textContent = 'tv';
} else if (item.Type === 'Episode') {
iconSpan.textContent = 'tv';
} else if (item.Type === 'Person') {
iconSpan.textContent = 'person';
} else if (item.Type === 'MusicAlbum') {
iconSpan.textContent = 'album';
} else if (item.Type === 'Audio') {
iconSpan.textContent = 'music_note';
} else if (item.Type === 'Artist' || item.Type === 'MusicArtist') {
iconSpan.textContent = 'person';
} else {
iconSpan.textContent = 'folder';
}
cardImageContainer.appendChild(iconSpan);
}
// Card overlay container
const cardOverlayContainer = document.createElement('div');
cardOverlayContainer.className = 'cardOverlayContainer itemAction';
cardOverlayContainer.setAttribute('data-action', 'link');
// Overlay link
const overlayLink = document.createElement('a');
overlayLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
overlayLink.className = 'cardImageContainer';
// Play button
const playButton = document.createElement('button');
playButton.setAttribute('is', 'paper-icon-button-light');
playButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary';
playButton.setAttribute('data-action', 'resume');
const playIcon = document.createElement('span');
playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow';
playIcon.setAttribute('aria-hidden', 'true');
playButton.appendChild(playIcon);
// Button container for additional overlay buttons (watchlist, etc.)
const buttonContainer = document.createElement('div');
buttonContainer.className = 'cardOverlayButton-br flex';
// Watched button
const watchedButton = document.createElement('button');
watchedButton.setAttribute('is', 'emby-playstatebutton');
watchedButton.type = 'button';
watchedButton.setAttribute('data-action', 'none');
watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
watchedButton.setAttribute('data-id', item.Id);
watchedButton.setAttribute('data-serverid', serverId);
watchedButton.setAttribute('data-itemtype', item.Type);
watchedButton.setAttribute('data-played', item.UserData?.Played || 'false');
watchedButton.title = 'Mark played';
const watchedIcon = document.createElement('span');
watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed';
watchedIcon.setAttribute('aria-hidden', 'true');
watchedButton.appendChild(watchedIcon);
buttonContainer.appendChild(watchedButton);
// Favorite button
const favoriteButton = document.createElement('button');
favoriteButton.setAttribute('is', 'emby-ratingbutton');
favoriteButton.type = 'button';
favoriteButton.setAttribute('data-action', 'none');
favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
favoriteButton.setAttribute('data-id', item.Id);
favoriteButton.setAttribute('data-serverid', serverId);
favoriteButton.setAttribute('data-itemtype', item.Type);
favoriteButton.setAttribute('data-likes', '');
favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false');
favoriteButton.title = 'Add to favorites';
const favoriteIcon = document.createElement('span');
favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite';
favoriteIcon.setAttribute('aria-hidden', 'true');
favoriteButton.appendChild(favoriteIcon);
buttonContainer.appendChild(favoriteButton);
// Assemble overlay
cardOverlayContainer.appendChild(overlayLink);
cardOverlayContainer.appendChild(playButton);
cardOverlayContainer.appendChild(buttonContainer);
// Card text container - different structure for episodes
const cardTextContainer = document.createElement('div');
cardTextContainer.className = 'cardText cardTextCentered cardText-first';
if (item.Type === 'Episode') {
// Episodes: Series name as primary, episode title as secondary
const seriesLink = document.createElement('a');
seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`;
seriesLink.className = 'itemAction textActionButton';
seriesLink.setAttribute('data-id', item.SeriesId || item.Id);
seriesLink.setAttribute('data-serverid', serverId);
seriesLink.setAttribute('data-type', 'Series');
seriesLink.setAttribute('data-mediatype', 'undefined');
seriesLink.setAttribute('data-channelid', 'undefined');
seriesLink.setAttribute('data-isfolder', 'true');
seriesLink.setAttribute('data-action', 'link');
seriesLink.title = item.SeriesName || 'Unknown Series';
seriesLink.textContent = item.SeriesName || 'Unknown Series';
const seriesBdi = document.createElement('bdi');
seriesBdi.appendChild(seriesLink);
cardTextContainer.appendChild(seriesBdi);
// Episode title as secondary
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
episodeLink.setAttribute('data-serverid', serverId);
episodeLink.setAttribute('data-type', 'Episode');
episodeLink.setAttribute('data-mediatype', 'undefined');
episodeLink.setAttribute('data-channelid', 'undefined');
episodeLink.setAttribute('data-isfolder', 'false');
episodeLink.setAttribute('data-action', 'link');
episodeLink.title = item.Name || 'Unknown Episode';
episodeLink.textContent = item.Name || 'Unknown Episode';
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
secondaryText.appendChild(episodeBdi);
} else {
// Default: Item name as primary, year as secondary
const titleLink = document.createElement('a');
titleLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
titleLink.className = 'itemAction textActionButton';
titleLink.setAttribute('data-id', item.Id);
titleLink.setAttribute('data-serverid', serverId);
titleLink.setAttribute('data-type', item.Type);
titleLink.setAttribute('data-mediatype', 'undefined');
titleLink.setAttribute('data-channelid', 'undefined');
titleLink.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' || item.Type === 'MusicArtist' ? 'true' : 'false');
titleLink.setAttribute('data-action', 'link');
titleLink.title = item.Name || 'Unknown';
titleLink.textContent = item.Name || 'Unknown';
const titleBdi = document.createElement('bdi');
titleBdi.appendChild(titleLink);
cardTextContainer.appendChild(titleBdi);
// Secondary text (year)
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const yearBdi = document.createElement('bdi');
yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
secondaryText.appendChild(yearBdi);
}
// Assemble card
cardScalable.appendChild(cardPadder);
cardScalable.appendChild(blurhashCanvas);
cardScalable.appendChild(cardImageContainer);
cardScalable.appendChild(cardOverlayContainer);
cardBox.appendChild(cardScalable);
cardBox.appendChild(cardTextContainer);
// Add secondary text after primary text
if (item.Type === 'Episode') {
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name;
episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
episodeLink.setAttribute('data-id', item.Id);
episodeLink.setAttribute('data-serverid', serverId);
episodeLink.setAttribute('data-type', 'Episode');
episodeLink.setAttribute('data-mediatype', 'undefined');
episodeLink.setAttribute('data-channelid', 'undefined');
episodeLink.setAttribute('data-isfolder', 'false');
episodeLink.setAttribute('data-action', 'link');
episodeLink.title = episodeName;
episodeLink.textContent = episodeName;
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
secondaryText.appendChild(episodeBdi);
cardBox.appendChild(secondaryText);
} else {
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const yearBdi = document.createElement('bdi');
yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
secondaryText.appendChild(yearBdi);
cardBox.appendChild(secondaryText);
}
card.appendChild(cardBox);
return card;
}
/**
* Creates a scrollable container with horizontal scrolling functionality
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the scrollable container
* @param {string} viewMoreUrl - Optional URL to make title clickable
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed scrollable container
*/
function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
// Create the main vertical section container
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
// Create section title
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
if (viewMoreUrl) {
// Create clickable title with chevron icon
const titleLink = document.createElement('a');
titleLink.className = 'sectionTitle-link sectionTitleTextButton';
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
// Handle both URL and function
if (typeof viewMoreUrl === 'function') {
titleLink.addEventListener('click', (e) => {
e.preventDefault();
viewMoreUrl();
});
} else {
titleLink.href = viewMoreUrl;
}
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
const chevronIcon = document.createElement('span');
chevronIcon.className = 'material-icons chevron_right';
chevronIcon.setAttribute('aria-hidden', 'true');
titleLink.appendChild(titleText);
titleLink.appendChild(chevronIcon);
sectionTitleContainer.appendChild(titleLink);
} else {
// Regular non-clickable title
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
sectionTitleContainer.appendChild(titleText);
}
// Create "Show All" button (will be added after items are created)
const showAllButton = document.createElement('button');
showAllButton.type = 'button';
showAllButton.className = 'show-all-button';
showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer; color: var(--main-text, #fff) !important; margin-bottom: .35em; align-self: center;';
showAllButton.textContent = 'Show All';
showAllButton.title = 'Show all items in a grid layout';
// Create scroller container
const scroller = document.createElement('div');
scroller.setAttribute('is', 'emby-scroller');
scroller.setAttribute('data-horizontal', 'true');
scroller.setAttribute('data-centerfocus', 'card');
scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller';
scroller.setAttribute('data-scroll-mode-x', 'custom');
// Enable smooth native horizontal touch scrolling (no snapping, no buttons)
scroller.style.scrollSnapType = 'none';
// Allow both axes so vertical page scroll isn't blocked when gesture starts over the scroller
scroller.style.touchAction = 'auto';
// Keep horizontal scroll self-contained but allow vertical to bubble to page
scroller.style.overscrollBehaviorX = 'contain';
scroller.style.overscrollBehaviorY = 'auto';
// iOS inertia scrolling
scroller.style.webkitOverflowScrolling = 'touch';
// Create items container
const itemsContainer = document.createElement('div');
itemsContainer.setAttribute('is', 'emby-itemscontainer');
itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX';
itemsContainer.style.whiteSpace = 'nowrap';
// Add items to container
items.forEach((item, index) => {
const card = createJellyfinCardElement(item, overflowCard, cardFormat);
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
scroller.appendChild(itemsContainer);
// Add "Show All" button to title if there are more than 20 items
if (items.length > 20) {
sectionTitleContainer.style.display = 'flex';
sectionTitleContainer.appendChild(showAllButton);
}
// Toggle between scroll and grid view
let isShowingAll = false;
const originalItemsContainerStyle = itemsContainer.style.cssText;
showAllButton.addEventListener('click', () => {
// Find the scroll buttons for this container
const scrollerContainer = showAllButton.closest('.emby-scroller-container');
const scrollButtons = scrollerContainer ? scrollerContainer.querySelector('.emby-scrollbuttons') : null;
if (isShowingAll) {
// Switch back to scroll view
itemsContainer.style.cssText = originalItemsContainerStyle;
if (scrollButtons) scrollButtons.style.display = '';
showAllButton.textContent = 'Show All';
showAllButton.title = 'Show all items in a grid layout';
isShowingAll = false;
} else {
// Switch to grid view
itemsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 12px; white-space: normal; transform: none !important; transition: none !important;';
if (scrollButtons) scrollButtons.style.display = 'none';
showAllButton.textContent = 'Show Less';
showAllButton.title = 'Show items in scrollable layout';
isShowingAll = true;
}
});
// Assemble the section
verticalSection.appendChild(sectionTitleContainer);
verticalSection.appendChild(scroller);
return verticalSection;
}
// Expose the cardBuilder to the global window object
window.cardBuilder = cardBuilder;
console.log('[KefinTweaks CardBuilder] Module loaded and available at window.cardBuilder');
})();
+657
View File
@@ -0,0 +1,657 @@
// KefinTweaks Collections
// Adds sorting functionality to collection pages
// Requires: utils.js, modal.js modules to be loaded before this script
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Collections]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Collections]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Collections]', ...args);
// localStorage key prefix
const STORAGE_KEY_PREFIX_SORT = 'kefinTweaks_collectionSort_';
const STORAGE_KEY_PREFIX_DIRECTION = 'kefinTweaks_collectionSortDirection_';
// Sort options
const SORT_OPTIONS = {
'sortTitle': { label: 'Sort Title', field: 'SortName', defaultDirection: 'asc' },
'releaseDate': { label: 'Release Date', field: 'PremiereDate', defaultDirection: 'asc' },
'dateAdded': { label: 'Date Added', field: 'DateCreated', defaultDirection: 'desc' },
'communityRating': { label: 'Community Rating', field: 'CommunityRating', defaultDirection: 'desc' },
'criticRating': { label: 'Critic Rating', field: 'CriticRating', defaultDirection: 'desc' }
};
// Map DisplayOrder values to our sort keys
const DISPLAY_ORDER_MAP = {
'SortName': 'sortTitle',
'PremiereDate': 'releaseDate',
'Default': 'dateAdded'
};
// Store collection data per page
const collectionData = new Map();
// Single MutationObserver for the current collection (only one can exist at a time)
let collectionObserver = null;
/**
* Get default sort from collection DisplayOrder property
* @param {Object} collectionItem - Collection item with DisplayOrder property
* @returns {string} Sort key
*/
function getDefaultSortFromCollection(collectionItem) {
if (!collectionItem || !collectionItem.DisplayOrder) {
return 'sortTitle'; // Fallback default
}
const displayOrder = collectionItem.DisplayOrder;
return DISPLAY_ORDER_MAP[displayOrder] || 'sortTitle';
}
/**
* Get current sort preference from localStorage or collection default
* @param {string} collectionId - Collection item ID
* @param {string} defaultSort - Default sort key from collection DisplayOrder
* @returns {string} Sort key
*/
function getCurrentSort(collectionId, defaultSort) {
const storageKey = STORAGE_KEY_PREFIX_SORT + collectionId;
const savedSort = localStorage.getItem(storageKey);
if (savedSort && SORT_OPTIONS[savedSort]) {
return savedSort;
}
return defaultSort || 'sortTitle';
}
/**
* Get current sort direction from localStorage
* @param {string} collectionId - Collection item ID
* @param {string} sortKey - Current sort key
* @returns {string} 'asc' or 'desc'
*/
function getCurrentSortDirection(collectionId, sortKey) {
const storageKey = STORAGE_KEY_PREFIX_DIRECTION + collectionId;
const savedDirection = localStorage.getItem(storageKey);
if (savedDirection === 'asc' || savedDirection === 'desc') {
return savedDirection;
}
// Default to the sort option's default direction
return SORT_OPTIONS[sortKey].defaultDirection;
}
/**
* Save sort preference to localStorage
* @param {string} collectionId - Collection item ID
* @param {string} sortKey - Sort key
* @param {string} direction - 'asc' or 'desc'
*/
function saveSortPreference(collectionId, sortKey, direction) {
const sortStorageKey = STORAGE_KEY_PREFIX_SORT + collectionId;
const directionStorageKey = STORAGE_KEY_PREFIX_DIRECTION + collectionId;
localStorage.setItem(sortStorageKey, sortKey);
localStorage.setItem(directionStorageKey, direction);
}
/**
* Fetch collection children items
* @param {string} collectionId - Collection item ID
* @returns {Promise<Array>} Array of child items
*/
async function fetchCollectionChildren(collectionId) {
try {
const userId = ApiClient.getCurrentUserId();
const response = await ApiClient.getItems(userId, {
ParentId: collectionId,
Fields: 'PrimaryImageAspectRatio,DateCreated,CommunityRating,CriticRating,SortName,PremiereDate'
});
return response.Items || [];
} catch (error) {
ERR('Error fetching collection children:', error);
return [];
}
}
/**
* Sort items array based on current sort criteria
* @param {Array} items - Array of item objects
* @param {string} sortKey - Sort key
* @param {string} direction - 'asc' or 'desc'
* @returns {Array} Sorted array
*/
function sortItems(items, sortKey, direction) {
const sortOption = SORT_OPTIONS[sortKey];
if (!sortOption) {
WARN('Invalid sort key:', sortKey);
return items;
}
const field = sortOption.field;
const sorted = [...items].sort((a, b) => {
let valueA = a[field];
let valueB = b[field];
// Handle null/undefined values
if (valueA == null && valueB == null) return 0;
if (valueA == null) return 1;
if (valueB == null) return -1;
// Handle dates
if (field === 'PremiereDate' || field === 'DateCreated') {
valueA = valueA ? new Date(valueA).getTime() : 0;
valueB = valueB ? new Date(valueB).getTime() : 0;
}
// Handle numbers (ratings)
if (field === 'CommunityRating' || field === 'CriticRating') {
valueA = valueA || 0;
valueB = valueB || 0;
}
// Handle strings (sort title)
if (typeof valueA === 'string' && typeof valueB === 'string') {
valueA = valueA.toLowerCase();
valueB = valueB.toLowerCase();
}
// Compare values
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}
/**
* Reorder DOM cards to match sorted items order
* @param {HTMLElement} itemsContainer - Container with .card elements
* @param {Array} sortedItems - Sorted array of items
*/
function reorderCards(itemsContainer, sortedItems) {
if (!itemsContainer) {
WARN('Items container not found');
return;
}
const cards = Array.from(itemsContainer.querySelectorAll('.card'));
if (cards.length === 0) {
WARN('No cards found in container');
return;
}
// Create a map of item ID to card element
const cardMap = new Map();
cards.forEach(card => {
const itemId = card.getAttribute('data-id');
if (itemId) {
cardMap.set(itemId, card);
}
});
// Check if reordering is needed by comparing current order with desired order
const currentOrder = cards.map(card => card.getAttribute('data-id'));
const desiredOrder = sortedItems.map(item => item.Id);
// If order matches, no need to reorder
if (currentOrder.length === desiredOrder.length &&
currentOrder.every((id, index) => id === desiredOrder[index])) {
LOG('Cards already in correct order');
return;
}
// Remove all cards from DOM (but keep references in cardMap)
cards.forEach(card => card.remove());
// Reorder cards based on sorted items and append in correct order
sortedItems.forEach((item) => {
const card = cardMap.get(item.Id);
if (card) {
itemsContainer.appendChild(card);
}
});
}
/**
* Sort collection items and update DOM
* @param {string} collectionId - Collection item ID
* @param {string} defaultSort - Default sort key from collection DisplayOrder
*/
function sortCollectionItems(collectionId, defaultSort) {
const data = collectionData.get(collectionId);
if (!data || !data.items || !data.itemsContainer) {
WARN('Collection data not found for:', collectionId);
return;
}
const sortKey = getCurrentSort(collectionId, defaultSort);
const direction = getCurrentSortDirection(collectionId, sortKey);
LOG(`Sorting collection ${collectionId} by ${sortKey} (${direction})`);
// Sort items
const sortedItems = sortItems(data.items, sortKey, direction);
// Update stored items
data.items = sortedItems;
// Reorder DOM
reorderCards(data.itemsContainer, sortedItems);
}
/**
* Show sort selection modal
* @param {string} collectionId - Collection item ID
* @param {string} defaultSort - Default sort key from collection DisplayOrder
*/
function showSortModal(collectionId, defaultSort) {
const currentSort = getCurrentSort(collectionId, defaultSort);
const currentDirection = getCurrentSortDirection(collectionId, currentSort);
// Build modal content matching watchlist sort modal structure
const sortOptionsContent = `
<div>
${Object.entries(SORT_OPTIONS).map(([key, option]) => {
const checked = key === currentSort ? 'checked=""' : '';
return `
<label class="radio-label-block mdl-radio mdl-js-radio mdl-js-ripple-effect show-focus">
<input type="radio" is="emby-radio" name="sortOption" data-id="${key}" value="${key}" class="menuSortBy mdl-radio__button" data-radio="true" ${checked}>
<div class="mdl-radio__circles">
<svg>
<defs>
<clipPath id="cutoff-collections-${key}">
<circle cx="50%" cy="50%" r="50%"></circle>
</clipPath>
</defs>
<circle class="mdl-radio__outer-circle" cx="50%" cy="50%" r="50%" fill="none" stroke="currentcolor" stroke-width="0.26em" clip-path="url(#cutoff-collections-${key})"></circle>
<circle class="mdl-radio__inner-circle" cx="50%" cy="50%" r="25%" fill="currentcolor"></circle>
</svg>
<div class="mdl-radio__focus-circle"></div>
</div>
<span class="radioButtonLabel mdl-radio__label">${option.label}</span>
</label>
`;
}).join('')}
</div>
<h2 style="margin: 1em 0 .5em;">Sort Order</h2>
<div>
<label class="radio-label-block mdl-radio mdl-js-radio mdl-js-ripple-effect show-focus">
<input type="radio" is="emby-radio" name="sortDirection" value="asc" class="menuSortOrder mdl-radio__button" data-radio="true" ${currentDirection === 'asc' ? 'checked=""' : ''}>
<div class="mdl-radio__circles">
<svg>
<defs>
<clipPath id="cutoff-collections-asc">
<circle cx="50%" cy="50%" r="50%"></circle>
</clipPath>
</defs>
<circle class="mdl-radio__outer-circle" cx="50%" cy="50%" r="50%" fill="none" stroke="currentcolor" stroke-width="0.26em" clip-path="url(#cutoff-collections-asc)"></circle>
<circle class="mdl-radio__inner-circle" cx="50%" cy="50%" r="25%" fill="currentcolor"></circle>
</svg>
<div class="mdl-radio__focus-circle"></div>
</div>
<span class="radioButtonLabel mdl-radio__label">Ascending</span>
</label>
<label class="radio-label-block mdl-radio mdl-js-radio mdl-js-ripple-effect show-focus">
<input type="radio" is="emby-radio" name="sortDirection" value="desc" class="menuSortOrder mdl-radio__button" data-radio="true" ${currentDirection === 'desc' ? 'checked=""' : ''}>
<div class="mdl-radio__circles">
<svg>
<defs>
<clipPath id="cutoff-collections-desc">
<circle cx="50%" cy="50%" r="50%"></circle>
</clipPath>
</defs>
<circle class="mdl-radio__outer-circle" cx="50%" cy="50%" r="50%" fill="none" stroke="currentcolor" stroke-width="0.26em" clip-path="url(#cutoff-collections-desc)"></circle>
<circle class="mdl-radio__inner-circle" cx="50%" cy="50%" r="25%" fill="currentcolor"></circle>
</svg>
<div class="mdl-radio__focus-circle"></div>
</div>
<span class="radioButtonLabel mdl-radio__label">Descending</span>
</label>
</div>
`;
// Create modal using the generic modal system
const modal = window.ModalSystem.create({
id: 'kt-collections-sort-modal',
title: 'Sort By',
content: sortOptionsContent,
onOpen: (modalInstance) => {
// Set current selection
const currentRadio = modalInstance.dialog.querySelector(`input[value="${currentSort}"]`);
const currentDirectionRadio = modalInstance.dialog.querySelector(`input[value="${currentDirection}"]`);
if (currentRadio) {
currentRadio.checked = true;
}
if (currentDirectionRadio) {
currentDirectionRadio.checked = true;
}
// Add event listeners for radio button changes
const sortOptions = modalInstance.dialog.querySelectorAll('input[name="sortOption"]');
const sortDirections = modalInstance.dialog.querySelectorAll('input[name="sortDirection"]');
// Apply changes immediately when radio buttons change
const applyChanges = () => {
const selectedOption = modalInstance.dialog.querySelector('input[name="sortOption"]:checked');
const selectedDirection = modalInstance.dialog.querySelector('input[name="sortDirection"]:checked');
if (selectedOption) {
const direction = selectedDirection ? selectedDirection.value : getCurrentSortDirection(collectionId, selectedOption.value);
saveSortPreference(collectionId, selectedOption.value, direction);
sortCollectionItems(collectionId, defaultSort);
}
};
sortOptions.forEach(option => {
option.addEventListener('change', applyChanges);
});
sortDirections.forEach(direction => {
direction.addEventListener('change', applyChanges);
});
}
});
}
/**
* Create and add sort button to section title
* @param {HTMLElement} sectionTitle - Section title element
* @param {string} collectionId - Collection item ID
* @param {string} defaultSort - Default sort key from collection DisplayOrder
*/
function addSortButton(sectionTitle, collectionId, defaultSort) {
// Check if button already exists
if (sectionTitle.querySelector('.kt-collections-sort-btn')) {
return;
}
// Create button
const button = document.createElement('button');
button.className = 'kt-collections-sort-btn paper-icon-button-light emby-button';
button.setAttribute('is', 'paper-icon-button-light');
button.title = 'Sort Collection';
button.style.cssText = 'margin-left: 0.5em; cursor: pointer; font-size: 0.7em; padding: 0;';
// Create icon
const icon = document.createElement('span');
icon.className = 'material-icons sort';
icon.setAttribute('aria-hidden', 'true');
button.appendChild(icon);
// Add click handler
button.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
showSortModal(collectionId, defaultSort);
};
// Append after existing span in sectionTitle
const existingSpan = sectionTitle.querySelector('span');
if (existingSpan) {
existingSpan.insertAdjacentElement('afterend', button);
} else {
sectionTitle.appendChild(button);
}
}
/**
* Poll for an element to appear in the DOM
* @param {Function} selectorFn - Function that returns the element or null
* @param {number} maxWaitTime - Maximum time to wait in milliseconds (default: 5000)
* @param {number} pollInterval - Interval between checks in milliseconds (default: 500)
* @returns {Promise<HTMLElement|null>} The element if found, null otherwise
*/
async function pollForElement(selectorFn, maxWaitTime = 5000, pollInterval = 500) {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
const element = selectorFn();
if (element) {
return element;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
return null;
}
/**
* Re-apply sorting after container re-render
* @param {string} collectionId - Collection item ID
* @param {HTMLElement} collectionItemsContainer - Collection items container
* @param {string} defaultSort - Default sort key from collection DisplayOrder
*/
async function reapplyCollectionSorting(collectionId, collectionItemsContainer, defaultSort) {
// Poll for section title (up to 2 seconds, every 200ms) - shorter timeout since we know container exists
const sectionTitle = await pollForElement(() => {
return collectionItemsContainer.querySelector('.sectionTitle');
}, 2000, 200);
if (!sectionTitle) {
WARN('Section title not found after re-render for collection:', collectionId);
return;
}
// Find items container
const itemsContainer = collectionItemsContainer.querySelector('.itemsContainer');
if (!itemsContainer) {
WARN('Items container not found after re-render for collection:', collectionId);
return;
}
// Check if sort button exists, if not add it
const sortButton = sectionTitle.querySelector('.kt-collections-sort-btn');
if (!sortButton) {
LOG('Sort button missing after re-render, re-adding for collection:', collectionId);
addSortButton(sectionTitle, collectionId, defaultSort);
}
// Update stored items container reference (in case it was recreated)
const data = collectionData.get(collectionId);
if (data) {
data.itemsContainer = itemsContainer;
// Re-apply sort
sortCollectionItems(collectionId, defaultSort);
} else {
WARN('Collection data not found for re-render:', collectionId);
}
}
/**
* Setup MutationObserver for collection items container
* @param {string} collectionId - Collection item ID
* @param {HTMLElement} collectionItemsContainer - Collection items container
* @param {string} defaultSort - Default sort key from collection DisplayOrder
*/
function setupCollectionObserver(collectionId, collectionItemsContainer, defaultSort) {
// Disconnect existing observer if any
if (collectionObserver) {
collectionObserver.disconnect();
collectionObserver = null;
}
// Debounce timer for re-apply
let debounceTimer = null;
collectionObserver = new MutationObserver((mutations) => {
let shouldReapply = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check if nodes were added (indicating a re-render)
if (mutation.addedNodes.length > 0) {
// Check if any added node is a direct child or contains important elements
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// If sectionTitle or itemsContainer was added, we need to reapply
if (node.classList?.contains('sectionTitle') ||
node.classList?.contains('itemsContainer') ||
node.querySelector?.('.sectionTitle') ||
node.querySelector?.('.itemsContainer')) {
shouldReapply = true;
}
}
});
}
}
});
if (shouldReapply) {
// Debounce to avoid multiple rapid re-applications
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
LOG('Collection container re-rendered, re-applying sort for:', collectionId);
reapplyCollectionSorting(collectionId, collectionItemsContainer, defaultSort);
}, 300); // 300ms debounce
}
});
// Observe only the collectionItems container with childList changes
collectionObserver.observe(collectionItemsContainer, {
childList: true,
subtree: true
});
LOG('MutationObserver set up for collection:', collectionId);
}
/**
* Add collection sorting functionality
* @param {Object} item - Collection item (BoxSet)
*/
async function addCollectionSorting(item) {
if (!item || item.Type !== 'BoxSet' || !item.Id) {
return;
}
const collectionId = item.Id;
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) {
return;
}
// Find collection items container
const collectionItemsContainer = activePage.querySelector('.collectionItems');
if (!collectionItemsContainer) {
WARN('Collection items container not found');
return;
}
// Check if already processed (but allow re-processing if button is missing)
const sortButton = collectionItemsContainer.querySelector('.kt-collections-sort-btn');
if (activePage.dataset.collectionSortingAdded === 'true' && sortButton) {
return;
}
// Poll for section title (up to 5 seconds, every 500ms)
const sectionTitle = await pollForElement(() => {
return collectionItemsContainer.querySelector('.sectionTitle');
}, 5000, 100);
if (!sectionTitle) {
WARN('Section title not found after polling for 5 seconds');
return;
}
// Find items container
const itemsContainer = collectionItemsContainer.querySelector('.itemsContainer');
if (!itemsContainer) {
WARN('Items container not found');
return;
}
LOG('Adding collection sorting for:', collectionId);
// Fetch collection children
const children = await fetchCollectionChildren(collectionId);
if (children.length === 0) {
WARN('No children found for collection:', collectionId);
return;
}
// Get default sort from collection DisplayOrder
const defaultSort = getDefaultSortFromCollection(item);
// Store collection data
collectionData.set(collectionId, {
items: children,
itemsContainer: itemsContainer,
defaultSort: defaultSort
});
// Add sort button (only if it doesn't exist)
if (!sortButton) {
addSortButton(sectionTitle, collectionId, defaultSort);
}
// Apply initial sort on page load
sortCollectionItems(collectionId, defaultSort);
// Setup MutationObserver to handle re-renders
setupCollectionObserver(collectionId, collectionItemsContainer, defaultSort);
// Mark as processed
activePage.dataset.collectionSortingAdded = 'true';
}
/**
* Initialize collections hook
*/
function initializeCollectionsHook() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second');
setTimeout(initializeCollectionsHook, 1000);
return;
}
LOG('Registering collections handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
async (view, element, hash, itemPromise) => {
// Only handle details pages
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) return;
// Await the item promise to get the actual item data
const item = await itemPromise;
// Small delay to ensure details DOM is ready
setTimeout(async () => {
if (item && item.Id) {
await addCollectionSorting(item);
}
}, 100);
},
{
pages: ['details']
}
);
LOG('Collections hook initialized');
}
/**
* Initialize script
*/
function initialize() {
if (!window.ApiClient || !window.ApiClient.getCurrentUserId) {
WARN('ApiClient not available, retrying in 1 second');
setTimeout(initialize, 1000);
return;
}
// Initialize collections hook
initializeCollectionsHook();
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
LOG('Script loaded successfully');
})();
+113
View File
@@ -0,0 +1,113 @@
// 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
// 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 || [];
}
// Initialize custom menu links
async function initializeCustomMenuLinks() {
const customMenuLinks = getCustomMenuLinksConfig();
if (!Array.isArray(customMenuLinks) || customMenuLinks.length === 0) {
LOG('No custom menu links configured');
return;
}
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) => {
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,
icon = 'link', // Default icon
url,
openInNewTab = false
} = linkConfig;
LOG(`Adding custom menu link: ${name}`);
const success = await window.KefinTweaksUtils.addCustomMenuLink(
name,
icon,
url,
openInNewTab
);
if (success) {
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');
initializeCustomMenuLinks();
return;
}
LOG('Waiting for KefinTweaksUtils to be available');
// Poll for utils availability
const checkInterval = setInterval(() => {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
clearInterval(checkInterval);
LOG('Utils available, initializing custom menu links');
initializeCustomMenuLinks();
}
}, 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');
})();
+37
View File
@@ -0,0 +1,37 @@
// Dashboard Button Fix
// This is a workaround for a bug in the dashboard back button
// The dashboard back button currently leaves the Jellyfin app if the user navigates to the dashboard directly from the URL
// 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;
if (btn._backCheckAttached) return;
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.");
// 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();
}
// Observe DOM in case button is recreated
const observer = new MutationObserver(attachHandler);
observer.observe(document.body, { childList: true, subtree: true });
// Initial run
attachHandler();
})();
+38
View File
@@ -0,0 +1,38 @@
.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;
}
/* Item Details Page */
div#seriesScheduleSection {
order: 2;
}
.detailPageContent>* {
order: 1;
}
.commentsSection {
order: 3;
}
@media (max-width: 768px) {
.headerRight .btnRecommendations,
.headerRight .btnAdmin,
.headerRight .headerAppearanceButton {
display: none;
}
}
.itemBackdrop {
pointer-events: none;
}
+392
View File
@@ -0,0 +1,392 @@
// Device Manager
// Adds a "Remove Device" button to active device sessions on the dashboard page
// Allows users to remove devices via a custom endpoint
(function() {
'use strict';
// Logging functions
const LOG = (...args) => console.log('[DeviceManager]', ...args);
const WARN = (...args) => console.warn('[DeviceManager]', ...args);
const ERR = (...args) => console.error('[DeviceManager]', ...args);
LOG('Initializing Device Manager');
// Polling interval references
let pollingInterval = null;
let continuousPollingInterval = null;
// Wait for KefinTweaksUtils to become available
async function waitForUtils(maxWaitTime = 30000, checkInterval = 100) {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.onViewPage) {
LOG('KefinTweaksUtils is now available');
return true;
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
ERR('KefinTweaksUtils not available after waiting. Device Manager requires utils.js');
return false;
}
// Initialize the device manager after utils are available
async function initializeDeviceManager() {
// Wait for utils to be available
const utilsAvailable = await waitForUtils();
if (!utilsAvailable) {
return;
}
// Register handler for dashboard page
window.KefinTweaksUtils.onViewPage((view, element) => {
// Check if we're on the dashboard page
const isDashboardPage = view === 'dashboard' ||
view === 'dashboardPage' ||
window.location.hash.includes('dashboard') ||
document.querySelector('#dashboardPage:not(.hide)');
if (!isDashboardPage) {
// Not on dashboard, clear polling intervals
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
if (continuousPollingInterval) {
clearInterval(continuousPollingInterval);
continuousPollingInterval = null;
}
return;
}
LOG('Dashboard page detected, initializing device manager');
// Start polling for activeDevicesContainer
initializeDashboard();
}, {
pages: ['dashboard', 'dashboardPage']
});
LOG('Device Manager initialized');
}
// Check if ApiClient is available
function isApiClientAvailable() {
return window.ApiClient &&
typeof window.ApiClient.accessToken === 'function' &&
typeof window.ApiClient.serverAddress === 'function' &&
typeof window.ApiClient.getSessions === 'function';
}
// Get session data by session ID
async function getSessionData(sessionId, sessions) {
try {
if (!isApiClientAvailable()) {
throw new Error('ApiClient not available or getSessions method not found');
}
if (!sessions || !Array.isArray(sessions)) {
throw new Error('Failed to get sessions or sessions is not an array');
}
LOG(`Retrieved ${sessions.length} sessions, filtering for session ID: ${sessionId}`);
// Filter sessions to find the one matching our session ID
const sessionData = sessions.find(session => session.Id === sessionId);
if (!sessionData) {
WARN(`Session with ID ${sessionId} not found in sessions list`);
return null;
}
return sessionData;
} catch (error) {
ERR('Error fetching session data:', error);
return null;
}
}
// Remove device via custom endpoint
async function removeDevice(deviceId) {
try {
if (!isApiClientAvailable()) {
throw new Error('ApiClient not available');
}
const token = window.ApiClient.accessToken();
if (!token) {
throw new Error('Access token not available');
}
LOG(`Removing device: ${deviceId}`);
const serverAddress = window.ApiClient.serverAddress();
if (!serverAddress) {
throw new Error('Server address not available');
}
const response = await fetch(`${serverAddress}/Devices?Id=${encodeURIComponent(deviceId)}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
LOG('Device removed successfully');
return true;
} catch (error) {
ERR('Error removing device:', error);
return false;
}
}
// Create remove device button
function createRemoveDeviceButton(deviceId) {
const button = document.createElement('button');
button.setAttribute('is', 'paper-icon-button-light');
button.className = 'sessionCardButton btnSessionRemoveDevice paper-icon-button-light';
button.setAttribute('title', 'Remove Device');
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons';
iconSpan.setAttribute('aria-hidden', 'true');
iconSpan.textContent = 'delete';
button.appendChild(iconSpan);
// Add click handler
button.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (!confirm('Are you sure you want to remove this device?')) {
return;
}
button.disabled = true;
const originalTitle = button.getAttribute('title');
button.setAttribute('title', 'Removing...');
const success = await removeDevice(deviceId);
if (success) {
// Remove the session container from the DOM
const sessionContainer = button.closest('.activeSession');
if (sessionContainer) {
sessionContainer.style.transition = 'opacity 0.3s';
sessionContainer.style.opacity = '0';
setTimeout(() => {
sessionContainer.remove();
}, 300);
}
// Show success message if toaster is available
if (window.KefinTweaksToaster) {
window.KefinTweaksToaster.show('Device removed successfully', 'success');
}
} else {
button.disabled = false;
button.setAttribute('title', originalTitle);
// Show error message if toaster is available
if (window.KefinTweaksToaster) {
window.KefinTweaksToaster.show('Failed to remove device', 'error');
}
}
});
return button;
}
// Process a single session container
async function processSessionContainer(container, sessions) {
// Check if button already exists
const existingButton = container.querySelector('.btnSessionRemoveDevice');
if (existingButton) {
LOG('Remove device button already exists for this session');
return;
}
// Get session ID from container ID
const containerId = container.id;
if (!containerId || !containerId.startsWith('session')) {
WARN('Container ID does not match expected format:', containerId);
return;
}
const sessionId = containerId.substring(7); // Remove "session" prefix
if (!sessionId) {
WARN('Could not extract session ID from container ID:', containerId);
return;
}
LOG(`Processing session container with ID: ${sessionId}`);
// Find the sessionCardButtons container
const buttonsContainer = container.querySelector('.sessionCardButtons');
if (!buttonsContainer) {
WARN('sessionCardButtons container not found');
return;
}
// Fetch session data to get device ID
const sessionData = await getSessionData(sessionId, sessions);
if (!sessionData || !sessionData.DeviceId) {
WARN('Could not get device ID from session data');
return;
}
const deviceId = sessionData.DeviceId;
LOG(`Found device ID: ${deviceId}`);
// Create and add remove device button
const removeButton = createRemoveDeviceButton(deviceId);
buttonsContainer.appendChild(removeButton);
LOG('Remove device button added successfully');
}
// Process all active session containers on the dashboard
async function processActiveSessions() {
const dashboardPage = document.querySelector('#dashboardPage:not(.hide)');
if (!dashboardPage) {
return;
}
const activeDevicesContainer = dashboardPage.querySelector('.activeDevices');
if (!activeDevicesContainer) {
return;
}
const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession');
LOG(`Found ${activeSessions.length} active session containers`);
if (activeSessions.length === 0) {
return;
}
let sessions = null;
if (window.ApiClient && window.ApiClient.getSessions) {
sessions = await window.ApiClient.getSessions();
}
if (!sessions) {
WARN('Failed to get sessions');
return;
}
// Process each session container
for (const sessionContainer of activeSessions) {
await processSessionContainer(sessionContainer, sessions);
}
}
// Poll for activeDevicesContainer and process sessions
function pollForActiveDevicesContainer(maxAttempts = 50, interval = 500) {
// Clear any existing intervals
if (pollingInterval) {
clearInterval(pollingInterval);
}
if (continuousPollingInterval) {
clearInterval(continuousPollingInterval);
}
let attempts = 0;
pollingInterval = setInterval(async () => {
attempts++;
const dashboardPage = document.querySelector('#dashboardPage:not(.hide)');
if (!dashboardPage) {
if (attempts >= maxAttempts) {
clearInterval(pollingInterval);
pollingInterval = null;
LOG('Dashboard page not found after max attempts');
}
return;
}
const activeDevicesContainer = dashboardPage.querySelector('.activeDevices');
if (!activeDevicesContainer) {
if (attempts >= maxAttempts) {
clearInterval(pollingInterval);
pollingInterval = null;
LOG('Active devices container not found after max attempts');
}
return;
}
// Found the container, check for active sessions
clearInterval(pollingInterval);
pollingInterval = null;
LOG('Active devices container found, checking for sessions');
// Check if there are any active session containers
const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession');
if (activeSessions.length > 0) {
LOG(`Found ${activeSessions.length} active session containers, processing and stopping polling`);
// Process all existing sessions
await processActiveSessions();
// Stop polling once we've found and processed sessions
LOG('Sessions processed, polling stopped');
return;
}
// No sessions found yet, continue polling periodically
if (!continuousPollingInterval) {
continuousPollingInterval = setInterval(async () => {
const currentDashboardPage = document.querySelector('#dashboardPage:not(.hide)');
if (!currentDashboardPage) {
// Dashboard page no longer visible, stop continuous polling
clearInterval(continuousPollingInterval);
continuousPollingInterval = null;
// Restart initial polling
pollForActiveDevicesContainer();
return;
}
const currentActiveDevicesContainer = currentDashboardPage.querySelector('.activeDevices');
if (!currentActiveDevicesContainer) {
return;
}
const currentActiveSessions = currentActiveDevicesContainer.querySelectorAll('.activeSession');
if (currentActiveSessions.length > 0) {
LOG(`Found ${currentActiveSessions.length} active session containers, processing and stopping polling`);
// Stop continuous polling
clearInterval(continuousPollingInterval);
continuousPollingInterval = null;
// Process all sessions
await processActiveSessions();
LOG('Sessions processed, polling stopped');
}
}, interval * 2); // Poll less frequently after initial find
}
}, interval);
}
// Main initialization
function initializeDashboard() {
// Start polling for activeDevicesContainer
pollForActiveDevicesContainer();
}
// Start initialization
initializeDeviceManager();
})();
+58
View File
@@ -0,0 +1,58 @@
// Exclusive Elsewhere Script
// Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality
// Adds custom branding when the item is not available on any selected streaming services
//
// If you want to add an icon to the text you can do so by targeting the "exclusive" class.
//
// For example:
// .streaming-lookup-container a.exclusive::after {
// content: '';
// background: url(/path/to/icon.png);
// background-size: contain;
// background-repeat: no-repeat;
// width: 25px;
// height: 25px;
// display: inline-block;
// margin-left: 0.5rem;
// }
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks ExclusiveElsewhere]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks ExclusiveElsewhere]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ExclusiveElsewhere]', ...args);
LOG('Script loaded - JS Injector mode');
// Get configuration from global config
const config = window.KefinTweaksConfig?.exclusiveElsewhere || {
hideServerName: false // Default fallback
};
const observer = new MutationObserver(() => {
const elsewhereContainer = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div');
if (elsewhereContainer && elsewhereContainer.children && elsewhereContainer.children.length > 2) {
return;
}
const serverName = config.hideServerName ? '' : 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.disable = true;
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
LOG('Exclusive elsewhere functionality initialized');
})();
+358
View File
@@ -0,0 +1,358 @@
// KefinTweaks Flatten Single Season Shows
// Flattens series with only 1 season to show episodes directly on the series details page
// Requires: cardBuilder.js, utils.js modules to be loaded before this script
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks FlattenSingleSeasonShows]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks FlattenSingleSeasonShows]', ...args);
const ERR = (...args) => console.error('[KefinTweaks FlattenSingleSeasonShows]', ...args);
/**
* Fetches the Next Up episode for a given series
* @param {string} seriesId - The series ID
* @returns {Promise<Object|null>} - Episode number object with season and episode, or null
*/
async function fetchNextUpEpisode(seriesId) {
try {
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching Next Up episode for series: ${seriesId}`);
const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`;
const nextUpRes = await fetch(nextUpUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!nextUpRes.ok) {
throw new Error(`HTTP ${nextUpRes.status}: ${nextUpRes.statusText}`);
}
const nextUpData = await nextUpRes.json();
const items = nextUpData.Items || [];
if (items.length > 0) {
const item = items[0];
if (item.IndexNumber && item.ParentIndexNumber) {
const episodeNumber = {
season: item.ParentIndexNumber,
episode: item.IndexNumber
};
LOG(`Fetched Next Up episode from API: S${episodeNumber.season}:E${episodeNumber.episode}`);
return episodeNumber;
}
}
LOG('No Next Up episode found in API response');
return null;
} catch (error) {
ERR(`Failed to fetch Next Up episode for series ${seriesId}:`, error);
return null;
}
}
/**
* Fetches all episodes for a given series
* @param {string} seriesId - The series ID
* @returns {Promise<Array>} - Array of episode items
*/
async function fetchEpisodesForSeries(seriesId) {
try {
const apiClient = window.ApiClient;
const userId = apiClient.getCurrentUserId();
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
LOG(`Fetching episodes for series: ${seriesId}`);
const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`;
const episodesRes = await fetch(episodesUrl, {
headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!episodesRes.ok) {
throw new Error(`HTTP ${episodesRes.status}: ${episodesRes.statusText}`);
}
const episodesData = await episodesRes.json();
const episodes = episodesData.Items || [];
LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}`);
return episodes;
} catch (error) {
ERR(`Failed to fetch episodes for series ${seriesId}:`, error);
return [];
}
}
/**
* Flattens a single-season show by creating a "Season 1" section with all episodes
* @param {string} seriesId - The series ID
*/
async function flattenShow(seriesId) {
LOG(`Flattening show: ${seriesId}`);
// Check if already flattened for this series (avoid duplicates)
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (activePage && activePage.dataset.flattenedSeriesId === seriesId) {
LOG(`Show ${seriesId} already flattened, skipping`);
return;
}
// Check if flattened section already exists
const existingSection = activePage?.querySelector('.flattened-season-section');
if (existingSection) {
LOG(`Flattened section already exists, skipping`);
return;
}
// Get all episodes for the series
const episodes = await fetchEpisodesForSeries(seriesId);
if (episodes.length === 0) {
WARN(`No episodes found for series: ${seriesId}`);
return;
}
// Check for cardBuilder
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
WARN('cardBuilder.renderCards not available');
return;
}
// Find the detailPageContent
const detailPageContent = activePage?.querySelector('.detailPageContent');
if (!detailPageContent) {
WARN('detailPageContent not found');
return;
}
// Find the #childrenCollapsible div
const childrenCollapsible = detailPageContent.querySelector('#childrenCollapsible');
if (!childrenCollapsible) {
WARN('#childrenCollapsible not found');
return;
}
try {
// Get the season ID from the first episode's ParentId
const seasonId = episodes[0]?.SeasonId;
let viewMoreUrl = null;
if (seasonId) {
const apiClient = window.ApiClient;
const serverId = apiClient.serverId();
viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${seasonId}&serverId=${serverId}`;
LOG(`Setting viewMoreUrl to season details: ${viewMoreUrl}`);
} else {
WARN('Could not determine season ID from episodes');
}
// Check if Next Up section exists
const nextUpSection = activePage?.querySelector('.nextUpSection');
const nextUpItemsContainer = nextUpSection?.querySelector('.itemsContainer');
// Extract episode info from Next Up if it exists
let targetEpisodeNumber = null;
if (nextUpSection && nextUpItemsContainer) {
LOG('Next Up section found, hiding nextUpSection');
// Get the existing card from Next Up and extract episode info
const existingCard = nextUpItemsContainer.querySelector('.card');
if (existingCard) {
// Find the .cardText a element that contains the episode number
const cardTextLinks = existingCard.querySelectorAll('.cardText a');
for (const link of cardTextLinks) {
const linkText = link.innerText || link.textContent;
// Match SX:EY pattern (may be part of longer text like "S1:E5 - Episode Name")
const match = linkText.match(/S(\d+):E(\d+)/);
if (match) {
targetEpisodeNumber = {
season: parseInt(match[1], 10),
episode: parseInt(match[2], 10)
};
LOG(`Found Next Up episode from DOM: S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
break;
}
}
}
// If we couldn't find it from the DOM, fetch from API
if (!targetEpisodeNumber) {
LOG('Could not find episode number from DOM, fetching from API');
targetEpisodeNumber = await fetchNextUpEpisode(seriesId);
}
// Hide the entire Next Up section
nextUpSection.style.display = 'none';
}
// Render Season 1 section using renderCards (default behavior)
const seasonSection = window.cardBuilder.renderCards(
episodes,
'Season 1',
viewMoreUrl,
true, // overflowCard
null // cardFormat (use default)
);
// Mark as flattened section
seasonSection.classList.add('flattened-season-section');
seasonSection.setAttribute('data-flattened-section', 'true');
const scrollerContainer = seasonSection.querySelector('.emby-scroller');
if (scrollerContainer) {
scrollerContainer.classList.add('no-padding');
}
const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer');
if (sectionTitleContainer) {
sectionTitleContainer.classList.add('no-padding');
}
// Prepend the Season 1 section right before #childrenCollapsible
childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible);
// 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 scrollToPosition value
// The card offset minus padding gives us how much to scroll
const translateX = (cardOffset - scrollerPadding);
scrollerContainer.scrollToPosition(translateX);
// Enable the left scroll button so users can scroll back
const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons');
if (scrollButtons) {
const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
if (leftButton) {
leftButton.removeAttribute('disabled');
LOG('Enabled left scroll button');
}
}
LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode} using translateX(${translateX}px)`);
} else {
WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`);
}
}, 100);
}
// Mark as flattened for this series to avoid duplicates
if (activePage) {
activePage.dataset.flattenedSeriesId = seriesId;
}
LOG(`Successfully flattened show ${seriesId} with ${episodes.length} episodes`);
} catch (error) {
ERR('Error rendering flattened season section:', error);
}
}
/**
* Initialize the flatten single season shows hook
*/
function initializeFlattenHook() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second');
setTimeout(initializeFlattenHook, 1000);
return;
}
LOG('Registering flatten single season shows handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
async (view, element, hash, itemPromise) => {
// Only handle details pages
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) return;
// Remove any existing flattened section and reset flag when page changes
const existingSection = activePage.querySelector('.flattened-season-section');
if (existingSection) {
LOG('Flattened section already exists, skipping');
return;
}
// Await the item promise to get the actual item data
const item = await itemPromise;
// Check if item is a Series and has only 1 ChildCount
if (item && item.Type === 'Series' && item.ChildCount === 1) {
LOG(`Found single-season series: ${item.Id} (${item.Name})`);
// Small delay to ensure details DOM is ready
setTimeout(async () => {
await flattenShow(item.Id);
}, 100);
}
},
{
pages: ['details']
}
);
LOG('Flatten single season shows hook initialized');
}
// Initialize the hook when the script loads
function initialize() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils not available, retrying in 1 second');
setTimeout(initialize, 1000);
return;
}
initializeFlattenHook();
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
LOG('Script loaded successfully');
})();
+453
View File
@@ -0,0 +1,453 @@
// Header Tabs Script
// Enhances header tab functionality by updating URL parameters when tabs are clicked
// Maintains tab state in the URL for better navigation and bookmarking
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks HeaderTabs]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HeaderTabs]', ...args);
const ERR = (...args) => console.error('[KefinTweaks HeaderTabs]', ...args);
LOG('Initializing...');
const SUPPORTED_PAGES = ['home', 'home.html', 'tv', 'tv.html', 'movies', 'movies.html', 'music', 'music.html', 'livetv', 'livetv.html'];
// Get current page from URL hash
function getCurrentPage() {
const hash = window.location.hash;
const hashMatches = hash.match(/#\/([^?]+)/g);
if (!hashMatches || hashMatches.length === 0) return null;
for (const match of hashMatches) {
const pageFromHash = match.replace('#/', '');
if (SUPPORTED_PAGES.includes(pageFromHash)) {
return pageFromHash;
}
}
return null;
}
// Get parent ID from URL (topParentId or parentId)
function getParentIdFromUrl() {
const hash = window.location.hash;
if (!hash.includes('?')) return null;
const [hashPath, hashSearch] = hash.split('?');
const params = new URLSearchParams(hashSearch);
// Prefer topParentId, fall back to parentId
return params.get('topParentId') || params.get('parentId') || null;
}
// Get current tab index from URL parameter
// Returns null if no tab parameter is present (so we can fetch from preferences)
function getCurrentTabFromUrl() {
const hash = window.location.hash;
if (!hash.includes('?')) return null;
const [hashPath, hashSearch] = hash.split('?');
const params = new URLSearchParams(hashSearch);
const tabParam = params.get('tab');
if (tabParam) {
const tabIndex = parseInt(tabParam, 10);
if (!isNaN(tabIndex) && tabIndex >= 0) {
LOG('Current tab from URL:', tabIndex);
return tabIndex;
}
}
return null;
}
let _userDisplayPreferences = null;
// Fetch display preferences and find matching tab based on landing preference
// Returns the tab index if found, or null if no match/preference found
// landingKey can be either a parentId (e.g., "f137a2dd21bbc1b99aa5c0f6bf02a805") or a page name (e.g., "livetv")
async function getTabFromDisplayPreferences(landingKey) {
if (!landingKey || !window.ApiClient) {
LOG('No landingKey or ApiClient not available');
return null;
}
try {
const userId = window.ApiClient.getCurrentUserId();
if (!userId) {
LOG('No user ID available');
return null;
}
if (!_userDisplayPreferences) {
const serverAddress = window.ApiClient._serverAddress;
const accessToken = window.ApiClient.accessToken();
if (!serverAddress || !accessToken) {
LOG('Server address or access token not available');
return null;
}
const response = await fetch(
`${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`,
{
headers: {
'X-Emby-Token': accessToken
}
}
);
if (!response.ok) {
WARN('Failed to fetch display preferences:', response.status);
return null;
}
_userDisplayPreferences = await response.json();
LOG('Fetched display preferences for landing tab preference');
}
const customPrefs = _userDisplayPreferences.CustomPrefs || {};
// Look for landing-{landingKey} in CustomPrefs
const fullLandingKey = `landing-${landingKey}`;
const landingValue = customPrefs[fullLandingKey];
if (!landingValue) {
LOG(`No landing preference found for ${fullLandingKey}`);
return null;
}
LOG(`Found landing preference for ${fullLandingKey}:`, landingValue);
// Find the tab button whose inner div text matches the landing value
const headerTabs = document.querySelector('.headerTabs');
if (!headerTabs) {
LOG('Header tabs not found');
return null;
}
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
for (const button of buttons) {
const div = button.querySelector('div');
if (div && div.innerText && div.innerText.replace(/\s+/g, '').trim().toLowerCase() === landingValue.replace(/\s+/g, '').trim().toLowerCase()) {
const tabIndex = parseInt(button.getAttribute('data-index') || '0', 10);
LOG(`Matched landing preference "${landingValue}" to tab index:`, tabIndex);
return tabIndex;
}
}
LOG(`No tab button found matching landing preference "${landingValue}"`);
return null;
} catch (error) {
ERR('Error fetching display preferences:', error);
return null;
}
}
// Update URL with tab parameter
function updateUrlWithTab(tabIndex) {
const currentHash = window.location.hash;
const currentPage = getCurrentPage();
if (!currentPage) return;
// Check if there's a parentId - if not, don't add tab=0
const parentId = getParentIdFromUrl();
const shouldIncludeTab = tabIndex !== 0 || parentId !== null;
let newHash = currentHash;
if (currentHash.includes('?')) {
// Replace existing tab parameter and clear watchlist-specific params
const [hashPath, hashSearch] = currentHash.split('?');
const params = new URLSearchParams(hashSearch);
// Clear watchlist-specific parameters when switching header tabs
params.delete('pageTab');
params.delete('page');
params.delete('sort');
params.delete('sortDirection');
params.delete('movieSort');
params.delete('movieSortDirection');
if (shouldIncludeTab) {
params.set('tab', tabIndex.toString());
} else {
// Remove tab parameter if it's tab 0 and no parentId
params.delete('tab');
}
const paramString = params.toString();
newHash = paramString ? `${hashPath}?${paramString}` : hashPath;
} else {
// Add tab parameter only if we should include it
if (shouldIncludeTab) {
newHash = `${currentHash}?tab=${tabIndex}`;
} else {
// Don't add tab parameter for tab 0 when no parentId
newHash = currentHash;
}
}
// Manually notify handlers for Header Tab navigation because it doesn't trigger normal Jellyfin navigation
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);
window.history.replaceState(null, '', newUrl);
LOG('Updated URL with tab:', tabIndex, newUrl);
}
// Handle tab button clicks
function handleTabClick(event) {
const clickedButton = event.currentTarget;
const tabIndex = parseInt(clickedButton.getAttribute('data-index') || '0', 10);
const currentPage = getCurrentPage();
if (currentPage) {
updateUrlWithTab(tabIndex);
// Clean up tab content - ensure only the clicked tab has is-active
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (libraryPage) {
const tabContents = libraryPage.querySelectorAll('.pageTabContent');
// Remove is-active class from all tab contents
tabContents.forEach(content => {
content.classList.remove('is-active');
});
// Add is-active class to the clicked tab content
const targetContent = libraryPage.querySelector(`.pageTabContent[data-index="${tabIndex}"]`);
if (targetContent) {
targetContent.classList.add('is-active');
LOG('Set active tab content:', tabIndex);
} else {
WARN('Target tab content not found for index:', tabIndex);
}
}
window.scrollTo(0, 0);
LOG('Tab clicked:', currentPage, tabIndex);
}
}
// Synchronize active tab state with URL parameter
async function syncActiveTabState() {
const headerTabs = document.querySelector('.headerTabs');
if (!headerTabs) return;
let currentTabFromUrl = getCurrentTabFromUrl();
// If no tab parameter in URL, try to fetch from display preferences
if (currentTabFromUrl === null) {
const parentId = getParentIdFromUrl();
let matchedTab = null;
if (parentId) {
// Try to get landing preference using parentId
LOG('No tab parameter found, fetching from display preferences for parentId:', parentId);
matchedTab = await getTabFromDisplayPreferences(parentId);
} else {
// No parentId, try to get landing preference using page name (e.g., "livetv", "home")
const currentPage = getCurrentPage();
if (currentPage) {
// Remove .html extension if present for the landing key
const pageName = currentPage.replace('.html', '');
LOG('No tab parameter and no parentId, fetching from display preferences for page:', pageName);
matchedTab = await getTabFromDisplayPreferences(pageName);
}
}
if (matchedTab !== null) {
// Only update URL if we successfully matched a landing preference
currentTabFromUrl = matchedTab;
updateUrlWithTab(matchedTab);
} else {
// No match found, use tab 0 for active state but don't update URL
LOG('No landing preference match found, using tab 0 without updating URL');
currentTabFromUrl = 0;
}
}
// Patch for media bar because it usually reacts slowly and hides later
// Hide media bar slideshow if we aren't specifically on the home page first tab
const currentPage = getCurrentPage();
if (currentPage !== 'home' && currentPage !== 'home.html' || currentTabFromUrl !== 0) {
const mediaBarSlideshow = document.getElementById('slides-container');
if (mediaBarSlideshow) {
mediaBarSlideshow.style.display = 'none';
}
}
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
const activeButton = headerTabs.querySelector('.emby-tab-button-active');
const activeTabContent = document.querySelector('.libraryPage:not(.hide) .pageTabContent.is-active');
const activeTabContentIndex = activeTabContent ? activeTabContent.getAttribute('data-index') : null;
// Remove is-active class from the active tab content if it's not the current tab
if (activeTabContentIndex && activeTabContentIndex !== currentTabFromUrl.toString()) {
activeTabContent.classList.remove('is-active');
}
// Add is-active class to the current tab content if it's not already active
if (!activeTabContentIndex || activeTabContentIndex !== currentTabFromUrl.toString()) {
const targetContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabFromUrl}"]`);
if (targetContent) {
targetContent.classList.add('is-active');
LOG('Set active tab content:', currentTabFromUrl);
}
}
if (activeButton && activeButton.getAttribute('data-index') === currentTabFromUrl.toString()) {
return;
}
// Remove active class from all buttons
buttons.forEach(button => {
button.classList.remove('emby-tab-button-active');
});
// Find the button with the correct data-index
const correctButton = Array.from(buttons).find(button => {
const buttonIndex = parseInt(button.getAttribute('data-index') || '0', 10);
return buttonIndex === currentTabFromUrl;
});
if (correctButton) {
correctButton.classList.add('emby-tab-button-active');
LOG('Set active tab button to index:', currentTabFromUrl);
}
}
// Add click listeners to tab buttons
async function addClickListeners() {
const headerTabs = document.querySelector('.headerTabs');
if (headerTabs) {
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
let totalButtons = 2;
buttons.forEach(button => {
if (button.dataset.kefin === 'true') {
return;
}
button.addEventListener('click', handleTabClick);
button.dataset.kefin = 'true';
});
const view = window.KefinTweaksUtils.getCurrentView();
if (view === 'home' || view === 'home.html') {
// Check for custom tabs
const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Emby-Token": ApiClient._serverInfo.AccessToken || ApiClient.accessToken(),
},
});
const customTabs = await response.json();
const customTabsCount = customTabs?.length || 0;
totalButtons += customTabsCount;
if (totalButtons > buttons.length && customTabsCount > 0) {
// Wait for custom tabs to be rendered and add click listeners to them
const tabsSlider = headerTabs.querySelector('.emby-tabs-slider');
if (tabsSlider) {
// Check if all custom tabs are already present
const checkAllTabsPresent = () => {
for (let i = 0; i < customTabsCount; i++) {
const customTabId = `customTabButton_${i}`;
if (!tabsSlider.querySelector(`#${customTabId}`)) {
return false;
}
}
return true;
};
if (checkAllTabsPresent()) {
// All tabs already present, add listeners immediately
const newButtons = headerTabs.querySelectorAll('.emby-tab-button');
newButtons.forEach(button => {
if (button.dataset.kefin !== 'true') {
button.addEventListener('click', handleTabClick);
button.dataset.kefin = 'true';
}
});
LOG('All custom tabs already present, added click listeners');
} else {
// Wait for custom tabs using MutationObserver
LOG('Waiting for custom tabs to be rendered...');
const observer = new MutationObserver((mutations, obs) => {
if (checkAllTabsPresent()) {
// All custom tabs are now present
obs.disconnect();
LOG('All custom tabs rendered, adding click listeners');
syncActiveTabState();
// Add click listeners to all buttons including the new custom tabs
const allButtons = headerTabs.querySelectorAll('.emby-tab-button');
allButtons.forEach(button => {
if (button.dataset.kefin !== 'true') {
button.addEventListener('click', handleTabClick);
button.dataset.kefin = 'true';
}
});
}
});
// Start observing the tabs slider for child additions
observer.observe(tabsSlider, {
childList: true,
subtree: true
});
// Set a timeout to disconnect observer after a reasonable time (e.g., 10 seconds)
setTimeout(() => {
observer.disconnect();
LOG('MutationObserver timeout reached, stopped waiting for custom tabs');
}, 10000);
}
LOG('Synced active tab state after custom tabs were rendered');
}
}
}
LOG('Added click listeners to', totalButtons, 'buttons');
}
}
const MAX_INIT_ATTEMPTS = 10;
let INIT_ATTEMPTS = 0;
function initialize() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
WARN('KefinTweaksUtils not available, retrying in 1 second');
if (INIT_ATTEMPTS < MAX_INIT_ATTEMPTS) {
setTimeout(initialize, 1000);
INIT_ATTEMPTS++;
} else {
ERR('KefinTweaksUtils not available after 10 seconds, giving up');
}
return;
}
// Register onViewPage handler to sync active tab state
window.KefinTweaksUtils.onViewPage((view, element) => {
LOG('onViewPage handler triggered for view:', view);
addClickListeners();
// Sync active tab state when page view changes
syncActiveTabState().catch(err => {
ERR('Error in syncActiveTabState:', err);
});
}, {
pages: SUPPORTED_PAGES // Only trigger for supported pages
});
LOG('Registered onViewPage handler for syncActiveTabState');
LOG('Header tabs functionality initialized');
}
initialize();
})();
+127
View File
@@ -0,0 +1,127 @@
/* 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;
}
*/
.sections.homeSectionsContainer {
display: flex;
flex-direction: column;
}
.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;
}
/* Halloween theme */
.home-screen.halloween-theme #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%;
}
.home-screen h2.sectionTitle {
margin-bottom: .35em;
margin-top: 0;
}
.sectionTitleContainer.sectionTitleContainer-cards.padded-left > .sectionTitle.sectionTitle-cards {
display: flex !important;
}
div#discovery-loading-indicator:not(.show)::before {
content: 'Loading...';
position: absolute;
top: 50%;
left: 0;
right: 0;
bottom: 0;
transform: translateY(-50%);
visibility: visible;
text-align: center;
opacity: 1;
}
#indexPage[data-discovery-ready] div#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;
}
.libraryPage:has(.load-more-discovery-btn) div#discovery-loading-indicator {
opacity: 0;
}
#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: '';
}
/* Ensure Editor's Choice Banner appears at top of home screen if enabled */
.editorsChoiceContainer {
order: 1 !important;
}
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
// KefinTweaks IndexedDB Cache Manager
// Provides TTL caching with large storage capacity using IndexedDB
// Usage: For caches that exceed localStorage limits
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks IndexedDBCache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks IndexedDBCache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks IndexedDBCache]', ...args);
const DB_NAME = 'KefinTweaksCache';
const DB_VERSION = 1;
const STORE_NAME = 'cache';
// Generic IndexedDB cache manager
class IndexedDBCache {
constructor(prefix = 'kefinTweaks_', ttl = 24 * 60 * 60 * 1000) {
this.prefix = prefix;
this.ttl = ttl;
this.db = null;
}
// Open database connection
async openDB() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
ERR('Failed to open IndexedDB:', request.error);
reject(request.error);
};
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
}
};
});
}
// Generate cache key with user context
getCacheKey(cacheName, userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
return `${this.prefix}${cacheName}_${user}`;
}
// Check if cache is valid (not expired)
async isCacheValid(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(key);
return new Promise((resolve) => {
request.onsuccess = () => {
const data = request.result;
if (!data) {
resolve(false);
return;
}
const now = Date.now();
const ttl = data.ttl || this.ttl;
resolve((now - data.timestamp) < ttl);
};
request.onerror = () => {
WARN('Error checking cache validity:', request.error);
resolve(false);
};
});
} catch (error) {
ERR('Error in isCacheValid:', error);
return false;
}
}
// Get cached data if valid
async get(cacheName, userId = null) {
if (!(await this.isCacheValid(cacheName, userId))) {
return null;
}
const key = this.getCacheKey(cacheName, userId);
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(key);
return new Promise((resolve) => {
request.onsuccess = () => {
const data = request.result;
resolve(data ? data.payload : null);
};
request.onerror = () => {
WARN('Error getting cache:', request.error);
resolve(null);
};
});
} catch (error) {
ERR('Error in get:', error);
return null;
}
}
// Store data with timestamp
async set(cacheName, data, userId = null, customTtl = null) {
const key = this.getCacheKey(cacheName, userId);
const ttl = customTtl || this.ttl;
const payload = {
key: key,
timestamp: Date.now(),
ttl: ttl,
payload: data
};
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.put(payload);
return new Promise((resolve) => {
request.onsuccess = () => {
LOG(`Cached data for ${cacheName}: ${Array.isArray(data) ? data.length : 'object'} items (TTL: ${ttl / (1000 * 60 * 60)}h)`);
resolve(true);
};
request.onerror = () => {
WARN('Failed to store data:', request.error);
resolve(false);
};
});
} catch (error) {
ERR('Error in set:', error);
return false;
}
}
// Clear specific cache
async clear(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(key);
return new Promise((resolve) => {
request.onsuccess = () => {
LOG(`Cleared cache for ${cacheName}`);
resolve(true);
};
request.onerror = () => {
WARN('Error clearing cache:', request.error);
resolve(false);
};
});
} catch (error) {
ERR('Error in clear:', error);
return false;
}
}
// Clear all caches for current user
async clearAll(userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
const userSuffix = `_${user}`;
try {
const db = await this.openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.openCursor();
return new Promise((resolve) => {
let count = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const key = cursor.value.key;
if (key.startsWith(this.prefix) && key.endsWith(userSuffix)) {
cursor.delete();
count++;
}
cursor.continue();
} else {
LOG(`Cleared all caches for user ${user}: ${count} entries`);
resolve(count);
}
};
request.onerror = () => {
WARN('Error clearing all caches:', request.error);
resolve(0);
};
});
} catch (error) {
ERR('Error in clearAll:', error);
return false;
}
}
}
// Create global instance
window.IndexedDBCache = IndexedDBCache;
LOG('IndexedDBCache manager loaded');
})();
+842
View File
@@ -0,0 +1,842 @@
// KefinTweaks Infinite Scroll
// Adds infinite scrolling to media library pages (Movies, TV, Music)
// Requires: cardBuilder.js module to be loaded before this script
(function () {
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY = 1000;
// Common logging functions
const LOG = (...args) => console.log('[KefinTweaks InfiniteScroll]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks InfiniteScroll]', ...args);
const ERR = (...args) => console.error('[KefinTweaks InfiniteScroll]', ...args);
LOG('Initializing...');
// Supported pages (same as headerTabs)
const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html', 'tv', 'movies', 'music'];
// Media type configurations - Read from centralized config or use defaults
const MEDIA_CONFIGS = {
'movies.html': {
includeItemTypes: 'Movie',
context: 'movies',
sortBy: 'SortName,ProductionYear'
},
'tv.html': {
includeItemTypes: 'Series',
context: 'tvshows',
sortBy: 'SortName,ProductionYear'
},
'music.html': {
includeItemTypes: 'Music',
context: 'music',
sortBy: 'SortName,ProductionYear'
},
'movies': {
includeItemTypes: 'Movie',
context: 'movies',
sortBy: 'SortName,ProductionYear'
},
'tv': {
includeItemTypes: 'Series',
context: 'tvshows',
sortBy: 'SortName,ProductionYear'
},
'music': {
includeItemTypes: 'Music',
context: 'music',
sortBy: 'SortName,ProductionYear'
}
};
let userId = null;
const limit = 100;
let currentPage = null;
let currentTab = 0;
let startIndex = 0;
let loading = false;
let hasMore = true;
let totalRecordCount = null; // Track total items available from API
let retryCount = 0;
// scrollState removed - not needed
let scrollTimeout = null;
let isScrollTriggered = false; // Prevent multiple triggers
let isRequestInProgress = false; // Blocking flag to prevent multiple simultaneous requests
let hasInitialized = false; // Prevent multiple initializations
let isInitializing = false; // Prevent concurrent initializations
// Create loading indicator element
function createLoadingIndicator() {
const loadingDiv = document.createElement('div');
loadingDiv.className = 'infinite-scroll-loading';
loadingDiv.id = 'infinite-scroll-loading-indicator';
loadingDiv.innerHTML = `
<div class="spinner"></div>
<span>Loading more content...</span>
`;
return loadingDiv;
}
// Show loading indicator
function showLoadingIndicator() {
let loadingIndicator = document.getElementById('infinite-scroll-loading-indicator');
if (!loadingIndicator) {
loadingIndicator = createLoadingIndicator();
// Find the container and append the loading indicator as a sibling
const container = getContainer();
if (container && container.parentNode) {
container.parentNode.insertBefore(loadingIndicator, container.nextSibling);
} else {
// Fallback: append to body
document.body.appendChild(loadingIndicator);
}
}
// Show with animation
setTimeout(() => {
loadingIndicator.classList.add('show');
}, 10);
LOG('Loading indicator shown');
}
// Hide loading indicator
function hideLoadingIndicator() {
const loadingIndicator = document.getElementById('infinite-scroll-loading-indicator');
if (loadingIndicator) {
loadingIndicator.classList.remove('show');
// Remove from DOM after animation
setTimeout(() => {
if (loadingIndicator.parentNode) {
loadingIndicator.parentNode.removeChild(loadingIndicator);
}
}, 300);
LOG('Loading indicator hidden');
}
}
// Add CSS to hide paging controls when infinite scroll is active
function addInfiniteScrollCSS() {
// Check if infinite scroll is enabled in configuration
const isEnabled = window.KefinTweaksConfig?.scripts?.infiniteScroll !== false;
if (!isEnabled) {
LOG('Infinite scroll disabled in configuration, skipping CSS addition');
return;
}
const styleId = 'infinite-scroll-paging-hide';
// Check if style already exists
if (document.getElementById(styleId)) {
return;
}
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 {
display: flex;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
font-size: 14px;
color: #666;
opacity: 0;
transition: opacity 0.3s ease;
}
.infinite-scroll-loading.show {
opacity: 1;
}
.infinite-scroll-loading .spinner {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: infinite-scroll-spin 1s linear infinite;
margin-right: 10px;
}
@keyframes infinite-scroll-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
LOG('Added CSS to hide paging controls for infinite scroll');
}
// Wait for ApiClient to be available
function waitForApiClient() {
return new Promise((resolve) => {
if (typeof ApiClient !== 'undefined' && ApiClient._serverInfo && ApiClient._serverInfo.UserId) {
userId = ApiClient._serverInfo.UserId;
LOG('ApiClient ready, userId:', userId);
resolve();
} else {
LOG('Waiting for ApiClient...');
setTimeout(() => waitForApiClient().then(resolve), 100);
}
});
}
// Wait for cardBuilder to be available
function waitForCardBuilder() {
return new Promise((resolve) => {
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) {
LOG('cardBuilder ready');
resolve();
} else {
LOG('Waiting for cardBuilder...');
setTimeout(() => waitForCardBuilder().then(resolve), 100);
}
});
}
// Page detection function (same as headerTabs)
function getCurrentPage() {
const hash = window.location.hash;
const hashMatches = hash.match(/#\/([^?]+)/g);
if (!hashMatches || hashMatches.length === 0) {
LOG('No hash matches found for:', hash);
return null;
}
for (const match of hashMatches) {
const pageFromHash = match.replace('#/', '');
if (SUPPORTED_PAGES.includes(pageFromHash)) {
LOG('Found supported page:', pageFromHash);
return pageFromHash;
}
}
return null;
}
// Get current tab index from URL
function getCurrentTab() {
const hash = window.location.hash;
let searchParams = '';
if (hash.includes('?')) {
searchParams = hash.split('?')[1];
} else {
searchParams = window.location.search.substring(1);
}
const urlParams = new URLSearchParams(searchParams);
const tabParam = urlParams.get('tab');
return tabParam ? parseInt(tabParam, 10) : 0;
}
// State persistence functions - REMOVED
// We don't need to store scroll state as it can cause issues with infinite scroll behavior
function getParentId() {
const match = window.location.hash.match(/topParentId=([^&]+)/);
return match ? match[1] : null;
}
// Get current sort settings from Jellyfin's localStorage keys
function getCurrentSortSettings() {
const userId = ApiClient.getCurrentUserId();
const parentId = getParentId();
const page = getCurrentPage();
if (!userId || !parentId || !page) {
LOG('Missing required IDs for sort detection:', { userId, parentId, page });
return null;
}
// Map page to media type
const mediaTypeMap = {
'movies.html': 'movies',
'tv.html': 'series'
};
const mediaType = mediaTypeMap[page];
if (!mediaType) {
LOG('Unsupported page for sort detection:', page);
return null;
}
// Build localStorage keys
const storageKey = `${userId}-${parentId}-${mediaType}`;
const filterKey = `${userId}-${parentId}-${mediaType}-filter`;
LOG('Looking for sort settings in localStorage keys:', { storageKey, filterKey });
try {
// Get main sort settings
const sortData = JSON.parse(localStorage.getItem(storageKey) || '{}');
const filterData = JSON.parse(localStorage.getItem(filterKey) || '{}');
LOG('Found sort data:', sortData);
LOG('Found filter data:', filterData);
return {
sortBy: sortData.SortBy || 'SortName,ProductionYear',
sortOrder: sortData.SortOrder || 'Ascending',
filters: filterData.Filters || '',
years: filterData.Years || '',
genres: filterData.Genres || '',
tags: filterData.Tags || '',
// Add other filter properties as needed
};
} catch (error) {
ERR('Error parsing sort settings from localStorage:', error);
return null;
}
}
// Get current alpha picker selection
function getCurrentAlphaPicker() {
const page = getCurrentPage();
if (!page) return null;
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 null;
const alphaPickerRow = activeTab.querySelector(".alphaPickerRow");
if (!alphaPickerRow) return null;
const selectedButton = alphaPickerRow.querySelector(".alphaPickerButton-selected");
if (!selectedButton) return null;
const dataValue = selectedButton.getAttribute('data-value');
LOG('Found alpha picker selection:', dataValue);
return dataValue;
}
// Get current start index based on existing items count
function getCurrentStartIndex() {
const container = getContainer();
if (!container) return 0;
const existingItemsCount = container.children.length;
LOG('Current start index based on existing items:', existingItemsCount);
return existingItemsCount;
}
// 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");
if (!listPagings.length) return;
listPagings.forEach(listPaging => {
const span = listPaging.querySelector("span");
if (!span) return;
const container = getContainer();
if (!container) return;
const currentItemsCount = container.children.length;
const totalCount = totalRecordCount || '?';
// Update the display to show current range
span.textContent = `1-${currentItemsCount} of ${totalCount}`;
LOG('Updated pagination display:', { currentItemsCount, totalCount });
});
}
function getContainer() {
const libraryPage = document.querySelector(".libraryPage:not(.hide)");
if (!libraryPage) return null;
// Check if we're on the correct active tab
const page = getCurrentPage();
if (!page) return null;
let activeTab = null;
if (page === 'tv.html' || page === 'tv') {
activeTab = document.querySelector("#seriesTab.is-active");
} else if (page === 'movies.html' || page === 'movies') {
activeTab = document.querySelector("#moviesTab.is-active");
}
if (!activeTab) {
return null;
}
const container = activeTab.querySelector(".itemsContainer");
if (!container) {
return null;
}
return container;
}
async function loadMore() {
LOG('LoadMore called:', { loading, hasMore, isRequestInProgress, isScrollTriggered });
if (loading || !hasMore || isRequestInProgress) {
LOG('LoadMore blocked:', { loading, hasMore, isRequestInProgress });
return;
}
// Don't load more if there are no items in the container (page still loading)
const container = getContainer();
if (container && container.children.length === 0) {
LOG('LoadMore blocked: No items in container yet (page still loading)');
return;
}
LOG('Starting loadMore request...');
loading = true;
isRequestInProgress = true;
// Show loading indicator
showLoadingIndicator();
// Wait for ApiClient to be ready
if (!userId) {
LOG('Waiting for ApiClient before loading...');
await waitForApiClient();
}
// Wait for cardBuilder to be ready
await waitForCardBuilder();
const page = getCurrentPage();
const tab = getCurrentTab();
if (!page) {
WARN("No supported page detected");
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
const parentId = getParentId();
if (!parentId) {
WARN("No topParentId found in URL");
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
const mediaConfig = MEDIA_CONFIGS[page];
if (!mediaConfig) {
WARN("No media configuration found for page:", page);
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
// Double-check ApiClient is available
if (typeof ApiClient === 'undefined') {
ERR('ApiClient is not available');
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
// Get current start index based on existing items
startIndex = getCurrentStartIndex();
// Get current sort settings from localStorage
const sortSettings = getCurrentSortSettings();
let apiParams = {
SortBy: mediaConfig.sortBy, // fallback
SortOrder: "Ascending", // fallback
IncludeItemTypes: mediaConfig.includeItemTypes,
Recursive: true,
Fields: "PrimaryImageAspectRatio,MediaSourceCount",
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
ParentId: parentId,
StartIndex: startIndex,
Limit: limit
};
// Apply detected sort settings if available
if (sortSettings) {
LOG('Using detected sort settings:', sortSettings);
apiParams.SortBy = sortSettings.sortBy;
apiParams.SortOrder = sortSettings.sortOrder;
// Add filter parameters if they exist
if (sortSettings.filters) {
apiParams.Filters = sortSettings.filters;
}
if (sortSettings.years) {
apiParams.Years = sortSettings.years;
}
if (sortSettings.genres) {
apiParams.Genres = sortSettings.genres;
}
if (sortSettings.tags) {
apiParams.Tags = sortSettings.tags;
}
} else {
LOG('Using fallback sort settings');
}
// Add alpha picker support
const alphaPicker = getCurrentAlphaPicker();
if (alphaPicker) {
apiParams.NameStartsWith = alphaPicker;
LOG('Added alpha picker filter:', alphaPicker);
}
const url = ApiClient.getUrl("Users/" + userId + "/Items", apiParams);
LOG('Loading more items:', { page, tab, startIndex, limit, url });
try {
const result = await ApiClient.getJSON(url);
// Update total record count from API response
if (result.TotalRecordCount !== undefined) {
totalRecordCount = result.TotalRecordCount;
LOG('Updated total record count from API:', totalRecordCount);
}
if (result.Items && result.Items.length > 0) {
const container = getContainer();
if (container) {
// Check if cardBuilder is available
if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.buildCard) {
ERR('cardBuilder not available or buildCard method missing');
loading = false;
isRequestInProgress = false;
hideLoadingIndicator();
return;
}
LOG('Building cards:', {
itemCount: result.Items.length,
context: mediaConfig.context,
container: container
});
// Build each card individually and append to container
result.Items.forEach((item, index) => {
const card = window.cardBuilder.buildCard(item);
card.setAttribute('data-index', startIndex + index);
container.appendChild(card);
});
LOG('Cards built successfully');
}
startIndex += result.Items.length;
// Update pagination display
updatePaginationDisplay();
// Update hasMore based on total record count
if (totalRecordCount !== null) {
hasMore = startIndex < totalRecordCount;
LOG('hasMore updated based on total count:', {
startIndex,
totalRecordCount,
hasMore
});
} else {
// Fallback to old logic if no total count available
hasMore = result.Items.length >= limit;
LOG('hasMore updated using fallback logic:', {
itemsLength: result.Items.length,
limit,
hasMore
});
}
// Scroll state persistence removed - not needed
LOG('Loaded items successfully:', {
loaded: result.Items.length,
startIndex,
totalRecordCount,
hasMore
});
} else {
hasMore = false;
LOG('No more items to load (empty result)');
}
// Reset retry count on success
retryCount = 0;
} catch (err) {
ERR("Infinite scroll load error", err);
// Retry logic
if (retryCount < RETRY_ATTEMPTS) {
retryCount++;
WARN(`Retrying loadMore (attempt ${retryCount}/${RETRY_ATTEMPTS})`);
setTimeout(() => {
loading = false;
isRequestInProgress = false;
loadMore();
}, RETRY_DELAY * retryCount);
return;
} else {
ERR("Max retry attempts reached, giving up");
hasMore = false;
}
} finally {
loading = false;
isRequestInProgress = false;
// Hide loading indicator
hideLoadingIndicator();
}
}
function setupScrollWatcher() {
// Remove any existing scroll listener
window.removeEventListener("scroll", handleScroll);
// Add throttled scroll listener
window.addEventListener("scroll", handleScroll);
LOG('Scroll watcher setup complete');
}
function handleScroll() {
// Throttle scroll events - only check every 100ms
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(() => {
checkScrollPosition();
}, 100);
}
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;
}
const container = getContainer();
if (!container) {
LOG('No container found in scroll check');
return;
}
// Don't trigger if there are no items in the container (page still loading)
const existingItemsCount = container.children.length;
if (existingItemsCount === 0) {
LOG('Scroll check blocked: No items in container yet (page still loading)');
return;
}
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Calculate distance from bottom - trigger when one viewport height away
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)');
loadMore().finally(() => {
// Reset trigger flag after load completes
setTimeout(() => {
isScrollTriggered = false;
LOG('Scroll trigger flag reset');
}, 1000); // Wait 1 second before allowing next trigger
});
}
}
// Reset state when navigating to a new library or tab
function reset() {
const page = getCurrentPage();
const tab = getCurrentTab();
LOG('Resetting infinite scroll state:', { page, tab });
// startIndex will be calculated dynamically based on existing items
hasMore = true;
totalRecordCount = null;
loading = false;
retryCount = 0;
isScrollTriggered = false;
isRequestInProgress = false;
// Scroll state restoration removed - not needed
// Clear container if switching pages/tabs
if (currentPage !== page || currentTab !== tab) {
const container = getContainer();
if (container) {
//container.innerHTML = '';
LOG('[REMOVED] Cleared container for new page/tab');
}
hasInitialized = false; // Reset initialization flag for new page/tab
isInitializing = false; // Reset initializing flag for new page/tab
}
currentPage = page;
currentTab = tab;
}
// Check if we should initialize infinite scroll
function shouldInitialize() {
const page = getCurrentPage();
const container = getContainer();
if (!page) {
return false;
}
if (!container) {
LOG('No active tab container found, waiting...');
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 (!activeTab) {
LOG('No active tab found for page, skipping initialization:', page);
return false;
}
return true;
}
// Initialize infinite scroll
async function initialize() {
if (!shouldInitialize() || hasInitialized || isInitializing) {
LOG('Skipping initialization:', { shouldInit: shouldInitialize(), hasInitialized, isInitializing });
return;
}
LOG('Initializing infinite scroll');
isInitializing = true;
hasInitialized = true;
try {
// Add CSS to hide paging controls
addInfiniteScrollCSS();
// Wait for ApiClient to be ready
if (!userId) {
LOG('Waiting for ApiClient before initialization...');
await waitForApiClient();
}
// Wait for cardBuilder to be ready
await waitForCardBuilder();
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(() => {
if (shouldInitialize()) {
initialize().catch(err => ERR('Initialization failed:', err));
}
});
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);
}
// Cleanup function
function cleanup() {
window.removeEventListener("scroll", handleScroll);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// Remove the CSS that hides paging controls
const styleElement = document.getElementById('infinite-scroll-paging-hide');
if (styleElement) {
styleElement.remove();
LOG('Removed CSS for paging controls');
}
// Hide and remove loading indicator
hideLoadingIndicator();
LOG('Infinite scroll cleanup complete');
}
// Cleanup on page unload
window.addEventListener('beforeunload', cleanup);
LOG('Initialized successfully');
})();
+253
View File
@@ -0,0 +1,253 @@
// 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';
const LOG = (...args) => console.log('[KefinTweaks ItemDetailsCollections]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks ItemDetailsCollections]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ItemDetailsCollections]', ...args);
const CACHE_NAME = 'collections';
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
const POLL_INTERVAL = 1000; // 1 second
// Populate collections cache
async function populateCollectionsCache() {
// Check if user is logged in and poll for 5 seconds if not
if (ApiClient._loggedIn === false) {
return;
}
if (!window.IndexedDBCache) {
WARN('IndexedDBCache not available');
return;
}
const cache = new window.IndexedDBCache('kefinTweaks_', CACHE_TTL);
// Check if cache is valid
if (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
});
const cacheData = [];
for (const coll of allCollections.Items) {
const items = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
ParentId: coll.Id,
});
// Store collection item data needed for renderCards
const collectionItemData = {
Id: coll.Id,
Type: coll.Type,
Name: coll.Name,
ParentIndexNumber: coll.ParentIndexNumber,
IndexNumber: coll.IndexNumber,
ImageTags: coll.ImageTags,
BackdropImageTags: coll.BackdropImageTags,
ProductionYear: coll.ProductionYear,
PremiereDate: coll.PremiereDate
};
// Store item IDs in this collection for lookup
const itemIds = items.Items.map(item => item.Id);
if (itemIds.length > 0) {
cacheData.push({
CollectionItem: collectionItemData,
ItemIds: itemIds
});
}
}
await cache.set(CACHE_NAME, cacheData);
LOG(`Collections cache populated: ${cacheData.length} collections`);
} catch (error) {
ERR('Error populating collections cache:', error);
}
}
// Get parent collections for an item ID (returns collection items that can be passed to renderCards)
async function getParentCollections(itemId) {
if (!window.IndexedDBCache) {
WARN('IndexedDBCache not available');
return [];
}
const cache = new window.IndexedDBCache('kefinTweaks_', CACHE_TTL);
const cacheData = await cache.get(CACHE_NAME);
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;
}
const activePage = document.querySelector('.libraryPage:not(.hide)');
if (!activePage) {
return;
}
if (activePage.dataset.collectionsChecked === 'true') {
return;
}
activePage.dataset.collectionsChecked = 'true';
// Check if section already rendered
const existingSection = activePage.querySelector('.collections-section');
if (existingSection) {
return;
}
// Get parent collections (returns collection item objects)
const parentCollectionItems = await getParentCollections(item.Id);
if (!parentCollectionItems || parentCollectionItems.length === 0) {
return;
}
// Check for cardBuilder
if (!window.cardBuilder || !window.cardBuilder.renderCards) {
WARN('cardBuilder.renderCards not available');
return;
}
// Find detailPageContent
const detailPageContent = activePage.querySelector('.detailPageContent');
if (!detailPageContent) {
WARN('detailPageContent not found');
return;
}
try {
// Render collections section using renderCards with cached item data
const collectionsSection = window.cardBuilder.renderCards(
parentCollectionItems,
'Included In',
null,
true,
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');
}
const sectionTitleContainer = collectionsSection.querySelector('.sectionTitleContainer');
if (sectionTitleContainer) {
sectionTitleContainer.classList.add('no-padding');
}
// Add to detailPageContent
const similarSection = detailPageContent.querySelector('#similarCollapsible');
if (similarSection) {
similarSection.before(collectionsSection);
} else {
detailPageContent.appendChild(collectionsSection);
}
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');
setTimeout(initializeCollectionsHook, 1000);
return;
}
LOG('Registering collections handler with KefinTweaksUtils');
window.KefinTweaksUtils.onViewPage(
async (view, element, hash, itemPromise) => {
if (ApiClient._loggedIn === false) {
return;
}
await populateCollectionsCache();
// 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;
// Small delay to ensure details DOM is ready
setTimeout(async () => {
if (item && item.Id) {
await renderCollectionsSection(item);
}
}, 100);
},
{
pages: ['details']
}
);
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');
}
return;
}
// Initialize collections hook
initializeCollectionsHook();
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
LOG('Script loaded successfully');
})();
+269
View File
@@ -0,0 +1,269 @@
// KefinTweaks localStorage Cache Manager
// Provides 24-hour TTL caching with manual refresh functionality
// Usage: Integrates with existing cache system to add persistence
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks LocalStorageCache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks LocalStorageCache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks LocalStorageCache]', ...args);
// Generic localStorage cache manager with chunked storage support
class LocalStorageCache {
constructor(prefix = 'kefinTweaks_', ttl = 24 * 60 * 60 * 1000, chunkSize = 10) { // 24 hours, 10 items per chunk
this.prefix = prefix;
this.ttl = ttl;
this.chunkSize = chunkSize;
}
// Generate cache key with user context
getCacheKey(cacheName, userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
return `${this.prefix}${cacheName}_${user}`;
}
// Check if cache is valid (not expired)
isCacheValid(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
const cached = localStorage.getItem(key);
if (!cached) return false;
try {
const data = JSON.parse(cached);
const now = Date.now();
const ttl = data.ttl || this.ttl; // Use stored TTL or default
return (now - data.timestamp) < ttl;
} catch {
return false;
}
}
// Get cached data if valid
get(cacheName, userId = null) {
if (!this.isCacheValid(cacheName, userId)) {
return null;
}
const key = this.getCacheKey(cacheName, userId);
try {
const cached = localStorage.getItem(key);
const data = JSON.parse(cached);
return data.payload;
} catch {
return null;
}
}
// Store data with timestamp
set(cacheName, data, userId = null, customTtl = null) {
const key = this.getCacheKey(cacheName, userId);
const ttl = customTtl || this.ttl;
const payload = {
timestamp: Date.now(),
ttl: ttl,
payload: data
};
try {
localStorage.setItem(key, JSON.stringify(payload));
LOG(`Cached data for ${cacheName}: ${Array.isArray(data) ? data.length : 'object'} items (TTL: ${ttl / (1000 * 60 * 60)}h)`);
return true;
} catch (error) {
WARN('Failed to store data:', error);
return false;
}
}
// Clear specific cache
clear(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
localStorage.removeItem(key);
LOG(`Cleared cache for ${cacheName}`);
}
// Clear all caches for current user
clearAll(userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(this.prefix) && key.endsWith(`_${user}`)
);
keys.forEach(key => localStorage.removeItem(key));
LOG(`Cleared all caches for user ${user}: ${keys.length} entries`);
}
// Get cache age in hours
getCacheAge(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
const cached = localStorage.getItem(key);
if (!cached) return null;
try {
const data = JSON.parse(cached);
return (Date.now() - data.timestamp) / (1000 * 60 * 60); // hours
} catch {
return null;
}
}
// Get cache size in bytes (approximate)
getCacheSize(cacheName, userId = null) {
const key = this.getCacheKey(cacheName, userId);
const cached = localStorage.getItem(key);
return cached ? cached.length : 0;
}
// Get all cache names for current user
getAllCacheNames(userId = null) {
const user = userId || window.ApiClient?.getCurrentUserId() || 'anonymous';
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(this.prefix) && key.endsWith(`_${user}`)
);
return keys.map(key => key.replace(this.prefix, '').replace(`_${user}`, ''));
}
// Chunked storage methods for large datasets
setChunked(cacheName, data, userId = null) {
if (!Array.isArray(data)) {
WARN('Chunked storage requires array data');
return false;
}
if (data.length === 0) {
LOG(`No data to chunk for ${cacheName}`);
return true;
}
try {
const chunks = this.chunkArray(data, this.chunkSize);
const chunkCount = chunks.length;
LOG(`Storing ${data.length} items in ${chunkCount} chunks for ${cacheName}`);
// Store each chunk
for (let i = 0; i < chunks.length; i++) {
const chunkKey = `${cacheName}_chunk_${i}`;
const chunkData = {
chunkIndex: i,
totalChunks: chunkCount,
data: chunks[i],
timestamp: Date.now()
};
if (!this.set(chunkKey, chunkData, userId)) {
WARN(`Failed to store chunk ${i} for ${cacheName}`);
return false;
}
}
// Store metadata
const metadata = {
totalChunks: chunkCount,
totalItems: data.length,
timestamp: Date.now(),
chunkSize: this.chunkSize
};
if (!this.set(`${cacheName}_meta`, metadata, userId)) {
WARN(`Failed to store metadata for ${cacheName}`);
return false;
}
LOG(`Successfully stored ${data.length} items in ${chunkCount} chunks for ${cacheName}`);
return true;
} catch (error) {
ERR(`Error storing chunked data for ${cacheName}:`, error);
return false;
}
}
// Load chunked data
getChunked(cacheName, userId = null) {
try {
const meta = this.get(`${cacheName}_meta`, userId);
if (!meta) {
LOG(`No metadata found for chunked cache ${cacheName}`);
return null;
}
if (!this.isCacheValid(`${cacheName}_meta`, userId)) {
LOG(`Chunked cache ${cacheName} has expired`);
return null;
}
const allData = [];
let missingChunks = 0;
for (let i = 0; i < meta.totalChunks; i++) {
const chunk = this.get(`${cacheName}_chunk_${i}`, userId);
if (chunk && chunk.data) {
allData.push(...chunk.data);
} else {
missingChunks++;
WARN(`Missing chunk ${i} for ${cacheName}`);
}
}
if (missingChunks > 0) {
WARN(`Missing ${missingChunks} chunks for ${cacheName}, returning null`);
return null;
}
if (allData.length !== meta.totalItems) {
WARN(`Data length mismatch for ${cacheName}: expected ${meta.totalItems}, got ${allData.length}`);
return null;
}
LOG(`Successfully loaded ${allData.length} items from ${meta.totalChunks} chunks for ${cacheName}`);
return allData;
} catch (error) {
ERR(`Error loading chunked data for ${cacheName}:`, error);
return null;
}
}
// Clear chunked cache
clearChunked(cacheName, userId = null) {
try {
const meta = this.get(`${cacheName}_meta`, userId);
if (meta) {
// Clear all chunks
for (let i = 0; i < meta.totalChunks; i++) {
this.clear(`${cacheName}_chunk_${i}`, userId);
}
// Clear metadata
this.clear(`${cacheName}_meta`, userId);
LOG(`Cleared chunked cache ${cacheName} (${meta.totalChunks} chunks)`);
} else {
LOG(`No chunked cache found for ${cacheName}`);
}
} catch (error) {
ERR(`Error clearing chunked cache ${cacheName}:`, error);
}
}
// Utility method to chunk arrays
chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
// Check if a cache is chunked
isChunked(cacheName, userId = null) {
const meta = this.get(`${cacheName}_meta`, userId);
return meta && meta.totalChunks > 1;
}
}
// Create global instance
window.LocalStorageCache = LocalStorageCache;
LOG('LocalStorageCache manager loaded');
})();
+131
View File
@@ -0,0 +1,131 @@
.mdl-radio {
box-sizing: border-box;
display: inline-block;
line-height: 24px;
margin: 0;
position: relative
}
.radio-label-block {
-webkit-align-items: center;
align-items: center;
display: -webkit-flex;
display: flex;
margin-bottom: .5em;
margin-top: .5em
}
.mdl-radio__button {
-webkit-appearance: none;
appearance: none;
border: none;
height: 1px;
line-height: 24px;
margin: 0;
opacity: 0;
padding: 0;
position: absolute;
width: 1px
}
.mdl-radio__circles {
border-radius: 50%;
cursor: pointer;
height: 1.08em;
position: relative;
width: 1.08em
}
[dir=ltr] .mdl-radio__circles {
margin-right: .54em
}
[dir=rtl] .mdl-radio__circles {
margin-left: .54em
}
.mdl-radio__circles svg {
height: 100%;
overflow: visible;
position: absolute;
top: 0;
width: 100%;
z-index: 1
}
[dir=ltr] .mdl-radio__circles svg {
left: 0
}
[dir=rtl] .mdl-radio__circles svg {
right: 0
}
.mdl-radio__button:disabled+.mdl-radio__circles {
cursor: auto
}
.mdl-radio__button:disabled+.mdl-radio__circles .mdl-radio__outer-circle {
color: rgba(0,0,0,.26)
}
.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__outer-circle {
color: #00a4dc
}
.mdl-radio__inner-circle {
-webkit-transform: scale(0);
transform: scale(0);
-webkit-transform-origin: 50% 50%;
transform-origin: 50% 50%;
transition-duration: .2s;
transition-property: -webkit-transform;
transition-property: transform;
transition-property: transform,-webkit-transform
}
.mdl-radio__button:checked+.mdl-radio__circles .mdl-radio__inner-circle {
-webkit-transform: scale(1);
transform: scale(1)
}
.mdl-radio__button:disabled+.mdl-radio__circles .mdl-radio__inner-circle {
color: rgba(0,0,0,.26)
}
.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__inner-circle {
color: #00a4dc
}
.mdl-radio__focus-circle {
background: #00a4dc;
border-radius: 50%;
box-sizing: border-box;
height: 100%;
left: 0;
margin: 0;
opacity: .26;
position: absolute;
top: 0;
-webkit-transform: scale(0);
transform: scale(0);
transition-duration: .2s;
transition-property: -webkit-transform;
transition-property: transform;
transition-property: transform,-webkit-transform;
width: 100%
}
.mdl-radio.show-focus .mdl-radio__button:focus+.mdl-radio__circles .mdl-radio__focus-circle {
-webkit-transform: scale(1.75);
transform: scale(1.75)
}
.mdl-radio__label {
cursor: pointer
}
.mdl-radio__button:disabled+.mdl-radio__label {
color: rgba(0,0,0,.26);
cursor: auto
}
+226
View File
@@ -0,0 +1,226 @@
/**
* Generic Modal System for Jellyfin-style dialogs
* Provides reusable modal functionality similar to cardBuilder.js
*/
window.ModalSystem = (function() {
'use strict';
// Store active modals
const activeModals = new Map();
/**
* 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} options.content - HTML content for the modal body
* @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)
* @returns {Object} Modal instance
*/
function createModal(options = {}) {
const {
id,
title,
content,
onClose,
onOpen,
closeOnBackdrop = true,
closeOnEscape = true
} = options;
if (!id) {
throw new Error('Modal ID is required');
}
// Remove existing modal if it exists
removeModal(id);
// Create modal elements
const backdrop = document.createElement('div');
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
backdrop.setAttribute('data-modal-id', id);
const dialogContainer = document.createElement('div');
dialogContainer.className = 'dialogContainer';
dialogContainer.setAttribute('data-modal-id', id);
const dialog = document.createElement('div');
dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened';
dialog.setAttribute('data-history', 'true');
dialog.setAttribute('data-autofocus', 'true');
dialog.setAttribute('data-removeonclose', 'true');
dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
const dialogContent = document.createElement('div');
dialogContent.style.margin = '0';
dialogContent.style.padding = '1.25em 1.5em 1.5em';
// Add title if provided
if (title) {
const titleElement = document.createElement('h2');
titleElement.style.margin = '0 0 .5em';
titleElement.textContent = title;
dialogContent.appendChild(titleElement);
}
// Add content
if (content) {
if (typeof content === 'string') {
dialogContent.innerHTML += content;
} else if (content instanceof HTMLElement) {
dialogContent.appendChild(content);
}
}
// Assemble modal
dialog.appendChild(dialogContent);
dialogContainer.appendChild(dialog);
// Add to DOM
document.body.appendChild(backdrop);
document.body.appendChild(dialogContainer);
// Create modal instance
const modalInstance = {
id,
backdrop,
dialogContainer,
dialog,
dialogContent,
isOpen: true,
close: () => closeModal(id),
updateContent: (newContent) => updateModalContent(id, newContent),
addEventListener: (event, handler) => {
dialog.addEventListener(event, handler);
}
};
// Store modal instance
activeModals.set(id, modalInstance);
// Add event listeners
if (closeOnBackdrop) {
backdrop.addEventListener('click', () => closeModal(id));
dialogContainer.addEventListener('click', (e) => {
if (e.target === dialogContainer) {
closeModal(id);
}
});
}
if (closeOnEscape) {
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal(id);
}
};
document.addEventListener('keydown', escapeHandler);
modalInstance._escapeHandler = escapeHandler;
}
// Call onOpen callback
if (onOpen && typeof onOpen === 'function') {
onOpen(modalInstance);
}
return modalInstance;
}
/**
* Close a modal by ID
* @param {string} id - Modal ID
*/
function closeModal(id) {
const modal = activeModals.get(id);
if (!modal) return;
// Remove escape handler if it exists
if (modal._escapeHandler) {
document.removeEventListener('keydown', modal._escapeHandler);
}
// Remove from DOM
if (modal.backdrop && modal.backdrop.parentNode) {
modal.backdrop.parentNode.removeChild(modal.backdrop);
}
if (modal.dialogContainer && modal.dialogContainer.parentNode) {
modal.dialogContainer.parentNode.removeChild(modal.dialogContainer);
}
// Remove from active modals
activeModals.delete(id);
// Call onClose callback if modal instance has it
if (modal.onClose && typeof modal.onClose === 'function') {
modal.onClose();
}
}
/**
* Remove a modal by ID (force removal)
* @param {string} id - Modal ID
*/
function removeModal(id) {
const modal = activeModals.get(id);
if (modal) {
closeModal(id);
}
}
/**
* Update modal content
* @param {string} id - Modal ID
* @param {string|HTMLElement} content - New content
*/
function updateModalContent(id, content) {
const modal = activeModals.get(id);
if (!modal) return;
if (typeof content === 'string') {
modal.dialogContent.innerHTML = content;
} else if (content instanceof HTMLElement) {
modal.dialogContent.innerHTML = '';
modal.dialogContent.appendChild(content);
}
}
/**
* Check if a modal is open
* @param {string} id - Modal ID
* @returns {boolean}
*/
function isModalOpen(id) {
return activeModals.has(id);
}
/**
* Get all active modal IDs
* @returns {Array<string>}
*/
function getActiveModalIds() {
return Array.from(activeModals.keys());
}
/**
* Close all modals
*/
function closeAllModals() {
const ids = Array.from(activeModals.keys());
ids.forEach(id => closeModal(id));
}
// Public API
return {
create: createModal,
close: closeModal,
remove: removeModal,
updateContent: updateModalContent,
isOpen: isModalOpen,
getActiveIds: getActiveModalIds,
closeAll: closeAllModals
};
})();
+248
View File
@@ -0,0 +1,248 @@
// Jellyfin Playlist Script
// Modifies playlist view page behavior to navigate to item details instead of playing
// Adds play button to playlist items
// Requires: utils.js, cardBuilder.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks Playlist]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Playlist]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Playlist]', ...args);
LOG('Initializing...');
/**
* Modifies playlist item click behavior to navigate to details page
*/
function modifyPlaylistItemClicks() {
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
WARN('Library page not found');
return;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
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.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
*/
function addPlayButtons() {
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
WARN('Library page not found for play buttons');
return;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
if (!childrenItemsContainer) {
childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible');
if (!childrenItemsContainer) {
WARN('Children items container not found for play buttons');
return;
}
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
LOG(`Found ${playlistItems.length} playlist items to add play buttons to`);
playlistItems.forEach((item, index) => {
const playlistItemId = item.getAttribute('data-playlistitemid');
if (!playlistItemId) {
WARN(`Playlist item ${index} has no data-playlistitemid`);
return;
}
const buttonsContainer = item.querySelector('.listViewUserDataButtons');
if (!buttonsContainer) {
WARN(`No listViewUserDataButtons container found for playlist item ${index}`);
return;
}
// Check if play button already exists for this item
const existingPlayButton = buttonsContainer.querySelector('.playlist-play-button');
if (existingPlayButton) {
LOG(`Play button already exists for item ${index}`);
return;
}
// Create play button following the same pattern as other buttons
// Play button
const playButton = document.createElement('button');
playButton.setAttribute('is', 'paper-icon-button-light');
playButton.className = 'itemAction paper-icon-button-light playBtn';
playButton.setAttribute('data-action', 'resume');
const playIcon = document.createElement('span');
playIcon.className = 'material-icons play_arrow';
playIcon.setAttribute('aria-hidden', 'true');
playButton.appendChild(playIcon);
// Prepend the play button to the container
buttonsContainer.insertBefore(playButton, buttonsContainer.firstChild);
});
LOG(`Added play buttons to ${playlistItems.length} playlist items`);
}
/**
* Main function to modify the playlist page
* @param {string} itemId - The playlist item ID
*/
async function modifyPlaylistPage() {
if (document.querySelector('.libraryPage:not(.hide)')?.dataset?.kefinPlaylist === 'true') {
LOG('Playlist page already modified, skipping');
return;
}
// Poll for page elements to be ready (every 100ms for up to 10 seconds)
const pollInterval = 100;
const maxAttempts = 100; // 100ms * 100 = 10 seconds
let attempts = 0;
const pollForElements = () => {
attempts++;
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
if (attempts < maxAttempts) {
setTimeout(pollForElements, pollInterval);
} else {
WARN('Library page not found after 10 seconds');
}
return;
}
let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer');
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;
}
}
const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]');
if (playlistItems.length === 0) {
if (attempts < maxAttempts) {
setTimeout(pollForElements, pollInterval);
} else {
WARN('No playlist items found after 10 seconds');
}
return;
}
if (libraryPage.dataset.kefinPlaylist === 'true') {
LOG('Playlist page already modified, skipping');
return;
}
// Elements found, proceed with modifications
libraryPage.dataset.kefinPlaylist = 'true';
modifyPlaylistItemClicks();
addPlayButtons();
LOG('Playlist page modifications complete');
};
pollForElements();
}
/**
* Initialize playlist hook using utils
*/
function initializePlaylistHook() {
if (!window.KefinTweaksUtils) {
WARN('KefinTweaksUtils not available, retrying in 1 second');
setTimeout(initializePlaylistHook, 1000);
return;
}
LOG('Registering playlist handler with KefinTweaksUtils');
// Register handler for details pages
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') {
modifyPlaylistPage();
return;
}
}, {
pages: ['details']
});
LOG('Playlist hook initialized successfully');
}
// Initialize the hook when the script loads
initializePlaylistHook();
console.log('[KefinTweaks Playlist] Module loaded and ready');
})();
+295
View File
@@ -0,0 +1,295 @@
// Remove From Continue Watching
// Adds functionality to remove items from the continue watchlist list
//
// This script adds a "remove from continue watching" button to cards that have
// data-positionticks attribute (indicating they are in progress)
//
// No external dependencies required - self-contained functionality
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks RemoveContinue]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks RemoveContinue]', ...args);
const ERROR = (...args) => console.error('[KefinTweaks RemoveContinue]', ...args);
LOG('Initializing...');
// Add CSS for positioning the remove button in top right corner
const style = document.createElement('style');
style.textContent = `
.remove-continue-button {
position: absolute !important;
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;
height: 32px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
opacity: 0 !important;
transform: scale(0.8) !important;
}
.cardOverlayContainer: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;
}
.remove-continue-button:disabled {
opacity: 0.6 !important;
cursor: not-allowed !important;
transform: scale(0.9) !important;
}
.remove-continue-button .material-icons {
color: white !important;
font-size: 18px !important;
}
`;
document.head.appendChild(style);
/************ Remove Continue Watching Button Observer ************/
// Function to add remove continue watching button to a card overlay container
function addRemoveContinueButton(overlayContainer) {
// Check if remove continue button already exists
if (overlayContainer && overlayContainer.querySelector('.remove-continue-button')) {
return;
}
// Find the card parent to get the item ID and check for data-positionticks
const card = overlayContainer.closest('.card');
if (!card) {
WARN('Could not find card parent for overlay container');
return;
}
// Only add button to cards with data-positionticks (in progress items)
const positionTicks = card.getAttribute('data-positionticks');
if (!positionTicks) {
return; // Not a continue watching item, skip
}
const itemId = card.getAttribute('data-id');
if (!itemId) {
WARN('Could not find data-id on card element');
return;
}
// Find the .cardOverlayButton-br container to position our button before it
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (!buttonContainer) {
WARN('Could not find .cardOverlayButton-br container');
return;
}
// Create remove continue watching button
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'remove-continue-button';
removeButton.setAttribute('data-action', 'none');
removeButton.setAttribute('data-id', itemId);
removeButton.title = 'Remove from Continue Watching';
// Create the close icon
const removeIcon = document.createElement('span');
removeIcon.className = 'material-icons';
removeIcon.textContent = 'close';
removeIcon.setAttribute('aria-hidden', 'true');
removeButton.appendChild(removeIcon);
// Add click event listener
removeButton.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// Show loading state
const originalIcon = removeIcon.textContent;
removeIcon.textContent = 'hourglass_empty';
removeButton.disabled = true;
try {
// Call API to remove from continue watching
await removeFromContinueWatching(itemId);
// Remove the card from the DOM
card.remove();
LOG(`Successfully removed item ${itemId} from continue watching`);
} catch (error) {
ERROR('Failed to remove from continue watching:', error);
// Restore original state on error
removeIcon.textContent = originalIcon;
removeButton.disabled = false;
// Show error message to user
alert('Failed to remove from continue watching. Please try again.');
}
});
// Add the button as a sibling before the .cardOverlayButton-br container
buttonContainer.parentNode.insertBefore(removeButton, buttonContainer);
LOG(`Added remove continue watching button for item ${itemId}`);
}
// Function to fetch current user data for an item
async function fetchUserData(itemId) {
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const token = ApiClient.accessToken();
if (!userId || !serverUrl || !token) {
throw new Error('Missing required API credentials');
}
const userDataUrl = `${serverUrl}/UserItems/${itemId}/UserData`;
const response = await fetch(userDataUrl, {
method: 'GET',
headers: {
"Authorization": `MediaBrowser Token="${token}"`,
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user data: ${response.status} ${response.statusText}`);
}
return await response.json();
}
// Function to update user data to reset progress while preserving played status
async function updateUserDataProgress(itemId, userData) {
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const token = ApiClient.accessToken();
if (!userId || !serverUrl || !token) {
throw new Error('Missing required API credentials');
}
// Create updated user data with progress reset
const updatedUserData = {
...userData,
PlayedPercentage: 0,
PlaybackPositionTicks: 0
};
const userDataUrl = `${serverUrl}/UserItems/${itemId}/UserData`;
const response = await fetch(userDataUrl, {
method: 'POST',
headers: {
"Authorization": `MediaBrowser Token="${token}"`,
"Content-Type": "application/json"
},
body: JSON.stringify(updatedUserData)
});
if (!response.ok) {
throw new Error(`Failed to update user data: ${response.status} ${response.statusText}`);
}
return response;
}
// Function to call the API to remove item from continue watching
async function removeFromContinueWatching(itemId) {
try {
// First, fetch the current user data
const userData = await fetchUserData(itemId);
// Then update it to reset progress while preserving played status
await updateUserDataProgress(itemId, userData);
LOG(`Successfully reset progress for item ${itemId} while preserving played status`);
} catch (error) {
ERROR('Failed to reset progress:', error);
throw error;
}
}
// Function to process all existing overlay containers
function processExistingOverlayContainers() {
const overlayContainers = document.querySelectorAll('.cardOverlayContainer');
overlayContainers.forEach((overlayContainer) => {
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
if (buttonContainer) {
addRemoveContinueButton(overlayContainer);
}
});
}
// Set up MutationObserver to watch for new overlay containers
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);
}
});
}
}
});
}
});
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
// Process any existing overlay containers
processExistingOverlayContainers();
}
// Initialize the observer
setupRemoveContinueButtonObserver();
// Debug function for troubleshooting (available in console)
window.debugRemoveContinueButtons = function() {
LOG('Manual debug trigger called');
LOG('Current overlay containers:', document.querySelectorAll('.cardOverlayContainer').length);
LOG('Current remove continue buttons:', document.querySelectorAll('.remove-continue-button').length);
LOG('Cards with data-positionticks:', document.querySelectorAll('.card[data-positionticks]').length);
processExistingOverlayContainers();
};
LOG('Initialized successfully');
})();
+62
View File
@@ -0,0 +1,62 @@
.searchFields {
position: relative;
}
.padded-left.padded-right.searchFields {
display: flex;
flex-direction: column-reverse;
align-items: center;
}
.layout-desktop .searchFieldsInner {
width: 100%;
}
div#smart-search-wrapper:not([style*="display: none"]) ~ #jellyseerr-search-icon {
display: none;
}
.smart-search-mode .searchFieldsInner {
position: relative;
}
div#searchPage {
padding-top: 3rem !important;
}
@media (min-width: 600px) {
div#searchPage {
padding-top: 5rem !important;
}
}
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#smart-search-results {
padding-bottom: 0 !important;
padding-right: 0 !important;
}
label[for="searchTextInput"] {
display: none;
}
.searchFieldsInner>.searchfields-icon {
display: none;
}
.input-wrapper {
position: relative;
}
+697
View File
@@ -0,0 +1,697 @@
// KefinTweaks Enhanced Search
// Enhanced search functionality with better UI and Jellyseerr integration
// Requires: cardBuilder.js, utils.js modules to be loaded before this script
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Search]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Search]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Search]', ...args);
LOG('Initializing...');
// Configuration
const CONFIG = window.KefinTweaksConfig?.search || {
enableJellyseerr: false // Toggle for Jellyseerr integration
};
// Override XHR to block automatic search requests from direct URL navigation
const OriginalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const xhr = new OriginalXHR();
const originalOpen = xhr.open;
const originalSend = xhr.send;
xhr.open = function(method, url, ...args) {
// Check if this is a search request we want to block
const isSearchRequest = url.includes('/Items?') &&
(url.includes('searchTerm=') || url.includes('query='));
const isSearchPage = window.location.hash.includes('#/search');
if (isSearchRequest && isSearchPage) {
LOG('Blocking default Jellyfin search request:', url);
// Override send to do nothing
xhr.send = function() {
// Simulate a successful response
setTimeout(() => {
xhr.readyState = 4;
xhr.status = 200;
xhr.responseText = '{"Items":[],"TotalRecordCount":0}';
if (xhr.onreadystatechange) {
xhr.onreadystatechange();
}
}, 100);
};
} else {
// Use original send for non-blocked requests
xhr.send = function(...args) {
return originalSend.apply(this, args);
};
}
return originalOpen.apply(this, [method, url, ...args]);
};
return xhr;
};
// config
const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person'];
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist', 'MusicVideo'];
const BOOKS_TYPES = ['Book', 'AudioBook'];
const OTHER_TYPES = ['Playlist', 'Photo', 'PhotoAlbum', 'TvChannel', 'LiveTvProgram', 'BoxSet'];
// Cache for search results by search term and type
const searchCache = new Map();
// Function to generate cache key
function getCacheKey(searchTerm, searchType) {
return `${searchTerm.toLowerCase().trim()}_${searchType}`;
}
// 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 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 });
}
// Function to clear search results except jellyseerr-section
function clearSearchResultsExceptJellyseerr() {
LOG('Clearing search results except jellyseerr-section');
// Clear the smart search results container
const resultsContainer = document.getElementById('smart-search-results');
if (resultsContainer) {
resultsContainer.innerHTML = '';
}
}
// styles (dedupe by id)
function addCustomStyles() {
if (document.getElementById('smart-search-styles')) return;
LOG('adding styles');
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-btn.active { background:#673AB7; }
.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 {
top: 50%;
transform: translateY(-50%);
position: absolute;
z-index: 1;
right: 0;
cursor: pointer;
}
`;
document.head.appendChild(style);
}
// update search URL helper
function updateSearchUrl(searchTerm, searchType = 'videos') {
try {
// Jellyfin uses hash-based routing, so we need to update the hash
const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html';
const baseHash = `#/search${urlSuffix}`;
const params = new URLSearchParams();
const trimmed = searchTerm.trim();
if (trimmed) {
params.set('query', trimmed);
}
params.set('type', searchType);
const newHash = `${baseHash}?${params.toString()}`;
window.history.replaceState({}, '', window.location.pathname + newHash);
LOG('Updated search URL hash:', newHash);
} catch (e) {
ERR('updateSearchUrl error', e);
}
}
// 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.remove();
}
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;
if (type === 'Artist') {
// Use dedicated Artists endpoint for better results
const userId = ApiClient.getCurrentUserId();
const baseUrl = ApiClient.serverAddress();
url = `${baseUrl}/Artists?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 === 'Artist') {
// Artists: show all items, they often don't have primary images
filteredItems = r.Items;
} 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',
'Series': 'TV Shows',
'Episode': 'Episodes',
'Person': 'People',
'MusicAlbum': 'Albums',
'Audio': 'Songs',
'Artist': 'Artists',
'Playlist': 'Playlists',
'Book': 'Books',
'AudioBook': 'Audiobooks',
'Photo': 'Photos',
'PhotoAlbum': 'Photo Albums',
'TvChannel': 'TV Channels',
'LiveTvProgram': 'Live TV',
'BoxSet': 'Collections'
};
return typeMap[itemType] || itemType;
}
// Create smart search results container lazily when needed
function ensureSmartResultsContainer() {
let smartResults = document.getElementById('smart-search-results');
if (!smartResults) {
LOG('Creating smart-search-results container');
smartResults = document.createElement('div');
smartResults.id = 'smart-search-results';
smartResults.className = 'smart-search-results emby-scroller searchResults, padded-top, padded-bottom-page';
const searchPage = document.getElementById('searchPage');
if (searchPage) {
searchPage.appendChild(smartResults);
} else {
// Fallback to body if #searchPage not found
document.body.appendChild(smartResults);
}
}
return smartResults;
}
function displaySmartResults(results, ms) {
const resultsContainer = ensureSmartResultsContainer();
const stats = document.getElementById('smart-search-stats');
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`;
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', 'MusicAlbum', 'Audio', 'Artist', 'Playlist', 'Book', 'AudioBook', 'Photo', 'PhotoAlbum', 'TvChannel', 'LiveTvProgram', 'BoxSet'];
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;
}
});
resultsContainer.appendChild(frag);
stats.textContent = `Search completed in ${ms}ms - ${results.total} results found`;
}
// main init
function initSmartSearch() {
if (typeof ApiClient === 'undefined') { setTimeout(initSmartSearch, 500); return; }
const originalInput = document.getElementById('searchTextInput');
if (!originalInput) { setTimeout(initSmartSearch, 500); return; }
addCustomStyles();
// Handle URL query parameter on page load
// Parse query from hash since Jellyfin uses hash-based routing
let queryFromUrl = null;
if (window.location.hash.includes('#/search') && window.location.hash.includes('query=')) {
const hashQuery = window.location.hash.split('query=')[1];
if (hashQuery) {
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
queryFromUrl = decodeURIComponent(queryFromUrl).replace(/\+/g, ' ');
}
}
LOG('initSmartSearch - URL query:', queryFromUrl);
LOG('initSmartSearch - originalInput exists:', !!originalInput);
if (originalInput) {
LOG('initSmartSearch - originalInput value:', originalInput.value);
}
if (queryFromUrl) {
LOG('initSmartSearch - URL query detected, leaving input alone');
}
// Replace original search input with our smart search input
let wrapper = document.getElementById('smart-search-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.id = 'smart-search-wrapper';
wrapper.className = 'smart-search-wrapper';
// Create the search icon element
const searchIcon = document.createElement('span');
searchIcon.className = 'searchfields-icon material-icons search';
searchIcon.setAttribute('aria-hidden', 'true');
// Replace the original input with our smart search input
const input = originalInput.cloneNode(true);
input.id = 'searchTextInput'; // Use the same ID as original
input.className = 'smart-search-input emby-input searchfields-txtSearch';
input.type = 'text';
input.placeholder = 'Search...';
input.value = ''; // Clear any existing value
input.removeAttribute('data-jellyseerr-listener');
const btnRow = document.createElement('div');
btnRow.className = 'smart-search-buttons';
const btnAll = document.createElement('button');
btnAll.id = 'smart-search-all';
btnAll.className = 'smart-search-btn emby-button';
btnAll.textContent = 'All';
const btnCore = document.createElement('button');
btnCore.id = 'smart-search-core';
btnCore.className = 'smart-search-btn emby-button active';
btnCore.textContent = 'Movies/TV';
const btnMusic = document.createElement('button');
btnMusic.id = 'smart-search-music';
btnMusic.className = 'smart-search-btn emby-button';
btnMusic.textContent = 'Music';
const btnBooks = document.createElement('button');
btnBooks.id = 'smart-search-books';
btnBooks.className = 'smart-search-btn emby-button';
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 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);
// Replace the original input with our wrapper
originalInput.parentElement.insertBefore(wrapper, originalInput);
originalInput.remove(); // Remove the original input
input.focus();
}
// Smart results container will be created lazily when needed
const smartInput = document.getElementById('searchTextInput');
const smartCoreBtn = document.getElementById('smart-search-core');
const smartMusicBtn = document.getElementById('smart-search-music');
const smartBooksBtn = document.getElementById('smart-search-books');
const smartAllBtn = document.getElementById('smart-search-all');
const smartRequestBtn = document.getElementById('smart-search-request');
// Track current search type
let currentSearchType = 'videos';
// Function to update button states
function updateButtonStates(activeType) {
// Remove active class from all buttons
smartAllBtn.classList.remove('active');
smartCoreBtn.classList.remove('active');
smartMusicBtn.classList.remove('active');
smartBooksBtn.classList.remove('active');
if (smartRequestBtn) {
smartRequestBtn.classList.remove('active');
}
// Add active class to the selected button
if (activeType === 'all') {
smartAllBtn.classList.add('active');
} else if (activeType === 'videos') {
smartCoreBtn.classList.add('active');
} else if (activeType === 'music') {
smartMusicBtn.classList.add('active');
} else if (activeType === 'books') {
smartBooksBtn.classList.add('active');
} else if (activeType === 'request' && smartRequestBtn) {
smartRequestBtn.classList.add('active');
}
}
// Function to handle search type toggle
function setSearchType(type) {
LOG('setSearchType called:', { type, currentSearchType });
currentSearchType = 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 (!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;
timer = setTimeout(() => performSmartSearch(term, internalType), 300);
};
smartInput.addEventListener('input', doSmartSearchDebounced);
smartInput.addEventListener('keydown', (e)=>{
if(e.key==='Enter'){
e.preventDefault();
const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType;
performSmartSearch((smartInput.value||'').trim(), internalType);
}
});
smartCoreBtn.addEventListener('click', ()=>setSearchType('videos'));
smartMusicBtn.addEventListener('click', ()=>setSearchType('music'));
smartBooksBtn.addEventListener('click', ()=>setSearchType('books'));
smartAllBtn.addEventListener('click', ()=>setSearchType('all'));
smartRequestBtn.addEventListener('click', ()=>setSearchType('request'));
// Parse type parameter from URL
let typeFromUrl = 'videos'; // default
if (window.location.hash.includes('type=')) {
const typeMatch = window.location.hash.match(/type=([^&]+)/);
if (typeMatch) {
typeFromUrl = typeMatch[1];
}
}
// Set initial search type and update UI
currentSearchType = 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;
performSmartSearch(queryFromUrl, internalType);
}
}
// persistent toggle button
let toggleBtn = document.getElementById('persistent-toggle-btn');
if (!toggleBtn) {
toggleBtn = document.createElement('button');
toggleBtn.id = 'persistent-toggle-btn';
toggleBtn.textContent = 'Switch to Default Search';
const searchFields = document.querySelector('.searchFields');
if (searchFields) {
searchFields.appendChild(toggleBtn);
} else {
// Fallback to body if .searchFields not found
document.body.appendChild(toggleBtn);
}
}
let smartMode = true;
const setMode = (isSmart) => {
smartMode = isSmart;
LOG('setMode', smartMode);
if (smartMode) {
wrapper.style.display = '';
// Hide search results containers (be more specific to avoid affecting other pages)
const searchResults = document.querySelectorAll('#searchPage .searchResults, #searchPage .padded-top, #searchPage .padded-bottom-page');
searchResults.forEach(sr => sr.style.display = 'none');
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)
const searchResults = document.querySelectorAll('#searchPage .searchResults, #searchPage .padded-top, #searchPage .padded-bottom-page');
searchResults.forEach(sr => sr.style.display = '');
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
}
};
toggleBtn.addEventListener('click', ()=>setMode(!smartMode));
// initial mode
setMode(true);
}
// Initialize search hook using utils
function initializeSearchHook() {
if (!window.KefinTweaksUtils) {
WARN('KefinTweaksUtils not available, retrying in 1 second');
setTimeout(initializeSearchHook, 1000);
return;
}
LOG('Registering search page handler with KefinTweaksUtils');
// Register handler for search page
window.KefinTweaksUtils.onViewPage((view, element) => {
LOG('Search page detected via utils');
// Small delay to ensure DOM is ready
setTimeout(() => {
const searchInput = document.getElementById('searchTextInput');
if (searchInput && !document.getElementById('smart-search-wrapper')) {
LOG('Search input found, initializing smart search');
initSmartSearch();
}
}, 100);
}, {
pages: ['search']
});
LOG('Search hook initialized successfully');
}
// Initialize the hook when the script loads
//initializeSearchHook();
if (document.readyState==='loading') {
document.addEventListener('DOMContentLoaded', () => {
initializeSearchHook();
});
} else {
initializeSearchHook();
}
LOG('Initialized successfully');
})();
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
div#subtitleSearchResults {
overflow-y: scroll;
height: auto !important;
max-height: unset;
display: grid;
grid-auto-rows: min-content;
}
div#subtitleSearchResults button.btnDownload {
margin-right: 20px;
}
#videoOsdPage:not(.hide) button.btnSubtitles.hide {
display: block !important;
}
.dialogContainer [data-id="secondarysubtitle"] {
display: none;
}
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
// KefinTweaks Toaster
// Provides toast notification functionality using Jellyfin's existing toast system
// Usage: window.KefinTweaksToaster.toast(message, duration)
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks Toaster]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Toaster]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Toaster]', ...args);
LOG('Initializing');
/**
* Get the toast container
* The container is the last child at the root of body with class toastContainer
* @returns {HTMLElement|null} The toast container element or null if not found
*/
function getToastContainer() {
// Look for existing toastContainer at the root of body (last child)
const container = document.querySelector('body > .toastContainer:last-child');
if (container) {
return container;
}
// Fallback: try to find any toastContainer
const anyContainer = document.querySelector('.toastContainer');
if (anyContainer) {
return anyContainer;
}
return null;
}
/**
* Convert duration to milliseconds
* @param {string|number} duration - Duration in seconds (string like "3s") or milliseconds (number)
* @returns {number} Duration in milliseconds
*/
function parseDuration(duration) {
if (typeof duration === 'number') {
return duration;
}
if (typeof duration === 'string') {
// Remove 's' if present and convert to number
const seconds = parseFloat(duration.replace('s', ''));
if (!isNaN(seconds)) {
return seconds * 1000;
}
}
// Default to 3 seconds
return 3000;
}
/**
* 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)
*/
function toast(message, duration = '3', autoHide = true) {
if (!message) {
WARN('Toast message is required');
return;
}
let container = getToastContainer();
let customContainer = false;
if (!container) {
// Create a new toast container
const newContainer = document.createElement('div');
newContainer.className = 'toastContainer';
document.body.appendChild(newContainer);
container = newContainer;
customContainer = true;
}
// Remove any existing toast element from the container
const existingToast = container.querySelector('.toast');
if (existingToast) {
existingToast.remove();
LOG('Removed existing toast to make room for new one');
}
// Create the toast element with just 'toast' class initially
const toastElement = document.createElement('div');
toastElement.className = 'toast';
toastElement.textContent = message;
// Add to container first
container.appendChild(toastElement);
LOG(`Displaying toast: "${message}"`);
// Force a browser reflow by reading a layout property
// This ensures the element is rendered with just 'toast' class before adding 'toastVisible'
void toastElement.offsetHeight;
// Now add toastVisible class - the browser will see the state change and apply the transition
toastElement.classList.add('toastVisible');
// Only set up auto-hide if enabled
if (autoHide) {
// Calculate duration in milliseconds
const durationMs = parseDuration(duration);
// 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);
}, durationMs);
}
}
// Expose toaster to global scope
window.KefinTweaksToaster = {
toast
};
LOG('Initialized successfully');
LOG('Available at window.KefinTweaksToaster');
})();
+1234
View File
File diff suppressed because it is too large Load Diff
+416
View File
@@ -0,0 +1,416 @@
// KefinTweaks User Configuration
// User-specific settings and configurations for KefinTweaks features
// This file contains settings that users can customize for their specific needs
(function() {
'use strict';
// ============================================================================
// USER CONFIGURATION
// ============================================================================
const USER_CONFIG = {
// ============================================================================
// SKIN CONFIGURATION
// ============================================================================
// Configure available skins for the display settings page
// These skins will be merged with skins from the main KefinTweaks config
// User-defined skins take priority over main config skins (no duplicates by name)
// Each skin's url field should be an array of objects with majorServerVersions and urls arrays
// Example: url: [{ majorServerVersions: [10, 11], urls: ['url1.css', 'url2.css'] }]
// Each skin can also have colorSchemes array for color variations specific to that skin
// Each skin should have an author field indicating who created the skin
skins: [
{
name: 'Default',
author: 'Jellyfin',
url: null, // null means no additional CSS (default Jellyfin theme)
colorSchemes: []
},
{
name: 'ElegantFin',
author: 'lscambo13',
url: [
{
majorServerVersions: [10, 11],
urls: [
'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css',
'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/assets/add-ons/media-bar-plugin-support-nightly.css',
`${KefinTweaksConfig.scriptRoot}skins/elegantKefin.css`
]
}
],
colorSchemes: []
},
{
name: 'NeutralFin',
author: 'KartoffelChipss',
url: [
{
majorServerVersions: [10, 11],
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',
`${KefinTweaksConfig.scriptRoot}skins/neutralfin-kefin.css`
]
}
],
colorSchemes: []
},
{
name: 'Jellypane',
author: 'tedhinklater',
url: [
{
majorServerVersions: [10],
urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/Jellypane.css']
},
{
majorServerVersions: [11],
urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/Jellypane10.11.css']
}
],
colorSchemes: []
},
{
name: 'Jellyfish',
author: 'n00bcodr',
url: [
{
majorServerVersions: [10],
urls: ['https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css']
},
{
majorServerVersions: [11],
urls: [
'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css',
'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/10.11_fixes.css'
]
}
],
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Banana',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/banana.css'
},
{
name: 'Coal',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coal.css'
},
{
name: 'Coral',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coral.css'
},
{
name: 'Grass',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/grass.css'
},
{
name: 'Jellyblue',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyblue.css'
},
{
name: 'Jellyflix',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyflix.css'
},
{
name: 'Lavender',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/lavender.css'
},
{
name: 'Mint',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/mint.css'
},
{
name: 'Ocean',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/ocean.css'
},
{
name: 'Peach',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/peach.css'
},
{
name: 'Watermelon',
url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/watermelon.css'
}
]
},
{
name: 'Glassmorphism',
author: 'alexyle',
url: [
{
majorServerVersions: [10, 11],
urls: ['https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css']
}
],
colorSchemes: []
},
{
name: 'Zombie',
author: 'MakD',
url: [
{
majorServerVersions: [10],
urls: ['https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/zombie_revived.css']
}
],
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Amazon Prime',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/amazon-prime.css'
},
{
name: 'Apple TV',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/apple-tv.css'
},
{
name: 'Disney',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/disney.css'
},
{
name: 'HBO Max',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hbo-max.css'
},
{
name: 'Hulu',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hulu.css'
},
{
name: 'Netflix',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/netflix.css'
},
{
name: 'YouTube',
url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/youtube.css'
}
]
},
{
name: 'Jamfin',
author: 'JamsRepos',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css',
`${KefinTweaksConfig.scriptRoot}skins/jamfin-kefin.css`,
`${KefinTweaksConfig.scriptRoot}skins/jamfin-kefin-10.css`
]
},
{
majorServerVersions: [11],
urls: [
'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css',
`${KefinTweaksConfig.scriptRoot}skins/jamfin-kefin.css`
]
}
],
colorSchemes: []
},
{
name: 'Scyfin',
author: 'loof2736',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/loof2736/scyfin@v1.4.17/CSS/scyfin-theme.css',
`${KefinTweaksConfig.scriptRoot}skins/scyfin-kefin.css`
]
},
{
majorServerVersions: [11],
urls: [
'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css',
`${KefinTweaksConfig.scriptRoot}skins/scyfin-kefin.css`
]
}
],
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Seafoam',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css'
},
{
name: 'Coral',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css'
},
{
name: 'Snow',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css'
},
{
name: 'OLED',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-oled.css'
}
]
},
{
name: 'Catppuccin',
author: 'Catppuccin',
url: [
{
majorServerVersions: [10, 11],
urls: ['https://jellyfin.catppuccin.com/theme.css']
}
],
colorSchemes: [
{
name: 'Latte',
url: 'https://jellyfin.catppuccin.com/catppuccin-latte.css'
},
{
name: 'Frappé',
url: 'https://jellyfin.catppuccin.com/catppuccin-frappe.css'
},
{
name: 'Macchiato',
url: 'https://jellyfin.catppuccin.com/catppuccin-macchiato.css'
},
{
name: 'Mocha',
url: 'https://jellyfin.catppuccin.com/catppuccin-mocha.css'
}
]
},
{
name: 'Flow',
author: 'LitCastVlog',
url: [
{
majorServerVersions: [10],
urls: [
'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css',
`${KefinTweaksConfig.scriptRoot}skins/flow-kefin.css`
]
},
{
majorServerVersions: [11],
urls: [
'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css',
'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Compatibility.css',
`${KefinTweaksConfig.scriptRoot}skins/flow-kefin.css`
]
}
],
colorSchemes: [
{
name: 'Default',
url: ''
},
{
name: 'Orange',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Orange.css'
},
{
name: 'Pink',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Pink.css'
},
{
name: 'Rainbow',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Rainbow.css'
},
{
name: 'Red',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Red.css'
},
{
name: 'White',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-White.css'
},
{
name: 'Blue',
url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Blue.css'
},
{
name: 'Seafoam',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css'
},
{
name: 'Coral',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css'
},
{
name: 'Snow',
url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css'
}
]
},
{
name: 'Monochromic',
author: 'CTalvio',
url: [
{
majorServerVersions: [10, 11],
urls: [
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/monochromic_preset.css',
`${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css`
]
}
],
colorSchemes: []
},
{
name: 'Kaleidochromic',
author: 'CTalvio',
url: [
{
majorServerVersions: [10, 11],
urls: [
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/kaleidochromic_preset.css',
`${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css`
]
}
],
colorSchemes: []
},
{
name: 'Novachromic',
author: 'CTalvio',
url: [
{
majorServerVersions: [10, 11],
urls: [
'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/novachromic_preset.css',
`${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css`
]
}
],
colorSchemes: []
}
// Add more skins here as needed
],
// ============================================================================
// THEME CONFIGURATION
// ============================================================================
// Configure available themes for color variations
// These themes will be added as options to the existing select#selectTheme dropdown
// When a custom theme is selected, the cssTheme link href will be updated to the custom URL
// Each theme should have a name and URL pointing to a CSS file
themes: [
]
};
// ============================================================================
// CONFIGURATION EXPOSURE
// ============================================================================
// Make user configuration available to scripts
window.KefinTweaksUserConfig = USER_CONFIG;
console.log('[KefinTweaks UserConfig] User configuration loaded. Available at window.KefinTweaksUserConfig');
})();
+395
View File
@@ -0,0 +1,395 @@
// KefinTweaks Utilities
// Provides standardized functionality for hooking into Emby.Page.onViewShow
// while maintaining original Jellyfin functionality
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[KefinTweaks Utils]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Utils]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Utils]', ...args);
LOG('Initializing');
// Store the original onViewShow function
let originalOnViewShow = null;
// Initialize the utils by hooking into Emby.Page.onViewShow
function initialize() {
// Store the original onViewShow if it exists
if (window.Emby && window.Emby.Page && window.Emby.Page.onViewShow) {
originalOnViewShow = window.Emby.Page.onViewShow;
LOG('Stored original Emby.Page.onViewShow');
}
// Override onViewShow to maintain original functionality
if (window.Emby && window.Emby.Page) {
window.Emby.Page.onViewShow = function (...args) {
// Call original handler if it exists
if (originalOnViewShow) {
try {
originalOnViewShow.apply(this, ...args);
} catch (err) {
ERR('Error in original onViewShow handler:', err);
}
}
// Call our registered handlers
notifyHandlers(args[0], args[1], window.location.hash);
};
LOG('Hooked into Emby.Page.onViewShow');
} else {
WARN('Emby.Page.onViewShow not found - utils may not work correctly');
}
}
// Array to store registered handlers
const handlers = [];
// Cache item per page view to avoid multiple fetches
let cachedItem = null;
let cachedItemId = null;
let fetchInProgress = null; // Track the promise of an in-progress fetch
let fetchItemId = null; // Track which item ID is currently being fetched
/**
* Register a callback to be called when page view changes
* @param {Function} callback - Function to call when page view changes
* @param {Object} options - Options for the handler
*/
function onViewPage(callback, options = {}) {
if (typeof callback !== 'function') {
ERR('Callback must be a function');
return;
}
const handlerConfig = {
callback,
options: {
pages: [], // Specific pages to watch (empty = all pages)
...options
}
};
handlers.push(handlerConfig);
LOG(`Registered onViewPage handler (total: ${handlers.length})`);
// Test if this is causing issues
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);
}
}
// Return a function to unregister this handler
return () => {
const index = handlers.indexOf(handlerConfig);
if (index !== -1) {
handlers.splice(index, 1);
LOG(`Unregistered onViewPage handler (remaining: ${handlers.length})`);
}
};
}
/**
* Get item ID from URL parameters
* Works with both hash-based routing and traditional query strings
* @returns {string|null} Item ID or null if not found
*/
function getItemIdFromUrl() {
try {
const match = window.location.href.match(/[\?&]id=([^&]+)/);
return match ? match[1] : null;
} catch {
return null;
}
}
/**
* Fetch item by ID
* @param {string} itemId - The item ID
* @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 || !window.ApiClient._loggedIn) {
return null;
}
try {
const userId = window.ApiClient.getCurrentUserId();
const item = await window.ApiClient.getItem(userId, itemId);
return item;
} catch (error) {
WARN('Error fetching item:', error);
return null;
}
}
/**
* Get current item from URL, using cache if available
* @returns {Promise<Object|null>} The item object or null if not found
*/
async function getCurrentItem() {
const itemId = getItemIdFromUrl();
// If no item ID, return null
if (!itemId) {
return null;
}
// If we have a cached item for this ID, return it
if (cachedItem && cachedItemId === itemId) {
return cachedItem;
}
// If a fetch is already in progress for this item, wait for it
if (fetchInProgress && fetchItemId === itemId) {
LOG('Fetch already in progress for this item, waiting for it to complete');
return await fetchInProgress;
}
// Start a new fetch and store the promise
fetchItemId = itemId;
fetchInProgress = (async () => {
try {
LOG('Fetching item:', itemId);
const item = await fetchItemById(itemId);
cachedItem = item;
cachedItemId = itemId;
return item;
} finally {
// Clear the in-progress flag when done (only if still for this item)
if (fetchItemId === itemId) {
fetchInProgress = null;
fetchItemId = null;
}
}
})();
LOG('Fetch in progress for item:', itemId);
return await fetchInProgress;
}
/**
* Notify all registered handlers of a view change
* @param {string} view - The view name
* @param {Element} element - The view element
*/
function notifyHandlers(view, element, hash) {
// Clear cache when view changes to ensure fresh data
const currentItemId = getItemIdFromUrl();
if (cachedItemId !== currentItemId) {
LOG('Clearing cache for new item:', currentItemId);
cachedItem = null;
cachedItemId = null;
fetchInProgress = null; // Clear any in-progress fetch for previous item
fetchItemId = null;
}
// Get the item promise once - will be shared across all handlers
// The promise is cached/fetched by getCurrentItem(), ensuring only one API call
const itemPromise = getCurrentItem();
handlers.forEach((config) => {
if (shouldCallHandler(config, view)) {
try {
// Pass the promise as third parameter - handlers can await if needed
config.callback(view, element, hash, itemPromise);
} catch (err) {
ERR('Error in onViewPage handler:', err);
}
}
});
}
/**
* Check if a handler should be called for a given view
* @param {Object} config - Handler configuration
* @param {string} view - The view name
* @returns {boolean} Whether the handler should be called
*/
function shouldCallHandler(config, view) {
const { pages } = config.options;
// If no specific pages are specified, call for all pages
if (pages.length === 0) return true;
// Get current URL hash to determine actual page
const currentUrl = window.location.hash;
// Check if the current URL matches any of the specified pages
return pages.some(page => {
if (typeof page === 'string') {
// Check if URL hash contains the page pattern
return currentUrl.includes(`/${page}.html`) || currentUrl.includes(`/${page}`) || currentUrl.includes(page);
} else if (page instanceof RegExp) {
return page.test(currentUrl);
}
return false;
});
}
/**
* Get the current view name
* @returns {string|null} Current view name or null if not available
*/
function getCurrentView() {
const hash = window.location.hash;
if (hash) {
// Extract page name from hash - match part after #/ and before ? or /
// This handles both .html pages (old) and pages without .html (Jellyfin 10.11+)
const pageMatch = hash.match(/#\/([^\/\?]+)/);
if (pageMatch) {
return pageMatch[1];
}
}
return null;
}
/**
* Get the number of registered handlers
* @returns {number} Number of registered handlers
*/
function getHandlerCount() {
return handlers.length;
}
/**
* Clear all handlers
*/
function clearHandlers() {
handlers.length = 0;
LOG('Cleared all onViewPage handlers');
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
/**
* Add a custom menu link to the custom menu options
* @param {string} name - Display name for the menu item
* @param {string} icon - Material icon name
* @param {string} url - URL to navigate to
* @param {boolean} openInNewTab - Whether to open in new tab (default: false)
* @returns {Promise<boolean>} - True if successfully added, false otherwise
*/
function addCustomMenuLink(name, icon, url, openInNewTab = false) {
return new Promise((resolve) => {
// Check if menu container already exists
const existingContainer = document.querySelector('.customMenuOptions');
if (existingContainer) {
const success = addLinkToContainer(existingContainer, name, icon, url, openInNewTab);
resolve(success);
return;
}
LOG(`Custom menu container not found, waiting for it to appear for link: ${name}`);
// Use MutationObserver to watch for the menu container
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node is the menu or contains it
if (node.matches?.('.customMenuOptions') || node.querySelector?.('.customMenuOptions')) {
const container = node.matches?.('.customMenuOptions') ? node : node.querySelector('.customMenuOptions');
LOG(`Custom menu container detected, adding link: ${name}`);
observer.disconnect();
const success = addLinkToContainer(container, name, icon, url, openInNewTab);
resolve(success);
return;
}
}
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
/**
* Add the link to the existing container
* @param {Element} container - The custom menu options container
* @param {string} name - Display name for the menu item
* @param {string} icon - Material icon name
* @param {string} url - URL to navigate to
* @param {boolean} openInNewTab - Whether to open in new tab
* @returns {boolean} - True if successfully added
*/
function addLinkToContainer(container, name, icon, url, openInNewTab) {
try {
// Check if link already exists to prevent duplicates
const existingLinks = container.querySelectorAll('a[href="' + url + '"]');
const existingByText = Array.from(container.querySelectorAll('.navMenuOptionText')).find(el => el.textContent === name);
if (existingLinks.length > 0 || existingByText) {
LOG(`Custom menu link already exists, skipping: ${name}`);
return true;
}
// Create the menu link element
const link = document.createElement('a');
link.setAttribute('is', 'emby-linkbutton');
link.className = 'emby-button navMenuOption lnkMediaFolder';
link.href = url;
if (openInNewTab) {
link.setAttribute('rel', 'noopener noreferrer');
link.setAttribute('target', '_blank');
}
// Create the icon span
const iconSpan = document.createElement('span');
iconSpan.className = `material-icons navMenuOptionIcon ${icon}`;
iconSpan.setAttribute('aria-hidden', 'true');
// Create the text span
const textSpan = document.createElement('span');
textSpan.className = 'navMenuOptionText';
textSpan.textContent = name;
// Append icon and text to link
link.appendChild(iconSpan);
link.appendChild(textSpan);
// Append link to container
container.appendChild(link);
LOG(`Successfully added custom menu link: ${name}`);
return true;
} catch (err) {
ERR(`Error adding custom menu link ${name}:`, err);
return false;
}
}
// Expose utilities to global scope
window.KefinTweaksUtils = {
onViewPage,
notifyHandlers,
getCurrentView,
getHandlerCount,
clearHandlers,
addCustomMenuLink
};
LOG('Initialized successfully');
LOG('Available at window.KefinTweaksUtils');
})();
File diff suppressed because it is too large Load Diff
+6703
View File
File diff suppressed because it is too large Load Diff