From 43797fbb98a19453a87a00b9e0922482f349b89b Mon Sep 17 00:00:00 2001 From: CodeDevMLH <145071728+CodeDevMLH@users.noreply.github.com> Date: Sat, 14 Feb 2026 03:20:04 +0100 Subject: [PATCH] Refactor video playback handling and improve tab visibility management --- .../Web/mediaBarEnhanced.js | 569 ++++++------------ 1 file changed, 191 insertions(+), 378 deletions(-) diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js index 87e008b..9fb5407 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js @@ -1435,19 +1435,16 @@ const VisibilityObserver = { // If a full screen video player is active, hide slideshow and stop playback if ((videoPlayer && !videoPlayer.classList.contains('hide')) || (trailerPlayer && !trailerPlayer.classList.contains('hide'))) { - if (this._lastVisibleState !== 'player-active') { - this._lastVisibleState = 'player-active'; - const container = document.getElementById("slides-container"); - if (container) { - container.style.display = "none"; - container.style.visibility = "hidden"; - container.style.pointerEvents = "none"; - } - if (STATE.slideshow.slideInterval) { - STATE.slideshow.slideInterval.stop(); - } - SlideshowManager.stopAllPlayback(); + const container = document.getElementById("slides-container"); + if (container) { + container.style.display = "none"; + container.style.visibility = "hidden"; + container.style.pointerEvents = "none"; } + if (STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + SlideshowManager.stopAllPlayback(); return; } @@ -1462,27 +1459,20 @@ const VisibilityObserver = { activeTab && activeTab.getAttribute("data-index") === "0"; - const newState = isVisible ? 'visible' : 'hidden'; - - // Only update DOM and trigger actions when state actually changes - if (this._lastVisibleState !== newState) { - this._lastVisibleState = newState; - - container.style.display = isVisible ? "block" : "none"; - container.style.visibility = isVisible ? "visible" : "hidden"; - container.style.pointerEvents = isVisible ? "auto" : "none"; + container.style.display = isVisible ? "block" : "none"; + container.style.visibility = isVisible ? "visible" : "hidden"; + container.style.pointerEvents = isVisible ? "auto" : "none"; - if (isVisible) { - if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) { - STATE.slideshow.slideInterval.start(); - SlideshowManager.resumeActivePlayback(); - } - } else { - if (STATE.slideshow.slideInterval) { - STATE.slideshow.slideInterval.stop(); - } - SlideshowManager.stopAllPlayback(); + if (isVisible) { + if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) { + STATE.slideshow.slideInterval.start(); + SlideshowManager.resumeActivePlayback(); } + } else { + if (STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + SlideshowManager.stopAllPlayback(); } }, @@ -1491,11 +1481,6 @@ const VisibilityObserver = { */ init() { const observer = new MutationObserver(() => this.updateVisibility()); - // let debounceTimer = null; - // const observer = new MutationObserver(() => { - // if (debounceTimer) clearTimeout(debounceTimer); - // debounceTimer = setTimeout(() => this.updateVisibility(), 250); - // }); observer.observe(document.body, { childList: true, subtree: true }); document.body.addEventListener("click", () => this.updateVisibility()); @@ -1622,6 +1607,11 @@ const SlideCreator = { else if (item.RemoteTrailers && item.RemoteTrailers.length > 0) { trailerUrl = item.RemoteTrailers[0].Url; } + // 1d. Final Fallback to Local Trailer (even if not preferred) + else if (item.LocalTrailerCount > 0 && item.localTrailerUrl) { + trailerUrl = item.localTrailerUrl; + console.log(`Using local trailer fallback for ${itemId}: ${trailerUrl}`); + } const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); @@ -1714,20 +1704,6 @@ const SlideCreator = { playerVars: playerVars, events: { 'onReady': (event) => { - // Prevent iframe from stealing focus (critical for TV mode) - const iframe = event.target.getIframe(); - if (iframe) { - iframe.setAttribute('tabindex', '-1'); - iframe.setAttribute('inert', ''); - // Preserve video-backdrop class on the iframe (YT API replaces the original div) - iframe.classList.add('backdrop', 'video-backdrop'); - if (CONFIG.fullWidthVideo) { - iframe.classList.add('video-backdrop-full'); - } else { - iframe.classList.add('video-backdrop-default'); - } - } - // Store start/end time and videoId for later use event.target._startTime = playerVars.start || 0; event.target._endTime = playerVars.end || undefined; @@ -1744,18 +1720,11 @@ const SlideCreator = { event.target.setPlaybackQuality(quality); } - // Only play if this is the active slide and not paused + // Only play if this is the active slide const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer'); - const isActive = slide && slide.classList.contains('active'); - const isHidden = document.hidden; - const isPaused = STATE.slideshow.isPaused; - const isPlayerOpen = isVideoPlayerOpen && !isVideoPlayerOpen.classList.contains('hide'); - console.log(`[MBE-READY] onReady for ${itemId}: active=${isActive}, hidden=${isHidden}, paused=${isPaused}, playerOpen=${!!isPlayerOpen}`); - - if (isActive && !isHidden && !isPaused && !isPlayerOpen) { - console.log(`[MBE-READY] → Playing video for ${itemId}`); + if (slide && slide.classList.contains('active') && !document.hidden && (!isVideoPlayerOpen || isVideoPlayerOpen.classList.contains('hide'))) { event.target.playVideo(); // Check if it actually started playing after a short delay (handling autoplay blocks) @@ -1788,10 +1757,12 @@ const SlideCreator = { } }, 'onStateChange': (event) => { - const stateNames = {[-1]: 'UNSTARTED', 0: 'ENDED', 1: 'PLAYING', 2: 'PAUSED', 3: 'BUFFERING', 5: 'CUED'}; - console.log(`[MBE-STATE] ${itemId}: ${stateNames[event.data] || event.data}`); if (event.data === YT.PlayerState.ENDED) { - SlideshowManager.nextSlide(); + if (CONFIG.waitForTrailerToEnd) { + SlideshowManager.nextSlide(); + } else { + event.target.playVideo(); // Loop if not waiting for end if trailer is shorter than slide duration + } } }, 'onError': (event) => { @@ -1832,23 +1803,16 @@ const SlideCreator = { STATE.slideshow.videoPlayers[itemId] = backdrop; - backdrop.addEventListener('play', (event) => { - const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); - - if (!slide || !slide.classList.contains('active')) { - console.log(`Local video ${itemId} started playing but is not active, pausing.`); - event.target.pause(); - event.target.currentTime = 0; - return; - } - + backdrop.addEventListener('play', () => { if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { STATE.slideshow.slideInterval.stop(); } }); backdrop.addEventListener('ended', () => { - SlideshowManager.nextSlide(); + if (CONFIG.waitForTrailerToEnd) { + SlideshowManager.nextSlide(); + } }); backdrop.addEventListener('error', () => { @@ -2283,16 +2247,6 @@ const SlideshowManager = { let previousVisibleSlide; try { const container = SlideUtils.getOrCreateSlidesContainer(); - - const activeElement = document.activeElement; - let focusSelector = null; - if (container.contains(activeElement)) { - if (activeElement.classList.contains('play-button')) focusSelector = '.play-button'; - else if (activeElement.classList.contains('detail-button')) focusSelector = '.detail-button'; - else if (activeElement.classList.contains('favorite-button')) focusSelector = '.favorite-button'; - else if (activeElement.classList.contains('trailer-button')) focusSelector = '.trailer-button'; - } - const totalItems = STATE.slideshow.totalItems; index = Math.max(0, Math.min(index, totalItems - 1)); @@ -2321,47 +2275,89 @@ const SlideshowManager = { currentSlide.classList.add("active"); - // Restore focus for TV mode navigation continuity - requestAnimationFrame(() => { - if (focusSelector) { - const target = currentSlide.querySelector(focusSelector); - if (target) { - target.focus(); - return; + // Manage Video Playback: Stop others, Play current + + // 1. Pause all other YouTube players + if (STATE.slideshow.videoPlayers) { + Object.keys(STATE.slideshow.videoPlayers).forEach(id => { + if (id !== currentItemId) { + const p = STATE.slideshow.videoPlayers[id]; + if (p && typeof p.pauseVideo === 'function') { + p.pauseVideo(); + } } - } - // Always ensure container has focus in TV mode to keep keyboard navigation working - const isTvMode = (window.layoutManager && window.layoutManager.tv) || - document.documentElement.classList.contains('layout-tv') || - document.body.classList.contains('layout-tv'); - if (isTvMode) { - container.focus({ preventScroll: true }); + }); + } + + // 2. Pause all other HTML5 videos e.g. local trailers + document.querySelectorAll('video').forEach(video => { + if (!video.closest(`.slide[data-item-id="${currentItemId}"]`)) { + video.pause(); } }); - // Manage Video Playback: Stop others, Play current - this.pauseOtherVideos(currentItemId); + // 3. Play and Reset current video + const videoBackdrop = currentSlide.querySelector('.video-backdrop'); - if (!STATE.slideshow.isPaused) { - this.playCurrentVideo(currentSlide, currentItemId); - } else { - // Check if new slide has video — Option B: un-pause for video slides - const videoBackdrop = currentSlide.querySelector('.video-backdrop'); - if (videoBackdrop) { - STATE.slideshow.isPaused = false; - const pauseButton = document.querySelector('.pause-button'); - if (pauseButton) { - pauseButton.innerHTML = 'pause'; - const pauseLabel = LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'); - pauseButton.setAttribute('aria-label', pauseLabel); - pauseButton.setAttribute('title', pauseLabel); + // Update mute button visibility + const muteButton = document.querySelector('.mute-button'); + if (muteButton) { + const hasVideo = !!videoBackdrop; + muteButton.style.display = hasVideo ? 'block' : 'none'; + } + + if (videoBackdrop) { + if (videoBackdrop.tagName === 'VIDEO') { + videoBackdrop.currentTime = 0; + + videoBackdrop.muted = STATE.slideshow.isMuted; + if (!STATE.slideshow.isMuted) { + videoBackdrop.volume = 0.4; + } + + videoBackdrop.play().catch(e => { + // Check if it actually started playing after a short delay (handling autoplay blocks) + setTimeout(() => { + if (videoBackdrop.paused) { + console.warn(`Autoplay blocked for ${itemId}, attempting muted fallback`); + videoBackdrop.muted = true; + videoBackdrop.play().catch(err => console.error("Muted fallback failed", err)); + } + }, 1000); + }); + } else if (STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) { + const player = STATE.slideshow.videoPlayers[currentItemId]; + if (player && typeof player.loadVideoById === 'function' && player._videoId) { + // Use loadVideoById to enforce start and end times + player.loadVideoById({ + videoId: player._videoId, + startSeconds: player._startTime || 0, + endSeconds: player._endTime + }); + + if (STATE.slideshow.isMuted) { + player.mute(); + } else { + player.unMute(); + player.setVolume(40); + } + + // Check if playback successfully started, otherwise fallback to muted + setTimeout(() => { + if (player.getPlayerState && + player.getPlayerState() !== YT.PlayerState.PLAYING && + player.getPlayerState() !== YT.PlayerState.BUFFERING) { + console.log("YouTube loadVideoById didn't start playback, retrying muted..."); + player.mute(); + player.playVideo(); + } + }, 1000); + } else if (player && typeof player.seekTo === 'function') { + // Fallback if loadVideoById is not available or videoId missing + const startTime = player._startTime || 0; + player.seekTo(startTime); + player.playVideo(); } - this.playCurrentVideo(currentSlide, currentItemId); - } - // Update mute button visibility - const muteButton = document.querySelector('.mute-button'); - if (muteButton) { - muteButton.style.display = videoBackdrop ? 'block' : 'none'; } } @@ -2398,8 +2394,7 @@ const SlideshowManager = { this.updateDots(); // Only restart interval if we are NOT waiting for a video to end - const hasVideo = currentSlide.querySelector('.video-backdrop') || - (STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]); + const hasVideo = currentSlide.querySelector('.video-backdrop'); if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) { if (CONFIG.waitForTrailerToEnd && hasVideo) { STATE.slideshow.slideInterval.stop(); @@ -2458,23 +2453,26 @@ const SlideshowManager = { async preloadAdjacentSlides(currentIndex) { const totalItems = STATE.slideshow.totalItems; const preloadCount = Math.min(Math.max(CONFIG.preloadCount || 1, 1), 5); - + const preloadedIds = new Set(); // Preload next slides for (let i = 1; i <= preloadCount; i++) { const nextIndex = (currentIndex + i) % totalItems; if (nextIndex === currentIndex) break; - const itemId = STATE.slideshow.itemIds[nextIndex]; - SlideCreator.createSlideForItemId(itemId); + if (!preloadedIds.has(itemId)) { + preloadedIds.add(itemId); + SlideCreator.createSlideForItemId(itemId); + } } - // Preload previous slides for (let i = 1; i <= preloadCount; i++) { const prevIndex = (currentIndex - i + totalItems) % totalItems; if (prevIndex === currentIndex) break; - const prevItemId = STATE.slideshow.itemIds[prevIndex]; - SlideCreator.createSlideForItemId(prevItemId); + if (!preloadedIds.has(prevItemId)) { + preloadedIds.add(prevItemId); + SlideCreator.createSlideForItemId(prevItemId); + } } }, @@ -2510,13 +2508,11 @@ const SlideshowManager = { if (index === -1) return; const totalItems = STATE.slideshow.itemIds.length; - - // Calculate wrapped distance let distance = Math.abs(index - currentIndex); if (totalItems > keepRange * 2) { distance = Math.min(distance, totalItems - distance); } - + if (distance > keepRange) { // Destroy video player if exists if (STATE.slideshow.videoPlayers[itemId]) { @@ -2541,13 +2537,11 @@ const SlideshowManager = { } }); - // After pruning, restore focus to container in TV mode if (prunedAny) { const isTvMode = (window.layoutManager && window.layoutManager.tv) || document.documentElement.classList.contains('layout-tv') || document.body.classList.contains('layout-tv'); if (isTvMode) { - // Use setTimeout to execute AFTER Jellyfin's focus manager processes the iframe removal setTimeout(() => { const container = document.getElementById("slides-container"); if (container && container.style.display !== 'none') { @@ -2674,187 +2668,6 @@ const SlideshowManager = { } }, - /** - * Pauses all video players except the one with the given item ID - * @param {string} excludeItemId - Item ID to exclude from pausing - */ - pauseOtherVideos(excludeItemId) { - // Pause YouTube players - if (STATE.slideshow.videoPlayers) { - Object.keys(STATE.slideshow.videoPlayers).forEach(id => { - if (id !== excludeItemId) { - const p = STATE.slideshow.videoPlayers[id]; - if (p) { - try { - if (typeof p.pauseVideo === 'function') { - p.pauseVideo(); - if (typeof p.mute === 'function') { - p.mute(); - } - } - else if (p.tagName === 'VIDEO') { - p.pause(); - p.muted = true; - } - } catch (e) { console.warn("Error pausing player", id, e); } - } - } - }); - } - // Pause HTML5 videos - document.querySelectorAll('video').forEach(video => { - const slideParent = video.closest('.slide'); - if (slideParent && slideParent.dataset.itemId !== excludeItemId) { - try { - video.pause(); - video.muted = true; - } catch (e) {} - } - }); - }, - - /** - * Plays the video backdrop on the given slide and updates mute button visibility. - * Includes a retry mechanism for YouTube players that aren't ready yet. - * @param {Element} slide - The slide DOM element - * @param {string} itemId - The item ID of the slide - */ - playCurrentVideo(slide, itemId) { - // Find video element — check class (covers both original div and iframe with class restored by onReady) - const videoBackdrop = slide.querySelector('.video-backdrop'); - const ytPlayer = STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[itemId]; - const hasAnyVideo = !!(videoBackdrop || ytPlayer); - - console.log(`[MBE-PLAY] playCurrentVideo for ${itemId}: videoBackdrop=${videoBackdrop?.tagName || 'null'}, ytPlayer=${!!ytPlayer}, ytReady=${ytPlayer && typeof ytPlayer.loadVideoById === 'function'}`); - - // Update mute button visibility - const muteButton = document.querySelector('.mute-button'); - if (muteButton) { - muteButton.style.display = hasAnyVideo ? 'block' : 'none'; - } - - if (!hasAnyVideo) { - console.log(`[MBE-PLAY] No video found for ${itemId}, skipping`); - return; - } - - // HTML5