From 19d98e0cc9af84d20784bfa625a63e9d7f5a850a Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 08:45:24 -0800 Subject: [PATCH] created favicon and playlists --- AGENTS.md | 58 ++++- db.ts | 277 +++++++++++++++++++++ public/favicon.ico | Bin 0 -> 15406 bytes public/index.html | 27 +++ public/init.js | 9 + public/playlists.js | 570 ++++++++++++++++++++++++++++++++++++++++++++ public/queue.js | 115 ++++++++- public/styles.css | 35 +++ routes/index.ts | 54 +++++ routes/playlists.ts | 260 ++++++++++++++++++++ routes/static.ts | 6 + 11 files changed, 1403 insertions(+), 8 deletions(-) create mode 100644 public/favicon.ico create mode 100644 public/playlists.js create mode 100644 routes/playlists.ts diff --git a/AGENTS.md b/AGENTS.md index 611999b..7d93d7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,13 +98,25 @@ M.trackBlobs // Map - blob URLs for cached tracks ``` GET / → Serves public/index.html +GET /listen/:trackId → Serves index.html (direct track link) GET /api/channels → List all channels with listener counts POST /api/channels → Create a new channel GET /api/channels/:id → Get channel state +PATCH /api/channels/:id → Rename channel DELETE /api/channels/:id → Delete a channel (not default) WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes GET /api/tracks/:id → Serve audio by content hash (supports Range) GET /api/library → List all tracks with id, filename, title, duration +GET /api/playlists → List user's + shared playlists +POST /api/playlists → Create new playlist +GET /api/playlists/:id → Get playlist details +PATCH /api/playlists/:id → Update playlist (name, description, public) +DELETE /api/playlists/:id → Delete playlist +PATCH /api/playlists/:id/tracks → Modify tracks (add/remove/set) +POST /api/playlists/:id/share → Generate share token +DELETE /api/playlists/:id/share → Remove sharing +GET /api/playlists/shared/:token → Get shared playlist by token +POST /api/playlists/shared/:token → Copy shared playlist to own ``` ## Files @@ -129,6 +141,7 @@ GET /api/library → List all tracks with id, filename, title, dura - **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode). - **tracks.ts** — Library listing, file upload, audio serving with range support. - **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status). +- **playlists.ts** — Playlist CRUD and track management. - **static.ts** — Static file serving (index.html, styles.css, JS files). ### Client (public/) @@ -137,7 +150,8 @@ GET /api/library → List all tracks with id, filename, title, dura - **audioCache.js** — Track caching, segment downloads, prefetching - **channelSync.js** — WebSocket connection, server sync, channel switching - **ui.js** — Progress bar, buffer display, UI updates -- **queue.js** — Queue/library rendering, cache status +- **queue.js** — Queue/library rendering, cache status, context menus +- **playlists.js** — Playlist UI, create/edit/delete, add tracks - **controls.js** — Play, pause, seek, volume - **auth.js** — Login, signup, logout - **init.js** — App initialization @@ -260,13 +274,55 @@ Tracks in Library and Queue should behave identically where applicable: - ▶ Play track (local mode, single track) - ⏭ Play next (insert after current) - + Add to queue (append to end) +- 📁 Add to Playlist... (submenu) +- 🔗 Generate listening link **Queue tracks:** - ▶ Play track (jump to track) - ⏭ Play next (re-add after current) - + Add again (duplicate at end) +- 📁 Add to Playlist... (submenu) +- 🔗 Generate listening link - ✕ Remove from queue +## Playlists + +Playlists are reusable collections of tracks that can be added to the queue. + +### Data Model +```ts +interface Playlist { + id: string; + name: string; + description: string; + ownerId: number; + isPublic: boolean; + shareToken: string | null; + trackIds: string[]; +} +``` + +### UI Structure +The Playlists tab has a dual-panel layout: +- **Left panel**: List of playlists (My Playlists + Shared) +- **Right panel**: Selected playlist's track list + +### Context Menu Options +**Playlist (in list):** +- ▶ Add to Queue +- ⏭ Play Next +- ✏️ Rename (owner only) +- 🌐 Make Public / 🔒 Make Private +- 🔗 Copy Share Link / Generate Share Link +- 📋 Copy to My Playlists (shared only) +- 🗑️ Delete (owner only) + +**Track (in playlist):** +- ▶ Play +- ➕ Add to Queue +- ⏭ Play Next +- 🗑️ Remove from Playlist (owner only) + ### Mobile/Touch Support - Larger touch targets (min 44px) - No hover-dependent features (always show action buttons) diff --git a/db.ts b/db.ts index ab8eb8d..87ce889 100644 --- a/db.ts +++ b/db.ts @@ -417,3 +417,280 @@ export function loadChannelQueue(channelId: string): string[] { export function removeTrackFromQueues(trackId: string): void { db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId); } + +// Playlist tables +db.run(` + CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + owner_id INTEGER NOT NULL, + is_public INTEGER DEFAULT 0, + share_token TEXT, + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()), + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS playlist_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL, + track_id TEXT NOT NULL, + position INTEGER NOT NULL, + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE + ) +`); + +db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id)`); + +// Playlist types +export interface PlaylistRow { + id: string; + name: string; + description: string; + owner_id: number; + is_public: number; + share_token: string | null; + created_at: number; + updated_at: number; +} + +export interface Playlist { + id: string; + name: string; + description: string; + ownerId: number; + isPublic: boolean; + shareToken: string | null; + trackIds: string[]; + createdAt: number; + updatedAt: number; +} + +// Playlist CRUD functions +export function createPlaylist(name: string, ownerId: number, description: string = ""): Playlist { + const id = crypto.randomUUID(); + const now = Math.floor(Date.now() / 1000); + + db.query(` + INSERT INTO playlists (id, name, description, owner_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, name, description, ownerId, now, now); + + return { + id, + name, + description, + ownerId, + isPublic: false, + shareToken: null, + trackIds: [], + createdAt: now, + updatedAt: now + }; +} + +export function getPlaylist(id: string): Playlist | null { + const row = db.query("SELECT * FROM playlists WHERE id = ?").get(id) as PlaylistRow | null; + if (!row) return null; + + const tracks = db.query( + "SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position" + ).all(id) as { track_id: string }[]; + + return { + id: row.id, + name: row.name, + description: row.description, + ownerId: row.owner_id, + isPublic: !!row.is_public, + shareToken: row.share_token, + trackIds: tracks.map(t => t.track_id), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +export function getPlaylistsByUser(userId: number): Playlist[] { + const rows = db.query( + "SELECT * FROM playlists WHERE owner_id = ? ORDER BY name" + ).all(userId) as PlaylistRow[]; + + return rows.map(row => { + const tracks = db.query( + "SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position" + ).all(row.id) as { track_id: string }[]; + + return { + id: row.id, + name: row.name, + description: row.description, + ownerId: row.owner_id, + isPublic: !!row.is_public, + shareToken: row.share_token, + trackIds: tracks.map(t => t.track_id), + createdAt: row.created_at, + updatedAt: row.updated_at + }; + }); +} + +export function getPublicPlaylists(excludeUserId?: number): Playlist[] { + const query = excludeUserId + ? "SELECT * FROM playlists WHERE is_public = 1 AND owner_id != ? ORDER BY name" + : "SELECT * FROM playlists WHERE is_public = 1 ORDER BY name"; + + const rows = excludeUserId + ? db.query(query).all(excludeUserId) as PlaylistRow[] + : db.query(query).all() as PlaylistRow[]; + + return rows.map(row => { + const tracks = db.query( + "SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position" + ).all(row.id) as { track_id: string }[]; + + return { + id: row.id, + name: row.name, + description: row.description, + ownerId: row.owner_id, + isPublic: !!row.is_public, + shareToken: row.share_token, + trackIds: tracks.map(t => t.track_id), + createdAt: row.created_at, + updatedAt: row.updated_at + }; + }); +} + +export function getPlaylistByShareToken(token: string): Playlist | null { + const row = db.query( + "SELECT * FROM playlists WHERE share_token = ?" + ).get(token) as PlaylistRow | null; + + if (!row) return null; + + const tracks = db.query( + "SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position" + ).all(row.id) as { track_id: string }[]; + + return { + id: row.id, + name: row.name, + description: row.description, + ownerId: row.owner_id, + isPublic: !!row.is_public, + shareToken: row.share_token, + trackIds: tracks.map(t => t.track_id), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +export function updatePlaylist(id: string, updates: { name?: string; description?: string; isPublic?: boolean }): void { + const now = Math.floor(Date.now() / 1000); + const sets: string[] = ["updated_at = ?"]; + const values: any[] = [now]; + + if (updates.name !== undefined) { + sets.push("name = ?"); + values.push(updates.name); + } + if (updates.description !== undefined) { + sets.push("description = ?"); + values.push(updates.description); + } + if (updates.isPublic !== undefined) { + sets.push("is_public = ?"); + values.push(updates.isPublic ? 1 : 0); + } + + values.push(id); + db.query(`UPDATE playlists SET ${sets.join(", ")} WHERE id = ?`).run(...values); +} + +export function deletePlaylist(id: string): void { + db.query("DELETE FROM playlists WHERE id = ?").run(id); +} + +export function setPlaylistTracks(playlistId: string, trackIds: string[]): void { + const now = Math.floor(Date.now() / 1000); + + db.query("BEGIN").run(); + try { + db.query("DELETE FROM playlist_tracks WHERE playlist_id = ?").run(playlistId); + + const insert = db.query( + "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)" + ); + for (let i = 0; i < trackIds.length; i++) { + insert.run(playlistId, trackIds[i], i); + } + + db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId); + db.query("COMMIT").run(); + } catch (e) { + db.query("ROLLBACK").run(); + throw e; + } +} + +export function addTracksToPlaylist(playlistId: string, trackIds: string[]): void { + const now = Math.floor(Date.now() / 1000); + + db.query("BEGIN").run(); + try { + // Get current max position + const max = db.query( + "SELECT COALESCE(MAX(position), -1) as max_pos FROM playlist_tracks WHERE playlist_id = ?" + ).get(playlistId) as { max_pos: number }; + + let pos = max.max_pos + 1; + const insert = db.query( + "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)" + ); + for (const trackId of trackIds) { + insert.run(playlistId, trackId, pos++); + } + + db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId); + db.query("COMMIT").run(); + } catch (e) { + db.query("ROLLBACK").run(); + throw e; + } +} + +export function removeTrackFromPlaylist(playlistId: string, position: number): void { + const now = Math.floor(Date.now() / 1000); + + db.query("BEGIN").run(); + try { + db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position); + + // Reorder remaining tracks + db.query(` + UPDATE playlist_tracks + SET position = position - 1 + WHERE playlist_id = ? AND position > ? + `).run(playlistId, position); + + db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId); + db.query("COMMIT").run(); + } catch (e) { + db.query("ROLLBACK").run(); + throw e; + } +} + +export function generatePlaylistShareToken(playlistId: string): string { + const token = crypto.randomUUID().slice(0, 12); + db.query("UPDATE playlists SET share_token = ? WHERE id = ?").run(token, playlistId); + return token; +} + +export function removePlaylistShareToken(playlistId: string): void { + db.query("UPDATE playlists SET share_token = NULL WHERE id = ?").run(playlistId); +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..dc163ce8f839ce0b713684293af5f91516e91c73 GIT binary patch literal 15406 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
+
@@ -100,6 +101,31 @@
Drop audio files here
+
+
+
+
+

My Playlists

+
+
+
+

Shared

+
+
+ +
+
+
+ Select a playlist + +
+
+
+
+
No active tasks
@@ -158,6 +184,7 @@ + diff --git a/public/init.js b/public/init.js index e41b2f1..9e758b1 100644 --- a/public/init.js +++ b/public/init.js @@ -108,6 +108,11 @@ } initPanelTabs(); + + // Initialize playlists + if (M.playlists?.init) { + M.playlists.init(); + } }); // Update UI based on server status @@ -128,6 +133,10 @@ await M.loadCurrentUser(); if (M.currentUser) { M.loadChannels(); + // Load playlists after auth + if (M.playlists?.load) { + M.playlists.load(); + } } // Handle direct track link after everything is loaded diff --git a/public/playlists.js b/public/playlists.js new file mode 100644 index 0000000..65e1dbd --- /dev/null +++ b/public/playlists.js @@ -0,0 +1,570 @@ +// 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'); + } + } + + 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 = '
No tracks in this playlist
'; + 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) => ` +
+ ${i + 1} + ${escapeHtml(track.title || track.filename || 'Unknown')} + ${formatTime(track.duration)} +
+ `).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 + }; +})(); diff --git a/public/queue.js b/public/queue.js index 7148c17..be8f9d2 100644 --- a/public/queue.js +++ b/public/queue.js @@ -164,15 +164,68 @@ menu.className = "context-menu"; items.forEach(item => { + if (item.separator) { + const sep = document.createElement("div"); + sep.className = "context-menu-separator"; + menu.appendChild(sep); + return; + } + const el = document.createElement("div"); - el.className = "context-menu-item" + (item.danger ? " danger" : ""); + el.className = "context-menu-item" + (item.danger ? " danger" : "") + (item.disabled ? " disabled" : ""); el.textContent = item.label; - el.onclick = (ev) => { - ev.stopPropagation(); - menu.remove(); - activeContextMenu = null; - item.action(); - }; + + if (item.submenu) { + // Has submenu - show on hover + el.classList.add("has-submenu"); + el.innerHTML += ' ▸'; + + const submenu = document.createElement("div"); + submenu.className = "context-menu context-submenu"; + + item.submenu.forEach(sub => { + const subEl = document.createElement("div"); + subEl.className = "context-menu-item"; + subEl.textContent = sub.label; + subEl.onclick = (ev) => { + ev.stopPropagation(); + menu.remove(); + activeContextMenu = null; + sub.action(); + }; + submenu.appendChild(subEl); + }); + + el.appendChild(submenu); + + // Position submenu on hover + el.onmouseenter = () => { + const rect = el.getBoundingClientRect(); + submenu.style.display = "block"; + submenu.style.left = rect.width + "px"; + submenu.style.top = "0px"; + + // Check if submenu goes off screen + const subRect = submenu.getBoundingClientRect(); + if (subRect.right > window.innerWidth) { + submenu.style.left = (-subRect.width) + "px"; + } + if (subRect.bottom > window.innerHeight) { + submenu.style.top = (window.innerHeight - subRect.bottom - 5) + "px"; + } + }; + el.onmouseleave = () => { + submenu.style.display = "none"; + }; + } else { + el.onclick = (ev) => { + ev.stopPropagation(); + if (item.disabled) return; + menu.remove(); + activeContextMenu = null; + item.action(); + }; + } menu.appendChild(el); }); @@ -190,6 +243,17 @@ activeContextMenu = menu; } + // Expose context menu API for other modules (playlists.js) + M.contextMenu = { + show: showContextMenu, + hide: () => { + if (activeContextMenu) { + activeContextMenu.remove(); + activeContextMenu = null; + } + } + }; + // Drag state for queue reordering let draggedIndices = []; let draggedLibraryIds = []; @@ -698,6 +762,26 @@ }); } + // Add to Playlist option + if (M.playlists && !M.currentUser?.is_guest) { + const trackIds = hasSelection + ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) + : [trackId]; + const submenu = M.playlists.showAddToPlaylistMenu(trackIds); + if (submenu && submenu.length > 0) { + menuItems.push({ + label: hasSelection && trackIds.length > 1 ? `📁 Add ${trackIds.length} to Playlist...` : "📁 Add to Playlist...", + submenu: submenu + }); + } else if (M.playlists.getMyPlaylists().length === 0) { + menuItems.push({ + label: "📁 Add to Playlist...", + disabled: true, + action: () => M.showToast("Create a playlist first in the Playlists tab", "info") + }); + } + } + // Clear selection option (if items selected) if (hasSelection) { menuItems.push({ @@ -940,6 +1024,23 @@ }); } + // Add to Playlist option + if (M.playlists && !M.currentUser?.is_guest) { + const submenu = M.playlists.showAddToPlaylistMenu(idsToAdd); + if (submenu && submenu.length > 0) { + menuItems.push({ + label: hasSelection && idsToAdd.length > 1 ? `📁 Add ${idsToAdd.length} to Playlist...` : "📁 Add to Playlist...", + submenu: submenu + }); + } else if (M.playlists.getMyPlaylists().length === 0) { + menuItems.push({ + label: "📁 Add to Playlist...", + disabled: true, + action: () => M.showToast("Create a playlist first in the Playlists tab", "info") + }); + } + } + // Export all cached option (if there are cached tracks) if (M.cachedTracks.size > 0) { menuItems.push({ diff --git a/public/styles.css b/public/styles.css index dca790f..3b67e38 100644 --- a/public/styles.css +++ b/public/styles.css @@ -261,6 +261,41 @@ button:hover { background: #333; } .history-item.history-error { color: #e44; background: #2a1a1a; } .history-time { color: #666; margin-right: 0.4rem; } +/* Playlists UI */ +.playlists-container { display: flex; flex: 1; min-height: 0; gap: 0.5rem; } +#playlists-list-panel { width: 180px; flex-shrink: 0; display: flex; flex-direction: column; overflow: hidden; } +#playlist-contents-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; } +.playlists-section { margin-bottom: 0.5rem; } +.playlists-section h4 { color: #666; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.3rem; padding: 0 0.3rem; } +.playlist-item { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem; } +.playlist-item:hover { background: #2a2a2a; } +.playlist-item.selected { background: #2a4a3a; } +.playlist-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.playlist-owner { color: #666; font-size: 0.7rem; } +.playlist-count { color: #666; font-size: 0.7rem; background: #333; padding: 0.1rem 0.3rem; border-radius: 3px; } +.empty-playlists { color: #555; font-size: 0.75rem; padding: 0.5rem; font-style: italic; } +.new-playlist-btn { width: 100%; padding: 0.4rem 0.5rem; background: #252525; border: 1px dashed #444; border-radius: 4px; color: #888; font-size: 0.8rem; cursor: pointer; margin-top: auto; } +.new-playlist-btn:hover { background: #2a2a2a; border-color: #4e8; color: #4e8; } +.new-playlist-input, .playlist-rename-input { width: 100%; padding: 0.3rem 0.5rem; background: #222; border: 1px solid #4e8; border-radius: 4px; color: #eee; font-size: 0.8rem; margin-bottom: 0.3rem; outline: none; } +#playlist-contents-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0.3rem; background: #252525; border-radius: 4px; } +#selected-playlist-name { font-size: 0.9rem; font-weight: 600; color: #eee; } +#playlist-actions { display: flex; gap: 0.3rem; } +#playlist-actions.hidden { display: none; } +#playlist-actions button { padding: 0.2rem 0.5rem; font-size: 0.75rem; background: #333; border: 1px solid #444; border-radius: 3px; color: #aaa; cursor: pointer; } +#playlist-actions button:hover { background: #444; color: #eee; } +#playlist-tracks { flex: 1; overflow-y: auto; } +.playlist-track { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer; } +.playlist-track:hover { background: #2a2a2a; } +.playlist-track .track-number { color: #555; font-size: 0.7rem; min-width: 1.5rem; } +.playlist-track .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.playlist-track .track-duration { color: #666; font-size: 0.75rem; } +.empty-playlist-tracks { color: #555; font-size: 0.8rem; padding: 1rem; text-align: center; font-style: italic; } +.context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; } +.context-menu-item.has-submenu { position: relative; } +.context-submenu { position: absolute; display: none; top: 0; left: 100%; margin-left: 2px; } +.context-menu-item.disabled { color: #666; cursor: default; } +.context-menu-item.disabled:hover { background: transparent; } + /* Mobile tab bar - hidden on desktop */ #mobile-tabs { display: none; } diff --git a/routes/index.ts b/routes/index.ts index 9f9d33e..67505fa 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -42,6 +42,20 @@ import { handleGetFetchQueue, } from "./fetch"; +// Playlist routes +import { + handleListPlaylists, + handleCreatePlaylist, + handleGetPlaylist, + handleUpdatePlaylist, + handleDeletePlaylist, + handleModifyPlaylistTracks, + handleSharePlaylist, + handleUnsharePlaylist, + handleGetSharedPlaylist, + handleCopySharedPlaylist, +} from "./playlists"; + // Static file serving import { handleStatic } from "./static"; @@ -136,6 +150,46 @@ export function createRouter() { return handleGetFetchQueue(req, server); } + // Playlist routes + if (path === "/api/playlists" && req.method === "GET") { + return handleListPlaylists(req, server); + } + if (path === "/api/playlists" && req.method === "POST") { + return handleCreatePlaylist(req, server); + } + + const sharedPlaylistMatch = path.match(/^\/api\/playlists\/shared\/([^/]+)$/); + if (sharedPlaylistMatch && req.method === "GET") { + return handleGetSharedPlaylist(req, server, sharedPlaylistMatch[1]); + } + if (sharedPlaylistMatch && req.method === "POST") { + return handleCopySharedPlaylist(req, server, sharedPlaylistMatch[1]); + } + + const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/); + if (playlistMatch && req.method === "GET") { + return handleGetPlaylist(req, server, playlistMatch[1]); + } + if (playlistMatch && req.method === "PATCH") { + return handleUpdatePlaylist(req, server, playlistMatch[1]); + } + if (playlistMatch && req.method === "DELETE") { + return handleDeletePlaylist(req, server, playlistMatch[1]); + } + + const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/); + if (playlistTracksMatch && req.method === "PATCH") { + return handleModifyPlaylistTracks(req, server, playlistTracksMatch[1]); + } + + const playlistShareMatch = path.match(/^\/api\/playlists\/([^/]+)\/share$/); + if (playlistShareMatch && req.method === "POST") { + return handleSharePlaylist(req, server, playlistShareMatch[1]); + } + if (playlistShareMatch && req.method === "DELETE") { + return handleUnsharePlaylist(req, server, playlistShareMatch[1]); + } + // Auth routes if (path === "/api/auth/signup" && req.method === "POST") { return handleSignup(req, server); diff --git a/routes/playlists.ts b/routes/playlists.ts new file mode 100644 index 0000000..20d9afd --- /dev/null +++ b/routes/playlists.ts @@ -0,0 +1,260 @@ +import { + createPlaylist, + getPlaylist, + getPlaylistsByUser, + getPublicPlaylists, + getPlaylistByShareToken, + updatePlaylist, + deletePlaylist, + setPlaylistTracks, + addTracksToPlaylist, + removeTrackFromPlaylist, + generatePlaylistShareToken, + removePlaylistShareToken, + findUserById, +} from "../db"; +import { getOrCreateUser, userHasPermission } from "./helpers"; + +// GET /api/playlists - List user's + shared playlists +export function handleListPlaylists(req: Request, server: any): Response { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const myPlaylists = getPlaylistsByUser(user.id); + const sharedPlaylists = getPublicPlaylists(user.id); + + return Response.json({ + mine: myPlaylists, + shared: sharedPlaylists, + }); +} + +// POST /api/playlists - Create new playlist +export async function handleCreatePlaylist(req: Request, server: any): Promise { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.is_guest) { + return Response.json({ error: "Guests cannot create playlists" }, { status: 403 }); + } + + let body: { name: string; description?: string }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.name?.trim()) { + return Response.json({ error: "Name required" }, { status: 400 }); + } + + const playlist = createPlaylist(body.name.trim(), user.id, body.description?.trim() || ""); + return Response.json(playlist, { status: 201 }); +} + +// GET /api/playlists/:id - Get playlist details +export function handleGetPlaylist(req: Request, server: any, playlistId: string): Response { + const { user } = getOrCreateUser(req, server); + const playlist = getPlaylist(playlistId); + + if (!playlist) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + // Check access: owner, public, or has share token + const url = new URL(req.url); + const shareToken = url.searchParams.get("token"); + + if ( + playlist.ownerId !== user?.id && + !playlist.isPublic && + playlist.shareToken !== shareToken + ) { + return Response.json({ error: "Access denied" }, { status: 403 }); + } + + // Include owner username + const owner = findUserById(playlist.ownerId); + return Response.json({ + ...playlist, + ownerName: owner?.username || "Unknown", + }); +} + +// PATCH /api/playlists/:id - Update playlist +export async function handleUpdatePlaylist(req: Request, server: any, playlistId: string): Promise { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const playlist = getPlaylist(playlistId); + if (!playlist) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + if (playlist.ownerId !== user.id && !user.is_admin) { + return Response.json({ error: "Not your playlist" }, { status: 403 }); + } + + let body: { name?: string; description?: string; isPublic?: boolean }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + updatePlaylist(playlistId, { + name: body.name?.trim(), + description: body.description?.trim(), + isPublic: body.isPublic, + }); + + return Response.json({ ok: true }); +} + +// DELETE /api/playlists/:id - Delete playlist +export function handleDeletePlaylist(req: Request, server: any, playlistId: string): Response { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const playlist = getPlaylist(playlistId); + if (!playlist) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + if (playlist.ownerId !== user.id && !user.is_admin) { + return Response.json({ error: "Not your playlist" }, { status: 403 }); + } + + deletePlaylist(playlistId); + return Response.json({ ok: true }); +} + +// PATCH /api/playlists/:id/tracks - Modify tracks +export async function handleModifyPlaylistTracks(req: Request, server: any, playlistId: string): Promise { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const playlist = getPlaylist(playlistId); + if (!playlist) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + if (playlist.ownerId !== user.id && !user.is_admin) { + return Response.json({ error: "Not your playlist" }, { status: 403 }); + } + + let body: { add?: string[]; remove?: number[]; set?: string[] }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + // If 'set' is provided, replace entire track list + if (body.set !== undefined) { + setPlaylistTracks(playlistId, body.set); + return Response.json({ ok: true }); + } + + // Remove tracks by position (do removes first, in reverse order) + if (body.remove?.length) { + const positions = [...body.remove].sort((a, b) => b - a); + for (const pos of positions) { + removeTrackFromPlaylist(playlistId, pos); + } + } + + // Add tracks + if (body.add?.length) { + addTracksToPlaylist(playlistId, body.add); + } + + return Response.json({ ok: true }); +} + +// POST /api/playlists/:id/share - Generate share token +export function handleSharePlaylist(req: Request, server: any, playlistId: string): Response { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const playlist = getPlaylist(playlistId); + if (!playlist) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + if (playlist.ownerId !== user.id && !user.is_admin) { + return Response.json({ error: "Not your playlist" }, { status: 403 }); + } + + const token = generatePlaylistShareToken(playlistId); + return Response.json({ shareToken: token }); +} + +// DELETE /api/playlists/:id/share - Remove sharing +export function handleUnsharePlaylist(req: Request, server: any, playlistId: string): Response { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const playlist = getPlaylist(playlistId); + if (!playlist) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + if (playlist.ownerId !== user.id && !user.is_admin) { + return Response.json({ error: "Not your playlist" }, { status: 403 }); + } + + removePlaylistShareToken(playlistId); + return Response.json({ ok: true }); +} + +// GET /api/playlists/shared/:token - Get shared playlist by token +export function handleGetSharedPlaylist(req: Request, server: any, token: string): Response { + const playlist = getPlaylistByShareToken(token); + if (!playlist) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + const owner = findUserById(playlist.ownerId); + return Response.json({ + ...playlist, + ownerName: owner?.username || "Unknown", + }); +} + +// POST /api/playlists/shared/:token/copy - Copy shared playlist to own +export function handleCopySharedPlaylist(req: Request, server: any, token: string): Response { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.is_guest) { + return Response.json({ error: "Guests cannot copy playlists" }, { status: 403 }); + } + + const original = getPlaylistByShareToken(token); + if (!original) { + return Response.json({ error: "Playlist not found" }, { status: 404 }); + } + + const copy = createPlaylist(`${original.name} (Copy)`, user.id, original.description); + setPlaylistTracks(copy.id, original.trackIds); + + return Response.json(copy, { status: 201 }); +} diff --git a/routes/static.ts b/routes/static.ts index 0db7a53..284dab6 100644 --- a/routes/static.ts +++ b/routes/static.ts @@ -16,6 +16,12 @@ export async function handleStatic(path: string): Promise { }); } + if (path === "/favicon.ico") { + return new Response(file(join(PUBLIC_DIR, "favicon.ico")), { + headers: { "Content-Type": "image/x-icon" }, + }); + } + if (path.endsWith(".js")) { const jsFile = file(join(PUBLIC_DIR, path.slice(1))); if (await jsFile.exists()) {