571 lines
17 KiB
JavaScript
571 lines
17 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');
|
||
}
|
||
}
|
||
|
||
function renderPlaylistContents() {
|
||
const header = $('#selected-playlist-name');
|
||
const actions = $('#playlist-actions');
|
||
const container = $('#playlist-tracks');
|
||
|
||
if (!selectedPlaylist) {
|
||
header.textContent = 'Select a playlist';
|
||
actions.classList.add('hidden');
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
header.textContent = selectedPlaylist.name;
|
||
actions.classList.remove('hidden');
|
||
|
||
if (selectedPlaylist.trackIds.length === 0) {
|
||
container.innerHTML = '<div class="empty-playlist-tracks">No tracks in this playlist</div>';
|
||
return;
|
||
}
|
||
|
||
// Get track info from library
|
||
const tracks = selectedPlaylist.trackIds.map(id => {
|
||
const track = M.library.find(t => t.id === id);
|
||
return track || { id, title: 'Unknown track', duration: 0 };
|
||
});
|
||
|
||
container.innerHTML = tracks.map((track, i) => `
|
||
<div class="playlist-track" data-id="${track.id}" data-index="${i}">
|
||
<span class="track-number">${i + 1}</span>
|
||
<span class="track-title">${escapeHtml(track.title || track.filename || 'Unknown')}</span>
|
||
<span class="track-duration">${formatTime(track.duration)}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Attach click handlers
|
||
container.querySelectorAll('.playlist-track').forEach(el => {
|
||
el.oncontextmenu = (e) => showPlaylistTrackContextMenu(e, el.dataset.id, parseInt(el.dataset.index));
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function showPlaylistTrackContextMenu(e, trackId, index) {
|
||
e.preventDefault();
|
||
M.contextMenu.hide();
|
||
|
||
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
|
||
const items = [];
|
||
|
||
// Play
|
||
items.push({
|
||
label: '▶ Play',
|
||
action: () => playTrackFromPlaylist(trackId)
|
||
});
|
||
|
||
items.push({ separator: true });
|
||
|
||
// Add to queue
|
||
items.push({
|
||
label: '➕ Add to Queue',
|
||
action: () => addTracksToQueue([trackId])
|
||
});
|
||
items.push({
|
||
label: '⏭ Play Next',
|
||
action: () => addTracksToQueue([trackId], true)
|
||
});
|
||
|
||
if (isMine) {
|
||
items.push({ separator: true });
|
||
|
||
// Remove from playlist
|
||
items.push({
|
||
label: '🗑️ Remove from Playlist',
|
||
action: () => removeTrackFromPlaylist(index),
|
||
className: 'danger'
|
||
});
|
||
}
|
||
|
||
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
|
||
};
|
||
})();
|