diff --git a/__install/migrations/003_enhance_playlists.sql b/__install/migrations/003_enhance_playlists.sql new file mode 100644 index 0000000..8b5bb11 --- /dev/null +++ b/__install/migrations/003_enhance_playlists.sql @@ -0,0 +1,74 @@ +-- Migration: Enhance playlist system with advanced features + +-- Check if playlists table exists, create if not +CREATE TABLE IF NOT EXISTS db_playlists ( + playlist_id INT AUTO_INCREMENT PRIMARY KEY, + usr_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + privacy ENUM('public', 'unlisted', 'private') DEFAULT 'public', + thumbnail VARCHAR(512), + is_collaborative TINYINT(1) DEFAULT 0, + allow_shuffle TINYINT(1) DEFAULT 1, + auto_play TINYINT(1) DEFAULT 1, + video_count INT DEFAULT 0, + view_count INT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user (usr_id), + INDEX idx_privacy (privacy), + INDEX idx_created (created_at), + FOREIGN KEY (usr_id) REFERENCES db_accountuser(usr_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Playlist items (videos in playlists) +CREATE TABLE IF NOT EXISTS db_playlist_items ( + item_id INT AUTO_INCREMENT PRIMARY KEY, + playlist_id INT NOT NULL, + video_id INT NOT NULL, + position INT NOT NULL, + added_by INT, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_playlist (playlist_id, position), + INDEX idx_video (video_id), + UNIQUE KEY unique_playlist_video (playlist_id, video_id), + FOREIGN KEY (playlist_id) REFERENCES db_playlists(playlist_id) ON DELETE CASCADE, + FOREIGN KEY (added_by) REFERENCES db_accountuser(usr_id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Playlist collaborators +CREATE TABLE IF NOT EXISTS db_playlist_collaborators ( + collaborator_id INT AUTO_INCREMENT PRIMARY KEY, + playlist_id INT NOT NULL, + usr_id INT NOT NULL, + can_add TINYINT(1) DEFAULT 1, + can_remove TINYINT(1) DEFAULT 0, + can_reorder TINYINT(1) DEFAULT 0, + invited_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_playlist_user (playlist_id, usr_id), + FOREIGN KEY (playlist_id) REFERENCES db_playlists(playlist_id) ON DELETE CASCADE, + FOREIGN KEY (usr_id) REFERENCES db_accountuser(usr_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Playlist follows (users following playlists) +CREATE TABLE IF NOT EXISTS db_playlist_follows ( + follow_id INT AUTO_INCREMENT PRIMARY KEY, + playlist_id INT NOT NULL, + usr_id INT NOT NULL, + followed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_playlist_follower (playlist_id, usr_id), + FOREIGN KEY (playlist_id) REFERENCES db_playlists(playlist_id) ON DELETE CASCADE, + FOREIGN KEY (usr_id) REFERENCES db_accountuser(usr_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Playlist play history (for analytics) +CREATE TABLE IF NOT EXISTS db_playlist_plays ( + play_id INT AUTO_INCREMENT PRIMARY KEY, + playlist_id INT NOT NULL, + usr_id INT, + video_id INT, + played_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_playlist_date (playlist_id, played_at), + INDEX idx_user (usr_id), + FOREIGN KEY (playlist_id) REFERENCES db_playlists(playlist_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/api/playlists.php b/api/playlists.php new file mode 100644 index 0000000..f16586d --- /dev/null +++ b/api/playlists.php @@ -0,0 +1,249 @@ +get($_GET['id'], $current_user_id); + + if (!$playlist) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Playlist not found']); + exit; + } + + // Get items + $shuffle = isset($_GET['shuffle']) && $_GET['shuffle'] === 'true'; + $items = $playlists->getItems($_GET['id'], $shuffle); + $playlist['items'] = $items; + + echo json_encode(['success' => true, 'data' => $playlist]); + + } elseif (isset($_GET['user_id'])) { + // Get user's playlists + $include_private = $current_user_id && $current_user_id == $_GET['user_id']; + $user_playlists = $playlists->getUserPlaylists($_GET['user_id'], $include_private); + + echo json_encode(['success' => true, 'data' => $user_playlists]); + + } else { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Missing playlist or user ID']); + } +} + +// POST - Create or perform actions +elseif ($method === 'POST') { + if (!$current_user_id) { + http_response_code(401); + echo json_encode(['success' => false, 'error' => 'Authentication required']); + exit; + } + + $input = json_decode(file_get_contents('php://input'), true) ?: $_POST; + $action = $_GET['action'] ?? $input['action'] ?? 'create'; + + if ($action === 'create') { + // Create new playlist + $title = $input['title'] ?? ''; + $description = $input['description'] ?? ''; + $privacy = $input['privacy'] ?? 'public'; + $is_collaborative = isset($input['is_collaborative']) && $input['is_collaborative']; + + if (empty($title)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Title is required']); + exit; + } + + $playlist_id = $playlists->create($current_user_id, $title, $description, $privacy, $is_collaborative); + + if ($playlist_id) { + echo json_encode(['success' => true, 'playlist_id' => $playlist_id]); + } else { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to create playlist']); + } + + } elseif ($action === 'add_video') { + $playlist_id = $_GET['id'] ?? $input['playlist_id']; + $video_id = $input['video_id'] ?? null; + + if (!$playlist_id || !$video_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID and video ID required']); + exit; + } + + $result = $playlists->addVideo($playlist_id, $video_id, $current_user_id); + echo json_encode(['success' => (bool)$result]); + + } elseif ($action === 'remove_video') { + $playlist_id = $_GET['id'] ?? $input['playlist_id']; + $video_id = $input['video_id'] ?? null; + + if (!$playlist_id || !$video_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID and video ID required']); + exit; + } + + $result = $playlists->removeVideo($playlist_id, $video_id, $current_user_id); + echo json_encode(['success' => (bool)$result]); + + } elseif ($action === 'reorder') { + $playlist_id = $_GET['id'] ?? $input['playlist_id']; + $item_positions = $input['positions'] ?? null; + + if (!$playlist_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID required']); + exit; + } + + $result = $playlists->reorderItems($playlist_id, $item_positions); + echo json_encode(['success' => (bool)$result]); + + } elseif ($action === 'follow') { + $playlist_id = $_GET['id'] ?? $input['playlist_id']; + + if (!$playlist_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID required']); + exit; + } + + $result = $playlists->follow($playlist_id, $current_user_id); + echo json_encode(['success' => (bool)$result]); + + } elseif ($action === 'unfollow') { + $playlist_id = $_GET['id'] ?? $input['playlist_id']; + + if (!$playlist_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID required']); + exit; + } + + $result = $playlists->unfollow($playlist_id, $current_user_id); + echo json_encode(['success' => (bool)$result]); + + } elseif ($action === 'add_collaborator') { + $playlist_id = $_GET['id'] ?? $input['playlist_id']; + $collaborator_id = $input['collaborator_id'] ?? null; + $permissions = $input['permissions'] ?? []; + + if (!$playlist_id || !$collaborator_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID and collaborator ID required']); + exit; + } + + $result = $playlists->addCollaborator($playlist_id, $current_user_id, $collaborator_id, $permissions); + echo json_encode(['success' => (bool)$result]); + + } elseif ($action === 'remove_collaborator') { + $playlist_id = $_GET['id'] ?? $input['playlist_id']; + $collaborator_id = $input['collaborator_id'] ?? null; + + if (!$playlist_id || !$collaborator_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID and collaborator ID required']); + exit; + } + + $result = $playlists->removeCollaborator($playlist_id, $current_user_id, $collaborator_id); + echo json_encode(['success' => (bool)$result]); + + } else { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid action']); + } +} + +// PUT - Update playlist +elseif ($method === 'PUT') { + if (!$current_user_id) { + http_response_code(401); + echo json_encode(['success' => false, 'error' => 'Authentication required']); + exit; + } + + $playlist_id = $_GET['id'] ?? null; + + if (!$playlist_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID required']); + exit; + } + + $input = json_decode(file_get_contents('php://input'), true); + + $result = $playlists->update($playlist_id, $current_user_id, $input); + + if ($result) { + echo json_encode(['success' => true]); + } else { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Permission denied or invalid data']); + } +} + +// DELETE - Delete playlist +elseif ($method === 'DELETE') { + if (!$current_user_id) { + http_response_code(401); + echo json_encode(['success' => false, 'error' => 'Authentication required']); + exit; + } + + $playlist_id = $_GET['id'] ?? null; + + if (!$playlist_id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Playlist ID required']); + exit; + } + + $result = $playlists->delete($playlist_id, $current_user_id); + + if ($result) { + echo json_encode(['success' => true]); + } else { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Permission denied']); + } +} + +else { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} diff --git a/docs/PLAYLISTS.md b/docs/PLAYLISTS.md new file mode 100644 index 0000000..f6efc8f --- /dev/null +++ b/docs/PLAYLISTS.md @@ -0,0 +1,465 @@ +# Enhanced Playlist System + +EasyStream now includes advanced playlist features with auto-play, shuffle, collaborative editing, and more. + +## Features + +- 📼 **Auto-play** - Automatically play next video when current ends +- 🔀 **Shuffle mode** - Random playback order +- 🔁 **Repeat modes** - None, repeat all, repeat one +- 👥 **Collaborative playlists** - Multiple users can add/manage videos +- 👁️ **Privacy controls** - Public, unlisted, or private playlists +- 📊 **Analytics** - Track playlist views and playback +- ❤️ **Follow playlists** - Users can follow and get updates +- 🎨 **Modern player UI** - Clean controls with visual feedback + +## Setup + +### 1. Database Migration + +Run the migration to create/enhance playlist tables: + +```bash +docker-compose exec db mysql -u easystream -peasystream easystream < __install/migrations/003_enhance_playlists.sql +``` + +This creates: +- `db_playlists` - Playlist metadata +- `db_playlist_items` - Videos in playlists +- `db_playlist_collaborators` - Collaborative access control +- `db_playlist_follows` - Playlist followers +- `db_playlist_plays` - Playback analytics + +### 2. Add Frontend Assets + +Include CSS and JavaScript in your templates: + +```html + + +``` + +### 3. Video Player Integration + +On your video watch page (`watch.php`), check for playlist parameter: + +```php +$playlist_id = $_GET['list'] ?? null; +$video_id = $_GET['v']; + +// If playlist exists, the JavaScript will auto-initialize the player +``` + +The playlist player will automatically initialize if the URL contains `?v=VIDEO_ID&list=PLAYLIST_ID`. + +## Usage + +### Creating Playlists + +```php +require_once 'f_core/f_classes/class.playlists.php'; +$playlists = new VPlaylists(); + +// Create a new playlist +$playlist_id = $playlists->create( + $usr_id, + 'My Favorite Videos', + 'A collection of my top picks', + 'public', // 'public', 'unlisted', or 'private' + false // is_collaborative +); + +// Create a collaborative playlist +$playlist_id = $playlists->create( + $usr_id, + 'Team Project Videos', + 'Videos for our project', + 'unlisted', + true // Enable collaboration +); +``` + +### Managing Videos + +```php +// Add video to playlist +$playlists->addVideo($playlist_id, $video_id, $usr_id); + +// Remove video +$playlists->removeVideo($playlist_id, $video_id, $usr_id); + +// Reorder videos (auto-fix gaps) +$playlists->reorderItems($playlist_id); + +// Manual reorder +$positions = [ + 101 => 1, // item_id 101 → position 1 + 102 => 2, // item_id 102 → position 2 + 103 => 3 +]; +$playlists->reorderItems($playlist_id, $positions); +``` + +### Collaboration + +```php +// Add collaborator with permissions +$playlists->addCollaborator($playlist_id, $owner_id, $collaborator_id, [ + 'can_add' => true, + 'can_remove' => false, + 'can_reorder' => false +]); + +// Remove collaborator +$playlists->removeCollaborator($playlist_id, $owner_id, $collaborator_id); +``` + +### Following Playlists + +```php +// Follow a playlist +$playlists->follow($playlist_id, $usr_id); + +// Unfollow +$playlists->unfollow($playlist_id, $usr_id); +``` + +## API Endpoints + +### Get Playlist + +```http +GET /api/playlists.php?id=123&shuffle=true +``` + +**Response:** +```json +{ + "success": true, + "data": { + "playlist_id": 123, + "title": "My Playlist", + "description": "Great videos", + "privacy": "public", + "is_collaborative": true, + "auto_play": true, + "allow_shuffle": true, + "video_count": 15, + "view_count": 342, + "is_following": true, + "is_collaborator": false, + "is_owner": false, + "items": [...] + } +} +``` + +### Create Playlist + +```http +POST /api/playlists.php +Content-Type: application/json + +{ + "title": "New Playlist", + "description": "My videos", + "privacy": "public", + "is_collaborative": false +} +``` + +### Update Playlist + +```http +PUT /api/playlists.php?id=123 +Content-Type: application/json + +{ + "title": "Updated Title", + "auto_play": true, + "allow_shuffle": true +} +``` + +### Add Video + +```http +POST /api/playlists.php?id=123&action=add_video +Content-Type: application/json + +{ + "video_id": 456 +} +``` + +### Remove Video + +```http +POST /api/playlists.php?id=123&action=remove_video +Content-Type: application/json + +{ + "video_id": 456 +} +``` + +### Follow/Unfollow + +```http +POST /api/playlists.php?id=123&action=follow +POST /api/playlists.php?id=123&action=unfollow +``` + +### Add Collaborator + +```http +POST /api/playlists.php?id=123&action=add_collaborator +Content-Type: application/json + +{ + "collaborator_id": 789, + "permissions": { + "can_add": true, + "can_remove": false, + "can_reorder": false + } +} +``` + +## Frontend Player + +### Initialization + +The player auto-initializes from URL parameters: + +``` +/watch?v=456&list=123 +``` + +Or initialize manually: + +```javascript +const player = new PlaylistPlayer(playlistId, { + videoId: currentVideoId, + autoPlay: true, + shuffle: false, + repeat: 'all', // 'none', 'one', 'all' + playerElement: document.getElementById('video-player') +}); +``` + +### Player Controls + +- **Shuffle** - Randomize playback order +- **Previous** - Go to previous video +- **Next** - Go to next video +- **Autoplay** - Toggle auto-play when video ends +- **Repeat** - Cycle through: Off → All → One + +### Player API + +```javascript +// Access the global player instance +const player = window.playlistPlayer; + +// Playback control +player.playNext(); +player.playPrevious(); +player.playByIndex(5); +player.playVideo(videoId); + +// Settings +player.toggleShuffle(); +player.toggleAutoplay(); +player.cycleRepeat(); + +// Playlist management +player.removeVideo(videoId); +player.loadPlaylist(); // Reload from server +``` + +## Collaborative Playlists + +Collaborative playlists allow multiple users to contribute. + +**Owner permissions:** +- Create/delete playlist +- Add/remove/reorder videos +- Manage collaborators +- Change privacy settings + +**Collaborator permissions (configurable):** +- `can_add` - Add videos to playlist +- `can_remove` - Remove videos from playlist +- `can_reorder` - Change video order + +**Example workflow:** + +1. Owner creates collaborative playlist +2. Owner adds collaborators with specific permissions +3. Collaborators can add videos (if permitted) +4. All changes tracked with `added_by` field + +## Privacy Modes + +| Mode | Visibility | Shareable | Use Case | +|------|-----------|-----------|----------| +| **public** | Everyone can see and search | Yes | Public collections, curated lists | +| **unlisted** | Only with direct link | Yes | Share with specific people | +| **private** | Only owner + collaborators | No | Personal collections | + +## Database Schema + +### db_playlists + +| Column | Type | Description | +|--------|------|-------------| +| playlist_id | INT | Primary key | +| usr_id | INT | Owner user ID | +| title | VARCHAR(255) | Playlist title | +| description | TEXT | Optional description | +| privacy | ENUM | public/unlisted/private | +| thumbnail | VARCHAR(512) | Custom thumbnail URL | +| is_collaborative | TINYINT | Collaboration enabled | +| allow_shuffle | TINYINT | Shuffle allowed | +| auto_play | TINYINT | Auto-play enabled | +| video_count | INT | Cached video count | +| view_count | INT | Total playlist views | + +### db_playlist_items + +| Column | Type | Description | +|--------|------|-------------| +| item_id | INT | Primary key | +| playlist_id | INT | Playlist ID | +| video_id | INT | Video ID | +| position | INT | Order in playlist | +| added_by | INT | User who added video | +| added_at | DATETIME | When added | + +### db_playlist_collaborators + +| Column | Type | Description | +|--------|------|-------------| +| collaborator_id | INT | Primary key | +| playlist_id | INT | Playlist ID | +| usr_id | INT | Collaborator user ID | +| can_add | TINYINT | Can add videos | +| can_remove | TINYINT | Can remove videos | +| can_reorder | TINYINT | Can reorder videos | + +## Analytics + +Track playlist playback for insights: + +```php +// Track a play +$playlists->trackPlay($playlist_id, $video_id, $usr_id); + +// Get play history +$sql = "SELECT * FROM db_playlist_plays + WHERE playlist_id = ? + ORDER BY played_at DESC + LIMIT 100"; +``` + +**Available metrics:** +- Total playlist views +- Video completion rates +- Popular videos within playlist +- User engagement patterns + +## Integration Points + +### Add "Save to Playlist" Button + +On video pages, add a quick-save button: + +```html + + + +``` + +### Playlist Notifications + +Notify followers when a collaborative playlist is updated: + +```php +// After adding video to collaborative playlist +if ($playlist['is_collaborative']) { + $triggers = new VNotificationTriggers(); + + // Get followers + $followers = $playlists->getFollowers($playlist_id); + + foreach ($followers as $follower) { + $triggers->notifySystem( + $follower['usr_id'], + "New video added to {$playlist['title']}", + $video_title, + "/playlist?id=$playlist_id" + ); + } +} +``` + +## Performance + +- **Pagination:** Load videos in batches for large playlists +- **Caching:** Cache playlist metadata with Redis +- **Lazy loading:** Load thumbnails on scroll +- **Position indexing:** Fast reordering with indexed position column + +## Future Enhancements + +- [ ] Playlist recommendations based on viewing history +- [ ] Batch add videos (from search/channel) +- [ ] Playlist templates (create from another playlist) +- [ ] Smart playlists (auto-add based on criteria) +- [ ] Playlist comments/discussions +- [ ] Export/import playlists (JSON/M3U) +- [ ] Playlist thumbnail generation (grid of video thumbs) +- [ ] Drag-and-drop reordering in UI +- [ ] Playlist merging +- [ ] View mode (grid vs list) + +## Troubleshooting + +### Videos not playing in sequence + +1. Check `auto_play` is enabled in playlist settings +2. Verify video player has `ended` event listener +3. Check browser console for JavaScript errors + +### Collaborator can't add videos + +1. Verify playlist has `is_collaborative = 1` +2. Check collaborator has `can_add = 1` permission +3. Ensure user is authenticated + +### Shuffle not working + +1. Check playlist has `allow_shuffle = 1` +2. Verify JavaScript is loaded +3. Check for shuffle button active state + +## Learn More + +- [YouTube Playlists Documentation](https://support.google.com/youtube/answer/57792) +- [HTML5 Video Events](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#events) +- [Fisher-Yates Shuffle Algorithm](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle) diff --git a/f_core/f_classes/class.playlists.php b/f_core/f_classes/class.playlists.php new file mode 100644 index 0000000..beff218 --- /dev/null +++ b/f_core/f_classes/class.playlists.php @@ -0,0 +1,422 @@ +db = $class_database; + $this->logger = new VLogger('playlists'); + } + + /** + * Create a new playlist + */ + public function create($usr_id, $title, $description = '', $privacy = 'public', $is_collaborative = false) { + $sql = "INSERT INTO db_playlists (usr_id, title, description, privacy, is_collaborative) + VALUES (?, ?, ?, ?, ?)"; + + $result = $this->db->execute($sql, [$usr_id, $title, $description, $privacy, $is_collaborative ? 1 : 0]); + + if (!$result) { + return false; + } + + $playlist_id = $this->db->lastInsertId(); + $this->logger->info("Created playlist $playlist_id by user $usr_id"); + + return $playlist_id; + } + + /** + * Get playlist details + */ + public function get($playlist_id, $current_user_id = null) { + $sql = "SELECT p.*, u.usr_user as owner_username, u.usr_avatar as owner_avatar, + (SELECT COUNT(*) FROM db_playlist_items WHERE playlist_id = p.playlist_id) as video_count, + (SELECT COUNT(*) FROM db_playlist_follows WHERE playlist_id = p.playlist_id) as follower_count + FROM db_playlists p + LEFT JOIN db_accountuser u ON p.usr_id = u.usr_id + WHERE p.playlist_id = ?"; + + $result = $this->db->execute($sql, [$playlist_id]); + + if (!$result || $this->db->rowCount($result) == 0) { + return null; + } + + $playlist = $this->db->fetch($result); + + // Check access permissions + if (!$this->canView($playlist, $current_user_id)) { + return null; + } + + // Check if current user is following + if ($current_user_id) { + $playlist['is_following'] = $this->isFollowing($playlist_id, $current_user_id); + $playlist['is_collaborator'] = $this->isCollaborator($playlist_id, $current_user_id); + $playlist['is_owner'] = $playlist['usr_id'] == $current_user_id; + } + + return $playlist; + } + + /** + * Update playlist + */ + public function update($playlist_id, $usr_id, $data) { + // Verify ownership + if (!$this->isOwner($playlist_id, $usr_id)) { + return false; + } + + $allowed = ['title', 'description', 'privacy', 'is_collaborative', 'allow_shuffle', 'auto_play', 'thumbnail']; + $sets = []; + $values = []; + + foreach ($data as $field => $value) { + if (in_array($field, $allowed)) { + $sets[] = "$field = ?"; + $values[] = $value; + } + } + + if (empty($sets)) { + return false; + } + + $values[] = $playlist_id; + + $sql = "UPDATE db_playlists SET " . implode(', ', $sets) . " WHERE playlist_id = ?"; + + return $this->db->execute($sql, $values); + } + + /** + * Delete playlist + */ + public function delete($playlist_id, $usr_id) { + if (!$this->isOwner($playlist_id, $usr_id)) { + return false; + } + + $sql = "DELETE FROM db_playlists WHERE playlist_id = ?"; + return $this->db->execute($sql, [$playlist_id]); + } + + /** + * Add video to playlist + */ + public function addVideo($playlist_id, $video_id, $usr_id) { + // Check permissions + if (!$this->canAddVideos($playlist_id, $usr_id)) { + return false; + } + + // Get next position + $sql = "SELECT COALESCE(MAX(position), 0) + 1 as next_position + FROM db_playlist_items WHERE playlist_id = ?"; + + $result = $this->db->execute($sql, [$playlist_id]); + $row = $this->db->fetch($result); + $position = $row['next_position']; + + // Insert item + $sql = "INSERT INTO db_playlist_items (playlist_id, video_id, position, added_by) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE position = position"; // Prevent duplicate + + $result = $this->db->execute($sql, [$playlist_id, $video_id, $position, $usr_id]); + + if ($result) { + $this->updateVideoCount($playlist_id); + $this->logger->info("Added video $video_id to playlist $playlist_id by user $usr_id"); + } + + return $result; + } + + /** + * Remove video from playlist + */ + public function removeVideo($playlist_id, $video_id, $usr_id) { + if (!$this->canRemoveVideos($playlist_id, $usr_id)) { + return false; + } + + $sql = "DELETE FROM db_playlist_items + WHERE playlist_id = ? AND video_id = ?"; + + $result = $this->db->execute($sql, [$playlist_id, $video_id]); + + if ($result) { + $this->reorderItems($playlist_id); + $this->updateVideoCount($playlist_id); + } + + return $result; + } + + /** + * Reorder playlist items + */ + public function reorderItems($playlist_id, $item_positions = null) { + if ($item_positions) { + // Manual reordering with specific positions + foreach ($item_positions as $item_id => $position) { + $sql = "UPDATE db_playlist_items + SET position = ? + WHERE item_id = ? AND playlist_id = ?"; + $this->db->execute($sql, [$position, $item_id, $playlist_id]); + } + } else { + // Auto-reorder to fill gaps + $sql = "SELECT item_id FROM db_playlist_items + WHERE playlist_id = ? ORDER BY position ASC"; + + $result = $this->db->execute($sql, [$playlist_id]); + $items = $this->db->resultsToArray($result); + + $position = 1; + foreach ($items as $item) { + $this->db->execute( + "UPDATE db_playlist_items SET position = ? WHERE item_id = ?", + [$position++, $item['item_id']] + ); + } + } + + return true; + } + + /** + * Get playlist items (videos) + */ + public function getItems($playlist_id, $shuffle = false) { + $order = $shuffle ? "RAND()" : "pi.position ASC"; + + $sql = "SELECT pi.*, v.video_title as title, v.video_duration as duration, + v.video_views as views, v.video_thumb as thumbnail, + u.usr_user as username + FROM db_playlist_items pi + LEFT JOIN db_videofiles v ON pi.video_id = v.video_id + LEFT JOIN db_accountuser u ON v.usr_id = u.usr_id + WHERE pi.playlist_id = ? + ORDER BY $order"; + + $result = $this->db->execute($sql, [$playlist_id]); + + if (!$result) { + return []; + } + + return $this->db->resultsToArray($result); + } + + /** + * Get next video in playlist + */ + public function getNextVideo($playlist_id, $current_video_id, $shuffle = false) { + if ($shuffle) { + // Random next video + $sql = "SELECT video_id FROM db_playlist_items + WHERE playlist_id = ? AND video_id != ? + ORDER BY RAND() LIMIT 1"; + + $result = $this->db->execute($sql, [$playlist_id, $current_video_id]); + } else { + // Next in order + $sql = "SELECT pi2.video_id + FROM db_playlist_items pi1 + JOIN db_playlist_items pi2 ON pi1.playlist_id = pi2.playlist_id + WHERE pi1.playlist_id = ? AND pi1.video_id = ? + AND pi2.position > pi1.position + ORDER BY pi2.position ASC + LIMIT 1"; + + $result = $this->db->execute($sql, [$playlist_id, $current_video_id]); + } + + if (!$result || $this->db->rowCount($result) == 0) { + return null; + } + + $row = $this->db->fetch($result); + return $row['video_id']; + } + + /** + * Add collaborator to playlist + */ + public function addCollaborator($playlist_id, $usr_id, $collaborator_id, $permissions = []) { + if (!$this->isOwner($playlist_id, $usr_id)) { + return false; + } + + $playlist = $this->get($playlist_id); + if (!$playlist || !$playlist['is_collaborative']) { + return false; + } + + $can_add = $permissions['can_add'] ?? 1; + $can_remove = $permissions['can_remove'] ?? 0; + $can_reorder = $permissions['can_reorder'] ?? 0; + + $sql = "INSERT INTO db_playlist_collaborators + (playlist_id, usr_id, can_add, can_remove, can_reorder) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + can_add = VALUES(can_add), + can_remove = VALUES(can_remove), + can_reorder = VALUES(can_reorder)"; + + return $this->db->execute($sql, [$playlist_id, $collaborator_id, $can_add, $can_remove, $can_reorder]); + } + + /** + * Remove collaborator + */ + public function removeCollaborator($playlist_id, $usr_id, $collaborator_id) { + if (!$this->isOwner($playlist_id, $usr_id)) { + return false; + } + + $sql = "DELETE FROM db_playlist_collaborators + WHERE playlist_id = ? AND usr_id = ?"; + + return $this->db->execute($sql, [$playlist_id, $collaborator_id]); + } + + /** + * Follow/unfollow playlist + */ + public function follow($playlist_id, $usr_id) { + $sql = "INSERT INTO db_playlist_follows (playlist_id, usr_id) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE followed_at = NOW()"; + + return $this->db->execute($sql, [$playlist_id, $usr_id]); + } + + public function unfollow($playlist_id, $usr_id) { + $sql = "DELETE FROM db_playlist_follows + WHERE playlist_id = ? AND usr_id = ?"; + + return $this->db->execute($sql, [$playlist_id, $usr_id]); + } + + /** + * Track playlist play + */ + public function trackPlay($playlist_id, $video_id = null, $usr_id = null) { + $sql = "INSERT INTO db_playlist_plays (playlist_id, video_id, usr_id) + VALUES (?, ?, ?)"; + + $this->db->execute($sql, [$playlist_id, $video_id, $usr_id]); + + // Update view count + $this->db->execute( + "UPDATE db_playlists SET view_count = view_count + 1 WHERE playlist_id = ?", + [$playlist_id] + ); + } + + /** + * Get user's playlists + */ + public function getUserPlaylists($usr_id, $include_private = false) { + $where = $include_private ? "" : "AND privacy = 'public'"; + + $sql = "SELECT p.*, + (SELECT COUNT(*) FROM db_playlist_items WHERE playlist_id = p.playlist_id) as video_count + FROM db_playlists p + WHERE p.usr_id = ? $where + ORDER BY p.updated_at DESC"; + + $result = $this->db->execute($sql, [$usr_id]); + + if (!$result) { + return []; + } + + return $this->db->resultsToArray($result); + } + + // Permission helpers + + private function canView($playlist, $current_user_id) { + if ($playlist['privacy'] === 'public') return true; + if (!$current_user_id) return false; + if ($playlist['usr_id'] == $current_user_id) return true; + if ($this->isCollaborator($playlist['playlist_id'], $current_user_id)) return true; + return false; + } + + private function isOwner($playlist_id, $usr_id) { + $sql = "SELECT usr_id FROM db_playlists WHERE playlist_id = ?"; + $result = $this->db->execute($sql, [$playlist_id]); + + if (!$result) return false; + + $row = $this->db->fetch($result); + return $row && $row['usr_id'] == $usr_id; + } + + private function isCollaborator($playlist_id, $usr_id) { + $sql = "SELECT 1 FROM db_playlist_collaborators + WHERE playlist_id = ? AND usr_id = ?"; + + $result = $this->db->execute($sql, [$playlist_id, $usr_id]); + return $result && $this->db->rowCount($result) > 0; + } + + private function isFollowing($playlist_id, $usr_id) { + $sql = "SELECT 1 FROM db_playlist_follows + WHERE playlist_id = ? AND usr_id = ?"; + + $result = $this->db->execute($sql, [$playlist_id, $usr_id]); + return $result && $this->db->rowCount($result) > 0; + } + + private function canAddVideos($playlist_id, $usr_id) { + if ($this->isOwner($playlist_id, $usr_id)) return true; + + $sql = "SELECT can_add FROM db_playlist_collaborators + WHERE playlist_id = ? AND usr_id = ?"; + + $result = $this->db->execute($sql, [$playlist_id, $usr_id]); + + if (!$result || $this->db->rowCount($result) == 0) return false; + + $row = $this->db->fetch($result); + return $row['can_add'] == 1; + } + + private function canRemoveVideos($playlist_id, $usr_id) { + if ($this->isOwner($playlist_id, $usr_id)) return true; + + $sql = "SELECT can_remove FROM db_playlist_collaborators + WHERE playlist_id = ? AND usr_id = ?"; + + $result = $this->db->execute($sql, [$playlist_id, $usr_id]); + + if (!$result || $this->db->rowCount($result) == 0) return false; + + $row = $this->db->fetch($result); + return $row['can_remove'] == 1; + } + + private function updateVideoCount($playlist_id) { + $sql = "UPDATE db_playlists + SET video_count = (SELECT COUNT(*) FROM db_playlist_items WHERE playlist_id = ?) + WHERE playlist_id = ?"; + + return $this->db->execute($sql, [$playlist_id, $playlist_id]); + } +} diff --git a/f_scripts/fe/css/playlists.css b/f_scripts/fe/css/playlists.css new file mode 100644 index 0000000..d2b95a6 --- /dev/null +++ b/f_scripts/fe/css/playlists.css @@ -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; + } +} diff --git a/f_scripts/fe/js/playlist-player.js b/f_scripts/fe/js/playlist-player.js new file mode 100644 index 0000000..1db607e --- /dev/null +++ b/f_scripts/fe/js/playlist-player.js @@ -0,0 +1,388 @@ + +
${this.items.length} videos • ${this.playlist.view_count || 0} views
+ + +