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);
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
@media (max-width: 1300px) {
|
||||
.yt-sidebar-container {
|
||||
@ -685,14 +737,39 @@
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container">
|
||||
@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>
|
||||
{{ $playlist->name }}
|
||||
<span
|
||||
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
|
||||
videos)</span>
|
||||
</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)
|
||||
@php $isCurrent = $playlistVideo->id === $video->id; @endphp
|
||||
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
|
||||
@ -869,31 +946,175 @@
|
||||
@endif
|
||||
|
||||
<script>
|
||||
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)
|
||||
// ── Playlist data ────────────────────────────────────────────────────────
|
||||
var PL_ID = '{{ $playlist->share_token }}';
|
||||
var PL_TOKEN = '{{ $playlist->share_token }}';
|
||||
var PL_CURRENT = {{ $video->id }};
|
||||
var PL_NEXT_URL = '{{ $nextVideo ? route("videos.show", $nextVideo)."?playlist=".$playlist->share_token : "" }}';
|
||||
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)
|
||||
// Auto-advance to next playlist video when this one ends
|
||||
videoPlayer.addEventListener('ended', function() {
|
||||
window.location.href = '{{ route('videos.show', $nextVideo) }}?playlist={{ $playlist->share_token }}';
|
||||
});
|
||||
@endif
|
||||
}
|
||||
// Render control states
|
||||
plRenderControls();
|
||||
@endif
|
||||
}
|
||||
|
||||
// Scroll the currently playing video into view in the playlist sidebar
|
||||
var currentCard = document.querySelector('.sidebar-video-card.current-video');
|
||||
if (currentCard) {
|
||||
setTimeout(function() {
|
||||
currentCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
// Scroll current video into view in sidebar
|
||||
var currentCard = document.querySelector('.sidebar-video-card.current-video');
|
||||
if (currentCard) {
|
||||
setTimeout(function() {
|
||||
currentCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@auth
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user