// 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 // Use apiHelper.getQuery rather than getAncestorItems so we can use the cached data const ancestorsUrl = `${ApiClient.serverAddress()}/Items/${item.Id}/Ancestors`; const queryResult = await window.apiHelper.getQuery(ancestorsUrl, { useCache: true }); let ancestors = queryResult.data; if (!ancestors || !Array.isArray(ancestors)) { ancestors = await queryResult.dataPromise; } const collectionFolder = ancestors.find((ancestor) => ancestor.Type === 'CollectionFolder'); const userRootFolder = ancestors.find((ancestor) => ancestor.Type === 'UserRootFolder'); if (collectionFolder) { queryParams = `?topParentId=${collectionFolder.Id}&serverId=${ApiClient.serverId()}`; } else if (userRootFolder) { 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 isModernUI() { if (ApiClient._appVersion && ApiClient._appVersion.startsWith('12') && localStorage.getItem('layout')?.length === 0) { return true; } return !!document.querySelector('main.MuiBox-root'); } function resolveBreadcrumbMount() { if (isModernUI()) { return document.querySelector('.MuiToolbar-root'); } return document.querySelector('.skinHeader .headerLeft'); } function createBreadcrumbContainer() { if (breadcrumbContainer) { return breadcrumbContainer; } const isModern = isModernUI(); const mount = resolveBreadcrumbMount(); if (!mount) { error(isModern ? 'Could not find .MuiToolbar-root for breadcrumbs' : 'Could not find .skinHeader .headerLeft element'); return null; } // Create main wrapper const wrapper = document.createElement('div'); wrapper.id = 'kefinTweaks-breadcrumbs-wrapper'; wrapper.className = 'kefinTweaks-breadcrumbs-wrapper'; if (isModern) { wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern'); } wrapper.style.cssText = ` display: none; `; // 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: 100%; left: 0; z-index: 1001; `; wrapper.appendChild(breadcrumbContainer); wrapper.appendChild(popoverContainer); mount.appendChild(wrapper); log( isModern ? 'Created breadcrumb container inside .MuiToolbar-root' : 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft', ); return breadcrumbContainer; } function ensureBreadcrumbContainer(retries = 20) { const existing = createBreadcrumbContainer(); if (existing) return Promise.resolve(existing); return new Promise((resolve) => { let attempts = 0; const tryMount = () => { const container = createBreadcrumbContainer(); if (container) { resolve(container); return; } attempts += 1; if (attempts >= retries) { resolve(null); return; } setTimeout(tryMount, 100); }; tryMount(); }); } function showBreadcrumbs() { const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper'); const wideScreen = isWideScreen(); // 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 itemDetailsGroup'; 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 detailsGroupItem'; 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 const triggerRect = triggerElement.getBoundingClientRect(); const popoverContainerRect = popoverContainer.getBoundingClientRect(); const leftOffset = triggerRect.left - popoverContainerRect.left - 22; popover.style.left = `${leftOffset}px`; // Decide whether to open above or below based on viewport space // When breadcrumbs are at the bottom of the page, open above const viewportHeight = window.innerHeight; const spaceBelow = viewportHeight - triggerRect.bottom; const spaceAbove = triggerRect.top; const showAbove = spaceBelow < spaceAbove; popoverContainer.classList.toggle('kefinTweaks-popover-container--above', showAbove); popover.classList.toggle('kefinTweaks-popover--above', showAbove); // Clear inline top when above so CSS bottom: 100% takes effect; restore when below if (showAbove) { popoverContainer.style.top = ''; popoverContainer.style.bottom = '100%'; } else { popoverContainer.style.top = '100%'; popoverContainer.style.bottom = ''; } popoverContainer.appendChild(popover); log('Popover added to popover container with left offset:', leftOffset, 'showAbove:', showAbove); } else { log('Popover container not found, falling back to trigger element'); triggerElement.style.position = 'relative'; 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 window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { response.data = await response.dataPromise; } const item = response.data; log('Retrieved item details:', item.Name, item.Type); return item; } catch (err) { console.error('Failed to get item details:', err); return null; } } async function getSeasons(seriesId) { try { const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`); const response = await window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { response.data = await response.dataPromise; } const seasons = response.data.Items || response.data || []; log('Retrieved seasons:', seasons.length); return seasons; } catch (err) { error('Failed to get seasons:', err); return []; } } 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 window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { response.data = await response.dataPromise; } const albums = response.data.Items || response.data || []; log('Retrieved albums:', albums.length); return albums; } catch (err) { error('Failed to get albums:', err); return []; } } 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 window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { response.data = await response.dataPromise; } const items = response.data.Items || response.data || []; // Deduplicate by Id when multiple artists overlap const unique = []; const seen = new Set(); 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 window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { response.data = await response.dataPromise; } const songs = response.data.Items || response.data || []; log('Retrieved songs:', songs.length); return songs; } catch (err) { error('Failed to get songs:', err); return []; } } function createSeparator() { const separator = document.createElement('span'); separator.className = 'kefinTweaks-breadcrumb-separator'; separator.textContent = ' / '; separator.style.margin = '0 8px'; return separator; } // Serialize page-change work so overlapping view callbacks cannot race the DOM. // Latest-wins pending: overwrite the single pending slot; drain runs one at a time. let pageChangePending = undefined; // undefined = empty; null/item = pending job let pageChangeDrain = Promise.resolve(); function enqueuePageChange(item = null) { pageChangePending = item; pageChangeDrain = pageChangeDrain .then(async () => { if (pageChangePending === undefined) return; const next = pageChangePending; pageChangePending = undefined; await handlePageChange(next); }) .catch((err) => { error('Breadcrumb page change queue failed:', err); }); return pageChangeDrain; } // Page change handler // Simplified page change handler async function handlePageChange(item = null) { 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 || (item.Type !== 'Series' && item.Type !== 'Season' && item.Type !== 'Episode')) { 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), ); const breadcrumbContainer = getBreadcrumbContainer(); // 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 // Update the currentBreadcrumbs from the current page if (breadcrumbContainer && breadcrumbContainer.children) { currentBreadcrumbs = Array.from(breadcrumbContainer.children); } //currentBreadcrumbs = targetStructure; currentItemId = item.Id; log('About to show breadcrumbs...'); showBreadcrumbs(); log('Breadcrumbs updated successfully'); } catch (err) { error('Error handling page change:', err); clearBreadcrumbs(); hideBreadcrumbs(); } } // Create breadcrumbs from scratch async function createBreadcrumbsFromScratch(targetStructure) { // Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready) if (!breadcrumbContainer) { breadcrumbContainer = await ensureBreadcrumbContainer(); } if (!breadcrumbContainer) { error('Breadcrumb container unavailable'); return; } // 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'); } function getBreadcrumbContainer() { return document.getElementById('kefinTweaks-breadcrumbs'); } // 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 { const item = await itemPromise; await enqueuePageChange(item); } catch (err) { error('Breadcrumb page change handler failed:', err); } }, { pages: [], // Empty array means all pages }, ); } else { error('KefinTweaksUtils not available, breadcrumbs may not work correctly'); } // Add resize listener window.addEventListener('resize', handleResize); // Initial check enqueuePageChange(null); log('Breadcrumbs initialized successfully'); } // Start initialization when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } })();