mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Move scripts to /scripts folder
This commit is contained in:
@@ -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');
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
@@ -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');
|
||||
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
|
||||
})();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user