Files
easystream/f_scripts/fe/js/playlist-player.js
T
Krystie 9928a23c25 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
2026-03-30 17:16:37 -07:00

389 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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')
});
}
});