Compare commits
20 Commits
34d4c4ef66
...
c58e30b30d
| Author | SHA1 | Date |
|---|---|---|
|
|
c58e30b30d | |
|
|
ce328acd03 | |
|
|
6901f75dfb | |
|
|
336e5db0be | |
|
|
c02fd261e4 | |
|
|
a87e9a8fd1 | |
|
|
11f5804e04 | |
|
|
424873e7b0 | |
|
|
3f7bd2ec1c | |
|
|
cd4237dcbe | |
|
|
702ef6cb3e | |
|
|
8da0a76f28 | |
|
|
2803410a90 | |
|
|
a89cc14448 | |
|
|
04acbf896e | |
|
|
62c7fb9e19 | |
|
|
a9f69752ed | |
|
|
f7a743c600 | |
|
|
3da3a5e482 | |
|
|
19d98e0cc9 |
58
AGENTS.md
58
AGENTS.md
|
|
@ -98,13 +98,25 @@ M.trackBlobs // Map<trackId, blobUrl> - blob URLs for cached tracks
|
||||||
|
|
||||||
```
|
```
|
||||||
GET / → Serves public/index.html
|
GET / → Serves public/index.html
|
||||||
|
GET /listen/:trackId → Serves index.html (direct track link)
|
||||||
GET /api/channels → List all channels with listener counts
|
GET /api/channels → List all channels with listener counts
|
||||||
POST /api/channels → Create a new channel
|
POST /api/channels → Create a new channel
|
||||||
GET /api/channels/:id → Get channel state
|
GET /api/channels/:id → Get channel state
|
||||||
|
PATCH /api/channels/:id → Rename channel
|
||||||
DELETE /api/channels/:id → Delete a channel (not default)
|
DELETE /api/channels/:id → Delete a channel (not default)
|
||||||
WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes
|
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/tracks/:id → Serve audio by content hash (supports Range)
|
||||||
GET /api/library → List all tracks with id, filename, title, duration
|
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
|
## 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).
|
- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
|
||||||
- **tracks.ts** — Library listing, file upload, audio serving with range support.
|
- **tracks.ts** — Library listing, file upload, audio serving with range support.
|
||||||
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
|
- **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).
|
- **static.ts** — Static file serving (index.html, styles.css, JS files).
|
||||||
|
|
||||||
### Client (public/)
|
### Client (public/)
|
||||||
|
|
@ -137,7 +150,8 @@ GET /api/library → List all tracks with id, filename, title, dura
|
||||||
- **audioCache.js** — Track caching, segment downloads, prefetching
|
- **audioCache.js** — Track caching, segment downloads, prefetching
|
||||||
- **channelSync.js** — WebSocket connection, server sync, channel switching
|
- **channelSync.js** — WebSocket connection, server sync, channel switching
|
||||||
- **ui.js** — Progress bar, buffer display, UI updates
|
- **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
|
- **controls.js** — Play, pause, seek, volume
|
||||||
- **auth.js** — Login, signup, logout
|
- **auth.js** — Login, signup, logout
|
||||||
- **init.js** — App initialization
|
- **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 track (local mode, single track)
|
||||||
- ⏭ Play next (insert after current)
|
- ⏭ Play next (insert after current)
|
||||||
- + Add to queue (append to end)
|
- + Add to queue (append to end)
|
||||||
|
- 📁 Add to Playlist... (submenu)
|
||||||
|
- 🔗 Generate listening link
|
||||||
|
|
||||||
**Queue tracks:**
|
**Queue tracks:**
|
||||||
- ▶ Play track (jump to track)
|
- ▶ Play track (jump to track)
|
||||||
- ⏭ Play next (re-add after current)
|
- ⏭ Play next (re-add after current)
|
||||||
- + Add again (duplicate at end)
|
- + Add again (duplicate at end)
|
||||||
|
- 📁 Add to Playlist... (submenu)
|
||||||
|
- 🔗 Generate listening link
|
||||||
- ✕ Remove from queue
|
- ✕ 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
|
### Mobile/Touch Support
|
||||||
- Larger touch targets (min 44px)
|
- Larger touch targets (min 44px)
|
||||||
- No hover-dependent features (always show action buttons)
|
- No hover-dependent features (always show action buttons)
|
||||||
|
|
|
||||||
38
channel.ts
38
channel.ts
|
|
@ -305,6 +305,44 @@ export class Channel {
|
||||||
this.broadcast();
|
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() {
|
broadcast() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000);
|
const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000);
|
||||||
|
|
|
||||||
548
db.ts
548
db.ts
|
|
@ -417,3 +417,551 @@ export function loadChannelQueue(channelId: string): string[] {
|
||||||
export function removeTrackFromQueues(trackId: string): void {
|
export function removeTrackFromQueues(trackId: string): void {
|
||||||
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId);
|
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})`;
|
||||||
|
}
|
||||||
|
|
|
||||||
152
init.ts
152
init.ts
|
|
@ -8,6 +8,7 @@ import {
|
||||||
saveChannelQueue,
|
saveChannelQueue,
|
||||||
loadChannelQueue,
|
loadChannelQueue,
|
||||||
removeTrackFromQueues,
|
removeTrackFromQueues,
|
||||||
|
addTracksToPlaylist,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config";
|
import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config";
|
||||||
import { state, setLibrary } from "./state";
|
import { state, setLibrary } from "./state";
|
||||||
|
|
@ -15,6 +16,10 @@ import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast";
|
||||||
import {
|
import {
|
||||||
initYtdlp,
|
initYtdlp,
|
||||||
setProgressCallback,
|
setProgressCallback,
|
||||||
|
setTrackReadyCallback,
|
||||||
|
skipSlowQueueItem,
|
||||||
|
getQueuedSlowItems,
|
||||||
|
type QueueItem,
|
||||||
} from "./ytdlp";
|
} from "./ytdlp";
|
||||||
|
|
||||||
// Auto-discover tracks if queue is empty
|
// Auto-discover tracks if queue is empty
|
||||||
|
|
@ -98,6 +103,8 @@ export async function init(): Promise<void> {
|
||||||
status: item.status,
|
status: item.status,
|
||||||
progress: item.progress,
|
progress: item.progress,
|
||||||
queueType: item.queueType,
|
queueType: item.queueType,
|
||||||
|
playlistId: item.playlistId,
|
||||||
|
playlistName: item.playlistName,
|
||||||
error: item.error
|
error: item.error
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -106,6 +113,23 @@ export async function init(): Promise<void> {
|
||||||
const library = new Library(MUSIC_DIR);
|
const library = new Library(MUSIC_DIR);
|
||||||
setLibrary(library);
|
setLibrary(library);
|
||||||
|
|
||||||
|
// Track pending playlist additions (title -> {playlistId, playlistName, userId})
|
||||||
|
const pendingPlaylistTracks = new Map<string, { playlistId: string; playlistName: string; userId: number }>();
|
||||||
|
|
||||||
|
// 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
|
// Scan library first
|
||||||
await library.scan();
|
await library.scan();
|
||||||
library.startWatching();
|
library.startWatching();
|
||||||
|
|
@ -115,16 +139,144 @@ export async function init(): Promise<void> {
|
||||||
broadcastToAll({ type: "scan_progress", scanning: false });
|
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
|
// Broadcast when tracks are added/updated
|
||||||
library.on("added", (track) => {
|
library.on("added", (track) => {
|
||||||
broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" });
|
broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" });
|
||||||
library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title });
|
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) => {
|
library.on("changed", (track) => {
|
||||||
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
|
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
|
||||||
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
|
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
|
// Load channels from database
|
||||||
const savedChannels = loadAllChannels();
|
const savedChannels = loadAllChannels();
|
||||||
let hasDefault = false;
|
let hasDefault = false;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@
|
||||||
M.currentUser.permissions = data.permissions;
|
M.currentUser.permissions = data.permissions;
|
||||||
}
|
}
|
||||||
M.updateAuthUI();
|
M.updateAuthUI();
|
||||||
|
// Start slow queue polling if logged in
|
||||||
|
if (M.currentUser && !M.currentUser.isGuest && M.startSlowQueuePoll) {
|
||||||
|
M.startSlowQueuePoll();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
M.currentUser = null;
|
M.currentUser = null;
|
||||||
M.updateAuthUI();
|
M.updateAuthUI();
|
||||||
|
|
@ -108,6 +112,8 @@
|
||||||
const wasGuest = M.currentUser?.isGuest;
|
const wasGuest = M.currentUser?.isGuest;
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
M.currentUser = null;
|
M.currentUser = null;
|
||||||
|
// Stop slow queue polling on logout
|
||||||
|
if (M.stopSlowQueuePoll) M.stopSlowQueuePoll();
|
||||||
if (wasGuest) {
|
if (wasGuest) {
|
||||||
// Guest clicking "Sign In" - show login panel
|
// Guest clicking "Sign In" - show login panel
|
||||||
M.updateAuthUI();
|
M.updateAuthUI();
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,6 @@
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
// Handle channel list updates
|
// Handle channel list updates
|
||||||
if (data.type === "channel_list") {
|
if (data.type === "channel_list") {
|
||||||
console.log("[WS] Received channel_list:", data.channels.length, "channels");
|
|
||||||
M.channels = data.channels;
|
M.channels = data.channels;
|
||||||
M.renderChannelList();
|
M.renderChannelList();
|
||||||
return;
|
return;
|
||||||
|
|
@ -405,6 +404,9 @@
|
||||||
M.setTrackTitle(data.track.title);
|
M.setTrackTitle(data.track.title);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
|
|
||||||
|
// Auto-scroll queue to current track
|
||||||
|
setTimeout(() => M.scrollToCurrentTrack(), 100);
|
||||||
|
|
||||||
// Debug: log cache state for this track
|
// Debug: log cache state for this track
|
||||||
const trackCache = M.trackCaches.get(trackId);
|
const trackCache = M.trackCaches.get(trackId);
|
||||||
console.log("[Playback] Starting track:", data.track.title, {
|
console.log("[Playback] Starting track:", data.track.title, {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
M.localTimestamp = 0;
|
M.localTimestamp = 0;
|
||||||
M.audio.play();
|
M.audio.play();
|
||||||
M.renderQueue();
|
M.renderQueue();
|
||||||
|
setTimeout(() => M.scrollToCurrentTrack(), 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +93,9 @@
|
||||||
// Play/pause button
|
// Play/pause button
|
||||||
M.$("#status-icon").onclick = togglePlayback;
|
M.$("#status-icon").onclick = togglePlayback;
|
||||||
|
|
||||||
|
// Expose jumpToTrack for double-click handling
|
||||||
|
M.jumpToTrack = jumpToTrack;
|
||||||
|
|
||||||
// Prev/next buttons
|
// Prev/next buttons
|
||||||
M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1);
|
M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1);
|
||||||
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
|
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
|
||||||
|
|
@ -110,9 +114,9 @@
|
||||||
|
|
||||||
// Playback mode button
|
// Playback mode button
|
||||||
const modeLabels = {
|
const modeLabels = {
|
||||||
"once": "once",
|
"once": "loop(off)",
|
||||||
"repeat-all": "repeat",
|
"repeat-all": "loop(all)",
|
||||||
"repeat-one": "single",
|
"repeat-one": "loop(one)",
|
||||||
"shuffle": "shuffle"
|
"shuffle": "shuffle"
|
||||||
};
|
};
|
||||||
const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"];
|
const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"];
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -68,6 +68,7 @@
|
||||||
<div id="library-panel">
|
<div id="library-panel">
|
||||||
<div class="panel-tabs">
|
<div class="panel-tabs">
|
||||||
<button class="panel-tab active" data-tab="library">Library</button>
|
<button class="panel-tab active" data-tab="library">Library</button>
|
||||||
|
<button class="panel-tab" data-tab="playlists">Playlists</button>
|
||||||
<button class="panel-tab" data-tab="tasks">Tasks</button>
|
<button class="panel-tab" data-tab="tasks">Tasks</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-views">
|
<div class="panel-views">
|
||||||
|
|
@ -100,6 +101,31 @@
|
||||||
<div class="dropzone-content">Drop audio files here</div>
|
<div class="dropzone-content">Drop audio files here</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="playlists-view" class="panel-view">
|
||||||
|
<div class="playlists-container">
|
||||||
|
<div id="playlists-list-panel">
|
||||||
|
<div class="playlists-section">
|
||||||
|
<h4>My Playlists</h4>
|
||||||
|
<div id="my-playlists"></div>
|
||||||
|
</div>
|
||||||
|
<div class="playlists-section">
|
||||||
|
<h4>Shared</h4>
|
||||||
|
<div id="shared-playlists"></div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-new-playlist" class="new-playlist-btn">+ New Playlist</button>
|
||||||
|
</div>
|
||||||
|
<div id="playlist-contents-panel">
|
||||||
|
<div id="playlist-contents-header">
|
||||||
|
<span id="selected-playlist-name">Select a playlist</span>
|
||||||
|
<div id="playlist-actions" class="hidden">
|
||||||
|
<button id="btn-playlist-add-queue" title="Add playlist to queue">Add playlist to queue</button>
|
||||||
|
<button id="btn-playlist-play-next" title="Play next">Queue playlist next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="playlist-tracks"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="tasks-view" class="panel-view">
|
<div id="tasks-view" class="panel-view">
|
||||||
<div id="tasks-list"></div>
|
<div id="tasks-list"></div>
|
||||||
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
|
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
|
||||||
|
|
@ -107,7 +133,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="queue-panel">
|
<div id="queue-panel">
|
||||||
<h3 id="queue-title">Queue</h3>
|
<h3 id="queue-title">Queue <span id="queue-duration"></span></h3>
|
||||||
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>
|
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>
|
||||||
<div id="queue"></div>
|
<div id="queue"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,10 +180,13 @@
|
||||||
<script src="/trackStorage.js"></script>
|
<script src="/trackStorage.js"></script>
|
||||||
<script src="/core.js"></script>
|
<script src="/core.js"></script>
|
||||||
<script src="/utils.js"></script>
|
<script src="/utils.js"></script>
|
||||||
|
<script src="/trackComponent.js"></script>
|
||||||
|
<script src="/trackContainer.js"></script>
|
||||||
<script src="/audioCache.js"></script>
|
<script src="/audioCache.js"></script>
|
||||||
<script src="/channelSync.js"></script>
|
<script src="/channelSync.js"></script>
|
||||||
<script src="/ui.js"></script>
|
<script src="/ui.js"></script>
|
||||||
<script src="/queue.js"></script>
|
<script src="/queue.js"></script>
|
||||||
|
<script src="/playlists.js"></script>
|
||||||
<script src="/controls.js"></script>
|
<script src="/controls.js"></script>
|
||||||
<script src="/auth.js"></script>
|
<script src="/auth.js"></script>
|
||||||
<script src="/upload.js"></script>
|
<script src="/upload.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
initPanelTabs();
|
initPanelTabs();
|
||||||
|
|
||||||
|
// Initialize playlists
|
||||||
|
if (M.playlists?.init) {
|
||||||
|
M.playlists.init();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update UI based on server status
|
// Update UI based on server status
|
||||||
|
|
@ -128,6 +133,10 @@
|
||||||
await M.loadCurrentUser();
|
await M.loadCurrentUser();
|
||||||
if (M.currentUser) {
|
if (M.currentUser) {
|
||||||
M.loadChannels();
|
M.loadChannels();
|
||||||
|
// Load playlists after auth
|
||||||
|
if (M.playlists?.load) {
|
||||||
|
M.playlists.load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle direct track link after everything is loaded
|
// Handle direct track link after everything is loaded
|
||||||
|
|
|
||||||
|
|
@ -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 = '<div class="empty-playlists">No playlists yet</div>';
|
||||||
|
} else {
|
||||||
|
myContainer.innerHTML = myPlaylists.map(p => `
|
||||||
|
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
||||||
|
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
||||||
|
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
|
||||||
|
<span class="playlist-count">${p.trackIds.length}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared playlists
|
||||||
|
if (sharedPlaylists.length === 0) {
|
||||||
|
sharedContainer.innerHTML = '<div class="empty-playlists">No shared playlists</div>';
|
||||||
|
} else {
|
||||||
|
sharedContainer.innerHTML = sharedPlaylists.map(p => `
|
||||||
|
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
||||||
|
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
||||||
|
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span>
|
||||||
|
<span class="playlist-count">${p.trackIds.length}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach click handlers
|
||||||
|
myContainer.querySelectorAll('.playlist-item').forEach(el => {
|
||||||
|
el.onclick = () => selectPlaylist(el.dataset.id);
|
||||||
|
el.oncontextmenu = (e) => showPlaylistContextMenu(e, el.dataset.id, true);
|
||||||
|
});
|
||||||
|
sharedContainer.querySelectorAll('.playlist-item').forEach(el => {
|
||||||
|
el.onclick = () => selectPlaylist(el.dataset.id);
|
||||||
|
el.oncontextmenu = (e) => showPlaylistContextMenu(e, el.dataset.id, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectPlaylist(id) {
|
||||||
|
selectedPlaylistId = id;
|
||||||
|
|
||||||
|
// Update selection in list
|
||||||
|
document.querySelectorAll('.playlist-item').forEach(el => {
|
||||||
|
el.classList.toggle('selected', el.dataset.id === id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load playlist details
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playlists/${id}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load playlist');
|
||||||
|
selectedPlaylist = await res.json();
|
||||||
|
renderPlaylistContents();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load playlist:', err);
|
||||||
|
showToast('Failed to load playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
877
public/queue.js
877
public/queue.js
|
|
@ -1,25 +1,21 @@
|
||||||
// MusicRoom - Queue module
|
// MusicRoom - Queue module
|
||||||
// Queue rendering and library display
|
// Queue and library display using trackContainer
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
const M = window.MusicRoom;
|
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
|
// Download state - only one at a time
|
||||||
let isDownloading = false;
|
let isDownloading = false;
|
||||||
let exportQueue = [];
|
let exportQueue = [];
|
||||||
let isExporting = false;
|
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)
|
// Download a track to user's device (uses cache if available)
|
||||||
async function downloadTrack(trackId, filename) {
|
async function downloadTrack(trackId, filename) {
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
|
|
@ -81,38 +77,31 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build list of cached tracks with filenames
|
|
||||||
const cachedIds = [...M.cachedTracks];
|
const cachedIds = [...M.cachedTracks];
|
||||||
if (cachedIds.length === 0) {
|
if (cachedIds.length === 0) {
|
||||||
M.showToast("No cached tracks to export", "warning");
|
M.showToast("No cached tracks to export", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find filenames from library or queue
|
|
||||||
const trackMap = new Map();
|
const trackMap = new Map();
|
||||||
M.library.forEach(t => { if (t.filename) trackMap.set(t.id, t.filename); });
|
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); });
|
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
|
exportQueue = cachedIds
|
||||||
.filter(id => trackMap.has(id))
|
.filter(id => trackMap.has(id))
|
||||||
.map(id => ({ id, filename: trackMap.get(id) }));
|
.map(id => ({ id, filename: trackMap.get(id) }));
|
||||||
|
|
||||||
const skipped = cachedIds.length - exportQueue.length;
|
|
||||||
if (exportQueue.length === 0) {
|
if (exportQueue.length === 0) {
|
||||||
M.showToast("No exportable tracks (filenames unknown)", "warning");
|
M.showToast("No exportable tracks found", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isExporting = true;
|
isExporting = true;
|
||||||
const msg = skipped > 0
|
M.showToast(`Exporting ${exportQueue.length} tracks...`);
|
||||||
? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)`
|
|
||||||
: `Exporting ${exportQueue.length} cached tracks...`;
|
|
||||||
M.showToast(msg);
|
|
||||||
|
|
||||||
let exported = 0;
|
let exported = 0;
|
||||||
for (const { id, filename } of exportQueue) {
|
for (const { id, filename } of exportQueue) {
|
||||||
if (!isExporting) break; // Allow cancellation
|
if (!isExporting) break;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cached = await TrackStorage.get(id);
|
const cached = await TrackStorage.get(id);
|
||||||
|
|
@ -126,8 +115,6 @@
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
exported++;
|
exported++;
|
||||||
|
|
||||||
// Small delay between downloads to not overwhelm browser
|
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -146,250 +133,51 @@
|
||||||
M.showToast("Export cancelled");
|
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
|
// Update cache status for all tracks
|
||||||
M.updateCacheStatus = async function() {
|
M.updateCacheStatus = async function() {
|
||||||
const cached = await TrackStorage.list();
|
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:"));
|
const oldEntries = cached.filter(id => !id.startsWith("sha256:"));
|
||||||
if (oldEntries.length > 0) {
|
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) {
|
for (const oldId of oldEntries) {
|
||||||
await TrackStorage.remove(oldId);
|
await TrackStorage.remove(oldId);
|
||||||
}
|
}
|
||||||
// Re-fetch after cleanup
|
|
||||||
const updated = await TrackStorage.list();
|
const updated = await TrackStorage.list();
|
||||||
M.cachedTracks = new Set(updated);
|
M.cachedTracks = new Set(updated);
|
||||||
} else {
|
} else {
|
||||||
M.cachedTracks = new Set(cached);
|
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() {
|
M.debugCacheStatus = function() {
|
||||||
if (!M.currentTrackId) {
|
if (!M.currentTrackId) {
|
||||||
console.log("[Cache Debug] No current track");
|
console.log("[Cache Debug] No current track");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trackCache = M.getTrackCache(M.currentTrackId);
|
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]", {
|
console.log("[Cache Debug]", {
|
||||||
trackId: M.currentTrackId.slice(0, 16) + "...",
|
trackId: M.currentTrackId.slice(0, 16) + "...",
|
||||||
segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`,
|
segments: `${trackCache.size}/${M.SEGMENTS}`,
|
||||||
inCachedTracks,
|
inCachedTracks: M.cachedTracks.has(M.currentTrackId),
|
||||||
hasBlobUrl,
|
hasBlobUrl: M.trackBlobs.has(M.currentTrackId)
|
||||||
bulkStarted,
|
|
||||||
loadingSegments: [...M.loadingSegments],
|
|
||||||
cachedTracksSize: M.cachedTracks.size
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug: compare queue track IDs with cached track IDs
|
|
||||||
M.debugCacheMismatch = function() {
|
M.debugCacheMismatch = function() {
|
||||||
console.log("[Cache Mismatch Debug]");
|
console.log("[Cache Mismatch Debug]");
|
||||||
console.log("=== Raw State ===");
|
|
||||||
console.log("M.cachedTracks:", M.cachedTracks);
|
console.log("M.cachedTracks:", M.cachedTracks);
|
||||||
console.log("M.trackCaches:", M.trackCaches);
|
console.log("M.queue tracks:");
|
||||||
console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]);
|
|
||||||
console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted);
|
|
||||||
console.log("=== Queue Tracks ===");
|
|
||||||
M.queue.forEach((t, i) => {
|
M.queue.forEach((t, i) => {
|
||||||
const id = t.id || t.filename;
|
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(` [${i}] ${t.title?.slice(0, 30)} | cached: ${M.cachedTracks.has(id)}`);
|
||||||
});
|
|
||||||
console.log("=== Cached Track IDs ===");
|
|
||||||
[...M.cachedTracks].forEach(id => {
|
|
||||||
console.log(` ${id.slice(0, 20)}...`);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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() {
|
M.clearAllCaches = async function() {
|
||||||
await TrackStorage.clear();
|
await TrackStorage.clear();
|
||||||
M.cachedTracks.clear();
|
M.cachedTracks.clear();
|
||||||
|
|
@ -398,324 +186,80 @@
|
||||||
M.bulkDownloadStarted.clear();
|
M.bulkDownloadStarted.clear();
|
||||||
M.renderQueue();
|
M.renderQueue();
|
||||||
M.renderLibrary();
|
M.renderLibrary();
|
||||||
console.log("[Cache] All caches cleared. Refresh the page.");
|
console.log("[Cache] Cleared. Refresh the page.");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render the current queue
|
// Initialize containers
|
||||||
M.renderQueue = function() {
|
function initContainers() {
|
||||||
const container = M.$("#queue");
|
const queueEl = M.$("#queue");
|
||||||
if (!container) return;
|
const libraryEl = M.$("#library");
|
||||||
container.innerHTML = "";
|
|
||||||
|
|
||||||
const canEdit = M.canControl();
|
if (queueEl && !queueContainer) {
|
||||||
|
queueContainer = M.trackContainer.createContainer({
|
||||||
// Setup container-level drag handlers for dropping from library
|
type: 'queue',
|
||||||
if (canEdit) {
|
element: queueEl,
|
||||||
container.ondragover = (e) => {
|
getTracks: () => M.queue,
|
||||||
if (dragSource === 'library') {
|
canReorder: true,
|
||||||
e.preventDefault();
|
onRender: () => M.updateNowPlayingBar()
|
||||||
e.dataTransfer.dropEffect = "copy";
|
});
|
||||||
// If no tracks or hovering at bottom, show we can drop
|
|
||||||
if (M.queue.length === 0) {
|
|
||||||
container.classList.add("drop-target");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.ondragleave = (e) => {
|
|
||||||
// Only remove if leaving the container entirely
|
|
||||||
if (!container.contains(e.relatedTarget)) {
|
|
||||||
container.classList.remove("drop-target");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.ondrop = (e) => {
|
|
||||||
container.classList.remove("drop-target");
|
|
||||||
// Handle drop on empty queue or at the end
|
|
||||||
if (dragSource === 'library' && draggedLibraryIds.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
|
|
||||||
insertTracksAtPosition(draggedLibraryIds, targetIndex);
|
|
||||||
draggedLibraryIds = [];
|
|
||||||
dragSource = null;
|
|
||||||
dropTargetIndex = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (M.queue.length === 0) {
|
if (libraryEl && !libraryContainer) {
|
||||||
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
|
libraryContainer = M.trackContainer.createContainer({
|
||||||
M.updateNowPlayingBar();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
const hours = Math.floor(totalSecs / 3600);
|
||||||
// Debug: log first few track cache statuses
|
const mins = Math.floor((totalSecs % 3600) / 60);
|
||||||
if (M.queue.length > 0 && M.cachedTracks.size > 0) {
|
const secs = Math.floor(totalSecs % 60);
|
||||||
const sample = M.queue.slice(0, 3).map(t => {
|
let text = "";
|
||||||
const id = t.id || t.filename;
|
if (hours > 0) text = `${hours}h ${mins}m`;
|
||||||
return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) };
|
else if (mins > 0) text = `${mins}m ${secs}s`;
|
||||||
});
|
else text = `${secs}s`;
|
||||||
console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12)));
|
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 ? `<span class="track-checkmark">✓</span>` : '';
|
|
||||||
const trackNum = `<span class="track-number">${i + 1}.</span>`;
|
|
||||||
div.innerHTML = `${checkmark}<span class="cache-indicator"></span>${trackNum}<span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
|
|
||||||
|
|
||||||
// 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() {
|
M.updateNowPlayingBar = function() {
|
||||||
const bar = M.$("#now-playing-bar");
|
const bar = M.$("#now-playing-bar");
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
|
|
@ -731,8 +275,7 @@
|
||||||
bar.title = title;
|
bar.title = title;
|
||||||
bar.classList.remove("hidden");
|
bar.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll queue to current track
|
|
||||||
M.scrollToCurrentTrack = function() {
|
M.scrollToCurrentTrack = function() {
|
||||||
const container = M.$("#queue");
|
const container = M.$("#queue");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
@ -742,229 +285,14 @@
|
||||||
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
|
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup now-playing bar click handler
|
// Backwards compatibility
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
M.clearSelections = function() {
|
||||||
const bar = M.$("#now-playing-bar");
|
M.clearAllSelections();
|
||||||
if (bar) {
|
M.renderQueue();
|
||||||
bar.onclick = () => M.scrollToCurrentTrack();
|
M.renderLibrary();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 = '<div class="empty">No tracks discovered</div>';
|
|
||||||
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 = '<div class="empty">No matches</div>';
|
|
||||||
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 ? `<span class="track-checkmark">✓</span>` : '';
|
|
||||||
div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load library from server
|
// Load library from server
|
||||||
M.loadLibrary = async function() {
|
M.loadLibrary = async function() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -975,9 +303,14 @@
|
||||||
console.warn("Failed to load library");
|
console.warn("Failed to load library");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup library search
|
// Setup event listeners
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const bar = M.$("#now-playing-bar");
|
||||||
|
if (bar) {
|
||||||
|
bar.onclick = () => M.scrollToCurrentTrack();
|
||||||
|
}
|
||||||
|
|
||||||
const searchInput = M.$("#library-search");
|
const searchInput = M.$("#library-search");
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener("input", (e) => {
|
searchInput.addEventListener("input", (e) => {
|
||||||
|
|
|
||||||
|
|
@ -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-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 .task-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: #ea4; transition: width 0.2s; }
|
||||||
.task-item.complete .task-bar { background: #4e8; }
|
.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 { 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.hidden { display: none; }
|
||||||
.scan-progress.complete { color: #4e8; background: #1a2a1a; }
|
.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; }
|
.upload-dropzone.hidden { display: none; }
|
||||||
.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; }
|
.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; }
|
||||||
#queue-title { margin: 0 0 0.3rem 0; }
|
#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 { 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:hover { background: #2a3a2a; }
|
||||||
.now-playing-bar.hidden { display: none; }
|
.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 { 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; }
|
.search-input::placeholder { color: #666; }
|
||||||
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
|
#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, #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] { cursor: pointer; }
|
#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
|
||||||
#library .track:hover, #queue .track:hover { background: #222; }
|
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
|
||||||
#queue .track.active { background: #2a4a3a; color: #4e8; }
|
#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; }
|
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
|
||||||
.track.cached .cache-indicator { background: #4e8; }
|
.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-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 { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||||
.track-actions .duration { color: #666; font-size: 0.75rem; }
|
.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-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:hover .track-add, .track:hover .track-remove { opacity: 0.6; }
|
||||||
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
|
.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-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
|
||||||
.track.selected { background: #2a3a4a; }
|
.track.selected { background: #2a3a4a; }
|
||||||
.track.dragging { opacity: 0.5; }
|
.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-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; }
|
.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; }
|
#queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; }
|
||||||
|
|
||||||
/* Context menu */
|
/* Context menu */
|
||||||
|
|
@ -261,6 +289,44 @@ button:hover { background: #333; }
|
||||||
.history-item.history-error { color: #e44; background: #2a1a1a; }
|
.history-item.history-error { color: #e44; background: #2a1a1a; }
|
||||||
.history-time { color: #666; margin-right: 0.4rem; }
|
.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 tab bar - hidden on desktop */
|
||||||
#mobile-tabs { display: none; }
|
#mobile-tabs { display: none; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ? '<span class="track-checkmark">✓</span>' : '';
|
||||||
|
const trackNum = displayIndex != null ? `<span class="track-number">${displayIndex}.</span>` : '';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
${checkmark}
|
||||||
|
<span class="cache-indicator"></span>
|
||||||
|
${trackNum}
|
||||||
|
<span class="track-title">${escapeHtml(title)}</span>
|
||||||
|
<span class="track-actions">
|
||||||
|
<span class="duration">${M.fmt(track.duration)}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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 = `<div class="empty">${emptyMsg}</div>`;
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
202
public/upload.js
202
public/upload.js
|
|
@ -12,6 +12,10 @@
|
||||||
const tasksList = M.$("#tasks-list");
|
const tasksList = M.$("#tasks-list");
|
||||||
const tasksEmpty = M.$("#tasks-empty");
|
const tasksEmpty = M.$("#tasks-empty");
|
||||||
|
|
||||||
|
// Slow queue section elements (created dynamically)
|
||||||
|
let slowQueueSection = null;
|
||||||
|
let slowQueuePollInterval = null;
|
||||||
|
|
||||||
if (!addBtn || !fileInput || !dropzone) return;
|
if (!addBtn || !fileInput || !dropzone) return;
|
||||||
|
|
||||||
function openPanel() {
|
function openPanel() {
|
||||||
|
|
@ -103,20 +107,23 @@
|
||||||
|
|
||||||
if (data.type === "playlist") {
|
if (data.type === "playlist") {
|
||||||
// Ask user to confirm playlist download
|
// 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) {
|
if (confirmed) {
|
||||||
// Confirm playlist download
|
// Confirm playlist download with title for auto-playlist creation
|
||||||
const confirmRes = await fetch("/api/fetch/confirm", {
|
const confirmRes = await fetch("/api/fetch/confirm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ items: data.items })
|
body: JSON.stringify({ items: data.items, playlistTitle: data.title })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmRes.ok) {
|
if (confirmRes.ok) {
|
||||||
const confirmData = await confirmRes.json();
|
const confirmData = await confirmRes.json();
|
||||||
M.showToast(confirmData.message);
|
M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`);
|
||||||
// Tasks will be created by WebSocket progress messages
|
// Refresh playlists to show the new one
|
||||||
|
if (M.playlists?.load) M.playlists.load();
|
||||||
|
// Refresh slow queue display
|
||||||
|
pollSlowQueue();
|
||||||
} else {
|
} else {
|
||||||
const err = await confirmRes.json().catch(() => ({}));
|
const err = await confirmRes.json().catch(() => ({}));
|
||||||
M.showToast(err.error || "Failed to queue playlist", "error");
|
M.showToast(err.error || "Failed to queue playlist", "error");
|
||||||
|
|
@ -200,10 +207,187 @@
|
||||||
const fetchTasks = new Map(); // Map<id, taskHandle>
|
const fetchTasks = new Map(); // Map<id, taskHandle>
|
||||||
|
|
||||||
function updateTasksEmpty() {
|
function updateTasksEmpty() {
|
||||||
const hasTasks = tasksList.children.length > 0;
|
const hasActiveTasks = tasksList.children.length > 0;
|
||||||
tasksEmpty.classList.toggle("hidden", hasTasks);
|
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 = `
|
||||||
|
<div class="slow-queue-header">
|
||||||
|
<span class="slow-queue-title">Playlist Queue</span>
|
||||||
|
<span class="slow-queue-timer"></span>
|
||||||
|
<button class="slow-queue-cancel-all" title="Cancel all">Cancel All</button>
|
||||||
|
</div>
|
||||||
|
<div class="slow-queue-list"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`;
|
||||||
|
}
|
||||||
|
html += group.items.map((item, i) => {
|
||||||
|
const isNext = queuedItems.indexOf(item) === 0;
|
||||||
|
return `
|
||||||
|
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
|
||||||
|
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
|
||||||
|
<span class="slow-queue-item-title">${item.title}</span>
|
||||||
|
<button class="slow-queue-cancel" title="Cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add show more/less button if needed
|
||||||
|
if (queuedItems.length > QUEUE_PREVIEW_COUNT) {
|
||||||
|
if (showAllQueue) {
|
||||||
|
html += `<button class="slow-queue-show-toggle">Show less</button>`;
|
||||||
|
} else {
|
||||||
|
html += `<button class="slow-queue-show-toggle">Show ${hiddenCount} more...</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Handle WebSocket fetch progress messages
|
||||||
M.handleFetchProgress = function(data) {
|
M.handleFetchProgress = function(data) {
|
||||||
let task = fetchTasks.get(data.id);
|
let task = fetchTasks.get(data.id);
|
||||||
|
|
@ -220,9 +404,13 @@
|
||||||
} else if (data.status === "complete") {
|
} else if (data.status === "complete") {
|
||||||
task.setComplete();
|
task.setComplete();
|
||||||
fetchTasks.delete(data.id);
|
fetchTasks.delete(data.id);
|
||||||
|
// Refresh slow queue on completion
|
||||||
|
pollSlowQueue();
|
||||||
} else if (data.status === "error") {
|
} else if (data.status === "error") {
|
||||||
task.setError(data.error || "Failed");
|
task.setError(data.error || "Failed");
|
||||||
fetchTasks.delete(data.id);
|
fetchTasks.delete(data.id);
|
||||||
|
// Refresh slow queue on error
|
||||||
|
pollSlowQueue();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { add, remove, set, insertAt } = body;
|
const { add, remove, set, insertAt, move, to } = body;
|
||||||
|
|
||||||
if (Array.isArray(set)) {
|
if (Array.isArray(set)) {
|
||||||
const tracks = buildTracksFromIds(set, state.library);
|
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 });
|
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) {
|
if (Array.isArray(remove) && remove.length > 0) {
|
||||||
const indices = remove.filter((i: unknown) => typeof i === "number");
|
const indices = remove.filter((i: unknown) => typeof i === "number");
|
||||||
channel.removeTracksByIndex(indices);
|
channel.removeTracksByIndex(indices);
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@ import {
|
||||||
checkUrl,
|
checkUrl,
|
||||||
addToFastQueue,
|
addToFastQueue,
|
||||||
addToSlowQueue,
|
addToSlowQueue,
|
||||||
getUserQueues,
|
getQueues,
|
||||||
|
cancelSlowQueueItem,
|
||||||
|
cancelAllSlowQueueItems,
|
||||||
} from "../ytdlp";
|
} from "../ytdlp";
|
||||||
import { getOrCreateUser } from "./helpers";
|
import { getOrCreateUser } from "./helpers";
|
||||||
|
import { createPlaylist, generateUniquePlaylistName } from "../db";
|
||||||
|
|
||||||
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
||||||
|
|
||||||
|
|
@ -72,12 +75,19 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await req.json();
|
const { items, playlistTitle } = await req.json();
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
return Response.json({ error: "Items required" }, { status: 400 });
|
return Response.json({ error: "Items required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueItems = addToSlowQueue(items, user.id);
|
// Auto-create playlist with unique name
|
||||||
|
const baseName = playlistTitle || "Imported Playlist";
|
||||||
|
const uniqueName = generateUniquePlaylistName(baseName, user.id);
|
||||||
|
const playlist = createPlaylist(uniqueName, user.id, `Imported from URL (${items.length} tracks)`);
|
||||||
|
|
||||||
|
console.log(`[Fetch] ${user.username} created playlist: ${uniqueName} (id=${playlist.id})`);
|
||||||
|
|
||||||
|
const queueItems = addToSlowQueue(items, user.id, { id: playlist.id, name: uniqueName });
|
||||||
const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60);
|
const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60);
|
||||||
const hours = Math.floor(estimatedMinutes / 60);
|
const hours = Math.floor(estimatedMinutes / 60);
|
||||||
const mins = estimatedMinutes % 60;
|
const mins = estimatedMinutes % 60;
|
||||||
|
|
@ -89,6 +99,8 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
|
||||||
message: `Added ${queueItems.length} items to queue`,
|
message: `Added ${queueItems.length} items to queue`,
|
||||||
queueType: "slow",
|
queueType: "slow",
|
||||||
estimatedTime,
|
estimatedTime,
|
||||||
|
playlistId: playlist.id,
|
||||||
|
playlistName: uniqueName,
|
||||||
items: queueItems.map(i => ({ id: i.id, title: i.title }))
|
items: queueItems.map(i => ({ id: i.id, title: i.title }))
|
||||||
}, { headers });
|
}, { headers });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -97,13 +109,39 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/fetch - get fetch queue status
|
// GET /api/fetch - get fetch queue status (all users see all tasks)
|
||||||
export function handleGetFetchQueue(req: Request, server: any): Response {
|
export function handleGetFetchQueue(req: Request, server: any): Response {
|
||||||
const { user, headers } = getOrCreateUser(req, server);
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const queues = getUserQueues(user.id);
|
const queues = getQueues(); // Return all queues, not filtered by user
|
||||||
return Response.json(queues, { headers });
|
return Response.json(queues, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DELETE /api/fetch/:id - cancel a slow queue item
|
||||||
|
export function handleCancelFetchItem(req: Request, server: any, itemId: string): Response {
|
||||||
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = cancelSlowQueueItem(itemId, user.id);
|
||||||
|
if (success) {
|
||||||
|
return Response.json({ message: "Item cancelled" }, { headers });
|
||||||
|
} else {
|
||||||
|
return Response.json({ error: "Cannot cancel item (not found, not owned, or already downloading)" }, { status: 400, headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/fetch - cancel all slow queue items for user
|
||||||
|
export function handleCancelAllFetchItems(req: Request, server: any): Response {
|
||||||
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelled = cancelAllSlowQueueItems(user.id);
|
||||||
|
return Response.json({ message: `Cancelled ${cancelled} items`, cancelled }, { headers });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,24 @@ import {
|
||||||
handleFetch,
|
handleFetch,
|
||||||
handleFetchConfirm,
|
handleFetchConfirm,
|
||||||
handleGetFetchQueue,
|
handleGetFetchQueue,
|
||||||
|
handleCancelFetchItem,
|
||||||
|
handleCancelAllFetchItems,
|
||||||
} from "./fetch";
|
} from "./fetch";
|
||||||
|
|
||||||
|
// Playlist routes
|
||||||
|
import {
|
||||||
|
handleListPlaylists,
|
||||||
|
handleCreatePlaylist,
|
||||||
|
handleGetPlaylist,
|
||||||
|
handleUpdatePlaylist,
|
||||||
|
handleDeletePlaylist,
|
||||||
|
handleModifyPlaylistTracks,
|
||||||
|
handleSharePlaylist,
|
||||||
|
handleUnsharePlaylist,
|
||||||
|
handleGetSharedPlaylist,
|
||||||
|
handleCopySharedPlaylist,
|
||||||
|
} from "./playlists";
|
||||||
|
|
||||||
// Static file serving
|
// Static file serving
|
||||||
import { handleStatic } from "./static";
|
import { handleStatic } from "./static";
|
||||||
|
|
||||||
|
|
@ -135,6 +151,53 @@ export function createRouter() {
|
||||||
if (path === "/api/fetch" && req.method === "GET") {
|
if (path === "/api/fetch" && req.method === "GET") {
|
||||||
return handleGetFetchQueue(req, server);
|
return handleGetFetchQueue(req, server);
|
||||||
}
|
}
|
||||||
|
if (path === "/api/fetch" && req.method === "DELETE") {
|
||||||
|
return handleCancelAllFetchItems(req, server);
|
||||||
|
}
|
||||||
|
const fetchCancelMatch = path.match(/^\/api\/fetch\/([^/]+)$/);
|
||||||
|
if (fetchCancelMatch && req.method === "DELETE") {
|
||||||
|
return handleCancelFetchItem(req, server, fetchCancelMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist routes
|
||||||
|
if (path === "/api/playlists" && req.method === "GET") {
|
||||||
|
return handleListPlaylists(req, server);
|
||||||
|
}
|
||||||
|
if (path === "/api/playlists" && req.method === "POST") {
|
||||||
|
return handleCreatePlaylist(req, server);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedPlaylistMatch = path.match(/^\/api\/playlists\/shared\/([^/]+)$/);
|
||||||
|
if (sharedPlaylistMatch && req.method === "GET") {
|
||||||
|
return handleGetSharedPlaylist(req, server, sharedPlaylistMatch[1]);
|
||||||
|
}
|
||||||
|
if (sharedPlaylistMatch && req.method === "POST") {
|
||||||
|
return handleCopySharedPlaylist(req, server, sharedPlaylistMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/);
|
||||||
|
if (playlistMatch && req.method === "GET") {
|
||||||
|
return handleGetPlaylist(req, server, playlistMatch[1]);
|
||||||
|
}
|
||||||
|
if (playlistMatch && req.method === "PATCH") {
|
||||||
|
return handleUpdatePlaylist(req, server, playlistMatch[1]);
|
||||||
|
}
|
||||||
|
if (playlistMatch && req.method === "DELETE") {
|
||||||
|
return handleDeletePlaylist(req, server, playlistMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/);
|
||||||
|
if (playlistTracksMatch && req.method === "PATCH") {
|
||||||
|
return handleModifyPlaylistTracks(req, server, playlistTracksMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistShareMatch = path.match(/^\/api\/playlists\/([^/]+)\/share$/);
|
||||||
|
if (playlistShareMatch && req.method === "POST") {
|
||||||
|
return handleSharePlaylist(req, server, playlistShareMatch[1]);
|
||||||
|
}
|
||||||
|
if (playlistShareMatch && req.method === "DELETE") {
|
||||||
|
return handleUnsharePlaylist(req, server, playlistShareMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
if (path === "/api/auth/signup" && req.method === "POST") {
|
if (path === "/api/auth/signup" && req.method === "POST") {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import {
|
||||||
|
createPlaylist,
|
||||||
|
getPlaylist,
|
||||||
|
getPlaylistsByUser,
|
||||||
|
getPublicPlaylists,
|
||||||
|
getPlaylistByShareToken,
|
||||||
|
updatePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
setPlaylistTracks,
|
||||||
|
addTracksToPlaylist,
|
||||||
|
removeTrackFromPlaylist,
|
||||||
|
removeTracksFromPlaylist,
|
||||||
|
movePlaylistTracks,
|
||||||
|
insertTracksToPlaylistAt,
|
||||||
|
generatePlaylistShareToken,
|
||||||
|
removePlaylistShareToken,
|
||||||
|
findUserById,
|
||||||
|
} from "../db";
|
||||||
|
import { getOrCreateUser, userHasPermission } from "./helpers";
|
||||||
|
|
||||||
|
// GET /api/playlists - List user's + shared playlists
|
||||||
|
export function handleListPlaylists(req: Request, server: any): Response {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const myPlaylists = getPlaylistsByUser(user.id);
|
||||||
|
const sharedPlaylists = getPublicPlaylists(user.id);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
mine: myPlaylists,
|
||||||
|
shared: sharedPlaylists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/playlists - Create new playlist
|
||||||
|
export async function handleCreatePlaylist(req: Request, server: any): Promise<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 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<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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,12 @@ export async function handleStatic(path: string): Promise<Response | null> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/favicon.ico") {
|
||||||
|
return new Response(file(join(PUBLIC_DIR, "favicon.ico")), {
|
||||||
|
headers: { "Content-Type": "image/x-icon" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (path.endsWith(".js")) {
|
if (path.endsWith(".js")) {
|
||||||
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
||||||
if (await jsFile.exists()) {
|
if (await jsFile.exists()) {
|
||||||
|
|
|
||||||
199
ytdlp.ts
199
ytdlp.ts
|
|
@ -3,19 +3,32 @@
|
||||||
|
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import {
|
||||||
|
saveSlowQueueItem,
|
||||||
|
updateSlowQueueItem,
|
||||||
|
loadSlowQueue,
|
||||||
|
deleteSlowQueueItem,
|
||||||
|
clearCompletedSlowQueue,
|
||||||
|
addTracksToPlaylist,
|
||||||
|
type SlowQueueRow
|
||||||
|
} from "./db";
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
status: "queued" | "downloading" | "complete" | "error";
|
status: "queued" | "downloading" | "complete" | "error" | "cancelled";
|
||||||
progress: number;
|
progress: number;
|
||||||
queueType: "fast" | "slow";
|
queueType: "fast" | "slow";
|
||||||
error?: string;
|
error?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
|
playlistId?: string;
|
||||||
|
playlistName?: string;
|
||||||
|
position?: number;
|
||||||
|
trackId?: string; // Set after successful download
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YtdlpStatus {
|
export interface YtdlpStatus {
|
||||||
|
|
@ -65,6 +78,7 @@ let lastSlowDownload = 0;
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
let onProgress: ProgressCallback | null = null;
|
let onProgress: ProgressCallback | null = null;
|
||||||
|
let onTrackReady: ((item: QueueItem) => void) | null = null;
|
||||||
|
|
||||||
// Generate unique ID
|
// Generate unique ID
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
|
|
@ -115,6 +129,17 @@ export async function initYtdlp(config: {
|
||||||
ffmpegAvailable = false;
|
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
|
// Start slow queue processor
|
||||||
if (featureEnabled) {
|
if (featureEnabled) {
|
||||||
startSlowQueueProcessor();
|
startSlowQueueProcessor();
|
||||||
|
|
@ -123,6 +148,25 @@ export async function initYtdlp(config: {
|
||||||
return getStatus();
|
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
|
// Run a command and return stdout
|
||||||
function runCommand(cmd: string, args: string[]): Promise<string> {
|
function runCommand(cmd: string, args: string[]): Promise<string> {
|
||||||
const fullCmd = `${cmd} ${args.join(" ")}`;
|
const fullCmd = `${cmd} ${args.join(" ")}`;
|
||||||
|
|
@ -162,6 +206,11 @@ export function setProgressCallback(callback: ProgressCallback): void {
|
||||||
onProgress = callback;
|
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
|
// Get all queue items
|
||||||
export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
|
export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
|
||||||
const now = Date.now();
|
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)
|
// Add items to slow queue (for playlists)
|
||||||
export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] {
|
export function addToSlowQueue(
|
||||||
const queueItems: QueueItem[] = items.map(item => ({
|
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(),
|
id: generateId(),
|
||||||
url: item.url,
|
url: item.url,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|
@ -267,8 +321,28 @@ export function addToSlowQueue(items: { url: string; title: string }[], userId:
|
||||||
status: "queued" as const,
|
status: "queued" as const,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
queueType: "slow" as const,
|
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);
|
slowQueue.push(...queueItems);
|
||||||
return queueItems;
|
return queueItems;
|
||||||
}
|
}
|
||||||
|
|
@ -366,6 +440,21 @@ async function downloadItem(item: QueueItem): Promise<void> {
|
||||||
item.status = "complete";
|
item.status = "complete";
|
||||||
item.progress = 100;
|
item.progress = 100;
|
||||||
item.completedAt = Date.now();
|
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);
|
notifyProgress(item);
|
||||||
|
|
||||||
// Remove from queue after delay
|
// Remove from queue after delay
|
||||||
|
|
@ -374,6 +463,17 @@ async function downloadItem(item: QueueItem): Promise<void> {
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
item.status = "error";
|
item.status = "error";
|
||||||
item.error = e.message || "Download failed";
|
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);
|
notifyProgress(item);
|
||||||
|
|
||||||
// Remove from queue after delay
|
// Remove from queue after delay
|
||||||
|
|
@ -389,9 +489,65 @@ function removeFromQueue(item: QueueItem): void {
|
||||||
} else {
|
} else {
|
||||||
const idx = slowQueue.findIndex(i => i.id === item.id);
|
const idx = slowQueue.findIndex(i => i.id === item.id);
|
||||||
if (idx !== -1) slowQueue.splice(idx, 1);
|
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
|
// Notify progress callback
|
||||||
function notifyProgress(item: QueueItem): void {
|
function notifyProgress(item: QueueItem): void {
|
||||||
if (onProgress) {
|
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
|
// Cleanup old completed/failed items
|
||||||
export function cleanupOldItems(maxAge: number = 3600000): void {
|
export function cleanupOldItems(maxAge: number = 3600000): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cleanup = (queue: QueueItem[]) => {
|
const cleanup = (queue: QueueItem[]) => {
|
||||||
for (let i = queue.length - 1; i >= 0; i--) {
|
for (let i = queue.length - 1; i >= 0; i--) {
|
||||||
const item = queue[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) {
|
now - item.createdAt > maxAge) {
|
||||||
queue.splice(i, 1);
|
queue.splice(i, 1);
|
||||||
}
|
}
|
||||||
|
|
@ -413,4 +599,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void {
|
||||||
};
|
};
|
||||||
cleanup(fastQueue);
|
cleanup(fastQueue);
|
||||||
cleanup(slowQueue);
|
cleanup(slowQueue);
|
||||||
|
|
||||||
|
// Also cleanup database
|
||||||
|
clearCompletedSlowQueue(Math.floor(maxAge / 1000));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue