Merge pull request 'dev/playlists' (#13) from dev/playlists into integration
Reviewed-on: #13
This commit is contained in:
commit
c58e30b30d
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 /listen/:trackId → Serves index.html (direct track link)
|
||||
GET /api/channels → List all channels with listener counts
|
||||
POST /api/channels → Create a new channel
|
||||
GET /api/channels/:id → Get channel state
|
||||
PATCH /api/channels/:id → Rename channel
|
||||
DELETE /api/channels/:id → Delete a channel (not default)
|
||||
WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes
|
||||
GET /api/tracks/:id → Serve audio by content hash (supports Range)
|
||||
GET /api/library → List all tracks with id, filename, title, duration
|
||||
GET /api/playlists → List user's + shared playlists
|
||||
POST /api/playlists → Create new playlist
|
||||
GET /api/playlists/:id → Get playlist details
|
||||
PATCH /api/playlists/:id → Update playlist (name, description, public)
|
||||
DELETE /api/playlists/:id → Delete playlist
|
||||
PATCH /api/playlists/:id/tracks → Modify tracks (add/remove/set)
|
||||
POST /api/playlists/:id/share → Generate share token
|
||||
DELETE /api/playlists/:id/share → Remove sharing
|
||||
GET /api/playlists/shared/:token → Get shared playlist by token
|
||||
POST /api/playlists/shared/:token → Copy shared playlist to own
|
||||
```
|
||||
|
||||
## Files
|
||||
|
|
@ -129,6 +141,7 @@ GET /api/library → List all tracks with id, filename, title, dura
|
|||
- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
|
||||
- **tracks.ts** — Library listing, file upload, audio serving with range support.
|
||||
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
|
||||
- **playlists.ts** — Playlist CRUD and track management.
|
||||
- **static.ts** — Static file serving (index.html, styles.css, JS files).
|
||||
|
||||
### Client (public/)
|
||||
|
|
@ -137,7 +150,8 @@ GET /api/library → List all tracks with id, filename, title, dura
|
|||
- **audioCache.js** — Track caching, segment downloads, prefetching
|
||||
- **channelSync.js** — WebSocket connection, server sync, channel switching
|
||||
- **ui.js** — Progress bar, buffer display, UI updates
|
||||
- **queue.js** — Queue/library rendering, cache status
|
||||
- **queue.js** — Queue/library rendering, cache status, context menus
|
||||
- **playlists.js** — Playlist UI, create/edit/delete, add tracks
|
||||
- **controls.js** — Play, pause, seek, volume
|
||||
- **auth.js** — Login, signup, logout
|
||||
- **init.js** — App initialization
|
||||
|
|
@ -260,13 +274,55 @@ Tracks in Library and Queue should behave identically where applicable:
|
|||
- ▶ Play track (local mode, single track)
|
||||
- ⏭ Play next (insert after current)
|
||||
- + Add to queue (append to end)
|
||||
- 📁 Add to Playlist... (submenu)
|
||||
- 🔗 Generate listening link
|
||||
|
||||
**Queue tracks:**
|
||||
- ▶ Play track (jump to track)
|
||||
- ⏭ Play next (re-add after current)
|
||||
- + Add again (duplicate at end)
|
||||
- 📁 Add to Playlist... (submenu)
|
||||
- 🔗 Generate listening link
|
||||
- ✕ Remove from queue
|
||||
|
||||
## Playlists
|
||||
|
||||
Playlists are reusable collections of tracks that can be added to the queue.
|
||||
|
||||
### Data Model
|
||||
```ts
|
||||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: number;
|
||||
isPublic: boolean;
|
||||
shareToken: string | null;
|
||||
trackIds: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### UI Structure
|
||||
The Playlists tab has a dual-panel layout:
|
||||
- **Left panel**: List of playlists (My Playlists + Shared)
|
||||
- **Right panel**: Selected playlist's track list
|
||||
|
||||
### Context Menu Options
|
||||
**Playlist (in list):**
|
||||
- ▶ Add to Queue
|
||||
- ⏭ Play Next
|
||||
- ✏️ Rename (owner only)
|
||||
- 🌐 Make Public / 🔒 Make Private
|
||||
- 🔗 Copy Share Link / Generate Share Link
|
||||
- 📋 Copy to My Playlists (shared only)
|
||||
- 🗑️ Delete (owner only)
|
||||
|
||||
**Track (in playlist):**
|
||||
- ▶ Play
|
||||
- ➕ Add to Queue
|
||||
- ⏭ Play Next
|
||||
- 🗑️ Remove from Playlist (owner only)
|
||||
|
||||
### Mobile/Touch Support
|
||||
- Larger touch targets (min 44px)
|
||||
- No hover-dependent features (always show action buttons)
|
||||
|
|
|
|||
38
channel.ts
38
channel.ts
|
|
@ -305,6 +305,44 @@ export class Channel {
|
|||
this.broadcast();
|
||||
}
|
||||
|
||||
moveTracks(indices: number[], targetIndex: number) {
|
||||
if (indices.length === 0) return;
|
||||
|
||||
// Get the tracks being moved
|
||||
const sorted = [...indices].sort((a, b) => a - b);
|
||||
const tracksToMove = sorted.map(i => this.queue[i]).filter(Boolean);
|
||||
if (tracksToMove.length === 0) return;
|
||||
|
||||
const currentTrackId = this.currentTrack?.id;
|
||||
|
||||
// Remove tracks from their current positions (from end to preserve indices)
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
this.queue.splice(sorted[i], 1);
|
||||
}
|
||||
|
||||
// Adjust target index for removed items that were before it
|
||||
let adjustedTarget = targetIndex;
|
||||
for (const idx of sorted) {
|
||||
if (idx < targetIndex) adjustedTarget--;
|
||||
}
|
||||
|
||||
// Insert at new position
|
||||
this.queue.splice(adjustedTarget, 0, ...tracksToMove);
|
||||
|
||||
// Update currentIndex to follow the currently playing track
|
||||
if (currentTrackId) {
|
||||
const newIndex = this.queue.findIndex(t => t.id === currentTrackId);
|
||||
if (newIndex !== -1) {
|
||||
this.currentIndex = newIndex;
|
||||
}
|
||||
}
|
||||
|
||||
this.queueDirty = true;
|
||||
this.persistQueue();
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
broadcast() {
|
||||
const now = Date.now();
|
||||
const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000);
|
||||
|
|
|
|||
548
db.ts
548
db.ts
|
|
@ -417,3 +417,551 @@ export function loadChannelQueue(channelId: string): string[] {
|
|||
export function removeTrackFromQueues(trackId: string): void {
|
||||
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId);
|
||||
}
|
||||
|
||||
// Playlist tables
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
owner_id INTEGER NOT NULL,
|
||||
is_public INTEGER DEFAULT 0,
|
||||
share_token TEXT,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id)`);
|
||||
|
||||
// Playlist types
|
||||
export interface PlaylistRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
owner_id: number;
|
||||
is_public: number;
|
||||
share_token: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: number;
|
||||
isPublic: boolean;
|
||||
shareToken: string | null;
|
||||
trackIds: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// Playlist CRUD functions
|
||||
export function createPlaylist(name: string, ownerId: number, description: string = ""): Playlist {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
db.query(`
|
||||
INSERT INTO playlists (id, name, description, owner_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, name, description, ownerId, now, now);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
isPublic: false,
|
||||
shareToken: null,
|
||||
trackIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlaylist(id: string): Playlist | null {
|
||||
const row = db.query("SELECT * FROM playlists WHERE id = ?").get(id) as PlaylistRow | null;
|
||||
if (!row) return null;
|
||||
|
||||
const tracks = db.query(
|
||||
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||
).all(id) as { track_id: string }[];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
ownerId: row.owner_id,
|
||||
isPublic: !!row.is_public,
|
||||
shareToken: row.share_token,
|
||||
trackIds: tracks.map(t => t.track_id),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlaylistsByUser(userId: number): Playlist[] {
|
||||
const rows = db.query(
|
||||
"SELECT * FROM playlists WHERE owner_id = ? ORDER BY name"
|
||||
).all(userId) as PlaylistRow[];
|
||||
|
||||
return rows.map(row => {
|
||||
const tracks = db.query(
|
||||
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||
).all(row.id) as { track_id: string }[];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
ownerId: row.owner_id,
|
||||
isPublic: !!row.is_public,
|
||||
shareToken: row.share_token,
|
||||
trackIds: tracks.map(t => t.track_id),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicPlaylists(excludeUserId?: number): Playlist[] {
|
||||
const query = excludeUserId
|
||||
? "SELECT * FROM playlists WHERE is_public = 1 AND owner_id != ? ORDER BY name"
|
||||
: "SELECT * FROM playlists WHERE is_public = 1 ORDER BY name";
|
||||
|
||||
const rows = excludeUserId
|
||||
? db.query(query).all(excludeUserId) as PlaylistRow[]
|
||||
: db.query(query).all() as PlaylistRow[];
|
||||
|
||||
return rows.map(row => {
|
||||
const tracks = db.query(
|
||||
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||
).all(row.id) as { track_id: string }[];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
ownerId: row.owner_id,
|
||||
isPublic: !!row.is_public,
|
||||
shareToken: row.share_token,
|
||||
trackIds: tracks.map(t => t.track_id),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlaylistByShareToken(token: string): Playlist | null {
|
||||
const row = db.query(
|
||||
"SELECT * FROM playlists WHERE share_token = ?"
|
||||
).get(token) as PlaylistRow | null;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const tracks = db.query(
|
||||
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||
).all(row.id) as { track_id: string }[];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
ownerId: row.owner_id,
|
||||
isPublic: !!row.is_public,
|
||||
shareToken: row.share_token,
|
||||
trackIds: tracks.map(t => t.track_id),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function updatePlaylist(id: string, updates: { name?: string; description?: string; isPublic?: boolean }): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: any[] = [now];
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
sets.push("name = ?");
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
sets.push("description = ?");
|
||||
values.push(updates.description);
|
||||
}
|
||||
if (updates.isPublic !== undefined) {
|
||||
sets.push("is_public = ?");
|
||||
values.push(updates.isPublic ? 1 : 0);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.query(`UPDATE playlists SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
export function deletePlaylist(id: string): void {
|
||||
db.query("DELETE FROM playlists WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function setPlaylistTracks(playlistId: string, trackIds: string[]): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
db.query("BEGIN").run();
|
||||
try {
|
||||
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ?").run(playlistId);
|
||||
|
||||
const insert = db.query(
|
||||
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (let i = 0; i < trackIds.length; i++) {
|
||||
insert.run(playlistId, trackIds[i], i);
|
||||
}
|
||||
|
||||
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||
db.query("COMMIT").run();
|
||||
} catch (e) {
|
||||
db.query("ROLLBACK").run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function addTracksToPlaylist(playlistId: string, trackIds: string[]): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
db.query("BEGIN").run();
|
||||
try {
|
||||
// Get current max position
|
||||
const max = db.query(
|
||||
"SELECT COALESCE(MAX(position), -1) as max_pos FROM playlist_tracks WHERE playlist_id = ?"
|
||||
).get(playlistId) as { max_pos: number };
|
||||
|
||||
let pos = max.max_pos + 1;
|
||||
const insert = db.query(
|
||||
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (const trackId of trackIds) {
|
||||
insert.run(playlistId, trackId, pos++);
|
||||
}
|
||||
|
||||
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||
db.query("COMMIT").run();
|
||||
} catch (e) {
|
||||
db.query("ROLLBACK").run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeTrackFromPlaylist(playlistId: string, position: number): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
db.query("BEGIN").run();
|
||||
try {
|
||||
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position);
|
||||
|
||||
// Reorder remaining tracks
|
||||
db.query(`
|
||||
UPDATE playlist_tracks
|
||||
SET position = position - 1
|
||||
WHERE playlist_id = ? AND position > ?
|
||||
`).run(playlistId, position);
|
||||
|
||||
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||
db.query("COMMIT").run();
|
||||
} catch (e) {
|
||||
db.query("ROLLBACK").run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeTracksFromPlaylist(playlistId: string, positions: number[]): void {
|
||||
if (positions.length === 0) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Sort descending to remove from end first (preserves indices)
|
||||
const sorted = [...positions].sort((a, b) => b - a);
|
||||
|
||||
db.query("BEGIN").run();
|
||||
try {
|
||||
for (const pos of sorted) {
|
||||
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos);
|
||||
// Reorder remaining tracks
|
||||
db.query(`
|
||||
UPDATE playlist_tracks
|
||||
SET position = position - 1
|
||||
WHERE playlist_id = ? AND position > ?
|
||||
`).run(playlistId, pos);
|
||||
}
|
||||
|
||||
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||
db.query("COMMIT").run();
|
||||
} catch (e) {
|
||||
db.query("ROLLBACK").run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function movePlaylistTracks(playlistId: string, fromPositions: number[], toPosition: number): void {
|
||||
if (fromPositions.length === 0) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sorted = [...fromPositions].sort((a, b) => a - b);
|
||||
|
||||
db.query("BEGIN").run();
|
||||
try {
|
||||
// Get tracks to move
|
||||
const tracksToMove: string[] = [];
|
||||
for (const pos of sorted) {
|
||||
const row = db.query(
|
||||
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? AND position = ?"
|
||||
).get(playlistId, pos) as { track_id: string } | null;
|
||||
if (row) tracksToMove.push(row.track_id);
|
||||
}
|
||||
|
||||
if (tracksToMove.length === 0) {
|
||||
db.query("ROLLBACK").run();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove tracks from current positions (from end to preserve indices)
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const pos = sorted[i];
|
||||
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos);
|
||||
db.query(`
|
||||
UPDATE playlist_tracks
|
||||
SET position = position - 1
|
||||
WHERE playlist_id = ? AND position > ?
|
||||
`).run(playlistId, pos);
|
||||
}
|
||||
|
||||
// Adjust target for removed items
|
||||
let adjustedTarget = toPosition;
|
||||
for (const pos of sorted) {
|
||||
if (pos < toPosition) adjustedTarget--;
|
||||
}
|
||||
|
||||
// Make room at target position
|
||||
db.query(`
|
||||
UPDATE playlist_tracks
|
||||
SET position = position + ?
|
||||
WHERE playlist_id = ? AND position >= ?
|
||||
`).run(tracksToMove.length, playlistId, adjustedTarget);
|
||||
|
||||
// Insert tracks at new position
|
||||
const insert = db.query(
|
||||
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (let i = 0; i < tracksToMove.length; i++) {
|
||||
insert.run(playlistId, tracksToMove[i], adjustedTarget + i);
|
||||
}
|
||||
|
||||
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||
db.query("COMMIT").run();
|
||||
} catch (e) {
|
||||
db.query("ROLLBACK").run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function insertTracksToPlaylistAt(playlistId: string, trackIds: string[], position: number): void {
|
||||
if (trackIds.length === 0) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
db.query("BEGIN").run();
|
||||
try {
|
||||
// Make room at position
|
||||
db.query(`
|
||||
UPDATE playlist_tracks
|
||||
SET position = position + ?
|
||||
WHERE playlist_id = ? AND position >= ?
|
||||
`).run(trackIds.length, playlistId, position);
|
||||
|
||||
// Insert tracks
|
||||
const insert = db.query(
|
||||
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (let i = 0; i < trackIds.length; i++) {
|
||||
insert.run(playlistId, trackIds[i], position + i);
|
||||
}
|
||||
|
||||
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||
db.query("COMMIT").run();
|
||||
} catch (e) {
|
||||
db.query("ROLLBACK").run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePlaylistShareToken(playlistId: string): string {
|
||||
const token = crypto.randomUUID().slice(0, 12);
|
||||
db.query("UPDATE playlists SET share_token = ? WHERE id = ?").run(token, playlistId);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function removePlaylistShareToken(playlistId: string): void {
|
||||
db.query("UPDATE playlists SET share_token = NULL WHERE id = ?").run(playlistId);
|
||||
}
|
||||
|
||||
// Slow queue table for yt-dlp playlist downloads
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS slow_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'queued',
|
||||
progress REAL DEFAULT 0,
|
||||
error TEXT,
|
||||
playlist_id TEXT,
|
||||
playlist_name TEXT,
|
||||
position INTEGER,
|
||||
created_at INTEGER,
|
||||
completed_at INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_user ON slow_queue(user_id)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_status ON slow_queue(status)`);
|
||||
|
||||
// Slow queue types
|
||||
export interface SlowQueueRow {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
user_id: number;
|
||||
status: string;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
playlist_id: string | null;
|
||||
playlist_name: string | null;
|
||||
position: number | null;
|
||||
created_at: number;
|
||||
completed_at: number | null;
|
||||
}
|
||||
|
||||
// Slow queue CRUD functions
|
||||
export function saveSlowQueueItem(item: {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
userId: number;
|
||||
status: string;
|
||||
progress: number;
|
||||
error?: string;
|
||||
playlistId?: string;
|
||||
playlistName?: string;
|
||||
position?: number;
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
}): void {
|
||||
db.query(`
|
||||
INSERT INTO slow_queue (id, url, title, user_id, status, progress, error, playlist_id, playlist_name, position, created_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
progress = excluded.progress,
|
||||
error = excluded.error,
|
||||
completed_at = excluded.completed_at
|
||||
`).run(
|
||||
item.id,
|
||||
item.url,
|
||||
item.title,
|
||||
item.userId,
|
||||
item.status,
|
||||
item.progress,
|
||||
item.error ?? null,
|
||||
item.playlistId ?? null,
|
||||
item.playlistName ?? null,
|
||||
item.position ?? null,
|
||||
item.createdAt,
|
||||
item.completedAt ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSlowQueueItem(id: string, updates: {
|
||||
status?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
completedAt?: number;
|
||||
}): void {
|
||||
const sets: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.status !== undefined) {
|
||||
sets.push("status = ?");
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.progress !== undefined) {
|
||||
sets.push("progress = ?");
|
||||
values.push(updates.progress);
|
||||
}
|
||||
if (updates.error !== undefined) {
|
||||
sets.push("error = ?");
|
||||
values.push(updates.error);
|
||||
}
|
||||
if (updates.completedAt !== undefined) {
|
||||
sets.push("completed_at = ?");
|
||||
values.push(updates.completedAt);
|
||||
}
|
||||
|
||||
if (sets.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
db.query(`UPDATE slow_queue SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
export function loadSlowQueue(): SlowQueueRow[] {
|
||||
return db.query(
|
||||
"SELECT * FROM slow_queue WHERE status IN ('queued', 'downloading') ORDER BY created_at"
|
||||
).all() as SlowQueueRow[];
|
||||
}
|
||||
|
||||
export function deleteSlowQueueItem(id: string): void {
|
||||
db.query("DELETE FROM slow_queue WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function clearCompletedSlowQueue(maxAge: number = 3600): void {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - maxAge;
|
||||
db.query(
|
||||
"DELETE FROM slow_queue WHERE status IN ('complete', 'error', 'cancelled') AND completed_at < ?"
|
||||
).run(cutoff);
|
||||
}
|
||||
|
||||
export function getSlowQueueByUser(userId: number): SlowQueueRow[] {
|
||||
return db.query(
|
||||
"SELECT * FROM slow_queue WHERE user_id = ? ORDER BY created_at"
|
||||
).all(userId) as SlowQueueRow[];
|
||||
}
|
||||
|
||||
export function playlistNameExists(name: string, userId: number): boolean {
|
||||
const result = db.query(
|
||||
"SELECT 1 FROM playlists WHERE name = ? AND owner_id = ? LIMIT 1"
|
||||
).get(name, userId);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
export function generateUniquePlaylistName(baseName: string, userId: number): string {
|
||||
if (!playlistNameExists(baseName, userId)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
let counter = 2;
|
||||
while (playlistNameExists(`${baseName} (${counter})`, userId)) {
|
||||
counter++;
|
||||
}
|
||||
return `${baseName} (${counter})`;
|
||||
}
|
||||
|
|
|
|||
152
init.ts
152
init.ts
|
|
@ -8,6 +8,7 @@ import {
|
|||
saveChannelQueue,
|
||||
loadChannelQueue,
|
||||
removeTrackFromQueues,
|
||||
addTracksToPlaylist,
|
||||
} from "./db";
|
||||
import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config";
|
||||
import { state, setLibrary } from "./state";
|
||||
|
|
@ -15,6 +16,10 @@ import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast";
|
|||
import {
|
||||
initYtdlp,
|
||||
setProgressCallback,
|
||||
setTrackReadyCallback,
|
||||
skipSlowQueueItem,
|
||||
getQueuedSlowItems,
|
||||
type QueueItem,
|
||||
} from "./ytdlp";
|
||||
|
||||
// Auto-discover tracks if queue is empty
|
||||
|
|
@ -98,6 +103,8 @@ export async function init(): Promise<void> {
|
|||
status: item.status,
|
||||
progress: item.progress,
|
||||
queueType: item.queueType,
|
||||
playlistId: item.playlistId,
|
||||
playlistName: item.playlistName,
|
||||
error: item.error
|
||||
});
|
||||
});
|
||||
|
|
@ -106,6 +113,23 @@ export async function init(): Promise<void> {
|
|||
const library = new Library(MUSIC_DIR);
|
||||
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
|
||||
await library.scan();
|
||||
library.startWatching();
|
||||
|
|
@ -115,16 +139,144 @@ export async function init(): Promise<void> {
|
|||
broadcastToAll({ type: "scan_progress", scanning: false });
|
||||
});
|
||||
|
||||
// Normalize string for matching (handle Windows filename character substitutions)
|
||||
function normalizeForMatch(s: string): string {
|
||||
return s.toLowerCase()
|
||||
.replace(/|/g, "|") // fullwidth vertical line → pipe
|
||||
.replace(/"/g, '"') // fullwidth quotation
|
||||
.replace(/*/g, "*") // fullwidth asterisk
|
||||
.replace(/?/g, "?") // fullwidth question mark
|
||||
.replace(/</g, "<") // fullwidth less-than
|
||||
.replace(/>/g, ">") // fullwidth greater-than
|
||||
.replace(/:/g, ":") // fullwidth colon
|
||||
.replace(///g, "/") // fullwidth slash
|
||||
.replace(/\/g, "\\") // fullwidth backslash
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Helper to check if track matches a pending playlist addition
|
||||
function checkPendingPlaylistAddition(track: { id: string; title?: string; filename?: string }) {
|
||||
if (pendingPlaylistTracks.size === 0) return;
|
||||
|
||||
const trackTitle = normalizeForMatch(track.title || "");
|
||||
const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); // Remove extension
|
||||
|
||||
// Skip if both title and filename are too short
|
||||
if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) return;
|
||||
|
||||
console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`);
|
||||
|
||||
for (const [pendingTitle, pending] of pendingPlaylistTracks) {
|
||||
const normalizedPending = normalizeForMatch(pendingTitle);
|
||||
|
||||
// Skip if pending title is too short
|
||||
if (!normalizedPending || normalizedPending.length < 5) continue;
|
||||
|
||||
// Match by title or filename (yt-dlp uses title as filename)
|
||||
// Require exact match or very high overlap
|
||||
const matches =
|
||||
(trackTitle && trackTitle === normalizedPending) ||
|
||||
(trackFilename && trackFilename === normalizedPending) ||
|
||||
(trackTitle && normalizedPending && trackTitle.includes(normalizedPending) && normalizedPending.length >= trackTitle.length * 0.8) ||
|
||||
(trackTitle && normalizedPending && normalizedPending.includes(trackTitle) && trackTitle.length >= normalizedPending.length * 0.8) ||
|
||||
(trackFilename && normalizedPending && trackFilename.includes(normalizedPending) && normalizedPending.length >= trackFilename.length * 0.8) ||
|
||||
(trackFilename && normalizedPending && normalizedPending.includes(trackFilename) && trackFilename.length >= normalizedPending.length * 0.8);
|
||||
|
||||
console.log(`[ytdlp] vs pending="${normalizedPending}" → ${matches ? "MATCH" : "no match"}`);
|
||||
|
||||
if (matches) {
|
||||
console.log(`[ytdlp] Adding track ${track.id} to playlist ${pending.playlistId}`);
|
||||
try {
|
||||
addTracksToPlaylist(pending.playlistId, [track.id]);
|
||||
sendToUser(pending.userId, {
|
||||
type: "toast",
|
||||
message: `Added to playlist: ${pending.playlistName}`,
|
||||
toastType: "info"
|
||||
});
|
||||
pendingPlaylistTracks.delete(pendingTitle);
|
||||
} catch (e) {
|
||||
console.error(`[ytdlp] Failed to add track to playlist:`, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast when tracks are added/updated
|
||||
library.on("added", (track) => {
|
||||
broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" });
|
||||
library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title });
|
||||
|
||||
// Check if this track was pending playlist addition (defer to ensure DB is updated)
|
||||
setTimeout(() => checkPendingPlaylistAddition(track), 100);
|
||||
});
|
||||
library.on("changed", (track) => {
|
||||
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
|
||||
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
|
||||
});
|
||||
|
||||
// Prescan slow queue to find tracks already in library
|
||||
function prescanSlowQueue() {
|
||||
const queuedItems = getQueuedSlowItems();
|
||||
if (queuedItems.length === 0) return;
|
||||
|
||||
const tracks = library.getAllTracks();
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
for (const item of queuedItems) {
|
||||
const itemTitle = normalizeForMatch(item.title);
|
||||
|
||||
// Skip if title is too short (avoid false matches)
|
||||
if (!itemTitle || itemTitle.length < 5) continue;
|
||||
|
||||
// Check if any library track matches
|
||||
for (const track of tracks) {
|
||||
const trackTitle = normalizeForMatch(track.title || "");
|
||||
const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, ""));
|
||||
|
||||
// Skip if both track title and filename are too short
|
||||
if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) continue;
|
||||
|
||||
// Require exact match or very high overlap (not just substring)
|
||||
const matches =
|
||||
(trackTitle && trackTitle === itemTitle) ||
|
||||
(trackFilename && trackFilename === itemTitle) ||
|
||||
// Only allow includes if the shorter string is at least 80% of the longer
|
||||
(trackTitle && itemTitle && trackTitle.includes(itemTitle) && itemTitle.length >= trackTitle.length * 0.8) ||
|
||||
(trackTitle && itemTitle && itemTitle.includes(trackTitle) && trackTitle.length >= itemTitle.length * 0.8) ||
|
||||
(trackFilename && itemTitle && trackFilename.includes(itemTitle) && itemTitle.length >= trackFilename.length * 0.8) ||
|
||||
(trackFilename && itemTitle && itemTitle.includes(trackFilename) && trackFilename.length >= itemTitle.length * 0.8);
|
||||
|
||||
if (matches) {
|
||||
console.log(`[ytdlp] Prescan: "${item.title}" already exists as "${track.title || track.filename}"`);
|
||||
|
||||
// Skip download and add to playlist
|
||||
const skipped = skipSlowQueueItem(item.id, track.id);
|
||||
if (skipped && skipped.playlistId) {
|
||||
try {
|
||||
addTracksToPlaylist(skipped.playlistId, [track.id]);
|
||||
sendToUser(skipped.userId, {
|
||||
type: "toast",
|
||||
message: `Already in library, added to: ${skipped.playlistName}`,
|
||||
toastType: "info"
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[ytdlp] Failed to add existing track to playlist:`, e);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run prescan periodically (every 30 seconds)
|
||||
setInterval(prescanSlowQueue, 30000);
|
||||
// Also run once after initial scan completes
|
||||
library.onScanComplete(() => {
|
||||
setTimeout(prescanSlowQueue, 1000);
|
||||
});
|
||||
|
||||
// Load channels from database
|
||||
const savedChannels = loadAllChannels();
|
||||
let hasDefault = false;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
M.currentUser.permissions = data.permissions;
|
||||
}
|
||||
M.updateAuthUI();
|
||||
// Start slow queue polling if logged in
|
||||
if (M.currentUser && !M.currentUser.isGuest && M.startSlowQueuePoll) {
|
||||
M.startSlowQueuePoll();
|
||||
}
|
||||
} catch (e) {
|
||||
M.currentUser = null;
|
||||
M.updateAuthUI();
|
||||
|
|
@ -108,6 +112,8 @@
|
|||
const wasGuest = M.currentUser?.isGuest;
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
M.currentUser = null;
|
||||
// Stop slow queue polling on logout
|
||||
if (M.stopSlowQueuePoll) M.stopSlowQueuePoll();
|
||||
if (wasGuest) {
|
||||
// Guest clicking "Sign In" - show login panel
|
||||
M.updateAuthUI();
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@
|
|||
const data = JSON.parse(e.data);
|
||||
// Handle channel list updates
|
||||
if (data.type === "channel_list") {
|
||||
console.log("[WS] Received channel_list:", data.channels.length, "channels");
|
||||
M.channels = data.channels;
|
||||
M.renderChannelList();
|
||||
return;
|
||||
|
|
@ -405,6 +404,9 @@
|
|||
M.setTrackTitle(data.track.title);
|
||||
M.loadingSegments.clear();
|
||||
|
||||
// Auto-scroll queue to current track
|
||||
setTimeout(() => M.scrollToCurrentTrack(), 100);
|
||||
|
||||
// Debug: log cache state for this track
|
||||
const trackCache = M.trackCaches.get(trackId);
|
||||
console.log("[Playback] Starting track:", data.track.title, {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
M.localTimestamp = 0;
|
||||
M.audio.play();
|
||||
M.renderQueue();
|
||||
setTimeout(() => M.scrollToCurrentTrack(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +93,9 @@
|
|||
// Play/pause button
|
||||
M.$("#status-icon").onclick = togglePlayback;
|
||||
|
||||
// Expose jumpToTrack for double-click handling
|
||||
M.jumpToTrack = jumpToTrack;
|
||||
|
||||
// Prev/next buttons
|
||||
M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1);
|
||||
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
|
||||
|
|
@ -110,9 +114,9 @@
|
|||
|
||||
// Playback mode button
|
||||
const modeLabels = {
|
||||
"once": "once",
|
||||
"repeat-all": "repeat",
|
||||
"repeat-one": "single",
|
||||
"once": "loop(off)",
|
||||
"repeat-all": "loop(all)",
|
||||
"repeat-one": "loop(one)",
|
||||
"shuffle": "shuffle"
|
||||
};
|
||||
const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"];
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -68,6 +68,7 @@
|
|||
<div id="library-panel">
|
||||
<div class="panel-tabs">
|
||||
<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>
|
||||
</div>
|
||||
<div class="panel-views">
|
||||
|
|
@ -100,6 +101,31 @@
|
|||
<div class="dropzone-content">Drop audio files here</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-list"></div>
|
||||
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
|
||||
|
|
@ -107,7 +133,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<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="queue"></div>
|
||||
</div>
|
||||
|
|
@ -154,10 +180,13 @@
|
|||
<script src="/trackStorage.js"></script>
|
||||
<script src="/core.js"></script>
|
||||
<script src="/utils.js"></script>
|
||||
<script src="/trackComponent.js"></script>
|
||||
<script src="/trackContainer.js"></script>
|
||||
<script src="/audioCache.js"></script>
|
||||
<script src="/channelSync.js"></script>
|
||||
<script src="/ui.js"></script>
|
||||
<script src="/queue.js"></script>
|
||||
<script src="/playlists.js"></script>
|
||||
<script src="/controls.js"></script>
|
||||
<script src="/auth.js"></script>
|
||||
<script src="/upload.js"></script>
|
||||
|
|
|
|||
|
|
@ -108,6 +108,11 @@
|
|||
}
|
||||
|
||||
initPanelTabs();
|
||||
|
||||
// Initialize playlists
|
||||
if (M.playlists?.init) {
|
||||
M.playlists.init();
|
||||
}
|
||||
});
|
||||
|
||||
// Update UI based on server status
|
||||
|
|
@ -128,6 +133,10 @@
|
|||
await M.loadCurrentUser();
|
||||
if (M.currentUser) {
|
||||
M.loadChannels();
|
||||
// Load playlists after auth
|
||||
if (M.playlists?.load) {
|
||||
M.playlists.load();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle direct track link after everything is loaded
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
855
public/queue.js
855
public/queue.js
|
|
@ -1,25 +1,21 @@
|
|||
// MusicRoom - Queue module
|
||||
// Queue rendering and library display
|
||||
// Queue and library display using trackContainer
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Selection state for bulk operations
|
||||
M.selectedQueueIndices = new Set();
|
||||
M.selectedLibraryIds = new Set();
|
||||
|
||||
// Last selected index for shift-select range
|
||||
let lastSelectedQueueIndex = null;
|
||||
let lastSelectedLibraryIndex = null;
|
||||
|
||||
// Context menu state
|
||||
let activeContextMenu = null;
|
||||
|
||||
// Download state - only one at a time
|
||||
let isDownloading = false;
|
||||
let exportQueue = [];
|
||||
let isExporting = false;
|
||||
|
||||
// Container instances
|
||||
let queueContainer = null;
|
||||
let libraryContainer = null;
|
||||
|
||||
// Library search state
|
||||
M.librarySearchQuery = "";
|
||||
|
||||
// Download a track to user's device (uses cache if available)
|
||||
async function downloadTrack(trackId, filename) {
|
||||
if (isDownloading) {
|
||||
|
|
@ -81,38 +77,31 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Build list of cached tracks with filenames
|
||||
const cachedIds = [...M.cachedTracks];
|
||||
if (cachedIds.length === 0) {
|
||||
M.showToast("No cached tracks to export", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find filenames from library or queue
|
||||
const trackMap = new Map();
|
||||
M.library.forEach(t => { if (t.filename) trackMap.set(t.id, t.filename); });
|
||||
M.queue.forEach(t => { if (t.filename && !trackMap.has(t.id)) trackMap.set(t.id, t.filename); });
|
||||
|
||||
// Only export tracks with known filenames
|
||||
exportQueue = cachedIds
|
||||
.filter(id => trackMap.has(id))
|
||||
.map(id => ({ id, filename: trackMap.get(id) }));
|
||||
|
||||
const skipped = cachedIds.length - exportQueue.length;
|
||||
if (exportQueue.length === 0) {
|
||||
M.showToast("No exportable tracks (filenames unknown)", "warning");
|
||||
M.showToast("No exportable tracks found", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting = true;
|
||||
const msg = skipped > 0
|
||||
? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)`
|
||||
: `Exporting ${exportQueue.length} cached tracks...`;
|
||||
M.showToast(msg);
|
||||
M.showToast(`Exporting ${exportQueue.length} tracks...`);
|
||||
|
||||
let exported = 0;
|
||||
for (const { id, filename } of exportQueue) {
|
||||
if (!isExporting) break; // Allow cancellation
|
||||
if (!isExporting) break;
|
||||
|
||||
try {
|
||||
const cached = await TrackStorage.get(id);
|
||||
|
|
@ -126,8 +115,6 @@
|
|||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
exported++;
|
||||
|
||||
// Small delay between downloads to not overwhelm browser
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -147,249 +134,50 @@
|
|||
}
|
||||
};
|
||||
|
||||
// Close context menu when clicking elsewhere
|
||||
document.addEventListener("click", () => {
|
||||
if (activeContextMenu) {
|
||||
activeContextMenu.remove();
|
||||
activeContextMenu = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Show context menu
|
||||
function showContextMenu(e, items) {
|
||||
e.preventDefault();
|
||||
if (activeContextMenu) activeContextMenu.remove();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "context-menu";
|
||||
|
||||
items.forEach(item => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "context-menu-item" + (item.danger ? " danger" : "");
|
||||
el.textContent = item.label;
|
||||
el.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
menu.remove();
|
||||
activeContextMenu = null;
|
||||
item.action();
|
||||
};
|
||||
menu.appendChild(el);
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Position menu, keep within viewport
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5;
|
||||
if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5;
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
|
||||
activeContextMenu = menu;
|
||||
}
|
||||
|
||||
// Drag state for queue reordering
|
||||
let draggedIndices = [];
|
||||
let draggedLibraryIds = [];
|
||||
let dropTargetIndex = null;
|
||||
let dragSource = null; // 'queue' or 'library'
|
||||
|
||||
// Insert library tracks into queue at position
|
||||
async function insertTracksAtPosition(trackIds, position) {
|
||||
if (!M.currentChannelId || trackIds.length === 0) return;
|
||||
|
||||
// Build new queue with tracks inserted at position
|
||||
const newQueue = [...M.queue];
|
||||
const newTrackIds = [...newQueue.map(t => t.id)];
|
||||
newTrackIds.splice(position, 0, ...trackIds);
|
||||
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ set: newTrackIds })
|
||||
});
|
||||
|
||||
if (res.status === 403) M.flashPermissionDenied();
|
||||
else if (res.ok) {
|
||||
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
|
||||
M.clearSelections();
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder queue on server
|
||||
async function reorderQueue(fromIndices, toIndex) {
|
||||
if (!M.currentChannelId || fromIndices.length === 0) return;
|
||||
|
||||
// Build new queue order
|
||||
const newQueue = [...M.queue];
|
||||
|
||||
// Sort indices descending to remove from end first
|
||||
const sortedIndices = [...fromIndices].sort((a, b) => b - a);
|
||||
const movedTracks = [];
|
||||
|
||||
// Remove items (in reverse order to preserve indices)
|
||||
for (const idx of sortedIndices) {
|
||||
movedTracks.unshift(newQueue.splice(idx, 1)[0]);
|
||||
}
|
||||
|
||||
// Adjust target index for removed items before it
|
||||
let adjustedTarget = toIndex;
|
||||
for (const idx of fromIndices) {
|
||||
if (idx < toIndex) adjustedTarget--;
|
||||
}
|
||||
|
||||
// Insert at new position
|
||||
newQueue.splice(adjustedTarget, 0, ...movedTracks);
|
||||
|
||||
// Send to server
|
||||
const trackIds = newQueue.map(t => t.id);
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ set: trackIds })
|
||||
});
|
||||
|
||||
if (res.status === 403) M.flashPermissionDenied();
|
||||
else if (res.ok) {
|
||||
M.clearSelections();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle selection mode (with optional shift for range select)
|
||||
M.toggleQueueSelection = function(index, shiftKey = false) {
|
||||
if (shiftKey && lastSelectedQueueIndex !== null) {
|
||||
// Range select: select all between last and current
|
||||
const start = Math.min(lastSelectedQueueIndex, index);
|
||||
const end = Math.max(lastSelectedQueueIndex, index);
|
||||
for (let i = start; i <= end; i++) {
|
||||
M.selectedQueueIndices.add(i);
|
||||
}
|
||||
} else {
|
||||
if (M.selectedQueueIndices.has(index)) {
|
||||
M.selectedQueueIndices.delete(index);
|
||||
} else {
|
||||
M.selectedQueueIndices.add(index);
|
||||
}
|
||||
lastSelectedQueueIndex = index;
|
||||
}
|
||||
M.renderQueue();
|
||||
};
|
||||
|
||||
M.toggleLibrarySelection = function(index, shiftKey = false) {
|
||||
if (shiftKey && lastSelectedLibraryIndex !== null) {
|
||||
// Range select: select all between last and current
|
||||
const start = Math.min(lastSelectedLibraryIndex, index);
|
||||
const end = Math.max(lastSelectedLibraryIndex, index);
|
||||
for (let i = start; i <= end; i++) {
|
||||
M.selectedLibraryIds.add(M.library[i].id);
|
||||
}
|
||||
} else {
|
||||
const trackId = M.library[index].id;
|
||||
if (M.selectedLibraryIds.has(trackId)) {
|
||||
M.selectedLibraryIds.delete(trackId);
|
||||
} else {
|
||||
M.selectedLibraryIds.add(trackId);
|
||||
}
|
||||
lastSelectedLibraryIndex = index;
|
||||
}
|
||||
M.renderLibrary();
|
||||
};
|
||||
|
||||
M.clearSelections = function() {
|
||||
M.selectedQueueIndices.clear();
|
||||
M.selectedLibraryIds.clear();
|
||||
lastSelectedQueueIndex = null;
|
||||
lastSelectedLibraryIndex = null;
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
};
|
||||
|
||||
// Update cache status for all tracks
|
||||
M.updateCacheStatus = async function() {
|
||||
const cached = await TrackStorage.list();
|
||||
|
||||
// Migration: remove old filename-based cache entries (keep only sha256: prefixed)
|
||||
// Migration: remove old filename-based cache entries
|
||||
const oldEntries = cached.filter(id => !id.startsWith("sha256:"));
|
||||
if (oldEntries.length > 0) {
|
||||
console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries");
|
||||
console.log("[Cache] Migrating: removing", oldEntries.length, "old entries");
|
||||
for (const oldId of oldEntries) {
|
||||
await TrackStorage.remove(oldId);
|
||||
}
|
||||
// Re-fetch after cleanup
|
||||
const updated = await TrackStorage.list();
|
||||
M.cachedTracks = new Set(updated);
|
||||
} else {
|
||||
M.cachedTracks = new Set(cached);
|
||||
}
|
||||
console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached");
|
||||
console.log("[Cache] Updated:", M.cachedTracks.size, "tracks cached");
|
||||
};
|
||||
|
||||
// Debug: log cache status for current track
|
||||
// Debug functions
|
||||
M.debugCacheStatus = function() {
|
||||
if (!M.currentTrackId) {
|
||||
console.log("[Cache Debug] No current track");
|
||||
return;
|
||||
}
|
||||
const trackCache = M.getTrackCache(M.currentTrackId);
|
||||
const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100);
|
||||
const inCachedTracks = M.cachedTracks.has(M.currentTrackId);
|
||||
const hasBlobUrl = M.trackBlobs.has(M.currentTrackId);
|
||||
const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId);
|
||||
|
||||
console.log("[Cache Debug]", {
|
||||
trackId: M.currentTrackId.slice(0, 16) + "...",
|
||||
segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`,
|
||||
inCachedTracks,
|
||||
hasBlobUrl,
|
||||
bulkStarted,
|
||||
loadingSegments: [...M.loadingSegments],
|
||||
cachedTracksSize: M.cachedTracks.size
|
||||
segments: `${trackCache.size}/${M.SEGMENTS}`,
|
||||
inCachedTracks: M.cachedTracks.has(M.currentTrackId),
|
||||
hasBlobUrl: M.trackBlobs.has(M.currentTrackId)
|
||||
});
|
||||
};
|
||||
|
||||
// Debug: compare queue track IDs with cached track IDs
|
||||
M.debugCacheMismatch = function() {
|
||||
console.log("[Cache Mismatch Debug]");
|
||||
console.log("=== Raw State ===");
|
||||
console.log("M.cachedTracks:", M.cachedTracks);
|
||||
console.log("M.trackCaches:", M.trackCaches);
|
||||
console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]);
|
||||
console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted);
|
||||
console.log("=== Queue Tracks ===");
|
||||
console.log("M.queue tracks:");
|
||||
M.queue.forEach((t, i) => {
|
||||
const id = t.id || t.filename;
|
||||
console.log(` [${i}] ${t.title?.slice(0, 30)} | id: ${id?.slice(0, 12)}... | cached: ${M.cachedTracks.has(id)}`);
|
||||
});
|
||||
console.log("=== Cached Track IDs ===");
|
||||
[...M.cachedTracks].forEach(id => {
|
||||
console.log(` ${id.slice(0, 20)}...`);
|
||||
console.log(` [${i}] ${t.title?.slice(0, 30)} | cached: ${M.cachedTracks.has(id)}`);
|
||||
});
|
||||
};
|
||||
|
||||
// Debug: detailed info for a specific track
|
||||
M.debugTrack = function(index) {
|
||||
const track = M.queue[index];
|
||||
if (!track) {
|
||||
console.log("[Debug] No track at index", index);
|
||||
return;
|
||||
}
|
||||
const id = track.id || track.filename;
|
||||
console.log("[Debug Track]", {
|
||||
index,
|
||||
title: track.title,
|
||||
id,
|
||||
idPrefix: id?.slice(0, 16),
|
||||
inCachedTracks: M.cachedTracks.has(id),
|
||||
inTrackCaches: M.trackCaches.has(id),
|
||||
segmentCount: M.trackCaches.get(id)?.size || 0,
|
||||
inTrackBlobs: M.trackBlobs.has(id),
|
||||
bulkStarted: M.bulkDownloadStarted.get(id)
|
||||
});
|
||||
};
|
||||
|
||||
// Clear all caches (for debugging)
|
||||
M.clearAllCaches = async function() {
|
||||
await TrackStorage.clear();
|
||||
M.cachedTracks.clear();
|
||||
|
|
@ -398,324 +186,80 @@
|
|||
M.bulkDownloadStarted.clear();
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
console.log("[Cache] All caches cleared. Refresh the page.");
|
||||
console.log("[Cache] Cleared. Refresh the page.");
|
||||
};
|
||||
|
||||
// Render the current queue
|
||||
M.renderQueue = function() {
|
||||
const container = M.$("#queue");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
// Initialize containers
|
||||
function initContainers() {
|
||||
const queueEl = M.$("#queue");
|
||||
const libraryEl = M.$("#library");
|
||||
|
||||
const canEdit = M.canControl();
|
||||
|
||||
// Setup container-level drag handlers for dropping from library
|
||||
if (canEdit) {
|
||||
container.ondragover = (e) => {
|
||||
if (dragSource === 'library') {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
// If no tracks or hovering at bottom, show we can drop
|
||||
if (M.queue.length === 0) {
|
||||
container.classList.add("drop-target");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.ondragleave = (e) => {
|
||||
// Only remove if leaving the container entirely
|
||||
if (!container.contains(e.relatedTarget)) {
|
||||
container.classList.remove("drop-target");
|
||||
}
|
||||
};
|
||||
|
||||
container.ondrop = (e) => {
|
||||
container.classList.remove("drop-target");
|
||||
// Handle drop on empty queue or at the end
|
||||
if (dragSource === 'library' && draggedLibraryIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
|
||||
insertTracksAtPosition(draggedLibraryIds, targetIndex);
|
||||
draggedLibraryIds = [];
|
||||
dragSource = null;
|
||||
dropTargetIndex = null;
|
||||
}
|
||||
};
|
||||
if (queueEl && !queueContainer) {
|
||||
queueContainer = M.trackContainer.createContainer({
|
||||
type: 'queue',
|
||||
element: queueEl,
|
||||
getTracks: () => M.queue,
|
||||
canReorder: true,
|
||||
onRender: () => M.updateNowPlayingBar()
|
||||
});
|
||||
}
|
||||
|
||||
if (M.queue.length === 0) {
|
||||
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
|
||||
M.updateNowPlayingBar();
|
||||
if (libraryEl && !libraryContainer) {
|
||||
libraryContainer = M.trackContainer.createContainer({
|
||||
type: 'library',
|
||||
element: libraryEl,
|
||||
getTracks: () => M.library,
|
||||
getFilteredTracks: () => {
|
||||
const query = M.librarySearchQuery.toLowerCase();
|
||||
if (!query) {
|
||||
return M.library.map((track, i) => ({ track, originalIndex: i }));
|
||||
}
|
||||
return M.library
|
||||
.map((track, i) => ({ track, originalIndex: i }))
|
||||
.filter(({ track }) => {
|
||||
const title = track.title?.trim() || track.filename || '';
|
||||
return title.toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Render functions
|
||||
M.renderQueue = function() {
|
||||
initContainers();
|
||||
if (queueContainer) {
|
||||
queueContainer.render();
|
||||
}
|
||||
updateQueueDuration();
|
||||
};
|
||||
|
||||
function updateQueueDuration() {
|
||||
const el = M.$("#queue-duration");
|
||||
if (!el) return;
|
||||
const totalSecs = M.queue.reduce((sum, t) => sum + (t.duration || 0), 0);
|
||||
if (totalSecs === 0) {
|
||||
el.textContent = "";
|
||||
return;
|
||||
}
|
||||
const hours = Math.floor(totalSecs / 3600);
|
||||
const mins = Math.floor((totalSecs % 3600) / 60);
|
||||
const secs = Math.floor(totalSecs % 60);
|
||||
let text = "";
|
||||
if (hours > 0) text = `${hours}h ${mins}m`;
|
||||
else if (mins > 0) text = `${mins}m ${secs}s`;
|
||||
else text = `${secs}s`;
|
||||
el.textContent = `(${M.queue.length} tracks · ${text})`;
|
||||
}
|
||||
|
||||
// Debug: log first few track cache statuses
|
||||
if (M.queue.length > 0 && M.cachedTracks.size > 0) {
|
||||
const sample = M.queue.slice(0, 3).map(t => {
|
||||
const id = t.id || t.filename;
|
||||
return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) };
|
||||
});
|
||||
console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12)));
|
||||
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() {
|
||||
const bar = M.$("#now-playing-bar");
|
||||
if (!bar) return;
|
||||
|
|
@ -732,7 +276,6 @@
|
|||
bar.classList.remove("hidden");
|
||||
};
|
||||
|
||||
// Scroll queue to current track
|
||||
M.scrollToCurrentTrack = function() {
|
||||
const container = M.$("#queue");
|
||||
if (!container) return;
|
||||
|
|
@ -743,226 +286,11 @@
|
|||
}
|
||||
};
|
||||
|
||||
// Setup now-playing bar click handler
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const bar = M.$("#now-playing-bar");
|
||||
if (bar) {
|
||||
bar.onclick = () => M.scrollToCurrentTrack();
|
||||
}
|
||||
});
|
||||
|
||||
// Library search state
|
||||
M.librarySearchQuery = "";
|
||||
|
||||
// Render the library
|
||||
M.renderLibrary = function() {
|
||||
const container = M.$("#library");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
if (M.library.length === 0) {
|
||||
container.innerHTML = '<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);
|
||||
});
|
||||
// Backwards compatibility
|
||||
M.clearSelections = function() {
|
||||
M.clearAllSelections();
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
};
|
||||
|
||||
// Load library from server
|
||||
|
|
@ -976,8 +304,13 @@
|
|||
}
|
||||
};
|
||||
|
||||
// Setup library search
|
||||
// Setup event listeners
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const bar = M.$("#now-playing-bar");
|
||||
if (bar) {
|
||||
bar.onclick = () => M.scrollToCurrentTrack();
|
||||
}
|
||||
|
||||
const searchInput = M.$("#library-search");
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,25 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
|||
.task-item .task-progress { font-size: 0.7rem; color: #888; flex-shrink: 0; }
|
||||
.task-item .task-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: #ea4; transition: width 0.2s; }
|
||||
.task-item.complete .task-bar { background: #4e8; }
|
||||
.slow-queue-section { margin-top: 0.5rem; border-top: 1px solid #333; padding-top: 0.5rem; }
|
||||
.slow-queue-section.hidden { display: none; }
|
||||
.slow-queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0 0.2rem; gap: 0.5rem; }
|
||||
.slow-queue-title { font-size: 0.75rem; color: #888; font-weight: 500; }
|
||||
.slow-queue-timer { font-size: 0.7rem; color: #6af; flex: 1; }
|
||||
.slow-queue-cancel-all { background: none; border: 1px solid #633; color: #a66; font-size: 0.65rem; padding: 0.15rem 0.4rem; border-radius: 3px; cursor: pointer; transition: all 0.15s; }
|
||||
.slow-queue-cancel-all:hover { background: #422; border-color: #844; color: #e88; }
|
||||
.slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 200px; overflow-y: auto; }
|
||||
.slow-queue-playlist-header { font-size: 0.7rem; color: #888; padding: 0.3rem 0.2rem 0.15rem; margin-top: 0.2rem; border-top: 1px solid #2a2a2a; }
|
||||
.slow-queue-playlist-header:first-child { border-top: none; margin-top: 0; }
|
||||
.slow-queue-item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; background: #1a1a2a; border-radius: 3px; font-size: 0.75rem; color: #6af; }
|
||||
.slow-queue-item.next { background: #1a2a2a; color: #4cf; }
|
||||
.slow-queue-item-icon { flex-shrink: 0; font-size: 0.7rem; }
|
||||
.slow-queue-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.slow-queue-cancel { background: none; border: none; color: #666; cursor: pointer; padding: 0 0.2rem; font-size: 0.7rem; opacity: 0; transition: opacity 0.15s; }
|
||||
.slow-queue-item:hover .slow-queue-cancel { opacity: 1; }
|
||||
.slow-queue-cancel:hover { color: #e44; }
|
||||
.slow-queue-show-toggle { background: none; border: none; color: #68a; font-size: 0.7rem; padding: 0.3rem; cursor: pointer; width: 100%; text-align: center; }
|
||||
.slow-queue-show-toggle:hover { color: #8af; text-decoration: underline; }
|
||||
.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||
.scan-progress.hidden { display: none; }
|
||||
.scan-progress.complete { color: #4e8; background: #1a2a1a; }
|
||||
|
|
@ -110,6 +129,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
|||
.upload-dropzone.hidden { display: none; }
|
||||
.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; }
|
||||
#queue-title { margin: 0 0 0.3rem 0; }
|
||||
#queue-duration { font-size: 0.75rem; color: #888; font-weight: normal; }
|
||||
.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.now-playing-bar:hover { background: #2a3a2a; }
|
||||
.now-playing-bar.hidden { display: none; }
|
||||
|
|
@ -125,9 +145,9 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
|||
.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
|
||||
.search-input::placeholder { color: #666; }
|
||||
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
|
||||
#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
|
||||
#library .track[title], #queue .track[title] { cursor: pointer; }
|
||||
#library .track:hover, #queue .track:hover { background: #222; }
|
||||
#library .track, #queue .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
|
||||
#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
|
||||
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
|
||||
#queue .track.active { background: #2a4a3a; color: #4e8; }
|
||||
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
|
||||
.track.cached .cache-indicator { background: #4e8; }
|
||||
|
|
@ -136,6 +156,10 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
|||
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||
.track-actions .duration { color: #666; font-size: 0.75rem; }
|
||||
.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
||||
.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
|
||||
.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
||||
.track-actions .track-preview-btn:hover { background: #4a4; color: #fff; }
|
||||
.track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
|
||||
.track:hover .track-add, .track:hover .track-remove { opacity: 0.6; }
|
||||
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
|
||||
|
|
@ -146,9 +170,13 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
|||
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
|
||||
.track.selected { background: #2a3a4a; }
|
||||
.track.dragging { opacity: 0.5; }
|
||||
/* Allow drop events to pass through to parent track element */
|
||||
.track > * { pointer-events: none; }
|
||||
.track > .track-actions { pointer-events: auto; }
|
||||
.track > .track-actions > * { pointer-events: auto; }
|
||||
.track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
|
||||
.track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
|
||||
#queue.drop-target, #queue .drop-zone { border: 2px dashed #4e8; border-radius: 4px; }
|
||||
#queue.drop-target, #queue .drop-zone, #playlist-tracks.drop-target { border: 2px dashed #4e8; border-radius: 4px; }
|
||||
#queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; }
|
||||
|
||||
/* Context menu */
|
||||
|
|
@ -261,6 +289,44 @@ button:hover { background: #333; }
|
|||
.history-item.history-error { color: #e44; background: #2a1a1a; }
|
||||
.history-time { color: #666; margin-right: 0.4rem; }
|
||||
|
||||
/* Playlists UI */
|
||||
.playlists-container { display: flex; flex: 1; min-height: 0; gap: 0.5rem; }
|
||||
#playlists-list-panel { width: 180px; flex-shrink: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
#playlist-contents-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||||
.playlists-section { margin-bottom: 0.5rem; }
|
||||
.playlists-section h4 { color: #666; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.3rem; padding: 0 0.3rem; }
|
||||
.playlist-item { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem; }
|
||||
.playlist-item:hover { background: #2a2a2a; }
|
||||
.playlist-item.selected { background: #2a4a3a; }
|
||||
.playlist-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playlist-owner { color: #666; font-size: 0.7rem; }
|
||||
.playlist-count { color: #666; font-size: 0.7rem; background: #333; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||
.empty-playlists { color: #555; font-size: 0.75rem; padding: 0.5rem; font-style: italic; }
|
||||
.new-playlist-btn { width: 100%; padding: 0.4rem 0.5rem; background: #252525; border: 1px dashed #444; border-radius: 4px; color: #888; font-size: 0.8rem; cursor: pointer; margin-top: auto; }
|
||||
.new-playlist-btn:hover { background: #2a2a2a; border-color: #4e8; color: #4e8; }
|
||||
.new-playlist-input, .playlist-rename-input { width: 100%; padding: 0.3rem 0.5rem; background: #222; border: 1px solid #4e8; border-radius: 4px; color: #eee; font-size: 0.8rem; margin-bottom: 0.3rem; outline: none; }
|
||||
#playlist-contents-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0.3rem; background: #252525; border-radius: 4px; }
|
||||
#selected-playlist-name { font-size: 0.9rem; font-weight: 600; color: #eee; }
|
||||
#playlist-actions { display: flex; gap: 0.3rem; }
|
||||
#playlist-actions.hidden { display: none; }
|
||||
#playlist-actions button { padding: 0.2rem 0.5rem; font-size: 0.75rem; background: #333; border: 1px solid #444; border-radius: 3px; color: #aaa; cursor: pointer; }
|
||||
#playlist-actions button:hover { background: #444; color: #eee; }
|
||||
#playlist-tracks { flex: 1; overflow-y: auto; }
|
||||
.playlist-track { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer; }
|
||||
.playlist-track:hover { background: #2a2a2a; }
|
||||
.playlist-track .track-number { color: #555; font-size: 0.7rem; min-width: 1.5rem; }
|
||||
.playlist-track .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playlist-track .track-duration { color: #666; font-size: 0.75rem; }
|
||||
.empty-playlist-tracks { color: #555; font-size: 0.8rem; padding: 1rem; text-align: center; font-style: italic; }
|
||||
.context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; }
|
||||
.context-menu-item.has-submenu { position: relative; }
|
||||
.context-submenu { position: absolute; display: none; top: -4px; left: calc(100% - 8px); padding-left: 8px; background: transparent; min-width: 120px; z-index: 1001; }
|
||||
.context-submenu-inner { background: #222; border: 1px solid #444; border-radius: 4px; padding: 0.2rem 0; }
|
||||
.context-menu-item.has-submenu:hover > .context-submenu { display: block; }
|
||||
.context-submenu .context-menu-item { padding: 0.4rem 0.75rem; }
|
||||
.context-menu-item.disabled { color: #666; cursor: default; }
|
||||
.context-menu-item.disabled:hover { background: transparent; }
|
||||
|
||||
/* Mobile tab bar - hidden on desktop */
|
||||
#mobile-tabs { display: none; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 tasksEmpty = M.$("#tasks-empty");
|
||||
|
||||
// Slow queue section elements (created dynamically)
|
||||
let slowQueueSection = null;
|
||||
let slowQueuePollInterval = null;
|
||||
|
||||
if (!addBtn || !fileInput || !dropzone) return;
|
||||
|
||||
function openPanel() {
|
||||
|
|
@ -103,20 +107,23 @@
|
|||
|
||||
if (data.type === "playlist") {
|
||||
// Ask user to confirm playlist download
|
||||
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.`);
|
||||
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`);
|
||||
|
||||
if (confirmed) {
|
||||
// Confirm playlist download
|
||||
// Confirm playlist download with title for auto-playlist creation
|
||||
const confirmRes = await fetch("/api/fetch/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: data.items })
|
||||
body: JSON.stringify({ items: data.items, playlistTitle: data.title })
|
||||
});
|
||||
|
||||
if (confirmRes.ok) {
|
||||
const confirmData = await confirmRes.json();
|
||||
M.showToast(confirmData.message);
|
||||
// Tasks will be created by WebSocket progress messages
|
||||
M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`);
|
||||
// Refresh playlists to show the new one
|
||||
if (M.playlists?.load) M.playlists.load();
|
||||
// Refresh slow queue display
|
||||
pollSlowQueue();
|
||||
} else {
|
||||
const err = await confirmRes.json().catch(() => ({}));
|
||||
M.showToast(err.error || "Failed to queue playlist", "error");
|
||||
|
|
@ -200,10 +207,187 @@
|
|||
const fetchTasks = new Map(); // Map<id, taskHandle>
|
||||
|
||||
function updateTasksEmpty() {
|
||||
const hasTasks = tasksList.children.length > 0;
|
||||
tasksEmpty.classList.toggle("hidden", hasTasks);
|
||||
const hasActiveTasks = tasksList.children.length > 0;
|
||||
const hasSlowQueue = slowQueueSection && !slowQueueSection.classList.contains("hidden");
|
||||
tasksEmpty.classList.toggle("hidden", hasActiveTasks || hasSlowQueue);
|
||||
}
|
||||
|
||||
// Slow queue display
|
||||
function createSlowQueueSection() {
|
||||
if (slowQueueSection) return slowQueueSection;
|
||||
|
||||
slowQueueSection = document.createElement("div");
|
||||
slowQueueSection.className = "slow-queue-section hidden";
|
||||
slowQueueSection.innerHTML = `
|
||||
<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
|
||||
M.handleFetchProgress = function(data) {
|
||||
let task = fetchTasks.get(data.id);
|
||||
|
|
@ -220,9 +404,13 @@
|
|||
} else if (data.status === "complete") {
|
||||
task.setComplete();
|
||||
fetchTasks.delete(data.id);
|
||||
// Refresh slow queue on completion
|
||||
pollSlowQueue();
|
||||
} else if (data.status === "error") {
|
||||
task.setError(data.error || "Failed");
|
||||
fetchTasks.delete(data.id);
|
||||
// Refresh slow queue on error
|
||||
pollSlowQueue();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
|
|||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { add, remove, set, insertAt } = body;
|
||||
const { add, remove, set, insertAt, move, to } = body;
|
||||
|
||||
if (Array.isArray(set)) {
|
||||
const tracks = buildTracksFromIds(set, state.library);
|
||||
|
|
@ -229,6 +229,12 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
|
|||
return Response.json({ success: true, queueLength: channel.queue.length });
|
||||
}
|
||||
|
||||
// Move/reorder tracks within queue
|
||||
if (Array.isArray(move) && typeof to === "number") {
|
||||
channel.moveTracks(move, to);
|
||||
return Response.json({ success: true, queueLength: channel.queue.length });
|
||||
}
|
||||
|
||||
if (Array.isArray(remove) && remove.length > 0) {
|
||||
const indices = remove.filter((i: unknown) => typeof i === "number");
|
||||
channel.removeTracksByIndex(indices);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import {
|
|||
checkUrl,
|
||||
addToFastQueue,
|
||||
addToSlowQueue,
|
||||
getUserQueues,
|
||||
getQueues,
|
||||
cancelSlowQueueItem,
|
||||
cancelAllSlowQueueItems,
|
||||
} from "../ytdlp";
|
||||
import { getOrCreateUser } from "./helpers";
|
||||
import { createPlaylist, generateUniquePlaylistName } from "../db";
|
||||
|
||||
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
||||
|
||||
|
|
@ -72,12 +75,19 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
|
|||
}
|
||||
|
||||
try {
|
||||
const { items } = await req.json();
|
||||
const { items, playlistTitle } = await req.json();
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
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 hours = Math.floor(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`,
|
||||
queueType: "slow",
|
||||
estimatedTime,
|
||||
playlistId: playlist.id,
|
||||
playlistName: uniqueName,
|
||||
items: queueItems.map(i => ({ id: i.id, title: i.title }))
|
||||
}, { headers });
|
||||
} 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 {
|
||||
const { user, headers } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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,
|
||||
handleFetchConfirm,
|
||||
handleGetFetchQueue,
|
||||
handleCancelFetchItem,
|
||||
handleCancelAllFetchItems,
|
||||
} from "./fetch";
|
||||
|
||||
// Playlist routes
|
||||
import {
|
||||
handleListPlaylists,
|
||||
handleCreatePlaylist,
|
||||
handleGetPlaylist,
|
||||
handleUpdatePlaylist,
|
||||
handleDeletePlaylist,
|
||||
handleModifyPlaylistTracks,
|
||||
handleSharePlaylist,
|
||||
handleUnsharePlaylist,
|
||||
handleGetSharedPlaylist,
|
||||
handleCopySharedPlaylist,
|
||||
} from "./playlists";
|
||||
|
||||
// Static file serving
|
||||
import { handleStatic } from "./static";
|
||||
|
||||
|
|
@ -135,6 +151,53 @@ export function createRouter() {
|
|||
if (path === "/api/fetch" && req.method === "GET") {
|
||||
return handleGetFetchQueue(req, server);
|
||||
}
|
||||
if (path === "/api/fetch" && req.method === "DELETE") {
|
||||
return handleCancelAllFetchItems(req, server);
|
||||
}
|
||||
const fetchCancelMatch = path.match(/^\/api\/fetch\/([^/]+)$/);
|
||||
if (fetchCancelMatch && req.method === "DELETE") {
|
||||
return handleCancelFetchItem(req, server, fetchCancelMatch[1]);
|
||||
}
|
||||
|
||||
// Playlist routes
|
||||
if (path === "/api/playlists" && req.method === "GET") {
|
||||
return handleListPlaylists(req, server);
|
||||
}
|
||||
if (path === "/api/playlists" && req.method === "POST") {
|
||||
return handleCreatePlaylist(req, server);
|
||||
}
|
||||
|
||||
const sharedPlaylistMatch = path.match(/^\/api\/playlists\/shared\/([^/]+)$/);
|
||||
if (sharedPlaylistMatch && req.method === "GET") {
|
||||
return handleGetSharedPlaylist(req, server, sharedPlaylistMatch[1]);
|
||||
}
|
||||
if (sharedPlaylistMatch && req.method === "POST") {
|
||||
return handleCopySharedPlaylist(req, server, sharedPlaylistMatch[1]);
|
||||
}
|
||||
|
||||
const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/);
|
||||
if (playlistMatch && req.method === "GET") {
|
||||
return handleGetPlaylist(req, server, playlistMatch[1]);
|
||||
}
|
||||
if (playlistMatch && req.method === "PATCH") {
|
||||
return handleUpdatePlaylist(req, server, playlistMatch[1]);
|
||||
}
|
||||
if (playlistMatch && req.method === "DELETE") {
|
||||
return handleDeletePlaylist(req, server, playlistMatch[1]);
|
||||
}
|
||||
|
||||
const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/);
|
||||
if (playlistTracksMatch && req.method === "PATCH") {
|
||||
return handleModifyPlaylistTracks(req, server, playlistTracksMatch[1]);
|
||||
}
|
||||
|
||||
const playlistShareMatch = path.match(/^\/api\/playlists\/([^/]+)\/share$/);
|
||||
if (playlistShareMatch && req.method === "POST") {
|
||||
return handleSharePlaylist(req, server, playlistShareMatch[1]);
|
||||
}
|
||||
if (playlistShareMatch && req.method === "DELETE") {
|
||||
return handleUnsharePlaylist(req, server, playlistShareMatch[1]);
|
||||
}
|
||||
|
||||
// Auth routes
|
||||
if (path === "/api/auth/signup" && req.method === "POST") {
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
||||
if (await jsFile.exists()) {
|
||||
|
|
|
|||
199
ytdlp.ts
199
ytdlp.ts
|
|
@ -3,19 +3,32 @@
|
|||
|
||||
import { spawn } from "child_process";
|
||||
import { join } from "path";
|
||||
import {
|
||||
saveSlowQueueItem,
|
||||
updateSlowQueueItem,
|
||||
loadSlowQueue,
|
||||
deleteSlowQueueItem,
|
||||
clearCompletedSlowQueue,
|
||||
addTracksToPlaylist,
|
||||
type SlowQueueRow
|
||||
} from "./db";
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
userId: number;
|
||||
status: "queued" | "downloading" | "complete" | "error";
|
||||
status: "queued" | "downloading" | "complete" | "error" | "cancelled";
|
||||
progress: number;
|
||||
queueType: "fast" | "slow";
|
||||
error?: string;
|
||||
filename?: string;
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
playlistId?: string;
|
||||
playlistName?: string;
|
||||
position?: number;
|
||||
trackId?: string; // Set after successful download
|
||||
}
|
||||
|
||||
export interface YtdlpStatus {
|
||||
|
|
@ -65,6 +78,7 @@ let lastSlowDownload = 0;
|
|||
|
||||
// Callbacks
|
||||
let onProgress: ProgressCallback | null = null;
|
||||
let onTrackReady: ((item: QueueItem) => void) | null = null;
|
||||
|
||||
// Generate unique ID
|
||||
function generateId(): string {
|
||||
|
|
@ -115,6 +129,17 @@ export async function initYtdlp(config: {
|
|||
ffmpegAvailable = false;
|
||||
}
|
||||
|
||||
// Load persisted slow queue from database
|
||||
if (featureEnabled) {
|
||||
const savedQueue = loadSlowQueue();
|
||||
for (const row of savedQueue) {
|
||||
slowQueue.push(rowToQueueItem(row));
|
||||
}
|
||||
if (savedQueue.length > 0) {
|
||||
console.log(`[ytdlp] Restored ${savedQueue.length} items from slow queue`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start slow queue processor
|
||||
if (featureEnabled) {
|
||||
startSlowQueueProcessor();
|
||||
|
|
@ -123,6 +148,25 @@ export async function initYtdlp(config: {
|
|||
return getStatus();
|
||||
}
|
||||
|
||||
// Convert database row to QueueItem
|
||||
function rowToQueueItem(row: SlowQueueRow): QueueItem {
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
userId: row.user_id,
|
||||
status: row.status as QueueItem["status"],
|
||||
progress: row.progress,
|
||||
queueType: "slow",
|
||||
error: row.error ?? undefined,
|
||||
createdAt: row.created_at * 1000,
|
||||
completedAt: row.completed_at ? row.completed_at * 1000 : undefined,
|
||||
playlistId: row.playlist_id ?? undefined,
|
||||
playlistName: row.playlist_name ?? undefined,
|
||||
position: row.position ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Run a command and return stdout
|
||||
function runCommand(cmd: string, args: string[]): Promise<string> {
|
||||
const fullCmd = `${cmd} ${args.join(" ")}`;
|
||||
|
|
@ -162,6 +206,11 @@ export function setProgressCallback(callback: ProgressCallback): void {
|
|||
onProgress = callback;
|
||||
}
|
||||
|
||||
// Set track ready callback (called when download completes and needs playlist association)
|
||||
export function setTrackReadyCallback(callback: (item: QueueItem) => void): void {
|
||||
onTrackReady = callback;
|
||||
}
|
||||
|
||||
// Get all queue items
|
||||
export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
|
||||
const now = Date.now();
|
||||
|
|
@ -258,8 +307,13 @@ export function addToFastQueue(url: string, title: string, userId: number): Queu
|
|||
}
|
||||
|
||||
// Add items to slow queue (for playlists)
|
||||
export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] {
|
||||
const queueItems: QueueItem[] = items.map(item => ({
|
||||
export function addToSlowQueue(
|
||||
items: { url: string; title: string }[],
|
||||
userId: number,
|
||||
playlist?: { id: string; name: string }
|
||||
): QueueItem[] {
|
||||
const now = Date.now();
|
||||
const queueItems: QueueItem[] = items.map((item, index) => ({
|
||||
id: generateId(),
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
|
|
@ -267,8 +321,28 @@ export function addToSlowQueue(items: { url: string; title: string }[], userId:
|
|||
status: "queued" as const,
|
||||
progress: 0,
|
||||
queueType: "slow" as const,
|
||||
createdAt: Date.now()
|
||||
createdAt: now,
|
||||
playlistId: playlist?.id,
|
||||
playlistName: playlist?.name,
|
||||
position: playlist ? index : undefined
|
||||
}));
|
||||
|
||||
// Persist to database
|
||||
for (const item of queueItems) {
|
||||
saveSlowQueueItem({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
userId: item.userId,
|
||||
status: item.status,
|
||||
progress: item.progress,
|
||||
playlistId: item.playlistId,
|
||||
playlistName: item.playlistName,
|
||||
position: item.position,
|
||||
createdAt: Math.floor(item.createdAt / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
slowQueue.push(...queueItems);
|
||||
return queueItems;
|
||||
}
|
||||
|
|
@ -366,6 +440,21 @@ async function downloadItem(item: QueueItem): Promise<void> {
|
|||
item.status = "complete";
|
||||
item.progress = 100;
|
||||
item.completedAt = Date.now();
|
||||
|
||||
// Update database
|
||||
if (item.queueType === "slow") {
|
||||
updateSlowQueueItem(item.id, {
|
||||
status: "complete",
|
||||
progress: 100,
|
||||
completedAt: Math.floor(item.completedAt / 1000)
|
||||
});
|
||||
|
||||
// Register for playlist addition immediately - library will match when it scans
|
||||
if (item.playlistId && onTrackReady) {
|
||||
onTrackReady(item);
|
||||
}
|
||||
}
|
||||
|
||||
notifyProgress(item);
|
||||
|
||||
// Remove from queue after delay
|
||||
|
|
@ -374,6 +463,17 @@ async function downloadItem(item: QueueItem): Promise<void> {
|
|||
} catch (e: any) {
|
||||
item.status = "error";
|
||||
item.error = e.message || "Download failed";
|
||||
item.completedAt = Date.now();
|
||||
|
||||
// Update database
|
||||
if (item.queueType === "slow") {
|
||||
updateSlowQueueItem(item.id, {
|
||||
status: "error",
|
||||
error: item.error,
|
||||
completedAt: Math.floor(item.completedAt / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
notifyProgress(item);
|
||||
|
||||
// Remove from queue after delay
|
||||
|
|
@ -389,9 +489,65 @@ function removeFromQueue(item: QueueItem): void {
|
|||
} else {
|
||||
const idx = slowQueue.findIndex(i => i.id === item.id);
|
||||
if (idx !== -1) slowQueue.splice(idx, 1);
|
||||
// Remove from database
|
||||
deleteSlowQueueItem(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a slow queue item
|
||||
export function cancelSlowQueueItem(id: string, userId: number): boolean {
|
||||
const item = slowQueue.find(i => i.id === id && i.userId === userId);
|
||||
if (!item || item.status === "downloading") {
|
||||
return false; // Can't cancel if not found, not owned, or already downloading
|
||||
}
|
||||
|
||||
item.status = "cancelled";
|
||||
item.completedAt = Date.now();
|
||||
|
||||
// Update database
|
||||
updateSlowQueueItem(id, {
|
||||
status: "cancelled",
|
||||
completedAt: Math.floor(item.completedAt / 1000)
|
||||
});
|
||||
|
||||
notifyProgress(item);
|
||||
|
||||
// Remove from queue after brief delay
|
||||
setTimeout(() => removeFromQueue(item), 1000);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cancel all queued items in slow queue for a user
|
||||
export function cancelAllSlowQueueItems(userId: number): number {
|
||||
const items = slowQueue.filter(i => i.userId === userId && i.status === "queued");
|
||||
let cancelled = 0;
|
||||
|
||||
for (const item of items) {
|
||||
item.status = "cancelled";
|
||||
item.completedAt = Date.now();
|
||||
|
||||
updateSlowQueueItem(item.id, {
|
||||
status: "cancelled",
|
||||
completedAt: Math.floor(item.completedAt / 1000)
|
||||
});
|
||||
|
||||
notifyProgress(item);
|
||||
cancelled++;
|
||||
}
|
||||
|
||||
// Remove all cancelled items after brief delay
|
||||
setTimeout(() => {
|
||||
for (let i = slowQueue.length - 1; i >= 0; i--) {
|
||||
if (slowQueue[i].status === "cancelled") {
|
||||
slowQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
// Notify progress callback
|
||||
function notifyProgress(item: QueueItem): void {
|
||||
if (onProgress) {
|
||||
|
|
@ -399,13 +555,43 @@ function notifyProgress(item: QueueItem): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Mark a slow queue item as skipped (already in library)
|
||||
export function skipSlowQueueItem(id: string, trackId: string): QueueItem | null {
|
||||
const item = slowQueue.find(i => i.id === id && i.status === "queued");
|
||||
if (!item) return null;
|
||||
|
||||
item.status = "complete";
|
||||
item.progress = 100;
|
||||
item.completedAt = Date.now();
|
||||
item.trackId = trackId;
|
||||
|
||||
// Update database
|
||||
updateSlowQueueItem(id, {
|
||||
status: "complete",
|
||||
progress: 100,
|
||||
completedAt: Math.floor(item.completedAt / 1000)
|
||||
});
|
||||
|
||||
notifyProgress(item);
|
||||
|
||||
// Remove from queue after brief delay
|
||||
setTimeout(() => removeFromQueue(item), 1000);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Get queued items from slow queue (for prescan)
|
||||
export function getQueuedSlowItems(): QueueItem[] {
|
||||
return slowQueue.filter(i => i.status === "queued");
|
||||
}
|
||||
|
||||
// Cleanup old completed/failed items
|
||||
export function cleanupOldItems(maxAge: number = 3600000): void {
|
||||
const now = Date.now();
|
||||
const cleanup = (queue: QueueItem[]) => {
|
||||
for (let i = queue.length - 1; i >= 0; i--) {
|
||||
const item = queue[i];
|
||||
if ((item.status === "complete" || item.status === "error") &&
|
||||
if ((item.status === "complete" || item.status === "error" || item.status === "cancelled") &&
|
||||
now - item.createdAt > maxAge) {
|
||||
queue.splice(i, 1);
|
||||
}
|
||||
|
|
@ -413,4 +599,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void {
|
|||
};
|
||||
cleanup(fastQueue);
|
||||
cleanup(slowQueue);
|
||||
|
||||
// Also cleanup database
|
||||
clearCompletedSlowQueue(Math.floor(maxAge / 1000));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue