From 19d98e0cc9af84d20784bfa625a63e9d7f5a850a Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 08:45:24 -0800 Subject: [PATCH 01/19] 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 zcmeHNd2Ce28Gp{vrb?AdB~6Uib~w#_7<1U(^>PHTW8-7@EhL5nCBaRhCf@Uz9Ar66 zxQ`-&auA|65tP%rOB^N8AR!XcHvK123k|IxP1=%1kc2BlCZ7I&&pYP%dEa_TkXlu{ z(&(Ev^Ih}JH#6USvx<_TT(8`An*x}tJa&Vkj8+sSH@E#e?{-Cb8EwOd$NT#!%DX)j zB@Z|t0Y!T|KuW*Tmiiy6(Z-YRXy{T+BsAVgq(-#$DBr5F#*M~yV_n@*-$RXRg2f%x z>WzKwNa&E>7t`K0wj1kNUT2?zHR{YN{BpuuNnJN?Oad)i=9i zT8pG5R3nXtAX5}=n5h@y%KD&1n#pR-KeXWj1 z9Xja4HqG=qesrLWEt z&KDmRMVn@c(Q9X%$@1A(VDDMosrVeLYz_8hBUZQ$IW|8e?%i4^DtCGHd7Hj3FgTfv zM1A_6=Py}_z4aUl_t@`L@}4QZkUhURKamWpwz}u*ZtU~KHx~9FHfa&BNSmg^#{rC7bPPB=}rT(5ds|xiMz-4E?O*xtZ-R(f5CO zj!DR~g!&HRxwiHJ!E-5pg@*E;3!e3twrTLaYdJQ zo@tcB^IhT+@|*2;_yop&CLV)iSjf-7TKxn4xA%fcdTe0i#X8PvL7SM{dB#&`+Q`_) zZ{03SD!ka6^fTT@DKe5dyhjscSWf|M4JNBqkx7X$0D&>QTfu`jx~yN?>XY< z1RVEZ44=@pjH9_GInVS%DW7`~+gS1zUco_}t{G(->x|7I{|oAnNPbO3b4dKV8ph_r zhu)VmB;-0MSm_arNo89ce%-U7xK7erL06=41nhiF+Cf5htnrmDGm}D_WW&NLRf^aGoW9S zYHdsXEs${>k;jPnwC&js)F=9_<0PmeyTjX z!-IV*t>i(MbMW2T?s&+4SM!1~ai33Q|I5tNN!)7%YxXtvim%y3GS9gp+RRjCalKpW zAJI&Xb=^@d0dols~2Uj&AZ^(^k6vKUsRmcQ0z=t zy!MhDpQF&`EgCgueeKNh?ep76U%AsOs(1SZ=C9o@5$&z}WB!i!i>uHt8EQsmXng-s zYt%RLSGn~!oW8j4_R=QmcYmAzs&j#tc{kVJoEACx8mZS%vjROqefZ-h?_bcgAnoPu-cPk$Ds&o?pS+AAL#kAsn-Q#?g2# z<14xELH`4(>hFmJi;#mm$=nwCQ8sf-%;hqVCJXa>)SEa%7I)1<$}uoc#r!4lm@_I| z*O>?VIalo0d0|aRz2orM^q4j*75!^l!iW_@+cEwQoAG^C>SBg6hsE5Hxlev`yzRM5 zhwfLh-3awD@-39MSUOGaM;D>Tdul8&E*0I0<7w6^FlPOH@6|X_=f`}yQI@ezy*N+i zT(1#_^}&lOlJeqQ@*SD_4_aJt0y^MrK>OL%yq7UH#2el3<=pX&oBZUPwOJ>A^Nm2) zA8!PS`ehq)s^pcw+?5c|sDEx?!tz~mn@s;%Zw79L-ybmQKk?-`y8hfBjIxY%z8CQfGrv_BaaiX( z(f$Ri60Z}|$M=i##{Uo`)7jXE7$=X0a}j1&4||E63k{kSjbeqqXi8};Y<%*I>^?u;&G3wQ+4u7iJ+i=_TJ-%UxlYW6%bqj6oiH80Ny-yi+XT4@`pmq52 z>N7Zh+l>GBkTMCmZfUonOS--zNj&{KDa+>>mqPeF%84D$PpOgCW{f~Kh7TIQmat@KZK9J0J5CiYGj9nAn zheDsjhwg*^J2?0B0ou|io8SB|4Clbgwo<=D>xlHUJ#;_Y)9ZLh2`iTOU)cSk*+-Io zN3_p)AF_ptGNJzoj$!bkX`RMEof%88&+43zXO~%j((-J~_La0D?%65-cVuvxPF2|d zeY3tb+r6T6X|1Sy;pcd#ipSws=Hk=p__@vh`rxhpB=zJuGZOnaeTRkbM{MUjnCF1~ z@qI;y+y`iL#!B++%l0|m&h$BKos>=RfwlSw^zV3Ai=UoBK;_d{~Wi<>)8Ay1$d9$2Kg7wdhwfnP9KvtHsQe69UtS&Thb%wy!u=Za%DBU zLs{7KUqo#8jU0ywe&&t^CnvK*FKyDTHotue*4;sz50_0k>b}yo&3*y8KM#KyHZVN! z2TAyM+r|48<9ztjud%L=Vr}4mhK(mNrgx}2W4Ugb5J6S1$N&CD=J@SR(Dw-J`cZtH ze~fXRz}h?t*kWsN%)9gPF+WX-=T#!rzFL1v)!txl*REjS?*3Pilr{Elo
+
@@ -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()) { From 3da3a5e482d7623c817343c821de662962314c74 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 09:00:23 -0800 Subject: [PATCH 02/19] updated queue editing and basic playlist functionality --- public/queue.js | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/public/queue.js b/public/queue.js index be8f9d2..d2724c5 100644 --- a/public/queue.js +++ b/public/queue.js @@ -10,7 +10,10 @@ // Last selected index for shift-select range let lastSelectedQueueIndex = null; - let lastSelectedLibraryIndex = null; + let lastSelectedLibraryIndex = null; // Index in filtered list, not original library + + // Current filtered library (for shift-select to work correctly with search) + let currentFilteredLibrary = []; // Context menu state let activeContextMenu = null; @@ -341,22 +344,26 @@ M.renderQueue(); }; - M.toggleLibrarySelection = function(index, shiftKey = false) { - if (shiftKey && lastSelectedLibraryIndex !== null) { - // Range select: select all between last and current - const start = Math.min(lastSelectedLibraryIndex, index); - const end = Math.max(lastSelectedLibraryIndex, index); + M.toggleLibrarySelection = function(filteredIndex, shiftKey = false) { + if (shiftKey && lastSelectedLibraryIndex !== null && currentFilteredLibrary.length > 0) { + // Range select: select all between last and current in FILTERED list + const start = Math.min(lastSelectedLibraryIndex, filteredIndex); + const end = Math.max(lastSelectedLibraryIndex, filteredIndex); for (let i = start; i <= end; i++) { - M.selectedLibraryIds.add(M.library[i].id); + if (currentFilteredLibrary[i]) { + M.selectedLibraryIds.add(currentFilteredLibrary[i].track.id); + } } } else { - const trackId = M.library[index].id; + const item = currentFilteredLibrary[filteredIndex]; + if (!item) return; + const trackId = item.track.id; if (M.selectedLibraryIds.has(trackId)) { M.selectedLibraryIds.delete(trackId); } else { M.selectedLibraryIds.add(trackId); } - lastSelectedLibraryIndex = index; + lastSelectedLibraryIndex = filteredIndex; } M.renderLibrary(); }; @@ -852,19 +859,19 @@ const query = M.librarySearchQuery.toLowerCase(); // Filter library by search query - const filteredLibrary = query + currentFilteredLibrary = query ? M.library.map((track, i) => ({ track, i })).filter(({ track }) => { const title = track.title?.trim() || track.filename; return title.toLowerCase().includes(query); }) : M.library.map((track, i) => ({ track, i })); - if (filteredLibrary.length === 0) { + if (currentFilteredLibrary.length === 0) { container.innerHTML = '
No matches
'; return; } - filteredLibrary.forEach(({ track, i }) => { + currentFilteredLibrary.forEach(({ track, i }, filteredIndex) => { const div = document.createElement("div"); const isCached = M.cachedTracks.has(track.id); const isSelected = M.selectedLibraryIds.has(track.id); @@ -911,7 +918,7 @@ // Click toggles selection div.onclick = (e) => { if (e.target.closest('.track-actions')) return; - M.toggleLibrarySelection(i, e.shiftKey); + M.toggleLibrarySelection(filteredIndex, e.shiftKey); }; // Right-click context menu From f7a743c60028e57688438caf387450e599af7258 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 16:00:50 -0800 Subject: [PATCH 03/19] refactored trackComponent and fixed a lot of bugs with playlist editing --- public/index.html | 2 + public/playlists.js | 97 ++-- public/queue.js | 962 ++++----------------------------------- public/styles.css | 19 +- public/trackComponent.js | 91 ++++ public/trackContainer.js | 922 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1146 insertions(+), 947 deletions(-) create mode 100644 public/trackComponent.js create mode 100644 public/trackContainer.js diff --git a/public/index.html b/public/index.html index aadbfae..b75990b 100644 --- a/public/index.html +++ b/public/index.html @@ -180,6 +180,8 @@ + + diff --git a/public/playlists.js b/public/playlists.js index 65e1dbd..79324ad 100644 --- a/public/playlists.js +++ b/public/playlists.js @@ -91,44 +91,50 @@ } } + // Playlist tracks container instance + let playlistContainer = null; + function renderPlaylistContents() { const header = $('#selected-playlist-name'); const actions = $('#playlist-actions'); - const container = $('#playlist-tracks'); + const containerEl = $('#playlist-tracks'); if (!selectedPlaylist) { header.textContent = 'Select a playlist'; actions.classList.add('hidden'); - container.innerHTML = ''; + containerEl.innerHTML = ''; return; } header.textContent = selectedPlaylist.name; actions.classList.remove('hidden'); - if (selectedPlaylist.trackIds.length === 0) { - container.innerHTML = '
No tracks in this playlist
'; - return; + const isMine = myPlaylists.some(p => p.id === selectedPlaylistId); + console.log("[Playlists] Creating container - isMine:", isMine, "selectedPlaylistId:", selectedPlaylistId); + + // Create or update container (even for empty playlists, to enable drag-drop) + playlistContainer = M.trackContainer.createContainer({ + type: 'playlist', + element: containerEl, + getTracks: () => { + return selectedPlaylist.trackIds.map((id, i) => { + const track = M.library.find(t => t.id === id); + return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i }; + }); + }, + canRemove: isMine, + playlistId: selectedPlaylistId + }); + + console.log("[Playlists] About to call render(), playlistContainer:", playlistContainer); + playlistContainer.render(); + console.log("[Playlists] After render()"); + } + + function reloadCurrentPlaylist() { + if (selectedPlaylistId) { + selectPlaylist(selectedPlaylistId); } - - // 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) { @@ -193,45 +199,6 @@ 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) { @@ -565,6 +532,8 @@ init: initPlaylists, getMyPlaylists: () => myPlaylists, showAddToPlaylistMenu, - addTracksToPlaylist + addTracksToPlaylist, + renderPlaylistContents, + reloadCurrentPlaylist }; })(); diff --git a/public/queue.js b/public/queue.js index d2724c5..1aa7745 100644 --- a/public/queue.js +++ b/public/queue.js @@ -1,28 +1,21 @@ // MusicRoom - Queue module -// Queue rendering and library display +// Queue and library display using trackContainer (function() { const M = window.MusicRoom; - // Selection state for bulk operations - M.selectedQueueIndices = new Set(); - M.selectedLibraryIds = new Set(); - - // Last selected index for shift-select range - let lastSelectedQueueIndex = null; - let lastSelectedLibraryIndex = null; // Index in filtered list, not original library - - // Current filtered library (for shift-select to work correctly with search) - let currentFilteredLibrary = []; - - // Context menu state - let activeContextMenu = null; - // Download state - only one at a time let isDownloading = false; let exportQueue = []; let isExporting = false; + // Container instances + let queueContainer = null; + let libraryContainer = null; + + // Library search state + M.librarySearchQuery = ""; + // Download a track to user's device (uses cache if available) async function downloadTrack(trackId, filename) { if (isDownloading) { @@ -84,38 +77,31 @@ return; } - // Build list of cached tracks with filenames const cachedIds = [...M.cachedTracks]; if (cachedIds.length === 0) { M.showToast("No cached tracks to export", "warning"); return; } - // Find filenames from library or queue const trackMap = new Map(); M.library.forEach(t => { if (t.filename) trackMap.set(t.id, t.filename); }); M.queue.forEach(t => { if (t.filename && !trackMap.has(t.id)) trackMap.set(t.id, t.filename); }); - // Only export tracks with known filenames exportQueue = cachedIds .filter(id => trackMap.has(id)) .map(id => ({ id, filename: trackMap.get(id) })); - const skipped = cachedIds.length - exportQueue.length; if (exportQueue.length === 0) { - M.showToast("No exportable tracks (filenames unknown)", "warning"); + M.showToast("No exportable tracks found", "warning"); return; } isExporting = true; - const msg = skipped > 0 - ? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)` - : `Exporting ${exportQueue.length} cached tracks...`; - M.showToast(msg); + M.showToast(`Exporting ${exportQueue.length} tracks...`); let exported = 0; for (const { id, filename } of exportQueue) { - if (!isExporting) break; // Allow cancellation + if (!isExporting) break; try { const cached = await TrackStorage.get(id); @@ -129,8 +115,6 @@ document.body.removeChild(a); URL.revokeObjectURL(url); exported++; - - // Small delay between downloads to not overwhelm browser await new Promise(r => setTimeout(r, 500)); } } catch (e) { @@ -149,318 +133,51 @@ M.showToast("Export cancelled"); } }; - - // Close context menu when clicking elsewhere - document.addEventListener("click", () => { - if (activeContextMenu) { - activeContextMenu.remove(); - activeContextMenu = null; - } - }); - - // Show context menu - function showContextMenu(e, items) { - e.preventDefault(); - if (activeContextMenu) activeContextMenu.remove(); - - const menu = document.createElement("div"); - 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" : "") + (item.disabled ? " disabled" : ""); - el.textContent = item.label; - - 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); - }); - - document.body.appendChild(menu); - - // Position menu, keep within viewport - let x = e.clientX; - let y = e.clientY; - const rect = menu.getBoundingClientRect(); - if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5; - if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5; - menu.style.left = x + "px"; - menu.style.top = y + "px"; - - 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 = []; - let dropTargetIndex = null; - let dragSource = null; // 'queue' or 'library' - - // Insert library tracks into queue at position - async function insertTracksAtPosition(trackIds, position) { - if (!M.currentChannelId || trackIds.length === 0) return; - - // Build new queue with tracks inserted at position - const newQueue = [...M.queue]; - const newTrackIds = [...newQueue.map(t => t.id)]; - newTrackIds.splice(position, 0, ...trackIds); - - const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ set: newTrackIds }) - }); - - if (res.status === 403) M.flashPermissionDenied(); - else if (res.ok) { - M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); - M.clearSelections(); - } - } - - // Reorder queue on server - async function reorderQueue(fromIndices, toIndex) { - if (!M.currentChannelId || fromIndices.length === 0) return; - - // Build new queue order - const newQueue = [...M.queue]; - - // Sort indices descending to remove from end first - const sortedIndices = [...fromIndices].sort((a, b) => b - a); - const movedTracks = []; - - // Remove items (in reverse order to preserve indices) - for (const idx of sortedIndices) { - movedTracks.unshift(newQueue.splice(idx, 1)[0]); - } - - // Adjust target index for removed items before it - let adjustedTarget = toIndex; - for (const idx of fromIndices) { - if (idx < toIndex) adjustedTarget--; - } - - // Insert at new position - newQueue.splice(adjustedTarget, 0, ...movedTracks); - - // Send to server - const trackIds = newQueue.map(t => t.id); - const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ set: trackIds }) - }); - - if (res.status === 403) M.flashPermissionDenied(); - else if (res.ok) { - M.clearSelections(); - } - } - - // Toggle selection mode (with optional shift for range select) - M.toggleQueueSelection = function(index, shiftKey = false) { - if (shiftKey && lastSelectedQueueIndex !== null) { - // Range select: select all between last and current - const start = Math.min(lastSelectedQueueIndex, index); - const end = Math.max(lastSelectedQueueIndex, index); - for (let i = start; i <= end; i++) { - M.selectedQueueIndices.add(i); - } - } else { - if (M.selectedQueueIndices.has(index)) { - M.selectedQueueIndices.delete(index); - } else { - M.selectedQueueIndices.add(index); - } - lastSelectedQueueIndex = index; - } - M.renderQueue(); - }; - - M.toggleLibrarySelection = function(filteredIndex, shiftKey = false) { - if (shiftKey && lastSelectedLibraryIndex !== null && currentFilteredLibrary.length > 0) { - // Range select: select all between last and current in FILTERED list - const start = Math.min(lastSelectedLibraryIndex, filteredIndex); - const end = Math.max(lastSelectedLibraryIndex, filteredIndex); - for (let i = start; i <= end; i++) { - if (currentFilteredLibrary[i]) { - M.selectedLibraryIds.add(currentFilteredLibrary[i].track.id); - } - } - } else { - const item = currentFilteredLibrary[filteredIndex]; - if (!item) return; - const trackId = item.track.id; - if (M.selectedLibraryIds.has(trackId)) { - M.selectedLibraryIds.delete(trackId); - } else { - M.selectedLibraryIds.add(trackId); - } - lastSelectedLibraryIndex = filteredIndex; - } - M.renderLibrary(); - }; - - M.clearSelections = function() { - M.selectedQueueIndices.clear(); - M.selectedLibraryIds.clear(); - lastSelectedQueueIndex = null; - lastSelectedLibraryIndex = null; - M.renderQueue(); - M.renderLibrary(); - }; - // Update cache status for all tracks M.updateCacheStatus = async function() { const cached = await TrackStorage.list(); - // Migration: remove old filename-based cache entries (keep only sha256: prefixed) + // Migration: remove old filename-based cache entries const oldEntries = cached.filter(id => !id.startsWith("sha256:")); if (oldEntries.length > 0) { - console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries"); + console.log("[Cache] Migrating: removing", oldEntries.length, "old entries"); for (const oldId of oldEntries) { await TrackStorage.remove(oldId); } - // Re-fetch after cleanup const updated = await TrackStorage.list(); M.cachedTracks = new Set(updated); } else { M.cachedTracks = new Set(cached); } - console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached"); + console.log("[Cache] Updated:", M.cachedTracks.size, "tracks cached"); }; - - // Debug: log cache status for current track + + // Debug functions M.debugCacheStatus = function() { if (!M.currentTrackId) { console.log("[Cache Debug] No current track"); return; } const trackCache = M.getTrackCache(M.currentTrackId); - const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100); - const inCachedTracks = M.cachedTracks.has(M.currentTrackId); - const hasBlobUrl = M.trackBlobs.has(M.currentTrackId); - const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId); - console.log("[Cache Debug]", { trackId: M.currentTrackId.slice(0, 16) + "...", - segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`, - inCachedTracks, - hasBlobUrl, - bulkStarted, - loadingSegments: [...M.loadingSegments], - cachedTracksSize: M.cachedTracks.size + segments: `${trackCache.size}/${M.SEGMENTS}`, + inCachedTracks: M.cachedTracks.has(M.currentTrackId), + hasBlobUrl: M.trackBlobs.has(M.currentTrackId) }); }; - - // Debug: compare queue track IDs with cached track IDs + M.debugCacheMismatch = function() { console.log("[Cache Mismatch Debug]"); - console.log("=== Raw State ==="); console.log("M.cachedTracks:", M.cachedTracks); - console.log("M.trackCaches:", M.trackCaches); - console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]); - console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted); - console.log("=== Queue Tracks ==="); + console.log("M.queue tracks:"); M.queue.forEach((t, i) => { const id = t.id || t.filename; - console.log(` [${i}] ${t.title?.slice(0, 30)} | id: ${id?.slice(0, 12)}... | cached: ${M.cachedTracks.has(id)}`); - }); - console.log("=== Cached Track IDs ==="); - [...M.cachedTracks].forEach(id => { - console.log(` ${id.slice(0, 20)}...`); + console.log(` [${i}] ${t.title?.slice(0, 30)} | cached: ${M.cachedTracks.has(id)}`); }); }; - - // Debug: detailed info for a specific track - M.debugTrack = function(index) { - const track = M.queue[index]; - if (!track) { - console.log("[Debug] No track at index", index); - return; - } - const id = track.id || track.filename; - console.log("[Debug Track]", { - index, - title: track.title, - id, - idPrefix: id?.slice(0, 16), - inCachedTracks: M.cachedTracks.has(id), - inTrackCaches: M.trackCaches.has(id), - segmentCount: M.trackCaches.get(id)?.size || 0, - inTrackBlobs: M.trackBlobs.has(id), - bulkStarted: M.bulkDownloadStarted.get(id) - }); - }; - - // Clear all caches (for debugging) + M.clearAllCaches = async function() { await TrackStorage.clear(); M.cachedTracks.clear(); @@ -469,344 +186,61 @@ M.bulkDownloadStarted.clear(); M.renderQueue(); M.renderLibrary(); - console.log("[Cache] All caches cleared. Refresh the page."); + console.log("[Cache] Cleared. Refresh the page."); }; - - // Render the current queue - M.renderQueue = function() { - const container = M.$("#queue"); - if (!container) return; - container.innerHTML = ""; + + // Initialize containers + function initContainers() { + const queueEl = M.$("#queue"); + const libraryEl = M.$("#library"); - const canEdit = M.canControl(); - - // Setup container-level drag handlers for dropping from library - if (canEdit) { - container.ondragover = (e) => { - if (dragSource === 'library') { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - // If no tracks or hovering at bottom, show we can drop - if (M.queue.length === 0) { - container.classList.add("drop-target"); - } - } - }; - - container.ondragleave = (e) => { - // Only remove if leaving the container entirely - if (!container.contains(e.relatedTarget)) { - container.classList.remove("drop-target"); - } - }; - - container.ondrop = (e) => { - container.classList.remove("drop-target"); - // Handle drop on empty queue or at the end - if (dragSource === 'library' && draggedLibraryIds.length > 0) { - e.preventDefault(); - const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length; - insertTracksAtPosition(draggedLibraryIds, targetIndex); - draggedLibraryIds = []; - dragSource = null; - dropTargetIndex = null; - } - }; - } - - if (M.queue.length === 0) { - container.innerHTML = '
Queue empty - drag tracks here
'; - M.updateNowPlayingBar(); - return; - } - - // Debug: log first few track cache statuses - if (M.queue.length > 0 && M.cachedTracks.size > 0) { - const sample = M.queue.slice(0, 3).map(t => { - const id = t.id || t.filename; - return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) }; + if (queueEl && !queueContainer) { + queueContainer = M.trackContainer.createContainer({ + type: 'queue', + element: queueEl, + getTracks: () => M.queue, + canReorder: true, + onRender: () => M.updateNowPlayingBar() }); - console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12))); } - M.queue.forEach((track, i) => { - const div = document.createElement("div"); - const trackId = track.id || track.filename; - const isCached = M.cachedTracks.has(trackId); - const isSelected = M.selectedQueueIndices.has(i); - div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); - div.dataset.index = i; - const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); - div.title = title; // Tooltip for full name - - const checkmark = isSelected ? `` : ''; - const trackNum = `${i + 1}.`; - div.innerHTML = `${checkmark}${trackNum}${title}${M.fmt(track.duration)}`; - - // Drag and drop for reordering (if user can edit) - if (canEdit) { - div.draggable = true; - - div.ondragstart = (e) => { - dragSource = 'queue'; - draggedLibraryIds = []; - // If dragging a selected item, drag all selected; otherwise just this one - if (M.selectedQueueIndices.has(i)) { - draggedIndices = [...M.selectedQueueIndices]; - } else { - draggedIndices = [i]; + if (libraryEl && !libraryContainer) { + libraryContainer = M.trackContainer.createContainer({ + type: 'library', + element: libraryEl, + getTracks: () => M.library, + getFilteredTracks: () => { + const query = M.librarySearchQuery.toLowerCase(); + if (!query) { + return M.library.map((track, i) => ({ track, originalIndex: i })); } - div.classList.add("dragging"); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", "queue:" + draggedIndices.join(",")); - }; - - div.ondragend = () => { - div.classList.remove("dragging"); - draggedIndices = []; - draggedLibraryIds = []; - dragSource = null; - // Clear all drop indicators - container.querySelectorAll(".drop-above, .drop-below").forEach(el => { - el.classList.remove("drop-above", "drop-below"); - }); - }; - - div.ondragover = (e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - // Determine if dropping above or below - const rect = div.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - const isAbove = e.clientY < midY; - - // Clear other indicators - container.querySelectorAll(".drop-above, .drop-below").forEach(el => { - el.classList.remove("drop-above", "drop-below"); - }); - - // Don't show indicator on dragged queue items (for reorder) - if (dragSource === 'queue' && draggedIndices.includes(i)) return; - - div.classList.add(isAbove ? "drop-above" : "drop-below"); - dropTargetIndex = isAbove ? i : i + 1; - }; - - div.ondragleave = () => { - div.classList.remove("drop-above", "drop-below"); - }; - - div.ondrop = (e) => { - e.preventDefault(); - div.classList.remove("drop-above", "drop-below"); - - if (dragSource === 'library' && draggedLibraryIds.length > 0 && dropTargetIndex !== null) { - // Insert library tracks at drop position - insertTracksAtPosition(draggedLibraryIds, dropTargetIndex); - } else if (dragSource === 'queue' && draggedIndices.length > 0 && dropTargetIndex !== null) { - // Reorder queue - const minDragged = Math.min(...draggedIndices); - const maxDragged = Math.max(...draggedIndices); - if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { - reorderQueue(draggedIndices, dropTargetIndex); - } - } - - draggedIndices = []; - draggedLibraryIds = []; - dragSource = null; - dropTargetIndex = null; - }; - } - - // Click toggles selection - div.onclick = (e) => { - if (e.target.closest('.track-actions')) return; - M.toggleQueueSelection(i, e.shiftKey); - }; - - // Right-click context menu - div.oncontextmenu = (e) => { - const menuItems = []; - const hasSelection = M.selectedQueueIndices.size > 0; - const selectedCount = hasSelection ? M.selectedQueueIndices.size : 1; - const indicesToRemove = hasSelection ? [...M.selectedQueueIndices] : [i]; - - // Play track option (only for single track, not bulk) - if (!hasSelection) { - menuItems.push({ - label: "▶ Play track", - action: async () => { - if (M.synced && M.currentChannelId) { - const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ index: i }) - }); - if (res.status === 403) M.flashPermissionDenied(); - } else { - M.currentIndex = i; - M.currentTrackId = trackId; - M.serverTrackDuration = track.duration; - M.setTrackTitle(title); - M.loadingSegments.clear(); - const cachedUrl = await M.loadTrackBlob(trackId); - M.audio.src = cachedUrl || M.getTrackUrl(trackId); - M.audio.currentTime = 0; - M.localTimestamp = 0; - M.audio.play(); - M.renderQueue(); - } - } - }); - } - - // Remove track(s) option (if user can edit) - if (canEdit) { - // Get track IDs for the selected indices - const idsToAdd = hasSelection - ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) - : [trackId]; - - // Add again option - duplicate tracks at end of queue - const addAgainLabel = selectedCount > 1 ? `+ Add ${selectedCount} again` : "+ Add again"; - menuItems.push({ - label: addAgainLabel, - action: async () => { - if (!M.currentChannelId) return; - const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ add: idsToAdd }) - }); - if (res.status === 403) M.flashPermissionDenied(); - else if (res.ok) { - M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks again` : "Track added again"); - M.clearSelections(); - } - } - }); - - // Play next option - insert after current track - const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next"; - menuItems.push({ - label: playNextLabel, - action: async () => { - if (!M.currentChannelId) return; - const insertAt = (M.currentIndex ?? 0) + 1; - const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ add: idsToAdd, insertAt }) - }); - if (res.status === 403) M.flashPermissionDenied(); - else if (res.ok) { - M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next"); - M.clearSelections(); - } - } - }); - - const label = selectedCount > 1 ? `✕ Remove ${selectedCount} tracks` : "✕ Remove track"; - menuItems.push({ - label, - danger: true, - action: async () => { - if (!M.currentChannelId) return; - const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ remove: indicesToRemove }) - }); - if (res.status === 403) M.flashPermissionDenied(); - else if (res.ok) { - M.showToast(selectedCount > 1 ? `Removed ${selectedCount} tracks` : "Track removed"); - M.clearSelections(); - } - } - }); - } - - // Preload track(s) option - only show if not in stream-only mode - if (!M.streamOnly) { - const idsToPreload = hasSelection - ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) - : [trackId]; - const preloadLabel = hasSelection && idsToPreload.length > 1 ? `Preload ${idsToPreload.length} tracks` : "Preload track"; - menuItems.push({ - label: preloadLabel, - action: () => { - const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id)); - if (uncachedIds.length === 0) { - M.showToast("All tracks already cached"); - return; - } - M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); - uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); - } - }); - } - - // Download track option (single track only) - if (!hasSelection) { - menuItems.push({ - label: "Download", - action: () => downloadTrack(trackId, track.filename) - }); - - // Copy link option - menuItems.push({ - label: "🔗 Generate listening link", - action: () => { - const url = `${location.origin}/listen/${encodeURIComponent(trackId)}`; - navigator.clipboard.writeText(url).then(() => { - M.showToast("Link copied to clipboard"); - }).catch(() => { - M.showToast("Failed to copy link", "error"); - }); - } - }); - } - - // 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 + return M.library + .map((track, i) => ({ track, originalIndex: i })) + .filter(({ track }) => { + const title = track.title?.trim() || track.filename || ''; + return title.toLowerCase().includes(query); }); - } 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({ - label: "Clear selection", - action: () => M.clearSelections() - }); - } - - showContextMenu(e, menuItems); - }; - - container.appendChild(div); - }); - - M.updateNowPlayingBar(); + }); + } + } + + // Render functions + M.renderQueue = function() { + initContainers(); + if (queueContainer) { + queueContainer.render(); + } }; - - // Update the now-playing bar above the queue + + M.renderLibrary = function() { + initContainers(); + if (libraryContainer) { + libraryContainer.render(); + } + }; + + // Now-playing bar M.updateNowPlayingBar = function() { const bar = M.$("#now-playing-bar"); if (!bar) return; @@ -822,8 +256,7 @@ bar.title = title; bar.classList.remove("hidden"); }; - - // Scroll queue to current track + M.scrollToCurrentTrack = function() { const container = M.$("#queue"); if (!container) return; @@ -833,246 +266,14 @@ activeTrack.scrollIntoView({ behavior: "smooth", block: "center" }); } }; - - // Setup now-playing bar click handler - document.addEventListener("DOMContentLoaded", () => { - const bar = M.$("#now-playing-bar"); - if (bar) { - bar.onclick = () => M.scrollToCurrentTrack(); - } - }); - - // Library search state - M.librarySearchQuery = ""; - - // Render the library - M.renderLibrary = function() { - const container = M.$("#library"); - if (!container) return; - container.innerHTML = ""; - if (M.library.length === 0) { - container.innerHTML = '
No tracks discovered
'; - return; - } - - const canEdit = M.canControl(); - const query = M.librarySearchQuery.toLowerCase(); - - // Filter library by search query - currentFilteredLibrary = query - ? M.library.map((track, i) => ({ track, i })).filter(({ track }) => { - const title = track.title?.trim() || track.filename; - return title.toLowerCase().includes(query); - }) - : M.library.map((track, i) => ({ track, i })); - - if (currentFilteredLibrary.length === 0) { - container.innerHTML = '
No matches
'; - return; - } - - currentFilteredLibrary.forEach(({ track, i }, filteredIndex) => { - const div = document.createElement("div"); - const isCached = M.cachedTracks.has(track.id); - const isSelected = M.selectedLibraryIds.has(track.id); - div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); - const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); - div.title = title; // Tooltip for full name - - const checkmark = isSelected ? `` : ''; - div.innerHTML = `${checkmark}${title}${M.fmt(track.duration)}`; - - // Drag from library to queue (if user can edit) - if (canEdit) { - div.draggable = true; - - div.ondragstart = (e) => { - dragSource = 'library'; - draggedIndices = []; - // If dragging a selected item, drag all selected; otherwise just this one - if (M.selectedLibraryIds.has(track.id)) { - draggedLibraryIds = [...M.selectedLibraryIds]; - } else { - draggedLibraryIds = [track.id]; - } - div.classList.add("dragging"); - e.dataTransfer.effectAllowed = "copy"; - e.dataTransfer.setData("text/plain", "library:" + draggedLibraryIds.join(",")); - }; - - div.ondragend = () => { - div.classList.remove("dragging"); - draggedIndices = []; - draggedLibraryIds = []; - dragSource = null; - // Clear drop indicators in queue - const queueContainer = M.$("#queue"); - if (queueContainer) { - queueContainer.querySelectorAll(".drop-above, .drop-below").forEach(el => { - el.classList.remove("drop-above", "drop-below"); - }); - } - }; - } - - // Click toggles selection - div.onclick = (e) => { - if (e.target.closest('.track-actions')) return; - M.toggleLibrarySelection(filteredIndex, e.shiftKey); - }; - - // Right-click context menu - div.oncontextmenu = (e) => { - const menuItems = []; - const hasSelection = M.selectedLibraryIds.size > 0; - const selectedCount = hasSelection ? M.selectedLibraryIds.size : 1; - const idsToAdd = hasSelection ? [...M.selectedLibraryIds] : [track.id]; - - // Play track option (local mode only, single track) - if (!M.synced && !hasSelection) { - menuItems.push({ - label: "▶ Play track", - action: async () => { - M.currentTrackId = track.id; - M.serverTrackDuration = track.duration; - M.setTrackTitle(title); - M.loadingSegments.clear(); - const cachedUrl = await M.loadTrackBlob(track.id); - M.audio.src = cachedUrl || M.getTrackUrl(track.id); - M.audio.currentTime = 0; - M.localTimestamp = 0; - M.audio.play(); - } - }); - } - - // Add to queue option (if user can edit) - if (canEdit) { - const label = selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue"; - menuItems.push({ - label, - action: async () => { - if (!M.currentChannelId) { - M.showToast("No channel selected"); - return; - } - const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ add: idsToAdd }) - }); - if (res.status === 403) M.flashPermissionDenied(); - else if (res.ok) { - M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks` : "Track added to queue"); - M.clearSelections(); - } - } - }); - - // Play next option - insert after current track - const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next"; - menuItems.push({ - label: playNextLabel, - action: async () => { - if (!M.currentChannelId) { - M.showToast("No channel selected"); - return; - } - const insertAt = (M.currentIndex ?? 0) + 1; - const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ add: idsToAdd, insertAt }) - }); - if (res.status === 403) M.flashPermissionDenied(); - else if (res.ok) { - M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next"); - M.clearSelections(); - } - } - }); - } - - // Preload track(s) option - only show if not in stream-only mode - if (!M.streamOnly) { - const preloadLabel = selectedCount > 1 ? `Preload ${selectedCount} tracks` : "Preload track"; - menuItems.push({ - label: preloadLabel, - action: () => { - const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id)); - if (uncachedIds.length === 0) { - M.showToast("All tracks already cached"); - return; - } - M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); - uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); - } - }); - } - - // Download track option (single track only) - if (!hasSelection) { - menuItems.push({ - label: "Download", - action: () => downloadTrack(track.id, track.filename) - }); - - // Copy link option - menuItems.push({ - label: "🔗 Generate listening link", - action: () => { - const url = `${location.origin}/listen/${encodeURIComponent(track.id)}`; - navigator.clipboard.writeText(url).then(() => { - M.showToast("Link copied to clipboard"); - }).catch(() => { - M.showToast("Failed to copy link", "error"); - }); - } - }); - } - - // 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({ - label: `Preload and export ${M.cachedTracks.size} cached`, - action: () => M.exportAllCached() - }); - } - - // Clear selection option (if items selected) - if (hasSelection) { - menuItems.push({ - label: "Clear selection", - action: () => M.clearSelections() - }); - } - - if (menuItems.length > 0) { - showContextMenu(e, menuItems); - } - }; - - container.appendChild(div); - }); + + // Backwards compatibility + M.clearSelections = function() { + M.clearAllSelections(); + M.renderQueue(); + M.renderLibrary(); }; - + // Load library from server M.loadLibrary = async function() { try { @@ -1083,9 +284,14 @@ console.warn("Failed to load library"); } }; - - // Setup library search + + // Setup event listeners document.addEventListener("DOMContentLoaded", () => { + const bar = M.$("#now-playing-bar"); + if (bar) { + bar.onclick = () => M.scrollToCurrentTrack(); + } + const searchInput = M.$("#library-search"); if (searchInput) { searchInput.addEventListener("input", (e) => { diff --git a/public/styles.css b/public/styles.css index 3b67e38..c3b9a2e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -125,9 +125,9 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } .search-input::placeholder { color: #666; } #library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; } -#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; } -#library .track[title], #queue .track[title] { cursor: pointer; } -#library .track:hover, #queue .track:hover { background: #222; } +#library .track, #queue .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; } +#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; } +#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; } #queue .track.active { background: #2a4a3a; color: #4e8; } .cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; } .track.cached .cache-indicator { background: #4e8; } @@ -136,6 +136,10 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .track-actions .duration { color: #666; font-size: 0.75rem; } +.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; } +.track-actions .track-play-btn:hover { background: #48f; color: #fff; } +.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; } +.track-actions .track-preview-btn:hover { background: #4a4; color: #fff; } .track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; } .track:hover .track-add, .track:hover .track-remove { opacity: 0.6; } .track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; } @@ -146,9 +150,13 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; } .track.selected { background: #2a3a4a; } .track.dragging { opacity: 0.5; } +/* Allow drop events to pass through to parent track element */ +.track > * { pointer-events: none; } +.track > .track-actions { pointer-events: auto; } +.track > .track-actions > * { pointer-events: auto; } .track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } .track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } -#queue.drop-target, #queue .drop-zone { border: 2px dashed #4e8; border-radius: 4px; } +#queue.drop-target, #queue .drop-zone, #playlist-tracks.drop-target { border: 2px dashed #4e8; border-radius: 4px; } #queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; } /* Context menu */ @@ -292,7 +300,8 @@ button:hover { background: #333; } .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-submenu { position: absolute; display: none; top: 0; left: 100%; margin-left: 2px; background: #222; border: 1px solid #444; border-radius: 4px; min-width: 120px; z-index: 1001; } +.context-menu-item.has-submenu:hover > .context-submenu { display: block; } .context-menu-item.disabled { color: #666; cursor: default; } .context-menu-item.disabled:hover { background: transparent; } diff --git a/public/trackComponent.js b/public/trackComponent.js new file mode 100644 index 0000000..f9db290 --- /dev/null +++ b/public/trackComponent.js @@ -0,0 +1,91 @@ +// MusicRoom - Track Component +// Pure rendering for track rows - no event handlers attached + +(function() { + const M = window.MusicRoom; + + /** + * Render a track row element (pure rendering, no handlers) + * @param {Object} track - Track object with id, title, filename, duration + * @param {Object} config - Configuration options + * @param {string} config.view - 'queue' | 'library' | 'playlist' + * @param {number} config.index - Index in the list + * @param {number} [config.displayIndex] - Display number (1-based) + * @param {boolean} config.isSelected - Whether track is selected + * @param {boolean} config.isCached - Whether track is cached locally + * @param {boolean} config.isActive - Whether this is the currently playing track + * @param {boolean} config.showPlayButton - Show play button (queue only) + * @param {boolean} config.draggable - Whether element is draggable + * @returns {HTMLElement} + */ + function render(track, config) { + const { + view, + index, + displayIndex, + isSelected, + isCached, + isActive, + showPlayButton, + draggable + } = config; + + const div = document.createElement("div"); + const trackId = track.id || track.filename; + + // Build class list + const classes = ["track"]; + if (isActive) classes.push("active"); + if (isCached) classes.push("cached"); + else classes.push("not-cached"); + if (isSelected) classes.push("selected"); + div.className = classes.join(" "); + + // Store data attributes + div.dataset.index = index; + div.dataset.trackId = trackId; + div.dataset.view = view; + + // Build title + const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); + div.title = title; + + // Build HTML + const checkmark = isSelected ? '' : ''; + const trackNum = displayIndex != null ? `${displayIndex}.` : ''; + const playBtn = showPlayButton ? '' : ''; + const previewBtn = ''; + + div.innerHTML = ` + ${checkmark} + + ${trackNum} + ${escapeHtml(title)} + + ${playBtn} + ${previewBtn} + ${M.fmt(track.duration)} + + `; + + if (draggable) { + div.draggable = true; + } + + return div; + } + + // HTML escape helper + function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); + } + + // Export + M.trackComponent = { + render, + escapeHtml + }; +})(); diff --git a/public/trackContainer.js b/public/trackContainer.js new file mode 100644 index 0000000..8d80078 --- /dev/null +++ b/public/trackContainer.js @@ -0,0 +1,922 @@ +// MusicRoom - Track Container +// Manages track lists with selection, drag-and-drop, and context menus + +(function() { + const M = window.MusicRoom; + + // Global debug: see if ANY drop event fires + document.addEventListener('drop', (e) => { + console.log("[GLOBAL drop] target:", e.target.tagName, e.target.className, "id:", e.target.id); + }, true); + + document.addEventListener('dragend', (e) => { + console.log("[GLOBAL dragend] target:", e.target.tagName, e.target.className); + }, true); + + // Track if a drag is in progress (to prevent re-renders from canceling it) + let isDragging = false; + + // Selection state per container type + const selection = { + queue: new Set(), // indices + library: new Set(), // track IDs + playlist: new Set() // indices (for duplicate support) + }; + + // Last selected for shift-select + const lastSelected = { + queue: null, + library: null, + playlist: null + }; + + // Drag state (shared across containers) + let dragSource = null; + let draggedIndices = []; + let draggedTrackIds = []; + let dropTargetIndex = null; + + // Active context menu + let activeContextMenu = null; + + /** + * Create a track container manager + * @param {Object} config + * @param {string} config.type - 'queue' | 'library' | 'playlist' + * @param {HTMLElement} config.element - Container DOM element + * @param {Function} config.getTracks - Returns array of tracks to render + * @param {Function} [config.getFilteredTracks] - Returns filtered tracks (for library search) + * @param {boolean} [config.canEdit] - Whether user can modify queue + * @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only) + * @param {boolean} [config.canRemove] - Whether tracks can be removed from playlist + * @param {string} [config.playlistId] - Playlist ID (for playlist type) + * @param {Function} [config.onRender] - Callback after render + */ + function createContainer(config) { + const { + type, + element, + getTracks, + getFilteredTracks, + canReorder = false, + canRemove = false, + playlistId = null, + onRender + } = config; + + console.log("[createContainer] type:", type, "canRemove:", canRemove, "playlistId:", playlistId); + + let currentTracks = []; + + // Get canEdit dynamically (permissions may change) + const getCanEdit = () => config.canEdit ?? M.canControl(); + + // Track if this container needs a render after drag ends + let pendingRender = false; + + function render() { + // Defer render if a drag is in progress (would cancel the drag) + if (isDragging) { + console.log("[render] DEFERRED - drag in progress, type:", type); + pendingRender = true; + return; + } + pendingRender = false; + console.log("[render] type:", type, "canRemove:", canRemove, "playlistId:", playlistId); + const canEdit = getCanEdit(); + element.innerHTML = ""; + + // Get tracks (filtered for library, direct for queue/playlist) + currentTracks = getFilteredTracks ? getFilteredTracks() : getTracks(); + + // Always wire up container drop handlers first (even for empty containers) + if (canEdit && type === 'queue') { + wireQueueContainerDrop(element); + } + if (canRemove && type === 'playlist' && playlistId) { + console.log("[Playlist] Wiring container drop on element:", element.id || element.className); + wirePlaylistContainerDrop(element); + } + + if (currentTracks.length === 0) { + const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here' + : type === 'library' ? 'No tracks' + : 'No tracks - drag here to add'; + element.innerHTML = `
${emptyMsg}
`; + if (onRender) onRender(); + return; + } + + currentTracks.forEach((item, filteredIndex) => { + // item can be { track, originalIndex } or just track + const track = item.track || item; + const index = type === 'queue' ? filteredIndex : (item.originalIndex ?? filteredIndex); + const trackId = track.id || track.filename; + + // Queue and playlist use indices, library uses trackIds + const isSelected = type === 'library' + ? selection.library.has(trackId) + : selection[type].has(index); + + const isCached = M.cachedTracks.has(trackId); + const isActive = type === 'queue' && index === M.currentIndex; + + // Library tracks are always draggable, queue tracks need edit permission + const isDraggable = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove); + + const div = M.trackComponent.render(track, { + view: type, + index: filteredIndex, + displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null, + isSelected, + isCached, + isActive, + showPlayButton: type === 'queue', + draggable: isDraggable + }); + + // Wire up event handlers + wireTrackEvents(div, track, filteredIndex, index, canEdit); + + element.appendChild(div); + }); + + if (onRender) onRender(); + } + + function wirePlaylistContainerDrop(container) { + container.ondragover = (e) => { + if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') { + e.preventDefault(); + e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; + container.classList.add("drop-target"); + } + }; + + container.ondragleave = (e) => { + if (!container.contains(e.relatedTarget)) { + container.classList.remove("drop-target"); + } + }; + + container.ondrop = (e) => { + console.log("[Container ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex); + container.classList.remove("drop-target"); + // Clear any drop indicators on tracks + container.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + + if (draggedTrackIds.length > 0) { + e.preventDefault(); + + // If dropTargetIndex was set by a track, use that position + // Otherwise append to end + const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length; + + if (dragSource === 'playlist') { + reorderPlaylist(draggedTrackIds, targetPos); + } else if (dragSource === 'queue' || dragSource === 'library') { + if (currentTracks.length === 0) { + addTracksToPlaylist(draggedTrackIds); + } else { + addTracksToPlaylistAt(draggedTrackIds, targetPos); + } + } + + draggedTrackIds = []; + draggedIndices = []; + dragSource = null; + dropTargetIndex = null; + } + }; + } + + async function addTracksToPlaylist(trackIds) { + if (!playlistId) return; + + const res = await fetch(`/api/playlists/${playlistId}/tracks`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ add: trackIds }) + }); + + if (res.ok) { + M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`); + if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } + } + + async function addTracksToPlaylistAt(trackIds, position) { + if (!playlistId) { + console.error("[addTracksToPlaylistAt] No playlistId"); + return; + } + + // Get current tracks and insert at position + const current = currentTracks.map(t => (t.track || t).id); + console.log("[addTracksToPlaylistAt] current:", current, "inserting:", trackIds, "at:", position); + const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)]; + console.log("[addTracksToPlaylistAt] newList:", newList); + + const res = await fetch(`/api/playlists/${playlistId}/tracks`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set: newList }) + }); + + if (res.ok) { + M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); + if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } else { + console.error("[addTracksToPlaylistAt] Failed:", await res.text()); + } + } + + async function reorderPlaylist(trackIds, targetIndex) { + if (!playlistId) return; + + // Get current track IDs + const current = currentTracks.map(t => (t.track || t).id); + + // Remove the dragged tracks from their current positions + const remaining = current.filter(id => !trackIds.includes(id)); + + // Calculate insertion position (adjusted for removed items before target) + let insertAt = targetIndex; + for (const id of trackIds) { + const originalPos = current.indexOf(id); + if (originalPos < targetIndex) { + insertAt--; + } + } + insertAt = Math.max(0, Math.min(insertAt, remaining.length)); + + // Insert at new position + const newList = [...remaining.slice(0, insertAt), ...trackIds, ...remaining.slice(insertAt)]; + + const res = await fetch(`/api/playlists/${playlistId}/tracks`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set: newList }) + }); + + if (res.ok) { + selection.playlist.clear(); + lastSelected.playlist = null; + if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } + } + + function wireTrackEvents(div, track, filteredIndex, originalIndex, canEdit) { + const trackId = track.id || track.filename; + const index = type === 'queue' ? originalIndex : filteredIndex; + + // Click - toggle selection + div.onclick = (e) => { + if (e.target.closest('.track-actions')) return; + toggleSelection(index, trackId, e.shiftKey); + render(); + }; + + // Play button (queue only) + const playBtn = div.querySelector('.track-play-btn'); + if (playBtn) { + playBtn.onclick = (e) => { + e.stopPropagation(); + playTrack(track, originalIndex); + }; + } + + // Preview button + const previewBtn = div.querySelector('.track-preview-btn'); + if (previewBtn) { + previewBtn.onclick = (e) => { + e.stopPropagation(); + previewTrack(track); + }; + } + + // Context menu + div.oncontextmenu = (e) => { + e.preventDefault(); + showContextMenu(e, track, originalIndex, canEdit); + }; + + // Drag start/end handlers - library always, queue/playlist with permissions + const canDrag = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove); + if (canDrag) { + div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); + div.ondragend = (e) => handleDragEnd(e, div); + } + + // Drop handlers - only queue accepts drops (from library/playlist) + if (canEdit && type === 'queue') { + div.ondragover = (e) => handleDragOver(e, div, originalIndex); + div.ondragleave = (e) => handleDragLeave(e, div); + div.ondrop = (e) => handleDrop(e, div, originalIndex); + } + + // Debug: log playlist track wiring conditions + if (type === 'playlist') { + console.log("[Playlist wireTrackEvents] type:", type, "playlistId:", playlistId, "canRemove:", canRemove); + } + + // For playlist tracks, allow reordering and insertion (separate from canEdit) + if (type === 'playlist' && playlistId && canRemove) { + console.log("[Playlist] Wiring drag handlers for track:", trackId); + + div.ondragover = (e) => { + console.log("[Playlist track ondragover] dragSource:", dragSource); + e.preventDefault(); + e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; + + const rect = div.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const isAbove = e.clientY < midY; + + // Clear other indicators + element.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + + // Don't show indicator on dragged items (for reorder) + if (dragSource === 'playlist' && draggedTrackIds.includes(trackId)) return; + + div.classList.add(isAbove ? "drop-above" : "drop-below"); + dropTargetIndex = isAbove ? filteredIndex : filteredIndex + 1; + }; + + div.ondragleave = () => { + div.classList.remove("drop-above", "drop-below"); + }; + + div.ondrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + div.classList.remove("drop-above", "drop-below"); + element.classList.remove("drop-target"); + + console.log("[Track ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex); + + if (draggedTrackIds.length > 0 && dropTargetIndex !== null) { + if (dragSource === 'playlist') { + // Reorder within playlist + reorderPlaylist(draggedTrackIds, dropTargetIndex); + } else if (dragSource === 'queue' || dragSource === 'library') { + // Insert at position + addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex); + } + } + + draggedTrackIds = []; + draggedIndices = []; + dragSource = null; + dropTargetIndex = null; + }; + } + } + + function toggleSelection(index, trackId, shiftKey) { + // Queue and playlist use indices, library uses trackIds + const key = type === 'library' ? trackId : index; + const sel = selection[type]; + + if (shiftKey && lastSelected[type] !== null) { + // Range select + const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index; + const start = Math.min(lastSelected[type], currentIdx); + const end = Math.max(lastSelected[type], currentIdx); + + for (let i = start; i <= end; i++) { + if (type === 'library') { + const t = currentTracks[i]; + if (t) { + const id = (t.track || t).id; + if (id) sel.add(id); + } + } else { + // Queue and playlist use indices + sel.add(i); + } + } + } else { + if (sel.has(key)) { + sel.delete(key); + } else { + sel.add(key); + } + lastSelected[type] = type === 'library' ? getFilteredIndex(trackId) : index; + } + } + + function getFilteredIndex(trackId) { + return currentTracks.findIndex(t => ((t.track || t).id) === trackId); + } + + function handleDragStart(e, track, index, div) { + isDragging = true; + const trackId = track.id || track.filename; + dragSource = type; + + if (type === 'queue') { + draggedIndices = selection.queue.has(index) ? [...selection.queue] : [index]; + draggedTrackIds = draggedIndices.map(i => M.queue[i]?.id).filter(Boolean); + } else { + draggedTrackIds = selection[type].has(trackId) ? [...selection[type]] : [trackId]; + draggedIndices = []; + } + + div.classList.add("dragging"); + // Use "copyMove" to allow both copy and move operations + e.dataTransfer.effectAllowed = "copyMove"; + e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`); + } + + function handleDragEnd(e, div) { + isDragging = false; + div.classList.remove("dragging"); + draggedIndices = []; + draggedTrackIds = []; + dragSource = null; + dropTargetIndex = null; + + // Clear all drop indicators + element.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + + // Execute deferred render if any + if (pendingRender) { + console.log("[handleDragEnd] Executing deferred render for:", type); + setTimeout(() => render(), 50); + } + } + + function handleDragOver(e, div, index) { + e.preventDefault(); + e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy"; + + const rect = div.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const isAbove = e.clientY < midY; + + // Clear other indicators + element.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + + // Don't show indicator on dragged items + if (dragSource === 'queue' && draggedIndices.includes(index)) return; + + div.classList.add(isAbove ? "drop-above" : "drop-below"); + dropTargetIndex = isAbove ? index : index + 1; + } + + function handleDragLeave(e, div) { + div.classList.remove("drop-above", "drop-below"); + } + + function handleDrop(e, div, index) { + console.log("[handleDrop] type:", type, "dragSource:", dragSource, "dropTargetIndex:", dropTargetIndex, "draggedIndices:", draggedIndices); + e.preventDefault(); + div.classList.remove("drop-above", "drop-below"); + + if (dropTargetIndex === null) return; + + if (dragSource === 'queue' && draggedIndices.length > 0) { + // Reorder within queue + const minDragged = Math.min(...draggedIndices); + const maxDragged = Math.max(...draggedIndices); + if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { + reorderQueue(draggedIndices, dropTargetIndex); + } + } else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { + // Insert tracks from library or playlist + insertTracksAtPosition(draggedTrackIds, dropTargetIndex); + } + + draggedIndices = []; + draggedTrackIds = []; + dragSource = null; + dropTargetIndex = null; + } + + function wireQueueContainerDrop(container) { + container.ondragover = (e) => { + if (dragSource === 'library' || dragSource === 'playlist') { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + if (M.queue.length === 0) { + container.classList.add("drop-target"); + } + } + }; + + container.ondragleave = (e) => { + if (!container.contains(e.relatedTarget)) { + container.classList.remove("drop-target"); + } + }; + + container.ondrop = (e) => { + container.classList.remove("drop-target"); + if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { + e.preventDefault(); + const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length; + insertTracksAtPosition(draggedTrackIds, targetIndex); + draggedTrackIds = []; + dragSource = null; + dropTargetIndex = null; + } + }; + } + + async function reorderQueue(indices, targetIndex) { + if (!M.currentChannelId) return; + + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ move: indices, to: targetIndex }) + }); + + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + selection.queue.clear(); + lastSelected.queue = null; + } + } + + async function insertTracksAtPosition(trackIds, position) { + if (!M.currentChannelId) return; + + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ add: trackIds, insertAt: position }) + }); + + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); + clearSelection(); + } + } + + async function playTrack(track, index) { + const trackId = track.id || track.filename; + const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); + + if (type === 'queue') { + // Jump to track in queue + if (M.synced && M.currentChannelId) { + const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index }) + }); + if (res.status === 403) M.flashPermissionDenied(); + } else { + // Local playback + M.currentIndex = index; + M.currentTrackId = trackId; + M.serverTrackDuration = track.duration; + M.setTrackTitle(title); + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + render(); + } + } + } + + async function previewTrack(track) { + const trackId = track.id || track.filename; + const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); + + M.currentTrackId = trackId; + M.serverTrackDuration = track.duration; + M.setTrackTitle(title); + M.loadingSegments.clear(); + + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + + if (M.synced) { + M.synced = false; + M.showToast("Previewing track (desynced)"); + } + } + + function showContextMenu(e, track, index, canEdit) { + console.log("[showContextMenu] type:", type, "canEdit:", canEdit, "canRemove:", canRemove, "playlistId:", playlistId); + const trackId = track.id || track.filename; + const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); + + const sel = selection[type]; + const hasSelection = sel.size > 0; + const selectedCount = hasSelection ? sel.size : 1; + + // Get IDs/indices for bulk operations + let idsForAction, indicesToRemove; + if (type === 'queue') { + indicesToRemove = hasSelection ? [...sel] : [index]; + idsForAction = indicesToRemove.map(i => M.queue[i]?.id).filter(Boolean); + } else if (type === 'playlist') { + // Playlist uses indices for selection/removal (supports duplicates) + indicesToRemove = hasSelection ? [...sel] : [index]; + idsForAction = indicesToRemove.map(i => currentTracks[i]?.track?.id || currentTracks[i]?.id).filter(Boolean); + } else { + // Library uses trackIds + idsForAction = hasSelection ? [...sel] : [trackId]; + } + + const menuItems = []; + + // Play (queue only, single track) + if (type === 'queue' && !hasSelection) { + menuItems.push({ + label: "▶ Play", + action: () => playTrack(track, index) + }); + } + + // Preview (all views, single track) + if (!hasSelection) { + menuItems.push({ + label: "⏵ Preview", + action: () => previewTrack(track) + }); + } + + // Queue actions + if (type === 'queue' && canEdit) { + menuItems.push({ + label: selectedCount > 1 ? `+ Add ${selectedCount} again` : "+ Add again", + action: () => addToQueue(idsForAction) + }); + menuItems.push({ + label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", + action: () => addToQueue(idsForAction, true) + }); + menuItems.push({ + label: selectedCount > 1 ? `✕ Remove ${selectedCount}` : "✕ Remove", + danger: true, + action: () => removeFromQueue(indicesToRemove) + }); + } + + // Library actions + if (type === 'library' && canEdit) { + menuItems.push({ + label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", + action: () => addToQueue(idsForAction) + }); + menuItems.push({ + label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", + action: () => addToQueue(idsForAction, true) + }); + } + + // Playlist actions + if (type === 'playlist') { + if (canEdit) { + menuItems.push({ + label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", + action: () => addToQueue(idsForAction) + }); + menuItems.push({ + label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", + action: () => addToQueue(idsForAction, true) + }); + } + if (canRemove && playlistId) { + menuItems.push({ + label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove", + danger: true, + action: () => removeFromPlaylist(indicesToRemove) + }); + } + } + + // Preload (library/queue, non-stream mode) + if ((type === 'library' || type === 'queue') && !M.streamOnly) { + menuItems.push({ + label: selectedCount > 1 ? `Preload ${selectedCount}` : "Preload", + action: () => { + const uncached = idsForAction.filter(id => !M.cachedTracks.has(id)); + if (uncached.length === 0) { + M.showToast("Already cached"); + return; + } + M.showToast(`Preloading ${uncached.length}...`); + uncached.forEach(id => M.downloadAndCacheTrack(id)); + } + }); + } + + // Add to Playlist + if (M.playlists && !M.currentUser?.is_guest) { + const submenu = M.playlists.showAddToPlaylistMenu(idsForAction); + if (submenu && submenu.length > 0) { + menuItems.push({ + label: idsForAction.length > 1 ? `📁 Add ${idsForAction.length} to Playlist...` : "📁 Add to Playlist...", + submenu + }); + } + } + + // Copy link (single) + if (!hasSelection) { + menuItems.push({ + label: "🔗 Copy link", + action: () => { + navigator.clipboard.writeText(`${location.origin}/listen/${encodeURIComponent(trackId)}`); + M.showToast("Link copied"); + } + }); + } + + // Clear selection + if (hasSelection) { + menuItems.push({ + label: "Clear selection", + action: () => { clearSelection(); render(); } + }); + } + + M.contextMenu.show(e, menuItems); + } + + async function addToQueue(trackIds, playNext = false) { + if (!M.currentChannelId) { + M.showToast("No channel selected"); + return; + } + const body = playNext + ? { add: trackIds, insertAt: (M.currentIndex ?? 0) + 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.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(playNext ? "Playing next" : "Added to queue"); + clearSelection(); + render(); + } + } + + async function removeFromQueue(indices) { + console.log("[removeFromQueue] indices:", indices, "channelId:", M.currentChannelId); + if (!M.currentChannelId) return; + + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ remove: indices }) + }); + + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast("Removed"); + clearSelection(); + } + } + + async function removeFromPlaylist(indices) { + console.log("[removeFromPlaylist] indices:", indices, "playlistId:", playlistId); + if (!playlistId) return; + + const res = await fetch(`/api/playlists/${playlistId}/tracks`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ remove: indices }) + }); + + if (res.ok) { + M.showToast("Removed from playlist"); + clearSelection(); + if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } + } + + function clearSelection() { + selection[type].clear(); + lastSelected[type] = null; + } + + function getSelection() { + return [...selection[type]]; + } + + return { + render, + clearSelection, + getSelection, + get currentTracks() { return currentTracks; } + }; + } + + // Context menu rendering (shared) + function showContextMenuUI(e, items) { + e.preventDefault(); + hideContextMenu(); + + const menu = document.createElement("div"); + 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" : "") + (item.disabled ? " disabled" : ""); + el.textContent = item.label; + + if (item.submenu) { + el.classList.add("has-submenu"); + el.innerHTML += ' ▸'; + + const sub = document.createElement("div"); + sub.className = "context-submenu"; + item.submenu.forEach(subItem => { + const subEl = document.createElement("div"); + subEl.className = "context-menu-item"; + subEl.textContent = subItem.label; + subEl.onclick = (e) => { + e.stopPropagation(); + hideContextMenu(); + subItem.action(); + }; + sub.appendChild(subEl); + }); + el.appendChild(sub); + } else if (!item.disabled) { + el.onclick = () => { + hideContextMenu(); + item.action(); + }; + } + + menu.appendChild(el); + }); + + menu.style.left = e.clientX + "px"; + menu.style.top = e.clientY + "px"; + document.body.appendChild(menu); + + // Adjust if off-screen + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = (window.innerWidth - rect.width - 5) + "px"; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = (window.innerHeight - rect.height - 5) + "px"; + } + + activeContextMenu = menu; + + // Close on click outside + setTimeout(() => { + document.addEventListener("click", hideContextMenu, { once: true }); + }, 0); + } + + function hideContextMenu() { + if (activeContextMenu) { + activeContextMenu.remove(); + activeContextMenu = null; + } + } + + // Export + M.trackContainer = { createContainer }; + M.contextMenu = { + show: showContextMenuUI, + hide: hideContextMenu + }; + + // Clear all selections helper + M.clearAllSelections = function() { + Object.keys(selection).forEach(k => { + selection[k].clear(); + lastSelected[k] = null; + }); + }; +})(); From a9f69752edf3083fbb12a67fda52980a930378cb Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 16:18:07 -0800 Subject: [PATCH 04/19] saving --- public/channelSync.js | 1 - public/playlists.js | 3 --- public/trackContainer.js | 42 +++++++++------------------------------- 3 files changed, 9 insertions(+), 37 deletions(-) diff --git a/public/channelSync.js b/public/channelSync.js index a4b955a..14214c7 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -252,7 +252,6 @@ const data = JSON.parse(e.data); // Handle channel list updates if (data.type === "channel_list") { - console.log("[WS] Received channel_list:", data.channels.length, "channels"); M.channels = data.channels; M.renderChannelList(); return; diff --git a/public/playlists.js b/public/playlists.js index 79324ad..8112e36 100644 --- a/public/playlists.js +++ b/public/playlists.js @@ -110,7 +110,6 @@ actions.classList.remove('hidden'); const isMine = myPlaylists.some(p => p.id === selectedPlaylistId); - console.log("[Playlists] Creating container - isMine:", isMine, "selectedPlaylistId:", selectedPlaylistId); // Create or update container (even for empty playlists, to enable drag-drop) playlistContainer = M.trackContainer.createContainer({ @@ -126,9 +125,7 @@ playlistId: selectedPlaylistId }); - console.log("[Playlists] About to call render(), playlistContainer:", playlistContainer); playlistContainer.render(); - console.log("[Playlists] After render()"); } function reloadCurrentPlaylist() { diff --git a/public/trackContainer.js b/public/trackContainer.js index 8d80078..5d143f5 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -4,15 +4,6 @@ (function() { const M = window.MusicRoom; - // Global debug: see if ANY drop event fires - document.addEventListener('drop', (e) => { - console.log("[GLOBAL drop] target:", e.target.tagName, e.target.className, "id:", e.target.id); - }, true); - - document.addEventListener('dragend', (e) => { - console.log("[GLOBAL dragend] target:", e.target.tagName, e.target.className); - }, true); - // Track if a drag is in progress (to prevent re-renders from canceling it) let isDragging = false; @@ -64,8 +55,6 @@ onRender } = config; - console.log("[createContainer] type:", type, "canRemove:", canRemove, "playlistId:", playlistId); - let currentTracks = []; // Get canEdit dynamically (permissions may change) @@ -77,12 +66,10 @@ function render() { // Defer render if a drag is in progress (would cancel the drag) if (isDragging) { - console.log("[render] DEFERRED - drag in progress, type:", type); pendingRender = true; return; } pendingRender = false; - console.log("[render] type:", type, "canRemove:", canRemove, "playlistId:", playlistId); const canEdit = getCanEdit(); element.innerHTML = ""; @@ -94,7 +81,6 @@ wireQueueContainerDrop(element); } if (canRemove && type === 'playlist' && playlistId) { - console.log("[Playlist] Wiring container drop on element:", element.id || element.className); wirePlaylistContainerDrop(element); } @@ -160,7 +146,6 @@ }; container.ondrop = (e) => { - console.log("[Container ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex); container.classList.remove("drop-target"); // Clear any drop indicators on tracks container.querySelectorAll(".drop-above, .drop-below").forEach(el => { @@ -215,9 +200,7 @@ // Get current tracks and insert at position const current = currentTracks.map(t => (t.track || t).id); - console.log("[addTracksToPlaylistAt] current:", current, "inserting:", trackIds, "at:", position); const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)]; - console.log("[addTracksToPlaylistAt] newList:", newList); const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: "PATCH", @@ -317,17 +300,9 @@ div.ondrop = (e) => handleDrop(e, div, originalIndex); } - // Debug: log playlist track wiring conditions - if (type === 'playlist') { - console.log("[Playlist wireTrackEvents] type:", type, "playlistId:", playlistId, "canRemove:", canRemove); - } - // For playlist tracks, allow reordering and insertion (separate from canEdit) if (type === 'playlist' && playlistId && canRemove) { - console.log("[Playlist] Wiring drag handlers for track:", trackId); - div.ondragover = (e) => { - console.log("[Playlist track ondragover] dragSource:", dragSource); e.preventDefault(); e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; @@ -357,8 +332,6 @@ div.classList.remove("drop-above", "drop-below"); element.classList.remove("drop-target"); - console.log("[Track ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex); - if (draggedTrackIds.length > 0 && dropTargetIndex !== null) { if (dragSource === 'playlist') { // Reorder within playlist @@ -422,8 +395,16 @@ if (type === 'queue') { draggedIndices = selection.queue.has(index) ? [...selection.queue] : [index]; draggedTrackIds = draggedIndices.map(i => M.queue[i]?.id).filter(Boolean); + } else if (type === 'playlist') { + // Playlist uses indices for selection (supports duplicates) + draggedIndices = selection.playlist.has(index) ? [...selection.playlist] : [index]; + draggedTrackIds = draggedIndices.map(i => { + const t = currentTracks[i]; + return t ? (t.track || t).id : null; + }).filter(Boolean); } else { - draggedTrackIds = selection[type].has(trackId) ? [...selection[type]] : [trackId]; + // Library uses trackIds + draggedTrackIds = selection.library.has(trackId) ? [...selection.library] : [trackId]; draggedIndices = []; } @@ -448,7 +429,6 @@ // Execute deferred render if any if (pendingRender) { - console.log("[handleDragEnd] Executing deferred render for:", type); setTimeout(() => render(), 50); } } @@ -478,7 +458,6 @@ } function handleDrop(e, div, index) { - console.log("[handleDrop] type:", type, "dragSource:", dragSource, "dropTargetIndex:", dropTargetIndex, "draggedIndices:", draggedIndices); e.preventDefault(); div.classList.remove("drop-above", "drop-below"); @@ -616,7 +595,6 @@ } function showContextMenu(e, track, index, canEdit) { - console.log("[showContextMenu] type:", type, "canEdit:", canEdit, "canRemove:", canRemove, "playlistId:", playlistId); const trackId = track.id || track.filename; const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); @@ -779,7 +757,6 @@ } async function removeFromQueue(indices) { - console.log("[removeFromQueue] indices:", indices, "channelId:", M.currentChannelId); if (!M.currentChannelId) return; const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { @@ -796,7 +773,6 @@ } async function removeFromPlaylist(indices) { - console.log("[removeFromPlaylist] indices:", indices, "playlistId:", playlistId); if (!playlistId) return; const res = await fetch(`/api/playlists/${playlistId}/tracks`, { From 62c7fb9e197b89f68dd9c5889f5f77035db4e922 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 16:31:19 -0800 Subject: [PATCH 05/19] lots of cleanups --- public/playlists.js | 2 +- public/styles.css | 4 +- public/trackContainer.js | 211 ++++++++++++++++++++------------------- 3 files changed, 111 insertions(+), 106 deletions(-) diff --git a/public/playlists.js b/public/playlists.js index 8112e36..6b947c8 100644 --- a/public/playlists.js +++ b/public/playlists.js @@ -121,7 +121,7 @@ return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i }; }); }, - canRemove: isMine, + isPlaylistOwner: isMine, playlistId: selectedPlaylistId }); diff --git a/public/styles.css b/public/styles.css index c3b9a2e..489c1a3 100644 --- a/public/styles.css +++ b/public/styles.css @@ -300,8 +300,10 @@ button:hover { background: #333; } .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; background: #222; border: 1px solid #444; border-radius: 4px; min-width: 120px; z-index: 1001; } +.context-submenu { position: absolute; display: none; top: -4px; left: calc(100% - 8px); padding-left: 8px; background: transparent; min-width: 120px; z-index: 1001; } +.context-submenu-inner { background: #222; border: 1px solid #444; border-radius: 4px; padding: 0.2rem 0; } .context-menu-item.has-submenu:hover > .context-submenu { display: block; } +.context-submenu .context-menu-item { padding: 0.4rem 0.75rem; } .context-menu-item.disabled { color: #666; cursor: default; } .context-menu-item.disabled:hover { background: transparent; } diff --git a/public/trackContainer.js b/public/trackContainer.js index 5d143f5..7f5cbc8 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -37,9 +37,9 @@ * @param {HTMLElement} config.element - Container DOM element * @param {Function} config.getTracks - Returns array of tracks to render * @param {Function} [config.getFilteredTracks] - Returns filtered tracks (for library search) - * @param {boolean} [config.canEdit] - Whether user can modify queue + * @param {boolean} [config.canEditQueue] - Whether user can modify queue * @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only) - * @param {boolean} [config.canRemove] - Whether tracks can be removed from playlist + * @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder) * @param {string} [config.playlistId] - Playlist ID (for playlist type) * @param {Function} [config.onRender] - Callback after render */ @@ -50,15 +50,15 @@ getTracks, getFilteredTracks, canReorder = false, - canRemove = false, + isPlaylistOwner = false, playlistId = null, onRender } = config; let currentTracks = []; - // Get canEdit dynamically (permissions may change) - const getCanEdit = () => config.canEdit ?? M.canControl(); + // Get canEditQueue dynamically (permissions may change) + const getCanEditQueue = () => config.canEditQueue ?? M.canControl(); // Track if this container needs a render after drag ends let pendingRender = false; @@ -70,17 +70,17 @@ return; } pendingRender = false; - const canEdit = getCanEdit(); + const canEditQueue = getCanEditQueue(); element.innerHTML = ""; // Get tracks (filtered for library, direct for queue/playlist) currentTracks = getFilteredTracks ? getFilteredTracks() : getTracks(); // Always wire up container drop handlers first (even for empty containers) - if (canEdit && type === 'queue') { + if (canEditQueue && type === 'queue') { wireQueueContainerDrop(element); } - if (canRemove && type === 'playlist' && playlistId) { + if (isPlaylistOwner && type === 'playlist' && playlistId) { wirePlaylistContainerDrop(element); } @@ -108,7 +108,7 @@ const isActive = type === 'queue' && index === M.currentIndex; // Library tracks are always draggable, queue tracks need edit permission - const isDraggable = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove); + const isDraggable = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner); const div = M.trackComponent.render(track, { view: type, @@ -122,7 +122,7 @@ }); // Wire up event handlers - wireTrackEvents(div, track, filteredIndex, index, canEdit); + wireTrackEvents(div, track, filteredIndex, index, canEditQueue); element.appendChild(div); }); @@ -160,7 +160,7 @@ const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length; if (dragSource === 'playlist') { - reorderPlaylist(draggedTrackIds, targetPos); + reorderPlaylist(draggedIndices, targetPos); } else if (dragSource === 'queue' || dragSource === 'library') { if (currentTracks.length === 0) { addTracksToPlaylist(draggedTrackIds); @@ -189,14 +189,13 @@ if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`); if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } else { + M.showToast("Failed to add to playlist", "error"); } } async function addTracksToPlaylistAt(trackIds, position) { - if (!playlistId) { - console.error("[addTracksToPlaylistAt] No playlistId"); - return; - } + if (!playlistId) return; // Get current tracks and insert at position const current = currentTracks.map(t => (t.track || t).id); @@ -212,31 +211,37 @@ M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); if (M.playlists) M.playlists.reloadCurrentPlaylist(); } else { - console.error("[addTracksToPlaylistAt] Failed:", await res.text()); + M.showToast("Failed to add to playlist", "error"); } } - async function reorderPlaylist(trackIds, targetIndex) { + async function reorderPlaylist(indices, targetIndex) { if (!playlistId) return; // Get current track IDs const current = currentTracks.map(t => (t.track || t).id); - // Remove the dragged tracks from their current positions - const remaining = current.filter(id => !trackIds.includes(id)); + // Sort indices descending for safe removal + const sortedIndices = [...indices].sort((a, b) => b - a); + + // Get track IDs being moved + const movedTrackIds = indices.map(i => current[i]); // Calculate insertion position (adjusted for removed items before target) let insertAt = targetIndex; - for (const id of trackIds) { - const originalPos = current.indexOf(id); - if (originalPos < targetIndex) { + for (const idx of indices) { + if (idx < targetIndex) { insertAt--; } } + + // Remove tracks at indices (from end to preserve indices) + const remaining = current.filter((_, i) => !indices.includes(i)); + insertAt = Math.max(0, Math.min(insertAt, remaining.length)); // Insert at new position - const newList = [...remaining.slice(0, insertAt), ...trackIds, ...remaining.slice(insertAt)]; + const newList = [...remaining.slice(0, insertAt), ...movedTrackIds, ...remaining.slice(insertAt)]; const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: "PATCH", @@ -248,17 +253,19 @@ selection.playlist.clear(); lastSelected.playlist = null; if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } else { + M.showToast("Failed to reorder playlist", "error"); } } - function wireTrackEvents(div, track, filteredIndex, originalIndex, canEdit) { + function wireTrackEvents(div, track, filteredIndex, originalIndex, canEditQueue) { const trackId = track.id || track.filename; const index = type === 'queue' ? originalIndex : filteredIndex; - // Click - toggle selection + // Click - handle selection (Ctrl = toggle, Shift = range, plain = select only this) div.onclick = (e) => { if (e.target.closest('.track-actions')) return; - toggleSelection(index, trackId, e.shiftKey); + toggleSelection(index, trackId, e.shiftKey, e.ctrlKey || e.metaKey); render(); }; @@ -283,81 +290,38 @@ // Context menu div.oncontextmenu = (e) => { e.preventDefault(); - showContextMenu(e, track, originalIndex, canEdit); + showContextMenu(e, track, originalIndex, canEditQueue); }; // Drag start/end handlers - library always, queue/playlist with permissions - const canDrag = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove); + const canDrag = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner); if (canDrag) { div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); div.ondragend = (e) => handleDragEnd(e, div); } - // Drop handlers - only queue accepts drops (from library/playlist) - if (canEdit && type === 'queue') { + // Drop handlers - queue and playlist accept drops + if (canEditQueue && type === 'queue') { div.ondragover = (e) => handleDragOver(e, div, originalIndex); div.ondragleave = (e) => handleDragLeave(e, div); div.ondrop = (e) => handleDrop(e, div, originalIndex); } - // For playlist tracks, allow reordering and insertion (separate from canEdit) - if (type === 'playlist' && playlistId && canRemove) { - div.ondragover = (e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; - - const rect = div.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - const isAbove = e.clientY < midY; - - // Clear other indicators - element.querySelectorAll(".drop-above, .drop-below").forEach(el => { - el.classList.remove("drop-above", "drop-below"); - }); - - // Don't show indicator on dragged items (for reorder) - if (dragSource === 'playlist' && draggedTrackIds.includes(trackId)) return; - - div.classList.add(isAbove ? "drop-above" : "drop-below"); - dropTargetIndex = isAbove ? filteredIndex : filteredIndex + 1; - }; - - div.ondragleave = () => { - div.classList.remove("drop-above", "drop-below"); - }; - - div.ondrop = (e) => { - e.preventDefault(); - e.stopPropagation(); - div.classList.remove("drop-above", "drop-below"); - element.classList.remove("drop-target"); - - if (draggedTrackIds.length > 0 && dropTargetIndex !== null) { - if (dragSource === 'playlist') { - // Reorder within playlist - reorderPlaylist(draggedTrackIds, dropTargetIndex); - } else if (dragSource === 'queue' || dragSource === 'library') { - // Insert at position - addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex); - } - } - - draggedTrackIds = []; - draggedIndices = []; - dragSource = null; - dropTargetIndex = null; - }; + if (type === 'playlist' && playlistId && isPlaylistOwner) { + div.ondragover = (e) => handleDragOver(e, div, filteredIndex); + div.ondragleave = (e) => handleDragLeave(e, div); + div.ondrop = (e) => handleDrop(e, div, filteredIndex); } } - function toggleSelection(index, trackId, shiftKey) { + function toggleSelection(index, trackId, shiftKey, ctrlKey) { // Queue and playlist use indices, library uses trackIds const key = type === 'library' ? trackId : index; const sel = selection[type]; + const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index; if (shiftKey && lastSelected[type] !== null) { - // Range select - const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index; + // Shift+click: Range select (add to existing selection) const start = Math.min(lastSelected[type], currentIdx); const end = Math.max(lastSelected[type], currentIdx); @@ -373,13 +337,19 @@ sel.add(i); } } - } else { + } else if (ctrlKey) { + // Ctrl+click: Toggle single item if (sel.has(key)) { sel.delete(key); } else { sel.add(key); } - lastSelected[type] = type === 'library' ? getFilteredIndex(trackId) : index; + lastSelected[type] = currentIdx; + } else { + // Plain click: Select only this item (clear others) + sel.clear(); + sel.add(key); + lastSelected[type] = currentIdx; } } @@ -435,7 +405,13 @@ function handleDragOver(e, div, index) { e.preventDefault(); - e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy"; + + // Set drop effect based on source + if (type === 'queue') { + e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy"; + } else if (type === 'playlist') { + e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; + } const rect = div.getBoundingClientRect(); const midY = rect.top + rect.height / 2; @@ -447,7 +423,7 @@ }); // Don't show indicator on dragged items - if (dragSource === 'queue' && draggedIndices.includes(index)) return; + if (dragSource === type && draggedIndices.includes(index)) return; div.classList.add(isAbove ? "drop-above" : "drop-below"); dropTargetIndex = isAbove ? index : index + 1; @@ -459,20 +435,32 @@ function handleDrop(e, div, index) { e.preventDefault(); + e.stopPropagation(); div.classList.remove("drop-above", "drop-below"); + element.classList.remove("drop-target"); if (dropTargetIndex === null) return; - if (dragSource === 'queue' && draggedIndices.length > 0) { - // Reorder within queue - const minDragged = Math.min(...draggedIndices); - const maxDragged = Math.max(...draggedIndices); - if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { - reorderQueue(draggedIndices, dropTargetIndex); + if (type === 'queue') { + if (dragSource === 'queue' && draggedIndices.length > 0) { + // Reorder within queue + const minDragged = Math.min(...draggedIndices); + const maxDragged = Math.max(...draggedIndices); + if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { + reorderQueue(draggedIndices, dropTargetIndex); + } + } else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { + // Insert tracks from library or playlist + insertTracksAtPosition(draggedTrackIds, dropTargetIndex); + } + } else if (type === 'playlist') { + if (dragSource === 'playlist' && draggedIndices.length > 0) { + // Reorder within playlist + reorderPlaylist(draggedIndices, dropTargetIndex); + } else if ((dragSource === 'queue' || dragSource === 'library') && draggedTrackIds.length > 0) { + // Insert at position + addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex); } - } else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { - // Insert tracks from library or playlist - insertTracksAtPosition(draggedTrackIds, dropTargetIndex); } draggedIndices = []; @@ -524,6 +512,8 @@ else if (res.ok) { selection.queue.clear(); lastSelected.queue = null; + } else { + M.showToast("Failed to reorder queue", "error"); } } @@ -540,6 +530,8 @@ else if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); clearSelection(); + } else { + M.showToast("Failed to add tracks", "error"); } } @@ -588,13 +580,16 @@ M.localTimestamp = 0; M.audio.play(); - if (M.synced) { + // Desync and disable auto-resync + if (M.synced || M.wantSync) { M.synced = false; + M.wantSync = false; M.showToast("Previewing track (desynced)"); + M.updateUI(); } } - function showContextMenu(e, track, index, canEdit) { + function showContextMenu(e, track, index, canEditQueue) { const trackId = track.id || track.filename; const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); @@ -618,16 +613,16 @@ const menuItems = []; - // Play (queue only, single track) - if (type === 'queue' && !hasSelection) { + // Play (queue only, single track or single selection) + if (type === 'queue' && selectedCount === 1) { menuItems.push({ label: "▶ Play", action: () => playTrack(track, index) }); } - // Preview (all views, single track) - if (!hasSelection) { + // Preview (all views, single track or single selection) + if (selectedCount === 1) { menuItems.push({ label: "⏵ Preview", action: () => previewTrack(track) @@ -635,7 +630,7 @@ } // Queue actions - if (type === 'queue' && canEdit) { + if (type === 'queue' && canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} again` : "+ Add again", action: () => addToQueue(idsForAction) @@ -651,8 +646,8 @@ }); } - // Library actions - if (type === 'library' && canEdit) { + // Library actions - can add to queue if user has queue edit permission + if (type === 'library' && canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", action: () => addToQueue(idsForAction) @@ -665,7 +660,8 @@ // Playlist actions if (type === 'playlist') { - if (canEdit) { + // Can add to queue if user has queue edit permission + if (canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", action: () => addToQueue(idsForAction) @@ -675,7 +671,8 @@ action: () => addToQueue(idsForAction, true) }); } - if (canRemove && playlistId) { + // Can remove if user owns the playlist + if (isPlaylistOwner && playlistId) { menuItems.push({ label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove", danger: true, @@ -753,6 +750,8 @@ M.showToast(playNext ? "Playing next" : "Added to queue"); clearSelection(); render(); + } else { + M.showToast("Failed to add to queue", "error"); } } @@ -769,6 +768,8 @@ else if (res.ok) { M.showToast("Removed"); clearSelection(); + } else { + M.showToast("Failed to remove from queue", "error"); } } @@ -785,6 +786,8 @@ M.showToast("Removed from playlist"); clearSelection(); if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } else { + M.showToast("Failed to remove from playlist", "error"); } } From 04acbf896ea05ab2ffa56351f266b3cb4f2f132c Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 16:36:10 -0800 Subject: [PATCH 06/19] cleaning up buttons --- public/trackComponent.js | 6 ------ public/trackContainer.js | 24 ++++-------------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/public/trackComponent.js b/public/trackComponent.js index f9db290..0e31b27 100644 --- a/public/trackComponent.js +++ b/public/trackComponent.js @@ -14,7 +14,6 @@ * @param {boolean} config.isSelected - Whether track is selected * @param {boolean} config.isCached - Whether track is cached locally * @param {boolean} config.isActive - Whether this is the currently playing track - * @param {boolean} config.showPlayButton - Show play button (queue only) * @param {boolean} config.draggable - Whether element is draggable * @returns {HTMLElement} */ @@ -26,7 +25,6 @@ isSelected, isCached, isActive, - showPlayButton, draggable } = config; @@ -53,8 +51,6 @@ // Build HTML const checkmark = isSelected ? '' : ''; const trackNum = displayIndex != null ? `${displayIndex}.` : ''; - const playBtn = showPlayButton ? '' : ''; - const previewBtn = ''; div.innerHTML = ` ${checkmark} @@ -62,8 +58,6 @@ ${trackNum} ${escapeHtml(title)} - ${playBtn} - ${previewBtn} ${M.fmt(track.duration)} `; diff --git a/public/trackContainer.js b/public/trackContainer.js index 7f5cbc8..30ad0b0 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -117,7 +117,6 @@ isSelected, isCached, isActive, - showPlayButton: type === 'queue', draggable: isDraggable }); @@ -269,24 +268,6 @@ render(); }; - // Play button (queue only) - const playBtn = div.querySelector('.track-play-btn'); - if (playBtn) { - playBtn.onclick = (e) => { - e.stopPropagation(); - playTrack(track, originalIndex); - }; - } - - // Preview button - const previewBtn = div.querySelector('.track-preview-btn'); - if (previewBtn) { - previewBtn.onclick = (e) => { - e.stopPropagation(); - previewTrack(track); - }; - } - // Context menu div.oncontextmenu = (e) => { e.preventDefault(); @@ -834,6 +815,8 @@ const sub = document.createElement("div"); sub.className = "context-submenu"; + const subInner = document.createElement("div"); + subInner.className = "context-submenu-inner"; item.submenu.forEach(subItem => { const subEl = document.createElement("div"); subEl.className = "context-menu-item"; @@ -843,8 +826,9 @@ hideContextMenu(); subItem.action(); }; - sub.appendChild(subEl); + subInner.appendChild(subEl); }); + sub.appendChild(subInner); el.appendChild(sub); } else if (!item.disabled) { el.onclick = () => { From a89cc144488bcf3947bfaadbe972fa16518d77bd Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 23:25:50 -0800 Subject: [PATCH 07/19] progress tracker for playlists --- public/auth.js | 6 +++ public/styles.css | 10 +++++ public/upload.js | 103 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/public/auth.js b/public/auth.js index b60216a..dda2df3 100644 --- a/public/auth.js +++ b/public/auth.js @@ -14,6 +14,10 @@ M.currentUser.permissions = data.permissions; } M.updateAuthUI(); + // Start slow queue polling if logged in + if (M.currentUser && !M.currentUser.isGuest && M.startSlowQueuePoll) { + M.startSlowQueuePoll(); + } } catch (e) { M.currentUser = null; M.updateAuthUI(); @@ -108,6 +112,8 @@ const wasGuest = M.currentUser?.isGuest; await fetch("/api/auth/logout", { method: "POST" }); M.currentUser = null; + // Stop slow queue polling on logout + if (M.stopSlowQueuePoll) M.stopSlowQueuePoll(); if (wasGuest) { // Guest clicking "Sign In" - show login panel M.updateAuthUI(); diff --git a/public/styles.css b/public/styles.css index 489c1a3..ab62b2a 100644 --- a/public/styles.css +++ b/public/styles.css @@ -70,6 +70,16 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .task-item .task-progress { font-size: 0.7rem; color: #888; flex-shrink: 0; } .task-item .task-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: #ea4; transition: width 0.2s; } .task-item.complete .task-bar { background: #4e8; } +.slow-queue-section { margin-top: 0.5rem; border-top: 1px solid #333; padding-top: 0.5rem; } +.slow-queue-section.hidden { display: none; } +.slow-queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0 0.2rem; } +.slow-queue-title { font-size: 0.75rem; color: #888; font-weight: 500; } +.slow-queue-timer { font-size: 0.7rem; color: #6af; } +.slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 150px; overflow-y: auto; } +.slow-queue-item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; background: #1a1a2a; border-radius: 3px; font-size: 0.75rem; color: #6af; } +.slow-queue-item.next { background: #1a2a2a; color: #4cf; } +.slow-queue-item-icon { flex-shrink: 0; font-size: 0.7rem; } +.slow-queue-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .scan-progress.hidden { display: none; } .scan-progress.complete { color: #4e8; background: #1a2a1a; } diff --git a/public/upload.js b/public/upload.js index 7e3c33d..03c3645 100644 --- a/public/upload.js +++ b/public/upload.js @@ -12,6 +12,10 @@ const tasksList = M.$("#tasks-list"); const tasksEmpty = M.$("#tasks-empty"); + // Slow queue section elements (created dynamically) + let slowQueueSection = null; + let slowQueuePollInterval = null; + if (!addBtn || !fileInput || !dropzone) return; function openPanel() { @@ -200,10 +204,101 @@ const fetchTasks = new Map(); // Map function updateTasksEmpty() { - const hasTasks = tasksList.children.length > 0; - tasksEmpty.classList.toggle("hidden", hasTasks); + const hasActiveTasks = tasksList.children.length > 0; + const hasSlowQueue = slowQueueSection && !slowQueueSection.classList.contains("hidden"); + tasksEmpty.classList.toggle("hidden", hasActiveTasks || hasSlowQueue); } + // Slow queue display + function createSlowQueueSection() { + if (slowQueueSection) return slowQueueSection; + + slowQueueSection = document.createElement("div"); + slowQueueSection.className = "slow-queue-section hidden"; + slowQueueSection.innerHTML = ` +
+ Playlist Queue + +
+
+ `; + + // Insert before tasks-empty + tasksEmpty.parentNode.insertBefore(slowQueueSection, tasksEmpty); + return slowQueueSection; + } + + function formatTime(seconds) { + if (seconds <= 0) return "now"; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins > 0) return `${mins}m ${secs}s`; + return `${secs}s`; + } + + function updateSlowQueueDisplay(slowQueue, slowQueueNextIn) { + const section = createSlowQueueSection(); + const queuedItems = slowQueue.filter(i => i.status === "queued"); + + if (queuedItems.length === 0) { + section.classList.add("hidden"); + updateTasksEmpty(); + return; + } + + section.classList.remove("hidden"); + + // Update header with count and timer + const timerEl = section.querySelector(".slow-queue-timer"); + timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`; + + // Update list + const listEl = section.querySelector(".slow-queue-list"); + listEl.innerHTML = queuedItems.map((item, i) => ` +
+ ${i === 0 ? '⏳' : '·'} + ${item.title} +
+ `).join(""); + + updateTasksEmpty(); + } + + async function pollSlowQueue() { + if (!M.currentUser || M.currentUser.isGuest) return; + + try { + const res = await fetch("/api/fetch"); + if (res.ok) { + const data = await res.json(); + updateSlowQueueDisplay(data.slowQueue || [], data.slowQueueNextIn || 0); + } + } catch (e) { + // Ignore poll errors + } + } + + function startSlowQueuePoll() { + if (slowQueuePollInterval) return; + pollSlowQueue(); + slowQueuePollInterval = setInterval(pollSlowQueue, 5000); + } + + function stopSlowQueuePoll() { + if (slowQueuePollInterval) { + clearInterval(slowQueuePollInterval); + slowQueuePollInterval = null; + } + if (slowQueueSection) { + slowQueueSection.classList.add("hidden"); + updateTasksEmpty(); + } + } + + // Expose start/stop for auth module to call + M.startSlowQueuePoll = startSlowQueuePoll; + M.stopSlowQueuePoll = stopSlowQueuePoll; + // Handle WebSocket fetch progress messages M.handleFetchProgress = function(data) { let task = fetchTasks.get(data.id); @@ -220,9 +315,13 @@ } else if (data.status === "complete") { task.setComplete(); fetchTasks.delete(data.id); + // Refresh slow queue on completion + pollSlowQueue(); } else if (data.status === "error") { task.setError(data.error || "Failed"); fetchTasks.delete(data.id); + // Refresh slow queue on error + pollSlowQueue(); } }; From 2803410a90be64005430941d9ebdd8b682c918f9 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 23:45:57 -0800 Subject: [PATCH 08/19] persistent playlists and auto playlist --- db.ts | 152 ++++++++++++++++++++++++++++++++++++++++++++++ init.ts | 81 ++++++++++++++++++++++++ public/styles.css | 7 ++- public/upload.js | 68 +++++++++++++++++---- routes/fetch.ts | 30 ++++++++- routes/index.ts | 5 ++ ytdlp.ts | 139 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 463 insertions(+), 19 deletions(-) diff --git a/db.ts b/db.ts index 87ce889..365a464 100644 --- a/db.ts +++ b/db.ts @@ -694,3 +694,155 @@ export function generatePlaylistShareToken(playlistId: string): string { export function removePlaylistShareToken(playlistId: string): void { db.query("UPDATE playlists SET share_token = NULL WHERE id = ?").run(playlistId); } + +// Slow queue table for yt-dlp playlist downloads +db.run(` + CREATE TABLE IF NOT EXISTS slow_queue ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + title TEXT NOT NULL, + user_id INTEGER NOT NULL, + status TEXT DEFAULT 'queued', + progress REAL DEFAULT 0, + error TEXT, + playlist_id TEXT, + playlist_name TEXT, + position INTEGER, + created_at INTEGER, + completed_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL + ) +`); + +db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_user ON slow_queue(user_id)`); +db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_status ON slow_queue(status)`); + +// Slow queue types +export interface SlowQueueRow { + id: string; + url: string; + title: string; + user_id: number; + status: string; + progress: number; + error: string | null; + playlist_id: string | null; + playlist_name: string | null; + position: number | null; + created_at: number; + completed_at: number | null; +} + +// Slow queue CRUD functions +export function saveSlowQueueItem(item: { + id: string; + url: string; + title: string; + userId: number; + status: string; + progress: number; + error?: string; + playlistId?: string; + playlistName?: string; + position?: number; + createdAt: number; + completedAt?: number; +}): void { + db.query(` + INSERT INTO slow_queue (id, url, title, user_id, status, progress, error, playlist_id, playlist_name, position, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + progress = excluded.progress, + error = excluded.error, + completed_at = excluded.completed_at + `).run( + item.id, + item.url, + item.title, + item.userId, + item.status, + item.progress, + item.error ?? null, + item.playlistId ?? null, + item.playlistName ?? null, + item.position ?? null, + item.createdAt, + item.completedAt ?? null + ); +} + +export function updateSlowQueueItem(id: string, updates: { + status?: string; + progress?: number; + error?: string; + completedAt?: number; +}): void { + const sets: string[] = []; + const values: any[] = []; + + if (updates.status !== undefined) { + sets.push("status = ?"); + values.push(updates.status); + } + if (updates.progress !== undefined) { + sets.push("progress = ?"); + values.push(updates.progress); + } + if (updates.error !== undefined) { + sets.push("error = ?"); + values.push(updates.error); + } + if (updates.completedAt !== undefined) { + sets.push("completed_at = ?"); + values.push(updates.completedAt); + } + + if (sets.length === 0) return; + + values.push(id); + db.query(`UPDATE slow_queue SET ${sets.join(", ")} WHERE id = ?`).run(...values); +} + +export function loadSlowQueue(): SlowQueueRow[] { + return db.query( + "SELECT * FROM slow_queue WHERE status IN ('queued', 'downloading') ORDER BY created_at" + ).all() as SlowQueueRow[]; +} + +export function deleteSlowQueueItem(id: string): void { + db.query("DELETE FROM slow_queue WHERE id = ?").run(id); +} + +export function clearCompletedSlowQueue(maxAge: number = 3600): void { + const cutoff = Math.floor(Date.now() / 1000) - maxAge; + db.query( + "DELETE FROM slow_queue WHERE status IN ('complete', 'error', 'cancelled') AND completed_at < ?" + ).run(cutoff); +} + +export function getSlowQueueByUser(userId: number): SlowQueueRow[] { + return db.query( + "SELECT * FROM slow_queue WHERE user_id = ? ORDER BY created_at" + ).all(userId) as SlowQueueRow[]; +} + +export function playlistNameExists(name: string, userId: number): boolean { + const result = db.query( + "SELECT 1 FROM playlists WHERE name = ? AND owner_id = ? LIMIT 1" + ).get(name, userId); + return !!result; +} + +export function generateUniquePlaylistName(baseName: string, userId: number): string { + if (!playlistNameExists(baseName, userId)) { + return baseName; + } + + let counter = 2; + while (playlistNameExists(`${baseName} (${counter})`, userId)) { + counter++; + } + return `${baseName} (${counter})`; +} diff --git a/init.ts b/init.ts index c4727aa..e7cc79f 100644 --- a/init.ts +++ b/init.ts @@ -8,6 +8,7 @@ import { saveChannelQueue, loadChannelQueue, removeTrackFromQueues, + addTracksToPlaylist, } from "./db"; import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config"; import { state, setLibrary } from "./state"; @@ -15,6 +16,8 @@ import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast"; import { initYtdlp, setProgressCallback, + setTrackReadyCallback, + type QueueItem, } from "./ytdlp"; // Auto-discover tracks if queue is empty @@ -98,6 +101,8 @@ export async function init(): Promise { status: item.status, progress: item.progress, queueType: item.queueType, + playlistId: item.playlistId, + playlistName: item.playlistName, error: item.error }); }); @@ -106,6 +111,23 @@ export async function init(): Promise { const library = new Library(MUSIC_DIR); setLibrary(library); + // Track pending playlist additions (title -> {playlistId, playlistName, userId}) + const pendingPlaylistTracks = new Map(); + + // When a download completes, register it for playlist addition + setTrackReadyCallback((item: QueueItem) => { + if (!item.playlistId) return; + + // Store the pending addition - will be processed when library detects the file + // yt-dlp saves files as "title.mp3", so use the title as key + pendingPlaylistTracks.set(item.title.toLowerCase(), { + playlistId: item.playlistId, + playlistName: item.playlistName!, + userId: item.userId + }); + console.log(`[ytdlp] Registered pending playlist addition: "${item.title}" → ${item.playlistName}`); + }); + // Scan library first await library.scan(); library.startWatching(); @@ -115,10 +137,69 @@ export async function init(): Promise { broadcastToAll({ type: "scan_progress", scanning: false }); }); + // Normalize string for matching (handle Windows filename character substitutions) + function normalizeForMatch(s: string): string { + return s.toLowerCase() + .replace(/|/g, "|") // fullwidth vertical line → pipe + .replace(/"/g, '"') // fullwidth quotation + .replace(/*/g, "*") // fullwidth asterisk + .replace(/?/g, "?") // fullwidth question mark + .replace(/</g, "<") // fullwidth less-than + .replace(/>/g, ">") // fullwidth greater-than + .replace(/:/g, ":") // fullwidth colon + .replace(///g, "/") // fullwidth slash + .replace(/\/g, "\\") // fullwidth backslash + .trim(); + } + + // Helper to check if track matches a pending playlist addition + function checkPendingPlaylistAddition(track: { id: string; title?: string; filename?: string }) { + if (pendingPlaylistTracks.size === 0) return; + + const trackTitle = normalizeForMatch(track.title || ""); + const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); // Remove extension + + console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`); + + for (const [pendingTitle, pending] of pendingPlaylistTracks) { + const normalizedPending = normalizeForMatch(pendingTitle); + + // Match by title or filename (yt-dlp uses title as filename) + const matches = + (trackTitle && trackTitle === normalizedPending) || + (trackFilename && trackFilename === normalizedPending) || + (trackTitle && normalizedPending && trackTitle.includes(normalizedPending)) || + (trackTitle && normalizedPending && normalizedPending.includes(trackTitle)) || + (trackFilename && normalizedPending && trackFilename.includes(normalizedPending)) || + (trackFilename && normalizedPending && normalizedPending.includes(trackFilename)); + + console.log(`[ytdlp] vs pending="${normalizedPending}" → ${matches ? "MATCH" : "no match"}`); + + if (matches) { + console.log(`[ytdlp] Adding track ${track.id} to playlist ${pending.playlistId}`); + try { + addTracksToPlaylist(pending.playlistId, [track.id]); + sendToUser(pending.userId, { + type: "toast", + message: `Added to playlist: ${pending.playlistName}`, + toastType: "info" + }); + pendingPlaylistTracks.delete(pendingTitle); + } catch (e) { + console.error(`[ytdlp] Failed to add track to playlist:`, e); + } + return; + } + } + } + // Broadcast when tracks are added/updated library.on("added", (track) => { broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" }); library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title }); + + // Check if this track was pending playlist addition (defer to ensure DB is updated) + setTimeout(() => checkPendingPlaylistAddition(track), 100); }); library.on("changed", (track) => { broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" }); diff --git a/public/styles.css b/public/styles.css index ab62b2a..61b1c62 100644 --- a/public/styles.css +++ b/public/styles.css @@ -75,11 +75,16 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .slow-queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0 0.2rem; } .slow-queue-title { font-size: 0.75rem; color: #888; font-weight: 500; } .slow-queue-timer { font-size: 0.7rem; color: #6af; } -.slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 150px; overflow-y: auto; } +.slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 200px; overflow-y: auto; } +.slow-queue-playlist-header { font-size: 0.7rem; color: #888; padding: 0.3rem 0.2rem 0.15rem; margin-top: 0.2rem; border-top: 1px solid #2a2a2a; } +.slow-queue-playlist-header:first-child { border-top: none; margin-top: 0; } .slow-queue-item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; background: #1a1a2a; border-radius: 3px; font-size: 0.75rem; color: #6af; } .slow-queue-item.next { background: #1a2a2a; color: #4cf; } .slow-queue-item-icon { flex-shrink: 0; font-size: 0.7rem; } .slow-queue-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.slow-queue-cancel { background: none; border: none; color: #666; cursor: pointer; padding: 0 0.2rem; font-size: 0.7rem; opacity: 0; transition: opacity 0.15s; } +.slow-queue-item:hover .slow-queue-cancel { opacity: 1; } +.slow-queue-cancel:hover { color: #e44; } .scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .scan-progress.hidden { display: none; } .scan-progress.complete { color: #4e8; background: #1a2a1a; } diff --git a/public/upload.js b/public/upload.js index 03c3645..45d4854 100644 --- a/public/upload.js +++ b/public/upload.js @@ -107,20 +107,23 @@ if (data.type === "playlist") { // Ask user to confirm playlist download - const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.`); + const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`); if (confirmed) { - // Confirm playlist download + // Confirm playlist download with title for auto-playlist creation const confirmRes = await fetch("/api/fetch/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: data.items }) + body: JSON.stringify({ items: data.items, playlistTitle: data.title }) }); if (confirmRes.ok) { const confirmData = await confirmRes.json(); - M.showToast(confirmData.message); - // Tasks will be created by WebSocket progress messages + M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`); + // Refresh playlists to show the new one + if (M.playlists?.load) M.playlists.load(); + // Refresh slow queue display + pollSlowQueue(); } else { const err = await confirmRes.json().catch(() => ({})); M.showToast(err.error || "Failed to queue playlist", "error"); @@ -252,14 +255,57 @@ const timerEl = section.querySelector(".slow-queue-timer"); timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`; + // Group items by playlist + const byPlaylist = new Map(); + for (const item of queuedItems) { + const key = item.playlistId || "__none__"; + if (!byPlaylist.has(key)) { + byPlaylist.set(key, { name: item.playlistName, items: [] }); + } + byPlaylist.get(key).items.push(item); + } + // Update list const listEl = section.querySelector(".slow-queue-list"); - listEl.innerHTML = queuedItems.map((item, i) => ` -
- ${i === 0 ? '⏳' : '·'} - ${item.title} -
- `).join(""); + let html = ""; + + for (const [playlistId, group] of byPlaylist) { + if (group.name) { + html += `
📁 ${group.name}
`; + } + html += group.items.map((item, i) => { + const isNext = queuedItems.indexOf(item) === 0; + return ` +
+ ${isNext ? '⏳' : '·'} + ${item.title} + +
+ `; + }).join(""); + } + + listEl.innerHTML = html; + + // Add cancel handlers + listEl.querySelectorAll(".slow-queue-cancel").forEach(btn => { + btn.onclick = async (e) => { + e.stopPropagation(); + const itemEl = btn.closest(".slow-queue-item"); + const itemId = itemEl.dataset.id; + try { + const res = await fetch(`/api/fetch/${itemId}`, { method: "DELETE" }); + if (res.ok) { + itemEl.remove(); + pollSlowQueue(); + } else { + M.showToast("Cannot cancel item", "error"); + } + } catch (e) { + M.showToast("Failed to cancel", "error"); + } + }; + }); updateTasksEmpty(); } diff --git a/routes/fetch.ts b/routes/fetch.ts index a53b632..7597bb2 100644 --- a/routes/fetch.ts +++ b/routes/fetch.ts @@ -5,8 +5,10 @@ import { addToFastQueue, addToSlowQueue, getUserQueues, + cancelSlowQueueItem, } from "../ytdlp"; import { getOrCreateUser } from "./helpers"; +import { createPlaylist, generateUniquePlaylistName } from "../db"; const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; @@ -72,12 +74,19 @@ export async function handleFetchConfirm(req: Request, server: any): Promise ({ id: i.id, title: i.title })) }, { headers }); } catch (e) { @@ -107,3 +118,18 @@ export function handleGetFetchQueue(req: Request, server: any): Response { const queues = getUserQueues(user.id); return Response.json(queues, { headers }); } + +// DELETE /api/fetch/:id - cancel a slow queue item +export function handleCancelFetchItem(req: Request, server: any, itemId: string): Response { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + const success = cancelSlowQueueItem(itemId, user.id); + if (success) { + return Response.json({ message: "Item cancelled" }, { headers }); + } else { + return Response.json({ error: "Cannot cancel item (not found, not owned, or already downloading)" }, { status: 400, headers }); + } +} diff --git a/routes/index.ts b/routes/index.ts index 67505fa..08a0cf8 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -40,6 +40,7 @@ import { handleFetch, handleFetchConfirm, handleGetFetchQueue, + handleCancelFetchItem, } from "./fetch"; // Playlist routes @@ -149,6 +150,10 @@ export function createRouter() { if (path === "/api/fetch" && req.method === "GET") { return handleGetFetchQueue(req, server); } + const fetchCancelMatch = path.match(/^\/api\/fetch\/([^/]+)$/); + if (fetchCancelMatch && req.method === "DELETE") { + return handleCancelFetchItem(req, server, fetchCancelMatch[1]); + } // Playlist routes if (path === "/api/playlists" && req.method === "GET") { diff --git a/ytdlp.ts b/ytdlp.ts index 880edc3..2fad06e 100644 --- a/ytdlp.ts +++ b/ytdlp.ts @@ -3,19 +3,32 @@ import { spawn } from "child_process"; import { join } from "path"; +import { + saveSlowQueueItem, + updateSlowQueueItem, + loadSlowQueue, + deleteSlowQueueItem, + clearCompletedSlowQueue, + addTracksToPlaylist, + type SlowQueueRow +} from "./db"; export interface QueueItem { id: string; url: string; title: string; userId: number; - status: "queued" | "downloading" | "complete" | "error"; + status: "queued" | "downloading" | "complete" | "error" | "cancelled"; progress: number; queueType: "fast" | "slow"; error?: string; filename?: string; createdAt: number; completedAt?: number; + playlistId?: string; + playlistName?: string; + position?: number; + trackId?: string; // Set after successful download } export interface YtdlpStatus { @@ -65,6 +78,7 @@ let lastSlowDownload = 0; // Callbacks let onProgress: ProgressCallback | null = null; +let onTrackReady: ((item: QueueItem) => void) | null = null; // Generate unique ID function generateId(): string { @@ -115,6 +129,17 @@ export async function initYtdlp(config: { ffmpegAvailable = false; } + // Load persisted slow queue from database + if (featureEnabled) { + const savedQueue = loadSlowQueue(); + for (const row of savedQueue) { + slowQueue.push(rowToQueueItem(row)); + } + if (savedQueue.length > 0) { + console.log(`[ytdlp] Restored ${savedQueue.length} items from slow queue`); + } + } + // Start slow queue processor if (featureEnabled) { startSlowQueueProcessor(); @@ -123,6 +148,25 @@ export async function initYtdlp(config: { return getStatus(); } +// Convert database row to QueueItem +function rowToQueueItem(row: SlowQueueRow): QueueItem { + return { + id: row.id, + url: row.url, + title: row.title, + userId: row.user_id, + status: row.status as QueueItem["status"], + progress: row.progress, + queueType: "slow", + error: row.error ?? undefined, + createdAt: row.created_at * 1000, + completedAt: row.completed_at ? row.completed_at * 1000 : undefined, + playlistId: row.playlist_id ?? undefined, + playlistName: row.playlist_name ?? undefined, + position: row.position ?? undefined + }; +} + // Run a command and return stdout function runCommand(cmd: string, args: string[]): Promise { const fullCmd = `${cmd} ${args.join(" ")}`; @@ -162,6 +206,11 @@ export function setProgressCallback(callback: ProgressCallback): void { onProgress = callback; } +// Set track ready callback (called when download completes and needs playlist association) +export function setTrackReadyCallback(callback: (item: QueueItem) => void): void { + onTrackReady = callback; +} + // Get all queue items export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } { const now = Date.now(); @@ -258,8 +307,13 @@ export function addToFastQueue(url: string, title: string, userId: number): Queu } // Add items to slow queue (for playlists) -export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] { - const queueItems: QueueItem[] = items.map(item => ({ +export function addToSlowQueue( + items: { url: string; title: string }[], + userId: number, + playlist?: { id: string; name: string } +): QueueItem[] { + const now = Date.now(); + const queueItems: QueueItem[] = items.map((item, index) => ({ id: generateId(), url: item.url, title: item.title, @@ -267,8 +321,28 @@ export function addToSlowQueue(items: { url: string; title: string }[], userId: status: "queued" as const, progress: 0, queueType: "slow" as const, - createdAt: Date.now() + createdAt: now, + playlistId: playlist?.id, + playlistName: playlist?.name, + position: playlist ? index : undefined })); + + // Persist to database + for (const item of queueItems) { + saveSlowQueueItem({ + id: item.id, + url: item.url, + title: item.title, + userId: item.userId, + status: item.status, + progress: item.progress, + playlistId: item.playlistId, + playlistName: item.playlistName, + position: item.position, + createdAt: Math.floor(item.createdAt / 1000) + }); + } + slowQueue.push(...queueItems); return queueItems; } @@ -366,6 +440,21 @@ async function downloadItem(item: QueueItem): Promise { item.status = "complete"; item.progress = 100; item.completedAt = Date.now(); + + // Update database + if (item.queueType === "slow") { + updateSlowQueueItem(item.id, { + status: "complete", + progress: 100, + completedAt: Math.floor(item.completedAt / 1000) + }); + + // Register for playlist addition immediately - library will match when it scans + if (item.playlistId && onTrackReady) { + onTrackReady(item); + } + } + notifyProgress(item); // Remove from queue after delay @@ -374,6 +463,17 @@ async function downloadItem(item: QueueItem): Promise { } catch (e: any) { item.status = "error"; item.error = e.message || "Download failed"; + item.completedAt = Date.now(); + + // Update database + if (item.queueType === "slow") { + updateSlowQueueItem(item.id, { + status: "error", + error: item.error, + completedAt: Math.floor(item.completedAt / 1000) + }); + } + notifyProgress(item); // Remove from queue after delay @@ -389,9 +489,35 @@ function removeFromQueue(item: QueueItem): void { } else { const idx = slowQueue.findIndex(i => i.id === item.id); if (idx !== -1) slowQueue.splice(idx, 1); + // Remove from database + deleteSlowQueueItem(item.id); } } +// Cancel a slow queue item +export function cancelSlowQueueItem(id: string, userId: number): boolean { + const item = slowQueue.find(i => i.id === id && i.userId === userId); + if (!item || item.status === "downloading") { + return false; // Can't cancel if not found, not owned, or already downloading + } + + item.status = "cancelled"; + item.completedAt = Date.now(); + + // Update database + updateSlowQueueItem(id, { + status: "cancelled", + completedAt: Math.floor(item.completedAt / 1000) + }); + + notifyProgress(item); + + // Remove from queue after brief delay + setTimeout(() => removeFromQueue(item), 1000); + + return true; +} + // Notify progress callback function notifyProgress(item: QueueItem): void { if (onProgress) { @@ -405,7 +531,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void { const cleanup = (queue: QueueItem[]) => { for (let i = queue.length - 1; i >= 0; i--) { const item = queue[i]; - if ((item.status === "complete" || item.status === "error") && + if ((item.status === "complete" || item.status === "error" || item.status === "cancelled") && now - item.createdAt > maxAge) { queue.splice(i, 1); } @@ -413,4 +539,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void { }; cleanup(fastQueue); cleanup(slowQueue); + + // Also cleanup database + clearCompletedSlowQueue(Math.floor(maxAge / 1000)); } From 8da0a76f288ca36880eafbdac42b88d8b422c68d Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 23:49:24 -0800 Subject: [PATCH 09/19] updating playlist queue visibility --- routes/fetch.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/fetch.ts b/routes/fetch.ts index 7597bb2..afd323e 100644 --- a/routes/fetch.ts +++ b/routes/fetch.ts @@ -4,7 +4,7 @@ import { checkUrl, addToFastQueue, addToSlowQueue, - getUserQueues, + getQueues, cancelSlowQueueItem, } from "../ytdlp"; import { getOrCreateUser } from "./helpers"; @@ -108,14 +108,14 @@ export async function handleFetchConfirm(req: Request, server: any): Promise Date: Fri, 6 Feb 2026 23:59:19 -0800 Subject: [PATCH 10/19] adding back in move handlers --- channel.ts | 38 ++++++++++++++++++++++++++++++++++++++ public/trackContainer.js | 13 ++++++++++++- routes/channels.ts | 8 +++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/channel.ts b/channel.ts index 78d191a..5de40a9 100644 --- a/channel.ts +++ b/channel.ts @@ -305,6 +305,44 @@ export class Channel { this.broadcast(); } + moveTracks(indices: number[], targetIndex: number) { + if (indices.length === 0) return; + + // Get the tracks being moved + const sorted = [...indices].sort((a, b) => a - b); + const tracksToMove = sorted.map(i => this.queue[i]).filter(Boolean); + if (tracksToMove.length === 0) return; + + const currentTrackId = this.currentTrack?.id; + + // Remove tracks from their current positions (from end to preserve indices) + for (let i = sorted.length - 1; i >= 0; i--) { + this.queue.splice(sorted[i], 1); + } + + // Adjust target index for removed items that were before it + let adjustedTarget = targetIndex; + for (const idx of sorted) { + if (idx < targetIndex) adjustedTarget--; + } + + // Insert at new position + this.queue.splice(adjustedTarget, 0, ...tracksToMove); + + // Update currentIndex to follow the currently playing track + if (currentTrackId) { + const newIndex = this.queue.findIndex(t => t.id === currentTrackId); + if (newIndex !== -1) { + this.currentIndex = newIndex; + } + } + + this.queueDirty = true; + this.persistQueue(); + this.persistState(); + this.broadcast(); + } + broadcast() { const now = Date.now(); const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000); diff --git a/public/trackContainer.js b/public/trackContainer.js index 30ad0b0..05ecc2b 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -276,6 +276,7 @@ // Drag start/end handlers - library always, queue/playlist with permissions const canDrag = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner); + console.log(`[Drag] wireTrackEvents: type=${type} canDrag=${canDrag} canEditQueue=${canEditQueue}`); if (canDrag) { div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); div.ondragend = (e) => handleDragEnd(e, div); @@ -283,6 +284,7 @@ // Drop handlers - queue and playlist accept drops if (canEditQueue && type === 'queue') { + console.log(`[Drag] Wiring drop handlers for queue track ${originalIndex}`); div.ondragover = (e) => handleDragOver(e, div, originalIndex); div.ondragleave = (e) => handleDragLeave(e, div); div.ondrop = (e) => handleDrop(e, div, originalIndex); @@ -339,6 +341,7 @@ } function handleDragStart(e, track, index, div) { + console.log(`[Drag] handleDragStart: type=${type} index=${index} track=${track.title || track.filename}`); isDragging = true; const trackId = track.id || track.filename; dragSource = type; @@ -415,20 +418,28 @@ } function handleDrop(e, div, index) { + console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`); e.preventDefault(); e.stopPropagation(); div.classList.remove("drop-above", "drop-below"); element.classList.remove("drop-target"); - if (dropTargetIndex === null) return; + if (dropTargetIndex === null) { + console.log(`[Drag] handleDrop: dropTargetIndex is null, aborting`); + return; + } if (type === 'queue') { if (dragSource === 'queue' && draggedIndices.length > 0) { // Reorder within queue const minDragged = Math.min(...draggedIndices); const maxDragged = Math.max(...draggedIndices); + console.log(`[Drag] Reorder check: dropTargetIndex=${dropTargetIndex} minDragged=${minDragged} maxDragged=${maxDragged}`); if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { + console.log(`[Drag] Calling reorderQueue(${draggedIndices}, ${dropTargetIndex})`); reorderQueue(draggedIndices, dropTargetIndex); + } else { + console.log(`[Drag] Skipping reorder - dropping on self`); } } else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { // Insert tracks from library or playlist diff --git a/routes/channels.ts b/routes/channels.ts index 71ec552..acfaf08 100644 --- a/routes/channels.ts +++ b/routes/channels.ts @@ -221,7 +221,7 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st try { const body = await req.json(); - const { add, remove, set, insertAt } = body; + const { add, remove, set, insertAt, move, to } = body; if (Array.isArray(set)) { const tracks = buildTracksFromIds(set, state.library); @@ -229,6 +229,12 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st return Response.json({ success: true, queueLength: channel.queue.length }); } + // Move/reorder tracks within queue + if (Array.isArray(move) && typeof to === "number") { + channel.moveTracks(move, to); + return Response.json({ success: true, queueLength: channel.queue.length }); + } + if (Array.isArray(remove) && remove.length > 0) { const indices = remove.filter((i: unknown) => typeof i === "number"); channel.removeTracksByIndex(indices); From cd4237dcbe6c9ec55cc7a40b6989a971b824a442 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:05:43 -0800 Subject: [PATCH 11/19] playlist skipping --- init.ts | 56 ++++++++++++++++++++++++++++++++++++++++ public/trackContainer.js | 1 + ytdlp.ts | 30 +++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/init.ts b/init.ts index e7cc79f..f5465ac 100644 --- a/init.ts +++ b/init.ts @@ -17,6 +17,8 @@ import { initYtdlp, setProgressCallback, setTrackReadyCallback, + skipSlowQueueItem, + getQueuedSlowItems, type QueueItem, } from "./ytdlp"; @@ -206,6 +208,60 @@ export async function init(): Promise { library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title }); }); + // Prescan slow queue to find tracks already in library + function prescanSlowQueue() { + const queuedItems = getQueuedSlowItems(); + if (queuedItems.length === 0) return; + + const tracks = library.getAllTracks(); + if (tracks.length === 0) return; + + for (const item of queuedItems) { + const itemTitle = normalizeForMatch(item.title); + + // Check if any library track matches + for (const track of tracks) { + const trackTitle = normalizeForMatch(track.title || ""); + const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); + + const matches = + (trackTitle && trackTitle === itemTitle) || + (trackFilename && trackFilename === itemTitle) || + (trackTitle && itemTitle && trackTitle.includes(itemTitle)) || + (trackTitle && itemTitle && itemTitle.includes(trackTitle)) || + (trackFilename && itemTitle && trackFilename.includes(itemTitle)) || + (trackFilename && itemTitle && itemTitle.includes(trackFilename)); + + if (matches) { + console.log(`[ytdlp] Prescan: "${item.title}" already exists as "${track.title || track.filename}"`); + + // Skip download and add to playlist + const skipped = skipSlowQueueItem(item.id, track.id); + if (skipped && skipped.playlistId) { + try { + addTracksToPlaylist(skipped.playlistId, [track.id]); + sendToUser(skipped.userId, { + type: "toast", + message: `Already in library, added to: ${skipped.playlistName}`, + toastType: "info" + }); + } catch (e) { + console.error(`[ytdlp] Failed to add existing track to playlist:`, e); + } + } + break; + } + } + } + } + + // Run prescan periodically (every 30 seconds) + setInterval(prescanSlowQueue, 30000); + // Also run once after initial scan completes + library.onScanComplete(() => { + setTimeout(prescanSlowQueue, 1000); + }); + // Load channels from database const savedChannels = loadAllChannels(); let hasDefault = false; diff --git a/public/trackContainer.js b/public/trackContainer.js index 05ecc2b..da84765 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -760,6 +760,7 @@ else if (res.ok) { M.showToast("Removed"); clearSelection(); + render(); } else { M.showToast("Failed to remove from queue", "error"); } diff --git a/ytdlp.ts b/ytdlp.ts index 2fad06e..c428556 100644 --- a/ytdlp.ts +++ b/ytdlp.ts @@ -525,6 +525,36 @@ function notifyProgress(item: QueueItem): void { } } +// Mark a slow queue item as skipped (already in library) +export function skipSlowQueueItem(id: string, trackId: string): QueueItem | null { + const item = slowQueue.find(i => i.id === id && i.status === "queued"); + if (!item) return null; + + item.status = "complete"; + item.progress = 100; + item.completedAt = Date.now(); + item.trackId = trackId; + + // Update database + updateSlowQueueItem(id, { + status: "complete", + progress: 100, + completedAt: Math.floor(item.completedAt / 1000) + }); + + notifyProgress(item); + + // Remove from queue after brief delay + setTimeout(() => removeFromQueue(item), 1000); + + return item; +} + +// Get queued items from slow queue (for prescan) +export function getQueuedSlowItems(): QueueItem[] { + return slowQueue.filter(i => i.status === "queued"); +} + // Cleanup old completed/failed items export function cleanupOldItems(maxAge: number = 3600000): void { const now = Date.now(); From 3f7bd2ec1cadfa28223249c1073c2347c159ae50 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:12:18 -0800 Subject: [PATCH 12/19] saving --- db.ts | 119 ++++++++++++++++++++++++++++++++++++++++++++ init.ts | 31 +++++++++--- routes/playlists.ts | 28 +++++++---- 3 files changed, 161 insertions(+), 17 deletions(-) diff --git a/db.ts b/db.ts index 365a464..3bb51d6 100644 --- a/db.ts +++ b/db.ts @@ -685,6 +685,125 @@ export function removeTrackFromPlaylist(playlistId: string, position: number): v } } +export function removeTracksFromPlaylist(playlistId: string, positions: number[]): void { + if (positions.length === 0) return; + + const now = Math.floor(Date.now() / 1000); + // Sort descending to remove from end first (preserves indices) + const sorted = [...positions].sort((a, b) => b - a); + + db.query("BEGIN").run(); + try { + for (const pos of sorted) { + db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos); + // Reorder remaining tracks + db.query(` + UPDATE playlist_tracks + SET position = position - 1 + WHERE playlist_id = ? AND position > ? + `).run(playlistId, 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 movePlaylistTracks(playlistId: string, fromPositions: number[], toPosition: number): void { + if (fromPositions.length === 0) return; + + const now = Math.floor(Date.now() / 1000); + const sorted = [...fromPositions].sort((a, b) => a - b); + + db.query("BEGIN").run(); + try { + // Get tracks to move + const tracksToMove: string[] = []; + for (const pos of sorted) { + const row = db.query( + "SELECT track_id FROM playlist_tracks WHERE playlist_id = ? AND position = ?" + ).get(playlistId, pos) as { track_id: string } | null; + if (row) tracksToMove.push(row.track_id); + } + + if (tracksToMove.length === 0) { + db.query("ROLLBACK").run(); + return; + } + + // Remove tracks from current positions (from end to preserve indices) + for (let i = sorted.length - 1; i >= 0; i--) { + const pos = sorted[i]; + db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos); + db.query(` + UPDATE playlist_tracks + SET position = position - 1 + WHERE playlist_id = ? AND position > ? + `).run(playlistId, pos); + } + + // Adjust target for removed items + let adjustedTarget = toPosition; + for (const pos of sorted) { + if (pos < toPosition) adjustedTarget--; + } + + // Make room at target position + db.query(` + UPDATE playlist_tracks + SET position = position + ? + WHERE playlist_id = ? AND position >= ? + `).run(tracksToMove.length, playlistId, adjustedTarget); + + // Insert tracks at new position + const insert = db.query( + "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)" + ); + for (let i = 0; i < tracksToMove.length; i++) { + insert.run(playlistId, tracksToMove[i], adjustedTarget + 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 insertTracksToPlaylistAt(playlistId: string, trackIds: string[], position: number): void { + if (trackIds.length === 0) return; + + const now = Math.floor(Date.now() / 1000); + + db.query("BEGIN").run(); + try { + // Make room at position + db.query(` + UPDATE playlist_tracks + SET position = position + ? + WHERE playlist_id = ? AND position >= ? + `).run(trackIds.length, playlistId, position); + + // Insert tracks + 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], position + 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 generatePlaylistShareToken(playlistId: string): string { const token = crypto.randomUUID().slice(0, 12); db.query("UPDATE playlists SET share_token = ? WHERE id = ?").run(token, playlistId); diff --git a/init.ts b/init.ts index f5465ac..0e5fe72 100644 --- a/init.ts +++ b/init.ts @@ -161,19 +161,26 @@ export async function init(): Promise { const trackTitle = normalizeForMatch(track.title || ""); const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); // Remove extension + // Skip if both title and filename are too short + if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) return; + console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`); for (const [pendingTitle, pending] of pendingPlaylistTracks) { const normalizedPending = normalizeForMatch(pendingTitle); + // Skip if pending title is too short + if (!normalizedPending || normalizedPending.length < 5) continue; + // Match by title or filename (yt-dlp uses title as filename) + // Require exact match or very high overlap const matches = (trackTitle && trackTitle === normalizedPending) || (trackFilename && trackFilename === normalizedPending) || - (trackTitle && normalizedPending && trackTitle.includes(normalizedPending)) || - (trackTitle && normalizedPending && normalizedPending.includes(trackTitle)) || - (trackFilename && normalizedPending && trackFilename.includes(normalizedPending)) || - (trackFilename && normalizedPending && normalizedPending.includes(trackFilename)); + (trackTitle && normalizedPending && trackTitle.includes(normalizedPending) && normalizedPending.length >= trackTitle.length * 0.8) || + (trackTitle && normalizedPending && normalizedPending.includes(trackTitle) && trackTitle.length >= normalizedPending.length * 0.8) || + (trackFilename && normalizedPending && trackFilename.includes(normalizedPending) && normalizedPending.length >= trackFilename.length * 0.8) || + (trackFilename && normalizedPending && normalizedPending.includes(trackFilename) && trackFilename.length >= normalizedPending.length * 0.8); console.log(`[ytdlp] vs pending="${normalizedPending}" → ${matches ? "MATCH" : "no match"}`); @@ -219,18 +226,26 @@ export async function init(): Promise { for (const item of queuedItems) { const itemTitle = normalizeForMatch(item.title); + // Skip if title is too short (avoid false matches) + if (!itemTitle || itemTitle.length < 5) continue; + // Check if any library track matches for (const track of tracks) { const trackTitle = normalizeForMatch(track.title || ""); const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); + // Skip if both track title and filename are too short + if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) continue; + + // Require exact match or very high overlap (not just substring) const matches = (trackTitle && trackTitle === itemTitle) || (trackFilename && trackFilename === itemTitle) || - (trackTitle && itemTitle && trackTitle.includes(itemTitle)) || - (trackTitle && itemTitle && itemTitle.includes(trackTitle)) || - (trackFilename && itemTitle && trackFilename.includes(itemTitle)) || - (trackFilename && itemTitle && itemTitle.includes(trackFilename)); + // Only allow includes if the shorter string is at least 80% of the longer + (trackTitle && itemTitle && trackTitle.includes(itemTitle) && itemTitle.length >= trackTitle.length * 0.8) || + (trackTitle && itemTitle && itemTitle.includes(trackTitle) && trackTitle.length >= itemTitle.length * 0.8) || + (trackFilename && itemTitle && trackFilename.includes(itemTitle) && itemTitle.length >= trackFilename.length * 0.8) || + (trackFilename && itemTitle && itemTitle.includes(trackFilename) && trackFilename.length >= itemTitle.length * 0.8); if (matches) { console.log(`[ytdlp] Prescan: "${item.title}" already exists as "${track.title || track.filename}"`); diff --git a/routes/playlists.ts b/routes/playlists.ts index 20d9afd..b4bb7ad 100644 --- a/routes/playlists.ts +++ b/routes/playlists.ts @@ -9,6 +9,9 @@ import { setPlaylistTracks, addTracksToPlaylist, removeTrackFromPlaylist, + removeTracksFromPlaylist, + movePlaylistTracks, + insertTracksToPlaylistAt, generatePlaylistShareToken, removePlaylistShareToken, findUserById, @@ -154,7 +157,7 @@ export async function handleModifyPlaylistTracks(req: Request, server: any, play return Response.json({ error: "Not your playlist" }, { status: 403 }); } - let body: { add?: string[]; remove?: number[]; set?: string[] }; + let body: { add?: string[]; remove?: number[]; set?: string[]; move?: number[]; to?: number; insertAt?: number }; try { body = await req.json(); } catch { @@ -167,17 +170,24 @@ export async function handleModifyPlaylistTracks(req: Request, server: any, play 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); - } + // Move/reorder tracks + if (body.move?.length && typeof body.to === "number") { + movePlaylistTracks(playlistId, body.move, body.to); + return Response.json({ ok: true }); } - // Add tracks + // Remove tracks by position (bulk operation) + if (body.remove?.length) { + removeTracksFromPlaylist(playlistId, body.remove); + } + + // Add tracks (at specific position or at end) if (body.add?.length) { - addTracksToPlaylist(playlistId, body.add); + if (typeof body.insertAt === "number") { + insertTracksToPlaylistAt(playlistId, body.add, body.insertAt); + } else { + addTracksToPlaylist(playlistId, body.add); + } } return Response.json({ ok: true }); From 424873e7b0782cfbe9bbca2e64dedccd29a9ab55 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:14:05 -0800 Subject: [PATCH 13/19] saving --- public/styles.css | 6 ++++-- public/upload.js | 17 +++++++++++++++++ routes/fetch.ts | 12 ++++++++++++ routes/index.ts | 4 ++++ ytdlp.ts | 30 ++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/public/styles.css b/public/styles.css index 61b1c62..4422778 100644 --- a/public/styles.css +++ b/public/styles.css @@ -72,9 +72,11 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .task-item.complete .task-bar { background: #4e8; } .slow-queue-section { margin-top: 0.5rem; border-top: 1px solid #333; padding-top: 0.5rem; } .slow-queue-section.hidden { display: none; } -.slow-queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0 0.2rem; } +.slow-queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0 0.2rem; gap: 0.5rem; } .slow-queue-title { font-size: 0.75rem; color: #888; font-weight: 500; } -.slow-queue-timer { font-size: 0.7rem; color: #6af; } +.slow-queue-timer { font-size: 0.7rem; color: #6af; flex: 1; } +.slow-queue-cancel-all { background: none; border: 1px solid #633; color: #a66; font-size: 0.65rem; padding: 0.15rem 0.4rem; border-radius: 3px; cursor: pointer; transition: all 0.15s; } +.slow-queue-cancel-all:hover { background: #422; border-color: #844; color: #e88; } .slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 200px; overflow-y: auto; } .slow-queue-playlist-header { font-size: 0.7rem; color: #888; padding: 0.3rem 0.2rem 0.15rem; margin-top: 0.2rem; border-top: 1px solid #2a2a2a; } .slow-queue-playlist-header:first-child { border-top: none; margin-top: 0; } diff --git a/public/upload.js b/public/upload.js index 45d4854..0743d7a 100644 --- a/public/upload.js +++ b/public/upload.js @@ -222,10 +222,27 @@
Playlist Queue +
`; + // Wire up cancel all button + slowQueueSection.querySelector(".slow-queue-cancel-all").onclick = async () => { + try { + const res = await fetch("/api/fetch", { method: "DELETE" }); + if (res.ok) { + const data = await res.json(); + M.showToast(data.message); + pollSlowQueue(); + } else { + M.showToast("Failed to cancel", "error"); + } + } catch (e) { + M.showToast("Failed to cancel", "error"); + } + }; + // Insert before tasks-empty tasksEmpty.parentNode.insertBefore(slowQueueSection, tasksEmpty); return slowQueueSection; diff --git a/routes/fetch.ts b/routes/fetch.ts index afd323e..f79b0bb 100644 --- a/routes/fetch.ts +++ b/routes/fetch.ts @@ -6,6 +6,7 @@ import { addToSlowQueue, getQueues, cancelSlowQueueItem, + cancelAllSlowQueueItems, } from "../ytdlp"; import { getOrCreateUser } from "./helpers"; import { createPlaylist, generateUniquePlaylistName } from "../db"; @@ -133,3 +134,14 @@ export function handleCancelFetchItem(req: Request, server: any, itemId: string) return Response.json({ error: "Cannot cancel item (not found, not owned, or already downloading)" }, { status: 400, headers }); } } + +// DELETE /api/fetch - cancel all slow queue items for user +export function handleCancelAllFetchItems(req: Request, server: any): Response { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + const cancelled = cancelAllSlowQueueItems(user.id); + return Response.json({ message: `Cancelled ${cancelled} items`, cancelled }, { headers }); +} diff --git a/routes/index.ts b/routes/index.ts index 08a0cf8..351e57d 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -41,6 +41,7 @@ import { handleFetchConfirm, handleGetFetchQueue, handleCancelFetchItem, + handleCancelAllFetchItems, } from "./fetch"; // Playlist routes @@ -150,6 +151,9 @@ export function createRouter() { if (path === "/api/fetch" && req.method === "GET") { return handleGetFetchQueue(req, server); } + if (path === "/api/fetch" && req.method === "DELETE") { + return handleCancelAllFetchItems(req, server); + } const fetchCancelMatch = path.match(/^\/api\/fetch\/([^/]+)$/); if (fetchCancelMatch && req.method === "DELETE") { return handleCancelFetchItem(req, server, fetchCancelMatch[1]); diff --git a/ytdlp.ts b/ytdlp.ts index c428556..836729c 100644 --- a/ytdlp.ts +++ b/ytdlp.ts @@ -518,6 +518,36 @@ export function cancelSlowQueueItem(id: string, userId: number): boolean { return true; } +// Cancel all queued items in slow queue for a user +export function cancelAllSlowQueueItems(userId: number): number { + const items = slowQueue.filter(i => i.userId === userId && i.status === "queued"); + let cancelled = 0; + + for (const item of items) { + item.status = "cancelled"; + item.completedAt = Date.now(); + + updateSlowQueueItem(item.id, { + status: "cancelled", + completedAt: Math.floor(item.completedAt / 1000) + }); + + notifyProgress(item); + cancelled++; + } + + // Remove all cancelled items after brief delay + setTimeout(() => { + for (let i = slowQueue.length - 1; i >= 0; i--) { + if (slowQueue[i].status === "cancelled") { + slowQueue.splice(i, 1); + } + } + }, 1000); + + return cancelled; +} + // Notify progress callback function notifyProgress(item: QueueItem): void { if (onProgress) { From 11f5804e049e53cb67ca3a92c14e939fa467bb6a Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:27:14 -0800 Subject: [PATCH 14/19] changing loop names --- public/controls.js | 6 +++--- public/styles.css | 2 ++ public/upload.js | 28 +++++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/public/controls.js b/public/controls.js index a73e85b..f6c50f9 100644 --- a/public/controls.js +++ b/public/controls.js @@ -110,9 +110,9 @@ // Playback mode button const modeLabels = { - "once": "once", - "repeat-all": "repeat", - "repeat-one": "single", + "once": "loop(off)", + "repeat-all": "loop(all)", + "repeat-one": "loop(one)", "shuffle": "shuffle" }; const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"]; diff --git a/public/styles.css b/public/styles.css index 4422778..96626b5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -87,6 +87,8 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .slow-queue-cancel { background: none; border: none; color: #666; cursor: pointer; padding: 0 0.2rem; font-size: 0.7rem; opacity: 0; transition: opacity 0.15s; } .slow-queue-item:hover .slow-queue-cancel { opacity: 1; } .slow-queue-cancel:hover { color: #e44; } +.slow-queue-show-toggle { background: none; border: none; color: #68a; font-size: 0.7rem; padding: 0.3rem; cursor: pointer; width: 100%; text-align: center; } +.slow-queue-show-toggle:hover { color: #8af; text-decoration: underline; } .scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .scan-progress.hidden { display: none; } .scan-progress.complete { color: #4e8; background: #1a2a1a; } diff --git a/public/upload.js b/public/upload.js index 0743d7a..66dc597 100644 --- a/public/upload.js +++ b/public/upload.js @@ -256,12 +256,16 @@ return `${secs}s`; } + const QUEUE_PREVIEW_COUNT = 5; + let showAllQueue = false; + function updateSlowQueueDisplay(slowQueue, slowQueueNextIn) { const section = createSlowQueueSection(); const queuedItems = slowQueue.filter(i => i.status === "queued"); if (queuedItems.length === 0) { section.classList.add("hidden"); + showAllQueue = false; updateTasksEmpty(); return; } @@ -272,9 +276,13 @@ const timerEl = section.querySelector(".slow-queue-timer"); timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`; + // Determine how many items to show + const itemsToShow = showAllQueue ? queuedItems : queuedItems.slice(0, QUEUE_PREVIEW_COUNT); + const hiddenCount = queuedItems.length - itemsToShow.length; + // Group items by playlist const byPlaylist = new Map(); - for (const item of queuedItems) { + for (const item of itemsToShow) { const key = item.playlistId || "__none__"; if (!byPlaylist.has(key)) { byPlaylist.set(key, { name: item.playlistName, items: [] }); @@ -302,6 +310,15 @@ }).join(""); } + // Add show more/less button if needed + if (queuedItems.length > QUEUE_PREVIEW_COUNT) { + if (showAllQueue) { + html += ``; + } else { + html += ``; + } + } + listEl.innerHTML = html; // Add cancel handlers @@ -324,6 +341,15 @@ }; }); + // Add show more/less handler + const toggleBtn = listEl.querySelector(".slow-queue-show-toggle"); + if (toggleBtn) { + toggleBtn.onclick = () => { + showAllQueue = !showAllQueue; + updateSlowQueueDisplay(slowQueue, slowQueueNextIn); + }; + } + updateTasksEmpty(); } From a87e9a8fd12e0cd0d125dd6f0df9abc5d0cc3256 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:42:34 -0800 Subject: [PATCH 15/19] saving --- public/index.html | 4 ++-- public/trackContainer.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/index.html b/public/index.html index b75990b..bc914c5 100644 --- a/public/index.html +++ b/public/index.html @@ -118,8 +118,8 @@
Select a playlist
diff --git a/public/trackContainer.js b/public/trackContainer.js index da84765..5947c79 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -274,8 +274,8 @@ showContextMenu(e, track, originalIndex, canEditQueue); }; - // Drag start/end handlers - library always, queue/playlist with permissions - const canDrag = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner); + // Drag start/end handlers - library/playlist always (read access), queue needs edit permission + const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue); console.log(`[Drag] wireTrackEvents: type=${type} canDrag=${canDrag} canEditQueue=${canEditQueue}`); if (canDrag) { div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); From c02fd261e419b4c0e924deb39b22bca8b5695e7f Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:44:12 -0800 Subject: [PATCH 16/19] saving --- public/trackContainer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/trackContainer.js b/public/trackContainer.js index 5947c79..40ddb24 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -107,8 +107,8 @@ const isCached = M.cachedTracks.has(trackId); const isActive = type === 'queue' && index === M.currentIndex; - // Library tracks are always draggable, queue tracks need edit permission - const isDraggable = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner); + // Library/playlist always draggable (read access), queue needs edit permission + const isDraggable = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue); const div = M.trackComponent.render(track, { view: type, From 336e5db0bebcee30b993ea78bd5467522bd14b4a Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:52:39 -0800 Subject: [PATCH 17/19] saving --- public/trackContainer.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/public/trackContainer.js b/public/trackContainer.js index 40ddb24..b89d284 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -276,7 +276,6 @@ // Drag start/end handlers - library/playlist always (read access), queue needs edit permission const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue); - console.log(`[Drag] wireTrackEvents: type=${type} canDrag=${canDrag} canEditQueue=${canEditQueue}`); if (canDrag) { div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); div.ondragend = (e) => handleDragEnd(e, div); @@ -284,7 +283,6 @@ // Drop handlers - queue and playlist accept drops if (canEditQueue && type === 'queue') { - console.log(`[Drag] Wiring drop handlers for queue track ${originalIndex}`); div.ondragover = (e) => handleDragOver(e, div, originalIndex); div.ondragleave = (e) => handleDragLeave(e, div); div.ondrop = (e) => handleDrop(e, div, originalIndex); From 6901f75dfba1b12563f69957d8767a0064a4ee8e Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sat, 7 Feb 2026 00:57:59 -0800 Subject: [PATCH 18/19] saving --- public/controls.js | 3 +++ public/trackContainer.js | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/public/controls.js b/public/controls.js index f6c50f9..2b665bc 100644 --- a/public/controls.js +++ b/public/controls.js @@ -92,6 +92,9 @@ // Play/pause button M.$("#status-icon").onclick = togglePlayback; + // Expose jumpToTrack for double-click handling + M.jumpToTrack = jumpToTrack; + // Prev/next buttons M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1); M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1); diff --git a/public/trackContainer.js b/public/trackContainer.js index b89d284..7b69299 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -268,6 +268,16 @@ render(); }; + // Double-click - queue: jump to track, library/playlist: add to queue + div.ondblclick = (e) => { + if (e.target.closest('.track-actions')) return; + if (type === 'queue') { + M.jumpToTrack(originalIndex); + } else { + addToQueue([trackId]); + } + }; + // Context menu div.oncontextmenu = (e) => { e.preventDefault(); From ce328acd03592816b106b3964f21479a5ae5f47f Mon Sep 17 00:00:00 2001 From: peterino2 Date: Sun, 8 Feb 2026 22:55:38 -0800 Subject: [PATCH 19/19] queue diff estimates --- public/channelSync.js | 3 +++ public/controls.js | 1 + public/index.html | 2 +- public/queue.js | 19 +++++++++++++++++++ public/styles.css | 1 + public/trackContainer.js | 7 +++++-- 6 files changed, 30 insertions(+), 3 deletions(-) diff --git a/public/channelSync.js b/public/channelSync.js index 14214c7..dab7142 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -404,6 +404,9 @@ M.setTrackTitle(data.track.title); M.loadingSegments.clear(); + // Auto-scroll queue to current track + setTimeout(() => M.scrollToCurrentTrack(), 100); + // Debug: log cache state for this track const trackCache = M.trackCaches.get(trackId); console.log("[Playback] Starting track:", data.track.title, { diff --git a/public/controls.js b/public/controls.js index 2b665bc..8b70d45 100644 --- a/public/controls.js +++ b/public/controls.js @@ -64,6 +64,7 @@ M.localTimestamp = 0; M.audio.play(); M.renderQueue(); + setTimeout(() => M.scrollToCurrentTrack(), 100); } } diff --git a/public/index.html b/public/index.html index bc914c5..671b35e 100644 --- a/public/index.html +++ b/public/index.html @@ -133,7 +133,7 @@
-

Queue

+

Queue

diff --git a/public/queue.js b/public/queue.js index 1aa7745..a0d7ebb 100644 --- a/public/queue.js +++ b/public/queue.js @@ -231,8 +231,27 @@ if (queueContainer) { queueContainer.render(); } + updateQueueDuration(); }; + function updateQueueDuration() { + const el = M.$("#queue-duration"); + if (!el) return; + const totalSecs = M.queue.reduce((sum, t) => sum + (t.duration || 0), 0); + if (totalSecs === 0) { + el.textContent = ""; + return; + } + const hours = Math.floor(totalSecs / 3600); + const mins = Math.floor((totalSecs % 3600) / 60); + const secs = Math.floor(totalSecs % 60); + let text = ""; + if (hours > 0) text = `${hours}h ${mins}m`; + else if (mins > 0) text = `${mins}m ${secs}s`; + else text = `${secs}s`; + el.textContent = `(${M.queue.length} tracks · ${text})`; + } + M.renderLibrary = function() { initContainers(); if (libraryContainer) { diff --git a/public/styles.css b/public/styles.css index 96626b5..f81da0b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -129,6 +129,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .upload-dropzone.hidden { display: none; } .dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; } #queue-title { margin: 0 0 0.3rem 0; } +#queue-duration { font-size: 0.75rem; color: #888; font-weight: normal; } .now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .now-playing-bar:hover { background: #2a3a2a; } .now-playing-bar.hidden { display: none; } diff --git a/public/trackContainer.js b/public/trackContainer.js index 7b69299..3d8cc6e 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -261,16 +261,19 @@ const trackId = track.id || track.filename; const index = type === 'queue' ? originalIndex : filteredIndex; - // Click - handle selection (Ctrl = toggle, Shift = range, plain = select only this) + // Click/double-click handling - delay render to allow double-click detection + let clickTimeout = null; div.onclick = (e) => { if (e.target.closest('.track-actions')) return; toggleSelection(index, trackId, e.shiftKey, e.ctrlKey || e.metaKey); - render(); + clearTimeout(clickTimeout); + clickTimeout = setTimeout(() => render(), 200); }; // Double-click - queue: jump to track, library/playlist: add to queue div.ondblclick = (e) => { if (e.target.closest('.track-actions')) return; + clearTimeout(clickTimeout); if (type === 'queue') { M.jumpToTrack(originalIndex); } else {