9928a23c25
- 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
389 lines
13 KiB
JavaScript
389 lines
13 KiB
JavaScript
<?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')
|
||
});
|
||
}
|
||
});
|