feat: Add enhanced playlist system with auto-play, shuffle, and collaboration

- Create comprehensive playlist management system
- Implement auto-play, shuffle, and repeat modes (none/one/all)
- Add collaborative playlists with granular permissions
- Build playlist follow system for users
- Implement playlist analytics and play tracking
- Create modern playlist player UI with controls
- Add privacy modes (public/unlisted/private)
- Document complete API and usage

Features:
- Auto-play next video when current ends
- Shuffle mode for random playback
- Repeat modes (off, one, all)
- Collaborative editing with permissions
- Follow/unfollow playlists
- Playlist view tracking
- Modern player controls UI
- Complete permission system
- Production-ready with error handling
This commit is contained in:
Krystie
2026-03-30 17:16:37 -07:00
parent f50b493df2
commit 9928a23c25
6 changed files with 1891 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
/**
* Playlist Player Styles
*/
.playlist-player-controls {
background: white;
border-radius: 12px;
padding: 20px;
margin-top: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.playlist-info h3 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.playlist-info p {
margin: 0;
color: #666;
font-size: 14px;
}
.playlist-controls {
display: flex;
gap: 12px;
margin: 20px 0;
padding: 15px 0;
border-top: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
}
.playlist-controls button {
background: #f8f9fa;
border: none;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.playlist-controls button:hover {
background: #e9ecef;
}
.playlist-controls button.active {
background: #007bff;
color: white;
}
.playlist-controls button svg {
width: 20px;
height: 20px;
}
.playlist-items {
max-height: 500px;
overflow-y: auto;
}
.playlist-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
position: relative;
}
.playlist-item:hover {
background: #f8f9fa;
}
.playlist-item.active {
background: #e7f3ff;
}
.playlist-item.active .item-number {
background: #007bff;
color: white;
}
.item-number {
width: 32px;
height: 32px;
background: #e9ecef;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.item-thumbnail {
width: 120px;
height: 68px;
border-radius: 8px;
overflow: hidden;
position: relative;
flex-shrink: 0;
}
.item-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-duration {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0,0,0,0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.item-info {
flex: 1;
min-width: 0;
}
.item-title {
font-weight: 500;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-meta {
font-size: 13px;
color: #666;
}
.btn-remove {
background: none;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
padding: 4px 8px;
opacity: 0;
transition: opacity 0.2s;
}
.playlist-item:hover .btn-remove {
opacity: 1;
}
.btn-remove:hover {
color: #dc3545;
}
/* Collaborative playlist badge */
.playlist-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: #e7f3ff;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
color: #007bff;
margin-left: 8px;
}
.playlist-badge svg {
width: 14px;
height: 14px;
}
/* Empty state */
.playlist-empty {
text-align: center;
padding: 60px 20px;
color: #666;
}
.playlist-empty h3 {
margin: 0 0 8px 0;
font-size: 18px;
}
.playlist-empty p {
margin: 0;
font-size: 14px;
}
/* Playlist grid (for browse page) */
.playlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.playlist-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.playlist-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.playlist-card-thumb {
width: 100%;
height: 180px;
background: #e9ecef;
position: relative;
overflow: hidden;
}
.playlist-card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.playlist-card-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(to bottom, transparent 50%, rgba(0,0,0,0.7));
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 16px;
color: white;
}
.playlist-video-count {
font-size: 14px;
font-weight: 600;
}
.playlist-card-info {
padding: 16px;
}
.playlist-card-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-card-meta {
font-size: 14px;
color: #666;
margin: 0;
}
/* Responsive */
@media (max-width: 768px) {
.playlist-controls {
flex-wrap: wrap;
}
.item-thumbnail {
width: 80px;
height: 45px;
}
.item-number {
display: none;
}
.playlist-grid {
grid-template-columns: 1fr;
}
}
+388
View File
@@ -0,0 +1,388 @@
<?php
/**
* Enhanced Playlist Player
* Auto-play, shuffle, repeat modes
*/
class PlaylistPlayer {
constructor(playlistId, options = {}) {
this.playlistId = playlistId;
this.playlist = null;
this.items = [];
this.currentIndex = 0;
this.currentVideoId = options.videoId || null;
// Playback settings
this.autoPlay = options.autoPlay !== false;
this.shuffle = options.shuffle || false;
this.repeat = options.repeat || 'none'; // 'none', 'one', 'all'
// Player element
this.playerElement = options.playerElement || document.getElementById('video-player');
this.init();
}
async init() {
await this.loadPlaylist();
this.renderUI();
this.attachEventListeners();
// Start with current video if provided
if (this.currentVideoId) {
this.playVideo(this.currentVideoId);
}
}
async loadPlaylist() {
try {
const response = await fetch(`/api/playlists.php?id=${this.playlistId}&shuffle=${this.shuffle}`);
const data = await response.json();
if (data.success) {
this.playlist = data.data;
this.items = data.data.items || [];
// Apply playlist settings
if (this.playlist.auto_play !== undefined) {
this.autoPlay = this.playlist.auto_play;
}
if (this.playlist.allow_shuffle !== undefined && !this.playlist.allow_shuffle) {
this.shuffle = false;
}
}
} catch (error) {
console.error('Failed to load playlist:', error);
}
}
renderUI() {
const container = document.createElement('div');
container.className = 'playlist-player-controls';
container.innerHTML = `
<div class="playlist-info">
<h3>${this.escapeHtml(this.playlist.title)}</h3>
<p>${this.items.length} videos • ${this.playlist.view_count || 0} views</p>
</div>
<div class="playlist-controls">
<button class="btn-shuffle" id="btnShuffle" title="Shuffle">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M14.59 7.41L13.18 6l-4.94 4.94L6.41 9.12 5 10.53l3.24 3.24z"/>
</svg>
</button>
<button class="btn-previous" id="btnPrevious" title="Previous">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M6 6h2v8H6zm3.5 1L14 10.5 9.5 14z"/>
</svg>
</button>
<button class="btn-next" id="btnNext" title="Next">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M12 6h2v8h-2zm-2.5 1L6 10.5 10.5 14z"/>
</svg>
</button>
<button class="btn-autoplay" id="btnAutoplay" title="Autoplay">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 4v12l10-6z"/>
</svg>
</button>
<button class="btn-repeat" id="btnRepeat" title="Repeat: Off">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>
</svg>
</button>
</div>
<div class="playlist-items" id="playlistItems">
${this.renderItems()}
</div>
`;
// Find insertion point (after video player)
const player = this.playerElement;
player.parentNode.insertBefore(container, player.nextSibling);
this.updateControlStates();
}
renderItems() {
return this.items.map((item, index) => `
<div class="playlist-item ${index === this.currentIndex ? 'active' : ''}" data-index="${index}" data-video-id="${item.video_id}">
<div class="item-number">${index + 1}</div>
<div class="item-thumbnail">
<img src="${item.thumbnail}" alt="${this.escapeHtml(item.title)}">
<div class="item-duration">${this.formatDuration(item.duration)}</div>
</div>
<div class="item-info">
<div class="item-title">${this.escapeHtml(item.title)}</div>
<div class="item-meta">${this.escapeHtml(item.username)}${this.formatViews(item.views)} views</div>
</div>
<button class="btn-remove" data-video-id="${item.video_id}" title="Remove from playlist">×</button>
</div>
`).join('');
}
attachEventListeners() {
// Shuffle button
document.getElementById('btnShuffle')?.addEventListener('click', () => {
this.toggleShuffle();
});
// Previous/Next buttons
document.getElementById('btnPrevious')?.addEventListener('click', () => {
this.playPrevious();
});
document.getElementById('btnNext')?.addEventListener('click', () => {
this.playNext();
});
// Autoplay button
document.getElementById('btnAutoplay')?.addEventListener('click', () => {
this.toggleAutoplay();
});
// Repeat button
document.getElementById('btnRepeat')?.addEventListener('click', () => {
this.cycleRepeat();
});
// Playlist item clicks
document.getElementById('playlistItems')?.addEventListener('click', (e) => {
const item = e.target.closest('.playlist-item');
if (item && !e.target.classList.contains('btn-remove')) {
const index = parseInt(item.dataset.index);
this.playByIndex(index);
}
// Remove button
if (e.target.classList.contains('btn-remove')) {
const videoId = e.target.dataset.videoId;
this.removeVideo(videoId);
}
});
// Listen for video end event
if (this.playerElement) {
this.playerElement.addEventListener('ended', () => {
this.onVideoEnded();
});
}
}
playVideo(videoId) {
const index = this.items.findIndex(item => item.video_id == videoId);
if (index !== -1) {
this.playByIndex(index);
}
}
playByIndex(index) {
if (index < 0 || index >= this.items.length) return;
this.currentIndex = index;
const item = this.items[index];
// Update active state
document.querySelectorAll('.playlist-item').forEach((el, i) => {
el.classList.toggle('active', i === index);
});
// Load video in player
this.loadVideoInPlayer(item.video_id);
// Track play
this.trackPlay(item.video_id);
}
playNext() {
if (this.repeat === 'one') {
// Replay current video
this.playByIndex(this.currentIndex);
return;
}
let nextIndex = this.currentIndex + 1;
if (nextIndex >= this.items.length) {
if (this.repeat === 'all') {
nextIndex = 0;
} else {
// End of playlist
return;
}
}
this.playByIndex(nextIndex);
}
playPrevious() {
let prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
if (this.repeat === 'all') {
prevIndex = this.items.length - 1;
} else {
prevIndex = 0;
}
}
this.playByIndex(prevIndex);
}
onVideoEnded() {
if (this.autoPlay) {
this.playNext();
}
}
toggleShuffle() {
this.shuffle = !this.shuffle;
if (this.shuffle) {
// Reshuffle items
this.shuffleItems();
} else {
// Restore original order
this.loadPlaylist();
}
this.updateControlStates();
}
shuffleItems() {
// Fisher-Yates shuffle
for (let i = this.items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.items[i], this.items[j]] = [this.items[j], this.items[i]];
}
// Re-render items
document.getElementById('playlistItems').innerHTML = this.renderItems();
}
toggleAutoplay() {
this.autoPlay = !this.autoPlay;
this.updateControlStates();
}
cycleRepeat() {
const modes = ['none', 'all', 'one'];
const currentIndex = modes.indexOf(this.repeat);
this.repeat = modes[(currentIndex + 1) % modes.length];
this.updateControlStates();
}
updateControlStates() {
const btnShuffle = document.getElementById('btnShuffle');
const btnAutoplay = document.getElementById('btnAutoplay');
const btnRepeat = document.getElementById('btnRepeat');
if (btnShuffle) {
btnShuffle.classList.toggle('active', this.shuffle);
}
if (btnAutoplay) {
btnAutoplay.classList.toggle('active', this.autoPlay);
}
if (btnRepeat) {
btnRepeat.classList.toggle('active', this.repeat !== 'none');
btnRepeat.title = `Repeat: ${this.repeat === 'one' ? 'One' : this.repeat === 'all' ? 'All' : 'Off'}`;
if (this.repeat === 'one') {
btnRepeat.innerHTML = `<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>
<text x="10" y="14" font-size="10" text-anchor="middle" fill="currentColor">1</text>
</svg>`;
}
}
}
async removeVideo(videoId) {
if (!confirm('Remove this video from the playlist?')) return;
try {
const response = await fetch(`/api/playlists.php?id=${this.playlistId}&action=remove_video`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: videoId })
});
const data = await response.json();
if (data.success) {
// Reload playlist
await this.loadPlaylist();
document.getElementById('playlistItems').innerHTML = this.renderItems();
}
} catch (error) {
console.error('Failed to remove video:', error);
}
}
loadVideoInPlayer(videoId) {
// Redirect to video page with playlist parameter
window.location.href = `/watch?v=${videoId}&list=${this.playlistId}`;
}
async trackPlay(videoId) {
try {
await fetch(`/api/playlists.php?id=${this.playlistId}&action=track_play`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: videoId })
});
} catch (error) {
console.error('Failed to track play:', error);
}
}
// Utility functions
formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
formatViews(views) {
if (views >= 1000000) {
return (views / 1000000).toFixed(1) + 'M';
}
if (views >= 1000) {
return (views / 1000).toFixed(1) + 'K';
}
return views.toString();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Auto-initialize from URL parameters
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const playlistId = params.get('list');
const videoId = params.get('v');
if (playlistId) {
window.playlistPlayer = new PlaylistPlayer(playlistId, {
videoId: videoId,
playerElement: document.getElementById('video-player')
});
}
});