created favicon and playlists
This commit is contained in:
parent
34d4c4ef66
commit
19d98e0cc9
58
AGENTS.md
58
AGENTS.md
|
|
@ -98,13 +98,25 @@ M.trackBlobs // Map<trackId, blobUrl> - blob URLs for cached tracks
|
||||||
|
|
||||||
```
|
```
|
||||||
GET / → Serves public/index.html
|
GET / → Serves public/index.html
|
||||||
|
GET /listen/:trackId → Serves index.html (direct track link)
|
||||||
GET /api/channels → List all channels with listener counts
|
GET /api/channels → List all channels with listener counts
|
||||||
POST /api/channels → Create a new channel
|
POST /api/channels → Create a new channel
|
||||||
GET /api/channels/:id → Get channel state
|
GET /api/channels/:id → Get channel state
|
||||||
|
PATCH /api/channels/:id → Rename channel
|
||||||
DELETE /api/channels/:id → Delete a channel (not default)
|
DELETE /api/channels/:id → Delete a channel (not default)
|
||||||
WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes
|
WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes
|
||||||
GET /api/tracks/:id → Serve audio by content hash (supports Range)
|
GET /api/tracks/:id → Serve audio by content hash (supports Range)
|
||||||
GET /api/library → List all tracks with id, filename, title, duration
|
GET /api/library → List all tracks with id, filename, title, duration
|
||||||
|
GET /api/playlists → List user's + shared playlists
|
||||||
|
POST /api/playlists → Create new playlist
|
||||||
|
GET /api/playlists/:id → Get playlist details
|
||||||
|
PATCH /api/playlists/:id → Update playlist (name, description, public)
|
||||||
|
DELETE /api/playlists/:id → Delete playlist
|
||||||
|
PATCH /api/playlists/:id/tracks → Modify tracks (add/remove/set)
|
||||||
|
POST /api/playlists/:id/share → Generate share token
|
||||||
|
DELETE /api/playlists/:id/share → Remove sharing
|
||||||
|
GET /api/playlists/shared/:token → Get shared playlist by token
|
||||||
|
POST /api/playlists/shared/:token → Copy shared playlist to own
|
||||||
```
|
```
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
@ -129,6 +141,7 @@ GET /api/library → List all tracks with id, filename, title, dura
|
||||||
- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
|
- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
|
||||||
- **tracks.ts** — Library listing, file upload, audio serving with range support.
|
- **tracks.ts** — Library listing, file upload, audio serving with range support.
|
||||||
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
|
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
|
||||||
|
- **playlists.ts** — Playlist CRUD and track management.
|
||||||
- **static.ts** — Static file serving (index.html, styles.css, JS files).
|
- **static.ts** — Static file serving (index.html, styles.css, JS files).
|
||||||
|
|
||||||
### Client (public/)
|
### Client (public/)
|
||||||
|
|
@ -137,7 +150,8 @@ GET /api/library → List all tracks with id, filename, title, dura
|
||||||
- **audioCache.js** — Track caching, segment downloads, prefetching
|
- **audioCache.js** — Track caching, segment downloads, prefetching
|
||||||
- **channelSync.js** — WebSocket connection, server sync, channel switching
|
- **channelSync.js** — WebSocket connection, server sync, channel switching
|
||||||
- **ui.js** — Progress bar, buffer display, UI updates
|
- **ui.js** — Progress bar, buffer display, UI updates
|
||||||
- **queue.js** — Queue/library rendering, cache status
|
- **queue.js** — Queue/library rendering, cache status, context menus
|
||||||
|
- **playlists.js** — Playlist UI, create/edit/delete, add tracks
|
||||||
- **controls.js** — Play, pause, seek, volume
|
- **controls.js** — Play, pause, seek, volume
|
||||||
- **auth.js** — Login, signup, logout
|
- **auth.js** — Login, signup, logout
|
||||||
- **init.js** — App initialization
|
- **init.js** — App initialization
|
||||||
|
|
@ -260,13 +274,55 @@ Tracks in Library and Queue should behave identically where applicable:
|
||||||
- ▶ Play track (local mode, single track)
|
- ▶ Play track (local mode, single track)
|
||||||
- ⏭ Play next (insert after current)
|
- ⏭ Play next (insert after current)
|
||||||
- + Add to queue (append to end)
|
- + Add to queue (append to end)
|
||||||
|
- 📁 Add to Playlist... (submenu)
|
||||||
|
- 🔗 Generate listening link
|
||||||
|
|
||||||
**Queue tracks:**
|
**Queue tracks:**
|
||||||
- ▶ Play track (jump to track)
|
- ▶ Play track (jump to track)
|
||||||
- ⏭ Play next (re-add after current)
|
- ⏭ Play next (re-add after current)
|
||||||
- + Add again (duplicate at end)
|
- + Add again (duplicate at end)
|
||||||
|
- 📁 Add to Playlist... (submenu)
|
||||||
|
- 🔗 Generate listening link
|
||||||
- ✕ Remove from queue
|
- ✕ Remove from queue
|
||||||
|
|
||||||
|
## Playlists
|
||||||
|
|
||||||
|
Playlists are reusable collections of tracks that can be added to the queue.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
```ts
|
||||||
|
interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
ownerId: number;
|
||||||
|
isPublic: boolean;
|
||||||
|
shareToken: string | null;
|
||||||
|
trackIds: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Structure
|
||||||
|
The Playlists tab has a dual-panel layout:
|
||||||
|
- **Left panel**: List of playlists (My Playlists + Shared)
|
||||||
|
- **Right panel**: Selected playlist's track list
|
||||||
|
|
||||||
|
### Context Menu Options
|
||||||
|
**Playlist (in list):**
|
||||||
|
- ▶ Add to Queue
|
||||||
|
- ⏭ Play Next
|
||||||
|
- ✏️ Rename (owner only)
|
||||||
|
- 🌐 Make Public / 🔒 Make Private
|
||||||
|
- 🔗 Copy Share Link / Generate Share Link
|
||||||
|
- 📋 Copy to My Playlists (shared only)
|
||||||
|
- 🗑️ Delete (owner only)
|
||||||
|
|
||||||
|
**Track (in playlist):**
|
||||||
|
- ▶ Play
|
||||||
|
- ➕ Add to Queue
|
||||||
|
- ⏭ Play Next
|
||||||
|
- 🗑️ Remove from Playlist (owner only)
|
||||||
|
|
||||||
### Mobile/Touch Support
|
### Mobile/Touch Support
|
||||||
- Larger touch targets (min 44px)
|
- Larger touch targets (min 44px)
|
||||||
- No hover-dependent features (always show action buttons)
|
- No hover-dependent features (always show action buttons)
|
||||||
|
|
|
||||||
277
db.ts
277
db.ts
|
|
@ -417,3 +417,280 @@ export function loadChannelQueue(channelId: string): string[] {
|
||||||
export function removeTrackFromQueues(trackId: string): void {
|
export function removeTrackFromQueues(trackId: string): void {
|
||||||
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId);
|
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playlist tables
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
owner_id INTEGER NOT NULL,
|
||||||
|
is_public INTEGER DEFAULT 0,
|
||||||
|
share_token TEXT,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id)`);
|
||||||
|
|
||||||
|
// Playlist types
|
||||||
|
export interface PlaylistRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
owner_id: number;
|
||||||
|
is_public: number;
|
||||||
|
share_token: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
ownerId: number;
|
||||||
|
isPublic: boolean;
|
||||||
|
shareToken: string | null;
|
||||||
|
trackIds: string[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist CRUD functions
|
||||||
|
export function createPlaylist(name: string, ownerId: number, description: string = ""): Playlist {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
db.query(`
|
||||||
|
INSERT INTO playlists (id, name, description, owner_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, name, description, ownerId, now, now);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
ownerId,
|
||||||
|
isPublic: false,
|
||||||
|
shareToken: null,
|
||||||
|
trackIds: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaylist(id: string): Playlist | null {
|
||||||
|
const row = db.query("SELECT * FROM playlists WHERE id = ?").get(id) as PlaylistRow | null;
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const tracks = db.query(
|
||||||
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||||
|
).all(id) as { track_id: string }[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
isPublic: !!row.is_public,
|
||||||
|
shareToken: row.share_token,
|
||||||
|
trackIds: tracks.map(t => t.track_id),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaylistsByUser(userId: number): Playlist[] {
|
||||||
|
const rows = db.query(
|
||||||
|
"SELECT * FROM playlists WHERE owner_id = ? ORDER BY name"
|
||||||
|
).all(userId) as PlaylistRow[];
|
||||||
|
|
||||||
|
return rows.map(row => {
|
||||||
|
const tracks = db.query(
|
||||||
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||||
|
).all(row.id) as { track_id: string }[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
isPublic: !!row.is_public,
|
||||||
|
shareToken: row.share_token,
|
||||||
|
trackIds: tracks.map(t => t.track_id),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicPlaylists(excludeUserId?: number): Playlist[] {
|
||||||
|
const query = excludeUserId
|
||||||
|
? "SELECT * FROM playlists WHERE is_public = 1 AND owner_id != ? ORDER BY name"
|
||||||
|
: "SELECT * FROM playlists WHERE is_public = 1 ORDER BY name";
|
||||||
|
|
||||||
|
const rows = excludeUserId
|
||||||
|
? db.query(query).all(excludeUserId) as PlaylistRow[]
|
||||||
|
: db.query(query).all() as PlaylistRow[];
|
||||||
|
|
||||||
|
return rows.map(row => {
|
||||||
|
const tracks = db.query(
|
||||||
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||||
|
).all(row.id) as { track_id: string }[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
isPublic: !!row.is_public,
|
||||||
|
shareToken: row.share_token,
|
||||||
|
trackIds: tracks.map(t => t.track_id),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaylistByShareToken(token: string): Playlist | null {
|
||||||
|
const row = db.query(
|
||||||
|
"SELECT * FROM playlists WHERE share_token = ?"
|
||||||
|
).get(token) as PlaylistRow | null;
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const tracks = db.query(
|
||||||
|
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
|
||||||
|
).all(row.id) as { track_id: string }[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
isPublic: !!row.is_public,
|
||||||
|
shareToken: row.share_token,
|
||||||
|
trackIds: tracks.map(t => t.track_id),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlaylist(id: string, updates: { name?: string; description?: string; isPublic?: boolean }): void {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sets: string[] = ["updated_at = ?"];
|
||||||
|
const values: any[] = [now];
|
||||||
|
|
||||||
|
if (updates.name !== undefined) {
|
||||||
|
sets.push("name = ?");
|
||||||
|
values.push(updates.name);
|
||||||
|
}
|
||||||
|
if (updates.description !== undefined) {
|
||||||
|
sets.push("description = ?");
|
||||||
|
values.push(updates.description);
|
||||||
|
}
|
||||||
|
if (updates.isPublic !== undefined) {
|
||||||
|
sets.push("is_public = ?");
|
||||||
|
values.push(updates.isPublic ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
db.query(`UPDATE playlists SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlaylist(id: string): void {
|
||||||
|
db.query("DELETE FROM playlists WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPlaylistTracks(playlistId: string, trackIds: string[]): void {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
db.query("BEGIN").run();
|
||||||
|
try {
|
||||||
|
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ?").run(playlistId);
|
||||||
|
|
||||||
|
const insert = db.query(
|
||||||
|
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
||||||
|
);
|
||||||
|
for (let i = 0; i < trackIds.length; i++) {
|
||||||
|
insert.run(playlistId, trackIds[i], i);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||||
|
db.query("COMMIT").run();
|
||||||
|
} catch (e) {
|
||||||
|
db.query("ROLLBACK").run();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTracksToPlaylist(playlistId: string, trackIds: string[]): void {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
db.query("BEGIN").run();
|
||||||
|
try {
|
||||||
|
// Get current max position
|
||||||
|
const max = db.query(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) as max_pos FROM playlist_tracks WHERE playlist_id = ?"
|
||||||
|
).get(playlistId) as { max_pos: number };
|
||||||
|
|
||||||
|
let pos = max.max_pos + 1;
|
||||||
|
const insert = db.query(
|
||||||
|
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
|
||||||
|
);
|
||||||
|
for (const trackId of trackIds) {
|
||||||
|
insert.run(playlistId, trackId, pos++);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||||
|
db.query("COMMIT").run();
|
||||||
|
} catch (e) {
|
||||||
|
db.query("ROLLBACK").run();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTrackFromPlaylist(playlistId: string, position: number): void {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
db.query("BEGIN").run();
|
||||||
|
try {
|
||||||
|
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position);
|
||||||
|
|
||||||
|
// Reorder remaining tracks
|
||||||
|
db.query(`
|
||||||
|
UPDATE playlist_tracks
|
||||||
|
SET position = position - 1
|
||||||
|
WHERE playlist_id = ? AND position > ?
|
||||||
|
`).run(playlistId, position);
|
||||||
|
|
||||||
|
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
|
||||||
|
db.query("COMMIT").run();
|
||||||
|
} catch (e) {
|
||||||
|
db.query("ROLLBACK").run();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -68,6 +68,7 @@
|
||||||
<div id="library-panel">
|
<div id="library-panel">
|
||||||
<div class="panel-tabs">
|
<div class="panel-tabs">
|
||||||
<button class="panel-tab active" data-tab="library">Library</button>
|
<button class="panel-tab active" data-tab="library">Library</button>
|
||||||
|
<button class="panel-tab" data-tab="playlists">Playlists</button>
|
||||||
<button class="panel-tab" data-tab="tasks">Tasks</button>
|
<button class="panel-tab" data-tab="tasks">Tasks</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-views">
|
<div class="panel-views">
|
||||||
|
|
@ -100,6 +101,31 @@
|
||||||
<div class="dropzone-content">Drop audio files here</div>
|
<div class="dropzone-content">Drop audio files here</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="playlists-view" class="panel-view">
|
||||||
|
<div class="playlists-container">
|
||||||
|
<div id="playlists-list-panel">
|
||||||
|
<div class="playlists-section">
|
||||||
|
<h4>My Playlists</h4>
|
||||||
|
<div id="my-playlists"></div>
|
||||||
|
</div>
|
||||||
|
<div class="playlists-section">
|
||||||
|
<h4>Shared</h4>
|
||||||
|
<div id="shared-playlists"></div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-new-playlist" class="new-playlist-btn">+ New Playlist</button>
|
||||||
|
</div>
|
||||||
|
<div id="playlist-contents-panel">
|
||||||
|
<div id="playlist-contents-header">
|
||||||
|
<span id="selected-playlist-name">Select a playlist</span>
|
||||||
|
<div id="playlist-actions" class="hidden">
|
||||||
|
<button id="btn-playlist-add-queue" title="Add all to queue">Add to Queue</button>
|
||||||
|
<button id="btn-playlist-play-next" title="Play next">Play Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="playlist-tracks"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="tasks-view" class="panel-view">
|
<div id="tasks-view" class="panel-view">
|
||||||
<div id="tasks-list"></div>
|
<div id="tasks-list"></div>
|
||||||
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
|
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
|
||||||
|
|
@ -158,6 +184,7 @@
|
||||||
<script src="/channelSync.js"></script>
|
<script src="/channelSync.js"></script>
|
||||||
<script src="/ui.js"></script>
|
<script src="/ui.js"></script>
|
||||||
<script src="/queue.js"></script>
|
<script src="/queue.js"></script>
|
||||||
|
<script src="/playlists.js"></script>
|
||||||
<script src="/controls.js"></script>
|
<script src="/controls.js"></script>
|
||||||
<script src="/auth.js"></script>
|
<script src="/auth.js"></script>
|
||||||
<script src="/upload.js"></script>
|
<script src="/upload.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
initPanelTabs();
|
initPanelTabs();
|
||||||
|
|
||||||
|
// Initialize playlists
|
||||||
|
if (M.playlists?.init) {
|
||||||
|
M.playlists.init();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update UI based on server status
|
// Update UI based on server status
|
||||||
|
|
@ -128,6 +133,10 @@
|
||||||
await M.loadCurrentUser();
|
await M.loadCurrentUser();
|
||||||
if (M.currentUser) {
|
if (M.currentUser) {
|
||||||
M.loadChannels();
|
M.loadChannels();
|
||||||
|
// Load playlists after auth
|
||||||
|
if (M.playlists?.load) {
|
||||||
|
M.playlists.load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle direct track link after everything is loaded
|
// Handle direct track link after everything is loaded
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,570 @@
|
||||||
|
// Playlists UI module
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
const $ = M.$;
|
||||||
|
const formatTime = M.fmt;
|
||||||
|
const showToast = M.showToast;
|
||||||
|
|
||||||
|
let myPlaylists = [];
|
||||||
|
let sharedPlaylists = [];
|
||||||
|
let selectedPlaylistId = null;
|
||||||
|
let selectedPlaylist = null;
|
||||||
|
|
||||||
|
async function loadPlaylists() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/playlists');
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Not logged in yet
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Failed to load playlists');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
myPlaylists = data.mine || [];
|
||||||
|
sharedPlaylists = data.shared || [];
|
||||||
|
renderPlaylistList();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load playlists:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaylistList() {
|
||||||
|
const myContainer = $('#my-playlists');
|
||||||
|
const sharedContainer = $('#shared-playlists');
|
||||||
|
if (!myContainer || !sharedContainer) return;
|
||||||
|
|
||||||
|
// My playlists
|
||||||
|
if (myPlaylists.length === 0) {
|
||||||
|
myContainer.innerHTML = '<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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaylistContents() {
|
||||||
|
const header = $('#selected-playlist-name');
|
||||||
|
const actions = $('#playlist-actions');
|
||||||
|
const container = $('#playlist-tracks');
|
||||||
|
|
||||||
|
if (!selectedPlaylist) {
|
||||||
|
header.textContent = 'Select a playlist';
|
||||||
|
actions.classList.add('hidden');
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.textContent = selectedPlaylist.name;
|
||||||
|
actions.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (selectedPlaylist.trackIds.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-playlist-tracks">No tracks in this playlist</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get track info from library
|
||||||
|
const tracks = selectedPlaylist.trackIds.map(id => {
|
||||||
|
const track = M.library.find(t => t.id === id);
|
||||||
|
return track || { id, title: 'Unknown track', duration: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = tracks.map((track, i) => `
|
||||||
|
<div class="playlist-track" data-id="${track.id}" data-index="${i}">
|
||||||
|
<span class="track-number">${i + 1}</span>
|
||||||
|
<span class="track-title">${escapeHtml(track.title || track.filename || 'Unknown')}</span>
|
||||||
|
<span class="track-duration">${formatTime(track.duration)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Attach click handlers
|
||||||
|
container.querySelectorAll('.playlist-track').forEach(el => {
|
||||||
|
el.oncontextmenu = (e) => showPlaylistTrackContextMenu(e, el.dataset.id, parseInt(el.dataset.index));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlaylistContextMenu(e, playlistId, isMine) {
|
||||||
|
e.preventDefault();
|
||||||
|
M.contextMenu.hide();
|
||||||
|
|
||||||
|
const playlist = isMine
|
||||||
|
? myPlaylists.find(p => p.id === playlistId)
|
||||||
|
: sharedPlaylists.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
// Add to queue options
|
||||||
|
items.push({
|
||||||
|
label: '▶ Add to Queue',
|
||||||
|
action: () => addPlaylistToQueue(playlistId)
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: '⏭ Play Next',
|
||||||
|
action: () => addPlaylistToQueue(playlistId, true)
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({ separator: true });
|
||||||
|
|
||||||
|
if (isMine) {
|
||||||
|
// Rename
|
||||||
|
items.push({
|
||||||
|
label: '✏️ Rename',
|
||||||
|
action: () => startRenamePlaylist(playlistId)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Share/unshare
|
||||||
|
if (playlist.isPublic) {
|
||||||
|
items.push({
|
||||||
|
label: '🔒 Make Private',
|
||||||
|
action: () => togglePlaylistPublic(playlistId, false)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
label: '🌐 Make Public',
|
||||||
|
action: () => togglePlaylistPublic(playlistId, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ separator: true });
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
items.push({
|
||||||
|
label: '🗑️ Delete',
|
||||||
|
action: () => deletePlaylist(playlistId),
|
||||||
|
className: 'danger'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Copy to my playlists
|
||||||
|
items.push({
|
||||||
|
label: '📋 Copy to My Playlists',
|
||||||
|
action: () => copyPlaylist(playlistId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
M.contextMenu.show(e, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlaylistTrackContextMenu(e, trackId, index) {
|
||||||
|
e.preventDefault();
|
||||||
|
M.contextMenu.hide();
|
||||||
|
|
||||||
|
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
// Play
|
||||||
|
items.push({
|
||||||
|
label: '▶ Play',
|
||||||
|
action: () => playTrackFromPlaylist(trackId)
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({ separator: true });
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
items.push({
|
||||||
|
label: '➕ Add to Queue',
|
||||||
|
action: () => addTracksToQueue([trackId])
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: '⏭ Play Next',
|
||||||
|
action: () => addTracksToQueue([trackId], true)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isMine) {
|
||||||
|
items.push({ separator: true });
|
||||||
|
|
||||||
|
// Remove from playlist
|
||||||
|
items.push({
|
||||||
|
label: '🗑️ Remove from Playlist',
|
||||||
|
action: () => removeTrackFromPlaylist(index),
|
||||||
|
className: 'danger'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
M.contextMenu.show(e, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPlaylistToQueue(playlistId, playNext = false) {
|
||||||
|
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
|
||||||
|
if (!playlist || playlist.trackIds.length === 0) {
|
||||||
|
showToast('Playlist is empty', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = playNext
|
||||||
|
? { add: playlist.trackIds, insertAt: M.currentIndex + 1 }
|
||||||
|
: { add: playlist.trackIds };
|
||||||
|
|
||||||
|
const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Failed to add to queue');
|
||||||
|
showToast(`Added ${playlist.trackIds.length} tracks to queue`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add playlist to queue:', err);
|
||||||
|
showToast('Failed to add to queue', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTracksToQueue(trackIds, playNext = false) {
|
||||||
|
try {
|
||||||
|
const body = playNext
|
||||||
|
? { add: trackIds, insertAt: M.currentIndex + 1 }
|
||||||
|
: { add: trackIds };
|
||||||
|
|
||||||
|
const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Failed to add to queue');
|
||||||
|
showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to queue`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add to queue:', err);
|
||||||
|
showToast('Failed to add to queue', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTrackFromPlaylist(trackId) {
|
||||||
|
// Desynced playback of single track
|
||||||
|
if (M.playDirectTrack) {
|
||||||
|
M.playDirectTrack(trackId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPlaylist(name) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/playlists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create playlist');
|
||||||
|
const playlist = await res.json();
|
||||||
|
showToast(`Created playlist "${name}"`);
|
||||||
|
await loadPlaylists();
|
||||||
|
selectPlaylist(playlist.id);
|
||||||
|
return playlist;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create playlist:', err);
|
||||||
|
showToast('Failed to create playlist', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlaylist(playlistId) {
|
||||||
|
const playlist = myPlaylists.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete playlist');
|
||||||
|
showToast(`Deleted playlist "${playlist.name}"`);
|
||||||
|
|
||||||
|
if (selectedPlaylistId === playlistId) {
|
||||||
|
selectedPlaylistId = null;
|
||||||
|
selectedPlaylist = null;
|
||||||
|
renderPlaylistContents();
|
||||||
|
}
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete playlist:', err);
|
||||||
|
showToast('Failed to delete playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRenamePlaylist(playlistId) {
|
||||||
|
const el = document.querySelector(`.playlist-item[data-id="${playlistId}"] .playlist-name`);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const playlist = myPlaylists.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'playlist-rename-input';
|
||||||
|
input.value = playlist.name;
|
||||||
|
|
||||||
|
const originalText = el.textContent;
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
const finish = async (save) => {
|
||||||
|
const newName = input.value.trim();
|
||||||
|
el.textContent = save && newName ? newName : originalText;
|
||||||
|
|
||||||
|
if (save && newName && newName !== originalText) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playlists/${playlistId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newName })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to rename');
|
||||||
|
showToast(`Renamed to "${newName}"`);
|
||||||
|
await loadPlaylists();
|
||||||
|
if (selectedPlaylistId === playlistId && selectedPlaylist) {
|
||||||
|
selectedPlaylist.name = newName;
|
||||||
|
renderPlaylistContents();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
el.textContent = originalText;
|
||||||
|
showToast('Failed to rename playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.onblur = () => finish(true);
|
||||||
|
input.onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
input.blur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlaylistPublic(playlistId, isPublic) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playlists/${playlistId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ isPublic })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update');
|
||||||
|
showToast(isPublic ? 'Playlist is now public' : 'Playlist is now private');
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to update playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateShareLink(playlistId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playlists/${playlistId}/share`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error('Failed to generate link');
|
||||||
|
const data = await res.json();
|
||||||
|
const url = `${window.location.origin}/playlist/${data.shareToken}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
showToast('Share link copied to clipboard');
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to generate share link', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyShareLink(token) {
|
||||||
|
const url = `${window.location.origin}/playlist/${token}`;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
showToast('Share link copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPlaylist(playlistId) {
|
||||||
|
const playlist = sharedPlaylists.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (playlist.shareToken) {
|
||||||
|
const res = await fetch(`/api/playlists/shared/${playlist.shareToken}`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error('Failed to copy');
|
||||||
|
} else {
|
||||||
|
// Create new playlist and copy tracks
|
||||||
|
const newPlaylist = await createPlaylist(`${playlist.name} (Copy)`);
|
||||||
|
if (newPlaylist) {
|
||||||
|
await fetch(`/api/playlists/${newPlaylist.id}/tracks`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ set: playlist.trackIds })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(`Copied "${playlist.name}" to your playlists`);
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to copy playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTrackFromPlaylist(index) {
|
||||||
|
if (!selectedPlaylistId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playlists/${selectedPlaylistId}/tracks`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ remove: [index] })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to remove track');
|
||||||
|
showToast('Track removed from playlist');
|
||||||
|
await selectPlaylist(selectedPlaylistId);
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to remove track', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracks to playlist (used from library/queue context menu)
|
||||||
|
async function addTracksToPlaylist(playlistId, trackIds) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ add: trackIds })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to add tracks');
|
||||||
|
|
||||||
|
const playlist = myPlaylists.find(p => p.id === playlistId);
|
||||||
|
showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to "${playlist?.name || 'playlist'}"`);
|
||||||
|
|
||||||
|
if (selectedPlaylistId === playlistId) {
|
||||||
|
await selectPlaylist(playlistId);
|
||||||
|
}
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to add tracks to playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show "Add to Playlist" submenu
|
||||||
|
function showAddToPlaylistMenu(trackIds) {
|
||||||
|
if (myPlaylists.length === 0) {
|
||||||
|
showToast('Create a playlist first', 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return myPlaylists.map(p => ({
|
||||||
|
label: p.name,
|
||||||
|
action: () => addTracksToPlaylist(p.id, trackIds)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>"']/g, c => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPlaylists() {
|
||||||
|
// New playlist button
|
||||||
|
const btnNew = $('#btn-new-playlist');
|
||||||
|
if (btnNew) {
|
||||||
|
btnNew.onclick = () => {
|
||||||
|
// Inline input for new playlist name
|
||||||
|
const container = $('#my-playlists');
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'new-playlist-input';
|
||||||
|
input.placeholder = 'Playlist name...';
|
||||||
|
container.insertBefore(input, container.firstChild);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
const finish = async (create) => {
|
||||||
|
const name = input.value.trim();
|
||||||
|
input.remove();
|
||||||
|
if (create && name) {
|
||||||
|
await createPlaylist(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.onblur = () => finish(true);
|
||||||
|
input.onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
input.blur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue buttons
|
||||||
|
const btnAddQueue = $('#btn-playlist-add-queue');
|
||||||
|
const btnPlayNext = $('#btn-playlist-play-next');
|
||||||
|
|
||||||
|
if (btnAddQueue) {
|
||||||
|
btnAddQueue.onclick = () => {
|
||||||
|
if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (btnPlayNext) {
|
||||||
|
btnPlayNext.onclick = () => {
|
||||||
|
if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load playlists when tab is shown
|
||||||
|
document.querySelectorAll('.panel-tab[data-tab="playlists"]').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
loadPlaylists();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for other modules
|
||||||
|
M.playlists = {
|
||||||
|
load: loadPlaylists,
|
||||||
|
init: initPlaylists,
|
||||||
|
getMyPlaylists: () => myPlaylists,
|
||||||
|
showAddToPlaylistMenu,
|
||||||
|
addTracksToPlaylist
|
||||||
|
};
|
||||||
|
})();
|
||||||
115
public/queue.js
115
public/queue.js
|
|
@ -164,15 +164,68 @@
|
||||||
menu.className = "context-menu";
|
menu.className = "context-menu";
|
||||||
|
|
||||||
items.forEach(item => {
|
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");
|
const el = document.createElement("div");
|
||||||
el.className = "context-menu-item" + (item.danger ? " danger" : "");
|
el.className = "context-menu-item" + (item.danger ? " danger" : "") + (item.disabled ? " disabled" : "");
|
||||||
el.textContent = item.label;
|
el.textContent = item.label;
|
||||||
el.onclick = (ev) => {
|
|
||||||
ev.stopPropagation();
|
if (item.submenu) {
|
||||||
menu.remove();
|
// Has submenu - show on hover
|
||||||
activeContextMenu = null;
|
el.classList.add("has-submenu");
|
||||||
item.action();
|
el.innerHTML += ' ▸';
|
||||||
};
|
|
||||||
|
const submenu = document.createElement("div");
|
||||||
|
submenu.className = "context-menu context-submenu";
|
||||||
|
|
||||||
|
item.submenu.forEach(sub => {
|
||||||
|
const subEl = document.createElement("div");
|
||||||
|
subEl.className = "context-menu-item";
|
||||||
|
subEl.textContent = sub.label;
|
||||||
|
subEl.onclick = (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
menu.remove();
|
||||||
|
activeContextMenu = null;
|
||||||
|
sub.action();
|
||||||
|
};
|
||||||
|
submenu.appendChild(subEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.appendChild(submenu);
|
||||||
|
|
||||||
|
// Position submenu on hover
|
||||||
|
el.onmouseenter = () => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
submenu.style.display = "block";
|
||||||
|
submenu.style.left = rect.width + "px";
|
||||||
|
submenu.style.top = "0px";
|
||||||
|
|
||||||
|
// Check if submenu goes off screen
|
||||||
|
const subRect = submenu.getBoundingClientRect();
|
||||||
|
if (subRect.right > window.innerWidth) {
|
||||||
|
submenu.style.left = (-subRect.width) + "px";
|
||||||
|
}
|
||||||
|
if (subRect.bottom > window.innerHeight) {
|
||||||
|
submenu.style.top = (window.innerHeight - subRect.bottom - 5) + "px";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.onmouseleave = () => {
|
||||||
|
submenu.style.display = "none";
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
el.onclick = (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (item.disabled) return;
|
||||||
|
menu.remove();
|
||||||
|
activeContextMenu = null;
|
||||||
|
item.action();
|
||||||
|
};
|
||||||
|
}
|
||||||
menu.appendChild(el);
|
menu.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -190,6 +243,17 @@
|
||||||
activeContextMenu = menu;
|
activeContextMenu = menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose context menu API for other modules (playlists.js)
|
||||||
|
M.contextMenu = {
|
||||||
|
show: showContextMenu,
|
||||||
|
hide: () => {
|
||||||
|
if (activeContextMenu) {
|
||||||
|
activeContextMenu.remove();
|
||||||
|
activeContextMenu = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Drag state for queue reordering
|
// Drag state for queue reordering
|
||||||
let draggedIndices = [];
|
let draggedIndices = [];
|
||||||
let draggedLibraryIds = [];
|
let draggedLibraryIds = [];
|
||||||
|
|
@ -698,6 +762,26 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to Playlist option
|
||||||
|
if (M.playlists && !M.currentUser?.is_guest) {
|
||||||
|
const trackIds = hasSelection
|
||||||
|
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
|
||||||
|
: [trackId];
|
||||||
|
const submenu = M.playlists.showAddToPlaylistMenu(trackIds);
|
||||||
|
if (submenu && submenu.length > 0) {
|
||||||
|
menuItems.push({
|
||||||
|
label: hasSelection && trackIds.length > 1 ? `📁 Add ${trackIds.length} to Playlist...` : "📁 Add to Playlist...",
|
||||||
|
submenu: submenu
|
||||||
|
});
|
||||||
|
} else if (M.playlists.getMyPlaylists().length === 0) {
|
||||||
|
menuItems.push({
|
||||||
|
label: "📁 Add to Playlist...",
|
||||||
|
disabled: true,
|
||||||
|
action: () => M.showToast("Create a playlist first in the Playlists tab", "info")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear selection option (if items selected)
|
// Clear selection option (if items selected)
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
|
|
@ -940,6 +1024,23 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to Playlist option
|
||||||
|
if (M.playlists && !M.currentUser?.is_guest) {
|
||||||
|
const submenu = M.playlists.showAddToPlaylistMenu(idsToAdd);
|
||||||
|
if (submenu && submenu.length > 0) {
|
||||||
|
menuItems.push({
|
||||||
|
label: hasSelection && idsToAdd.length > 1 ? `📁 Add ${idsToAdd.length} to Playlist...` : "📁 Add to Playlist...",
|
||||||
|
submenu: submenu
|
||||||
|
});
|
||||||
|
} else if (M.playlists.getMyPlaylists().length === 0) {
|
||||||
|
menuItems.push({
|
||||||
|
label: "📁 Add to Playlist...",
|
||||||
|
disabled: true,
|
||||||
|
action: () => M.showToast("Create a playlist first in the Playlists tab", "info")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Export all cached option (if there are cached tracks)
|
// Export all cached option (if there are cached tracks)
|
||||||
if (M.cachedTracks.size > 0) {
|
if (M.cachedTracks.size > 0) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,41 @@ button:hover { background: #333; }
|
||||||
.history-item.history-error { color: #e44; background: #2a1a1a; }
|
.history-item.history-error { color: #e44; background: #2a1a1a; }
|
||||||
.history-time { color: #666; margin-right: 0.4rem; }
|
.history-time { color: #666; margin-right: 0.4rem; }
|
||||||
|
|
||||||
|
/* Playlists UI */
|
||||||
|
.playlists-container { display: flex; flex: 1; min-height: 0; gap: 0.5rem; }
|
||||||
|
#playlists-list-panel { width: 180px; flex-shrink: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
#playlist-contents-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||||||
|
.playlists-section { margin-bottom: 0.5rem; }
|
||||||
|
.playlists-section h4 { color: #666; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.3rem; padding: 0 0.3rem; }
|
||||||
|
.playlist-item { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.playlist-item:hover { background: #2a2a2a; }
|
||||||
|
.playlist-item.selected { background: #2a4a3a; }
|
||||||
|
.playlist-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.playlist-owner { color: #666; font-size: 0.7rem; }
|
||||||
|
.playlist-count { color: #666; font-size: 0.7rem; background: #333; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||||
|
.empty-playlists { color: #555; font-size: 0.75rem; padding: 0.5rem; font-style: italic; }
|
||||||
|
.new-playlist-btn { width: 100%; padding: 0.4rem 0.5rem; background: #252525; border: 1px dashed #444; border-radius: 4px; color: #888; font-size: 0.8rem; cursor: pointer; margin-top: auto; }
|
||||||
|
.new-playlist-btn:hover { background: #2a2a2a; border-color: #4e8; color: #4e8; }
|
||||||
|
.new-playlist-input, .playlist-rename-input { width: 100%; padding: 0.3rem 0.5rem; background: #222; border: 1px solid #4e8; border-radius: 4px; color: #eee; font-size: 0.8rem; margin-bottom: 0.3rem; outline: none; }
|
||||||
|
#playlist-contents-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0.3rem; background: #252525; border-radius: 4px; }
|
||||||
|
#selected-playlist-name { font-size: 0.9rem; font-weight: 600; color: #eee; }
|
||||||
|
#playlist-actions { display: flex; gap: 0.3rem; }
|
||||||
|
#playlist-actions.hidden { display: none; }
|
||||||
|
#playlist-actions button { padding: 0.2rem 0.5rem; font-size: 0.75rem; background: #333; border: 1px solid #444; border-radius: 3px; color: #aaa; cursor: pointer; }
|
||||||
|
#playlist-actions button:hover { background: #444; color: #eee; }
|
||||||
|
#playlist-tracks { flex: 1; overflow-y: auto; }
|
||||||
|
.playlist-track { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer; }
|
||||||
|
.playlist-track:hover { background: #2a2a2a; }
|
||||||
|
.playlist-track .track-number { color: #555; font-size: 0.7rem; min-width: 1.5rem; }
|
||||||
|
.playlist-track .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.playlist-track .track-duration { color: #666; font-size: 0.75rem; }
|
||||||
|
.empty-playlist-tracks { color: #555; font-size: 0.8rem; padding: 1rem; text-align: center; font-style: italic; }
|
||||||
|
.context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; }
|
||||||
|
.context-menu-item.has-submenu { position: relative; }
|
||||||
|
.context-submenu { position: absolute; display: none; top: 0; left: 100%; margin-left: 2px; }
|
||||||
|
.context-menu-item.disabled { color: #666; cursor: default; }
|
||||||
|
.context-menu-item.disabled:hover { background: transparent; }
|
||||||
|
|
||||||
/* Mobile tab bar - hidden on desktop */
|
/* Mobile tab bar - hidden on desktop */
|
||||||
#mobile-tabs { display: none; }
|
#mobile-tabs { display: none; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,20 @@ import {
|
||||||
handleGetFetchQueue,
|
handleGetFetchQueue,
|
||||||
} from "./fetch";
|
} from "./fetch";
|
||||||
|
|
||||||
|
// Playlist routes
|
||||||
|
import {
|
||||||
|
handleListPlaylists,
|
||||||
|
handleCreatePlaylist,
|
||||||
|
handleGetPlaylist,
|
||||||
|
handleUpdatePlaylist,
|
||||||
|
handleDeletePlaylist,
|
||||||
|
handleModifyPlaylistTracks,
|
||||||
|
handleSharePlaylist,
|
||||||
|
handleUnsharePlaylist,
|
||||||
|
handleGetSharedPlaylist,
|
||||||
|
handleCopySharedPlaylist,
|
||||||
|
} from "./playlists";
|
||||||
|
|
||||||
// Static file serving
|
// Static file serving
|
||||||
import { handleStatic } from "./static";
|
import { handleStatic } from "./static";
|
||||||
|
|
||||||
|
|
@ -136,6 +150,46 @@ export function createRouter() {
|
||||||
return handleGetFetchQueue(req, server);
|
return handleGetFetchQueue(req, server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playlist routes
|
||||||
|
if (path === "/api/playlists" && req.method === "GET") {
|
||||||
|
return handleListPlaylists(req, server);
|
||||||
|
}
|
||||||
|
if (path === "/api/playlists" && req.method === "POST") {
|
||||||
|
return handleCreatePlaylist(req, server);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedPlaylistMatch = path.match(/^\/api\/playlists\/shared\/([^/]+)$/);
|
||||||
|
if (sharedPlaylistMatch && req.method === "GET") {
|
||||||
|
return handleGetSharedPlaylist(req, server, sharedPlaylistMatch[1]);
|
||||||
|
}
|
||||||
|
if (sharedPlaylistMatch && req.method === "POST") {
|
||||||
|
return handleCopySharedPlaylist(req, server, sharedPlaylistMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/);
|
||||||
|
if (playlistMatch && req.method === "GET") {
|
||||||
|
return handleGetPlaylist(req, server, playlistMatch[1]);
|
||||||
|
}
|
||||||
|
if (playlistMatch && req.method === "PATCH") {
|
||||||
|
return handleUpdatePlaylist(req, server, playlistMatch[1]);
|
||||||
|
}
|
||||||
|
if (playlistMatch && req.method === "DELETE") {
|
||||||
|
return handleDeletePlaylist(req, server, playlistMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/);
|
||||||
|
if (playlistTracksMatch && req.method === "PATCH") {
|
||||||
|
return handleModifyPlaylistTracks(req, server, playlistTracksMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistShareMatch = path.match(/^\/api\/playlists\/([^/]+)\/share$/);
|
||||||
|
if (playlistShareMatch && req.method === "POST") {
|
||||||
|
return handleSharePlaylist(req, server, playlistShareMatch[1]);
|
||||||
|
}
|
||||||
|
if (playlistShareMatch && req.method === "DELETE") {
|
||||||
|
return handleUnsharePlaylist(req, server, playlistShareMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
if (path === "/api/auth/signup" && req.method === "POST") {
|
if (path === "/api/auth/signup" && req.method === "POST") {
|
||||||
return handleSignup(req, server);
|
return handleSignup(req, server);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
import {
|
||||||
|
createPlaylist,
|
||||||
|
getPlaylist,
|
||||||
|
getPlaylistsByUser,
|
||||||
|
getPublicPlaylists,
|
||||||
|
getPlaylistByShareToken,
|
||||||
|
updatePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
setPlaylistTracks,
|
||||||
|
addTracksToPlaylist,
|
||||||
|
removeTrackFromPlaylist,
|
||||||
|
generatePlaylistShareToken,
|
||||||
|
removePlaylistShareToken,
|
||||||
|
findUserById,
|
||||||
|
} from "../db";
|
||||||
|
import { getOrCreateUser, userHasPermission } from "./helpers";
|
||||||
|
|
||||||
|
// GET /api/playlists - List user's + shared playlists
|
||||||
|
export function handleListPlaylists(req: Request, server: any): Response {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const myPlaylists = getPlaylistsByUser(user.id);
|
||||||
|
const sharedPlaylists = getPublicPlaylists(user.id);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
mine: myPlaylists,
|
||||||
|
shared: sharedPlaylists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/playlists - Create new playlist
|
||||||
|
export async function handleCreatePlaylist(req: Request, server: any): Promise<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[] };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If 'set' is provided, replace entire track list
|
||||||
|
if (body.set !== undefined) {
|
||||||
|
setPlaylistTracks(playlistId, body.set);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove tracks by position (do removes first, in reverse order)
|
||||||
|
if (body.remove?.length) {
|
||||||
|
const positions = [...body.remove].sort((a, b) => b - a);
|
||||||
|
for (const pos of positions) {
|
||||||
|
removeTrackFromPlaylist(playlistId, pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracks
|
||||||
|
if (body.add?.length) {
|
||||||
|
addTracksToPlaylist(playlistId, body.add);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/playlists/:id/share - Generate share token
|
||||||
|
export function handleSharePlaylist(req: Request, server: any, playlistId: string): Response {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = getPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlist.ownerId !== user.id && !user.is_admin) {
|
||||||
|
return Response.json({ error: "Not your playlist" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generatePlaylistShareToken(playlistId);
|
||||||
|
return Response.json({ shareToken: token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/playlists/:id/share - Remove sharing
|
||||||
|
export function handleUnsharePlaylist(req: Request, server: any, playlistId: string): Response {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = getPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlist.ownerId !== user.id && !user.is_admin) {
|
||||||
|
return Response.json({ error: "Not your playlist" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
removePlaylistShareToken(playlistId);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/playlists/shared/:token - Get shared playlist by token
|
||||||
|
export function handleGetSharedPlaylist(req: Request, server: any, token: string): Response {
|
||||||
|
const playlist = getPlaylistByShareToken(token);
|
||||||
|
if (!playlist) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = findUserById(playlist.ownerId);
|
||||||
|
return Response.json({
|
||||||
|
...playlist,
|
||||||
|
ownerName: owner?.username || "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/playlists/shared/:token/copy - Copy shared playlist to own
|
||||||
|
export function handleCopySharedPlaylist(req: Request, server: any, token: string): Response {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is_guest) {
|
||||||
|
return Response.json({ error: "Guests cannot copy playlists" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = getPlaylistByShareToken(token);
|
||||||
|
if (!original) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = createPlaylist(`${original.name} (Copy)`, user.id, original.description);
|
||||||
|
setPlaylistTracks(copy.id, original.trackIds);
|
||||||
|
|
||||||
|
return Response.json(copy, { status: 201 });
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,12 @@ export async function handleStatic(path: string): Promise<Response | null> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/favicon.ico") {
|
||||||
|
return new Response(file(join(PUBLIC_DIR, "favicon.ico")), {
|
||||||
|
headers: { "Content-Type": "image/x-icon" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (path.endsWith(".js")) {
|
if (path.endsWith(".js")) {
|
||||||
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
||||||
if (await jsFile.exists()) {
|
if (await jsFile.exists()) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue