SPA playlist transitions for generic (video) type
- Add _ytpLoadSource(hlsUrl, mp4Url) to video-player component:
destroys old HLS instance, creates a new one with the new source,
then plays — browser retains autoplay permission since the <video>
element never leaves the page
- Add _ytpNavOverride hook: playlist overlay can replace navigateNext/
navigatePrev without modifying the component internals
- Add _plOnVideoEnd hook to 'ended' handler so the playlist overlay
can control autoplay/loop behavior independently
- Expose window._ytpHls for HLS instance lifecycle management
- Add hls_url + has_hls to /videos/{video}/player-data JSON endpoint
- Replace generic.blade.php playlist controls with full SPA system
identical in structure to music type: plTransitionTo, plSwapContent,
plAdj, plRender, plHighlight — no page refresh on track change
- Sidebar shows all playlist tracks; current track highlighted in red
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
da02425aeb
commit
77e7b950be
@ -483,6 +483,8 @@ class VideoController extends Controller
|
||||
'id' => $video->id,
|
||||
'key' => $video->getRouteKey(),
|
||||
'type' => $video->type,
|
||||
'has_hls' => (bool) $video->has_hls,
|
||||
'hls_url' => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
|
||||
'stream_url' => route('videos.stream', $video),
|
||||
'cover_url' => $coverUrl,
|
||||
'slides' => $slides,
|
||||
|
||||
@ -806,18 +806,38 @@ function getShuffledPrevUrl() {
|
||||
return vids[order[posPrev]]?.showUrl ?? PREV_URL;
|
||||
}
|
||||
|
||||
window._ytpHls = null;
|
||||
|
||||
function initSource() {
|
||||
if (HLS_URL && window.Hls && Hls.isSupported()) {
|
||||
const hls = new Hls({ startLevel: -1 });
|
||||
hls.loadSource(HLS_URL);
|
||||
hls.attachMedia(video);
|
||||
window._ytpHls = new Hls({ startLevel: -1 });
|
||||
window._ytpHls.loadSource(HLS_URL);
|
||||
window._ytpHls.attachMedia(video);
|
||||
} else if (HLS_URL && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = HLS_URL; // Safari native HLS
|
||||
video.src = HLS_URL;
|
||||
} else {
|
||||
video.src = MP4_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize source after SPA transition — called by playlist overlay scripts
|
||||
window._ytpLoadSource = function(hlsUrl, mp4Url) {
|
||||
if (window._ytpHls) { window._ytpHls.destroy(); window._ytpHls = null; }
|
||||
if (hlsUrl && window.Hls && Hls.isSupported()) {
|
||||
window._ytpHls = new Hls({ startLevel: -1 });
|
||||
window._ytpHls.loadSource(hlsUrl);
|
||||
window._ytpHls.attachMedia(video);
|
||||
} else if (hlsUrl && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = hlsUrl;
|
||||
video.load();
|
||||
} else {
|
||||
video.src = mp4Url;
|
||||
video.load();
|
||||
}
|
||||
video.muted = false;
|
||||
video.play().catch(function(){});
|
||||
};
|
||||
|
||||
// Load HLS.js from CDN then init
|
||||
function loadHlsJs(cb) {
|
||||
if (window.Hls) { cb(); return; }
|
||||
@ -1169,10 +1189,12 @@ video.addEventListener('playing', () => spinner.classList.remove('active'));
|
||||
video.addEventListener('canplay', () => spinner.classList.remove('active'));
|
||||
function navigateNext() {
|
||||
const url = shuffleOn ? getShuffledNextUrl() : NEXT_URL;
|
||||
if (window._ytpNavOverride?.next) { window._ytpNavOverride.next(url); return; }
|
||||
if (url) window.location.href = url;
|
||||
}
|
||||
function navigatePrev() {
|
||||
const url = shuffleOn ? getShuffledPrevUrl() : PREV_URL;
|
||||
if (window._ytpNavOverride?.prev) { window._ytpNavOverride.prev(url); return; }
|
||||
if (url) window.location.href = url;
|
||||
}
|
||||
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
||||
@ -1182,7 +1204,9 @@ video.addEventListener('ended', () => {
|
||||
largePlay.classList.add('visible');
|
||||
clearTimeout(hideTimer);
|
||||
releaseWakeLock();
|
||||
if (autoplayOn || isLooping) {
|
||||
if (window._plOnVideoEnd) {
|
||||
window._plOnVideoEnd();
|
||||
} else if (autoplayOn || isLooping) {
|
||||
navigateNext();
|
||||
}
|
||||
});
|
||||
|
||||
@ -253,6 +253,14 @@
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
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 {
|
||||
@ -428,15 +436,6 @@
|
||||
|
||||
<x-video-comments :video="$video" />
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (typeof enhanceComments === 'function') {
|
||||
enhanceComments();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
@ -451,52 +450,187 @@
|
||||
|
||||
{{-- 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');
|
||||
var lb = document.getElementById('plLoopBtn');
|
||||
var ab = document.getElementById('plAutoplayBtn');
|
||||
var sr = document.getElementById('ytpShuffleRow');
|
||||
var lr = document.getElementById('ytpLoopRow');
|
||||
var 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:'' };
|
||||
}
|
||||
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:'')
|
||||
};
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function(){ setTimeout(plSyncGeneric, 100); });
|
||||
if(document.readyState !== 'loading') setTimeout(plSyncGeneric, 100);
|
||||
|
||||
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();
|
||||
|
||||
// reload video source (HLS or MP4)
|
||||
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=plFmt(d.duration);
|
||||
|
||||
// update page title
|
||||
var ts=document.querySelector('.video-title span');
|
||||
if(ts) ts.textContent=d.title;
|
||||
document.title=d.title+' | {{ config("app.name") }}';
|
||||
|
||||
// update loop state on video element
|
||||
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(); };
|
||||
|
||||
// hook into video player: intercept next/prev and ended
|
||||
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) }}"
|
||||
@ -534,7 +668,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($playlist->canEdit(Auth::user()))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user