537 lines
16 KiB
JavaScript
537 lines
16 KiB
JavaScript
// 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 = '<div class="empty-playlists">No playlists yet</div>';
|
|
} else {
|
|
myContainer.innerHTML = myPlaylists.map(p => `
|
|
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
|
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
|
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
|
|
<span class="playlist-count">${p.trackIds.length}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Shared playlists
|
|
if (sharedPlaylists.length === 0) {
|
|
sharedContainer.innerHTML = '<div class="empty-playlists">No shared playlists</div>';
|
|
} else {
|
|
sharedContainer.innerHTML = sharedPlaylists.map(p => `
|
|
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
|
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
|
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span>
|
|
<span class="playlist-count">${p.trackIds.length}</span>
|
|
</div>
|
|
`).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
|
|
};
|
|
})();
|