Compare commits
5 Commits
99f71c54e5
...
4f275de15f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f275de15f | ||
|
|
5960c6e7b1 | ||
|
|
d73f877d18 | ||
|
|
77e7b950be | ||
|
|
da02425aeb |
@ -98,6 +98,13 @@ All managed via `MatchEventController` under authenticated routes.
|
|||||||
|
|
||||||
## Rules
|
## 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.
|
**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.
|
**Never use `alert()`, `confirm()`, or `prompt()`** — use toast notifications or inline UI feedback instead.
|
||||||
|
|||||||
@ -465,6 +465,35 @@ 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,
|
||||||
|
'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,
|
||||||
|
'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())) {
|
||||||
|
|||||||
@ -806,18 +806,38 @@ function getShuffledPrevUrl() {
|
|||||||
return vids[order[posPrev]]?.showUrl ?? PREV_URL;
|
return vids[order[posPrev]]?.showUrl ?? PREV_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window._ytpHls = null;
|
||||||
|
|
||||||
function initSource() {
|
function initSource() {
|
||||||
if (HLS_URL && window.Hls && Hls.isSupported()) {
|
if (HLS_URL && window.Hls && Hls.isSupported()) {
|
||||||
const hls = new Hls({ startLevel: -1 });
|
window._ytpHls = new Hls({ startLevel: -1 });
|
||||||
hls.loadSource(HLS_URL);
|
window._ytpHls.loadSource(HLS_URL);
|
||||||
hls.attachMedia(video);
|
window._ytpHls.attachMedia(video);
|
||||||
} else if (HLS_URL && video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (HLS_URL && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
video.src = HLS_URL; // Safari native HLS
|
video.src = HLS_URL;
|
||||||
} else {
|
} else {
|
||||||
video.src = MP4_URL;
|
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
|
// Load HLS.js from CDN then init
|
||||||
function loadHlsJs(cb) {
|
function loadHlsJs(cb) {
|
||||||
if (window.Hls) { cb(); return; }
|
if (window.Hls) { cb(); return; }
|
||||||
@ -1169,10 +1189,12 @@ video.addEventListener('playing', () => spinner.classList.remove('active'));
|
|||||||
video.addEventListener('canplay', () => spinner.classList.remove('active'));
|
video.addEventListener('canplay', () => spinner.classList.remove('active'));
|
||||||
function navigateNext() {
|
function navigateNext() {
|
||||||
const url = shuffleOn ? getShuffledNextUrl() : NEXT_URL;
|
const url = shuffleOn ? getShuffledNextUrl() : NEXT_URL;
|
||||||
|
if (window._ytpNavOverride?.next) { window._ytpNavOverride.next(url); return; }
|
||||||
if (url) window.location.href = url;
|
if (url) window.location.href = url;
|
||||||
}
|
}
|
||||||
function navigatePrev() {
|
function navigatePrev() {
|
||||||
const url = shuffleOn ? getShuffledPrevUrl() : PREV_URL;
|
const url = shuffleOn ? getShuffledPrevUrl() : PREV_URL;
|
||||||
|
if (window._ytpNavOverride?.prev) { window._ytpNavOverride.prev(url); return; }
|
||||||
if (url) window.location.href = url;
|
if (url) window.location.href = url;
|
||||||
}
|
}
|
||||||
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
||||||
@ -1182,7 +1204,9 @@ video.addEventListener('ended', () => {
|
|||||||
largePlay.classList.add('visible');
|
largePlay.classList.add('visible');
|
||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
if (autoplayOn || isLooping) {
|
if (window._plOnVideoEnd) {
|
||||||
|
window._plOnVideoEnd();
|
||||||
|
} else if (autoplayOn || isLooping) {
|
||||||
navigateNext();
|
navigateNext();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -253,6 +253,24 @@
|
|||||||
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-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 {
|
||||||
@ -428,15 +446,6 @@
|
|||||||
|
|
||||||
<x-video-comments :video="$video" />
|
<x-video-comments :video="$video" />
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
setTimeout(function() {
|
|
||||||
if (typeof enhanceComments === 'function') {
|
|
||||||
enhanceComments();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Sidebar - Up Next / Recommendations -->
|
<!-- Sidebar - Up Next / Recommendations -->
|
||||||
<div class="yt-sidebar-container" style="display: flex; flex-direction: column; gap: 16px;">
|
<div class="yt-sidebar-container" style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
@ -451,52 +460,187 @@
|
|||||||
|
|
||||||
{{-- 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="window._ytpNav?.prev()">
|
|
||||||
<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"
|
<button class="pl-ctrl-btn" id="plNextBtn" title="Next" onclick="window.plNext()">
|
||||||
onclick="window._ytpNav?.next()">
|
|
||||||
<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>
|
||||||
<button class="pl-ctrl-btn" id="plShuffleBtn" title="Shuffle"
|
<button class="pl-ctrl-btn" id="plShuffleBtn" title="Shuffle" onclick="plToggleShuffle()">
|
||||||
onclick="document.getElementById('ytpShuffleRow')?.click(); plSyncGeneric()">
|
|
||||||
<i class="bi bi-shuffle"></i>
|
<i class="bi bi-shuffle"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="pl-ctrl-btn" id="plLoopBtn" title="Loop"
|
<button class="pl-ctrl-btn" id="plLoopBtn" title="Loop" onclick="plToggleLoop()">
|
||||||
onclick="document.getElementById('ytpLoopRow')?.click(); plSyncGeneric()">
|
|
||||||
<i class="bi bi-repeat"></i>
|
<i class="bi bi-repeat"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="plAutoplayBtn" title="Autoplay"
|
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="plAutoplayBtn" title="Autoplay" onclick="plToggleAutoplay()">
|
||||||
onclick="document.getElementById('ytpAutoplayRow')?.click(); plSyncGeneric()">
|
|
||||||
<i class="bi bi-play-circle"></i>
|
<i class="bi bi-play-circle"></i>
|
||||||
<span class="pl-autoplay-label">Autoplay</span>
|
<span class="pl-autoplay-label">Autoplay</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function plSyncGeneric(){
|
(function () {
|
||||||
var sb = document.getElementById('plShuffleBtn');
|
var PL_ID = '{{ $playlist->share_token }}';
|
||||||
var lb = document.getElementById('plLoopBtn');
|
var PL_CURRENT = {{ $video->id }};
|
||||||
var ab = document.getElementById('plAutoplayBtn');
|
var PL_VIDEOS = {!! $playlistVideos->map(fn($v) => ['id' => $v->id, 'url' => route('videos.show', $v).'?playlist='.$playlist->share_token])->values()->toJson() !!};
|
||||||
var sr = document.getElementById('ytpShuffleRow');
|
|
||||||
var lr = document.getElementById('ytpLoopRow');
|
function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; }
|
||||||
var ar = document.getElementById('ytpAutoplayRow');
|
function plSet(k,v){ localStorage.setItem('pl_'+k+'_'+PL_ID,v); }
|
||||||
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'));
|
var plAutoplay = plGet('autoplay','1');
|
||||||
if(ab && ar) ab.classList.toggle('pl-ctrl-active', ar.classList.contains('is-on'));
|
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); });
|
return {
|
||||||
if(document.readyState !== 'loading') setTimeout(plSyncGeneric, 100);
|
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();
|
||||||
|
|
||||||
|
// 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>
|
</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) }}"
|
||||||
@ -534,7 +678,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@if ($playlist->canEdit(Auth::user()))
|
@if ($playlist->canEdit(Auth::user()))
|
||||||
@ -543,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) }}"
|
||||||
@ -583,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>
|
||||||
|
|
||||||
|
|||||||
@ -2539,48 +2539,180 @@
|
|||||||
|
|
||||||
{{-- 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="window._ytpNav?.prev()">
|
|
||||||
<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"
|
<button class="pl-ctrl-btn" id="plNextBtn" title="Next" onclick="window.plNext()">
|
||||||
onclick="window._ytpNav?.next()">
|
|
||||||
<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>
|
||||||
<button class="pl-ctrl-btn" id="plShuffleBtn" title="Shuffle"
|
<button class="pl-ctrl-btn" id="plShuffleBtn" title="Shuffle" onclick="plToggleShuffle()">
|
||||||
onclick="document.getElementById('ytpShuffleRow')?.click(); plSyncGeneric()">
|
|
||||||
<i class="bi bi-shuffle"></i>
|
<i class="bi bi-shuffle"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="pl-ctrl-btn" id="plLoopBtn" title="Loop"
|
<button class="pl-ctrl-btn" id="plLoopBtn" title="Loop" onclick="plToggleLoop()">
|
||||||
onclick="document.getElementById('ytpLoopRow')?.click(); plSyncGeneric()">
|
|
||||||
<i class="bi bi-repeat"></i>
|
<i class="bi bi-repeat"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="plAutoplayBtn" title="Autoplay"
|
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="plAutoplayBtn" title="Autoplay" onclick="plToggleAutoplay()">
|
||||||
onclick="document.getElementById('ytpAutoplayRow')?.click(); plSyncGeneric()">
|
|
||||||
<i class="bi bi-play-circle"></i>
|
<i class="bi bi-play-circle"></i>
|
||||||
<span class="pl-autoplay-label">Autoplay</span>
|
<span class="pl-autoplay-label">Autoplay</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function plSyncGeneric(){
|
(function () {
|
||||||
var sb=document.getElementById('plShuffleBtn'), lb=document.getElementById('plLoopBtn'), ab=document.getElementById('plAutoplayBtn');
|
var PL_ID = '{{ $playlist->share_token }}';
|
||||||
var sr=document.getElementById('ytpShuffleRow'), lr=document.getElementById('ytpLoopRow'), ar=document.getElementById('ytpAutoplayRow');
|
var PL_CURRENT = {{ $video->id }};
|
||||||
if(sb&&sr) sb.classList.toggle('pl-ctrl-active',sr.classList.contains('is-on'));
|
var PL_VIDEOS = {!! $playlistVideos->map(fn($v) => ['id' => $v->id, 'url' => route('videos.show', $v).'?playlist='.$playlist->share_token])->values()->toJson() !!};
|
||||||
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 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); });
|
return {
|
||||||
if(document.readyState!=='loading') setTimeout(plSyncGeneric,100);
|
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>
|
</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) }}"
|
||||||
@ -2618,7 +2750,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@if ($playlist->canEdit(Auth::user()))
|
@if ($playlist->canEdit(Auth::user()))
|
||||||
@ -2627,12 +2758,27 @@
|
|||||||
</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) }}')"
|
||||||
|
style="cursor:pointer;">
|
||||||
<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) }}"
|
||||||
@ -2668,8 +2814,113 @@
|
|||||||
@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') !== '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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -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()))
|
||||||
@ -622,12 +732,120 @@
|
|||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
|
||||||
|
<h3 style="font-size:16px; font-weight:500; margin:0;">Up Next</h3>
|
||||||
|
<button id="recAutoplayBtn" class="pl-ctrl-btn pl-ctrl-autoplay" title="Autoplay" onclick="recToggleAutoplay()" style="margin-left:auto;">
|
||||||
|
<i class="bi bi-play-circle"></i>
|
||||||
|
<span class="pl-autoplay-label">Autoplay</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var recTransiting = false;
|
||||||
|
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
|
||||||
|
|
||||||
|
function recRender() {
|
||||||
|
var btn = document.getElementById('recAutoplayBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.toggle('pl-ctrl-active', recAutoplay);
|
||||||
|
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recTransitionTo(url, pushHist) {
|
||||||
|
if (recTransiting) return;
|
||||||
|
recTransiting = true;
|
||||||
|
try {
|
||||||
|
var dataUrl = url.split('?')[0] + '/player-data';
|
||||||
|
var resp = await fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||||
|
if (!resp.ok) { window.location.href = url; return; }
|
||||||
|
var d = await resp.json();
|
||||||
|
|
||||||
|
var audio = document.getElementById('audioEl');
|
||||||
|
if (audio) {
|
||||||
|
audio.src = d.stream_url;
|
||||||
|
audio.load();
|
||||||
|
audio.play().catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverImg = document.querySelector('.audio-cover-img');
|
||||||
|
if (coverImg) coverImg.src = d.cover_url || '';
|
||||||
|
|
||||||
|
var titleEl = document.querySelector('.audio-title');
|
||||||
|
if (titleEl) titleEl.textContent = d.title || '';
|
||||||
|
|
||||||
|
if (pushHist !== false) history.pushState({ url: 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: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
var html = await resp.text();
|
||||||
|
var parser = new DOMParser();
|
||||||
|
var doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
var newVdb = doc.getElementById('vdbWrap');
|
||||||
|
var oldVdb = document.getElementById('vdbWrap');
|
||||||
|
if (newVdb && oldVdb) oldVdb.innerHTML = newVdb.innerHTML;
|
||||||
|
|
||||||
|
var newCh = doc.querySelector('.channel-row');
|
||||||
|
var oldCh = document.querySelector('.channel-row');
|
||||||
|
if (newCh && oldCh) oldCh.outerHTML = newCh.outerHTML;
|
||||||
|
|
||||||
|
var newYtc = doc.getElementById('ytcSection');
|
||||||
|
var oldYtc = document.getElementById('ytcSection');
|
||||||
|
if (newYtc && oldYtc) {
|
||||||
|
oldYtc.innerHTML = newYtc.innerHTML;
|
||||||
|
var s = oldYtc.querySelector('script');
|
||||||
|
if (s) { var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns); }
|
||||||
|
}
|
||||||
|
|
||||||
|
var newSidebar = doc.querySelector('.yt-sidebar-container');
|
||||||
|
var oldSidebar = document.querySelector('.yt-sidebar-container');
|
||||||
|
if (newSidebar && oldSidebar) {
|
||||||
|
oldSidebar.innerHTML = newSidebar.innerHTML;
|
||||||
|
oldSidebar.querySelectorAll('script').forEach(function(s){
|
||||||
|
var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('recSwapContent', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.recGoTo = function(url) { if (url) recTransitionTo(url); };
|
||||||
|
window.recToggleAutoplay = function() {
|
||||||
|
recAutoplay = !recAutoplay;
|
||||||
|
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
|
||||||
|
recRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window._plOnTrackEnd = 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.url) recTransitionTo(e.state.url, false); });
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', recRender); else recRender();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
@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) }}')"
|
||||||
|
style="cursor:pointer;">
|
||||||
<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) }}"
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user