Add SPA autoplay + no-refresh clicks to Up Next recommendations
- Autoplay toggle button in the Up Next header (defaults Off, persisted in localStorage as ytpAutoplay_solo) - When autoplay is On and video ends (_plOnVideoEnd hook), automatically loads the first recommended video via SPA — no page refresh - Clicking any recommended video uses recGoTo() → recTransitionTo() instead of window.location.href: swaps video source, resets progress bar, updates title, then background-swaps description, channel row, comments, and the entire sidebar with fresh recommendations from the next page - First card gets a red ▶ indicator while autoplay is On so the user can see what will play next - Browser back/forward work via popstate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
77e7b950be
commit
d73f877d18
@ -263,6 +263,16 @@
|
|||||||
border-left: 3px solid #ef4444;
|
border-left: 3px solid #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-video-card.rec-next-up {
|
||||||
|
border-left: 3px solid var(--brand-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-video-card.rec-next-up .sidebar-title::before {
|
||||||
|
content: '▶ ';
|
||||||
|
color: var(--brand-red);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-thumb {
|
.sidebar-thumb {
|
||||||
width: 168px;
|
width: 168px;
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
@ -676,12 +686,26 @@
|
|||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
{{-- Up Next header + autoplay toggle --}}
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
|
||||||
|
<h3 style="font-size:16px; font-weight:500; margin:0;">
|
||||||
|
<i class="bi bi-collection-play" style="margin-right:6px;"></i>Up Next
|
||||||
|
</h3>
|
||||||
|
<div class="pl-controls-bar" style="margin-bottom:0; flex-shrink:0;">
|
||||||
|
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="recAutoplayBtn"
|
||||||
|
title="Autoplay" onclick="recToggleAutoplay()">
|
||||||
|
<i class="bi bi-play-circle"></i>
|
||||||
|
<span class="pl-autoplay-label">Autoplay</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||||
<div class="recommended-videos-list">
|
<div class="recommended-videos-list" id="recList">
|
||||||
@foreach ($recommendedVideos as $recVideo)
|
@foreach ($recommendedVideos as $recVideo)
|
||||||
<div class="sidebar-video-card"
|
<div class="sidebar-video-card"
|
||||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
data-rec-url="{{ route('videos.show', $recVideo) }}"
|
||||||
|
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||||
@ -716,8 +740,127 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="text-secondary">No recommendations available yet. Check back later!</div>
|
<div class="text-secondary" id="recList">No recommendations available yet. Check back later!</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var recTransiting = false;
|
||||||
|
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') === '1';
|
||||||
|
|
||||||
|
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
|
||||||
|
|
||||||
|
function recRender() {
|
||||||
|
var btn = document.getElementById('recAutoplayBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.toggle('pl-ctrl-active', recAutoplay);
|
||||||
|
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
|
||||||
|
// highlight the next-up card
|
||||||
|
var cards = document.querySelectorAll('#recList .sidebar-video-card');
|
||||||
|
cards.forEach(function(c,i){ c.style.opacity = recAutoplay && i===0 ? '1' : ''; });
|
||||||
|
var first = cards[0];
|
||||||
|
if(first) first.classList.toggle('rec-next-up', recAutoplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.recToggleAutoplay = function() {
|
||||||
|
recAutoplay = !recAutoplay;
|
||||||
|
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
|
||||||
|
recRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function recTransitionTo(url, pushHist) {
|
||||||
|
if (!url || recTransiting) return;
|
||||||
|
recTransiting = true;
|
||||||
|
try {
|
||||||
|
var m = url.match(/\/videos\/([^/?#]+)/);
|
||||||
|
if (!m) { window.location.href = url; return; }
|
||||||
|
var resp = await fetch('/videos/' + m[1] + '/player-data');
|
||||||
|
if (!resp.ok) { window.location.href = url; return; }
|
||||||
|
var d = await resp.json();
|
||||||
|
|
||||||
|
// load new video source
|
||||||
|
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
|
||||||
|
|
||||||
|
// reset progress bar
|
||||||
|
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=recFmt(d.duration);
|
||||||
|
|
||||||
|
// update title
|
||||||
|
var ts = document.querySelector('.video-title span');
|
||||||
|
if (ts) ts.textContent = d.title;
|
||||||
|
document.title = d.title + ' | {{ config("app.name") }}';
|
||||||
|
|
||||||
|
if (pushHist !== false) history.pushState({recUrl: url}, '', url);
|
||||||
|
|
||||||
|
// async: swap description + comments + sidebar
|
||||||
|
recSwapContent(url);
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('recTransitionTo', e);
|
||||||
|
window.location.href = url;
|
||||||
|
} finally {
|
||||||
|
recTransiting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recSwapContent(url) {
|
||||||
|
try {
|
||||||
|
var resp = await fetch(url, {headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
var html = await resp.text();
|
||||||
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
// description
|
||||||
|
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
|
||||||
|
if(nv&&ov) ov.innerHTML=nv.innerHTML;
|
||||||
|
|
||||||
|
// channel row
|
||||||
|
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
|
||||||
|
if(nc&&oc) oc.innerHTML=nc.innerHTML;
|
||||||
|
|
||||||
|
// comments
|
||||||
|
var ny=doc.getElementById('ytcSection'),oy=document.getElementById('ytcSection');
|
||||||
|
if(ny&&oy){
|
||||||
|
oy.innerHTML=ny.innerHTML;
|
||||||
|
var ytcScript=Array.from(doc.querySelectorAll('script')).find(function(s){return s.textContent.includes('const YTC =');});
|
||||||
|
if(ytcScript){
|
||||||
|
var ns=document.createElement('script');
|
||||||
|
ns.textContent=ytcScript.textContent;
|
||||||
|
document.body.appendChild(ns);
|
||||||
|
document.body.removeChild(ns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidebar: swap inner content and re-run init scripts
|
||||||
|
var nSide=doc.querySelector('.yt-sidebar-container');
|
||||||
|
var oSide=document.querySelector('.yt-sidebar-container');
|
||||||
|
if(nSide&&oSide){
|
||||||
|
oSide.innerHTML=nSide.innerHTML;
|
||||||
|
// re-execute any inline scripts (rec init, playlist init, etc.)
|
||||||
|
Array.from(oSide.querySelectorAll('script')).forEach(function(s){
|
||||||
|
var ns=document.createElement('script');
|
||||||
|
ns.textContent=s.textContent;
|
||||||
|
document.body.appendChild(ns);
|
||||||
|
document.body.removeChild(ns);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e){ console.warn('recSwapContent', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.recGoTo = function(url) { if(url) recTransitionTo(url); };
|
||||||
|
|
||||||
|
// autoplay: when video ends, play the first rec card
|
||||||
|
window._plOnVideoEnd = function() {
|
||||||
|
if (!recAutoplay) return;
|
||||||
|
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||||
|
if (first) recTransitionTo(first.dataset.recUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', function(e){ if(e.state&&e.state.recUrl) recTransitionTo(e.state.recUrl,false); });
|
||||||
|
|
||||||
|
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',recRender); else recRender();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user