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