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/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/db.ts b/db.ts index ab8eb8d..3bb51d6 100644 --- a/db.ts +++ b/db.ts @@ -417,3 +417,551 @@ 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 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); + return token; +} + +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..0e5fe72 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,10 @@ import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast"; import { initYtdlp, setProgressCallback, + setTrackReadyCallback, + skipSlowQueueItem, + getQueuedSlowItems, + type QueueItem, } from "./ytdlp"; // Auto-discover tracks if queue is empty @@ -98,6 +103,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 +113,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,16 +139,144 @@ 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 + + // 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) && 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"}`); + + 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" }); 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); + + // 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) || + // 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}"`); + + // 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/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/channelSync.js b/public/channelSync.js index a4b955a..dab7142 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; @@ -405,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 a73e85b..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); } } @@ -92,6 +93,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); @@ -110,9 +114,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/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..dc163ce Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html index e9be1c1..671b35e 100644 --- a/public/index.html +++ b/public/index.html @@ -68,6 +68,7 @@
+
@@ -100,6 +101,31 @@
Drop audio files here
+
+
+
+
+

My Playlists

+
+
+
+

Shared

+
+
+ +
+
+
+ Select a playlist + +
+
+
+
+
No active tasks
@@ -107,7 +133,7 @@
-

Queue

+

Queue

@@ -154,10 +180,13 @@ + + + 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..6b947c8 --- /dev/null +++ b/public/playlists.js @@ -0,0 +1,536 @@ +// 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'); + } + } + + // Playlist tracks container instance + let playlistContainer = null; + + function renderPlaylistContents() { + const header = $('#selected-playlist-name'); + const actions = $('#playlist-actions'); + const containerEl = $('#playlist-tracks'); + + if (!selectedPlaylist) { + header.textContent = 'Select a playlist'; + actions.classList.add('hidden'); + containerEl.innerHTML = ''; + return; + } + + header.textContent = selectedPlaylist.name; + actions.classList.remove('hidden'); + + const isMine = myPlaylists.some(p => p.id === selectedPlaylistId); + + // Create or update container (even for empty playlists, to enable drag-drop) + playlistContainer = M.trackContainer.createContainer({ + type: 'playlist', + element: containerEl, + getTracks: () => { + return selectedPlaylist.trackIds.map((id, i) => { + const track = M.library.find(t => t.id === id); + return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i }; + }); + }, + isPlaylistOwner: isMine, + playlistId: selectedPlaylistId + }); + + playlistContainer.render(); + } + + function reloadCurrentPlaylist() { + if (selectedPlaylistId) { + selectPlaylist(selectedPlaylistId); + } + } + + function showPlaylistContextMenu(e, playlistId, isMine) { + e.preventDefault(); + M.contextMenu.hide(); + + const playlist = isMine + ? myPlaylists.find(p => p.id === playlistId) + : sharedPlaylists.find(p => p.id === playlistId); + if (!playlist) return; + + const items = []; + + // Add to queue options + items.push({ + label: '▶ Add to Queue', + action: () => addPlaylistToQueue(playlistId) + }); + items.push({ + label: '⏭ Play Next', + action: () => addPlaylistToQueue(playlistId, true) + }); + + items.push({ separator: true }); + + if (isMine) { + // Rename + items.push({ + label: '✏️ Rename', + action: () => startRenamePlaylist(playlistId) + }); + + // Share/unshare + if (playlist.isPublic) { + items.push({ + label: '🔒 Make Private', + action: () => togglePlaylistPublic(playlistId, false) + }); + } else { + items.push({ + label: '🌐 Make Public', + action: () => togglePlaylistPublic(playlistId, true) + }); + } + + items.push({ separator: true }); + + // Delete + items.push({ + label: '🗑️ Delete', + action: () => deletePlaylist(playlistId), + className: 'danger' + }); + } else { + // Copy to my playlists + items.push({ + label: '📋 Copy to My Playlists', + action: () => copyPlaylist(playlistId) + }); + } + + M.contextMenu.show(e, items); + } + + async function addPlaylistToQueue(playlistId, playNext = false) { + const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId); + if (!playlist || playlist.trackIds.length === 0) { + showToast('Playlist is empty', 'error'); + return; + } + + try { + const body = playNext + ? { add: playlist.trackIds, insertAt: M.currentIndex + 1 } + : { add: playlist.trackIds }; + + const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) throw new Error('Failed to add to queue'); + showToast(`Added ${playlist.trackIds.length} tracks to queue`); + } catch (err) { + console.error('Failed to add playlist to queue:', err); + showToast('Failed to add to queue', 'error'); + } + } + + async function addTracksToQueue(trackIds, playNext = false) { + try { + const body = playNext + ? { add: trackIds, insertAt: M.currentIndex + 1 } + : { add: trackIds }; + + const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) throw new Error('Failed to add to queue'); + showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to queue`); + } catch (err) { + console.error('Failed to add to queue:', err); + showToast('Failed to add to queue', 'error'); + } + } + + function playTrackFromPlaylist(trackId) { + // Desynced playback of single track + if (M.playDirectTrack) { + M.playDirectTrack(trackId); + } + } + + async function createPlaylist(name) { + try { + const res = await fetch('/api/playlists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + if (!res.ok) throw new Error('Failed to create playlist'); + const playlist = await res.json(); + showToast(`Created playlist "${name}"`); + await loadPlaylists(); + selectPlaylist(playlist.id); + return playlist; + } catch (err) { + console.error('Failed to create playlist:', err); + showToast('Failed to create playlist', 'error'); + return null; + } + } + + async function deletePlaylist(playlistId) { + const playlist = myPlaylists.find(p => p.id === playlistId); + if (!playlist) return; + + try { + const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete playlist'); + showToast(`Deleted playlist "${playlist.name}"`); + + if (selectedPlaylistId === playlistId) { + selectedPlaylistId = null; + selectedPlaylist = null; + renderPlaylistContents(); + } + await loadPlaylists(); + } catch (err) { + console.error('Failed to delete playlist:', err); + showToast('Failed to delete playlist', 'error'); + } + } + + function startRenamePlaylist(playlistId) { + const el = document.querySelector(`.playlist-item[data-id="${playlistId}"] .playlist-name`); + if (!el) return; + + const playlist = myPlaylists.find(p => p.id === playlistId); + if (!playlist) return; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'playlist-rename-input'; + input.value = playlist.name; + + const originalText = el.textContent; + el.textContent = ''; + el.appendChild(input); + input.focus(); + input.select(); + + const finish = async (save) => { + const newName = input.value.trim(); + el.textContent = save && newName ? newName : originalText; + + if (save && newName && newName !== originalText) { + try { + const res = await fetch(`/api/playlists/${playlistId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName }) + }); + if (!res.ok) throw new Error('Failed to rename'); + showToast(`Renamed to "${newName}"`); + await loadPlaylists(); + if (selectedPlaylistId === playlistId && selectedPlaylist) { + selectedPlaylist.name = newName; + renderPlaylistContents(); + } + } catch (err) { + el.textContent = originalText; + showToast('Failed to rename playlist', 'error'); + } + } + }; + + input.onblur = () => finish(true); + input.onkeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } else if (e.key === 'Escape') { + finish(false); + } + }; + } + + async function togglePlaylistPublic(playlistId, isPublic) { + try { + const res = await fetch(`/api/playlists/${playlistId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isPublic }) + }); + if (!res.ok) throw new Error('Failed to update'); + showToast(isPublic ? 'Playlist is now public' : 'Playlist is now private'); + await loadPlaylists(); + } catch (err) { + showToast('Failed to update playlist', 'error'); + } + } + + async function generateShareLink(playlistId) { + try { + const res = await fetch(`/api/playlists/${playlistId}/share`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to generate link'); + const data = await res.json(); + const url = `${window.location.origin}/playlist/${data.shareToken}`; + await navigator.clipboard.writeText(url); + showToast('Share link copied to clipboard'); + await loadPlaylists(); + } catch (err) { + showToast('Failed to generate share link', 'error'); + } + } + + function copyShareLink(token) { + const url = `${window.location.origin}/playlist/${token}`; + navigator.clipboard.writeText(url).then(() => { + showToast('Share link copied to clipboard'); + }); + } + + async function copyPlaylist(playlistId) { + const playlist = sharedPlaylists.find(p => p.id === playlistId); + if (!playlist) return; + + try { + if (playlist.shareToken) { + const res = await fetch(`/api/playlists/shared/${playlist.shareToken}`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to copy'); + } else { + // Create new playlist and copy tracks + const newPlaylist = await createPlaylist(`${playlist.name} (Copy)`); + if (newPlaylist) { + await fetch(`/api/playlists/${newPlaylist.id}/tracks`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ set: playlist.trackIds }) + }); + } + } + showToast(`Copied "${playlist.name}" to your playlists`); + await loadPlaylists(); + } catch (err) { + showToast('Failed to copy playlist', 'error'); + } + } + + async function removeTrackFromPlaylist(index) { + if (!selectedPlaylistId) return; + + try { + const res = await fetch(`/api/playlists/${selectedPlaylistId}/tracks`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ remove: [index] }) + }); + if (!res.ok) throw new Error('Failed to remove track'); + showToast('Track removed from playlist'); + await selectPlaylist(selectedPlaylistId); + await loadPlaylists(); + } catch (err) { + showToast('Failed to remove track', 'error'); + } + } + + // Add tracks to playlist (used from library/queue context menu) + async function addTracksToPlaylist(playlistId, trackIds) { + try { + const res = await fetch(`/api/playlists/${playlistId}/tracks`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ add: trackIds }) + }); + if (!res.ok) throw new Error('Failed to add tracks'); + + const playlist = myPlaylists.find(p => p.id === playlistId); + showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to "${playlist?.name || 'playlist'}"`); + + if (selectedPlaylistId === playlistId) { + await selectPlaylist(playlistId); + } + await loadPlaylists(); + } catch (err) { + showToast('Failed to add tracks to playlist', 'error'); + } + } + + // Show "Add to Playlist" submenu + function showAddToPlaylistMenu(trackIds) { + if (myPlaylists.length === 0) { + showToast('Create a playlist first', 'info'); + return null; + } + + return myPlaylists.map(p => ({ + label: p.name, + action: () => addTracksToPlaylist(p.id, trackIds) + })); + } + + function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); + } + + function initPlaylists() { + // New playlist button + const btnNew = $('#btn-new-playlist'); + if (btnNew) { + btnNew.onclick = () => { + // Inline input for new playlist name + const container = $('#my-playlists'); + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'new-playlist-input'; + input.placeholder = 'Playlist name...'; + container.insertBefore(input, container.firstChild); + input.focus(); + + const finish = async (create) => { + const name = input.value.trim(); + input.remove(); + if (create && name) { + await createPlaylist(name); + } + }; + + input.onblur = () => finish(true); + input.onkeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } else if (e.key === 'Escape') { + finish(false); + } + }; + }; + } + + // Add to queue buttons + const btnAddQueue = $('#btn-playlist-add-queue'); + const btnPlayNext = $('#btn-playlist-play-next'); + + if (btnAddQueue) { + btnAddQueue.onclick = () => { + if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId); + }; + } + if (btnPlayNext) { + btnPlayNext.onclick = () => { + if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId, true); + }; + } + + // Load playlists when tab is shown + document.querySelectorAll('.panel-tab[data-tab="playlists"]').forEach(tab => { + tab.addEventListener('click', () => { + loadPlaylists(); + }); + }); + } + + // Expose for other modules + M.playlists = { + load: loadPlaylists, + init: initPlaylists, + getMyPlaylists: () => myPlaylists, + showAddToPlaylistMenu, + addTracksToPlaylist, + renderPlaylistContents, + reloadCurrentPlaylist + }; +})(); diff --git a/public/queue.js b/public/queue.js index 7148c17..a0d7ebb 100644 --- a/public/queue.js +++ b/public/queue.js @@ -1,25 +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; - - // 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) { @@ -81,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); @@ -126,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) { @@ -146,250 +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 => { - const el = document.createElement("div"); - el.className = "context-menu-item" + (item.danger ? " danger" : ""); - el.textContent = item.label; - el.onclick = (ev) => { - ev.stopPropagation(); - 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; - } - - // 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(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); - for (let i = start; i <= end; i++) { - M.selectedLibraryIds.add(M.library[i].id); - } - } else { - const trackId = M.library[index].id; - if (M.selectedLibraryIds.has(trackId)) { - M.selectedLibraryIds.delete(trackId); - } else { - M.selectedLibraryIds.add(trackId); - } - lastSelectedLibraryIndex = index; - } - 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(); @@ -398,324 +186,80 @@ 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 (queueEl && !queueContainer) { + queueContainer = M.trackContainer.createContainer({ + type: 'queue', + element: queueEl, + getTracks: () => M.queue, + canReorder: true, + onRender: () => M.updateNowPlayingBar() + }); } - if (M.queue.length === 0) { - container.innerHTML = '
Queue empty - drag tracks here
'; - M.updateNowPlayingBar(); + 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 })); + } + return M.library + .map((track, i) => ({ track, originalIndex: i })) + .filter(({ track }) => { + const title = track.title?.trim() || track.filename || ''; + return title.toLowerCase().includes(query); + }); + } + }); + } + } + + // Render functions + M.renderQueue = function() { + initContainers(); + 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; } - - // 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) }; - }); - console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12))); + 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) { + libraryContainer.render(); } - - 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]; - } - 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"); - }); - } - }); - } - - // Clear selection option (if items selected) - if (hasSelection) { - menuItems.push({ - label: "Clear selection", - action: () => M.clearSelections() - }); - } - - showContextMenu(e, menuItems); - }; - - container.appendChild(div); - }); - - M.updateNowPlayingBar(); }; - - // Update the now-playing bar above the queue + + // Now-playing bar M.updateNowPlayingBar = function() { const bar = M.$("#now-playing-bar"); if (!bar) return; @@ -731,8 +275,7 @@ bar.title = title; bar.classList.remove("hidden"); }; - - // Scroll queue to current track + M.scrollToCurrentTrack = function() { const container = M.$("#queue"); if (!container) return; @@ -742,229 +285,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 - const filteredLibrary = 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) { - container.innerHTML = '
No matches
'; - return; - } - - filteredLibrary.forEach(({ track, i }) => { - 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(i, 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"); - }); - } - }); - } - - // 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 { @@ -975,9 +303,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 dca790f..f81da0b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -70,6 +70,25 @@ 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; gap: 0.5rem; } +.slow-queue-title { font-size: 0.75rem; color: #888; font-weight: 500; } +.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; } +.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; } +.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; } @@ -110,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; } @@ -125,9 +145,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 +156,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 +170,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 */ @@ -261,6 +289,44 @@ 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: -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; } + /* Mobile tab bar - hidden on desktop */ #mobile-tabs { display: none; } diff --git a/public/trackComponent.js b/public/trackComponent.js new file mode 100644 index 0000000..0e31b27 --- /dev/null +++ b/public/trackComponent.js @@ -0,0 +1,85 @@ +// 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.draggable - Whether element is draggable + * @returns {HTMLElement} + */ + function render(track, config) { + const { + view, + index, + displayIndex, + isSelected, + isCached, + isActive, + 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}.` : ''; + + div.innerHTML = ` + ${checkmark} + + ${trackNum} + ${escapeHtml(title)} + + ${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..3d8cc6e --- /dev/null +++ b/public/trackContainer.js @@ -0,0 +1,908 @@ +// MusicRoom - Track Container +// Manages track lists with selection, drag-and-drop, and context menus + +(function() { + const M = window.MusicRoom; + + // 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.canEditQueue] - Whether user can modify queue + * @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only) + * @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 + */ + function createContainer(config) { + const { + type, + element, + getTracks, + getFilteredTracks, + canReorder = false, + isPlaylistOwner = false, + playlistId = null, + onRender + } = config; + + let currentTracks = []; + + // 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; + + function render() { + // Defer render if a drag is in progress (would cancel the drag) + if (isDragging) { + pendingRender = true; + return; + } + pendingRender = false; + 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 (canEditQueue && type === 'queue') { + wireQueueContainerDrop(element); + } + if (isPlaylistOwner && type === 'playlist' && playlistId) { + 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/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, + index: filteredIndex, + displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null, + isSelected, + isCached, + isActive, + draggable: isDraggable + }); + + // Wire up event handlers + wireTrackEvents(div, track, filteredIndex, index, canEditQueue); + + 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) => { + 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(draggedIndices, 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(); + } else { + M.showToast("Failed to add to playlist", "error"); + } + } + + async function addTracksToPlaylistAt(trackIds, position) { + if (!playlistId) return; + + // Get current tracks and insert at position + const current = currentTracks.map(t => (t.track || t).id); + const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)]; + + 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 { + M.showToast("Failed to add to playlist", "error"); + } + } + + async function reorderPlaylist(indices, targetIndex) { + if (!playlistId) return; + + // Get current track IDs + const current = currentTracks.map(t => (t.track || t).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 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), ...movedTrackIds, ...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(); + } else { + M.showToast("Failed to reorder playlist", "error"); + } + } + + function wireTrackEvents(div, track, filteredIndex, originalIndex, canEditQueue) { + const trackId = track.id || track.filename; + const index = type === 'queue' ? originalIndex : filteredIndex; + + // 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); + 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 { + addToQueue([trackId]); + } + }; + + // Context menu + div.oncontextmenu = (e) => { + e.preventDefault(); + showContextMenu(e, track, originalIndex, canEditQueue); + }; + + // Drag start/end handlers - library/playlist always (read access), queue needs edit permission + const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue); + if (canDrag) { + div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); + div.ondragend = (e) => handleDragEnd(e, div); + } + + // 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); + } + + 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, 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) { + // Shift+click: Range select (add to existing selection) + 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 (ctrlKey) { + // Ctrl+click: Toggle single item + if (sel.has(key)) { + sel.delete(key); + } else { + sel.add(key); + } + lastSelected[type] = currentIdx; + } else { + // Plain click: Select only this item (clear others) + sel.clear(); + sel.add(key); + lastSelected[type] = currentIdx; + } + } + + function getFilteredIndex(trackId) { + return currentTracks.findIndex(t => ((t.track || t).id) === trackId); + } + + 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; + + 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 { + // Library uses trackIds + draggedTrackIds = selection.library.has(trackId) ? [...selection.library] : [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) { + setTimeout(() => render(), 50); + } + } + + function handleDragOver(e, div, index) { + e.preventDefault(); + + // 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; + 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 === type && 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(`[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) { + 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 + 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); + } + } + + 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; + } else { + M.showToast("Failed to reorder queue", "error"); + } + } + + 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(); + } else { + M.showToast("Failed to add tracks", "error"); + } + } + + 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(); + + // 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, canEditQueue) { + 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 or single selection) + if (type === 'queue' && selectedCount === 1) { + menuItems.push({ + label: "▶ Play", + action: () => playTrack(track, index) + }); + } + + // Preview (all views, single track or single selection) + if (selectedCount === 1) { + menuItems.push({ + label: "⏵ Preview", + action: () => previewTrack(track) + }); + } + + // Queue actions + if (type === 'queue' && canEditQueue) { + 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 - 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) + }); + menuItems.push({ + label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", + action: () => addToQueue(idsForAction, true) + }); + } + + // Playlist actions + if (type === 'playlist') { + // 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) + }); + menuItems.push({ + label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next", + action: () => addToQueue(idsForAction, true) + }); + } + // Can remove if user owns the playlist + if (isPlaylistOwner && 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(); + } else { + M.showToast("Failed to add to queue", "error"); + } + } + + async function removeFromQueue(indices) { + 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(); + render(); + } else { + M.showToast("Failed to remove from queue", "error"); + } + } + + async function removeFromPlaylist(indices) { + 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(); + } else { + M.showToast("Failed to remove from playlist", "error"); + } + } + + 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"; + const subInner = document.createElement("div"); + subInner.className = "context-submenu-inner"; + 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(); + }; + subInner.appendChild(subEl); + }); + sub.appendChild(subInner); + 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; + }); + }; +})(); diff --git a/public/upload.js b/public/upload.js index 7e3c33d..66dc597 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() { @@ -103,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"); @@ -200,10 +207,187 @@ 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 + + +
+
+ `; + + // 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; + } + + 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`; + } + + 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; + } + + 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)}`; + + // 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 itemsToShow) { + 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"); + 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(""); + } + + // Add show more/less button if needed + if (queuedItems.length > QUEUE_PREVIEW_COUNT) { + if (showAllQueue) { + html += ``; + } else { + html += ``; + } + } + + 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"); + } + }; + }); + + // Add show more/less handler + const toggleBtn = listEl.querySelector(".slow-queue-show-toggle"); + if (toggleBtn) { + toggleBtn.onclick = () => { + showAllQueue = !showAllQueue; + updateSlowQueueDisplay(slowQueue, slowQueueNextIn); + }; + } + + 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 +404,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(); } }; 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); diff --git a/routes/fetch.ts b/routes/fetch.ts index a53b632..f79b0bb 100644 --- a/routes/fetch.ts +++ b/routes/fetch.ts @@ -4,9 +4,12 @@ import { checkUrl, addToFastQueue, addToSlowQueue, - getUserQueues, + getQueues, + cancelSlowQueueItem, + cancelAllSlowQueueItems, } from "../ytdlp"; import { getOrCreateUser } from "./helpers"; +import { createPlaylist, generateUniquePlaylistName } from "../db"; const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; @@ -72,12 +75,19 @@ export async function handleFetchConfirm(req: Request, server: any): Promise ({ id: i.id, title: i.title })) }, { headers }); } catch (e) { @@ -97,13 +109,39 @@ export async function handleFetchConfirm(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[]; move?: number[]; to?: number; insertAt?: number }; + 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 }); + } + + // Move/reorder tracks + if (body.move?.length && typeof body.to === "number") { + movePlaylistTracks(playlistId, body.move, body.to); + return Response.json({ ok: true }); + } + + // 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) { + if (typeof body.insertAt === "number") { + insertTracksToPlaylistAt(playlistId, body.add, body.insertAt); + } else { + 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()) { diff --git a/ytdlp.ts b/ytdlp.ts index 880edc3..836729c 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,65 @@ 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; +} + +// 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) { @@ -399,13 +555,43 @@ 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(); 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 +599,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void { }; cleanup(fastQueue); cleanup(slowQueue); + + // Also cleanup database + clearCompletedSlowQueue(Math.floor(maxAge / 1000)); }