SPA playlist transitions — no page refresh on track change

- Add GET /videos/{video}/player-data JSON endpoint returning stream URL,
  cover, slides, title, duration (used by client-side SPA transitions)
- Replace music playlist JS with full SPA system: plTransitionTo() swaps
  audio.src in-place (preserving browser autoplay permission), updates
  cover art, resets progress bar, then background-fetches the new page
  to swap #vdbWrap (description) and #ytcSection (comments) via DOMParser
- plSwapContent() re-runs the YTC comments IIFE after swapping innerHTML
  so comments load correctly for the new video
- Prev/next/shuffle/loop/autoplay controls now computed dynamically from
  PL_VIDEOS array — buttons stay correct after each SPA transition
- Sidebar shows ALL playlist tracks (removed @if filter); current track
  highlighted in red; clicking any card triggers SPA transition
- Browser back/forward handled via popstate + history.pushState

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-16 12:12:22 +03:00
parent 99f71c54e5
commit da02425aeb
3 changed files with 186 additions and 48 deletions

View File

@ -465,6 +465,33 @@ class VideoController extends Controller
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
} }
public function playerData(Video $video, Request $request)
{
if (! $video->canView(Auth::user())) {
abort(403);
}
$coverUrl = $video->thumbnail
? route('media.thumbnail', $video->thumbnail)
: asset('storage/images/logo.png');
$slides = $video->slides->count() > 1
? $video->slides->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()
: [];
return response()->json([
'id' => $video->id,
'key' => $video->getRouteKey(),
'type' => $video->type,
'stream_url' => route('videos.stream', $video),
'cover_url' => $coverUrl,
'slides' => $slides,
'title' => $video->title,
'author' => $video->user->name ?? '',
'duration' => $video->duration,
]);
}
public function matchData(Video $video) public function matchData(Video $video)
{ {
if (! $video->canView(Auth::user())) { if (! $video->canView(Auth::user())) {

View File

@ -299,6 +299,14 @@
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
border-radius: 8px;
padding: 4px;
transition: background .15s;
}
.sidebar-video-card.current-video {
background: rgba(239,68,68,.12);
border-left: 3px solid #ef4444;
} }
.sidebar-thumb { .sidebar-thumb {
@ -479,12 +487,10 @@
{{-- Playlist controls --}} {{-- Playlist controls --}}
<div class="pl-controls-bar"> <div class="pl-controls-bar">
<button class="pl-ctrl-btn" id="plPrevBtn" title="Previous" <button class="pl-ctrl-btn" id="plPrevBtn" title="Previous" onclick="window.plPrev()">
@if(!($previousVideo ?? null)) disabled @endif
onclick="plGoTo('{{ ($previousVideo ?? null) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : '' }}')">
<i class="bi bi-skip-start-fill"></i> <i class="bi bi-skip-start-fill"></i>
</button> </button>
<button class="pl-ctrl-btn" id="plNextBtn" title="Next" onclick="plNext()"> <button class="pl-ctrl-btn" id="plNextBtn" title="Next" onclick="window.plNext()">
<i class="bi bi-skip-end-fill"></i> <i class="bi bi-skip-end-fill"></i>
</button> </button>
<div class="pl-ctrl-divider"></div> <div class="pl-ctrl-divider"></div>
@ -504,9 +510,6 @@
(function () { (function () {
var PL_ID = '{{ $playlist->share_token }}'; var PL_ID = '{{ $playlist->share_token }}';
var PL_CURRENT = {{ $video->id }}; var PL_CURRENT = {{ $video->id }};
var PL_NEXT_URL = '{{ ($nextVideo ?? null) ? route("videos.show", $nextVideo)."?playlist=".$playlist->share_token : "" }}';
var PL_PREV_URL = '{{ ($previousVideo ?? null) ? route("videos.show", $previousVideo)."?playlist=".$playlist->share_token : "" }}';
var PL_FIRST_URL= '{{ $playlistVideos->count() ? route("videos.show", $playlistVideos->first())."?playlist=".$playlist->share_token : "" }}';
var PL_VIDEOS = {!! $playlistVideos->map(fn($v) => ['id' => $v->id, 'url' => route('videos.show', $v).'?playlist='.$playlist->share_token])->values()->toJson() !!}; var PL_VIDEOS = {!! $playlistVideos->map(fn($v) => ['id' => $v->id, 'url' => route('videos.show', $v).'?playlist='.$playlist->share_token])->values()->toJson() !!};
function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; } function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; }
@ -515,36 +518,132 @@
var plAutoplay = plGet('autoplay','1'); var plAutoplay = plGet('autoplay','1');
var plLoop = plGet('loop','off'); var plLoop = plGet('loop','off');
var plShuffle = plGet('shuffle','0'); var plShuffle = plGet('shuffle','0');
var plTransiting = false;
// ── shuffle helpers ──────────────────────────────────────
function plShuffleOrder(){ var o=PL_VIDEOS.map(function(_,i){return i;}); for(var i=o.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=o[i];o[i]=o[j];o[j]=t;} localStorage.setItem('pl_shuffleOrder_'+PL_ID,JSON.stringify(o)); return o; } function plShuffleOrder(){ var o=PL_VIDEOS.map(function(_,i){return i;}); for(var i=o.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=o[i];o[i]=o[j];o[j]=t;} localStorage.setItem('pl_shuffleOrder_'+PL_ID,JSON.stringify(o)); return o; }
function plGetOrder(){ var r=localStorage.getItem('pl_shuffleOrder_'+PL_ID); if(r){try{return JSON.parse(r);}catch(e){}} return null; } function plGetOrder(){ var r=localStorage.getItem('pl_shuffleOrder_'+PL_ID); if(r){try{return JSON.parse(r);}catch(e){}} return null; }
window.plGoTo = function(url){ if(url) window.location.href=url; }; // ── compute adjacent URLs from current state ──────────────
window.plNext = function(){ function plAdj(curId) {
var idx = PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
if (plShuffle==='1') { if (plShuffle==='1') {
var order=plGetOrder()||plShuffleOrder(); var ord=plGetOrder()||plShuffleOrder();
var curIdx=PL_VIDEOS.findIndex(function(v){return v.id===PL_CURRENT;}); var pos=ord.indexOf(idx);
var pos=order.indexOf(curIdx); var prevPos=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
var nxt=(pos+1)%order.length; var nextPos=pos<ord.length-1?pos+1:(plLoop==='all'?0:-1);
if(pos===order.length-1&&plLoop!=='all') return; return { prev: prevPos>=0?PL_VIDEOS[ord[prevPos]].url:'', next: nextPos>=0?PL_VIDEOS[ord[nextPos]].url:'' };
window.location.href=PL_VIDEOS[order[nxt]].url;
} else {
if(PL_NEXT_URL) window.location.href=PL_NEXT_URL;
else if(plLoop==='all'&&PL_FIRST_URL) window.location.href=PL_FIRST_URL;
} }
return {
prev: idx>0?PL_VIDEOS[idx-1].url:'',
next: idx<PL_VIDEOS.length-1?PL_VIDEOS[idx+1].url:(plLoop==='all'?PL_VIDEOS[0].url:'')
}; };
}
window._plOnTrackEnd = function(){ // ── time formatter ───────────────────────────────────────
if(plLoop==='one') return; // native loop handles it function plFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
if(plAutoplay==='1') window.plNext();
};
// ── SPA transition — swap audio src + update UI ───────────
async function plTransitionTo(url, pushHist) {
if (!url || plTransiting) return;
plTransiting = true;
try {
var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href=url; return; }
var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : '';
var resp = await fetch('/videos/'+m[1]+'/player-data'+qs);
if (!resp.ok) { window.location.href=url; return; }
var d = await resp.json();
// swap audio src (keeps browser autoplay permission)
var audio = document.getElementById('audioEl');
if (audio) { audio.src=d.stream_url; audio.load(); }
// update cover / slideshow
var ci = document.getElementById('audioCoverImg');
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
PL_CURRENT = d.id;
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
plRender();
plHighlight(d.id);
// history
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
// play (user already interacted — browser allows this)
if(audio) audio.play().catch(function(){});
// async: swap description + comments from fetched page
plSwapContent(url);
} catch(e) {
console.warn('plTransitionTo',e);
window.location.href=url;
} finally {
plTransiting=false;
}
}
// ── background page swap: description + comments ──────────
async function plSwapContent(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');
// swap description box
var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
// swap channel row
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
// swap comments (HTML + re-run its init script)
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);
}
}
} catch(e){ console.warn('plSwapContent',e); }
}
// ── sidebar highlight ────────────────────────────────────
function plHighlight(activeId) {
document.querySelectorAll('.sidebar-video-card[data-pl-id]').forEach(function(c){
c.classList.toggle('current-video', parseInt(c.dataset.plId)===activeId);
});
var active=document.querySelector('.sidebar-video-card.current-video');
if(active) active.scrollIntoView({behavior:'smooth',block:'nearest'});
}
// ── render control button states ─────────────────────────
function plRender(){ function plRender(){
var sb=document.getElementById('plShuffleBtn'); var adj=plAdj(PL_CURRENT);
var lb=document.getElementById('plLoopBtn'); var sb=document.getElementById('plShuffleBtn'),lb=document.getElementById('plLoopBtn');
var ab=document.getElementById('plAutoplayBtn'); var ab=document.getElementById('plAutoplayBtn'),nb=document.getElementById('plNextBtn'),pb=document.getElementById('plPrevBtn');
var nb=document.getElementById('plNextBtn');
var pb=document.getElementById('plPrevBtn');
if(sb){ sb.classList.toggle('pl-ctrl-active',plShuffle==='1'); sb.title='Shuffle: '+(plShuffle==='1'?'On':'Off'); } if(sb){ sb.classList.toggle('pl-ctrl-active',plShuffle==='1'); sb.title='Shuffle: '+(plShuffle==='1'?'On':'Off'); }
if(lb){ if(lb){
lb.classList.remove('pl-ctrl-active'); lb.classList.remove('pl-ctrl-active');
@ -553,29 +652,41 @@
else { lb.innerHTML='<i class="bi bi-repeat"></i>'; lb.title='Loop: Off'; } else { lb.innerHTML='<i class="bi bi-repeat"></i>'; lb.title='Loop: Off'; }
} }
if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); } if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); }
if(nb) nb.disabled=!PL_NEXT_URL&&plLoop!=='all'&&plShuffle!=='1'; if(nb) nb.disabled=!adj.next;
if(pb) pb.disabled=!PL_PREV_URL; if(pb) pb.disabled=!adj.prev;
} }
// ── public API ────────────────────────────────────────────
window.plGoTo = function(url){ if(url) plTransitionTo(url); };
window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
window._plOnTrackEnd = function(){ if(plLoop==='one') return; if(plAutoplay==='1') window.plNext(); };
window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); }; window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); };
window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var a=document.getElementById('audioEl'); if(a) a.loop=(plLoop==='one'); }; window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var a=document.getElementById('audioEl'); if(a) a.loop=(plLoop==='one'); };
window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); }; window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
// Apply state after audio player is ready // ── browser back/forward ──────────────────────────────────
document.addEventListener('DOMContentLoaded', function(){ window.addEventListener('popstate', function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url, false); });
// ── init ──────────────────────────────────────────────────
function plInit(){
var a=document.getElementById('audioEl'); var a=document.getElementById('audioEl');
if(a&&plLoop==='one') a.loop=true; if(a&&plLoop==='one') a.loop=true;
plRender(); plRender();
}); plHighlight(PL_CURRENT);
if(document.readyState!=='loading'){ var a=document.getElementById('audioEl'); if(a&&plLoop==='one') a.loop=true; plRender(); } }
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',plInit); else plInit();
})(); })();
</script> </script>
<div class="recommended-videos-list"> <div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo) @foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id) @php $isCurrentTrack = $playlistVideo->id === $video->id; @endphp
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}" <div class="sidebar-video-card{{ $isCurrentTrack ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'"> data-pl-id="{{ $playlistVideo->id }}"
onclick="plGoTo('{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}')"
style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail) @if ($playlistVideo->thumbnail)
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}" <img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
@ -613,7 +724,6 @@
</div> </div>
</div> </div>
</div> </div>
@endif
@endforeach @endforeach
</div> </div>
@if ($playlist->canEdit(Auth::user())) @if ($playlist->canEdit(Auth::user()))

View File

@ -45,6 +45,7 @@ Route::get('/videos/{video}/download-mp3', [VideoController::class, 'downloadMp3
Route::post('/videos/{video}/slideshow/generate', [VideoController::class, 'slideshowGenerate'])->name('videos.slideshow.generate'); Route::post('/videos/{video}/slideshow/generate', [VideoController::class, 'slideshowGenerate'])->name('videos.slideshow.generate');
Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slideshowProgress'])->name('videos.slideshow.progress'); Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slideshowProgress'])->name('videos.slideshow.progress');
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations'); Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData');
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare'); Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage'); Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access'); Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access');