Add playlist controls: prev/next, shuffle, loop, autoplay toggle
Controls bar added to playlist sidebar header with: - Prev/Next skip buttons (disabled when at bounds) - Shuffle toggle (Fisher-Yates order stored in localStorage) - Loop 3-state: off → loop all → loop one - Autoplay toggle (default on, persists per playlist in localStorage) All state is instant — no page reload on toggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c160242dbc
commit
05db0e128a
@ -312,6 +312,58 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Playlist controls bar */
|
||||||
|
.pl-controls-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.pl-ctrl-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: color .15s, background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pl-ctrl-btn:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255,255,255,.07);
|
||||||
|
}
|
||||||
|
.pl-ctrl-btn:disabled {
|
||||||
|
opacity: .35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.pl-ctrl-btn.pl-ctrl-active {
|
||||||
|
color: var(--brand-red);
|
||||||
|
}
|
||||||
|
.pl-ctrl-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pl-ctrl-autoplay {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pl-autoplay-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1300px) {
|
@media (max-width: 1300px) {
|
||||||
.yt-sidebar-container {
|
.yt-sidebar-container {
|
||||||
@ -685,14 +737,39 @@
|
|||||||
<!-- Sidebar - Up Next / Recommendations -->
|
<!-- Sidebar - Up Next / Recommendations -->
|
||||||
<div class="yt-sidebar-container">
|
<div class="yt-sidebar-container">
|
||||||
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
|
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 8px;">
|
||||||
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
||||||
{{ $playlist->name }}
|
{{ $playlist->name }}
|
||||||
<span
|
<span
|
||||||
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
|
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
|
||||||
videos)</span>
|
videos)</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="recommended-videos-list">
|
|
||||||
|
{{-- Playlist playback controls --}}
|
||||||
|
<div class="pl-controls-bar">
|
||||||
|
<button class="pl-ctrl-btn" id="plPrevBtn" title="Previous"
|
||||||
|
@if(!$previousVideo) disabled @endif
|
||||||
|
onclick="plGoTo('{{ $previousVideo ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : '' }}')">
|
||||||
|
<i class="bi bi-skip-start-fill"></i>
|
||||||
|
</button>
|
||||||
|
<button class="pl-ctrl-btn" id="plNextBtn" title="Next"
|
||||||
|
onclick="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="plToggleShuffle()">
|
||||||
|
<i class="bi bi-shuffle"></i>
|
||||||
|
</button>
|
||||||
|
<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="plToggleAutoplay()">
|
||||||
|
<i class="bi bi-play-circle"></i>
|
||||||
|
<span class="pl-autoplay-label">Autoplay</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recommended-videos-list" id="plVideoList">
|
||||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||||
@php $isCurrent = $playlistVideo->id === $video->id; @endphp
|
@php $isCurrent = $playlistVideo->id === $video->id; @endphp
|
||||||
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
|
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
|
||||||
@ -869,31 +946,175 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||||
var videoPlayer = document.getElementById('videoPlayer');
|
// ── Playlist data ────────────────────────────────────────────────────────
|
||||||
if (videoPlayer) {
|
var PL_ID = '{{ $playlist->share_token }}';
|
||||||
videoPlayer.volume = 0.5;
|
var PL_TOKEN = '{{ $playlist->share_token }}';
|
||||||
var playPromise = videoPlayer.play();
|
var PL_CURRENT = {{ $video->id }};
|
||||||
if (playPromise !== undefined) {
|
var PL_NEXT_URL = '{{ $nextVideo ? route("videos.show", $nextVideo)."?playlist=".$playlist->share_token : "" }}';
|
||||||
playPromise.then(function() {}).catch(function() {});
|
var PL_PREV_URL = '{{ $previousVideo ? 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() !!};
|
||||||
|
|
||||||
|
// ── localStorage helpers ─────────────────────────────────────────────────
|
||||||
|
function plGet(key, def) {
|
||||||
|
var v = localStorage.getItem('pl_' + key + '_' + PL_ID);
|
||||||
|
return v !== null ? v : def;
|
||||||
|
}
|
||||||
|
function plSet(key, val) { localStorage.setItem('pl_' + key + '_' + PL_ID, val); }
|
||||||
|
|
||||||
|
// ── State (persisted per playlist) ───────────────────────────────────────
|
||||||
|
// autoplay: '1' | '0' (default on)
|
||||||
|
// loop: 'off' | 'all' | 'one'
|
||||||
|
// shuffle: '1' | '0'
|
||||||
|
var plAutoplay = plGet('autoplay', '1');
|
||||||
|
var plLoop = plGet('loop', 'off');
|
||||||
|
var plShuffle = plGet('shuffle', '0');
|
||||||
|
|
||||||
|
// Shuffled order: array of indices into PL_VIDEOS
|
||||||
|
function plGetShuffleOrder() {
|
||||||
|
var raw = localStorage.getItem('pl_shuffleOrder_' + PL_ID);
|
||||||
|
if (raw) { try { return JSON.parse(raw); } catch(e){} }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function plBuildShuffleOrder() {
|
||||||
|
var order = PL_VIDEOS.map(function(_,i){ return i; });
|
||||||
|
for (var i = order.length - 1; i > 0; i--) {
|
||||||
|
var j = Math.floor(Math.random() * (i + 1));
|
||||||
|
var t = order[i]; order[i] = order[j]; order[j] = t;
|
||||||
|
}
|
||||||
|
localStorage.setItem('pl_shuffleOrder_' + PL_ID, JSON.stringify(order));
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────────────────────────
|
||||||
|
function plGoTo(url) { if (url) window.location.href = url; }
|
||||||
|
|
||||||
|
function plNext() {
|
||||||
|
if (plShuffle === '1') {
|
||||||
|
var order = plGetShuffleOrder() || plBuildShuffleOrder();
|
||||||
|
var curIdx = PL_VIDEOS.findIndex(function(v){ return v.id === PL_CURRENT; });
|
||||||
|
var pos = order.indexOf(curIdx);
|
||||||
|
var nextPos = (pos + 1) % order.length;
|
||||||
|
if (pos === order.length - 1 && plLoop !== 'all') return; // end of shuffle, no loop
|
||||||
|
window.location.href = PL_VIDEOS[order[nextPos]].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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button UI ────────────────────────────────────────────────────────────
|
||||||
|
function plRenderControls() {
|
||||||
|
var shuffleBtn = document.getElementById('plShuffleBtn');
|
||||||
|
var loopBtn = document.getElementById('plLoopBtn');
|
||||||
|
var autoplayBtn = document.getElementById('plAutoplayBtn');
|
||||||
|
var nextBtn = document.getElementById('plNextBtn');
|
||||||
|
var prevBtn = document.getElementById('plPrevBtn');
|
||||||
|
|
||||||
|
// Shuffle
|
||||||
|
if (shuffleBtn) {
|
||||||
|
shuffleBtn.classList.toggle('pl-ctrl-active', plShuffle === '1');
|
||||||
|
shuffleBtn.title = plShuffle === '1' ? 'Shuffle: On' : 'Shuffle: Off';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop
|
||||||
|
if (loopBtn) {
|
||||||
|
loopBtn.classList.remove('pl-ctrl-active');
|
||||||
|
if (plLoop === 'all') {
|
||||||
|
loopBtn.classList.add('pl-ctrl-active');
|
||||||
|
loopBtn.innerHTML = '<i class="bi bi-repeat"></i>';
|
||||||
|
loopBtn.title = 'Loop: All';
|
||||||
|
} else if (plLoop === 'one') {
|
||||||
|
loopBtn.classList.add('pl-ctrl-active');
|
||||||
|
loopBtn.innerHTML = '<i class="bi bi-repeat-1"></i>';
|
||||||
|
loopBtn.title = 'Loop: One';
|
||||||
|
} else {
|
||||||
|
loopBtn.innerHTML = '<i class="bi bi-repeat"></i>';
|
||||||
|
loopBtn.title = 'Loop: Off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoplay
|
||||||
|
if (autoplayBtn) {
|
||||||
|
autoplayBtn.classList.toggle('pl-ctrl-active', plAutoplay === '1');
|
||||||
|
autoplayBtn.title = plAutoplay === '1' ? 'Autoplay: On' : 'Autoplay: Off';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next/Prev availability
|
||||||
|
if (nextBtn) nextBtn.disabled = !PL_NEXT_URL && plLoop !== 'all' && plShuffle !== '1';
|
||||||
|
if (prevBtn) prevBtn.disabled = !PL_PREV_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle handlers ──────────────────────────────────────────────────────
|
||||||
|
function plToggleShuffle() {
|
||||||
|
plShuffle = plShuffle === '1' ? '0' : '1';
|
||||||
|
plSet('shuffle', plShuffle);
|
||||||
|
if (plShuffle === '1') plBuildShuffleOrder();
|
||||||
|
else localStorage.removeItem('pl_shuffleOrder_' + PL_ID);
|
||||||
|
plRenderControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plToggleLoop() {
|
||||||
|
var states = ['off', 'all', 'one'];
|
||||||
|
var idx = states.indexOf(plLoop);
|
||||||
|
plLoop = states[(idx + 1) % states.length];
|
||||||
|
plSet('loop', plLoop);
|
||||||
|
plRenderControls();
|
||||||
|
// Apply loop-one directly on the video element
|
||||||
|
var vp = document.getElementById('videoPlayer');
|
||||||
|
if (vp) vp.loop = (plLoop === 'one');
|
||||||
|
}
|
||||||
|
|
||||||
|
function plToggleAutoplay() {
|
||||||
|
plAutoplay = plAutoplay === '1' ? '0' : '1';
|
||||||
|
plSet('autoplay', plAutoplay);
|
||||||
|
plRenderControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@endif
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var videoPlayer = document.getElementById('videoPlayer');
|
||||||
|
if (videoPlayer) {
|
||||||
|
videoPlayer.volume = 0.5;
|
||||||
|
var playPromise = videoPlayer.play();
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
playPromise.then(function() {}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||||
|
// Apply loop-one state immediately on load
|
||||||
|
if (plLoop === 'one') videoPlayer.loop = true;
|
||||||
|
|
||||||
|
videoPlayer.addEventListener('ended', function() {
|
||||||
|
if (plLoop === 'one') return; // native loop handles it
|
||||||
|
|
||||||
|
if (plAutoplay === '1') {
|
||||||
|
plNext();
|
||||||
}
|
}
|
||||||
|
// If autoplay off, do nothing — video just stops
|
||||||
|
});
|
||||||
|
|
||||||
@if($nextVideo && $playlist)
|
// Render control states
|
||||||
// Auto-advance to next playlist video when this one ends
|
plRenderControls();
|
||||||
videoPlayer.addEventListener('ended', function() {
|
@endif
|
||||||
window.location.href = '{{ route('videos.show', $nextVideo) }}?playlist={{ $playlist->share_token }}';
|
}
|
||||||
});
|
|
||||||
@endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll the currently playing video into view in the playlist sidebar
|
// Scroll current video into view in sidebar
|
||||||
var currentCard = document.querySelector('.sidebar-video-card.current-video');
|
var currentCard = document.querySelector('.sidebar-video-card.current-video');
|
||||||
if (currentCard) {
|
if (currentCard) {
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
currentCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
currentCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user