From a8c7faab6b589f2eca7468efc0c911e1dd46d106 Mon Sep 17 00:00:00 2001 From: CodeDevMLH <145071728+CodeDevMLH@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:34:19 +0100 Subject: [PATCH] Add Safari support for YouTube video playback using plain iframe embed --- .../Web/mediaBarEnhanced.js | 290 ++++++++++-------- 1 file changed, 164 insertions(+), 126 deletions(-) diff --git a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js index feb32e0..b6cb6ff 100644 --- a/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js +++ b/Jellyfin.Plugin.MediaBarEnhanced/Web/mediaBarEnhanced.js @@ -1736,155 +1736,193 @@ const SlideCreator = { // Create a wrapper for opacity transition videoBackdrop = SlideUtils.createElement("div", { className: `backdrop video-backdrop ${videoClass}`, - style: "opacity: 0; transition: opacity 1.2s ease-in-out;" // Start interrupted/transparent + style: "opacity: 0; transition: opacity 1.2s ease-in-out;" }); - const ytPlayerDiv = SlideUtils.createElement("div", { - id: `youtube-player-${itemId}`, - style: "width: 100%; height: 100%;" - }); - - videoBackdrop.appendChild(ytPlayerDiv); + // Detect Safari/WebKit — the YouTube IFrame API causes Error 153 on WebKit + // due to cross-origin postMessage restrictions. Use a plain iframe embed instead. + const isSafariWebKit = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent) && !/Chromium/.test(navigator.userAgent); - // Initialize YouTube Player - SlideUtils.loadYouTubeIframeAPI().then(() => { - // Fetch SponsorBlock data - ApiUtils.fetchSponsorBlockData(videoId).then(segments => { - const playerVars = { - autoplay: 1, - mute: 1, // need to be muted for Safari, because apple makes life difficult... - controls: 0, - disablekb: 1, - fs: 0, - iv_load_policy: 3, - rel: 0, - loop: 0, - playsinline: 1, - origin: window.location.origin, - enablejsapi: 1 - }; + if (isSafariWebKit) { + // ── Safari: plain iframe embed ─────────────────────────────────────────── + const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&controls=0&playsinline=1&rel=0&iv_load_policy=3&enablejsapi=0&origin=${encodeURIComponent(window.location.origin)}`; - // Determine video quality - let quality = 'hd1080'; - if (CONFIG.preferredVideoQuality === 'Maximum') { - quality = 'highres'; - } else if (CONFIG.preferredVideoQuality === '720p') { - quality = 'hd720'; - } else if (CONFIG.preferredVideoQuality === '1080p') { - quality = 'hd1080'; - } else { // Auto or fallback - // If screen is wider than 1920, prefer highres, otherwise 1080p - quality = window.screen.width > 1920 ? 'highres' : 'hd1080'; - } + const ytIframe = document.createElement('iframe'); + ytIframe.style.cssText = 'width:100%;height:100%;border:0;pointer-events:none;'; + ytIframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'); + ytIframe.setAttribute('allowfullscreen', ''); + ytIframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + ytIframe.src = embedUrl; + videoBackdrop.appendChild(ytIframe); - playerVars.suggestedQuality = quality; + // Show immediately — no onStateChange available for plain iframes + videoBackdrop.style.opacity = '1'; - // Apply SponsorBlock start/end times - if (segments.intro) { - playerVars.start = Math.ceil(segments.intro[1]); - console.info(`SponsorBlock intro detected for video ${videoId}: skipping to ${playerVars.start}s`); - } - if (segments.outro) { - playerVars.end = Math.floor(segments.outro[0]); - console.info(`SponsorBlock outro detected for video ${videoId}: ending at ${playerVars.end}s`); - } + // Create a stub player compatible with all slide management code + STATE.slideshow.videoPlayers[itemId] = { + _isSafariIframe: true, + _iframe: ytIframe, + _videoId: videoId, + _embedUrl: embedUrl, + pauseVideo() { ytIframe.src = ''; }, + stopVideo() { ytIframe.src = ''; }, + playVideo() { if (!ytIframe.src) ytIframe.src = this._embedUrl; }, + mute() { /* cannot mute plain iframe mid-play */ }, + unMute() { /* cannot unmute plain iframe mid-play */ }, + setVolume() { /* not available */ }, + getIframe() { return ytIframe; }, + getPlayerState() { return 1; }, // always report PLAYING so fallback timeouts don't fire + loadVideoById({ videoId: vid }) { + const url = `https://www.youtube-nocookie.com/embed/${vid}?autoplay=1&mute=1&controls=0&playsinline=1&rel=0&iv_load_policy=3&enablejsapi=0`; + ytIframe.src = url; + this._videoId = vid; + this._embedUrl = url; + }, + destroy() { ytIframe.remove(); } + }; - STATE.slideshow.videoPlayers[itemId] = new YT.Player(`youtube-player-${itemId}`, { - height: '100%', - width: '100%', - videoId: videoId, - host: 'https://www.youtube-nocookie.com', - playerVars: playerVars, - events: { - 'onReady': (event) => { - const iframe = event.target.getIframe(); - if (iframe) { - iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); - // Full allow attribute matching what YouTube sets on their own embed pages - iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'); - } + console.log(`🍎 Safari detected — using plain iframe embed for YouTube video ${videoId}`); - // Store start/end time and videoId for later use - event.target._startTime = playerVars.start || 0; - event.target._endTime = playerVars.end || undefined; - event.target._videoId = videoId; - - // Store reference to wrapper for fading - event.target._wrapperDiv = videoBackdrop; + } else { + // ── Non-Safari: YouTube IFrame API ────────────────────────────────────── + const ytPlayerDiv = SlideUtils.createElement("div", { + id: `youtube-player-${itemId}`, + style: "width: 100%; height: 100%;" + }); - // Unmute now if user wants sound. - if (!STATE.slideshow.isMuted) { - event.target.unMute(); - event.target.setVolume(40); - } + videoBackdrop.appendChild(ytPlayerDiv); - if (typeof event.target.setPlaybackQuality === 'function') { - event.target.setPlaybackQuality(quality); - } + SlideUtils.loadYouTubeIframeAPI().then(() => { + ApiUtils.fetchSponsorBlockData(videoId).then(segments => { + const playerVars = { + autoplay: 1, + mute: 1, // need to be muted for Safari, because apple makes life difficult... + controls: 0, + disablekb: 1, + fs: 0, + iv_load_policy: 3, + rel: 0, + loop: 0, + playsinline: 1, + origin: window.location.origin, + enablejsapi: 1 + }; - // Stop playback if slide was navigated away from - const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); - const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer'); + // Determine video quality + let quality = 'hd1080'; + if (CONFIG.preferredVideoQuality === 'Maximum') { + quality = 'highres'; + } else if (CONFIG.preferredVideoQuality === '720p') { + quality = 'hd720'; + } else if (CONFIG.preferredVideoQuality === '1080p') { + quality = 'hd1080'; + } else { + quality = window.screen.width > 1920 ? 'highres' : 'hd1080'; + } - if (!slide || !slide.classList.contains('active') || document.hidden || (isVideoPlayerOpen && !isVideoPlayerOpen.classList.contains('hide'))) { - event.target.stopVideo(); - } else { - // Pause slideshow timer when video starts if configured - if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { - STATE.slideshow.slideInterval.stop(); + playerVars.suggestedQuality = quality; + + // Apply SponsorBlock start/end times + if (segments.intro) { + playerVars.start = Math.ceil(segments.intro[1]); + console.info(`SponsorBlock intro detected for video ${videoId}: skipping to ${playerVars.start}s`); + } + if (segments.outro) { + playerVars.end = Math.floor(segments.outro[0]); + console.info(`SponsorBlock outro detected for video ${videoId}: ending at ${playerVars.end}s`); + } + + STATE.slideshow.videoPlayers[itemId] = new YT.Player(`youtube-player-${itemId}`, { + height: '100%', + width: '100%', + videoId: videoId, + host: 'https://www.youtube-nocookie.com', + playerVars: playerVars, + events: { + 'onReady': (event) => { + const iframe = event.target.getIframe(); + if (iframe) { + iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + // Full allow attribute matching what YouTube sets on their own embed pages + iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'); } - // Safety check after 1s: handle navigation-away during the window, - // and fallback to muted play if autoplay failed for any reason. - const timeoutId = setTimeout(() => { - const isVideoPlayerOpenNow = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer'); - if (document.hidden || (isVideoPlayerOpenNow && !isVideoPlayerOpenNow.classList.contains('hide')) || !slide.classList.contains('active')) { - console.log(`Navigation detected during autoplay check for ${itemId}, stopping video.`); - try { - event.target.stopVideo(); - } catch (e) { console.warn("Error stopping video:", e); } - return; - } + // Store start/end time and videoId for later use + event.target._startTime = playerVars.start || 0; + event.target._endTime = playerVars.end || undefined; + event.target._videoId = videoId; - // If somehow not playing/buffering yet, force muted fallback - const state = event.target.getPlayerState(); - if (state !== YT.PlayerState.PLAYING && state !== YT.PlayerState.BUFFERING) { - console.warn(`Autoplay stalled for ${itemId}, attempting muted fallback`); - event.target.mute(); - event.target.playVideo(); - } - }, 1000); + // Store reference to wrapper for fading + event.target._wrapperDiv = videoBackdrop; - if (!STATE.slideshow.autoplayTimeouts) STATE.slideshow.autoplayTimeouts = []; - STATE.slideshow.autoplayTimeouts.push(timeoutId); - } - }, - 'onStateChange': (event) => { - // Fade in when playing - if (event.data === YT.PlayerState.PLAYING) { - if (event.target._wrapperDiv) { - event.target._wrapperDiv.style.opacity = "1"; - } - } - - if (event.data === YT.PlayerState.ENDED) { + // Unmute now if user wants sound. + if (!STATE.slideshow.isMuted) { + event.target.unMute(); + event.target.setVolume(40); + } + + if (typeof event.target.setPlaybackQuality === 'function') { + event.target.setPlaybackQuality(quality); + } + + // Stop playback if slide was navigated away from const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); - if (slide && slide.classList.contains('active')) { + const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer'); + + if (!slide || !slide.classList.contains('active') || document.hidden || (isVideoPlayerOpen && !isVideoPlayerOpen.classList.contains('hide'))) { + event.target.stopVideo(); + } else { + if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.stop(); + } + + const timeoutId = setTimeout(() => { + const isVideoPlayerOpenNow = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer'); + if (document.hidden || (isVideoPlayerOpenNow && !isVideoPlayerOpenNow.classList.contains('hide')) || !slide.classList.contains('active')) { + console.log(`Navigation detected during autoplay check for ${itemId}, stopping video.`); + try { + event.target.stopVideo(); + } catch (e) { console.warn("Error stopping video:", e); } + return; + } + + const state = event.target.getPlayerState(); + if (state !== YT.PlayerState.PLAYING && state !== YT.PlayerState.BUFFERING) { + console.warn(`Autoplay stalled for ${itemId}, attempting muted fallback`); + event.target.mute(); + event.target.playVideo(); + } + }, 1000); + + if (!STATE.slideshow.autoplayTimeouts) STATE.slideshow.autoplayTimeouts = []; + STATE.slideshow.autoplayTimeouts.push(timeoutId); + } + }, + 'onStateChange': (event) => { + if (event.data === YT.PlayerState.PLAYING) { + if (event.target._wrapperDiv) { + event.target._wrapperDiv.style.opacity = "1"; + } + } + + if (event.data === YT.PlayerState.ENDED) { + const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); + if (slide && slide.classList.contains('active')) { + SlideshowManager.nextSlide(); + } + } + }, + 'onError': (event) => { + console.warn(`YouTube player error ${event.data} for video ${videoId}`); + if (CONFIG.waitForTrailerToEnd) { SlideshowManager.nextSlide(); } } - }, - 'onError': (event) => { - console.warn(`YouTube player error ${event.data} for video ${videoId}`); - // Fallback to next slide on error - if (CONFIG.waitForTrailerToEnd) { - SlideshowManager.nextSlide(); - } } - } + }); }); }); - }); + } // end non-Safari // 2. Check for local video trailers in MediaSources if yt is not available } else if (!isYoutube) {