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:
ghassan 2026-05-16 11:16:42 +03:00
parent c160242dbc
commit 05db0e128a

View File

@ -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