Fix SPA transitions: title, cover image, and slideshow not updating

Three bugs in the Up Next / playlist SPA transition for music-type videos:
1. Title selector was '.audio-title' (doesn't exist) instead of '.video-title span'
2. Cover image only updated #audioCoverImg — missed when video has a slideshow
3. Slideshow SLIDE_URLS lived in a closed IIFE and couldn't be updated cross-video

Fix: always render both #audioCoverImg and #slideshowWrap in the DOM (toggle
display via inline style), hoist slideshow state variables outside the if-block,
and expose window._audioPlayerUpdate(d) that both recTransitionTo and
plTransitionTo call. The hook handles all cases: cover-to-cover, cover-to-slideshow,
slideshow-to-cover, slideshow-to-slideshow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-16 23:02:21 +03:00
parent 2c0888088d
commit 6e7d5d178a
2 changed files with 111 additions and 124 deletions

View File

@ -11,15 +11,12 @@
<div class="ytp-wrap" id="ytpWrap"> <div class="ytp-wrap" id="ytpWrap">
<div class="ytp audio-ytp" id="audioContainer" tabindex="0"> <div class="ytp audio-ytp" id="audioContainer" tabindex="0">
{{-- Cover art / slideshow --}} {{-- Cover art / slideshow both always in DOM so SPA transitions can switch between them --}}
@if(count($slideUrls) > 1) <img src="{{ $coverUrl }}" alt="{{ $video->title }}" class="audio-cover-img" id="audioCoverImg"@if(count($slideUrls) > 1) style="display:none"@endif>
<div class="slideshow-wrap" id="slideshowWrap"> <div class="slideshow-wrap" id="slideshowWrap"@if(count($slideUrls) <= 1) style="display:none"@endif>
<img src="{{ $slideUrls[0] }}" alt="" class="slide-img slide-a" id="slideA"> <img src="{{ $slideUrls[0] ?? $coverUrl }}" alt="" class="slide-img slide-a" id="slideA">
<img src="{{ $slideUrls[1] }}" alt="" class="slide-img slide-b" id="slideB"> <img src="{{ count($slideUrls) > 1 ? $slideUrls[1] : ($slideUrls[0] ?? $coverUrl) }}" alt="" class="slide-img slide-b" id="slideB">
</div> </div>
@else
<img src="{{ $coverUrl }}" alt="{{ $video->title }}" class="audio-cover-img" id="audioCoverImg">
@endif
{{-- Bars canvas overlay --}} {{-- Bars canvas overlay --}}
<canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas> <canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas>
@ -726,53 +723,37 @@ function stopBars() {
} }
// ── Crossfade slideshow ─────────────────────────────────────── // ── Crossfade slideshow ───────────────────────────────────────
// Variables hoisted outside the if-block so the SPA update hook can access them
const SLIDE_URLS = @json($slideUrls); const SLIDE_URLS = @json($slideUrls);
if (SLIDE_URLS.length > 1) {
const slideA = document.getElementById('slideA'); const slideA = document.getElementById('slideA');
const slideB = document.getElementById('slideB'); const slideB = document.getElementById('slideB');
let currentSlide = 0; let currentSlide = 0;
let aIsTop = true; // slideA starts on top (opacity 1) let aIsTop = true;
let slideshowTimer = null;
// Preload all images and detect portrait orientation const slideOrientations = new Array(Math.max(SLIDE_URLS.length, 1)).fill(false);
const slideOrientations = new Array(SLIDE_URLS.length).fill(false); // true = portrait
SLIDE_URLS.forEach((url, idx) => {
const img = new Image();
img.onload = () => { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
img.src = url;
});
function applyOrientation(el, idx) { function applyOrientation(el, idx) {
if (!el) return;
el.classList.toggle('portrait', !!slideOrientations[idx]); el.classList.toggle('portrait', !!slideOrientations[idx]);
} }
function getSlideInterval() { function getSlideInterval() {
return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000; return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000;
} }
function advanceSlide() { function advanceSlide() {
if (!slideA || !slideB || SLIDE_URLS.length <= 1) return;
currentSlide = (currentSlide + 1) % SLIDE_URLS.length; currentSlide = (currentSlide + 1) % SLIDE_URLS.length;
const next = SLIDE_URLS[currentSlide]; const next = SLIDE_URLS[currentSlide];
if (aIsTop) { if (aIsTop) {
slideB.src = next; slideB.src = next; applyOrientation(slideB, currentSlide);
applyOrientation(slideB, currentSlide); slideB.style.zIndex = '2'; slideB.style.opacity = '1';
slideB.style.zIndex = '2'; slideA.style.opacity = '0'; slideA.style.zIndex = '1';
slideB.style.opacity = '1';
slideA.style.opacity = '0';
slideA.style.zIndex = '1';
} else { } else {
slideA.src = next; slideA.src = next; applyOrientation(slideA, currentSlide);
applyOrientation(slideA, currentSlide); slideA.style.zIndex = '2'; slideA.style.opacity = '1';
slideA.style.zIndex = '2'; slideB.style.opacity = '0'; slideB.style.zIndex = '1';
slideA.style.opacity = '1';
slideB.style.opacity = '0';
slideB.style.zIndex = '1';
} }
aIsTop = !aIsTop; aIsTop = !aIsTop;
} }
let slideshowTimer = null;
function startSlideshow() { function startSlideshow() {
if (slideshowTimer || SLIDE_URLS.length <= 1) return; if (slideshowTimer || SLIDE_URLS.length <= 1) return;
slideshowTimer = setInterval(advanceSlide, getSlideInterval()); slideshowTimer = setInterval(advanceSlide, getSlideInterval());
@ -782,51 +763,79 @@ if (SLIDE_URLS.length > 1) {
slideshowTimer = null; slideshowTimer = null;
} }
// Always attach listeners; functions guard themselves with SLIDE_URLS.length check
audio.addEventListener('play', startSlideshow); audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow); audio.addEventListener('pause', stopSlideshow);
audio.addEventListener('ended', stopSlideshow); audio.addEventListener('ended', stopSlideshow);
// Seek: jump to the correct slide instantly (no transition flash)
audio.addEventListener('seeked', () => { audio.addEventListener('seeked', () => {
if (!audio.duration) return; if (SLIDE_URLS.length <= 1 || !slideA || !slideB || !audio.duration) return;
stopSlideshow(); stopSlideshow();
const idx = Math.min( const idx = Math.min(Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length), SLIDE_URLS.length - 1);
Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length),
SLIDE_URLS.length - 1
);
currentSlide = idx; currentSlide = idx;
// Reset: show correct slide without animation slideA.style.transition = 'none'; slideB.style.transition = 'none';
slideA.style.transition = 'none'; slideA.src = SLIDE_URLS[idx]; applyOrientation(slideA, idx);
slideB.style.transition = 'none';
slideA.src = SLIDE_URLS[idx];
applyOrientation(slideA, idx);
slideA.style.opacity = '1'; slideA.style.zIndex = '2'; slideA.style.opacity = '1'; slideA.style.zIndex = '2';
slideB.style.opacity = '0'; slideB.style.zIndex = '1'; slideB.style.opacity = '0'; slideB.style.zIndex = '1';
const nextIdx = (idx + 1) % SLIDE_URLS.length; const nextIdx = (idx + 1) % SLIDE_URLS.length;
slideB.src = SLIDE_URLS[nextIdx]; slideB.src = SLIDE_URLS[nextIdx]; applyOrientation(slideB, nextIdx);
applyOrientation(slideB, nextIdx);
aIsTop = true; aIsTop = true;
requestAnimationFrame(() => { requestAnimationFrame(() => { slideA.style.transition = ''; slideB.style.transition = ''; });
slideA.style.transition = '';
slideB.style.transition = '';
});
if (!audio.paused) startSlideshow(); if (!audio.paused) startSlideshow();
}); });
// Extract colors + apply orientation for first slide // Preload orientations
SLIDE_URLS.forEach((url, idx) => {
const img = new Image();
img.onload = () => { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
img.src = url;
});
if (SLIDE_URLS.length > 1 && slideA) {
function initSlideA() { function initSlideA() {
extractColors(slideA); extractColors(slideA);
const portrait = slideA.naturalHeight > slideA.naturalWidth; const portrait = slideA.naturalHeight > slideA.naturalWidth;
slideA.classList.toggle('portrait', portrait); slideA.classList.toggle('portrait', portrait);
slideOrientations[0] = portrait; slideOrientations[0] = portrait;
} }
if (slideA.complete && slideA.naturalWidth) { if (slideA.complete && slideA.naturalWidth) initSlideA();
initSlideA(); else slideA.addEventListener('load', initSlideA, { once: true });
}
// ── SPA update hook — called by recTransitionTo / plTransitionTo ──────────
window._audioPlayerUpdate = function(d) {
var newSlides = (d.slides && d.slides.length > 1) ? d.slides : [];
var coverEl = document.getElementById('audioCoverImg');
var slideshowEl = document.getElementById('slideshowWrap');
stopSlideshow();
SLIDE_URLS.length = 0;
if (newSlides.length > 1) {
newSlides.forEach(function(s) { SLIDE_URLS.push(s); });
if (coverEl) coverEl.style.display = 'none';
if (slideshowEl) slideshowEl.style.display = '';
currentSlide = 0; aIsTop = true;
if (slideA) { slideA.style.transition='none'; slideA.src=newSlides[0]; slideA.style.opacity='1'; slideA.style.zIndex='2'; }
if (slideB) { slideB.style.transition='none'; slideB.src=newSlides[1]||newSlides[0]; slideB.style.opacity='0'; slideB.style.zIndex='1'; }
requestAnimationFrame(function() { if(slideA) slideA.style.transition=''; if(slideB) slideB.style.transition=''; });
} else { } else {
slideA.addEventListener('load', initSlideA, { once: true }); if (slideshowEl) slideshowEl.style.display = 'none';
} if (coverEl) { coverEl.style.display = ''; coverEl.src = d.cover_url || ''; }
} }
// Update page title
var titleEl = document.querySelector('.video-title span');
if (titleEl) titleEl.textContent = d.title || '';
document.title = (d.title || '') + ' | {{ config("app.name") }}';
// Reset progress bar
if (played) played.style.width = '0%';
if (scrubber) scrubber.parentElement.style.left = '0%';
if (timeCur) timeCur.textContent = '0:00';
if (timeDur && d.duration) timeDur.textContent = fmt(d.duration);
};
// ── Init ───────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────
const savedVol = localStorage.getItem('ytpVolume'); const savedVol = localStorage.getItem('ytpVolume');
const savedMuted = localStorage.getItem('ytpMuted'); const savedMuted = localStorage.getItem('ytpMuted');

View File

@ -559,24 +559,8 @@
var audio = document.getElementById('audioEl'); var audio = document.getElementById('audioEl');
if (audio) { audio.src=d.stream_url; audio.load(); } if (audio) { audio.src=d.stream_url; audio.load(); }
// update cover / slideshow // update cover / slideshow / title / progress via shared hook
var ci = document.getElementById('audioCoverImg'); if (window._audioPlayerUpdate) window._audioPlayerUpdate(d);
if (ci) ci.src = d.cover_url;
var sa = document.getElementById('slideA');
if (sa) sa.src = d.cover_url;
// reset progress
var pl=document.getElementById('ytpPlayed'), sc=document.getElementById('ytpScrubber');
var cu=document.getElementById('ytpCurrent'), dr=document.getElementById('ytpDuration');
if(pl) pl.style.width='0%';
if(sc) sc.style.left='0%';
if(cu) cu.textContent='0:00';
if(dr&&d.duration) dr.textContent=plFmt(d.duration);
// update visible title
var ts=document.querySelector('.video-title span');
if(ts) ts.textContent=d.title;
document.title=d.title+' | {{ config("app.name") }}';
// update state // update state
PL_CURRENT = d.id; PL_CURRENT = d.id;
@ -762,17 +746,11 @@
var d = await resp.json(); var d = await resp.json();
var audio = document.getElementById('audioEl'); var audio = document.getElementById('audioEl');
if (audio) { if (audio) { audio.src = d.stream_url; audio.load(); }
audio.src = d.stream_url;
audio.load();
audio.play().catch(function(){});
}
var coverImg = document.querySelector('.audio-cover-img'); if (window._audioPlayerUpdate) window._audioPlayerUpdate(d);
if (coverImg) coverImg.src = d.cover_url || '';
var titleEl = document.querySelector('.audio-title'); if (audio) audio.play().catch(function(){});
if (titleEl) titleEl.textContent = d.title || '';
if (pushHist !== false) history.pushState({ url: url }, '', url); if (pushHist !== false) history.pushState({ url: url }, '', url);