// 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 };
});
},
canRemove: 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
};
})();