diff --git a/injector.js b/injector.js index 246c543..7b4f972 100644 --- a/injector.js +++ b/injector.js @@ -5,12 +5,54 @@ (function() { 'use strict'; - console.log('[KefinTweaks Injector] Initializing...'); - const versionNumber = "0.3.3"; - + console.log('[KefinTweaks Injector] Initializing...'); // Cache for resolved root URL (to avoid multiple API calls) let resolvedRootCache = null; + /** + * Extracts version string from root URL + * @param {string} root - The root URL (e.g., "https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@v0.3.3/") + * @returns {string} Version string (e.g., "0.3.3", "latest", "development", or commit hash) + */ + function extractVersionFromRoot(root) { + if (!root) { + return "Pending Install"; // Fallback + } + + // Match version patterns: @v0.3.3, @latest, @main, or @commitHash + const versionMatch = root.match(/@(v?[\d.]+|latest|main|[a-f0-9]{7,})/i); + + if (versionMatch) { + const version = versionMatch[1]; + + // Handle version tags (v0.3.3 -> 0.3.3) + if (version.startsWith('v') && /^\d+\.\d+\.\d+/.test(version.substring(1))) { + return version.substring(1); // Remove 'v' prefix + } + + // Handle @main -> "development" + if (version.toLowerCase() === 'main') { + return 'development'; + } + + // Handle @latest -> "latest" + if (version.toLowerCase() === 'latest') { + return 'latest'; + } + + // Handle commit hash -> show as "development" or short hash + if (/^[a-f0-9]{7,}$/i.test(version)) { + return `development (${version.substring(0, 7)})`; + } + + // Return as-is for other patterns + return "version"; + } + + // No version found in URL, this is a custom install + return "Custom Install"; + } + // Configuration: Start with defaults, then merge user config // This allows new scripts to work out of the box without requiring config updates const ENABLED_SCRIPTS = { @@ -30,6 +72,7 @@ playlist: true, // Playlist view page modifications itemDetailsCollections: true, // Add related collections to item details pages flattenSingleSeasonShows: true, // Flatten series with only 1 season to show episodes directly + seriesInfo: true, // Add series and season information to details pages collections: true, // Collection sorting functionality skinManager: true, // Skin selection and management @@ -220,10 +263,17 @@ }, { name: 'flattenSingleSeasonShows', - script: 'flattenSingleSeasonShows.js', + script: 'seriesEpisodes.js', css: null, dependencies: ['cardBuilder', 'utils'], - description: 'Flattens series with only 1 season to show episodes directly on the series details page' + description: 'Displays episodes directly on series page with season selection. Works for both single and multi-season shows when enabled.' + }, + { + name: 'seriesInfo', + script: 'seriesInfo.js', + css: null, + dependencies: ['utils'], + description: 'Adds series and season information (seasons count, episodes count, end time) to details pages' }, { name: 'collections', @@ -239,21 +289,43 @@ //const urlSuffix = `?v=${new Date().getTime()}`; // Inject dashboard drawer version badge CSS using dynamic version number - function injectVersionBadgeCSS() { + async function injectVersionBadgeCSS() { const styleId = 'kefinTweaks-version-css'; if (document.getElementById(styleId)) return; + + // Get version from root URL in config + let displayVersion = ""; + try { + const configRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; + if (configRoot) { + // Resolve the root to get the actual version (if @latest or @main) + const resolvedRoot = await resolveRootVersion(configRoot); + displayVersion = extractVersionFromRoot(resolvedRoot); + } + } catch (error) { + console.warn('[KefinTweaks Injector] Could not extract version, using fallback:', error); + } + + // Format version for display (add 'v' prefix if it's a version number, not for 'latest' or 'development') + let versionDisplay = displayVersion; + if (!displayVersion.startsWith('v') && + !displayVersion.includes('latest') && + !displayVersion.includes('development') && + /^\d+\.\d+/.test(displayVersion)) { + versionDisplay = 'v' + displayVersion; + } + const style = document.createElement('style'); style.id = styleId; style.textContent = ` body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft::after { - content: 'KefinTweaks v${versionNumber}'; + content: 'KefinTweaks ${versionDisplay}'; font-style: italic; text-align: center; margin-top: 10px; color: #b7b7b7; font-size: 0.9em; display: block; - cursor: help; } `; document.head.appendChild(style); @@ -550,7 +622,7 @@ // Main initialization function async function initialize() { - console.log(`[KefinTweaks Injector] Starting KefinTweaks v${versionNumber} initialization...`); + console.log(`[KefinTweaks Injector] Starting KefinTweaks initialization...`); const scriptRoot = await getScriptRoot(); if (!scriptRoot) { @@ -934,17 +1006,19 @@ // Start initialization when DOM is ready if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - injectVersionBadgeCSS(); + document.addEventListener('DOMContentLoaded', async () => { + await injectVersionBadgeCSS(); initialize(); // Run startup task after a short delay to ensure ApiClient is ready setTimeout(startupTask, 1000); }); } else { - injectVersionBadgeCSS(); - initialize(); - // Run startup task after a short delay to ensure ApiClient is ready - setTimeout(startupTask, 1000); + (async () => { + await injectVersionBadgeCSS(); + initialize(); + // Run startup task after a short delay to ensure ApiClient is ready + setTimeout(startupTask, 1000); + })(); } console.log('[KefinTweaks Injector] Injector script loaded. Available at window.KefinTweaks'); diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js index 23d65e1..b49c12e 100644 --- a/kefinTweaks-default-config.js +++ b/kefinTweaks-default-config.js @@ -391,6 +391,9 @@ window.KefinTweaksDefaultConfig = { "search": { "enableJellyseerr": false }, + "flattenSingleSeasonShows": { + "hideSingleSeasonContainer": false + }, "skins": [], "defaultSkin": null, "themes": [], diff --git a/kefinTweaks.minimal.js b/kefinTweaks.minimal.js index 93bcec8..53dc9f3 100644 --- a/kefinTweaks.minimal.js +++ b/kefinTweaks.minimal.js @@ -393,6 +393,9 @@ window.KefinTweaksConfig = { "search": { "enableJellyseerr": false }, + "flattenSingleSeasonShows": { + "showEpisodesOnSeriesPage": false + }, "skins": [], "defaultSkin": null, "themes": [], diff --git a/scripts/configuration.js b/scripts/configuration.js index 16cc33c..7786147 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -437,6 +437,7 @@ const homeScreen = config.homeScreen || {}; const exclusiveElsewhere = config.exclusiveElsewhere || {}; const search = config.search || {}; + const flattenSingleSeasonShows = config.flattenSingleSeasonShows || {}; const customMenuLinks = config.customMenuLinks || []; // Use merged skin config from skinManager (includes both defaults and admin skins) const skins = window.KefinTweaksSkinConfig || []; @@ -509,6 +510,16 @@ ${buildSearchConfig(search)} +
+
+
+

Episodes On Series Page Configuration

+
Configure episodes display on series pages
+
+
+ ${buildFlattenShowsConfig(flattenSingleSeasonShows)} +
+
@@ -953,7 +964,8 @@ { key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' }, { key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' }, { key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' }, - { key: 'flattenSingleSeasonShows', label: 'Flatten Single Season Shows', desc: 'Flattens single-season shows to display episodes directly on series page' }, + { key: 'flattenSingleSeasonShows', label: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' }, + { key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' }, { key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' }, { key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' }, { key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' }, @@ -1974,6 +1986,20 @@ `; } + // Build flatten shows configuration + function buildFlattenShowsConfig(flattenShows) { + const hideSingleSeasonContainer = flattenShows?.hideSingleSeasonContainer === true; + return ` +
+
+
Flatten Single Season Shows
+
When enabled, hides the season container section when a series has only one season, since episodes are already displayed directly.
+ ${buildJellyfinCheckbox('flattenSingleSeasonShows_hideSingleSeasonContainer', hideSingleSeasonContainer, 'Enabled')} +
+
+ `; + } + // Open configuration modal async function openConfigurationModal() { console.log('[KefinTweaks Configuration] Opening configuration modal...'); @@ -4159,7 +4185,8 @@ search: modalInstance.dialogContent.querySelector('#script_search'), exclusiveElsewhere: modalInstance.dialogContent.querySelector('#script_exclusiveElsewhere'), skinManager: modalInstance.dialogContent.querySelector('#script_skinManager'), - customMenuLinks: modalInstance.dialogContent.querySelector('#script_customMenuLinks') + customMenuLinks: modalInstance.dialogContent.querySelector('#script_customMenuLinks'), + flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#script_flattenSingleSeasonShows') }; const configSections = { @@ -4167,7 +4194,8 @@ exclusiveElsewhere: modalInstance.dialogContent.querySelector('#configSection_exclusiveElsewhere'), skin: modalInstance.dialogContent.querySelector('#configSection_skin'), theme: modalInstance.dialogContent.querySelector('#configSection_theme'), - customMenuLinks: modalInstance.dialogContent.querySelector('#configSection_customMenuLinks') + customMenuLinks: modalInstance.dialogContent.querySelector('#configSection_customMenuLinks'), + flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#configSection_flattenSingleSeasonShows') }; // Function to toggle section visibility @@ -4220,6 +4248,10 @@ scriptCheckboxes.customMenuLinks.addEventListener('change', () => toggleSectionVisibility('customMenuLinks', 'customMenuLinks')); } + if (scriptCheckboxes.flattenSingleSeasonShows) { + scriptCheckboxes.flattenSingleSeasonShows.addEventListener('change', () => toggleSectionVisibility('flattenSingleSeasonShows', 'flattenSingleSeasonShows')); + } + // Save button handler const saveBtn = modalInstance.dialogFooter?.querySelector('#saveConfigBtn'); if (saveBtn) { @@ -5092,6 +5124,11 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; enableJellyseerr: document.getElementById('search_enableJellyseerr')?.checked === true }; + // Collect flattenSingleSeasonShows config + config.flattenSingleSeasonShows = { + hideSingleSeasonContainer: document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true + }; + // Parse JSON fields const skinsFromJson = parseJSONField('skinsJson', []); config.themes = parseJSONField('themesJson', []); diff --git a/scripts/seriesEpisodes.js b/scripts/seriesEpisodes.js new file mode 100644 index 0000000..966a76e --- /dev/null +++ b/scripts/seriesEpisodes.js @@ -0,0 +1,890 @@ +// KefinTweaks Series Episodes +// Displays episodes directly on series page with season selection +// Requires: cardBuilder.js, utils.js modules to be loaded before this script + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SeriesEpisodes]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks SeriesEpisodes]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes]', ...args); + + /** + * Fetches the Next Up episode for a given series + * @param {string} seriesId - The series ID + * @returns {Promise} - Episode number object with season and episode, or null + */ + async function fetchNextUpEpisode(seriesId) { + try { + const apiClient = window.ApiClient; + const userId = apiClient.getCurrentUserId(); + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + + LOG(`Fetching Next Up episode for series: ${seriesId}`); + const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`; + const nextUpRes = await fetch(nextUpUrl, { + headers: { "Authorization": `MediaBrowser Token="${token}"` } + }); + + if (!nextUpRes.ok) { + throw new Error(`HTTP ${nextUpRes.status}: ${nextUpRes.statusText}`); + } + + const nextUpData = await nextUpRes.json(); + const items = nextUpData.Items || []; + + if (items.length > 0) { + const item = items[0]; + if (item.IndexNumber && item.ParentIndexNumber) { + const episodeNumber = { + season: item.ParentIndexNumber, + episode: item.IndexNumber + }; + LOG(`Fetched Next Up episode from API: S${episodeNumber.season}:E${episodeNumber.episode}`); + return episodeNumber; + } + } + + LOG('No Next Up episode found in API response'); + return null; + } catch (error) { + ERR(`Failed to fetch Next Up episode for series ${seriesId}:`, error); + return null; + } + } + + /** + * Fetches season data by season ID + * @param {string} seasonId - The season ID + * @returns {Promise} - Season item or null + */ + async function fetchSeasonData(seasonId) { + try { + const apiClient = window.ApiClient; + const userId = apiClient.getCurrentUserId(); + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + + LOG(`Fetching season data for season ID: ${seasonId}`); + const seasonUrl = `${serverUrl}/Items/${seasonId}?UserId=${userId}`; + const seasonRes = await fetch(seasonUrl, { + headers: { "Authorization": `MediaBrowser Token="${token}"` } + }); + + if (!seasonRes.ok) { + throw new Error(`HTTP ${seasonRes.status}: ${seasonRes.statusText}`); + } + + const seasonData = await seasonRes.json(); + LOG(`Fetched season data: IndexNumber=${seasonData.IndexNumber}`); + return seasonData; + } catch (error) { + ERR(`Failed to fetch season data for ${seasonId}:`, error); + return null; + } + } + + /** + * Fetches all episodes for a given series + * @param {string} seriesId - The series ID + * @param {string} [seasonId] - Optional season ID to filter episodes + * @returns {Promise} - Array of episode items + */ + async function fetchEpisodesForSeries(seriesId, seasonId = null) { + try { + const apiClient = window.ApiClient; + const userId = apiClient.getCurrentUserId(); + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + + LOG(`Fetching episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`); + const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`; + const episodesRes = await fetch(episodesUrl, { + headers: { "Authorization": `MediaBrowser Token="${token}"` } + }); + + if (!episodesRes.ok) { + throw new Error(`HTTP ${episodesRes.status}: ${episodesRes.statusText}`); + } + + const episodesData = await episodesRes.json(); + let episodes = episodesData.Items || []; + + // Filter by season ID if specified + if (seasonId !== null) { + episodes = episodes.filter(ep => ep.SeasonId === seasonId); + } + + LOG(`Fetched ${episodes.length} episodes for series: ${seriesId}${seasonId ? ` (Season ID: ${seasonId})` : ''}`); + return episodes; + } catch (error) { + ERR(`Failed to fetch episodes for series ${seriesId}:`, error); + return []; + } + } + + /** + * Extracts season data from the rendered DOM + * @param {HTMLElement} activePage - The active page element + * @returns {Array} - Array of season objects with { id, name, indexNumber } + */ + function extractSeasonsFromDOM(activePage) { + const seasons = []; + + // Check for 10.11+ seasons section first (.detailSection #listChildrenCollapsible) + let childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible'); + + // Fallback to 10.10 seasons section (#childrenCollapsible) + if (!childrenCollapsible) { + childrenCollapsible = activePage.querySelector('#childrenCollapsible'); + } + + if (!childrenCollapsible) { + WARN('Could not find childrenCollapsible element'); + return seasons; + } + + const itemsContainer = childrenCollapsible.querySelector('.itemsContainer'); + if (!itemsContainer) { + WARN('Could not find itemsContainer in childrenCollapsible'); + return seasons; + } + + // Get all .card immediate children + const seasonCards = itemsContainer.querySelectorAll(':scope > .card'); + + seasonCards.forEach(card => { + const seasonId = card.getAttribute('data-id'); + if (!seasonId) { + return; + } + + // Get season name from .cardText a link + const cardTextLink = card.querySelector('.cardText a'); + const seasonName = cardTextLink ? (cardTextLink.innerText || cardTextLink.textContent).trim() : null; + + // Try to extract index number from season name or data attributes + let indexNumber = null; + + // Try to parse from season name (e.g., "Season 1", "S1", etc.) + if (seasonName) { + const seasonMatch = seasonName.match(/Season\s+(\d+)/i) || seasonName.match(/S(\d+)/i); + if (seasonMatch) { + indexNumber = parseInt(seasonMatch[1], 10); + } + } + + // If we couldn't parse from name, try data-index or other attributes + if (indexNumber === null) { + const dataIndex = card.getAttribute('data-index'); + if (dataIndex) { + indexNumber = parseInt(dataIndex, 10); + } + } + + if (seasonId) { + seasons.push({ + id: seasonId, + name: seasonName || `Season ${indexNumber || 'Unknown'}`, + indexNumber: indexNumber + }); + } + }); + + // Sort by index number + seasons.sort((a, b) => { + const aIndex = a.indexNumber !== null ? a.indexNumber : 999; + const bIndex = b.indexNumber !== null ? b.indexNumber : 999; + return aIndex - bIndex; + }); + + LOG(`Extracted ${seasons.length} seasons from DOM:`, seasons); + return seasons; + } + + /** + * Formats a season name, adding season number in parentheses if not in "Season XX" format + * @param {string} seasonName - The season name + * @param {number} seasonNumber - The season number + * @returns {string} - Formatted season name + */ + function formatSeasonName(seasonName, seasonNumber) { + if (!seasonName) { + return `Season ${seasonNumber}`; + } + + return seasonName; + } + + /** + * Creates a season selector button with popover for multi-season shows + * @param {HTMLElement} sectionTitleContainer - The section title container element + * @param {Array} seasons - Array of season objects + * @param {Object} currentSeason - The currently selected season + * @param {Function} onSeasonSelect - Callback when a season is selected + */ + function createSeasonSelector(sectionTitleContainer, seasons, currentSeason, onSeasonSelect) { + // Create select season button + const selectButton = document.createElement('button'); + selectButton.className = 'emby-button raised season-selector-button'; + selectButton.textContent = 'Select Season'; + selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;'; + selectButton.setAttribute('aria-label', 'Select season'); + + // Find the tag link (titleLink) and append button after it + const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link'); + if (titleLink) { + titleLink.parentNode.insertBefore(selectButton, titleLink.nextSibling); + } else { + const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle'); + if (sectionTitle) { + sectionTitle.parentNode.insertBefore(selectButton, sectionTitle.nextSibling); + } else { + sectionTitleContainer.appendChild(selectButton); + } + } + + let activePopover = null; + + const closePopover = () => { + if (activePopover) { + activePopover.remove(); + activePopover = null; + } + }; + + const createPopover = () => { + const popover = document.createElement('div'); + popover.className = 'kefinTweaks-popover seasonPopover itemDetailsGroup'; + + const seasonPopoverCss = ` + .seasonPopover { + background-color: rgba(0, 0, 0, 0.95); + position: absolute; + z-index: 1001; + display: block; + } + `; + const styleElement = document.createElement('style'); + styleElement.textContent = seasonPopoverCss; + document.head.appendChild(styleElement); + + // Calculate left position based on right edge of the tag link + const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link'); + if (titleLink) { + const titleLinkRect = titleLink.getBoundingClientRect(); + const sectionTitleContainerRect = sectionTitleContainer.getBoundingClientRect(); + const leftOffset = titleLinkRect.right - sectionTitleContainerRect.left; + popover.style.left = `${leftOffset}px`; + } else { + popover.style.left = '0'; + } + + let selectedItemElement = null; + seasons.forEach(season => { + const itemElement = document.createElement('div'); + itemElement.className = 'kefinTweaks-popover-item'; + + if (currentSeason && season.id === currentSeason.id) { + itemElement.classList.add('selected'); + selectedItemElement = itemElement; + } + + const displayText = formatSeasonName(`${season.name}`, season.indexNumber); + itemElement.textContent = displayText; + itemElement.addEventListener('click', () => { + LOG(`Season selected: ${displayText}`); + onSeasonSelect(season); + closePopover(); + }); + + popover.appendChild(itemElement); + }); + + // Store reference to selected item for scrolling + popover._selectedItem = selectedItemElement; + + return popover; + }; + + selectButton.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + + if (activePopover) { + closePopover(); + } else { + const popover = createPopover(); + sectionTitleContainer.style.position = 'relative'; + selectButton.parentNode.insertBefore(popover, selectButton.nextSibling); + + // Check if popover would overflow bottom of viewport + const popoverRect = popover.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const wouldOverflowBottom = popoverRect.bottom > viewportHeight; + + if (wouldOverflowBottom) { + popover.style.top = 'auto'; + popover.style.bottom = '100%'; + popover.style.marginTop = '0'; + popover.style.marginBottom = '8px'; + LOG('Popover positioned above button to avoid overflow'); + } else { + popover.style.top = '100%'; + popover.style.bottom = 'auto'; + popover.style.marginTop = '8px'; + popover.style.marginBottom = '0'; + } + + activePopover = popover; + + // Scroll to selected season if it exists + if (popover._selectedItem) { + setTimeout(() => { + popover._selectedItem.scrollIntoView({ + behavior: 'instant', + block: 'nearest', + inline: 'nearest' + }); + LOG('Auto-scrolled to selected season:', popover._selectedItem.textContent); + }, 10); + } + + const closeHandler = (e) => { + if (!popover.contains(e.target) && !selectButton.contains(e.target)) { + closePopover(); + document.removeEventListener('click', closeHandler); + } + }; + + setTimeout(() => { + document.addEventListener('click', closeHandler); + }, 100); + } + }); + } + + /** + * Scrolls to a specific episode in the scroller container + * @param {HTMLElement} scrollerContainer - The scroller container element + * @param {Object} targetEpisodeNumber - Object with season and episode numbers + */ + function scrollToEpisode(scrollerContainer, targetEpisodeNumber) { + if (!scrollerContainer || !targetEpisodeNumber) return; + + setTimeout(() => { + const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer'); + if (!scrollerItemsContainer) { + WARN('Could not find itemsContainer in scroller'); + return; + } + + const cards = scrollerItemsContainer.querySelectorAll('.card'); + let targetCard = null; + + for (const card of cards) { + const cardTextLinks = card.querySelectorAll('.cardText a'); + for (const link of cardTextLinks) { + const linkText = link.innerText || link.textContent; + const match = linkText.match(/S(\d+):E(\d+)/); + if (match && + parseInt(match[1], 10) === targetEpisodeNumber.season && + parseInt(match[2], 10) === targetEpisodeNumber.episode) { + targetCard = card; + break; + } + } + if (targetCard) break; + } + + if (targetCard) { + const itemsContainer = scrollerItemsContainer; + const cardOffset = targetCard.offsetLeft; + const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0; + const translateX = (cardOffset - scrollerPadding); + scrollerContainer.scrollToPosition(translateX); + + const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons'); + if (scrollButtons) { + const leftButton = scrollButtons.querySelector('button[data-direction="left"]'); + if (leftButton) { + leftButton.removeAttribute('disabled'); + } + } + + LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + } else { + WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + } + }, 100); + } + + /** + * Waits for the seasons section to be rendered in the DOM + * @param {HTMLElement} activePage - The active page element + * @param {number} maxAttempts - Maximum number of attempts + * @param {number} interval - Interval between attempts in ms + * @returns {Promise} - The childrenCollapsible element or null + */ + function waitForSeasonsSection(activePage, maxAttempts = 10, interval = 500) { + return new Promise((resolve) => { + let attempts = 0; + + const checkForSeasons = () => { + attempts++; + + // Check for 10.11+ seasons section first + let childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible'); + + // Fallback to 10.10 seasons section + if (!childrenCollapsible) { + childrenCollapsible = activePage.querySelector('#childrenCollapsible'); + } + + if (childrenCollapsible) { + const itemsContainer = childrenCollapsible.querySelector('.itemsContainer'); + if (itemsContainer && itemsContainer.children.length > 0) { + LOG('Seasons section found and rendered'); + resolve(childrenCollapsible); + return; + } + } + + if (attempts >= maxAttempts) { + WARN('Seasons section not found after maximum attempts'); + resolve(null); + return; + } + + setTimeout(checkForSeasons, interval); + }; + + checkForSeasons(); + }); + } + + /** + * Renders episodes section for a series + * @param {string} seriesId - The series ID + * @param {Array} seasons - Array of season objects from DOM + * @param {Object} targetSeason - The target season to display + * @param {Object} targetEpisodeNumber - Target episode number for scrolling + * @param {string} nextUpHeaderText - Text from NextUp section header + */ + async function renderEpisodesSection(seriesId, seasons, targetSeason, targetEpisodeNumber, nextUpHeaderText) { + const activePage = document.querySelector('.libraryPage:not(.hide)'); + if (!activePage) { + WARN('Active page not found'); + return; + } + + // Check if already rendered + if (activePage.querySelector('.series-episodes-section')) { + LOG('Episodes section already rendered, skipping'); + return; + } + + // Fetch episodes for the target season + const episodes = await fetchEpisodesForSeries(seriesId, targetSeason.id); + if (episodes.length === 0) { + WARN(`No episodes found for season ${targetSeason.id}`); + return; + } + + // Check for cardBuilder + if (!window.cardBuilder || !window.cardBuilder.renderCards) { + WARN('cardBuilder.renderCards not available'); + return; + } + + // Format season name + const seasonName = formatSeasonName(targetSeason.name, targetSeason.indexNumber); + + // Build viewMoreUrl + const apiClient = window.ApiClient; + const serverId = apiClient.serverId(); + const viewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${targetSeason.id}&serverId=${serverId}`; + + // Render season section using renderCards + const seasonSection = window.cardBuilder.renderCards( + episodes, + seasonName, + viewMoreUrl, + true, // overflowCard + null // cardFormat (use default) + ); + + // Mark as series episodes section + seasonSection.classList.add('series-episodes-section'); + seasonSection.classList.add('nextUpSection'); + + // ElegantFin handling :) + seasonSection.style.cssText = ` + overflow: hidden; + display: block; + `; + + const scrollerContainer = seasonSection.querySelector('.emby-scroller'); + if (scrollerContainer) { + scrollerContainer.classList.add('no-padding'); + } + + const sectionTitleContainer = seasonSection.querySelector('.sectionTitleContainer'); + if (sectionTitleContainer) { + sectionTitleContainer.classList.add('no-padding'); + } + + // Hide NextUp section if it exists + const nextUpSection = activePage.querySelector('.nextUpSection'); + if (nextUpSection) { + nextUpSection.style.display = 'none'; + } + + // Insert episodes section right after NextUp section (or at the beginning if no NextUp) + const detailPageContent = activePage.querySelector('.detailPageContent'); + if (detailPageContent) { + if (nextUpSection && nextUpSection.parentNode) { + nextUpSection.parentNode.insertBefore(seasonSection, nextUpSection.nextSibling); + } else { + // Find a good insertion point - after NextUp or before childrenCollapsible + const childrenCollapsible = activePage.querySelector('.detailSection #listChildrenCollapsible') || + activePage.querySelector('#childrenCollapsible'); + if (childrenCollapsible && childrenCollapsible.parentNode) { + childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible); + } else { + detailPageContent.insertBefore(seasonSection, detailPageContent.firstChild); + } + } + } + + // Add season selector if more than one season + if (seasons.length > 1) { + const handleSeasonChange = async (selectedSeason) => { + LOG(`Switching to season: ${selectedSeason.name}`); + + const newEpisodes = await fetchEpisodesForSeries(seriesId, selectedSeason.id); + if (newEpisodes.length === 0) { + WARN(`No episodes found for Season ID: ${selectedSeason.id}`); + return; + } + + // Update section title + const sectionTitle = sectionTitleContainer.querySelector('.sectionTitle'); + if (sectionTitle) { + const newSeasonName = formatSeasonName(selectedSeason.name, selectedSeason.indexNumber); + sectionTitle.textContent = newSeasonName; + } + + // Update viewMoreUrl + const titleLink = sectionTitleContainer.querySelector('a.sectionTitle-link'); + if (titleLink && selectedSeason.id) { + const newViewMoreUrl = `${apiClient._serverAddress || apiClient.serverAddress()}/web/#/details?id=${selectedSeason.id}&serverId=${serverId}`; + titleLink.href = newViewMoreUrl; + LOG(`Updated viewMoreUrl to: ${newViewMoreUrl}`); + } + + // Update season selector button + const oldButton = sectionTitleContainer.querySelector('.season-selector-button'); + if (oldButton) { + oldButton.remove(); + } + createSeasonSelector(sectionTitleContainer, seasons, selectedSeason, handleSeasonChange); + + // Replace episodes + const itemsContainer = scrollerContainer?.querySelector('.itemsContainer'); + if (itemsContainer && window.cardBuilder && window.cardBuilder.buildCard) { + itemsContainer.innerHTML = ''; + + newEpisodes.forEach(episode => { + const card = window.cardBuilder.buildCard(episode, true); + if (card) { + itemsContainer.appendChild(card); + } + }); + + // Check if NextUp is in this season + if (targetEpisodeNumber && targetEpisodeNumber.season === selectedSeason.indexNumber) { + if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) { + setTimeout(() => { + const cards = itemsContainer.querySelectorAll('.card'); + for (const card of cards) { + const cardTextLinks = card.querySelectorAll('.cardText a'); + for (const link of cardTextLinks) { + const linkText = link.innerText || link.textContent; + const match = linkText.match(/S(\d+):E(\d+)/); + if (match && + parseInt(match[1], 10) === targetEpisodeNumber.season && + parseInt(match[2], 10) === targetEpisodeNumber.episode) { + card.classList.add('nextUpEpisode'); + LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + break; + } + } + } + }, 50); + } + scrollToEpisode(scrollerContainer, targetEpisodeNumber); + } else { + // Scroll to beginning if this season doesn't have NextUp + if (scrollerContainer) { + setTimeout(() => { + scrollerContainer.scrollToPosition(0); + LOG('Scrolled to beginning of season (not NextUp season)'); + }, 100); + } + } + } + }; + + createSeasonSelector(sectionTitleContainer, seasons, targetSeason, handleSeasonChange); + } + + // Add NextUp episode styling and class if needed + if (targetEpisodeNumber && scrollerContainer) { + if (targetEpisodeNumber.season === targetSeason.indexNumber) { + // Add CSS for nextUpEpisode class if not already added + if (!document.getElementById('kefinTweaks-nextUpEpisode-style')) { + const style = document.createElement('style'); + style.id = 'kefinTweaks-nextUpEpisode-style'; + const escapedHeaderText = nextUpHeaderText.replace(/'/g, "\\'").replace(/"/g, '\\"'); + style.textContent = ` + .nextUpEpisode .cardScalable::after { + content: '${escapedHeaderText}'; + position: absolute; + transform: translateY(-100%); + padding: 0.5em 1em; + width: calc(100% - 2em); + text-align: center; + background: linear-gradient(90deg, transparent, black, transparent); + font-size: 1.2em; + } + `; + document.head.appendChild(style); + LOG('Added nextUpEpisode CSS with header text:', nextUpHeaderText); + } + + // Add nextUpEpisode class if not S1E1 + if (targetEpisodeNumber.season !== 1 || targetEpisodeNumber.episode !== 1) { + setTimeout(() => { + const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer'); + if (scrollerItemsContainer) { + const cards = scrollerItemsContainer.querySelectorAll('.card'); + for (const card of cards) { + const cardTextLinks = card.querySelectorAll('.cardText a'); + for (const link of cardTextLinks) { + const linkText = link.innerText || link.textContent; + const match = linkText.match(/S(\d+):E(\d+)/); + if (match && + parseInt(match[1], 10) === targetEpisodeNumber.season && + parseInt(match[2], 10) === targetEpisodeNumber.episode) { + card.classList.add('nextUpEpisode'); + LOG(`Added nextUpEpisode class to card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + break; + } + } + } + } + }, 100); + } + + scrollToEpisode(scrollerContainer, targetEpisodeNumber); + } + } + + LOG(`Successfully rendered episodes section for season ${targetSeason.indexNumber} with ${episodes.length} episodes`); + } + + /** + * Processes a series page to add episodes section + * @param {string} seriesId - The series ID + */ + async function processSeriesPage(seriesId) { + const activePage = document.querySelector('.libraryPage:not(.hide)'); + if (!activePage) { + WARN('Active page not found'); + return; + } + + // Check if already processed + if (activePage.dataset.seriesEpisodesProcessed === 'true') { + LOG('Series page already processed, skipping'); + return; + } + + // Wait for seasons section to render + LOG('Waiting for seasons section to render...'); + const childrenCollapsible = await waitForSeasonsSection(activePage); + + if (!childrenCollapsible) { + WARN('Could not find seasons section, aborting'); + return; + } + + // Extract seasons from DOM + const seasons = extractSeasonsFromDOM(activePage); + if (seasons.length === 0) { + WARN('No seasons found in DOM'); + return; + } + + // Get configuration + const config = window.KefinTweaksConfig || {}; + const scripts = config.scripts || {}; + const flattenConfig = config.flattenSingleSeasonShows || {}; + const isScriptEnabled = scripts.flattenSingleSeasonShows !== false; // Default to true if not set + const hideSingleSeasonContainer = flattenConfig.hideSingleSeasonContainer === true; + + // Only proceed if script is enabled or if single season + const isSingleSeason = seasons.length === 1; + if (!isSingleSeason && !isScriptEnabled) { + LOG('Multi-season show and Episodes On Series Page script is disabled, skipping'); + return; + } + + // Get NextUp episode + let targetEpisodeNumber = await fetchNextUpEpisode(seriesId); + + // Determine target season + let targetSeason = null; + + if (targetEpisodeNumber) { + // Find season matching NextUp episode by index number + targetSeason = seasons.find(s => s.indexNumber === targetEpisodeNumber.season); + } + + // Fallback to first season if NextUp not found or season not in list + if (!targetSeason) { + targetSeason = seasons[0]; + // If we have NextUp but season not found, use first episode of first season + if (targetEpisodeNumber && !seasons.find(s => s.indexNumber === targetEpisodeNumber.season)) { + targetEpisodeNumber = { season: targetSeason.indexNumber, episode: 1 }; + LOG(`NextUp season not in seasons list, defaulting to first episode of first season`); + } else if (!targetEpisodeNumber) { + targetEpisodeNumber = { season: targetSeason.indexNumber, episode: 1 }; + LOG(`No NextUp found, defaulting to first episode of first season`); + } + } + + // Check if target season is at index 0 in itemsContainer and if it's specials (IndexNumber === 0) + const itemsContainer = childrenCollapsible.querySelector('.itemsContainer'); + if (itemsContainer && targetSeason) { + const firstCard = itemsContainer.querySelector(':scope > .card'); + if (firstCard) { + const firstCardSeasonId = firstCard.getAttribute('data-id'); + // If target season is the first card in itemsContainer, check if it's specials + if (firstCardSeasonId === targetSeason.id) { + const seasonData = await fetchSeasonData(targetSeason.id); + if (seasonData && seasonData.IndexNumber === 0) { + LOG(`Target season is specials (IndexNumber 0), skipping to next season`); + // Find the next season in the seasons array + const currentIndex = seasons.findIndex(s => s.id === targetSeason.id); + if (currentIndex !== -1 && currentIndex < seasons.length - 1) { + targetSeason = seasons[currentIndex + 1]; + // Update targetEpisodeNumber to match the new season + targetEpisodeNumber = { season: targetSeason.indexNumber, episode: 1 }; + LOG(`Using next season instead: ${targetSeason.name} (IndexNumber: ${targetSeason.indexNumber})`); + } else { + WARN('Target season is specials but no next season found'); + } + } + } + } + } + + // Extract NextUp header text + let nextUpHeaderText = 'Next Up'; + const nextUpSection = activePage.querySelector('.nextUpSection'); + if (nextUpSection) { + const nextUpHeader = nextUpSection.querySelector('.sectionTitle, h2, h3'); + if (nextUpHeader) { + nextUpHeaderText = nextUpHeader.textContent.trim() || 'Next Up'; + LOG(`Extracted NextUp header text: "${nextUpHeaderText}"`); + } + } + + // Render episodes section + await renderEpisodesSection(seriesId, seasons, targetSeason, targetEpisodeNumber, nextUpHeaderText); + + // Hide season container if option is enabled and there's only one season + if (hideSingleSeasonContainer && isSingleSeason && childrenCollapsible) { + childrenCollapsible.style.display = 'none'; + LOG('Hid season container for single-season show'); + } + + // Mark as processed + activePage.dataset.seriesEpisodesProcessed = 'true'; + } + + /** + * Initialize the series episodes hook + */ + function initializeSeriesEpisodesHook() { + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { + WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); + setTimeout(initializeSeriesEpisodesHook, 1000); + return; + } + + LOG('Registering series episodes handler with KefinTweaksUtils'); + + window.KefinTweaksUtils.onViewPage( + async (view, element, hash, itemPromise) => { + const activePage = document.querySelector('.libraryPage:not(.hide)'); + if (!activePage) return; + + // Remove processed flag when page changes + if (activePage.dataset.seriesEpisodesProcessed) { + //delete activePage.dataset.seriesEpisodesProcessed; + LOG('Existing episodes section found, skipping'); + return; + } + + // Remove any existing episodes section + const existingSection = activePage.querySelector('.series-episodes-section'); + if (existingSection) { + //existingSection.remove(); + LOG('Existing episodes section found, skipping'); + return; + } + + const item = await itemPromise; + + if (item && item.Type === 'Series') { + LOG(`Found series: ${item.Id} (${item.Name})`); + + // Small delay to ensure page is ready + setTimeout(async () => { + await processSeriesPage(item.Id); + }, 100); + } + }, + { + pages: ['details'] + } + ); + + LOG('Series episodes hook initialized'); + } + + // Initialize the hook when the script loads + function initialize() { + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { + WARN('KefinTweaksUtils not available, retrying in 1 second'); + setTimeout(initialize, 1000); + return; + } + + initializeSeriesEpisodesHook(); + } + + // Start initialization + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } + + LOG('Script loaded successfully'); +})(); + diff --git a/scripts/seriesInfo.js b/scripts/seriesInfo.js new file mode 100644 index 0000000..bbba39d --- /dev/null +++ b/scripts/seriesInfo.js @@ -0,0 +1,383 @@ +// KefinTweaks Series Info +// Adds series and season information to details pages +// Requires: utils.js module to be loaded before this script + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SeriesInfo]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks SeriesInfo]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SeriesInfo]', ...args); + + /** + * Parses a runtime string (e.g., "42m", "1h 5m") into total minutes + * @param {string} runtimeStr - Runtime string like "42m" or "1h 5m" + * @returns {number} Total minutes, or 0 if parsing fails + */ + function parseRuntimeString(runtimeStr) { + if (!runtimeStr || typeof runtimeStr !== 'string') { + return 0; + } + + let totalMinutes = 0; + + // Match hours: "1h" or "1 h" + const hourMatch = runtimeStr.match(/(\d+)\s*h/i); + if (hourMatch) { + totalMinutes += parseInt(hourMatch[1], 10) * 60; + } + + // Match minutes: "5m" or "5 m" + const minuteMatch = runtimeStr.match(/(\d+)\s*m/i); + if (minuteMatch) { + totalMinutes += parseInt(minuteMatch[1], 10); + } + + return totalMinutes; + } + + /** + * Extracts total runtime from episode DOM elements on season page + * @param {HTMLElement} activePage - The active page element + * @returns {Promise} Total runtime in minutes, or 0 if not found + */ + async function extractSeasonRuntimeFromDOM(activePage) { + // Check for #listChildrenCollapsible first (10.11+) + let childrenCollapsible = activePage.querySelector('#listChildrenCollapsible'); + + // Fallback to #childrenCollapsible (10.10) + if (!childrenCollapsible) { + childrenCollapsible = activePage.querySelector('#childrenCollapsible'); + } + + if (!childrenCollapsible) { + WARN('Could not find childrenCollapsible element for season runtime extraction'); + return 0; + } + + const itemsContainer = childrenCollapsible.querySelector('.itemsContainer'); + if (!itemsContainer) { + WARN('Could not find itemsContainer in childrenCollapsible'); + return 0; + } + + // Wait for episode containers to appear (up to 10 seconds) + const maxAttempts = 20; // 20 attempts * 500ms = 10 seconds + const interval = 500; + let attempts = 0; + + while (attempts < maxAttempts) { + // Get all direct children (episode containers) + const episodeContainers = itemsContainer.querySelectorAll(':scope > *'); + + // Check if we have episode containers with runtime data + let hasValidContainers = false; + if (episodeContainers.length > 0) { + // Check if at least one container has the expected structure + for (const container of episodeContainers) { + const mediaInfo = container.querySelector('.secondary.listItemMediaInfo.listItemBodyText'); + if (mediaInfo) { + const mediaInfoItem = mediaInfo.querySelector(':scope > .mediaInfoItem'); + if (mediaInfoItem) { + hasValidContainers = true; + break; + } + } + } + } + + if (hasValidContainers) { + // Found valid containers, extract runtimes + let totalMinutes = 0; + let parsedCount = 0; + + episodeContainers.forEach(episodeContainer => { + // Find .secondary.listItemMediaInfo.listItemBodyText + const mediaInfo = episodeContainer.querySelector('.secondary.listItemMediaInfo.listItemBodyText'); + if (!mediaInfo) { + return; + } + + // Find .mediaInfoItem direct child + const mediaInfoItem = mediaInfo.querySelector(':scope > .mediaInfoItem'); + if (!mediaInfoItem) { + return; + } + + const runtimeText = (mediaInfoItem.innerText || mediaInfoItem.textContent || '').trim(); + if (runtimeText) { + const minutes = parseRuntimeString(runtimeText); + if (minutes > 0) { + totalMinutes += minutes; + parsedCount++; + } + } + }); + + LOG(`Extracted runtime from ${parsedCount} episodes: ${totalMinutes} minutes total`); + return totalMinutes; + } + + attempts++; + if (attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + WARN('Could not find episode containers with runtime data after waiting 10 seconds'); + return 0; + } + + /** + * Formats RunTimeTicks into an end time string + * @param {number} runTimeTicks - Runtime in ticks (100-nanosecond intervals) + * @returns {string} Formatted end time (e.g., "2:40 AM Dec 6" or "2:40 AM" if same day) + */ + function formatEndTime(runTimeTicks) { + if (!runTimeTicks || runTimeTicks <= 0) { + return null; + } + + const now = new Date(); + // RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds + const runtimeMs = runTimeTicks / 10000; + const endTime = new Date(now.getTime() + runtimeMs); + + // Format time + const timeStr = endTime.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + // Check if end date is the same as current day + const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const endDate = new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate()); + + if (nowDate.getTime() === endDate.getTime()) { + // Same day, return only time + return timeStr; + } else { + // Different day, include date + const dateStr = endTime.toLocaleDateString([], { + month: 'short', + day: 'numeric' + }); + return `${timeStr} ${dateStr}`; + } + } + + /** + * Formats total minutes into an end time string + * @param {number} totalMinutes - Total runtime in minutes + * @returns {string} Formatted end time (e.g., "2:40 AM Dec 6" or "2:40 AM" if same day) + */ + function formatEndTimeFromMinutes(totalMinutes) { + if (!totalMinutes || totalMinutes <= 0) { + return null; + } + + const now = new Date(); + // Convert minutes to milliseconds + const runtimeMs = totalMinutes * 60 * 1000; + const endTime = new Date(now.getTime() + runtimeMs); + + // Format time + const timeStr = endTime.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + // Check if end date is the same as current day + const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const endDate = new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate()); + + if (nowDate.getTime() === endDate.getTime()) { + // Same day, return only time + return timeStr; + } else { + // Different day, include date + const dateStr = endTime.toLocaleDateString([], { + month: 'short', + day: 'numeric' + }); + return `${timeStr} ${dateStr}`; + } + } + + /** + * Adds series info to the itemMiscInfo container + * @param {Object} item - Series item object + * @param {HTMLElement} miscInfoContainer - The .itemMiscInfo container + */ + function addSeriesInfo(item, miscInfoContainer) { + // Check if already added + if (miscInfoContainer.querySelector('.kt-series-info')) { + LOG('Series info already added, skipping'); + return; + } + + const infoContainer = document.createElement('div'); + infoContainer.className = 'kt-series-info'; + infoContainer.style.display = 'flex'; + + // Seasons count (only show if more than 1 season) + const childCount = item.ChildCount || 0; + if (childCount > 1) { + const seasonsDiv = document.createElement('div'); + seasonsDiv.className = 'mediaInfoItem'; + seasonsDiv.textContent = `${childCount} Seasons`; + infoContainer.appendChild(seasonsDiv); + } + + // Episodes count + const RecursiveItemCount = item.RecursiveItemCount || 0; + if (RecursiveItemCount > 0) { + const episodesDiv = document.createElement('div'); + episodesDiv.className = 'mediaInfoItem'; + episodesDiv.textContent = `${RecursiveItemCount} ${RecursiveItemCount === 1 ? 'Episode' : 'Episodes'}`; + infoContainer.appendChild(episodesDiv); + } + + // End time - RunTimeTicks is for first episode, multiply by episode count for total + if (item.RunTimeTicks && item.RunTimeTicks > 0 && RecursiveItemCount > 0) { + const totalRuntimeTicks = item.RunTimeTicks * RecursiveItemCount; + const endTimeStr = formatEndTime(totalRuntimeTicks); + if (endTimeStr) { + const endTimeDiv = document.createElement('div'); + endTimeDiv.className = 'mediaInfoItem'; + endTimeDiv.textContent = `Ends at: ${endTimeStr}`; + infoContainer.appendChild(endTimeDiv); + } + } + + // Append to miscInfoContainer + if (infoContainer.children.length > 0) { + miscInfoContainer.appendChild(infoContainer); + LOG('Added series info to itemMiscInfo container'); + } + } + + /** + * Adds season info to the itemMiscInfo container + * @param {Object} item - Season item object + * @param {HTMLElement} miscInfoContainer - The .itemMiscInfo container + * @param {HTMLElement} activePage - The active page element + */ + async function addSeasonInfo(item, miscInfoContainer, activePage) { + // Remove hide class if present + if (miscInfoContainer.classList.contains('hide')) { + miscInfoContainer.classList.remove('hide'); + LOG('Removed hide class from itemMiscInfo container'); + } + + // Check if already added + if (miscInfoContainer.querySelector('.kt-season-info')) { + LOG('Season info already added, skipping'); + return; + } + + const infoContainer = document.createElement('div'); + infoContainer.className = 'kt-season-info'; + infoContainer.style.display = 'flex'; + + // Episodes count + const episodeCount = item.ChildCount || 0; + if (episodeCount > 0) { + const episodesDiv = document.createElement('div'); + episodesDiv.className = 'mediaInfoItem'; + episodesDiv.textContent = `${episodeCount} ${episodeCount === 1 ? 'Episode' : 'Episodes'}`; + infoContainer.appendChild(episodesDiv); + } + + // End time - extract from DOM episode runtimes + const totalMinutes = await extractSeasonRuntimeFromDOM(activePage); + if (totalMinutes > 0) { + const endTimeStr = formatEndTimeFromMinutes(totalMinutes); + if (endTimeStr) { + const endTimeDiv = document.createElement('div'); + endTimeDiv.className = 'mediaInfoItem'; + endTimeDiv.textContent = `Ends at: ${endTimeStr}`; + infoContainer.appendChild(endTimeDiv); + } + } + + // Append to miscInfoContainer + if (infoContainer.children.length > 0) { + miscInfoContainer.appendChild(infoContainer); + LOG('Added season info to itemMiscInfo container'); + } + } + + /** + * Processes a details page for Series or Season items + * @param {Object} item - The item object + */ + async function processDetailsPage(item) { + if (!item || (item.Type !== 'Series' && item.Type !== 'Season')) { + return; + } + + const activePage = document.querySelector('.libraryPage:not(.hide)'); + if (!activePage) { + WARN('Active page not found'); + return; + } + + // Find the itemMiscInfo container + const miscInfoContainer = activePage.querySelector('.itemMiscInfo'); + if (!miscInfoContainer) { + WARN('itemMiscInfo container not found'); + return; + } + + LOG(`Processing ${item.Type} details page for: ${item.Name}`); + + if (item.Type === 'Series') { + addSeriesInfo(item, miscInfoContainer); + } else if (item.Type === 'Season') { + await addSeasonInfo(item, miscInfoContainer, activePage); + } + } + + /** + * Initialize the series info hook + */ + function initializeSeriesInfoHook() { + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { + WARN('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); + setTimeout(initializeSeriesInfoHook, 1000); + return; + } + + LOG('Registering series info handler with KefinTweaksUtils'); + + window.KefinTweaksUtils.onViewPage( + async (view, element, hash, itemPromise) => { + // Await the item promise to get the actual item data + const item = await itemPromise; + + if (!item || (item.Type !== 'Series' && item.Type !== 'Season')) { + return; + } + + // Small delay to ensure details DOM is ready + setTimeout(() => { + processDetailsPage(item); + }, 100); + }, + { + pages: ['details'] + } + ); + + LOG('Series info hook initialized'); + } + + // Initialize the hook when the script loads + initializeSeriesInfoHook(); + + console.log('[KefinTweaks SeriesInfo] Module loaded and ready'); +})(); +