blastoise/public/playlists.js

571 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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
};
})();