SPA transitions + autoplay for match type; add no-refresh rule to CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-16 13:45:16 +03:00
parent 5960c6e7b1
commit 4f275de15f
2 changed files with 317 additions and 59 deletions

View File

@ -98,6 +98,13 @@ All managed via `MatchEventController` under authenticated routes.
## Rules
**Never navigate between videos with a page refresh** — all video-to-video transitions (Up Next recommendations, playlist tracks, prev/next) must use JavaScript SPA transitions. Never use `window.location.href` or `<a>` tags with hard navigation for video card clicks. The established pattern is:
- **Video player (generic, match types):** `recTransitionTo(url)` / `plTransitionTo(url)` — fetch `/videos/{key}/player-data` JSON, call `window._ytpLoadSource(hlsUrl, mp4Url)`, then `recSwapContent(url)` / `plSwapContent(url)` in the background to update description, comments, and sidebar.
- **Audio player (music type):** same pattern but swap `audio.src` and `audio.play()` instead of `_ytpLoadSource`.
- **Sidebar cards** must have `data-rec-url` (Up Next) or `data-pl-id` (playlist) attributes and call `recGoTo(url)` / `plGoTo(url)` onclick — never `window.location.href`.
- **Autoplay on track end** is wired via `window._plOnVideoEnd` (video) or `window._plOnTrackEnd` (audio) hooks — the player calls these hooks on `ended`; the SPA script sets them.
- The only fallback to `window.location.href` is inside `catch` blocks when the fetch itself fails.
**Database changes require confirmation** — if any task requires a migration, schema change, or new column, always ask before proceeding.
**Never use `alert()`, `confirm()`, or `prompt()`** — use toast notifications or inline UI feedback instead.

View File

@ -2539,48 +2539,180 @@
{{-- Playlist controls --}}
<div class="pl-controls-bar">
<button class="pl-ctrl-btn" id="plPrevBtn" title="Previous"
@if(!($previousVideo ?? null)) disabled @endif
onclick="window._ytpNav?.prev()">
<button class="pl-ctrl-btn" id="plPrevBtn" title="Previous" onclick="window.plPrev()">
<i class="bi bi-skip-start-fill"></i>
</button>
<button class="pl-ctrl-btn" id="plNextBtn" title="Next"
onclick="window._ytpNav?.next()">
<button class="pl-ctrl-btn" id="plNextBtn" title="Next" onclick="window.plNext()">
<i class="bi bi-skip-end-fill"></i>
</button>
<div class="pl-ctrl-divider"></div>
<button class="pl-ctrl-btn" id="plShuffleBtn" title="Shuffle"
onclick="document.getElementById('ytpShuffleRow')?.click(); plSyncGeneric()">
<button class="pl-ctrl-btn" id="plShuffleBtn" title="Shuffle" onclick="plToggleShuffle()">
<i class="bi bi-shuffle"></i>
</button>
<button class="pl-ctrl-btn" id="plLoopBtn" title="Loop"
onclick="document.getElementById('ytpLoopRow')?.click(); plSyncGeneric()">
<button class="pl-ctrl-btn" id="plLoopBtn" title="Loop" onclick="plToggleLoop()">
<i class="bi bi-repeat"></i>
</button>
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="plAutoplayBtn" title="Autoplay"
onclick="document.getElementById('ytpAutoplayRow')?.click(); plSyncGeneric()">
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="plAutoplayBtn" title="Autoplay" onclick="plToggleAutoplay()">
<i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span>
</button>
</div>
<script>
function plSyncGeneric(){
var sb=document.getElementById('plShuffleBtn'), lb=document.getElementById('plLoopBtn'), ab=document.getElementById('plAutoplayBtn');
var sr=document.getElementById('ytpShuffleRow'), lr=document.getElementById('ytpLoopRow'), ar=document.getElementById('ytpAutoplayRow');
if(sb&&sr) sb.classList.toggle('pl-ctrl-active',sr.classList.contains('is-on'));
if(lb&&lr) lb.classList.toggle('pl-ctrl-active',lr.classList.contains('is-on'));
if(ab&&ar) ab.classList.toggle('pl-ctrl-active',ar.classList.contains('is-on'));
(function () {
var PL_ID = '{{ $playlist->share_token }}';
var PL_CURRENT = {{ $video->id }};
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 plSet(k,v){ localStorage.setItem('pl_'+k+'_'+PL_ID,v); }
var plAutoplay = plGet('autoplay','1');
var plLoop = plGet('loop','off');
var plShuffle = plGet('shuffle','0');
var plTransiting = false;
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 plAdj(curId) {
var idx=PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
if(plShuffle==='1'){
var ord=plGetOrder()||plShuffleOrder();
var pos=ord.indexOf(idx);
var pp=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
var np=pos<ord.length-1?pos+1:(plLoop==='all'?0:-1);
return { prev:pp>=0?PL_VIDEOS[ord[pp]].url:'', next:np>=0?PL_VIDEOS[ord[np]].url:'' };
}
document.addEventListener('DOMContentLoaded',function(){ setTimeout(plSyncGeneric,100); });
if(document.readyState!=='loading') setTimeout(plSyncGeneric,100);
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:'')
};
}
function plFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
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();
if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
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);
var ts=document.querySelector('.video-title span');
if(ts) ts.textContent=d.title;
document.title=d.title+' | {{ config("app.name") }}';
var vid=document.getElementById('videoPlayer');
if(vid) vid.loop=(plLoop==='one');
PL_CURRENT=d.id;
plRender();
plHighlight(d.id);
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
plSwapContent(url);
} catch(e){
console.warn('plTransitionTo',e);
window.location.href=url;
} finally {
plTransiting=false;
}
}
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');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
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); }
}
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 a=document.querySelector('.sidebar-video-card.current-video');
if(a) a.scrollIntoView({behavior:'smooth',block:'nearest'});
}
function plRender(){
var adj=plAdj(PL_CURRENT);
var sb=document.getElementById('plShuffleBtn'),lb=document.getElementById('plLoopBtn');
var ab=document.getElementById('plAutoplayBtn'),nb=document.getElementById('plNextBtn'),pb=document.getElementById('plPrevBtn');
if(sb){ sb.classList.toggle('pl-ctrl-active',plShuffle==='1'); sb.title='Shuffle: '+(plShuffle==='1'?'On':'Off'); }
if(lb){
lb.classList.remove('pl-ctrl-active');
if(plLoop==='all'){ lb.classList.add('pl-ctrl-active'); lb.innerHTML='<i class="bi bi-repeat"></i>'; lb.title='Loop: All'; }
else if(plLoop==='one'){ lb.classList.add('pl-ctrl-active'); lb.innerHTML='<i class="bi bi-repeat-1"></i>'; lb.title='Loop: One'; }
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(nb) nb.disabled=!adj.next;
if(pb) pb.disabled=!adj.prev;
}
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.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 v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
window._ytpNavOverride = {
next: function(_url){ window.plNext(); },
prev: function(_url){ window.plPrev(); }
};
window._plOnVideoEnd = function(){ if(plLoop==='one') return; if(plAutoplay==='1') window.plNext(); };
window.addEventListener('popstate',function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url,false); });
function plInit(){
var v=document.getElementById('videoPlayer');
if(v&&plLoop==='one') v.loop=true;
plRender();
plHighlight(PL_CURRENT);
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',plInit); else plInit();
})();
</script>
<div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
@php $isCurrentTrack = $playlistVideo->id === $video->id; @endphp
<div class="sidebar-video-card{{ $isCurrentTrack ? ' current-video' : '' }}"
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;">
@if ($playlistVideo->thumbnail)
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
@ -2618,7 +2750,6 @@
</div>
</div>
</div>
@endif
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
@ -2627,12 +2758,27 @@
</a>
@endif
@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)
<div class="recommended-videos-list">
<div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo)
<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) }}')"
style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
@ -2668,8 +2814,113 @@
@endforeach
</div>
@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
<script>
(function () {
var recTransiting = false;
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
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');
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();
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
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);
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);
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');
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
if(nv&&ov) ov.innerHTML=nv.innerHTML;
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
if(nc&&oc) oc.innerHTML=nc.innerHTML;
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);
}
}
var nSide=doc.querySelector('.yt-sidebar-container');
var oSide=document.querySelector('.yt-sidebar-container');
if(nSide&&oSide){
oSide.innerHTML=nSide.innerHTML;
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); };
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
</div>