// Playlists UI module (function() { const M = window.MusicRoom; const $ = M.$; const formatTime = M.fmt; const showToast = M.showToast; let myPlaylists = []; let sharedPlaylists = []; let selectedPlaylistId = null; let selectedPlaylist = null; async function loadPlaylists() { try { const res = await fetch('/api/playlists'); if (!res.ok) { if (res.status === 401) { // Not logged in yet return; } throw new Error('Failed to load playlists'); } const data = await res.json(); myPlaylists = data.mine || []; sharedPlaylists = data.shared || []; renderPlaylistList(); } catch (err) { console.error('Failed to load playlists:', err); } } function renderPlaylistList() { const myContainer = $('#my-playlists'); const sharedContainer = $('#shared-playlists'); if (!myContainer || !sharedContainer) return; // My playlists if (myPlaylists.length === 0) { myContainer.innerHTML = '
No playlists yet
'; } else { myContainer.innerHTML = myPlaylists.map(p => `
${escapeHtml(p.name)} ${p.isPublic ? '🌐' : ''} ${p.trackIds.length}
`).join(''); } // Shared playlists if (sharedPlaylists.length === 0) { sharedContainer.innerHTML = '
No shared playlists
'; } else { sharedContainer.innerHTML = sharedPlaylists.map(p => `
${escapeHtml(p.name)} by ${escapeHtml(p.ownerName || 'Unknown')} ${p.trackIds.length}
`).join(''); } // Attach click handlers myContainer.querySelectorAll('.playlist-item').forEach(el => { el.onclick = () => selectPlaylist(el.dataset.id); el.oncontextmenu = (e) => showPlaylistContextMenu(e, el.dataset.id, true); }); sharedContainer.querySelectorAll('.playlist-item').forEach(el => { el.onclick = () => selectPlaylist(el.dataset.id); el.oncontextmenu = (e) => showPlaylistContextMenu(e, el.dataset.id, false); }); } async function selectPlaylist(id) { selectedPlaylistId = id; // Update selection in list document.querySelectorAll('.playlist-item').forEach(el => { el.classList.toggle('selected', el.dataset.id === id); }); // Load playlist details try { const res = await fetch(`/api/playlists/${id}`); if (!res.ok) throw new Error('Failed to load playlist'); selectedPlaylist = await res.json(); renderPlaylistContents(); } catch (err) { console.error('Failed to load playlist:', err); showToast('Failed to load playlist', 'error'); } } // Playlist tracks container instance let playlistContainer = null; function renderPlaylistContents() { const header = $('#selected-playlist-name'); const actions = $('#playlist-actions'); const containerEl = $('#playlist-tracks'); if (!selectedPlaylist) { header.textContent = 'Select a playlist'; actions.classList.add('hidden'); containerEl.innerHTML = ''; return; } header.textContent = selectedPlaylist.name; actions.classList.remove('hidden'); const isMine = myPlaylists.some(p => p.id === selectedPlaylistId); // Create or update container (even for empty playlists, to enable drag-drop) playlistContainer = M.trackContainer.createContainer({ type: 'playlist', element: containerEl, getTracks: () => { return selectedPlaylist.trackIds.map((id, i) => { const track = M.library.find(t => t.id === id); return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i }; }); }, isPlaylistOwner: isMine, playlistId: selectedPlaylistId }); playlistContainer.render(); } function reloadCurrentPlaylist() { if (selectedPlaylistId) { selectPlaylist(selectedPlaylistId); } } function showPlaylistContextMenu(e, playlistId, isMine) { e.preventDefault(); M.contextMenu.hide(); const playlist = isMine ? myPlaylists.find(p => p.id === playlistId) : sharedPlaylists.find(p => p.id === playlistId); if (!playlist) return; const items = []; // Add to queue options items.push({ label: '▶ Add to Queue', action: () => addPlaylistToQueue(playlistId) }); items.push({ label: '⏭ Play Next', action: () => addPlaylistToQueue(playlistId, true) }); items.push({ separator: true }); if (isMine) { // Rename items.push({ label: '✏️ Rename', action: () => startRenamePlaylist(playlistId) }); // Share/unshare if (playlist.isPublic) { items.push({ label: '🔒 Make Private', action: () => togglePlaylistPublic(playlistId, false) }); } else { items.push({ label: '🌐 Make Public', action: () => togglePlaylistPublic(playlistId, true) }); } items.push({ separator: true }); // Delete items.push({ label: '🗑️ Delete', action: () => deletePlaylist(playlistId), className: 'danger' }); } else { // Copy to my playlists items.push({ label: '📋 Copy to My Playlists', action: () => copyPlaylist(playlistId) }); } M.contextMenu.show(e, items); } async function addPlaylistToQueue(playlistId, playNext = false) { const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId); if (!playlist || playlist.trackIds.length === 0) { showToast('Playlist is empty', 'error'); return; } try { const body = playNext ? { add: playlist.trackIds, insertAt: M.currentIndex + 1 } : { add: playlist.trackIds }; const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) throw new Error('Failed to add to queue'); showToast(`Added ${playlist.trackIds.length} tracks to queue`); } catch (err) { console.error('Failed to add playlist to queue:', err); showToast('Failed to add to queue', 'error'); } } async function addTracksToQueue(trackIds, playNext = false) { try { const body = playNext ? { add: trackIds, insertAt: M.currentIndex + 1 } : { add: trackIds }; const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) throw new Error('Failed to add to queue'); showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to queue`); } catch (err) { console.error('Failed to add to queue:', err); showToast('Failed to add to queue', 'error'); } } function playTrackFromPlaylist(trackId) { // Desynced playback of single track if (M.playDirectTrack) { M.playDirectTrack(trackId); } } async function createPlaylist(name) { try { const res = await fetch('/api/playlists', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); if (!res.ok) throw new Error('Failed to create playlist'); const playlist = await res.json(); showToast(`Created playlist "${name}"`); await loadPlaylists(); selectPlaylist(playlist.id); return playlist; } catch (err) { console.error('Failed to create playlist:', err); showToast('Failed to create playlist', 'error'); return null; } } async function deletePlaylist(playlistId) { const playlist = myPlaylists.find(p => p.id === playlistId); if (!playlist) return; try { const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Failed to delete playlist'); showToast(`Deleted playlist "${playlist.name}"`); if (selectedPlaylistId === playlistId) { selectedPlaylistId = null; selectedPlaylist = null; renderPlaylistContents(); } await loadPlaylists(); } catch (err) { console.error('Failed to delete playlist:', err); showToast('Failed to delete playlist', 'error'); } } function startRenamePlaylist(playlistId) { const el = document.querySelector(`.playlist-item[data-id="${playlistId}"] .playlist-name`); if (!el) return; const playlist = myPlaylists.find(p => p.id === playlistId); if (!playlist) return; const input = document.createElement('input'); input.type = 'text'; input.className = 'playlist-rename-input'; input.value = playlist.name; const originalText = el.textContent; el.textContent = ''; el.appendChild(input); input.focus(); input.select(); const finish = async (save) => { const newName = input.value.trim(); el.textContent = save && newName ? newName : originalText; if (save && newName && newName !== originalText) { try { const res = await fetch(`/api/playlists/${playlistId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); if (!res.ok) throw new Error('Failed to rename'); showToast(`Renamed to "${newName}"`); await loadPlaylists(); if (selectedPlaylistId === playlistId && selectedPlaylist) { selectedPlaylist.name = newName; renderPlaylistContents(); } } catch (err) { el.textContent = originalText; showToast('Failed to rename playlist', 'error'); } } }; input.onblur = () => finish(true); input.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } else if (e.key === 'Escape') { finish(false); } }; } async function togglePlaylistPublic(playlistId, isPublic) { try { const res = await fetch(`/api/playlists/${playlistId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isPublic }) }); if (!res.ok) throw new Error('Failed to update'); showToast(isPublic ? 'Playlist is now public' : 'Playlist is now private'); await loadPlaylists(); } catch (err) { showToast('Failed to update playlist', 'error'); } } async function generateShareLink(playlistId) { try { const res = await fetch(`/api/playlists/${playlistId}/share`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to generate link'); const data = await res.json(); const url = `${window.location.origin}/playlist/${data.shareToken}`; await navigator.clipboard.writeText(url); showToast('Share link copied to clipboard'); await loadPlaylists(); } catch (err) { showToast('Failed to generate share link', 'error'); } } function copyShareLink(token) { const url = `${window.location.origin}/playlist/${token}`; navigator.clipboard.writeText(url).then(() => { showToast('Share link copied to clipboard'); }); } async function copyPlaylist(playlistId) { const playlist = sharedPlaylists.find(p => p.id === playlistId); if (!playlist) return; try { if (playlist.shareToken) { const res = await fetch(`/api/playlists/shared/${playlist.shareToken}`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to copy'); } else { // Create new playlist and copy tracks const newPlaylist = await createPlaylist(`${playlist.name} (Copy)`); if (newPlaylist) { await fetch(`/api/playlists/${newPlaylist.id}/tracks`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ set: playlist.trackIds }) }); } } showToast(`Copied "${playlist.name}" to your playlists`); await loadPlaylists(); } catch (err) { showToast('Failed to copy playlist', 'error'); } } async function removeTrackFromPlaylist(index) { if (!selectedPlaylistId) return; try { const res = await fetch(`/api/playlists/${selectedPlaylistId}/tracks`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ remove: [index] }) }); if (!res.ok) throw new Error('Failed to remove track'); showToast('Track removed from playlist'); await selectPlaylist(selectedPlaylistId); await loadPlaylists(); } catch (err) { showToast('Failed to remove track', 'error'); } } // Add tracks to playlist (used from library/queue context menu) async function addTracksToPlaylist(playlistId, trackIds) { try { const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ add: trackIds }) }); if (!res.ok) throw new Error('Failed to add tracks'); const playlist = myPlaylists.find(p => p.id === playlistId); showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to "${playlist?.name || 'playlist'}"`); if (selectedPlaylistId === playlistId) { await selectPlaylist(playlistId); } await loadPlaylists(); } catch (err) { showToast('Failed to add tracks to playlist', 'error'); } } // Show "Add to Playlist" submenu function showAddToPlaylistMenu(trackIds) { if (myPlaylists.length === 0) { showToast('Create a playlist first', 'info'); return null; } return myPlaylists.map(p => ({ label: p.name, action: () => addTracksToPlaylist(p.id, trackIds) })); } function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function initPlaylists() { // New playlist button const btnNew = $('#btn-new-playlist'); if (btnNew) { btnNew.onclick = () => { // Inline input for new playlist name const container = $('#my-playlists'); const input = document.createElement('input'); input.type = 'text'; input.className = 'new-playlist-input'; input.placeholder = 'Playlist name...'; container.insertBefore(input, container.firstChild); input.focus(); const finish = async (create) => { const name = input.value.trim(); input.remove(); if (create && name) { await createPlaylist(name); } }; input.onblur = () => finish(true); input.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } else if (e.key === 'Escape') { finish(false); } }; }; } // Add to queue buttons const btnAddQueue = $('#btn-playlist-add-queue'); const btnPlayNext = $('#btn-playlist-play-next'); if (btnAddQueue) { btnAddQueue.onclick = () => { if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId); }; } if (btnPlayNext) { btnPlayNext.onclick = () => { if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId, true); }; } // Load playlists when tab is shown document.querySelectorAll('.panel-tab[data-tab="playlists"]').forEach(tab => { tab.addEventListener('click', () => { loadPlaylists(); }); }); } // Expose for other modules M.playlists = { load: loadPlaylists, init: initPlaylists, getMyPlaylists: () => myPlaylists, showAddToPlaylistMenu, addTracksToPlaylist, renderPlaylistContents, reloadCurrentPlaylist }; })();