From 8f42625d84d40fab411aaf55612c0961acb260a1 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Thu, 1 Oct 2026 07:36:15 -0400 Subject: [PATCH] Fix breadcrumb styles and mount location --- scripts/breadcrumbNav.css | 11 +- scripts/breadcrumbs.js | 228 +++++++++++++++++++++++--------------- 2 files changed, 142 insertions(+), 97 deletions(-) diff --git a/scripts/breadcrumbNav.css b/scripts/breadcrumbNav.css index 3730c02..c056441 100644 --- a/scripts/breadcrumbNav.css +++ b/scripts/breadcrumbNav.css @@ -3,12 +3,6 @@ display: none; } -.MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) { - display: grid; - grid-template-columns: auto 1fr auto; - grid-template-rows: 1fr auto; -} - @media (min-width: 768px) { .kefinTweaks-breadcrumbs-wrapper { display: block; @@ -146,3 +140,8 @@ max-width: initial !important; white-space: nowrap; } +.MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: 1fr auto; +} diff --git a/scripts/breadcrumbs.js b/scripts/breadcrumbs.js index 9800e46..2fc3bc4 100644 --- a/scripts/breadcrumbs.js +++ b/scripts/breadcrumbs.js @@ -3,7 +3,7 @@ // Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum, Audio // Requires: utils.js module to be loaded before this script -(function() { +(function () { 'use strict'; console.log('[KefinTweaks Breadcrumbs] Initializing...'); @@ -72,12 +72,12 @@ async function getTargetBreadcrumbStructure(item) { const structure = { elements: [], - item: item + item: item, }; - + // Don't append .html for server versions 10.11 and above - const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html' - + 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 @@ -85,13 +85,13 @@ 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'); + + 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) { @@ -99,21 +99,20 @@ } else { queryParams = `?serverId=${ApiClient.serverId()}`; } - } - else if (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 } + { 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 } + { text: 'All Seasons', url: null, clickable: true, popover: true }, ]; } else if (item.Type === 'Season') { // Get series details for the series name @@ -122,8 +121,12 @@ 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 } + { + 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 @@ -132,26 +135,36 @@ // Get season details for the season name const seasonDetails = await getItemDetails(item.ParentId); - const seasonName = seasonDetails ? (seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`) : `Season ${item.ParentIndexNumber}`; + 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: 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 } + { 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 } + { 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.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 } + { text: 'All Songs', url: null, clickable: true, popover: true }, ]; } else if (item.Type === 'Audio') { // Get album details for the album name and artist info @@ -162,9 +175,13 @@ 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: 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 } + { text: item.Name, url: null, clickable: false }, ]; } @@ -176,8 +193,8 @@ 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') + const existingElements = Array.from(breadcrumbContainer.children).filter((child) => + child.classList.contains('kefinTweaks-breadcrumb-element'), ); log('Comparing breadcrumbs - existing:', existingElements.length, 'target:', target.elements.length); @@ -200,14 +217,14 @@ 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' - ); + 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 }); @@ -228,8 +245,8 @@ 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') + const existingElements = Array.from(breadcrumbContainer.children).filter((child) => + child.classList.contains('kefinTweaks-breadcrumb-element'), ); // Process differences in reverse order to maintain indices @@ -256,7 +273,6 @@ } log('Added new breadcrumb element:', diff.element.text); - } else if (diff.action === 'remove') { // Remove element and its separator const elementToRemove = existingElements[diff.index]; @@ -268,7 +284,6 @@ breadcrumbContainer.removeChild(elementToRemove); log('Removed breadcrumb element at index:', diff.index); } - } else if (diff.action === 'update') { // Update existing element const elementToUpdate = existingElements[diff.index]; @@ -386,7 +401,7 @@ window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`; }); } else { - const currentSeason = seasons.find(season => season.Id === item.ParentId); + const currentSeason = seasons.find((season) => season.Id === item.ParentId); element.addEventListener('click', (e) => { e.stopPropagation(); const popover = createPopover(seasons, currentSeason, (selectedSeason) => { @@ -401,14 +416,12 @@ let albumArtists = item.AlbumArtists; if (!albumArtists || !Array.isArray(albumArtists) || albumArtists.length === 0) { const albumDetails = await getItemDetails(item.Id); - albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : []; + 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); - + + 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()}`; @@ -425,12 +438,13 @@ } 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); - + 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()}`; @@ -475,28 +489,26 @@ 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'); + error(isModern ? 'Could not find .MuiToolbar-root for breadcrumbs' : 'Could not find .skinHeader .headerLeft element'); return null; } @@ -531,16 +543,18 @@ wrapper.appendChild(popoverContainer); mount.appendChild(wrapper); - log(isModern - ? 'Created breadcrumb container inside .MuiToolbar-root' - : 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft'); + 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 = () => { @@ -591,7 +605,7 @@ } closePopover(); } - + function closePopover() { if (activePopover) { activePopover.remove(); @@ -608,7 +622,7 @@ let selectedItemElement = null; - items.forEach(item => { + items.forEach((item) => { const itemElement = document.createElement('div'); itemElement.className = 'kefinTweaks-popover-item detailsGroupItem'; @@ -643,7 +657,7 @@ selectedItemElement.scrollIntoView({ behavior: 'instant', block: 'nearest', - inline: 'nearest' + inline: 'nearest', }); log('Auto-scrolled to selected item:', selectedItemElement.textContent); }, 10); @@ -714,14 +728,14 @@ 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; @@ -758,7 +772,7 @@ StartIndex: 0, CollapseBoxSetItems: false, AlbumArtistIds: artistId, - SortBy: 'PremiereDate,ProductionYear,Sortname' + SortBy: 'PremiereDate,ProductionYear,Sortname', }); const response = await window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { @@ -785,7 +799,7 @@ StartIndex: 0, CollapseBoxSetItems: false, AlbumArtistIds: idsParam, - SortBy: 'PremiereDate,ProductionYear,Sortname' + SortBy: 'PremiereDate,ProductionYear,Sortname', }); const response = await window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { @@ -818,7 +832,7 @@ Fields: 'ParentId', Limit: 100, StartIndex: 0, - SortBy: 'IndexNumber,SortName' + SortBy: 'IndexNumber,SortName', }); const response = await window.apiHelper.getQuery(url, { useCache: true }); if (!response.data) { @@ -841,6 +855,26 @@ 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) { @@ -856,7 +890,7 @@ hideBreadcrumbs(); return; } - + // Check if the user is logged in if (!ApiClient._loggedIn) { clearBreadcrumbs(); @@ -880,7 +914,12 @@ // Determine target breadcrumb structure const targetStructure = await getTargetBreadcrumbStructure(item); - log('Target breadcrumb structure:', targetStructure.elements.map(e => e.text)); + 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) { @@ -893,13 +932,16 @@ } // Update state - currentBreadcrumbs = targetStructure; + // 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(); @@ -936,11 +978,15 @@ breadcrumbContainer.innerHTML = ''; // Append all elements at once - elements.forEach(el => breadcrumbContainer.appendChild(el)); + 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) { @@ -961,18 +1007,19 @@ log('Registering breadcrumb handler with KefinTweaksUtils'); // Register handler for all pages (breadcrumbs can appear on any detail page) - window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => { - try { - // Await the item promise to get the actual item data - const item = await itemPromise; - // Run our custom code - handlePageChange(item); - } catch (err) { - error('Breadcrumb page change handler failed:', err); - } - }, { - pages: [] // Empty array means all pages - }); + 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'); } @@ -981,7 +1028,7 @@ window.addEventListener('resize', handleResize); // Initial check - handlePageChange(); + enqueuePageChange(null); log('Breadcrumbs initialized successfully'); } @@ -992,5 +1039,4 @@ } else { initialize(); } - })();