Add a bunch of new scripts with Injector.js to bundle and configure

This commit is contained in:
HighImKevin
2025-09-16 00:49:25 -04:00
parent a402b72343
commit a792f332c3
12 changed files with 2349 additions and 9 deletions
+40
View File
@@ -0,0 +1,40 @@
// Backdrop Leak Fix Script
// Fixes issue that causes backdrop images to be continuously added to the page if the tab isn't focused
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[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');
})();
+380
View File
@@ -14,6 +14,48 @@
*/
buildCard: function(item) {
return createJellyfinCardElement(item);
},
/**
* Renders a scrollable container with cards
* @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 {number} sectionIndex - Index for debugging
* @returns {HTMLElement} - The constructed scrollable container
*/
renderCards: function(items, title, viewMoreUrl = null, sectionIndex = 0) {
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
},
/**
* 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 {number} sectionIndex - Index for debugging
* @returns {Promise<HTMLElement>} - The constructed scrollable container
*/
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, sectionIndex = 0) {
const LOG = (...args) => console.log('[CardBuilder]', ...args);
if (!itemIds || itemIds.length === 0) {
LOG(`No item IDs provided for section: ${title}`);
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
}
try {
// Fetch all items at once
const items = await Promise.all(
itemIds.map(id => ApiClient.getItem(ApiClient.getCurrentUserId(), id))
);
LOG(`Fetched ${items.length} items for section: ${title}`);
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
} catch (error) {
console.error('[CardBuilder] Error fetching items:', error);
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
}
}
};
@@ -301,6 +343,344 @@
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 {number} sectionIndex - Index for debugging
* @returns {HTMLElement} - The constructed scrollable container
*/
function createScrollableContainer(items, title, viewMoreUrl = null, sectionIndex = 0) {
const LOG = (...args) => console.log('[CardBuilder]', ...args);
// Create the main vertical section container
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container';
// Create section title
const sectionTitle = document.createElement('h2');
sectionTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right';
if (viewMoreUrl) {
// Create clickable title with chevron icon
const titleLink = document.createElement('a');
titleLink.href = viewMoreUrl;
titleLink.className = 'sectionTitle-link sectionTitleTextButton';
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
const titleText = document.createElement('span');
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);
sectionTitle.appendChild(titleLink);
} else {
// Regular non-clickable title
sectionTitle.textContent = title;
}
// Create scroll buttons container
const scrollButtons = document.createElement('div');
//scrollButtons.setAttribute('is', 'emby-scrollbuttons');
scrollButtons.className = 'emby-scrollbuttons custom-scrollbuttons padded-right';
// Previous button
const prevButton = document.createElement('button');
prevButton.type = 'button';
prevButton.setAttribute('is', 'paper-icon-button-light');
prevButton.setAttribute('data-ripple', 'false');
prevButton.setAttribute('data-direction', 'left');
prevButton.title = 'Previous';
prevButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
prevButton.disabled = true;
const prevIcon = document.createElement('span');
prevIcon.className = 'material-icons chevron_left';
prevIcon.setAttribute('aria-hidden', 'true');
prevButton.appendChild(prevIcon);
// Next button
const nextButton = document.createElement('button');
nextButton.type = 'button';
nextButton.setAttribute('is', 'paper-icon-button-light');
nextButton.setAttribute('data-ripple', 'false');
nextButton.setAttribute('data-direction', 'right');
nextButton.title = 'Next';
nextButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
const nextIcon = document.createElement('span');
nextIcon.className = 'material-icons chevron_right';
nextIcon.setAttribute('aria-hidden', 'true');
nextButton.appendChild(nextIcon);
scrollButtons.appendChild(prevButton);
scrollButtons.appendChild(nextButton);
// 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');
scroller.style.overflow = 'hidden';
// Create items container
const itemsContainer = document.createElement('div');
itemsContainer.setAttribute('is', 'emby-itemscontainer');
itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX';
itemsContainer.style.whiteSpace = 'nowrap';
itemsContainer.style.willChange = 'transform';
itemsContainer.style.transition = 'transform 270ms ease-out';
itemsContainer.style.transform = 'translateX(0px)';
// Add items to container
items.forEach((item, index) => {
const card = createJellyfinCardElement(item);
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
scroller.appendChild(itemsContainer);
// Scroll functionality - calculate dynamically when needed
let scrollPosition = 0;
// Calculate scroll values dynamically based on current DOM state
const calculateScrollValues = () => {
const firstCard = itemsContainer.querySelector('.card');
if (!firstCard) {
LOG(`[SCROLL ERROR] No cards found in scroller ${sectionIndex}`);
return { cardWidth: 212, visibleCards: 1, scrollStep: 1, maxScroll: 0 };
}
const cardRect = firstCard.getBoundingClientRect();
const computedStyle = window.getComputedStyle(firstCard);
const marginRight = parseFloat(computedStyle.marginRight) || 0;
const cardWidth = cardRect.width + marginRight; // Card width + gap
const visibleCards = Math.floor(scroller.offsetWidth / cardWidth);
const scrollStep = Math.max(1, visibleCards); // Scroll by the number of fully visible cards
const maxScroll = Math.max(0, items.length - visibleCards);
LOG(`[SCROLL CALC] Scroller ${sectionIndex}: containerWidth=${scroller.offsetWidth}, cardWidth=${cardWidth}, visibleCards=${visibleCards}, scrollStep=${scrollStep}, totalItems=${items.length}, maxScroll=${maxScroll}`);
return { cardWidth, visibleCards, scrollStep, maxScroll };
};
const updateScrollButtons = () => {
const { maxScroll } = calculateScrollValues();
prevButton.disabled = scrollPosition <= 0;
nextButton.disabled = scrollPosition >= maxScroll;
};
const scrollTo = (position, smooth = true) => {
const { cardWidth, maxScroll } = calculateScrollValues();
scrollPosition = Math.max(0, Math.min(position, maxScroll));
if (smooth) {
itemsContainer.style.transition = 'transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
} else {
itemsContainer.style.transition = 'none';
}
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
updateScrollButtons();
if (smooth) {
setTimeout(() => {
itemsContainer.style.transition = 'transform 270ms ease-out';
}, 500);
}
};
// Check if buttons already have event listeners (from Jellyfin's default handlers)
const hasExistingListeners = () => {
// Check if the buttons have any event listeners attached
// We'll look for the presence of Jellyfin's default scroll behavior
const existingButtons = document.querySelectorAll('.emby-scrollbuttons-button.paper-icon-button-light');
return existingButtons.length > 0 && existingButtons[0].onclick !== null;
};
// Native horizontal scrolling with momentum and snap
let touchStartX = 0;
let touchStartY = 0;
let touchCurrentX = 0;
let isDragging = false;
let startScrollPosition = 0;
let currentScrollOffset = 0;
let lastTouchTime = 0;
let velocity = 0;
let lastTouchX = 0;
let animationFrame = null;
let momentumAnimation = null;
const handleTouchStart = (e) => {
if (e.touches.length !== 1) return;
// Cancel any ongoing momentum animation
if (momentumAnimation) {
cancelAnimationFrame(momentumAnimation);
momentumAnimation = null;
}
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
lastTouchX = touchStartX;
lastTouchTime = Date.now();
isDragging = true;
startScrollPosition = scrollPosition;
currentScrollOffset = 0;
velocity = 0;
// Disable smooth transitions during drag
itemsContainer.style.transition = 'none';
// Don't prevent default here - let touch move determine if we should handle it
};
const handleTouchMove = (e) => {
if (!isDragging || e.touches.length !== 1) return;
touchCurrentX = e.touches[0].clientX;
const deltaX = touchStartX - touchCurrentX;
const deltaY = touchStartY - e.touches[0].clientY;
// Only process horizontal swipes
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
e.preventDefault(); // Prevent default scrolling behavior
// Calculate velocity for momentum
const currentTime = Date.now();
const timeDelta = currentTime - lastTouchTime;
if (timeDelta > 0) {
velocity = (lastTouchX - touchCurrentX) / timeDelta;
}
lastTouchX = touchCurrentX;
lastTouchTime = currentTime;
// Update scroll position in real-time
const { cardWidth } = calculateScrollValues();
currentScrollOffset = deltaX / cardWidth;
const newPosition = startScrollPosition + currentScrollOffset;
// Apply the transform immediately
itemsContainer.style.transform = `translateX(-${newPosition * cardWidth}px)`;
}
};
const handleTouchEnd = (e) => {
if (!isDragging) return;
isDragging = false;
// Calculate final position with momentum
const { cardWidth, maxScroll } = calculateScrollValues();
let finalPosition = startScrollPosition + currentScrollOffset;
// Apply momentum if velocity is significant
if (Math.abs(velocity) > 0.1) {
const momentumDistance = velocity * 200; // Adjust multiplier for momentum strength
finalPosition += momentumDistance / cardWidth;
}
// Clamp to bounds
finalPosition = Math.max(0, Math.min(finalPosition, maxScroll));
// Snap to nearest card
const snapPosition = Math.round(finalPosition);
finalPosition = Math.max(0, Math.min(snapPosition, maxScroll));
// Update scroll position and animate to final position
scrollPosition = finalPosition;
// Re-enable smooth transitions
itemsContainer.style.transition = 'transform 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
// Update button states
updateScrollButtons();
LOG(`[TOUCH END] Scroller ${sectionIndex}: velocity=${velocity.toFixed(3)}, finalPosition=${finalPosition}, snapPosition=${snapPosition}`);
// Reset velocity
velocity = 0;
currentScrollOffset = 0;
};
// Add touch event listeners to the scroller
scroller.addEventListener('touchstart', handleTouchStart, { passive: false });
scroller.addEventListener('touchmove', handleTouchMove, { passive: false });
scroller.addEventListener('touchend', handleTouchEnd, { passive: false });
scroller.addEventListener('touchcancel', handleTouchEnd, { passive: false });
// Only attach our event listeners if there are no existing ones
if (true) {
prevButton.addEventListener('click', () => {
const { scrollStep, maxScroll } = calculateScrollValues();
const newPosition = Math.max(0, scrollPosition - scrollStep);
LOG(`[PREV CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
scrollTo(newPosition, true);
});
nextButton.addEventListener('click', () => {
const { scrollStep, maxScroll } = calculateScrollValues();
const newPosition = Math.min(maxScroll, scrollPosition + scrollStep);
LOG(`[NEXT CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
scrollTo(newPosition, true);
});
}
// Initial state
updateScrollButtons();
// Assemble the section
verticalSection.appendChild(sectionTitle);
verticalSection.appendChild(scrollButtons);
verticalSection.appendChild(scroller);
return verticalSection;
}
// Add CSS to hide emby-scrollbuttons that aren't custom-scrollbuttons and improve touch experience
const style = document.createElement('style');
style.textContent = `
.smart-search-results .emby-scrollbuttons {
display: none;
}
.layout-desktop .smart-search-results .emby-scrollbuttons:not(.custom-scrollbuttons) {
display: block !important;
}
/* Native horizontal scrolling experience - ONLY for our custom scrollers */
.emby-scroller.custom-scroller {
-webkit-overflow-scrolling: touch !important;
touch-action: pan-x !important;
overscroll-behavior-x: contain !important;
}
/* Improve touch responsiveness */
.emby-scroller.custom-scroller .scrollSlider {
touch-action: pan-x !important;
-webkit-user-select: none !important;
user-select: none !important;
}
/* Ensure smooth scrolling on mobile */
@media (max-width: 768px) {
.emby-scroller.custom-scroller {
scroll-behavior: smooth !important;
}
}
`;
document.head.appendChild(style);
// Expose the cardBuilder to the global window object
window.cardBuilder = cardBuilder;
+99
View File
@@ -0,0 +1,99 @@
// Custom Menu Script
// Removes target="_blank" from custom menu links to prevent opening in new tabs
// Ensures all custom menu links open in the same tab for better user experience
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[CustomMenu]', ...args);
const WARN = (...args) => console.warn('[CustomMenu]', ...args);
const ERR = (...args) => console.error('[CustomMenu]', ...args);
LOG('Script loaded - JS Injector mode');
function initMenuWatcher() {
const menuContainer = document.querySelector('.customMenuOptions');
if (!menuContainer) return false;
LOG('Custom menu container found, initializing watcher');
const cleanLinks = (node) => {
if (node.matches?.('a.navMenuOption[target="_blank"]')) {
LOG('Removing target="_blank" from menu link');
node.removeAttribute('target');
}
node.querySelectorAll?.('a.navMenuOption[target="_blank"]').forEach(link => {
LOG('Removing target="_blank" from nested menu link');
link.removeAttribute('target');
});
};
// Clean existing links
cleanLinks(menuContainer);
// Watch for new/changed nodes
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => cleanLinks(node));
if (mutation.type === 'attributes' && mutation.target.matches('a.navMenuOption')) {
cleanLinks(mutation.target);
}
}
});
observer.observe(menuContainer, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['target']
});
LOG('Menu watcher initialized successfully');
return true;
}
// Watch for menu container to appear
const waitForMenu = () => {
const menuContainer = document.querySelector('.customMenuOptions');
if (menuContainer) {
initMenuWatcher();
return;
}
LOG('Custom menu container not found, waiting for it to appear');
// 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')) {
LOG('Custom menu container detected, initializing watcher');
observer.disconnect();
initMenuWatcher();
return;
}
}
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Fallback timeout after 10 seconds
setTimeout(() => {
observer.disconnect();
WARN('Menu not found after 10 seconds');
}, 10000);
};
// Start watching
waitForMenu();
LOG('Custom menu functionality initialized');
})();
+37
View File
@@ -0,0 +1,37 @@
/*
Exclusive Elsewhere
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 () {
const observer = new MutationObserver(() => {
const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a');
if (link && link.innerHTML.trim() === "Not available on any streaming services in Canada") {
link.innerHTML = "Only available on ";
link.classList.add("exclusive");
link.title = "24kev.in Exclusive"
link.disable = true;
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
})();
+246
View File
@@ -0,0 +1,246 @@
// 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('[HeaderTabs]', ...args);
const WARN = (...args) => console.warn('[HeaderTabs]', ...args);
const ERR = (...args) => console.error('[HeaderTabs]', ...args);
LOG('Script loaded - JS Injector mode');
const SUPPORTED_PAGES = ['home.html', 'tv.html', 'movies.html', 'music.html', '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)) {
LOG('Current page detected:', pageFromHash);
return pageFromHash;
}
}
return null;
}
// Update URL with tab parameter
function updateUrlWithTab(tabIndex) {
const currentHash = window.location.hash;
const currentPage = getCurrentPage();
if (!currentPage) return;
let newHash = currentHash;
if (currentHash.includes('?')) {
// Replace existing tab parameter
const [hashPath, hashSearch] = currentHash.split('?');
const params = new URLSearchParams(hashSearch);
if (tabIndex === 0) {
params.delete('tab');
} else {
params.set('tab', tabIndex.toString());
}
const paramString = params.toString();
newHash = paramString ? `${hashPath}?${paramString}` : hashPath;
} else {
// Add tab parameter
if (tabIndex !== 0) {
newHash = `${currentHash}?tab=${tabIndex}`;
}
}
const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1);
// Replace history entry instead of adding new one
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);
}
}
LOG('Tab clicked:', currentPage, tabIndex);
}
}
// Add click listeners to tab buttons
function addClickListeners() {
const headerTabs = document.querySelector('.headerTabs');
if (headerTabs) {
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
buttons.forEach(button => {
button.removeEventListener('click', handleTabClick);
button.addEventListener('click', handleTabClick);
});
LOG('Added click listeners to', buttons.length, 'buttons');
}
}
// Setup observer to watch for emby-tab-button additions
function setupHeaderTabsObserver() {
const headerTabs = document.querySelector('.headerTabs');
if (!headerTabs) {
LOG('No .headerTabs found, will retry...');
setTimeout(setupHeaderTabsObserver, 500);
return;
}
LOG('Setting up headerTabs observer');
const observer = new MutationObserver(function(mutations) {
let shouldReinit = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
const addedNodes = Array.from(mutation.addedNodes);
const removedNodes = Array.from(mutation.removedNodes);
const hasTabButtons = addedNodes.some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList.contains('emby-tab-button') ||
node.querySelector && node.querySelector('.emby-tab-button'))
) || removedNodes.some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList.contains('emby-tab-button') ||
node.querySelector && node.querySelector('.emby-tab-button'))
);
if (hasTabButtons) {
shouldReinit = true;
}
}
});
if (shouldReinit) {
LOG('Tab buttons changed, reinitializing...');
setTimeout(addClickListeners, 100);
}
});
observer.observe(headerTabs, {
childList: true,
subtree: true
});
// Initial setup
addClickListeners();
}
// Setup observer to watch for pageTabContent changes
function setupObserver() {
// Find the library page container
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
if (!libraryPage) {
LOG('No library page found, will retry...');
setTimeout(setupObserver, 500);
return;
}
LOG('Setting up observer on library page');
const observer = new MutationObserver(function(mutations) {
let shouldReinit = false;
mutations.forEach(mutation => {
// Check if pageTabContent elements were added/removed
if (mutation.type === 'childList') {
const addedNodes = Array.from(mutation.addedNodes);
const removedNodes = Array.from(mutation.removedNodes);
const hasPageTabContent = addedNodes.some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList.contains('pageTabContent') ||
node.querySelector && node.querySelector('.pageTabContent'))
) || removedNodes.some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList.contains('pageTabContent') ||
node.querySelector && node.querySelector('.pageTabContent'))
);
if (hasPageTabContent) {
shouldReinit = true;
}
}
});
if (shouldReinit) {
LOG('pageTabContent changed, reinitializing...');
setTimeout(addClickListeners, 100);
}
});
observer.observe(libraryPage, {
childList: true,
subtree: true
});
}
// Monitor for page changes
function setupPageMonitor() {
let lastPage = null;
function checkPageChange() {
const newPage = getCurrentPage();
if (newPage !== lastPage) {
lastPage = newPage;
if (newPage) {
LOG('Page change detected:', newPage);
// Setup observers when page changes
setupHeaderTabsObserver();
setupObserver();
}
}
}
// Check immediately
checkPageChange();
// Check on hash changes
window.addEventListener('hashchange', checkPageChange);
// Check periodically in case we miss something
setInterval(checkPageChange, 1000);
}
// Initialize
setupPageMonitor();
LOG('Header tabs functionality initialized');
})();
+13
View File
@@ -0,0 +1,13 @@
// This will put the Continue Watching and Next Up sections at the top of the home screen
// You can order your custom sections between those and the rest of the sections by using 3-99 for the section order
// If you want your custom section to appear after the normal sections, use 101+ for the section order
.sections.homeSectionsContainer>div {
order: 100;
}
.sections.homeSectionsContainer>div:has(div[data-positionticks]) {
order: 1;
}
.sections.homeSectionsContainer>div:has(a[href*="type=nextup"]) {
order: 2;
}
+326
View File
@@ -0,0 +1,326 @@
// Jellyfin Home Screen Script
// Adds custom scrollable sections to the home screen
// Requires: cardBuilder.js module to be loaded before this script
(function() {
'use strict';
// Common logging function
const LOG = (...args) => console.log('[HomeScreen]', ...args);
const WARN = (...args) => console.warn('[HomeScreen]', ...args);
const ERR = (...args) => console.error('[HomeScreen]', ...args);
LOG('Script loaded - JS Injector mode');
// Custom home sections configuration
const customHomeSections = [
// Add home screen sections here with the following format:
/* {
name: 'Section Name',
id: 'playlistId', <- Make sure the playlist is Public
maxItems: 16, <- Maximum number of items to display on the home screen
order: 3 <- Order of the section on the home screen
}, */
];
// Processing flag to prevent parallel execution
let isProcessing = false;
/************ Helpers ************/
/**
* Fetches playlist data from Jellyfin API
* @param {string} playlistId - The playlist ID to fetch
* @returns {Promise<Object>} - Playlist data with ItemIds array
*/
async function fetchPlaylistData(playlistId) {
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
const url = `${serverUrl}/Playlists/${playlistId}`;
try {
const response = await fetch(url, {
headers: {
"Authorization": `MediaBrowser Token="${token}"`
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (err) {
ERR(`Failed to fetch playlist data for ${playlistId}:`, err);
return { ItemIds: [] };
}
}
/**
* Generates the view more URL for a playlist
* @param {string} playlistId - The playlist ID
* @returns {string} - The complete URL for the playlist page
*/
function generateViewMoreUrl(playlistId) {
const apiClient = window.ApiClient;
const serverId = apiClient.serverId();
return `/web/#/list.html?parentId=${playlistId}&serverId=${serverId}`;
}
/**
* Renders a custom home section
* @param {Object} section - Section configuration object
* @param {HTMLElement} container - Container to append the section to
* @returns {Promise<boolean>} - Success status
*/
async function renderCustomSection(section, container) {
try {
// Fetch playlist data
const playlistData = await fetchPlaylistData(section.id);
const itemIds = playlistData.ItemIds || [];
// Limit to maxItems
const limitedItemIds = itemIds.slice(0, section.maxItems);
if (limitedItemIds.length === 0) {
return false;
}
// Generate view more URL
const viewMoreUrl = generateViewMoreUrl(section.id);
// Check if cardBuilder is available
if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCardsFromIds) {
WARN("cardBuilder not available, skipping section:", section.name);
return false;
}
// Render the scrollable container
const scrollableContainer = await window.cardBuilder.renderCardsFromIds(
limitedItemIds,
section.name,
viewMoreUrl
);
// Add data attribute to track rendered sections
scrollableContainer.setAttribute('data-custom-section-id', section.id);
scrollableContainer.setAttribute('data-custom-section-name', section.name);
if (section.order) {
scrollableContainer.style.order = section.order;
}
// Append to container
container.appendChild(scrollableContainer);
return true;
} catch (err) {
ERR(`Error rendering section ${section.name}:`, err);
return false;
}
}
/**
* Renders all custom home sections
* @param {HTMLElement} container - Container to append sections to
*/
async function renderAllCustomSections(container) {
const results = await Promise.all(
customHomeSections.map(section => renderCustomSection(section, container))
);
const successCount = results.filter(Boolean).length;
LOG(`Rendered ${successCount}/${customHomeSections.length} custom sections`);
}
/************ Home Screen Observer ************/
/**
* Checks if custom sections are already rendered and renders them if not
*/
async function checkAndRenderCustomSections() {
// Check if already processing to prevent parallel execution
if (isProcessing) {
return;
}
// Try multiple selectors to find the container
const selectors = [
'.itemDetailPage:not(.hide) .homeSectionsContainer',
'.homeSectionsContainer',
'.homeSectionsContainer:not(.hide)',
'[class*="homeSections"]'
];
let homeSectionsContainer = null;
for (const selector of selectors) {
homeSectionsContainer = document.querySelector(selector);
if (homeSectionsContainer) {
break;
}
}
if (!homeSectionsContainer) {
return;
}
// Check if sections are already rendered
const hasRenderedSections = customHomeSections.some(section =>
homeSectionsContainer.querySelector(`[data-custom-section-id="${section.id}"]`)
);
if (hasRenderedSections) {
return;
}
// Set processing flag to prevent parallel execution
isProcessing = true;
try {
await renderAllCustomSections(homeSectionsContainer);
LOG('Custom sections rendered successfully');
} catch (error) {
ERR('Error rendering custom sections:', error);
} finally {
// Always reset the processing flag
isProcessing = false;
}
}
/**
* Sets up observer to watch for home sections container
*/
function setupHomeScreenObserver() {
// Check immediately in case the element already exists
checkAndRenderCustomSections();
// Set up MutationObserver to watch for home sections container
const observer = new MutationObserver((mutations) => {
let shouldCheck = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node contains home sections container
if (node.querySelector && node.querySelector('.homeSectionsContainer')) {
shouldCheck = true;
}
// Also check if the added node is the home sections container itself
if (node.classList && node.classList.contains('homeSectionsContainer')) {
shouldCheck = true;
}
// Check for any class containing "home" or "section"
if (node.classList) {
for (const className of node.classList) {
if (className.toLowerCase().includes('home') || className.toLowerCase().includes('section')) {
shouldCheck = true;
break;
}
}
}
}
});
}
// Also check on attribute changes (like class changes)
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target;
if (target.classList && (target.classList.contains('homeSectionsContainer') ||
Array.from(target.classList).some(cls => cls.toLowerCase().includes('home') || cls.toLowerCase().includes('section')))) {
shouldCheck = true;
}
}
});
if (shouldCheck) {
setTimeout(checkAndRenderCustomSections, 100); // Small delay to ensure DOM is ready
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
LOG('Home screen observer initialized');
}
// Initialize the home screen observer
setupHomeScreenObserver();
// Fallback: Check periodically in case mutation observer misses something
let checkInterval = null;
const startPeriodicCheck = () => {
if (checkInterval) return;
checkInterval = setInterval(() => {
// Skip if already processing
if (isProcessing) {
return;
}
const homeSectionsContainer = document.querySelector('.homeSectionsContainer');
if (homeSectionsContainer && !homeSectionsContainer.querySelector('[data-custom-section-id]')) {
checkAndRenderCustomSections();
}
}, 2000); // Check every 2 seconds
};
// Start periodic check after a short delay
setTimeout(startPeriodicCheck, 1000);
// Debug functions for troubleshooting (available in console)
window.debugHomeScreen = function() {
LOG('Manual debug trigger called');
checkAndRenderCustomSections();
};
window.debugCustomSections = function() {
LOG('Custom sections configuration:', customHomeSections);
LOG('Processing flag status:', isProcessing);
// Check multiple selectors
const selectors = [
'.itemDetailPage:not(.hide) .homeSectionsContainer',
'.homeSectionsContainer',
'.homeSectionsContainer:not(.hide)',
'[class*="homeSections"]'
];
selectors.forEach(selector => {
const element = document.querySelector(selector);
LOG(`Selector "${selector}":`, element ? 'FOUND' : 'NOT FOUND', element);
});
// Log all elements with "home" or "section" in class name
const allElements = document.querySelectorAll('*');
const relevantElements = Array.from(allElements).filter(el =>
el.className && typeof el.className === 'string' &&
(el.className.toLowerCase().includes('home') || el.className.toLowerCase().includes('section'))
);
LOG('Elements with "home" or "section" in class:', relevantElements.map(el => ({
tagName: el.tagName,
className: el.className,
id: el.id
})));
// Check if cardBuilder is available
LOG('cardBuilder available:', typeof window.cardBuilder !== 'undefined');
LOG('renderCardsFromIds available:', typeof window.cardBuilder?.renderCardsFromIds === 'function');
};
LOG('Home screen functionality initialized');
LOG('Debug functions available: window.debugHomeScreen(), window.debugCustomSections()');
})();
+301
View File
@@ -0,0 +1,301 @@
// KefinTweaks Script Injector
// Dynamically loads scripts and CSS files based on user configuration
// Usage: Modify the ENABLED_SCRIPTS array below to enable/disable features
(function() {
'use strict';
// Configuration: Set which scripts to load
// Set to true to enable, false to disable
const ENABLED_SCRIPTS = {
// Core functionality (required for other scripts)
cardBuilder: true, // Required by watchlist and homeScreen
// UI Enhancements
watchlist: true, // Watchlist functionality
homeScreen: true, // Custom home screen sections
search: true, // Enhanced search functionality
headerTabs: true, // Header tab enhancements
customMenu: true, // Custom menu functionality
exclusiveElsewhere: true, // Exclusive elsewhere branding
updoot: true, // Upvote functionality
// Utility scripts
backdropLeakFix: true, // Memory leak fixes
};
// Script definitions with dependencies and metadata
const SCRIPT_DEFINITIONS = [
{
name: 'cardBuilder',
script: 'cardBuilder.js',
css: null,
dependencies: [],
description: 'Core card building functionality (required by other scripts)',
required: true
},
{
name: 'watchlist',
script: 'watchlist.js',
css: 'watchlist.css',
dependencies: ['cardBuilder'],
description: 'Adds watchlist functionality throughout Jellyfin interface'
},
{
name: 'homeScreen',
script: 'homeScreen.js',
css: 'homeScreen.css',
dependencies: ['cardBuilder'],
description: 'Adds custom home screen sections'
},
{
name: 'search',
script: 'search.js',
css: 'search.css',
dependencies: [],
description: 'Enhanced search functionality'
},
{
name: 'headerTabs',
script: 'headerTabs.js',
css: null,
dependencies: [],
description: 'Header tab improvements'
},
{
name: 'customMenu',
script: 'custom-menu.js',
css: null,
dependencies: [],
description: 'Remove target="_blank" from custom menu links'
},
{
name: 'exclusiveElsewhere',
script: 'exclusiveElsewhere.js',
css: null,
dependencies: [],
description: 'Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality to add custom branding when items are not available on streaming services'
},
{
name: 'backdropLeakFix',
script: 'backdrop-leak-fix.js',
css: null,
dependencies: [],
description: 'Fixes issue that causes backdrop images to be continuously added to the page if the tab isn\'t focused.'
},
{
name: 'updoot',
script: 'updoot.js',
css: null,
dependencies: [],
description: 'Upvote functionality'
}
];
// Configuration validation
function validateConfiguration() {
const errors = [];
// Check if required scripts are enabled
SCRIPT_DEFINITIONS.forEach(script => {
if (script.required && !ENABLED_SCRIPTS[script.name]) {
errors.push(`Required script '${script.name}' is disabled`);
}
});
// Check dependencies
SCRIPT_DEFINITIONS.forEach(script => {
if (ENABLED_SCRIPTS[script.name]) {
script.dependencies.forEach(dep => {
if (!ENABLED_SCRIPTS[dep]) {
errors.push(`Script '${script.name}' requires '${dep}' to be enabled`);
}
});
}
});
if (errors.length > 0) {
console.error('[KefinTweaks Injector] Configuration errors:', errors);
return false;
}
return true;
}
// Get the root path for scripts (adjust this based on your setup)
function getScriptRoot() {
// Fallback: assume scripts are in the same directory
return 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/';
}
// Load a CSS file
function loadCSS(filename) {
return new Promise((resolve, reject) => {
// Check if CSS is already loaded
const existingLink = document.querySelector(`link[href*="${filename}"]`);
if (existingLink) {
console.log(`[KefinTweaks Injector] CSS already loaded: ${filename}`);
resolve();
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = `${getScriptRoot()}${filename}`;
link.onload = () => {
console.log(`[KefinTweaks Injector] CSS loaded: ${filename}`);
resolve();
};
link.onerror = () => {
console.warn(`[KefinTweaks Injector] Failed to load CSS: ${filename}`);
reject(new Error(`Failed to load CSS: ${filename}`));
};
document.head.appendChild(link);
});
}
// Load a JavaScript file
function loadScript(filename) {
return new Promise((resolve, reject) => {
// Check if script is already loaded
const existingScript = document.querySelector(`script[src*="${filename}"]`);
if (existingScript) {
console.log(`[KefinTweaks Injector] Script already loaded: ${filename}`);
resolve();
return;
}
const script = document.createElement('script');
script.src = `${getScriptRoot()}${filename}`;
script.async = true;
script.onload = () => {
console.log(`[KefinTweaks Injector] Script loaded: ${filename}`);
resolve();
};
script.onerror = () => {
console.error(`[KefinTweaks Injector] Failed to load script: ${filename}`);
reject(new Error(`Failed to load script: ${filename}`));
};
document.head.appendChild(script);
});
}
// Load a script and its dependencies
async function loadScriptWithDependencies(scriptDef) {
try {
// Load dependencies first
for (const depName of scriptDef.dependencies) {
const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName);
if (depScript && ENABLED_SCRIPTS[depName]) {
await loadScriptWithDependencies(depScript);
}
}
// Load CSS if specified
if (scriptDef.css) {
await loadCSS(scriptDef.css);
}
// Load the script
await loadScript(scriptDef.script);
console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`);
} catch (error) {
console.error(`[KefinTweaks Injector] Error loading ${scriptDef.name}:`, error);
throw error;
}
}
// Main initialization function
async function initialize() {
console.log('[KefinTweaks Injector] Starting initialization...');
// Validate configuration
if (!validateConfiguration()) {
console.error('[KefinTweaks Injector] Configuration validation failed. Aborting.');
return;
}
// Get enabled scripts in dependency order
const enabledScripts = SCRIPT_DEFINITIONS.filter(script => ENABLED_SCRIPTS[script.name]);
console.log(`[KefinTweaks Injector] Loading ${enabledScripts.length} scripts:`,
enabledScripts.map(s => s.name));
// Load all enabled scripts
const loadPromises = enabledScripts.map(script => loadScriptWithDependencies(script));
try {
await Promise.all(loadPromises);
console.log('[KefinTweaks Injector] All scripts loaded successfully!');
// Dispatch custom event when all scripts are loaded
const event = new CustomEvent('kefinTweaksLoaded', {
detail: {
loadedScripts: enabledScripts.map(s => s.name),
timestamp: new Date().toISOString()
}
});
document.dispatchEvent(event);
} catch (error) {
console.error('[KefinTweaks Injector] Error during initialization:', error);
}
}
// Utility functions for debugging and configuration
window.KefinTweaks = {
// Get current configuration
getConfig: () => ({ ...ENABLED_SCRIPTS }),
// Get script definitions
getScripts: () => [...SCRIPT_DEFINITIONS],
// Check if a script is loaded
isScriptLoaded: (scriptName) => {
const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
if (!scriptDef) return false;
const scriptElement = document.querySelector(`script[src*="${scriptDef.script}"]`);
return !!scriptElement;
},
// Reload all scripts (useful for development)
reload: () => {
console.log('[KefinTweaks Injector] Reloading all scripts...');
initialize();
},
// Load a specific script (useful for dynamic loading)
loadScript: async (scriptName) => {
const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
if (!scriptDef) {
throw new Error(`Script '${scriptName}' not found`);
}
if (!ENABLED_SCRIPTS[scriptName]) {
throw new Error(`Script '${scriptName}' is disabled in configuration`);
}
await loadScriptWithDependencies(scriptDef);
}
};
// Start initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
console.log('[KefinTweaks Injector] Injector script loaded. Available at window.KefinTweaks');
})();
+31
View File
@@ -0,0 +1,31 @@
.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;
}
}
+853
View File
@@ -0,0 +1,853 @@
// Jellyfin Smart Search - Dual Input + Persistent Toggle + Query Copy
(function() {
'use strict';
const LOG = (...args) => console.log('[SmartSearch]', ...args);
const WARN = (...args) => console.warn('[SmartSearch]', ...args);
const ERR = (...args) => console.error('[SmartSearch]', ...args);
// Configuration
const CONFIG = {
enableJellyseerr: false, // Toggle for Jellyseerr integration
};
LOG('script load start');
// Monitor network requests to see what's happening
const originalFetch = window.fetch;
const originalXHR = window.XMLHttpRequest;
// Track if we should block XHR requests (only for the initial URL query)
let shouldBlockXHR = false;
let hasBlockedInitialSearch = false;
// Override fetch to log our requests
window.fetch = function(...args) {
LOG('FETCH request:', args[0]);
return originalFetch.apply(this, args);
};
// Override XHR to log and potentially block default search requests
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) {
LOG('XHR request:', method, url);
// Check if this is a search request we want to block
// Our smart search uses fetch(), so any XHR search request should be blocked
const isSearchRequest = url.includes('/Items?') &&
(url.includes('searchTerm=') || url.includes('query='));
if (isSearchRequest && shouldBlockXHR && !hasBlockedInitialSearch) {
LOG('Blocking initial default search XHR request:', url);
hasBlockedInitialSearch = true;
// Disable blocking after this first request
shouldBlockXHR = false;
LOG('XHR blocking disabled after initial search');
// Override send to do nothing
xhr.send = function() {
LOG('Initial default search XHR blocked');
// Simulate a successful response
setTimeout(() => {
xhr.readyState = 4;
xhr.status = 200;
xhr.responseText = '{"Items":[],"TotalRecordCount":0}';
if (xhr.onreadystatechange) {
xhr.onreadystatechange();
}
}, 100);
};
}
return originalOpen.apply(this, [method, url, ...args]);
};
xhr.send = function(...args) {
LOG('XHR sending:', args);
return originalSend.apply(this, args);
};
return xhr;
};
// Immediate interception for URL queries - run this as early as possible
(function() {
// Parse query from hash since Jellyfin uses hash-based routing
let queryFromUrl = null;
if (window.location.hash.includes('search.html') && 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
}
}
LOG('Early interception - URL query:', queryFromUrl);
LOG('Current pathname:', window.location.pathname);
LOG('Current hash:', window.location.hash);
if (queryFromUrl && (window.location.pathname.includes('search.html') || window.location.hash.includes('search'))) {
LOG('URL query detected, setting up protection');
shouldBlockXHR = true; // Enable XHR blocking for initial URL query
// Just enable XHR blocking - don't touch the input
LOG('XHR blocking enabled for initial search');
}
})();
// config
const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person'];
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist'];
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 });
}
// 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: 12px 0 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-primary, .smart-search-secondary { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; }
.smart-search-primary { background:#00a4dc; color:#fff; }
.smart-search-secondary { background:#444; color:#fff; }
.smart-search-results { margin-top:12px; }
.smart-search-stats { color:#999; font-size:12px; margin-top:6px; display: none; }
#persistent-toggle-btn { display:none; position:relative; padding:8px 12px; background:#00a4dc; color:#fff; border:none; border-radius:4px; cursor:pointer; }
/* Smart search mode - hide search icon */
.smart-search-mode .searchfields-icon {
top: 37px;
position: absolute;
z-index: 1;
right: 0;
}
`;
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 baseHash = '#/search.html';
const params = new URLSearchParams();
if (searchTerm && searchTerm.trim()) {
params.set('query', searchTerm.trim());
}
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,
fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
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;
}
}
// Show/hide loading spinner
function setLoadingSpinner(show) {
const spinner = document.querySelector('.docspinner.mdl-spinner');
if (spinner) {
if (show) {
spinner.classList.add('mdlSpinnerActive');
LOG('Loading spinner shown');
} else {
spinner.classList.remove('mdlSpinnerActive');
LOG('Loading spinner hidden');
}
}
}
// Fetch Jellyseerr results
async function fetchJellyseerrResults(searchTerm) {
try {
const jellyseerrUrl = `${ApiClient.serverAddress()}/JellyfinEnhanced/jellyseerr/search?query=${encodeURIComponent(searchTerm)}`;
LOG('Fetching Jellyseerr results:', jellyseerrUrl);
const response = await ApiClient.fetch({
url: jellyseerrUrl,
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Jellyfin-User-Id': ApiClient.getCurrentUserId()
}
});
const data = await response.json();
LOG('Jellyseerr response:', data);
return data;
} catch (err) {
WARN('Jellyseerr fetch failed', err);
return { movies: [], tv: [] };
}
}
// Convert Jellyseerr item to our format
function convertJellyseerrItem(item, type) {
return {
Id: `jellyseerr_${item.id}`,
Name: item.title || item.name,
Type: type === 'movie' ? 'Movie' : 'Series',
MediaType: 'Video',
ImageTags: item.posterPath ? { Primary: item.posterPath } : null,
ProductionYear: item.releaseDate ? new Date(item.releaseDate).getFullYear() : null,
Overview: item.overview,
// Jellyseerr specific data
JellyseerrId: item.id,
JellyseerrType: type,
ExternalId: item.tmdbId || item.tvdbId,
IsJellyseerr: true
};
}
// perform smart search
async function performSmartSearch(searchTerm, searchType = 'core') {
LOG('performSmartSearch()', { searchTerm, searchType });
const results = { groupedItems: {}, total: 0 };
// Hide search suggestions
const searchSuggestions = document.querySelector('.searchSuggestions');
if (searchSuggestions) {
searchSuggestions.style.display = 'none';
}
// Show loading spinner
setLoadingSpinner(true);
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&fields=MediaSourceCount&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: [] };
});
});
// Add Jellyseerr search for core types (if enabled)
if (CONFIG.enableJellyseerr && (searchType === 'core' || searchType === 'all')) {
promises.push(fetchJellyseerrResults(searchTerm));
} else {
promises.push(Promise.resolve({ movies: [], tv: [] }));
}
const start = performance.now();
const responses = await Promise.all(promises);
const end = performance.now();
// Group results by type
responses.forEach((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: filter to show only those with images
filteredItems = r.Items.filter(item => item.ImageTags?.Primary);
}
results.groupedItems[type] = filteredItems;
results.total += filteredItems.length;
}
} else {
// Jellyseerr results
if (r && (r.movies || r.tv)) {
// Process Jellyseerr movies
if (r.movies && r.movies.length > 0) {
const jellyseerrMovies = r.movies.map(item => convertJellyseerrItem(item, 'movie'));
if (results.groupedItems['Movie']) {
results.groupedItems['Movie'] = [...results.groupedItems['Movie'], ...jellyseerrMovies];
} else {
results.groupedItems['Movie'] = jellyseerrMovies;
}
results.total += jellyseerrMovies.length;
}
// Process Jellyseerr TV shows
if (r.tv && r.tv.length > 0) {
const jellyseerrTv = r.tv.map(item => convertJellyseerrItem(item, 'tv'));
if (results.groupedItems['Series']) {
results.groupedItems['Series'] = [...results.groupedItems['Series'], ...jellyseerrTv];
} else {
results.groupedItems['Series'] = jellyseerrTv;
}
results.total += jellyseerrTv.length;
}
}
}
});
displaySmartResults(results, Math.round(end - start));
// Cache the results
cacheResults(searchTerm, searchType, results);
// Update URL with search query and type
const urlType = searchType === 'core' ? 'videos' : searchType;
updateSearchUrl(searchTerm, urlType);
// Hide loading spinner
setLoadingSpinner(false);
return results;
} catch (e) {
ERR('performSmartSearch error', e);
// Hide loading spinner on error too
setLoadingSpinner(false);
return results;
}
}
function createSmartItemElement(item) {
const serverId = ApiClient.serverId();
const div = document.createElement('div');
div.className = 'smart-search-item';
const img = document.createElement('img');
img.src = item.ImageTags?.Primary
? `${ApiClient.serverAddress()}/Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}&maxWidth=300`
: '/web/assets/img/icon-transparent.png';
img.alt = item.Name || 'Unknown';
img.onerror = () => { img.src = '/web/assets/img/icon-transparent.png'; };
const h = document.createElement('h3');
h.textContent = item.Name || 'Unknown';
h.title = h.textContent;
const p = document.createElement('p');
p.textContent = `${item.Type}${item.ProductionYear ? ` (${item.ProductionYear})` : ''}`;
p.title = p.textContent;
div.appendChild(img);
div.appendChild(h);
div.appendChild(p);
div.addEventListener('click', () => {
LOG('item click', { id: item.Id, name: item.Name });
try { Dashboard.navigate(`#!/details?id=${item.Id}&serverId=${serverId}`); } catch(e){ ERR(e); }
});
return div;
}
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';
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, index) => {
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, index);
frag.appendChild(section);
totalShown += items.length;
}
});
resultsContainer.appendChild(frag);
stats.textContent = `Search completed in ${ms}ms - ${results.total} results found`;
}
// main init
function initDualSearch() {
if (typeof ApiClient === 'undefined') { setTimeout(initDualSearch, 500); return; }
const originalInput = document.getElementById('searchTextInput');
if (!originalInput) { setTimeout(initDualSearch, 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.html') && 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
}
}
LOG('initDualSearch - URL query:', queryFromUrl);
LOG('initDualSearch - originalInput exists:', !!originalInput);
if (originalInput) {
LOG('initDualSearch - originalInput value:', originalInput.value);
}
if (queryFromUrl) {
LOG('initDualSearch - URL query detected, leaving input alone');
}
// create Smart search UI if not exist
let wrapper = document.getElementById('smart-search-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.id = 'smart-search-wrapper';
wrapper.className = 'smart-search-wrapper';
const input = document.createElement('input');
input.id = 'smart-search-input';
input.className = 'smart-search-input emby-input searchfields-txtSearch';
input.type = 'text';
input.placeholder = 'Smart Search...';
const btnRow = document.createElement('div');
btnRow.className = 'smart-search-buttons';
const btnAll = document.createElement('button');
btnAll.id = 'smart-search-all';
btnAll.className = 'smart-search-secondary emby-button';
btnAll.textContent = 'All';
const btnCore = document.createElement('button');
btnCore.id = 'smart-search-core';
btnCore.className = 'smart-search-primary emby-button';
btnCore.textContent = 'Movies/TV';
const btnMusic = document.createElement('button');
btnMusic.id = 'smart-search-music';
btnMusic.className = 'smart-search-secondary emby-button';
btnMusic.textContent = 'Music';
const btnBooks = document.createElement('button');
btnBooks.id = 'smart-search-books';
btnBooks.className = 'smart-search-secondary emby-button';
btnBooks.textContent = 'Books';
btnRow.appendChild(btnAll);
btnRow.appendChild(btnCore);
btnRow.appendChild(btnMusic);
btnRow.appendChild(btnBooks);
const stats = document.createElement('div');
stats.id = 'smart-search-stats';
stats.className = 'smart-search-stats';
wrapper.appendChild(input);
wrapper.appendChild(btnRow);
wrapper.appendChild(stats);
originalInput.parentElement.insertBefore(wrapper, originalInput);
}
// Smart results container will be created lazily when needed
const smartInput = document.getElementById('smart-search-input');
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');
// Track current search type
let currentSearchType = 'videos';
// Function to update button states
function updateButtonStates(activeType) {
// Remove active class from all buttons
smartAllBtn.classList.remove('smart-search-primary');
smartAllBtn.classList.add('smart-search-secondary');
smartCoreBtn.classList.remove('smart-search-primary');
smartCoreBtn.classList.add('smart-search-secondary');
smartMusicBtn.classList.remove('smart-search-primary');
smartMusicBtn.classList.add('smart-search-secondary');
smartBooksBtn.classList.remove('smart-search-primary');
smartBooksBtn.classList.add('smart-search-secondary');
// Add active class to the selected button
if (activeType === 'all') {
smartAllBtn.classList.remove('smart-search-secondary');
smartAllBtn.classList.add('smart-search-primary');
} else if (activeType === 'videos') {
smartCoreBtn.classList.remove('smart-search-secondary');
smartCoreBtn.classList.add('smart-search-primary');
} else if (activeType === 'music') {
smartMusicBtn.classList.remove('smart-search-secondary');
smartMusicBtn.classList.add('smart-search-primary');
} else if (activeType === 'books') {
smartBooksBtn.classList.remove('smart-search-secondary');
smartBooksBtn.classList.add('smart-search-primary');
}
}
// 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 });
// 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='';
setLoadingSpinner(false); // Hide spinner when clearing results
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'));
// 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 = '';
originalInput.style.display = 'none';
// Hide all search results containers
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
searchResults.forEach(sr => sr.style.display = 'none');
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = '';
toggleBtn.textContent='Switch to Default Search';
// Keep XHR blocking enabled for smart mode
shouldBlockXHR = true;
// Add smart-search-mode class to body for CSS targeting
document.body.classList.add('smart-search-mode');
} else {
wrapper.style.display = 'none';
originalInput.style.display = '';
// Show all search results containers
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
searchResults.forEach(sr => sr.style.display = '');
const resultsContainer = ensureSmartResultsContainer();
resultsContainer.style.display = 'none';
toggleBtn.textContent='Switch to Smart Search';
// Disable XHR blocking for default search mode
shouldBlockXHR = false;
LOG('Disabled XHR blocking for default search mode');
// Remove smart-search-mode class from body
document.body.classList.remove('smart-search-mode');
// Input is left alone - let it keep its value
}
};
toggleBtn.addEventListener('click', ()=>setMode(!smartMode));
// initial mode
setMode(true);
}
function init() {
LOG('init() called');
LOG('Pathname:', window.location.pathname);
LOG('Hash:', window.location.hash);
if (window.location.pathname.includes('search.html') || window.location.hash.includes('search')) {
LOG('Search page detected');
// Check for URL query parameter and prevent default search early
// Parse query from hash since Jellyfin uses hash-based routing
let queryFromUrl = null;
if (window.location.hash.includes('search.html') && 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
}
}
LOG('URL query in init:', queryFromUrl);
if (queryFromUrl) {
LOG('Query detected in init, enabling XHR blocking');
shouldBlockXHR = true;
// Also try to prevent search form submission
const searchForm = document.querySelector('form[action*="search"]');
LOG('Search form found:', !!searchForm);
if (searchForm) {
searchForm.addEventListener('submit', (e) => {
LOG('Search form submission prevented');
e.preventDefault();
e.stopPropagation();
return false;
});
}
// Try to hide search results containers immediately
const hideSearchResults = () => {
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
LOG('Found search results containers:', searchResults.length);
searchResults.forEach(sr => {
sr.style.display = 'none';
LOG('Hidden search result container');
});
};
// Hide immediately and on DOM changes
hideSearchResults();
document.addEventListener('DOMContentLoaded', hideSearchResults);
// Use mutation observer to hide any new search results
const hideObserver = new MutationObserver(() => {
hideSearchResults();
});
hideObserver.observe(document.body, { childList: true, subtree: true });
// Stop observing after 10 seconds
setTimeout(() => {
LOG('Stopping hide observer after 10 seconds');
hideObserver.disconnect();
}, 10000);
}
LOG('Calling initDualSearch');
initDualSearch();
}
}
// Mutation observer to detect when search page content is loaded dynamically
function setupMutationObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check if search input appears in the DOM
const searchInput = document.getElementById('searchTextInput');
if (searchInput && !document.getElementById('smart-search-wrapper')) {
LOG('Search input detected via mutation observer');
initDualSearch();
}
}
});
});
// Observe the entire document for changes
observer.observe(document.body, {
childList: true,
subtree: true
});
LOG('Mutation observer setup complete');
}
if (document.readyState==='loading') {
document.addEventListener('DOMContentLoaded', () => {
init();
setupMutationObserver();
});
} else {
init();
setupMutationObserver();
}
LOG('script load end');
})();
+3
View File
@@ -0,0 +1,3 @@
.watchlist-icon[data-active='true'] .detailButton-icon:before {
font-variation-settings: 'FILL' 1,'wght' 400,'GRAD' 0,'opsz' 20;
}
+20 -9
View File
@@ -56,13 +56,26 @@ async function renderCards(containerSelector, type) {
return { type, itemCount: 0 };
}
// Show section and create proper Jellyfin section structure
// Show section and use cardBuilder to create scrollable container
container.style.display = '';
const sectionHtml = createWatchlistSection(type, items);
container.innerHTML = sectionHtml;
// Check if cardBuilder is available
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.renderCards) {
const scrollableContainer = window.cardBuilder.renderCards(items, getTypeDisplayName(type));
container.innerHTML = '';
container.appendChild(scrollableContainer);
LOG(`Rendered ${items.length} ${type} items using cardBuilder`);
} else {
WARN("cardBuilder not available, using fallback");
// Fallback to old method if cardBuilder not available
const sectionHtml = createWatchlistSection(type, items);
container.innerHTML = sectionHtml;
}
return { type, itemCount: items.length };
}
// Fallback function for when cardBuilder is not available
function createWatchlistSection(type, items) {
// This function assumes items array is not empty (checked at higher level)
@@ -134,8 +147,6 @@ function createWatchlistSection(type, items) {
items.forEach((item, index) => {
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) {
const card = window.cardBuilder.buildCard(item);
card.setAttribute('data-index', index);
card.classList.add('discover-card');
itemsContainer.appendChild(card);
} else {
// Fallback to HTML string
@@ -227,9 +238,9 @@ function getTypeDisplayName(itemType) {
async function renderWatchlistContent() {
try {
const results = await Promise.all([
renderCards(".sections.watchlist > .watchlist-movies", "Movie"),
renderCards(".sections.watchlist > .watchlist-series", "Series"),
renderCards(".sections.watchlist > .watchlist-episodes", "Episode")
renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-movies", "Movie"),
renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-series", "Series"),
renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-episodes", "Episode")
]);
// Check if all sections are empty
@@ -249,7 +260,7 @@ async function renderWatchlistContent() {
// Function to check if watchlist section exists and render content
function checkAndRenderWatchlist() {
const watchlistSection = document.querySelector('.sections.watchlist');
const watchlistSection = document.querySelector('#indexPage:not(.hide) .sections.watchlist');
if (watchlistSection && !watchlistSection.dataset.watchlistRendered) {
watchlistSection.dataset.watchlistRendered = 'true';
renderWatchlistContent();