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,107 +723,119 @@ 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;
let aIsTop = true; // slideA starts on top (opacity 1) let slideshowTimer = null;
const slideOrientations = new Array(Math.max(SLIDE_URLS.length, 1)).fill(false);
// Preload all images and detect portrait orientation function applyOrientation(el, idx) {
const slideOrientations = new Array(SLIDE_URLS.length).fill(false); // true = portrait if (!el) return;
SLIDE_URLS.forEach((url, idx) => { el.classList.toggle('portrait', !!slideOrientations[idx]);
const img = new Image(); }
img.onload = () => { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; }; function getSlideInterval() {
img.src = url; return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000;
}); }
function advanceSlide() {
function applyOrientation(el, idx) { if (!slideA || !slideB || SLIDE_URLS.length <= 1) return;
el.classList.toggle('portrait', !!slideOrientations[idx]); currentSlide = (currentSlide + 1) % SLIDE_URLS.length;
} const next = SLIDE_URLS[currentSlide];
if (aIsTop) {
function getSlideInterval() { slideB.src = next; applyOrientation(slideB, currentSlide);
return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000; slideB.style.zIndex = '2'; slideB.style.opacity = '1';
} slideA.style.opacity = '0'; slideA.style.zIndex = '1';
} else {
function advanceSlide() { slideA.src = next; applyOrientation(slideA, currentSlide);
currentSlide = (currentSlide + 1) % SLIDE_URLS.length; slideA.style.zIndex = '2'; slideA.style.opacity = '1';
const next = SLIDE_URLS[currentSlide];
if (aIsTop) {
slideB.src = next;
applyOrientation(slideB, currentSlide);
slideB.style.zIndex = '2';
slideB.style.opacity = '1';
slideA.style.opacity = '0';
slideA.style.zIndex = '1';
} else {
slideA.src = next;
applyOrientation(slideA, currentSlide);
slideA.style.zIndex = '2';
slideA.style.opacity = '1';
slideB.style.opacity = '0';
slideB.style.zIndex = '1';
}
aIsTop = !aIsTop;
}
let slideshowTimer = null;
function startSlideshow() {
if (slideshowTimer || SLIDE_URLS.length <= 1) return;
slideshowTimer = setInterval(advanceSlide, getSlideInterval());
}
function stopSlideshow() {
clearInterval(slideshowTimer);
slideshowTimer = null;
}
audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow);
audio.addEventListener('ended', stopSlideshow);
// Seek: jump to the correct slide instantly (no transition flash)
audio.addEventListener('seeked', () => {
if (!audio.duration) return;
stopSlideshow();
const idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length),
SLIDE_URLS.length - 1
);
currentSlide = idx;
// Reset: show correct slide without animation
slideA.style.transition = 'none';
slideB.style.transition = 'none';
slideA.src = SLIDE_URLS[idx];
applyOrientation(slideA, idx);
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; }
slideB.src = SLIDE_URLS[nextIdx]; aIsTop = !aIsTop;
applyOrientation(slideB, nextIdx); }
aIsTop = true; function startSlideshow() {
requestAnimationFrame(() => { if (slideshowTimer || SLIDE_URLS.length <= 1) return;
slideA.style.transition = ''; slideshowTimer = setInterval(advanceSlide, getSlideInterval());
slideB.style.transition = ''; }
}); function stopSlideshow() {
if (!audio.paused) startSlideshow(); clearInterval(slideshowTimer);
}); slideshowTimer = null;
}
// Extract colors + apply orientation for first slide // Always attach listeners; functions guard themselves with SLIDE_URLS.length check
audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow);
audio.addEventListener('ended', stopSlideshow);
audio.addEventListener('seeked', () => {
if (SLIDE_URLS.length <= 1 || !slideA || !slideB || !audio.duration) return;
stopSlideshow();
const idx = Math.min(Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length), SLIDE_URLS.length - 1);
currentSlide = idx;
slideA.style.transition = 'none'; slideB.style.transition = 'none';
slideA.src = SLIDE_URLS[idx]; applyOrientation(slideA, idx);
slideA.style.opacity = '1'; slideA.style.zIndex = '2';
slideB.style.opacity = '0'; slideB.style.zIndex = '1';
const nextIdx = (idx + 1) % SLIDE_URLS.length;
slideB.src = SLIDE_URLS[nextIdx]; applyOrientation(slideB, nextIdx);
aIsTop = true;
requestAnimationFrame(() => { slideA.style.transition = ''; slideB.style.transition = ''; });
if (!audio.paused) startSlideshow();
});
// 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 });
} 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 {
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);