saving
This commit is contained in:
commit
d3e5c24962
|
|
@ -0,0 +1,40 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
tmp/
|
||||
library_cache.db
|
||||
musicroom.db
|
||||
blastoise.db
|
||||
config.json
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
# MusicRoom
|
||||
|
||||
Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through queues sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server
|
||||
|
||||
The server does NOT decode or play audio. It tracks time:
|
||||
- `currentTimestamp = (Date.now() - channel.startedAt) / 1000`
|
||||
- When `currentTimestamp >= track.duration`, advance to next track, reset `startedAt`
|
||||
- A 1s `setInterval` checks if tracks need advancing and broadcasts state every 30s
|
||||
|
||||
### Channels
|
||||
|
||||
Channels are virtual radio stations. A **default channel** is created on server start with the full library:
|
||||
|
||||
```ts
|
||||
interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
queue: Track[]; // tracks in playback order
|
||||
currentIndex: number;
|
||||
startedAt: number;
|
||||
paused: boolean;
|
||||
clients: Set<WebSocket>;
|
||||
createdBy: number | null; // user ID or null for default
|
||||
isDefault: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
- **Default Channel**: Created on startup, plays all library tracks, cannot be deleted
|
||||
- **Dynamic Channels**: Users can create channels via POST `/api/channels`
|
||||
- **Channel Switching**: Clients switch channels via WebSocket `{ action: "switch", channelId }`
|
||||
|
||||
### Client
|
||||
|
||||
The player's role is simple: **play an arbitrary track by ID**. It does not manage queues or sync logic directly.
|
||||
|
||||
- Receives track ID and timestamp from server via WebSocket
|
||||
- Downloads audio from `/api/tracks/:id`
|
||||
- Syncs playback position to server timestamp
|
||||
- Caches tracks locally in IndexedDB
|
||||
|
||||
### Library & Queue Views
|
||||
|
||||
Both views display tracks with real-time status indicators:
|
||||
- **Green bar**: Track is fully cached locally (in IndexedDB)
|
||||
- **Yellow bar**: Track is not cached
|
||||
|
||||
The buffer bar (below progress) shows 20 segments indicating download/buffer status:
|
||||
- Segments fill as audio is buffered by browser or fetched via range requests
|
||||
- When all segments are buffered, the track is automatically cached to IndexedDB
|
||||
|
||||
## Content-Addressed Tracks
|
||||
|
||||
All tracks are identified by a **content hash** (`sha256:` prefix + first 64KB hash):
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `track.id` | Content hash - primary key, used for API, caching, database |
|
||||
| `track.filename` | Original filename - display only |
|
||||
| `track.title` | Metadata title - display only |
|
||||
|
||||
Benefits:
|
||||
- Deduplication (same file with different names = same track)
|
||||
- Renaming files without breaking queues
|
||||
- Reliable client-side caching by content hash
|
||||
|
||||
The client uses `track.id` for:
|
||||
- Caching tracks in IndexedDB (`TrackStorage.set(trackId, blob)`)
|
||||
- Fetching audio (`/api/tracks/:trackId`)
|
||||
- Checking cache status (`M.cachedTracks.has(trackId)`)
|
||||
|
||||
## Client Caching System
|
||||
|
||||
### Segment-Based Buffering
|
||||
- Tracks are divided into 20 virtual segments
|
||||
- Browser's native buffering is synced to `M.trackCaches` Map
|
||||
- Range requests fetch individual segments for seeking
|
||||
|
||||
### Full Track Caching
|
||||
When all 20 segments are buffered:
|
||||
1. Full track is downloaded via `downloadAndCacheTrack()`
|
||||
2. Stored in IndexedDB via `TrackStorage`
|
||||
3. Added to `M.cachedTracks` Set
|
||||
4. UI indicators update to green
|
||||
|
||||
### Cache State (in-memory)
|
||||
```js
|
||||
M.trackCaches // Map<trackId, Set<segmentIndex>> - per-track segment status
|
||||
M.cachedTracks // Set<trackId> - tracks fully cached in IndexedDB
|
||||
M.trackBlobs // Map<trackId, blobUrl> - blob URLs for cached tracks
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
```
|
||||
GET / → Serves public/index.html
|
||||
GET /api/channels → List all channels with listener counts
|
||||
POST /api/channels → Create a new channel
|
||||
GET /api/channels/:id → Get channel state
|
||||
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
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
### Server
|
||||
- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
|
||||
- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting.
|
||||
- **library.ts** — `Library` class. Scans music directory, computes content hashes.
|
||||
- **db.ts** — SQLite database for users, sessions, tracks.
|
||||
|
||||
### Client (public/)
|
||||
- **core.js** — Global state namespace (`window.MusicRoom`)
|
||||
- **utils.js** — Helpers ($, fmt, showToast)
|
||||
- **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
|
||||
- **controls.js** — Play, pause, seek, volume
|
||||
- **auth.js** — Login, signup, logout
|
||||
- **init.js** — App initialization
|
||||
- **trackStorage.js** — IndexedDB abstraction for track blobs
|
||||
|
||||
### Config
|
||||
- **config.json** — Server configuration (port, musicDir, permissions)
|
||||
- **music/** — Audio files (.mp3, .ogg, .flac, .wav, .m4a, .aac)
|
||||
|
||||
## Key Types
|
||||
|
||||
```ts
|
||||
interface Track {
|
||||
id: string; // Content hash (sha256:...)
|
||||
filename: string; // Original filename
|
||||
title: string; // Display title
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// Channel.getState() returns:
|
||||
{
|
||||
track: Track | null,
|
||||
currentTimestamp: number,
|
||||
channelName: string,
|
||||
channelId: string,
|
||||
description: string,
|
||||
paused: boolean,
|
||||
queue: Track[],
|
||||
currentIndex: number,
|
||||
listenerCount: number,
|
||||
listeners: string[], // usernames of connected users
|
||||
isDefault: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket Messages
|
||||
|
||||
**Client → Server:**
|
||||
```json
|
||||
{ "action": "switch", "channelId": "abc123" }
|
||||
{ "action": "pause" }
|
||||
{ "action": "unpause" }
|
||||
{ "action": "seek", "timestamp": 45.5 }
|
||||
{ "action": "jump", "index": 3 }
|
||||
```
|
||||
|
||||
**Server → Client:**
|
||||
```json
|
||||
{ "type": "channel_list", "channels": [...] }
|
||||
{ "type": "switched", "channelId": "abc123" }
|
||||
{ "track": {...}, "currentTimestamp": 45.5, ... }
|
||||
```
|
||||
|
||||
## Client Sync Logic
|
||||
|
||||
On WebSocket message:
|
||||
1. New track → load audio by `track.id`, seek to server timestamp, play
|
||||
2. Same track, drift < 2s → ignore
|
||||
3. Same track, drift >= 2s → seek to server timestamp
|
||||
|
||||
## Debug Functions
|
||||
|
||||
Available in browser console:
|
||||
```js
|
||||
MusicRoom.debugCacheMismatch() // Compare queue IDs vs cached IDs
|
||||
MusicRoom.debugTrack(index) // Detailed cache state for track at index
|
||||
MusicRoom.debugCacheStatus() // Current track cache state
|
||||
MusicRoom.clearAllCaches() // Clear IndexedDB and in-memory caches
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Default port 3001 (override with `PORT` env var). Track durations read from file metadata on startup with `music-metadata`.
|
||||
|
||||
## Test User
|
||||
|
||||
- **Username**: test
|
||||
- **Password**: testuser
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Blastoise
|
||||
|
||||
Everything that I've wanted out of a networked music player. Self-host. Play your music through the server, but also cache all the tracks locally.
|
||||
|
||||
But most importantly, listen to the same stuff as your friends.
|
||||
|
||||
Its actually more of a file downloader than a normal music streamer. Built with bun.
|
||||
|
||||
First time setup:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
to launch the server
|
||||
|
||||
```bash
|
||||
bun run server.ts
|
||||
```
|
||||
|
||||
have a look at the config.json that gets created the first time.
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { validateSession, hasPermission, type User } from "./db";
|
||||
|
||||
const COOKIE_NAME = "blastoise_session";
|
||||
|
||||
export function getSessionToken(req: Request): string | null {
|
||||
const cookie = req.headers.get("cookie");
|
||||
if (!cookie) return null;
|
||||
|
||||
const match = cookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function getClientInfo(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent: string; ipAddress: string } {
|
||||
const userAgent = req.headers.get("user-agent") ?? "unknown";
|
||||
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
?? req.headers.get("x-real-ip")
|
||||
?? server?.requestIP?.(req)?.address
|
||||
?? "unknown";
|
||||
return { userAgent, ipAddress };
|
||||
}
|
||||
|
||||
export function getRequestMeta(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent?: string; ipAddress?: string } {
|
||||
const userAgent = req.headers.get("user-agent") ?? undefined;
|
||||
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
?? req.headers.get("x-real-ip")
|
||||
?? server?.requestIP?.(req)?.address
|
||||
?? undefined;
|
||||
return { userAgent, ipAddress };
|
||||
}
|
||||
|
||||
export function getUser(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): User | null {
|
||||
const token = getSessionToken(req);
|
||||
if (!token) return null;
|
||||
const { userAgent, ipAddress } = getRequestMeta(req, server);
|
||||
return validateSession(token, userAgent, ipAddress);
|
||||
}
|
||||
|
||||
export function requireUser(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): User {
|
||||
const user = getUser(req, server);
|
||||
if (!user) {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requirePermission(
|
||||
req: Request,
|
||||
resourceType: string,
|
||||
resourceId: string | null,
|
||||
permission: string,
|
||||
server?: { requestIP?: (req: Request) => { address: string } | null }
|
||||
): User {
|
||||
const user = requireUser(req, server);
|
||||
if (!user.is_admin && !hasPermission(user.id, resourceType, resourceId, permission)) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export function setSessionCookie(token: string): string {
|
||||
const maxAge = 7 * 24 * 60 * 60; // 7 days
|
||||
return `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookie(): string {
|
||||
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "blastoise",
|
||||
"dependencies": {
|
||||
"music-metadata": "^11.11.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"music-metadata": ["music-metadata@11.11.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.0", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
||||
|
||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import type { ServerWebSocket } from "bun";
|
||||
|
||||
export interface Track {
|
||||
id: string; // Content hash (primary key)
|
||||
filename: string; // Original filename
|
||||
title: string; // Display title
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";
|
||||
|
||||
export interface ChannelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tracks: Track[];
|
||||
createdBy?: number | null;
|
||||
isDefault?: boolean;
|
||||
currentIndex?: number;
|
||||
startedAt?: number;
|
||||
paused?: boolean;
|
||||
pausedAt?: number;
|
||||
playbackMode?: PlaybackMode;
|
||||
}
|
||||
|
||||
export type WsData = { channelId: string; userId: number | null; username: string };
|
||||
|
||||
export type PersistenceCallback = (channel: Channel, type: "state" | "queue") => void;
|
||||
|
||||
export class Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
queue: Track[];
|
||||
currentIndex: number = 0;
|
||||
startedAt: number = Date.now();
|
||||
clients: Set<ServerWebSocket<WsData>> = new Set();
|
||||
paused: boolean = false;
|
||||
pausedAt: number = 0;
|
||||
createdBy: number | null;
|
||||
createdAt: number;
|
||||
isDefault: boolean;
|
||||
playbackMode: PlaybackMode = "repeat-all";
|
||||
private lastQueueBroadcast: number = 0;
|
||||
private queueDirty: boolean = false;
|
||||
private onPersist: PersistenceCallback | null = null;
|
||||
|
||||
constructor(config: ChannelConfig) {
|
||||
this.id = config.id;
|
||||
this.name = config.name;
|
||||
this.description = config.description || "";
|
||||
this.queue = config.tracks;
|
||||
this.createdBy = config.createdBy ?? null;
|
||||
this.createdAt = Date.now();
|
||||
this.isDefault = config.isDefault ?? false;
|
||||
this.currentIndex = config.currentIndex ?? 0;
|
||||
this.startedAt = config.startedAt ?? Date.now();
|
||||
this.paused = config.paused ?? false;
|
||||
this.pausedAt = config.pausedAt ?? 0;
|
||||
this.playbackMode = config.playbackMode ?? "repeat-all";
|
||||
}
|
||||
|
||||
setPersistenceCallback(callback: PersistenceCallback) {
|
||||
this.onPersist = callback;
|
||||
}
|
||||
|
||||
private persistState() {
|
||||
this.onPersist?.(this, "state");
|
||||
}
|
||||
|
||||
private persistQueue() {
|
||||
this.onPersist?.(this, "queue");
|
||||
}
|
||||
|
||||
get currentTrack(): Track | null {
|
||||
if (this.queue.length === 0) return null;
|
||||
return this.queue[this.currentIndex];
|
||||
}
|
||||
|
||||
get currentTimestamp(): number {
|
||||
if (this.queue.length === 0) return 0;
|
||||
if (this.paused) return this.pausedAt;
|
||||
return (Date.now() - this.startedAt) / 1000;
|
||||
}
|
||||
|
||||
tick(): boolean {
|
||||
if (this.paused) return false;
|
||||
const track = this.currentTrack;
|
||||
if (!track) return false;
|
||||
if (this.currentTimestamp >= track.duration) {
|
||||
this.advance();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
advance() {
|
||||
if (this.queue.length === 0) return;
|
||||
|
||||
switch (this.playbackMode) {
|
||||
case "once":
|
||||
// Play through once, stop at end
|
||||
if (this.currentIndex < this.queue.length - 1) {
|
||||
this.currentIndex++;
|
||||
} else {
|
||||
// At end of playlist - pause
|
||||
this.paused = true;
|
||||
}
|
||||
break;
|
||||
case "repeat-one":
|
||||
// Stay on same track, just reset timestamp
|
||||
break;
|
||||
case "shuffle":
|
||||
// Pick a random track (different from current if possible)
|
||||
if (this.queue.length > 1) {
|
||||
let newIndex;
|
||||
do {
|
||||
newIndex = Math.floor(Math.random() * this.queue.length);
|
||||
} while (newIndex === this.currentIndex);
|
||||
this.currentIndex = newIndex;
|
||||
}
|
||||
break;
|
||||
case "repeat-all":
|
||||
default:
|
||||
this.currentIndex = (this.currentIndex + 1) % this.queue.length;
|
||||
break;
|
||||
}
|
||||
|
||||
this.startedAt = Date.now();
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
setPlaybackMode(mode: PlaybackMode) {
|
||||
this.playbackMode = mode;
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
getState(includeQueue: boolean = false) {
|
||||
const state: Record<string, unknown> = {
|
||||
track: this.currentTrack,
|
||||
currentTimestamp: this.currentTimestamp,
|
||||
channelName: this.name,
|
||||
channelId: this.id,
|
||||
description: this.description,
|
||||
paused: this.paused,
|
||||
currentIndex: this.currentIndex,
|
||||
listenerCount: this.clients.size,
|
||||
isDefault: this.isDefault,
|
||||
playbackMode: this.playbackMode,
|
||||
};
|
||||
if (includeQueue) {
|
||||
state.queue = this.queue;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.paused) return;
|
||||
this.pausedAt = this.currentTimestamp;
|
||||
this.paused = true;
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
unpause() {
|
||||
if (!this.paused) return;
|
||||
this.paused = false;
|
||||
this.startedAt = Date.now() - this.pausedAt * 1000;
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
jumpTo(index: number) {
|
||||
if (index < 0 || index >= this.queue.length) return;
|
||||
this.currentIndex = index;
|
||||
if (this.paused) {
|
||||
this.pausedAt = 0;
|
||||
} else {
|
||||
this.startedAt = Date.now();
|
||||
}
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
seek(timestamp: number) {
|
||||
const track = this.currentTrack;
|
||||
if (!track) return;
|
||||
const clamped = Math.max(0, Math.min(timestamp, track.duration));
|
||||
if (this.paused) {
|
||||
this.pausedAt = clamped;
|
||||
} else {
|
||||
this.startedAt = Date.now() - clamped * 1000;
|
||||
}
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
markQueueDirty() {
|
||||
this.queueDirty = true;
|
||||
}
|
||||
|
||||
setQueue(tracks: Track[]) {
|
||||
// Remember current track and timestamp to preserve playback position
|
||||
const currentTrackId = this.currentTrack?.id;
|
||||
const currentTimestampValue = this.currentTimestamp;
|
||||
const wasPaused = this.paused;
|
||||
|
||||
this.queue = tracks;
|
||||
|
||||
// Try to find the current track in the new queue
|
||||
if (currentTrackId) {
|
||||
const newIndex = this.queue.findIndex(t => t.id === currentTrackId);
|
||||
if (newIndex !== -1) {
|
||||
// Found the track - preserve playback position
|
||||
this.currentIndex = newIndex;
|
||||
if (wasPaused) {
|
||||
this.pausedAt = currentTimestampValue;
|
||||
} else {
|
||||
this.startedAt = Date.now() - currentTimestampValue * 1000;
|
||||
}
|
||||
} else {
|
||||
// Track not found in new queue - reset to start
|
||||
this.currentIndex = 0;
|
||||
this.startedAt = Date.now();
|
||||
this.pausedAt = 0;
|
||||
}
|
||||
} else {
|
||||
// No current track - reset to start
|
||||
this.currentIndex = 0;
|
||||
this.startedAt = Date.now();
|
||||
this.pausedAt = 0;
|
||||
}
|
||||
|
||||
this.queueDirty = true;
|
||||
this.persistQueue();
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
addTracks(tracks: Track[]) {
|
||||
if (tracks.length === 0) return;
|
||||
this.queue.push(...tracks);
|
||||
this.queueDirty = true;
|
||||
this.persistQueue();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
removeTracksByIndex(indices: number[]) {
|
||||
if (indices.length === 0) return;
|
||||
|
||||
// Sort descending to remove from end first (preserve indices)
|
||||
const sorted = [...indices].sort((a, b) => b - a);
|
||||
const currentTrackId = this.currentTrack?.id;
|
||||
|
||||
for (const idx of sorted) {
|
||||
if (idx >= 0 && idx < this.queue.length) {
|
||||
this.queue.splice(idx, 1);
|
||||
// Adjust currentIndex if we removed a track before it
|
||||
if (idx < this.currentIndex) {
|
||||
this.currentIndex--;
|
||||
} else if (idx === this.currentIndex) {
|
||||
// Removed currently playing track - stay at same index (next track slides in)
|
||||
// If we removed the last track, wrap to start
|
||||
if (this.currentIndex >= this.queue.length) {
|
||||
this.currentIndex = 0;
|
||||
this.startedAt = Date.now();
|
||||
this.pausedAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If queue is now empty, reset state
|
||||
if (this.queue.length === 0) {
|
||||
this.currentIndex = 0;
|
||||
this.startedAt = Date.now();
|
||||
this.pausedAt = 0;
|
||||
}
|
||||
|
||||
// If current track changed, reset playback position
|
||||
if (this.queue.length > 0 && this.currentTrack?.id !== currentTrackId) {
|
||||
this.startedAt = Date.now();
|
||||
this.pausedAt = 0;
|
||||
}
|
||||
|
||||
this.queueDirty = true;
|
||||
this.persistQueue();
|
||||
this.persistState();
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
broadcast() {
|
||||
const now = Date.now();
|
||||
const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000);
|
||||
if (includeQueue) {
|
||||
this.lastQueueBroadcast = now;
|
||||
this.queueDirty = false;
|
||||
}
|
||||
const msg = JSON.stringify(this.getState(includeQueue));
|
||||
|
||||
for (const ws of this.clients) {
|
||||
ws.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
addClient(ws: ServerWebSocket<WsData>) {
|
||||
this.clients.add(ws);
|
||||
console.log(`[Channel] "${this.name}" added client, now ${this.clients.size} clients`);
|
||||
|
||||
// Always send full state with queue on connect
|
||||
ws.send(JSON.stringify(this.getState(true)));
|
||||
// Reset timer so next queue broadcast is in 60s
|
||||
this.lastQueueBroadcast = Date.now();
|
||||
}
|
||||
|
||||
removeClient(ws: ServerWebSocket<WsData>) {
|
||||
this.clients.delete(ws);
|
||||
console.log(`[Channel] "${this.name}" removed client, now ${this.clients.size} clients`);
|
||||
}
|
||||
|
||||
getListInfo() {
|
||||
const listeners = Array.from(this.clients).map(ws => ws.data?.username ?? 'Unknown');
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
trackCount: this.queue.length,
|
||||
listenerCount: this.clients.size,
|
||||
listeners,
|
||||
isDefault: this.isDefault,
|
||||
createdBy: this.createdBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
|
||||
const DB_PATH = "./blastoise.db";
|
||||
const SESSION_EXPIRY_DAYS = 7;
|
||||
const GUEST_SESSION_EXPIRY_HOURS = 24;
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Initialize tables
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
is_guest INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: add is_guest column if it doesn't exist
|
||||
try {
|
||||
db.run(`ALTER TABLE users ADD COLUMN is_guest INTEGER DEFAULT 0`);
|
||||
} catch {}
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
user_agent TEXT,
|
||||
ip_address TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: add columns if they don't exist
|
||||
try {
|
||||
db.run(`ALTER TABLE sessions ADD COLUMN user_agent TEXT`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE sessions ADD COLUMN ip_address TEXT`);
|
||||
} catch {}
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
permission TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, resource_type, resource_id, permission)
|
||||
)
|
||||
`);
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
is_admin: boolean;
|
||||
is_guest: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
user_id: number;
|
||||
expires_at: number;
|
||||
created_at: number;
|
||||
user_agent: string | null;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: number;
|
||||
user_id: number;
|
||||
resource_type: string;
|
||||
resource_id: string | null;
|
||||
permission: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
duration: number;
|
||||
size: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// User functions
|
||||
export async function createUser(username: string, password: string): Promise<User> {
|
||||
const password_hash = await Bun.password.hash(password);
|
||||
|
||||
// First user becomes admin
|
||||
const userCount = db.query("SELECT COUNT(*) as count FROM users WHERE is_guest = 0").get() as { count: number };
|
||||
const is_admin = userCount.count === 0 ? 1 : 0;
|
||||
|
||||
const result = db.query(
|
||||
"INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, ?, 0) RETURNING *"
|
||||
).get(username, password_hash, is_admin) as any;
|
||||
|
||||
return { ...result, is_admin: !!result.is_admin, is_guest: false };
|
||||
}
|
||||
|
||||
export function createGuestUser(ipAddress: string, userAgent: string): User {
|
||||
const guestId = crypto.randomUUID().slice(0, 8);
|
||||
const username = `guest_${guestId}`;
|
||||
const password_hash = ""; // No password for guests
|
||||
|
||||
const result = db.query(
|
||||
"INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, 0, 1) RETURNING *"
|
||||
).get(username, password_hash) as any;
|
||||
|
||||
return { ...result, is_admin: false, is_guest: true };
|
||||
}
|
||||
|
||||
export function findUserById(id: number): User | null {
|
||||
const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any;
|
||||
if (!result) return null;
|
||||
return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest };
|
||||
}
|
||||
|
||||
export function findUserByUsername(username: string): User | null {
|
||||
const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any;
|
||||
if (!result) return null;
|
||||
return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest };
|
||||
}
|
||||
|
||||
export async function validatePassword(user: User, password: string): Promise<boolean> {
|
||||
return Bun.password.verify(password, user.password_hash);
|
||||
}
|
||||
|
||||
// Session functions
|
||||
export function createSession(userId: number, userAgent?: string, ipAddress?: string, isGuest: boolean = false): string {
|
||||
const token = crypto.randomUUID();
|
||||
const expirySeconds = isGuest
|
||||
? GUEST_SESSION_EXPIRY_HOURS * 60 * 60
|
||||
: SESSION_EXPIRY_DAYS * 24 * 60 * 60;
|
||||
const expires_at = Math.floor(Date.now() / 1000) + expirySeconds;
|
||||
|
||||
db.query("INSERT INTO sessions (token, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function createGuestSession(userAgent?: string, ipAddress?: string): { user: User, token: string } {
|
||||
const user = createGuestUser(ipAddress ?? "unknown", userAgent ?? "unknown");
|
||||
const token = createSession(user.id, userAgent, ipAddress, true);
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
export function validateSession(token: string, currentUserAgent?: string, currentIpAddress?: string): User | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const session = db.query(
|
||||
"SELECT * FROM sessions WHERE token = ? AND expires_at > ?"
|
||||
).get(token, now) as Session | null;
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const user = findUserById(session.user_id);
|
||||
if (!user) return null;
|
||||
|
||||
// Invalidate if BOTH ip and user agent changed (potential session hijack)
|
||||
if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) {
|
||||
const ipChanged = session.ip_address !== currentIpAddress;
|
||||
const uaChanged = session.user_agent !== currentUserAgent;
|
||||
if (ipChanged && uaChanged) {
|
||||
console.log(`[AUTH] Session invalidated (ip+ua changed): session=${token} old_ip=${session.ip_address} new_ip=${currentIpAddress}`);
|
||||
deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sliding expiration - extend on each use
|
||||
const expirySeconds = user.is_guest
|
||||
? GUEST_SESSION_EXPIRY_HOURS * 60 * 60
|
||||
: SESSION_EXPIRY_DAYS * 24 * 60 * 60;
|
||||
const newExpiry = now + expirySeconds;
|
||||
db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token);
|
||||
|
||||
return findUserById(session.user_id);
|
||||
}
|
||||
|
||||
export function deleteSession(token: string): void {
|
||||
db.query("DELETE FROM sessions WHERE token = ?").run(token);
|
||||
}
|
||||
|
||||
export function deleteExpiredSessions(): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.query("DELETE FROM sessions WHERE expires_at <= ?").run(now);
|
||||
}
|
||||
|
||||
// Permission functions
|
||||
export function hasPermission(
|
||||
userId: number,
|
||||
resourceType: string,
|
||||
resourceId: string | null,
|
||||
permission: string
|
||||
): boolean {
|
||||
const user = findUserById(userId);
|
||||
if (!user) return false;
|
||||
if (user.is_admin) return true;
|
||||
|
||||
const result = db.query(`
|
||||
SELECT 1 FROM permissions
|
||||
WHERE user_id = ?
|
||||
AND resource_type = ?
|
||||
AND (resource_id = ? OR resource_id IS NULL)
|
||||
AND permission = ?
|
||||
LIMIT 1
|
||||
`).get(userId, resourceType, resourceId, permission);
|
||||
|
||||
return !!result;
|
||||
}
|
||||
|
||||
export function grantPermission(
|
||||
userId: number,
|
||||
resourceType: string,
|
||||
resourceId: string | null,
|
||||
permission: string
|
||||
): void {
|
||||
db.query(`
|
||||
INSERT OR IGNORE INTO permissions (user_id, resource_type, resource_id, permission)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(userId, resourceType, resourceId, permission);
|
||||
}
|
||||
|
||||
export function revokePermission(
|
||||
userId: number,
|
||||
resourceType: string,
|
||||
resourceId: string | null,
|
||||
permission: string
|
||||
): void {
|
||||
db.query(`
|
||||
DELETE FROM permissions
|
||||
WHERE user_id = ? AND resource_type = ? AND resource_id IS ? AND permission = ?
|
||||
`).run(userId, resourceType, resourceId, permission);
|
||||
}
|
||||
|
||||
export function getUserPermissions(userId: number): Permission[] {
|
||||
return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[];
|
||||
}
|
||||
|
||||
export function getAllUsers(): Omit<User, 'password_hash'>[] {
|
||||
const users = db.query("SELECT id, username, is_admin, is_guest, created_at FROM users WHERE is_guest = 0").all() as any[];
|
||||
return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false }));
|
||||
}
|
||||
|
||||
export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
|
||||
return db.query(
|
||||
"SELECT user_id, expires_at, created_at, user_agent, ip_address FROM sessions WHERE user_id = ? AND expires_at > ?"
|
||||
).all(userId, Math.floor(Date.now() / 1000)) as Omit<Session, 'token'>[];
|
||||
}
|
||||
|
||||
// Cleanup expired sessions periodically
|
||||
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
|
||||
|
||||
// Channel tables
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
created_by INTEGER,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
current_index INTEGER DEFAULT 0,
|
||||
started_at INTEGER DEFAULT (unixepoch() * 1000),
|
||||
paused INTEGER DEFAULT 0,
|
||||
paused_at REAL DEFAULT 0,
|
||||
playback_mode TEXT DEFAULT 'repeat-all',
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS channel_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||
UNIQUE(channel_id, position)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index for faster queue lookups
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_channel_queue_channel ON channel_queue(channel_id)`);
|
||||
|
||||
// Migration: add playback_mode column to channels
|
||||
try {
|
||||
db.run(`ALTER TABLE channels ADD COLUMN playback_mode TEXT DEFAULT 'repeat-all'`);
|
||||
} catch {}
|
||||
|
||||
// Channel types
|
||||
export interface ChannelRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_by: number | null;
|
||||
is_default: number;
|
||||
current_index: number;
|
||||
started_at: number;
|
||||
paused: number;
|
||||
paused_at: number;
|
||||
playback_mode: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ChannelQueueRow {
|
||||
id: number;
|
||||
channel_id: string;
|
||||
track_id: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
// Channel CRUD functions
|
||||
export function saveChannel(channel: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdBy: number | null;
|
||||
isDefault: boolean;
|
||||
currentIndex: number;
|
||||
startedAt: number;
|
||||
paused: boolean;
|
||||
pausedAt: number;
|
||||
playbackMode: string;
|
||||
}): void {
|
||||
db.query(`
|
||||
INSERT INTO channels (id, name, description, created_by, is_default, current_index, started_at, paused, paused_at, playback_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
current_index = excluded.current_index,
|
||||
started_at = excluded.started_at,
|
||||
paused = excluded.paused,
|
||||
paused_at = excluded.paused_at,
|
||||
playback_mode = excluded.playback_mode
|
||||
`).run(
|
||||
channel.id,
|
||||
channel.name,
|
||||
channel.description,
|
||||
channel.createdBy,
|
||||
channel.isDefault ? 1 : 0,
|
||||
channel.currentIndex,
|
||||
channel.startedAt,
|
||||
channel.paused ? 1 : 0,
|
||||
channel.pausedAt,
|
||||
channel.playbackMode
|
||||
);
|
||||
}
|
||||
|
||||
export function updateChannelState(channelId: string, state: {
|
||||
currentIndex: number;
|
||||
startedAt: number;
|
||||
paused: boolean;
|
||||
pausedAt: number;
|
||||
playbackMode: string;
|
||||
}): void {
|
||||
db.query(`
|
||||
UPDATE channels
|
||||
SET current_index = ?, started_at = ?, paused = ?, paused_at = ?, playback_mode = ?
|
||||
WHERE id = ?
|
||||
`).run(state.currentIndex, state.startedAt, state.paused ? 1 : 0, state.pausedAt, state.playbackMode, channelId);
|
||||
}
|
||||
|
||||
export function loadChannel(id: string): ChannelRow | null {
|
||||
return db.query("SELECT * FROM channels WHERE id = ?").get(id) as ChannelRow | null;
|
||||
}
|
||||
|
||||
export function loadAllChannels(): ChannelRow[] {
|
||||
return db.query("SELECT * FROM channels").all() as ChannelRow[];
|
||||
}
|
||||
|
||||
export function deleteChannelFromDb(id: string): void {
|
||||
db.query("DELETE FROM channels WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// Queue persistence functions
|
||||
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
|
||||
db.query("BEGIN").run();
|
||||
try {
|
||||
db.query("DELETE FROM channel_queue WHERE channel_id = ?").run(channelId);
|
||||
|
||||
const insert = db.query(
|
||||
"INSERT INTO channel_queue (channel_id, track_id, position) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (let i = 0; i < trackIds.length; i++) {
|
||||
insert.run(channelId, trackIds[i], i);
|
||||
}
|
||||
db.query("COMMIT").run();
|
||||
} catch (e) {
|
||||
db.query("ROLLBACK").run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadChannelQueue(channelId: string): string[] {
|
||||
const rows = db.query(
|
||||
"SELECT track_id FROM channel_queue WHERE channel_id = ? ORDER BY position"
|
||||
).all(channelId) as { track_id: string }[];
|
||||
return rows.map(r => r.track_id);
|
||||
}
|
||||
|
||||
export function removeTrackFromQueues(trackId: string): void {
|
||||
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId);
|
||||
}
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
import { createHash } from "crypto";
|
||||
import { watch, type FSWatcher } from "fs";
|
||||
import { readdir, stat } from "fs/promises";
|
||||
import { join, relative } from "path";
|
||||
import { parseFile } from "music-metadata";
|
||||
import { type Track } from "./db";
|
||||
|
||||
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
|
||||
|
||||
export interface LibraryTrack extends Track {
|
||||
filename: string;
|
||||
filepath: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
path: string;
|
||||
track_id: string;
|
||||
size: number;
|
||||
mtime_ms: number;
|
||||
cached_at: number;
|
||||
}
|
||||
|
||||
type LibraryEventType = "added" | "removed" | "changed";
|
||||
type LibraryEventCallback = (track: LibraryTrack) => void;
|
||||
|
||||
export class Library {
|
||||
private cacheDb: Database;
|
||||
private musicDir: string;
|
||||
private trackMap = new Map<string, string>(); // trackId -> filepath
|
||||
private trackInfo = new Map<string, LibraryTrack>(); // trackId -> full info
|
||||
private watcher: FSWatcher | null = null;
|
||||
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
|
||||
private pendingFiles = new Map<string, NodeJS.Timeout>(); // filepath -> debounce timer
|
||||
|
||||
// Scan progress tracking
|
||||
private _scanProgress = { scanning: false, processed: 0, total: 0 };
|
||||
private scanCompleteCallbacks: Set<() => void> = new Set();
|
||||
|
||||
get scanProgress() {
|
||||
return { ...this._scanProgress };
|
||||
}
|
||||
|
||||
constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") {
|
||||
this.musicDir = musicDir;
|
||||
this.cacheDb = new Database(cacheDbPath);
|
||||
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
||||
this.initCacheDb();
|
||||
}
|
||||
|
||||
private initCacheDb(): void {
|
||||
this.cacheDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS file_cache (
|
||||
path TEXT PRIMARY KEY,
|
||||
track_id TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mtime_ms INTEGER NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`);
|
||||
|
||||
// Tracks table - stores metadata for each unique track
|
||||
this.cacheDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
duration REAL NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
// Library activity log
|
||||
this.cacheDb.run(`
|
||||
CREATE TABLE IF NOT EXISTS library_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER DEFAULT (unixepoch()),
|
||||
action TEXT NOT NULL,
|
||||
track_id TEXT,
|
||||
filename TEXT,
|
||||
title TEXT,
|
||||
user_id INTEGER,
|
||||
username TEXT
|
||||
)
|
||||
`);
|
||||
this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`);
|
||||
}
|
||||
|
||||
logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void {
|
||||
this.cacheDb.query(`
|
||||
INSERT INTO library_log (action, track_id, filename, title, user_id, username)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(action, track.id || null, track.filename || null, track.title || null, user?.id || null, user?.username || null);
|
||||
}
|
||||
|
||||
getActivityLog(limit: number = 100): Array<{ id: number; timestamp: number; action: string; track_id: string | null; filename: string | null; title: string | null; user_id: number | null; username: string | null }> {
|
||||
return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any;
|
||||
}
|
||||
|
||||
private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void {
|
||||
this.cacheDb.query(`
|
||||
INSERT INTO tracks (id, title, artist, album, duration, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = COALESCE(excluded.title, title),
|
||||
artist = COALESCE(excluded.artist, artist),
|
||||
album = COALESCE(excluded.album, album)
|
||||
`).run(track.id, track.title, track.artist, track.album, track.duration, track.size);
|
||||
}
|
||||
|
||||
async computeTrackId(filePath: string): Promise<string> {
|
||||
const file = Bun.file(filePath);
|
||||
const size = file.size;
|
||||
|
||||
// Read first 64KB
|
||||
const chunk = await file.slice(0, HASH_CHUNK_SIZE).arrayBuffer();
|
||||
|
||||
// Get duration from metadata
|
||||
let duration = 0;
|
||||
try {
|
||||
const metadata = await parseFile(filePath, { duration: true });
|
||||
duration = metadata.format.duration || 0;
|
||||
} catch {
|
||||
// If metadata parsing fails, use size only
|
||||
}
|
||||
|
||||
// Hash: size + duration + first 64KB
|
||||
const hash = createHash("sha256");
|
||||
hash.update(`${size}:${duration.toFixed(3)}:`);
|
||||
hash.update(new Uint8Array(chunk));
|
||||
|
||||
return "sha256:" + hash.digest("hex").substring(0, 32);
|
||||
}
|
||||
|
||||
private getCacheEntry(relativePath: string): CacheEntry | null {
|
||||
return this.cacheDb.query("SELECT * FROM file_cache WHERE path = ?").get(relativePath) as CacheEntry | null;
|
||||
}
|
||||
|
||||
private setCacheEntry(relativePath: string, trackId: string, size: number, mtimeMs: number): void {
|
||||
this.cacheDb.query(`
|
||||
INSERT OR REPLACE INTO file_cache (path, track_id, size, mtime_ms, cached_at)
|
||||
VALUES (?, ?, ?, ?, unixepoch())
|
||||
`).run(relativePath, trackId, size, mtimeMs);
|
||||
}
|
||||
|
||||
private removeCacheEntry(relativePath: string): void {
|
||||
this.cacheDb.query("DELETE FROM file_cache WHERE path = ?").run(relativePath);
|
||||
}
|
||||
|
||||
private isAudioFile(filename: string): boolean {
|
||||
const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
|
||||
return AUDIO_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
private async processFile(filePath: string): Promise<LibraryTrack | null> {
|
||||
const relativePath = relative(this.musicDir, filePath);
|
||||
const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath;
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
const size = fileStat.size;
|
||||
const mtimeMs = fileStat.mtimeMs;
|
||||
|
||||
// Check cache
|
||||
const cached = this.getCacheEntry(relativePath);
|
||||
let trackId: string;
|
||||
|
||||
if (cached && cached.size === size && cached.mtime_ms === Math.floor(mtimeMs)) {
|
||||
trackId = cached.track_id;
|
||||
} else {
|
||||
// Compute new hash
|
||||
trackId = await this.computeTrackId(filePath);
|
||||
this.setCacheEntry(relativePath, trackId, size, Math.floor(mtimeMs));
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
let title = filename.replace(/\.[^.]+$/, "");
|
||||
let artist: string | null = null;
|
||||
let album: string | null = null;
|
||||
let duration = 0;
|
||||
|
||||
try {
|
||||
const metadata = await parseFile(filePath, { duration: true });
|
||||
title = metadata.common.title || title;
|
||||
artist = metadata.common.artist || null;
|
||||
album = metadata.common.album || null;
|
||||
duration = metadata.format.duration || 0;
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
|
||||
const track: LibraryTrack = {
|
||||
id: trackId,
|
||||
filename,
|
||||
filepath: filePath,
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
duration,
|
||||
size,
|
||||
available: true,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Upsert to cache database
|
||||
this.upsertTrack({
|
||||
id: trackId,
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
duration,
|
||||
size,
|
||||
});
|
||||
|
||||
return track;
|
||||
} catch (e) {
|
||||
console.error(`[Library] Failed to process ${filePath}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async scan(): Promise<void> {
|
||||
console.log(`[Library] Quick loading from cache...`);
|
||||
|
||||
// Single query joining file_cache and tracks - all in cacheDb now
|
||||
const cachedTracks = this.cacheDb.query(`
|
||||
SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration
|
||||
FROM file_cache fc
|
||||
LEFT JOIN tracks t ON fc.track_id = t.id
|
||||
`).all() as Array<{
|
||||
path: string;
|
||||
track_id: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
duration: number | null;
|
||||
}>;
|
||||
|
||||
for (const row of cachedTracks) {
|
||||
const fullPath = join(this.musicDir, row.path);
|
||||
const filename = row.path.split(/[/\\]/).pop() || row.path;
|
||||
|
||||
const track: LibraryTrack = {
|
||||
id: row.track_id,
|
||||
filename,
|
||||
filepath: fullPath,
|
||||
title: row.title || filename.replace(/\.[^.]+$/, ""),
|
||||
artist: row.artist || null,
|
||||
album: row.album || null,
|
||||
duration: row.duration || 0,
|
||||
available: true,
|
||||
};
|
||||
|
||||
this.trackMap.set(track.id, fullPath);
|
||||
this.trackInfo.set(track.id, track);
|
||||
}
|
||||
|
||||
console.log(`[Library] Quick loaded ${cachedTracks.length} tracks from cache`);
|
||||
|
||||
// Start background scan for new/changed files
|
||||
this.startBackgroundScan();
|
||||
}
|
||||
|
||||
private async startBackgroundScan(): Promise<void> {
|
||||
console.log(`[Library] Starting background scan...`);
|
||||
const startTime = Date.now();
|
||||
let processed = 0;
|
||||
let skipped = 0;
|
||||
const BATCH_SIZE = 10;
|
||||
const BATCH_DELAY_MS = 50; // Pause between batches to not block
|
||||
|
||||
const filesToProcess: string[] = [];
|
||||
|
||||
// Collect all files first (fast operation)
|
||||
const collectFiles = async (dir: string) => {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectFiles(fullPath);
|
||||
} else if (entry.isFile() && this.isAudioFile(entry.name)) {
|
||||
filesToProcess.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Library] Error reading directory ${dir}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
await collectFiles(this.musicDir);
|
||||
console.log(`[Library] Found ${filesToProcess.length} audio files to check`);
|
||||
|
||||
// Initialize scan progress
|
||||
this._scanProgress = { scanning: true, processed: 0, total: filesToProcess.length };
|
||||
|
||||
// Process in batches with delays
|
||||
for (let i = 0; i < filesToProcess.length; i += BATCH_SIZE) {
|
||||
const batch = filesToProcess.slice(i, i + BATCH_SIZE);
|
||||
|
||||
for (const fullPath of batch) {
|
||||
const relativePath = relative(this.musicDir, fullPath);
|
||||
const cacheEntry = this.getCacheEntry(relativePath);
|
||||
|
||||
// Check if already loaded and cache is valid
|
||||
if (cacheEntry && this.trackInfo.has(cacheEntry.track_id)) {
|
||||
try {
|
||||
const fileStat = await stat(fullPath);
|
||||
if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) {
|
||||
skipped++;
|
||||
this._scanProgress.processed++;
|
||||
continue;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Need to process this file
|
||||
const track = await this.processFile(fullPath);
|
||||
if (track) {
|
||||
const isNew = !this.trackInfo.has(track.id);
|
||||
this.trackMap.set(track.id, fullPath);
|
||||
this.trackInfo.set(track.id, track);
|
||||
processed++;
|
||||
|
||||
if (isNew) {
|
||||
this.emit("added", track);
|
||||
}
|
||||
}
|
||||
this._scanProgress.processed++;
|
||||
}
|
||||
|
||||
// Yield to other operations
|
||||
if (i + BATCH_SIZE < filesToProcess.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS));
|
||||
}
|
||||
|
||||
// Progress log every 100 files
|
||||
if ((i + BATCH_SIZE) % 100 === 0) {
|
||||
console.log(`[Library] Background scan progress: ${Math.min(i + BATCH_SIZE, filesToProcess.length)}/${filesToProcess.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
this._scanProgress = { scanning: false, processed: filesToProcess.length, total: filesToProcess.length };
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[Library] Background scan complete: ${processed} new/updated, ${skipped} unchanged in ${elapsed}ms`);
|
||||
|
||||
// Notify listeners that scan is complete
|
||||
this.scanCompleteCallbacks.forEach(cb => cb());
|
||||
}
|
||||
|
||||
onScanComplete(callback: () => void): void {
|
||||
this.scanCompleteCallbacks.add(callback);
|
||||
}
|
||||
|
||||
startWatching(): void {
|
||||
if (this.watcher) return;
|
||||
|
||||
console.log(`[Library] Watching ${this.musicDir} for changes...`);
|
||||
|
||||
this.watcher = watch(this.musicDir, { recursive: true }, async (event, filename) => {
|
||||
if (!filename) return;
|
||||
|
||||
// Normalize path separators
|
||||
const normalizedFilename = filename.replace(/\\/g, "/");
|
||||
if (!this.isAudioFile(normalizedFilename)) return;
|
||||
|
||||
const fullPath = join(this.musicDir, filename);
|
||||
|
||||
// Debounce: wait 5 seconds after last change before processing
|
||||
const existing = this.pendingFiles.get(fullPath);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
this.pendingFiles.set(fullPath, setTimeout(async () => {
|
||||
this.pendingFiles.delete(fullPath);
|
||||
await this.processFileChange(fullPath);
|
||||
}, 5000));
|
||||
});
|
||||
}
|
||||
|
||||
private async processFileChange(fullPath: string): Promise<void> {
|
||||
try {
|
||||
const exists = await Bun.file(fullPath).exists();
|
||||
|
||||
if (exists) {
|
||||
// File added or modified
|
||||
const track = await this.processFile(fullPath);
|
||||
if (track) {
|
||||
const wasNew = !this.trackMap.has(track.id);
|
||||
this.trackMap.set(track.id, fullPath);
|
||||
this.trackInfo.set(track.id, track);
|
||||
this.emit(wasNew ? "added" : "changed", track);
|
||||
console.log(`[Library] ${wasNew ? "Added" : "Updated"}: ${track.title}`);
|
||||
}
|
||||
} else {
|
||||
// File deleted
|
||||
const relativePath = relative(this.musicDir, fullPath);
|
||||
const cacheEntry = this.getCacheEntry(relativePath);
|
||||
|
||||
if (cacheEntry) {
|
||||
const track = this.trackInfo.get(cacheEntry.track_id);
|
||||
if (track) {
|
||||
track.available = false;
|
||||
this.trackMap.delete(cacheEntry.track_id);
|
||||
this.emit("removed", track);
|
||||
console.log(`[Library] Removed: ${track.title}`);
|
||||
}
|
||||
this.removeCacheEntry(relativePath);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Library] Watch error for ${fullPath}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
stopWatching(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Event handling
|
||||
on(event: LibraryEventType, callback: LibraryEventCallback): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, new Set());
|
||||
}
|
||||
this.eventListeners.get(event)!.add(callback);
|
||||
}
|
||||
|
||||
off(event: LibraryEventType, callback: LibraryEventCallback): void {
|
||||
this.eventListeners.get(event)?.delete(callback);
|
||||
}
|
||||
|
||||
private emit(event: LibraryEventType, track: LibraryTrack): void {
|
||||
this.eventListeners.get(event)?.forEach((cb) => cb(track));
|
||||
}
|
||||
|
||||
// Accessors
|
||||
getTrack(id: string): LibraryTrack | null {
|
||||
return this.trackInfo.get(id) || null;
|
||||
}
|
||||
|
||||
getFilePath(id: string): string | null {
|
||||
return this.trackMap.get(id) || null;
|
||||
}
|
||||
|
||||
getAllTracks(): LibraryTrack[] {
|
||||
return Array.from(this.trackInfo.values()).filter((t) => t.available);
|
||||
}
|
||||
|
||||
getTrackCount(): number {
|
||||
return this.trackMap.size;
|
||||
}
|
||||
|
||||
// Check if a track ID is available (file exists)
|
||||
isAvailable(id: string): boolean {
|
||||
return this.trackMap.has(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "blastoise",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "bun run server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"music-metadata": "^11.11.2"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
// MusicRoom - Audio Cache module
|
||||
// Track caching, downloading, and prefetching
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Load stream-only preference from localStorage
|
||||
M.streamOnly = localStorage.getItem("blastoise_streamOnly") === "true";
|
||||
|
||||
// Toggle stream-only mode
|
||||
M.setStreamOnly = function(enabled) {
|
||||
M.streamOnly = enabled;
|
||||
localStorage.setItem("blastoise_streamOnly", enabled ? "true" : "false");
|
||||
M.showToast(enabled ? "Stream-only mode enabled" : "Caching enabled");
|
||||
};
|
||||
|
||||
// Check cache size and prune if over limit
|
||||
M.checkCacheSize = async function() {
|
||||
const stats = await TrackStorage.getStats();
|
||||
if (stats.totalSize > M.cacheLimit) {
|
||||
console.log(`[Cache] Size ${(stats.totalSize / 1024 / 1024).toFixed(1)}MB exceeds limit ${(M.cacheLimit / 1024 / 1024).toFixed(0)}MB, pruning...`);
|
||||
await M.pruneCache(stats.totalSize - M.cacheLimit + (10 * 1024 * 1024)); // Free extra 10MB
|
||||
}
|
||||
};
|
||||
|
||||
// Remove oldest cached tracks to free up space
|
||||
M.pruneCache = async function(bytesToFree) {
|
||||
// Get all cached tracks with metadata
|
||||
const allKeys = await TrackStorage.list();
|
||||
if (allKeys.length === 0) return;
|
||||
|
||||
// Get track sizes (we need to fetch each to know size)
|
||||
const trackSizes = [];
|
||||
for (const key of allKeys) {
|
||||
const cached = await TrackStorage.get(key);
|
||||
if (cached && cached.blob) {
|
||||
trackSizes.push({ id: key, size: cached.blob.size });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by... we don't have timestamps, so just remove from start
|
||||
let freed = 0;
|
||||
for (const { id, size } of trackSizes) {
|
||||
if (freed >= bytesToFree) break;
|
||||
await TrackStorage.remove(id);
|
||||
M.cachedTracks.delete(id);
|
||||
freed += size;
|
||||
console.log(`[Cache] Pruned: ${id.slice(0, 16)}... (${(size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
}
|
||||
|
||||
M.showToast(`Freed ${(freed / 1024 / 1024).toFixed(0)}MB cache space`);
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
};
|
||||
|
||||
// Get or create cache for a track
|
||||
M.getTrackCache = function(trackId) {
|
||||
if (!trackId) return new Set();
|
||||
if (!M.trackCaches.has(trackId)) {
|
||||
M.trackCaches.set(trackId, new Set());
|
||||
}
|
||||
return M.trackCaches.get(trackId);
|
||||
};
|
||||
|
||||
// Get track URL - prefers cached blob, falls back to API
|
||||
M.getTrackUrl = function(trackId) {
|
||||
return M.trackBlobs.get(trackId) || "/api/tracks/" + encodeURIComponent(trackId);
|
||||
};
|
||||
|
||||
// Load a track blob from storage or fetch from server
|
||||
M.loadTrackBlob = async function(trackId) {
|
||||
// Check if already in memory
|
||||
if (M.trackBlobs.has(trackId)) {
|
||||
return M.trackBlobs.get(trackId);
|
||||
}
|
||||
|
||||
// Check persistent storage
|
||||
const cached = await TrackStorage.get(trackId);
|
||||
if (cached) {
|
||||
const blobUrl = URL.createObjectURL(cached.blob);
|
||||
M.trackBlobs.set(trackId, blobUrl);
|
||||
// Mark all segments as cached
|
||||
const trackCache = M.getTrackCache(trackId);
|
||||
for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i);
|
||||
M.bulkDownloadStarted.set(trackId, true);
|
||||
// Update cache status indicator
|
||||
if (!M.cachedTracks.has(trackId)) {
|
||||
M.cachedTracks.add(trackId);
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
}
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Check if track has all segments and trigger full cache if so
|
||||
M.checkAndCacheComplete = function(trackId) {
|
||||
if (!trackId || M.cachedTracks.has(trackId)) return;
|
||||
|
||||
const trackCache = M.trackCaches.get(trackId);
|
||||
if (trackCache && trackCache.size >= M.SEGMENTS) {
|
||||
console.log("[Cache] Track has all segments, triggering full cache:", trackId.slice(0, 16) + "...");
|
||||
M.downloadAndCacheTrack(trackId);
|
||||
}
|
||||
};
|
||||
|
||||
// Download and cache a full track
|
||||
M.downloadAndCacheTrack = async function(trackId) {
|
||||
if (M.bulkDownloadStarted.get(trackId)) return M.trackBlobs.get(trackId);
|
||||
M.bulkDownloadStarted.set(trackId, true);
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const res = await fetch("/api/tracks/" + encodeURIComponent(trackId));
|
||||
const data = await res.arrayBuffer();
|
||||
const elapsed = (performance.now() - startTime) / 1000;
|
||||
|
||||
// Mark all segments as cached
|
||||
const trackCache = M.getTrackCache(trackId);
|
||||
for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i);
|
||||
|
||||
// Create blob and URL
|
||||
const contentType = res.headers.get("Content-Type") || "audio/mpeg";
|
||||
const blob = new Blob([data], { type: contentType });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
M.trackBlobs.set(trackId, blobUrl);
|
||||
|
||||
// Persist to storage (handle quota errors gracefully)
|
||||
try {
|
||||
await TrackStorage.set(trackId, blob, contentType);
|
||||
M.cachedTracks.add(trackId);
|
||||
// Check if we need to prune old tracks
|
||||
M.checkCacheSize();
|
||||
} catch (e) {
|
||||
if (e.name === 'QuotaExceededError') {
|
||||
M.showToast("Cache full - track will stream only", "warning");
|
||||
} else {
|
||||
console.warn("[Cache] Storage error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache status and re-render lists
|
||||
console.log("[Cache] Track cached:", trackId.slice(0, 16) + "...", "| size:", (data.byteLength / 1024 / 1024).toFixed(2) + "MB");
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
|
||||
// Update download speed
|
||||
if (elapsed > 0 && data.byteLength > 0) {
|
||||
M.recentDownloads.push(data.byteLength / elapsed);
|
||||
if (M.recentDownloads.length > 5) M.recentDownloads.shift();
|
||||
M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length;
|
||||
}
|
||||
|
||||
return blobUrl;
|
||||
} catch (e) {
|
||||
M.bulkDownloadStarted.set(trackId, false);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch a single segment with range request
|
||||
async function fetchSegment(i, segStart, segEnd) {
|
||||
const trackId = M.currentTrackId;
|
||||
const trackCache = M.getTrackCache(trackId);
|
||||
if (M.loadingSegments.has(i) || trackCache.has(i)) return;
|
||||
M.loadingSegments.add(i);
|
||||
try {
|
||||
const byteStart = Math.floor(segStart * M.audioBytesPerSecond);
|
||||
const byteEnd = Math.floor(segEnd * M.audioBytesPerSecond);
|
||||
const startTime = performance.now();
|
||||
const res = await fetch("/api/tracks/" + encodeURIComponent(trackId), {
|
||||
headers: { "Range": `bytes=${byteStart}-${byteEnd}` }
|
||||
});
|
||||
const data = await res.arrayBuffer();
|
||||
const elapsed = (performance.now() - startTime) / 1000;
|
||||
|
||||
// Mark segment as cached
|
||||
trackCache.add(i);
|
||||
|
||||
// Check if all segments are now cached - if so, trigger full cache
|
||||
if (trackCache.size >= M.SEGMENTS && !M.cachedTracks.has(trackId)) {
|
||||
console.log("[Cache] All segments complete for:", trackId.slice(0, 16) + "...", "- triggering full cache");
|
||||
// Download full track to persist to storage
|
||||
M.downloadAndCacheTrack(trackId);
|
||||
}
|
||||
|
||||
// Update audio bitrate estimate
|
||||
const bytesReceived = data.byteLength;
|
||||
const durationCovered = segEnd - segStart;
|
||||
if (bytesReceived > 0 && durationCovered > 0) {
|
||||
M.audioBytesPerSecond = Math.round(bytesReceived / durationCovered);
|
||||
}
|
||||
|
||||
// Update download speed (rolling average of last 5 downloads)
|
||||
if (elapsed > 0 && bytesReceived > 0) {
|
||||
M.recentDownloads.push(bytesReceived / elapsed);
|
||||
if (M.recentDownloads.length > 5) M.recentDownloads.shift();
|
||||
M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length;
|
||||
}
|
||||
} catch (e) {}
|
||||
M.loadingSegments.delete(i);
|
||||
}
|
||||
|
||||
// Background bulk download - runs independently
|
||||
async function startBulkDownload() {
|
||||
const trackId = M.currentTrackId;
|
||||
if (!trackId || M.bulkDownloadStarted.get(trackId)) return;
|
||||
|
||||
const blobUrl = await M.downloadAndCacheTrack(trackId);
|
||||
|
||||
// Switch to blob URL if still on this track
|
||||
if (blobUrl && M.currentTrackId === trackId && M.audio.src && !M.audio.src.startsWith("blob:")) {
|
||||
const currentTime = M.audio.currentTime;
|
||||
const wasPlaying = !M.audio.paused;
|
||||
M.audio.src = blobUrl;
|
||||
M.audio.currentTime = currentTime;
|
||||
if (wasPlaying) M.audio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Prefetch missing segments
|
||||
let prefetching = false;
|
||||
|
||||
M.prefetchSegments = async function() {
|
||||
if (prefetching || !M.currentTrackId || !M.audio.src || M.serverTrackDuration <= 0) return;
|
||||
prefetching = true;
|
||||
|
||||
const segmentDur = M.serverTrackDuration / M.SEGMENTS;
|
||||
const missingSegments = [];
|
||||
const trackCache = M.getTrackCache(M.currentTrackId);
|
||||
|
||||
// Find all missing segments (not in audio buffer AND not in our cache)
|
||||
for (let i = 0; i < M.SEGMENTS; i++) {
|
||||
if (trackCache.has(i) || M.loadingSegments.has(i)) continue;
|
||||
|
||||
const segStart = i * segmentDur;
|
||||
const segEnd = (i + 1) * segmentDur;
|
||||
let available = false;
|
||||
for (let j = 0; j < M.audio.buffered.length; j++) {
|
||||
if (M.audio.buffered.start(j) <= segStart && M.audio.buffered.end(j) >= segEnd) {
|
||||
available = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!available) {
|
||||
missingSegments.push({ i, segStart, segEnd });
|
||||
}
|
||||
}
|
||||
|
||||
if (missingSegments.length > 0) {
|
||||
// Fast connection: also start bulk download in background
|
||||
if (M.downloadSpeed >= M.FAST_THRESHOLD && !M.bulkDownloadStarted.get(M.currentTrackId)) {
|
||||
startBulkDownload(); // Fire and forget
|
||||
}
|
||||
// Always fetch segments one at a time for seek support
|
||||
const s = missingSegments[0];
|
||||
await fetchSegment(s.i, s.segStart, s.segEnd);
|
||||
}
|
||||
|
||||
prefetching = false;
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
// MusicRoom - Auth module
|
||||
// Login, signup, logout, guest authentication
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Load current user from session
|
||||
M.loadCurrentUser = async function() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
M.currentUser = data.user;
|
||||
if (M.currentUser && data.permissions) {
|
||||
M.currentUser.permissions = data.permissions;
|
||||
}
|
||||
M.updateAuthUI();
|
||||
} catch (e) {
|
||||
M.currentUser = null;
|
||||
M.updateAuthUI();
|
||||
}
|
||||
};
|
||||
|
||||
// Tab switching
|
||||
M.$("#tab-login").onclick = () => {
|
||||
M.$("#tab-login").classList.add("active");
|
||||
M.$("#tab-signup").classList.remove("active");
|
||||
M.$("#login-fields").classList.remove("hidden");
|
||||
M.$("#signup-fields").classList.add("hidden");
|
||||
M.$("#auth-error").textContent = "";
|
||||
M.$("#signup-error").textContent = "";
|
||||
};
|
||||
|
||||
M.$("#tab-signup").onclick = () => {
|
||||
M.$("#tab-signup").classList.add("active");
|
||||
M.$("#tab-login").classList.remove("active");
|
||||
M.$("#signup-fields").classList.remove("hidden");
|
||||
M.$("#login-fields").classList.add("hidden");
|
||||
M.$("#auth-error").textContent = "";
|
||||
M.$("#signup-error").textContent = "";
|
||||
};
|
||||
|
||||
// Login
|
||||
M.$("#btn-login").onclick = async () => {
|
||||
const username = M.$("#login-username").value.trim();
|
||||
const password = M.$("#login-password").value;
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
M.$("#auth-error").textContent = data.error || "Login failed";
|
||||
return;
|
||||
}
|
||||
M.$("#login-username").value = "";
|
||||
M.$("#login-password").value = "";
|
||||
await M.loadCurrentUser();
|
||||
M.loadStreams();
|
||||
} catch (e) {
|
||||
M.$("#auth-error").textContent = "Login failed";
|
||||
}
|
||||
};
|
||||
|
||||
// Signup
|
||||
M.$("#btn-signup").onclick = async () => {
|
||||
const username = M.$("#signup-username").value.trim();
|
||||
const password = M.$("#signup-password").value;
|
||||
try {
|
||||
const res = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
M.$("#signup-error").textContent = data.error || "Signup failed";
|
||||
return;
|
||||
}
|
||||
M.$("#signup-username").value = "";
|
||||
M.$("#signup-password").value = "";
|
||||
await M.loadCurrentUser();
|
||||
M.loadStreams();
|
||||
} catch (e) {
|
||||
M.$("#signup-error").textContent = "Signup failed";
|
||||
}
|
||||
};
|
||||
|
||||
// Guest login
|
||||
M.$("#btn-guest").onclick = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
M.currentUser = data.user;
|
||||
if (M.currentUser && data.permissions) {
|
||||
M.currentUser.permissions = data.permissions;
|
||||
}
|
||||
M.updateAuthUI();
|
||||
if (M.currentUser) M.loadStreams();
|
||||
} catch (e) {
|
||||
M.$("#auth-error").textContent = "Failed to continue as guest";
|
||||
}
|
||||
};
|
||||
|
||||
// Logout
|
||||
M.$("#btn-logout").onclick = async () => {
|
||||
const wasGuest = M.currentUser?.isGuest;
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
M.currentUser = null;
|
||||
if (wasGuest) {
|
||||
// Guest clicking "Sign In" - show login panel
|
||||
M.updateAuthUI();
|
||||
} else {
|
||||
// Regular user logging out - reload to get new guest session
|
||||
M.updateAuthUI();
|
||||
}
|
||||
};
|
||||
|
||||
// Kick other clients
|
||||
M.$("#btn-kick-others").onclick = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/kick-others", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
M.showToast(`Kicked ${data.kicked} other client${data.kicked !== 1 ? 's' : ''}`);
|
||||
}
|
||||
} catch (e) {
|
||||
M.showToast("Failed to kick other clients");
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
// MusicRoom - Channel Sync module
|
||||
// WebSocket connection and server synchronization
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Load available channels and connect to saved or default
|
||||
M.loadChannels = async function() {
|
||||
try {
|
||||
const res = await fetch("/api/channels");
|
||||
const channels = await res.json();
|
||||
if (channels.length === 0) {
|
||||
M.setTrackTitle("No channels available");
|
||||
return;
|
||||
}
|
||||
M.channels = channels;
|
||||
M.renderChannelList();
|
||||
// Try saved channel first, fall back to default
|
||||
const savedChannelId = localStorage.getItem("blastoise_channel");
|
||||
const savedChannel = savedChannelId && channels.find(c => c.id === savedChannelId);
|
||||
const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0];
|
||||
M.connectChannel(targetChannel.id);
|
||||
} catch (e) {
|
||||
M.setTrackTitle("Server unavailable");
|
||||
M.$("#status").textContent = "Local (offline)";
|
||||
M.synced = false;
|
||||
M.updateUI();
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new channel
|
||||
M.createChannel = async function(name, description) {
|
||||
try {
|
||||
const res = await fetch("/api/channels", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
M.showToast(err.error || "Failed to create channel");
|
||||
return null;
|
||||
}
|
||||
const channel = await res.json();
|
||||
M.showToast(`Channel "${channel.name}" created`);
|
||||
return channel;
|
||||
} catch (e) {
|
||||
M.showToast("Failed to create channel");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a channel
|
||||
M.deleteChannel = async function(channelId) {
|
||||
const channel = M.channels?.find(c => c.id === channelId);
|
||||
if (!channel) return;
|
||||
if (channel.isDefault) {
|
||||
M.showToast("Cannot delete default channel");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete channel "${channel.name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
M.showToast(err.error || "Failed to delete channel");
|
||||
return;
|
||||
}
|
||||
M.showToast(`Channel "${channel.name}" deleted`);
|
||||
} catch (e) {
|
||||
M.showToast("Failed to delete channel");
|
||||
}
|
||||
};
|
||||
|
||||
// New channel creation with slideout input
|
||||
M.createNewChannel = async function() {
|
||||
const header = M.$("#channels-panel .panel-header");
|
||||
const btn = M.$("#btn-new-channel");
|
||||
|
||||
// Already in edit mode?
|
||||
if (header.querySelector(".new-channel-input")) return;
|
||||
|
||||
// Hide button, show input
|
||||
btn.style.display = "none";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "new-channel-input";
|
||||
input.placeholder = "Channel name...";
|
||||
input.maxLength = 64;
|
||||
|
||||
const submit = document.createElement("button");
|
||||
submit.className = "btn-submit-channel";
|
||||
submit.textContent = "›";
|
||||
|
||||
header.appendChild(input);
|
||||
header.appendChild(submit);
|
||||
input.focus();
|
||||
|
||||
const cleanup = () => {
|
||||
input.remove();
|
||||
submit.remove();
|
||||
btn.style.display = "";
|
||||
};
|
||||
|
||||
const doCreate = async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await M.createChannel(name);
|
||||
} catch (e) {
|
||||
M.showToast("Failed to create channel");
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
|
||||
submit.onclick = doCreate;
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === "Enter") doCreate();
|
||||
if (e.key === "Escape") cleanup();
|
||||
};
|
||||
input.onblur = (e) => {
|
||||
if (e.relatedTarget !== submit) cleanup();
|
||||
};
|
||||
};
|
||||
|
||||
// New channel button handler
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const btn = M.$("#btn-new-channel");
|
||||
if (btn) {
|
||||
btn.onclick = () => M.createNewChannel();
|
||||
}
|
||||
});
|
||||
|
||||
// Render channel list in sidebar
|
||||
M.renderChannelList = function() {
|
||||
const container = M.$("#channels-list");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
for (const ch of M.channels || []) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "channel-item" + (ch.id === M.currentChannelId ? " active" : "");
|
||||
const listeners = ch.listeners || [];
|
||||
// Count occurrences of each user
|
||||
const counts = {};
|
||||
for (const name of listeners) {
|
||||
counts[name] = (counts[name] || 0) + 1;
|
||||
}
|
||||
const listenersHtml = Object.entries(counts).map(([name, count]) =>
|
||||
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
||||
).join("");
|
||||
|
||||
// Show delete button for non-default channels if user is admin or creator
|
||||
const canDelete = !ch.isDefault && M.currentUser &&
|
||||
(M.currentUser.isAdmin || ch.createdBy === M.currentUser.id);
|
||||
const deleteBtn = canDelete ? `<button class="btn-delete-channel" title="Delete channel">×</button>` : "";
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="channel-header">
|
||||
<span class="channel-name">${ch.name}</span>
|
||||
${deleteBtn}
|
||||
<span class="listener-count">${ch.listenerCount}</span>
|
||||
</div>
|
||||
<div class="channel-listeners">${listenersHtml}</div>
|
||||
`;
|
||||
const headerEl = div.querySelector(".channel-header");
|
||||
headerEl.querySelector(".channel-name").onclick = () => M.switchChannel(ch.id);
|
||||
const delBtn = headerEl.querySelector(".btn-delete-channel");
|
||||
if (delBtn) {
|
||||
delBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
M.deleteChannel(ch.id);
|
||||
};
|
||||
}
|
||||
container.appendChild(div);
|
||||
}
|
||||
};
|
||||
|
||||
// Switch to a different channel via WebSocket
|
||||
M.switchChannel = function(channelId) {
|
||||
if (channelId === M.currentChannelId) return;
|
||||
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
|
||||
M.ws.send(JSON.stringify({ action: "switch", channelId }));
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to a channel via WebSocket
|
||||
M.connectChannel = function(id) {
|
||||
if (M.ws) {
|
||||
const oldWs = M.ws;
|
||||
M.ws = null;
|
||||
oldWs.onclose = null;
|
||||
oldWs.onerror = null;
|
||||
oldWs.close();
|
||||
}
|
||||
M.currentChannelId = id;
|
||||
localStorage.setItem("blastoise_channel", id);
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
|
||||
|
||||
// Track if we've ever connected successfully
|
||||
let wasConnected = false;
|
||||
|
||||
M.ws.onmessage = (e) => {
|
||||
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;
|
||||
}
|
||||
// Handle channel switch confirmation
|
||||
if (data.type === "switched") {
|
||||
M.currentChannelId = data.channelId;
|
||||
M.renderChannelList();
|
||||
return;
|
||||
}
|
||||
// Handle kick command
|
||||
if (data.type === "kick") {
|
||||
M.showToast("Disconnected: " + (data.reason || "Kicked by another session"));
|
||||
M.wantSync = false;
|
||||
M.synced = false;
|
||||
M.audio.pause();
|
||||
if (M.ws) {
|
||||
const oldWs = M.ws;
|
||||
M.ws = null;
|
||||
oldWs.onclose = null;
|
||||
oldWs.onerror = null;
|
||||
oldWs.close();
|
||||
}
|
||||
M.updateUI();
|
||||
return;
|
||||
}
|
||||
// Handle library updates
|
||||
if (data.type === "track_added") {
|
||||
M.showToast(`"${data.track.title}" is now available`);
|
||||
if (data.library) {
|
||||
M.library = data.library;
|
||||
M.renderLibrary();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === "track_removed") {
|
||||
M.showToast(`"${data.track.title}" was removed`);
|
||||
if (data.library) {
|
||||
M.library = data.library;
|
||||
M.renderLibrary();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle scan progress
|
||||
if (data.type === "scan_progress") {
|
||||
const el = M.$("#scan-progress");
|
||||
const wasScanning = !el.classList.contains("complete") && !el.classList.contains("hidden");
|
||||
|
||||
if (data.scanning) {
|
||||
el.innerHTML = `<span class="spinner"></span>Scanning: ${data.processed}/${data.total} files`;
|
||||
el.classList.remove("hidden");
|
||||
el.classList.remove("complete");
|
||||
} else {
|
||||
// Show track count when not scanning
|
||||
const count = M.library.length;
|
||||
el.innerHTML = `${count} song${count !== 1 ? 's' : ''} in library`;
|
||||
el.classList.remove("hidden");
|
||||
el.classList.add("complete");
|
||||
|
||||
// Show toast if scan just finished
|
||||
if (wasScanning) {
|
||||
M.showToast("Scanning complete!");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle server-sent toasts
|
||||
if (data.type === "toast") {
|
||||
M.showToast(data.message, data.toastType || "info");
|
||||
return;
|
||||
}
|
||||
// Handle fetch progress from ytdlp
|
||||
if (data.type && data.type.startsWith("fetch_")) {
|
||||
if (M.handleFetchProgress) {
|
||||
M.handleFetchProgress(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Normal channel state update
|
||||
M.handleUpdate(data);
|
||||
};
|
||||
|
||||
M.ws.onerror = () => {
|
||||
console.log("[WS] Connection error");
|
||||
};
|
||||
|
||||
M.ws.onclose = () => {
|
||||
M.synced = false;
|
||||
M.ws = null;
|
||||
M.$("#sync-indicator").classList.add("disconnected");
|
||||
M.updateUI();
|
||||
// Auto-reconnect if user wants to be synced
|
||||
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
|
||||
if (M.wantSync) {
|
||||
const delay = wasConnected ? 3000 : 2000;
|
||||
setTimeout(() => M.connectChannel(id), delay);
|
||||
}
|
||||
};
|
||||
|
||||
M.ws.onopen = () => {
|
||||
wasConnected = true;
|
||||
M.synced = true;
|
||||
M.$("#sync-indicator").classList.remove("disconnected");
|
||||
M.updateUI();
|
||||
};
|
||||
};
|
||||
|
||||
// Handle channel state update from server
|
||||
M.handleUpdate = async function(data) {
|
||||
console.log("[WS] State update:", {
|
||||
track: data.track?.title,
|
||||
timestamp: data.currentTimestamp?.toFixed(1),
|
||||
paused: data.paused,
|
||||
currentIndex: data.currentIndex
|
||||
});
|
||||
|
||||
M.$("#channel-name").textContent = data.channelName || "";
|
||||
|
||||
// Update queue if provided (do this before early return for no track)
|
||||
if (data.queue) {
|
||||
M.queue = data.queue;
|
||||
M.currentIndex = data.currentIndex ?? 0;
|
||||
M.renderQueue();
|
||||
} else if (data.currentIndex !== undefined && data.currentIndex !== M.currentIndex) {
|
||||
M.currentIndex = data.currentIndex;
|
||||
M.renderQueue();
|
||||
}
|
||||
|
||||
// Update playback mode if provided
|
||||
if (data.playbackMode && data.playbackMode !== M.playbackMode) {
|
||||
M.playbackMode = data.playbackMode;
|
||||
if (M.updateModeButton) M.updateModeButton();
|
||||
}
|
||||
|
||||
if (!data.track) {
|
||||
M.setTrackTitle("No tracks");
|
||||
return;
|
||||
}
|
||||
M.serverTimestamp = data.currentTimestamp;
|
||||
M.serverTrackDuration = data.track.duration;
|
||||
M.lastServerUpdate = Date.now();
|
||||
const wasServerPaused = M.serverPaused;
|
||||
M.serverPaused = data.paused ?? true;
|
||||
|
||||
// Cache track info for local mode - use track.id (content hash) as the identifier
|
||||
const trackId = data.track.id || data.track.filename; // Fallback for compatibility
|
||||
const isNewTrack = trackId !== M.currentTrackId;
|
||||
if (isNewTrack) {
|
||||
M.currentTrackId = trackId;
|
||||
M.setTrackTitle(data.track.title);
|
||||
M.loadingSegments.clear();
|
||||
|
||||
// Debug: log cache state for this track
|
||||
const trackCache = M.trackCaches.get(trackId);
|
||||
console.log("[Playback] Starting track:", data.track.title, {
|
||||
trackId: trackId,
|
||||
segments: trackCache ? [...trackCache] : [],
|
||||
segmentCount: trackCache ? trackCache.size : 0,
|
||||
inCachedTracks: M.cachedTracks.has(trackId),
|
||||
bulkStarted: M.bulkDownloadStarted.get(trackId) || false,
|
||||
hasBlobUrl: M.trackBlobs.has(trackId)
|
||||
});
|
||||
|
||||
// Check if this track already has all segments cached
|
||||
M.checkAndCacheComplete(trackId);
|
||||
}
|
||||
|
||||
if (M.synced) {
|
||||
if (!M.serverPaused) {
|
||||
// Server is playing - ensure we're playing and synced
|
||||
if (isNewTrack || !M.audio.src) {
|
||||
// Try cache first
|
||||
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
||||
M.audio.currentTime = data.currentTimestamp;
|
||||
M.audio.play().catch(() => {});
|
||||
} else if (M.audio.paused) {
|
||||
M.audio.currentTime = data.currentTimestamp;
|
||||
M.audio.play().catch(() => {});
|
||||
} else {
|
||||
// Check drift
|
||||
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
|
||||
if (drift >= 2) {
|
||||
console.log("[Sync] Correcting drift:", drift.toFixed(1), "s");
|
||||
M.audio.currentTime = data.currentTimestamp;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Server is paused - ensure we're paused too
|
||||
if (!M.audio.paused) {
|
||||
M.audio.pause();
|
||||
}
|
||||
// Sync to paused position
|
||||
if (isNewTrack || !M.audio.src) {
|
||||
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
||||
M.audio.currentTime = data.currentTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
M.updateUI();
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
// MusicRoom - Controls module
|
||||
// Play, pause, seek, volume, prev/next track
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Load saved volume
|
||||
const savedVolume = localStorage.getItem(M.STORAGE_KEY);
|
||||
if (savedVolume !== null) {
|
||||
M.audio.volume = parseFloat(savedVolume);
|
||||
M.$("#volume").value = savedVolume;
|
||||
} else {
|
||||
// No saved volume - sync audio to slider's default value
|
||||
M.audio.volume = parseFloat(M.$("#volume").value);
|
||||
}
|
||||
|
||||
// Toggle play/pause
|
||||
function togglePlayback() {
|
||||
if (!M.currentTrackId) return;
|
||||
|
||||
if (M.synced) {
|
||||
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
|
||||
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
|
||||
}
|
||||
} else {
|
||||
if (M.audio.paused) {
|
||||
if (!M.audio.src) {
|
||||
M.audio.src = M.getTrackUrl(M.currentTrackId);
|
||||
M.audio.currentTime = M.localTimestamp;
|
||||
}
|
||||
M.audio.play();
|
||||
} else {
|
||||
M.localTimestamp = M.audio.currentTime;
|
||||
M.audio.pause();
|
||||
}
|
||||
M.updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to a specific track index
|
||||
async function jumpToTrack(index) {
|
||||
if (M.queue.length === 0) return;
|
||||
const newIndex = (index + M.queue.length) % M.queue.length;
|
||||
|
||||
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: newIndex })
|
||||
});
|
||||
if (res.status === 403) M.flashPermissionDenied();
|
||||
if (res.status === 400) console.warn("Jump failed: 400 - newIndex:", newIndex, "queue length:", M.queue.length);
|
||||
} else {
|
||||
const track = M.queue[newIndex];
|
||||
const trackId = track.id || track.filename;
|
||||
M.currentIndex = newIndex;
|
||||
M.currentTrackId = trackId;
|
||||
M.serverTrackDuration = track.duration;
|
||||
M.setTrackTitle(track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown");
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync toggle
|
||||
M.$("#btn-sync").onclick = () => {
|
||||
M.wantSync = !M.wantSync;
|
||||
if (M.wantSync) {
|
||||
// User wants to sync - try to connect
|
||||
if (M.currentChannelId) {
|
||||
M.connectChannel(M.currentChannelId);
|
||||
}
|
||||
} else {
|
||||
// User wants local mode - disconnect
|
||||
M.synced = false;
|
||||
M.localTimestamp = M.audio.currentTime || M.getServerTime();
|
||||
if (M.ws) {
|
||||
const oldWs = M.ws;
|
||||
M.ws = null;
|
||||
oldWs.onclose = null;
|
||||
oldWs.close();
|
||||
}
|
||||
}
|
||||
M.updateUI();
|
||||
};
|
||||
|
||||
// Play/pause button
|
||||
M.$("#status-icon").onclick = togglePlayback;
|
||||
|
||||
// Prev/next buttons
|
||||
M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1);
|
||||
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
|
||||
|
||||
// Stream-only toggle
|
||||
const streamBtn = M.$("#btn-stream-only");
|
||||
M.updateStreamOnlyButton = function() {
|
||||
streamBtn.classList.toggle("active", M.streamOnly);
|
||||
streamBtn.title = M.streamOnly ? "Stream-only mode (click to enable caching)" : "Click to enable stream-only mode (no caching)";
|
||||
};
|
||||
streamBtn.onclick = () => {
|
||||
M.setStreamOnly(!M.streamOnly);
|
||||
M.updateStreamOnlyButton();
|
||||
};
|
||||
M.updateStreamOnlyButton();
|
||||
|
||||
// Playback mode button
|
||||
const modeLabels = {
|
||||
"once": "once",
|
||||
"repeat-all": "repeat",
|
||||
"repeat-one": "single",
|
||||
"shuffle": "shuffle"
|
||||
};
|
||||
const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"];
|
||||
|
||||
M.updateModeButton = function() {
|
||||
const btn = M.$("#btn-mode");
|
||||
btn.textContent = modeLabels[M.playbackMode] || "repeat";
|
||||
btn.title = `Playback: ${M.playbackMode}`;
|
||||
btn.className = "mode-" + M.playbackMode;
|
||||
};
|
||||
|
||||
M.$("#btn-mode").onclick = async () => {
|
||||
if (!M.synced || !M.currentChannelId) {
|
||||
// Local mode - just cycle through modes
|
||||
const currentIdx = modeOrder.indexOf(M.playbackMode);
|
||||
M.playbackMode = modeOrder[(currentIdx + 1) % modeOrder.length];
|
||||
M.updateModeButton();
|
||||
return;
|
||||
}
|
||||
|
||||
// Synced mode - send to server
|
||||
const currentIdx = modeOrder.indexOf(M.playbackMode);
|
||||
const newMode = modeOrder[(currentIdx + 1) % modeOrder.length];
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/mode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: newMode })
|
||||
});
|
||||
if (res.status === 403) M.flashPermissionDenied();
|
||||
};
|
||||
|
||||
M.updateModeButton();
|
||||
|
||||
// Progress bar seek tooltip
|
||||
M.$("#progress-container").onmousemove = (e) => {
|
||||
if (M.serverTrackDuration <= 0) return;
|
||||
const rect = M.$("#progress-container").getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
const hoverTime = pct * M.serverTrackDuration;
|
||||
const tooltip = M.$("#seek-tooltip");
|
||||
tooltip.textContent = M.fmt(hoverTime);
|
||||
tooltip.style.left = (pct * 100) + "%";
|
||||
tooltip.style.display = "block";
|
||||
};
|
||||
|
||||
M.$("#progress-container").onmouseleave = () => {
|
||||
M.$("#seek-tooltip").style.display = "none";
|
||||
};
|
||||
|
||||
// Progress bar seek
|
||||
M.$("#progress-container").onclick = (e) => {
|
||||
const dur = M.synced ? M.serverTrackDuration : (M.audio.duration || M.serverTrackDuration);
|
||||
if (!M.currentTrackId || dur <= 0) return;
|
||||
const rect = M.$("#progress-container").getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const seekTime = pct * dur;
|
||||
|
||||
if (M.synced && M.currentChannelId) {
|
||||
fetch("/api/channels/" + M.currentChannelId + "/seek", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ timestamp: seekTime })
|
||||
}).then(res => { if (res.status === 403) M.flashPermissionDenied(); });
|
||||
} else {
|
||||
if (!M.audio.src) {
|
||||
M.audio.src = M.getTrackUrl(M.currentTrackId);
|
||||
}
|
||||
M.audio.currentTime = seekTime;
|
||||
M.localTimestamp = seekTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Mute toggle
|
||||
M.$("#btn-mute").onclick = () => {
|
||||
if (M.audio.volume > 0) {
|
||||
M.preMuteVolume = M.audio.volume;
|
||||
M.audio.volume = 0;
|
||||
M.$("#volume").value = 0;
|
||||
} else {
|
||||
M.audio.volume = M.preMuteVolume;
|
||||
M.$("#volume").value = M.preMuteVolume;
|
||||
}
|
||||
localStorage.setItem(M.STORAGE_KEY, M.audio.volume);
|
||||
M.updateUI();
|
||||
};
|
||||
|
||||
// Volume slider
|
||||
M.$("#volume").oninput = (e) => {
|
||||
M.audio.volume = e.target.value;
|
||||
localStorage.setItem(M.STORAGE_KEY, e.target.value);
|
||||
M.updateUI();
|
||||
};
|
||||
|
||||
// Audio element events
|
||||
M.audio.onplay = () => { M.$("#progress-bar").classList.add("playing"); M.updateUI(); };
|
||||
M.audio.onpause = () => { M.$("#progress-bar").classList.remove("playing"); M.updateUI(); };
|
||||
|
||||
// Track loading state from audio element's progress
|
||||
M.audio.onprogress = () => {
|
||||
if (M.serverTrackDuration <= 0) return;
|
||||
const segmentDur = M.serverTrackDuration / M.SEGMENTS;
|
||||
M.loadingSegments.clear();
|
||||
for (let i = 0; i < M.SEGMENTS; i++) {
|
||||
const segStart = i * segmentDur;
|
||||
const segEnd = (i + 1) * segmentDur;
|
||||
let fullyBuffered = false;
|
||||
let partiallyBuffered = false;
|
||||
for (let j = 0; j < M.audio.buffered.length; j++) {
|
||||
const bufStart = M.audio.buffered.start(j);
|
||||
const bufEnd = M.audio.buffered.end(j);
|
||||
if (bufStart <= segStart && bufEnd >= segEnd) {
|
||||
fullyBuffered = true;
|
||||
break;
|
||||
}
|
||||
// Check if buffer is actively loading into this segment
|
||||
if (bufEnd > segStart && bufEnd < segEnd && bufStart <= segStart) {
|
||||
partiallyBuffered = true;
|
||||
}
|
||||
}
|
||||
if (partiallyBuffered && !fullyBuffered) {
|
||||
M.loadingSegments.add(i);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// MusicRoom - Core module
|
||||
// Shared state and namespace setup
|
||||
|
||||
window.MusicRoom = {
|
||||
// Audio element
|
||||
audio: new Audio(),
|
||||
|
||||
// WebSocket and channel state
|
||||
ws: null,
|
||||
currentChannelId: null,
|
||||
currentTrackId: null,
|
||||
currentTitle: null,
|
||||
serverTimestamp: 0,
|
||||
serverTrackDuration: 0,
|
||||
lastServerUpdate: 0,
|
||||
serverPaused: true,
|
||||
|
||||
// Channels list
|
||||
channels: [],
|
||||
|
||||
// Sync state
|
||||
wantSync: true, // User intent - do they want to be synced?
|
||||
synced: false, // Actual state - are we currently synced?
|
||||
|
||||
// Volume
|
||||
preMuteVolume: 1,
|
||||
STORAGE_KEY: "blastoise_volume",
|
||||
|
||||
// Playback state
|
||||
localTimestamp: 0,
|
||||
queue: [],
|
||||
currentIndex: 0,
|
||||
playbackMode: "repeat-all",
|
||||
|
||||
// User state
|
||||
currentUser: null,
|
||||
serverStatus: null,
|
||||
|
||||
// Library (all discovered tracks)
|
||||
library: [],
|
||||
|
||||
// Caching state
|
||||
prefetchController: null,
|
||||
loadingSegments: new Set(),
|
||||
trackCaches: new Map(), // Map of filename -> Set of cached segment indices
|
||||
trackBlobs: new Map(), // Map of filename -> Blob URL for fully cached tracks
|
||||
bulkDownloadStarted: new Map(),
|
||||
cachedTracks: new Set(), // Set of track IDs that are fully cached locally
|
||||
streamOnly: false, // When true, skip bulk downloads (still cache played tracks)
|
||||
cacheLimit: 100 * 1024 * 1024, // 100MB default cache limit
|
||||
|
||||
// Download metrics
|
||||
audioBytesPerSecond: 20000, // Audio bitrate estimate for range requests
|
||||
downloadSpeed: 0, // Actual network download speed
|
||||
recentDownloads: [], // Track recent downloads for speed calculation
|
||||
|
||||
// Constants
|
||||
SEGMENTS: 20,
|
||||
FAST_THRESHOLD: 10 * 1024 * 1024, // 10 MB/s
|
||||
|
||||
// UI update tracking (to avoid unnecessary DOM updates)
|
||||
lastProgressPct: -1,
|
||||
lastTimeCurrent: "",
|
||||
lastTimeTotal: "",
|
||||
lastBufferPct: -1,
|
||||
lastSpeedText: ""
|
||||
};
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NeoRose</title>
|
||||
<link rel="stylesheet" href="styles.css?v=20">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>Blastoise <span id="sync-indicator"></span></h1>
|
||||
|
||||
<div id="login-panel">
|
||||
<h2>Sign in to continue</h2>
|
||||
<div class="tabs">
|
||||
<button id="tab-login" class="active">Login</button>
|
||||
<button id="tab-signup">Sign Up</button>
|
||||
</div>
|
||||
<div id="login-fields" class="form-group">
|
||||
<input type="text" id="login-username" placeholder="Username">
|
||||
<input type="password" id="login-password" placeholder="Password">
|
||||
<button class="submit-btn" id="btn-login">Login</button>
|
||||
<div id="auth-error"></div>
|
||||
</div>
|
||||
<div id="signup-fields" class="form-group hidden">
|
||||
<input type="text" id="signup-username" placeholder="Username (min 3 chars)">
|
||||
<input type="password" id="signup-password" placeholder="Password (min 6 chars)">
|
||||
<button class="submit-btn" id="btn-signup">Sign Up</button>
|
||||
<div id="signup-error"></div>
|
||||
</div>
|
||||
<div id="guest-section" class="hidden">
|
||||
<div class="divider"><span>or</span></div>
|
||||
<button class="guest-btn" id="btn-guest">Continue as Guest</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="player-content">
|
||||
<div id="header-row">
|
||||
<div id="auth-section">
|
||||
<div class="user-info">
|
||||
<span class="username" id="current-username"></span>
|
||||
<span class="admin-badge" id="admin-badge" style="display:none;">Admin</span>
|
||||
<button id="btn-kick-others" title="Disconnect all other devices">Disconnect all my devices</button>
|
||||
<button id="btn-logout">logout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stream-select"></div>
|
||||
</div>
|
||||
|
||||
<div id="main-content">
|
||||
<div id="channels-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Channels</h3>
|
||||
<button id="btn-new-channel" title="New channel">+</button>
|
||||
</div>
|
||||
<div id="channels-list"></div>
|
||||
</div>
|
||||
<div id="library-panel">
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" data-tab="library">Library</button>
|
||||
<button class="panel-tab" data-tab="tasks">Tasks</button>
|
||||
</div>
|
||||
<div class="panel-views">
|
||||
<div id="library-view" class="panel-view active">
|
||||
<div class="panel-header">
|
||||
<input type="text" id="library-search" placeholder="Search..." class="search-input">
|
||||
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
|
||||
</div>
|
||||
<div id="scan-progress" class="scan-progress hidden"></div>
|
||||
<div id="library"></div>
|
||||
<div id="add-panel" class="add-panel hidden">
|
||||
<button id="btn-add-close" class="add-panel-close">Close</button>
|
||||
<div class="add-panel-content">
|
||||
<button id="btn-upload-files" class="add-option">Upload files...</button>
|
||||
<button id="btn-fetch-url" class="add-option">Fetch from website...</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fetch-dialog" class="fetch-dialog hidden">
|
||||
<div class="fetch-dialog-header">
|
||||
<span>Fetch from URL</span>
|
||||
<button id="btn-fetch-close" class="fetch-dialog-close">×</button>
|
||||
</div>
|
||||
<div class="fetch-dialog-content">
|
||||
<input type="text" id="fetch-url-input" class="fetch-url-input" placeholder="https://youtube.com/watch?v=...">
|
||||
<button id="btn-fetch-submit" class="fetch-submit-btn">Fetch</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-add" class="add-btn">Add to library...</button>
|
||||
<div id="upload-dropzone" class="upload-dropzone hidden">
|
||||
<div class="dropzone-content">Drop audio files here</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="queue-panel">
|
||||
<h3 id="queue-title">Queue</h3>
|
||||
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>
|
||||
<div id="queue"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="player-bar">
|
||||
<div id="now-playing">
|
||||
<div id="channel-name"></div>
|
||||
<div id="track-name" class="empty">
|
||||
<span class="marquee-inner"><span id="track-title">Loading...</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-controls">
|
||||
<div id="progress-row">
|
||||
<span id="btn-sync" title="Toggle sync">sync</span>
|
||||
<span id="btn-prev" title="Previous track">⏮</span>
|
||||
<span id="status-icon">▶</span>
|
||||
<span id="btn-next" title="Next track">⏭</span>
|
||||
<span id="btn-mode" class="mode-repeat-all" title="Playback mode">repeat</span>
|
||||
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
|
||||
<div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
|
||||
</div>
|
||||
<div id="buffer-bar"></div>
|
||||
<div id="download-speed"></div>
|
||||
</div>
|
||||
<div id="volume-controls">
|
||||
<span id="btn-stream-only" title="Toggle stream-only mode (no caching)">stream</span>
|
||||
<span id="btn-mute" title="Toggle mute">🔊</span>
|
||||
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
<div id="toast-container"></div>
|
||||
<button id="btn-history" title="Show notification history">recent notifications</button>
|
||||
<div id="toast-history" class="hidden">
|
||||
<div class="history-header">
|
||||
<span>Notification History</span>
|
||||
<button id="btn-close-history">×</button>
|
||||
</div>
|
||||
<div id="toast-history-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="trackStorage.js"></script>
|
||||
<script src="core.js"></script>
|
||||
<script src="utils.js"></script>
|
||||
<script src="audioCache.js"></script>
|
||||
<script src="channelSync.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
<script src="queue.js"></script>
|
||||
<script src="controls.js"></script>
|
||||
<script src="auth.js"></script>
|
||||
<script src="upload.js"></script>
|
||||
<script src="init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// MusicRoom - Init module
|
||||
// Application initialization sequence
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Fetch server status/config
|
||||
async function loadServerStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/status");
|
||||
M.serverStatus = await res.json();
|
||||
console.log("Server status:", M.serverStatus);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load server status");
|
||||
M.serverStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize track storage
|
||||
async function initStorage() {
|
||||
await TrackStorage.init();
|
||||
await M.updateCacheStatus();
|
||||
console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`);
|
||||
}
|
||||
|
||||
// Setup panel tab switching
|
||||
function initPanelTabs() {
|
||||
const tabs = document.querySelectorAll(".panel-tab");
|
||||
tabs.forEach(tab => {
|
||||
tab.onclick = () => {
|
||||
const tabId = tab.dataset.tab;
|
||||
const panel = tab.closest("#library-panel, #queue-panel");
|
||||
if (!panel) return;
|
||||
|
||||
// Update active tab
|
||||
panel.querySelectorAll(".panel-tab").forEach(t => t.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
|
||||
// Update active view
|
||||
panel.querySelectorAll(".panel-view").forEach(v => v.classList.remove("active"));
|
||||
const view = panel.querySelector(`#${tabId}-view`);
|
||||
if (view) view.classList.add("active");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Setup history panel handlers
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const btnHistory = M.$("#btn-history");
|
||||
const btnClose = M.$("#btn-close-history");
|
||||
|
||||
if (btnHistory) {
|
||||
btnHistory.onclick = () => M.toggleToastHistory();
|
||||
}
|
||||
if (btnClose) {
|
||||
btnClose.onclick = () => M.toggleToastHistory();
|
||||
}
|
||||
|
||||
initPanelTabs();
|
||||
});
|
||||
|
||||
// Update UI based on server status
|
||||
function updateFeatureVisibility() {
|
||||
const fetchBtn = M.$("#btn-fetch-url");
|
||||
if (fetchBtn) {
|
||||
const ytdlpEnabled = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
|
||||
fetchBtn.style.display = ytdlpEnabled ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
Promise.all([initStorage(), loadServerStatus()]).then(async () => {
|
||||
updateFeatureVisibility();
|
||||
await M.loadLibrary();
|
||||
await M.loadCurrentUser();
|
||||
if (M.currentUser) {
|
||||
M.loadChannels();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -0,0 +1,896 @@
|
|||
// MusicRoom - Queue module
|
||||
// Queue rendering and library display
|
||||
|
||||
(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;
|
||||
|
||||
// Download a track to user's device (uses cache if available)
|
||||
async function downloadTrack(trackId, filename) {
|
||||
if (isDownloading) {
|
||||
M.showToast("Download already in progress", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
isDownloading = true;
|
||||
M.showToast(`Downloading: ${filename}`);
|
||||
|
||||
try {
|
||||
let blob = null;
|
||||
|
||||
// Try to get from cache first
|
||||
if (M.cachedTracks.has(trackId)) {
|
||||
try {
|
||||
const cached = await TrackStorage.get(trackId);
|
||||
if (cached && cached.blob) {
|
||||
blob = cached.blob;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Cache miss, fetching from server");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to fetching from server
|
||||
if (!blob) {
|
||||
const res = await fetch(`/api/tracks/${encodeURIComponent(trackId)}`);
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
blob = await res.blob();
|
||||
}
|
||||
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Empty blob");
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
M.showToast(`Downloaded: ${filename}`);
|
||||
} catch (e) {
|
||||
console.error("Download error:", e);
|
||||
M.showToast(`Download failed: ${e.message}`, "error");
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Export all cached tracks
|
||||
M.exportAllCached = async function() {
|
||||
if (isExporting) {
|
||||
M.showToast("Export already in progress", "warning");
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting = true;
|
||||
const msg = skipped > 0
|
||||
? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)`
|
||||
: `Exporting ${exportQueue.length} cached tracks...`;
|
||||
M.showToast(msg);
|
||||
|
||||
let exported = 0;
|
||||
for (const { id, filename } of exportQueue) {
|
||||
if (!isExporting) break; // Allow cancellation
|
||||
|
||||
try {
|
||||
const cached = await TrackStorage.get(id);
|
||||
if (cached && cached.blob) {
|
||||
const url = URL.createObjectURL(cached.blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
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) {
|
||||
console.error(`Export error for ${filename}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
isExporting = false;
|
||||
exportQueue = [];
|
||||
M.showToast(`Exported ${exported} tracks`);
|
||||
};
|
||||
|
||||
M.cancelExport = function() {
|
||||
if (isExporting) {
|
||||
isExporting = false;
|
||||
M.showToast("Export cancelled");
|
||||
}
|
||||
};
|
||||
|
||||
// Close context menu when clicking elsewhere
|
||||
document.addEventListener("click", () => {
|
||||
if (activeContextMenu) {
|
||||
activeContextMenu.remove();
|
||||
activeContextMenu = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Show context menu
|
||||
function showContextMenu(e, items) {
|
||||
e.preventDefault();
|
||||
if (activeContextMenu) activeContextMenu.remove();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "context-menu";
|
||||
|
||||
items.forEach(item => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "context-menu-item" + (item.danger ? " danger" : "");
|
||||
el.textContent = item.label;
|
||||
el.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
menu.remove();
|
||||
activeContextMenu = null;
|
||||
item.action();
|
||||
};
|
||||
menu.appendChild(el);
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Position menu, keep within viewport
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5;
|
||||
if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5;
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
|
||||
activeContextMenu = menu;
|
||||
}
|
||||
|
||||
// Drag state for queue reordering
|
||||
let draggedIndices = [];
|
||||
let draggedLibraryIds = [];
|
||||
let dropTargetIndex = null;
|
||||
let dragSource = null; // 'queue' or 'library'
|
||||
|
||||
// Insert library tracks into queue at position
|
||||
async function insertTracksAtPosition(trackIds, position) {
|
||||
if (!M.currentChannelId || trackIds.length === 0) return;
|
||||
|
||||
// Build new queue with tracks inserted at position
|
||||
const newQueue = [...M.queue];
|
||||
const newTrackIds = [...newQueue.map(t => t.id)];
|
||||
newTrackIds.splice(position, 0, ...trackIds);
|
||||
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ set: newTrackIds })
|
||||
});
|
||||
|
||||
if (res.status === 403) M.flashPermissionDenied();
|
||||
else if (res.ok) {
|
||||
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
|
||||
M.clearSelections();
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder queue on server
|
||||
async function reorderQueue(fromIndices, toIndex) {
|
||||
if (!M.currentChannelId || fromIndices.length === 0) return;
|
||||
|
||||
// Build new queue order
|
||||
const newQueue = [...M.queue];
|
||||
|
||||
// Sort indices descending to remove from end first
|
||||
const sortedIndices = [...fromIndices].sort((a, b) => b - a);
|
||||
const movedTracks = [];
|
||||
|
||||
// Remove items (in reverse order to preserve indices)
|
||||
for (const idx of sortedIndices) {
|
||||
movedTracks.unshift(newQueue.splice(idx, 1)[0]);
|
||||
}
|
||||
|
||||
// Adjust target index for removed items before it
|
||||
let adjustedTarget = toIndex;
|
||||
for (const idx of fromIndices) {
|
||||
if (idx < toIndex) adjustedTarget--;
|
||||
}
|
||||
|
||||
// Insert at new position
|
||||
newQueue.splice(adjustedTarget, 0, ...movedTracks);
|
||||
|
||||
// Send to server
|
||||
const trackIds = newQueue.map(t => t.id);
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ set: trackIds })
|
||||
});
|
||||
|
||||
if (res.status === 403) M.flashPermissionDenied();
|
||||
else if (res.ok) {
|
||||
M.clearSelections();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle selection mode (with optional shift for range select)
|
||||
M.toggleQueueSelection = function(index, shiftKey = false) {
|
||||
if (shiftKey && lastSelectedQueueIndex !== null) {
|
||||
// Range select: select all between last and current
|
||||
const start = Math.min(lastSelectedQueueIndex, index);
|
||||
const end = Math.max(lastSelectedQueueIndex, index);
|
||||
for (let i = start; i <= end; i++) {
|
||||
M.selectedQueueIndices.add(i);
|
||||
}
|
||||
} else {
|
||||
if (M.selectedQueueIndices.has(index)) {
|
||||
M.selectedQueueIndices.delete(index);
|
||||
} else {
|
||||
M.selectedQueueIndices.add(index);
|
||||
}
|
||||
lastSelectedQueueIndex = index;
|
||||
}
|
||||
M.renderQueue();
|
||||
};
|
||||
|
||||
M.toggleLibrarySelection = function(index, shiftKey = false) {
|
||||
if (shiftKey && lastSelectedLibraryIndex !== null) {
|
||||
// Range select: select all between last and current
|
||||
const start = Math.min(lastSelectedLibraryIndex, index);
|
||||
const end = Math.max(lastSelectedLibraryIndex, index);
|
||||
for (let i = start; i <= end; i++) {
|
||||
M.selectedLibraryIds.add(M.library[i].id);
|
||||
}
|
||||
} else {
|
||||
const trackId = M.library[index].id;
|
||||
if (M.selectedLibraryIds.has(trackId)) {
|
||||
M.selectedLibraryIds.delete(trackId);
|
||||
} else {
|
||||
M.selectedLibraryIds.add(trackId);
|
||||
}
|
||||
lastSelectedLibraryIndex = index;
|
||||
}
|
||||
M.renderLibrary();
|
||||
};
|
||||
|
||||
M.clearSelections = function() {
|
||||
M.selectedQueueIndices.clear();
|
||||
M.selectedLibraryIds.clear();
|
||||
lastSelectedQueueIndex = null;
|
||||
lastSelectedLibraryIndex = null;
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
};
|
||||
|
||||
// Update cache status for all tracks
|
||||
M.updateCacheStatus = async function() {
|
||||
const cached = await TrackStorage.list();
|
||||
|
||||
// Migration: remove old filename-based cache entries (keep only sha256: prefixed)
|
||||
const oldEntries = cached.filter(id => !id.startsWith("sha256:"));
|
||||
if (oldEntries.length > 0) {
|
||||
console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based 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");
|
||||
};
|
||||
|
||||
// Debug: log cache status for current track
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
// 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 ===");
|
||||
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)}...`);
|
||||
});
|
||||
};
|
||||
|
||||
// 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();
|
||||
M.trackCaches.clear();
|
||||
M.trackBlobs.clear();
|
||||
M.bulkDownloadStarted.clear();
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
console.log("[Cache] All caches cleared. Refresh the page.");
|
||||
};
|
||||
|
||||
// Render the current queue
|
||||
M.renderQueue = function() {
|
||||
const container = M.$("#queue");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
const canEdit = M.canControl();
|
||||
|
||||
// Setup container-level drag handlers for dropping from library
|
||||
if (canEdit) {
|
||||
container.ondragover = (e) => {
|
||||
if (dragSource === 'library') {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
// If no tracks or hovering at bottom, show we can drop
|
||||
if (M.queue.length === 0) {
|
||||
container.classList.add("drop-target");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.ondragleave = (e) => {
|
||||
// Only remove if leaving the container entirely
|
||||
if (!container.contains(e.relatedTarget)) {
|
||||
container.classList.remove("drop-target");
|
||||
}
|
||||
};
|
||||
|
||||
container.ondrop = (e) => {
|
||||
container.classList.remove("drop-target");
|
||||
// Handle drop on empty queue or at the end
|
||||
if (dragSource === 'library' && draggedLibraryIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
|
||||
insertTracksAtPosition(draggedLibraryIds, targetIndex);
|
||||
draggedLibraryIds = [];
|
||||
dragSource = null;
|
||||
dropTargetIndex = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (M.queue.length === 0) {
|
||||
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
|
||||
M.updateNowPlayingBar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: log first few track cache statuses
|
||||
if (M.queue.length > 0 && M.cachedTracks.size > 0) {
|
||||
const sample = M.queue.slice(0, 3).map(t => {
|
||||
const id = t.id || t.filename;
|
||||
return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) };
|
||||
});
|
||||
console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12)));
|
||||
}
|
||||
|
||||
M.queue.forEach((track, i) => {
|
||||
const div = document.createElement("div");
|
||||
const trackId = track.id || track.filename;
|
||||
const isCached = M.cachedTracks.has(trackId);
|
||||
const isSelected = M.selectedQueueIndices.has(i);
|
||||
div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
|
||||
div.dataset.index = i;
|
||||
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
||||
div.title = title; // Tooltip for full name
|
||||
|
||||
const checkmark = isSelected ? `<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) {
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
M.updateNowPlayingBar = function() {
|
||||
const bar = M.$("#now-playing-bar");
|
||||
if (!bar) return;
|
||||
|
||||
const track = M.queue[M.currentIndex];
|
||||
if (!track) {
|
||||
bar.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
||||
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`;
|
||||
bar.title = title;
|
||||
bar.classList.remove("hidden");
|
||||
};
|
||||
|
||||
// Scroll queue to current track
|
||||
M.scrollToCurrentTrack = function() {
|
||||
const container = M.$("#queue");
|
||||
if (!container) return;
|
||||
|
||||
const activeTrack = container.querySelector(".track.active");
|
||||
if (activeTrack) {
|
||||
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
};
|
||||
|
||||
// Setup now-playing bar click handler
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const bar = M.$("#now-playing-bar");
|
||||
if (bar) {
|
||||
bar.onclick = () => M.scrollToCurrentTrack();
|
||||
}
|
||||
});
|
||||
|
||||
// Library search state
|
||||
M.librarySearchQuery = "";
|
||||
|
||||
// Render the library
|
||||
M.renderLibrary = function() {
|
||||
const container = M.$("#library");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
if (M.library.length === 0) {
|
||||
container.innerHTML = '<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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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)
|
||||
});
|
||||
}
|
||||
|
||||
// Export all cached option (if there are cached tracks)
|
||||
if (M.cachedTracks.size > 0) {
|
||||
menuItems.push({
|
||||
label: `Preload and export ${M.cachedTracks.size} cached`,
|
||||
action: () => M.exportAllCached()
|
||||
});
|
||||
}
|
||||
|
||||
// Clear selection option (if items selected)
|
||||
if (hasSelection) {
|
||||
menuItems.push({
|
||||
label: "Clear selection",
|
||||
action: () => M.clearSelections()
|
||||
});
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
showContextMenu(e, menuItems);
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
};
|
||||
|
||||
// Load library from server
|
||||
M.loadLibrary = async function() {
|
||||
try {
|
||||
const res = await fetch("/api/library");
|
||||
M.library = await res.json();
|
||||
M.renderLibrary();
|
||||
} catch (e) {
|
||||
console.warn("Failed to load library");
|
||||
}
|
||||
};
|
||||
|
||||
// Setup library search
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const searchInput = M.$("#library-search");
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
M.librarySearchQuery = e.target.value;
|
||||
M.renderLibrary();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; }
|
||||
#app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; }
|
||||
h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
|
||||
h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
#sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; }
|
||||
#sync-indicator.visible { display: inline-block; }
|
||||
#sync-indicator.disconnected { background: #e44; }
|
||||
|
||||
/* Header */
|
||||
#header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
|
||||
#auth-section { display: flex; gap: 0.4rem; align-items: center; }
|
||||
#auth-section .user-info { display: flex; align-items: center; gap: 0.4rem; }
|
||||
#auth-section .username { color: #4e8; font-weight: 600; font-size: 0.85rem; }
|
||||
#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; }
|
||||
#btn-logout { background: none; border: none; color: #e44; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
||||
#btn-logout:hover { opacity: 1; text-decoration: underline; }
|
||||
#btn-kick-others { background: none; border: none; color: #ea4; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
|
||||
#btn-kick-others:hover { opacity: 1; text-decoration: underline; }
|
||||
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; }
|
||||
|
||||
/* Main content - library and queue */
|
||||
#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; }
|
||||
#channels-panel { flex: 0 0 160px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; }
|
||||
#channels-list { flex: 1; overflow-y: auto; }
|
||||
#channels-list .channel-item { padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
#channels-list .channel-item.active { background: #2a4a3a; color: #4e8; }
|
||||
#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; }
|
||||
#channels-list .channel-header:hover { background: #222; }
|
||||
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
|
||||
#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; }
|
||||
#channels-list .btn-delete-channel { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
|
||||
#channels-list .channel-header:hover .btn-delete-channel { opacity: 1; }
|
||||
#channels-list .btn-delete-channel:hover { color: #e44; }
|
||||
#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; }
|
||||
#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
|
||||
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
|
||||
#channels-list .listener-mult { color: #666; font-size: 0.55rem; }
|
||||
#library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
|
||||
.panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; }
|
||||
.panel-tab { background: #252525; border: none; color: #666; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; }
|
||||
.panel-tab:hover { color: #aaa; background: #2a2a2a; }
|
||||
.panel-tab.active { color: #eee; background: #222; }
|
||||
.panel-views { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; position: relative; background: #222; border-radius: 0 4px 4px 4px; }
|
||||
.panel-view { display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; position: relative; padding: 0.4rem; }
|
||||
.panel-view.active { display: flex; }
|
||||
.tasks-empty { color: #666; font-size: 0.85rem; padding: 1rem; text-align: center; }
|
||||
.tasks-empty.hidden { display: none; }
|
||||
#tasks-list { display: flex; flex-direction: column; gap: 0.3rem; overflow-y: auto; flex: 1; }
|
||||
.task-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; background: #2a2a1a; border-radius: 4px; font-size: 0.8rem; color: #ea4; position: relative; overflow: hidden; }
|
||||
.task-item.complete { background: #1a2a1a; color: #4e8; }
|
||||
.task-item.error { background: #2a1a1a; color: #e44; }
|
||||
.task-item .task-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; }
|
||||
.task-item.complete .task-spinner { display: none; }
|
||||
.task-item.error .task-spinner { display: none; }
|
||||
.task-item .task-icon { flex-shrink: 0; }
|
||||
.task-item.complete .task-icon::before { content: "✓"; }
|
||||
.task-item.error .task-icon::before { content: "✗"; }
|
||||
.task-item .task-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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; }
|
||||
.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; }
|
||||
.scan-progress .spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.upload-btn { width: 100%; padding: 0.5rem; background: #2a3a2a; border: 1px dashed #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; }
|
||||
.upload-btn:hover { background: #3a4a3a; }
|
||||
.add-btn { width: 100%; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; transition: all 0.2s; }
|
||||
.add-btn:hover { background: #2a2a2a; border-color: #666; color: #aaa; }
|
||||
.add-btn.hidden { display: none; }
|
||||
.fetch-dialog { position: absolute; bottom: 0; left: 0; right: 0; background: #1a1a1a; border-top: 1px solid #333; border-radius: 0 0 6px 6px; display: flex; flex-direction: column; animation: panelSlideUp 0.2s ease-out; }
|
||||
.fetch-dialog.hidden { display: none; }
|
||||
.fetch-dialog.closing { animation: panelSlideDown 0.2s ease-in forwards; }
|
||||
.fetch-dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0.5rem; border-bottom: 1px solid #333; }
|
||||
.fetch-dialog-header span { font-size: 0.8rem; color: #aaa; }
|
||||
.fetch-dialog-close { background: none; border: none; color: #666; font-size: 1.2rem; cursor: pointer; padding: 0 0.3rem; line-height: 1; }
|
||||
.fetch-dialog-close:hover { color: #aaa; }
|
||||
.fetch-dialog-content { padding: 0.5rem; display: flex; gap: 0.4rem; }
|
||||
.fetch-url-input { flex: 1; padding: 0.4rem 0.6rem; background: #222; border: 1px solid #444; border-radius: 4px; color: #eee; font-size: 0.85rem; font-family: inherit; }
|
||||
.fetch-url-input:focus { outline: none; border-color: #4e8; }
|
||||
.fetch-submit-btn { padding: 0.4rem 0.8rem; background: #2a3a2a; border: 1px solid #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; font-family: inherit; }
|
||||
.fetch-submit-btn:hover { background: #3a4a3a; }
|
||||
.add-panel { position: absolute; bottom: 0; left: 0; right: 0; background: #1a1a1a; border-top: 1px solid #333; border-radius: 0 0 6px 6px; display: flex; flex-direction: column; height: 50%; overflow: hidden; animation: panelSlideUp 0.2s ease-out; }
|
||||
.add-panel.hidden { display: none; }
|
||||
.add-panel.closing { animation: panelSlideDown 0.2s ease-in forwards; }
|
||||
@keyframes panelSlideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
@keyframes panelSlideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } }
|
||||
.add-panel-close { width: calc(100% - 1rem); margin: 0.5rem; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; flex-shrink: 0; }
|
||||
.add-panel-close:hover { background: #2a2a2a; border-color: #666; color: #aaa; }
|
||||
.add-panel-content { flex: 1; overflow-y: auto; padding: 0 0.5rem 0.5rem; display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.add-option { width: 100%; padding: 0.6rem 0.8rem; background: #222; border: 1px solid #333; border-radius: 4px; color: #aaa; font-size: 0.85rem; cursor: pointer; text-align: left; transition: all 0.15s; }
|
||||
.add-option:hover { background: #2a3a2a; border-color: #4e8; color: #4e8; }
|
||||
.upload-progress { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; background: #2a2a1a; border-radius: 4px; margin-top: 0.3rem; flex-shrink: 0; position: relative; overflow: hidden; }
|
||||
.upload-progress.hidden { display: none; }
|
||||
.upload-progress-bar { position: absolute; left: 0; top: 0; bottom: 0; background: #4a6a4a; transition: width 0.2s; }
|
||||
.upload-progress-text { position: relative; z-index: 1; font-size: 0.75rem; color: #4e8; }
|
||||
.upload-dropzone { position: absolute; inset: 0; background: rgba(42, 74, 58, 0.95); display: flex; align-items: center; justify-content: center; border-radius: 6px; border: 2px dashed #4e8; z-index: 10; }
|
||||
.upload-dropzone.hidden { display: none; }
|
||||
.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; }
|
||||
#queue-title { margin: 0 0 0.3rem 0; }
|
||||
.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; }
|
||||
.now-playing-bar .label { color: #666; }
|
||||
.panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
|
||||
.panel-header h3 { margin: 0; flex-shrink: 0; }
|
||||
.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
|
||||
.panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 0.9rem; line-height: 1; padding: 0; }
|
||||
.panel-header button:hover { background: #444; }
|
||||
.new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
|
||||
.btn-submit-channel { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; }
|
||||
.btn-submit-channel:hover { background: #3a5a4a; }
|
||||
.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; }
|
||||
#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; }
|
||||
.track.not-cached .cache-indicator { background: #ea4; }
|
||||
.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; }
|
||||
.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-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; }
|
||||
.track-actions .track-remove { color: #e44; }
|
||||
.track-actions .track-add { color: #4e4; }
|
||||
|
||||
/* Track selection */
|
||||
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
|
||||
.track.selected { background: #2a3a4a; }
|
||||
.track.dragging { opacity: 0.5; }
|
||||
.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-zone { padding: 1.5rem; text-align: center; color: #4e8; }
|
||||
|
||||
/* Context menu */
|
||||
.context-menu { position: fixed; background: #222; border: 1px solid #444; border-radius: 5px; padding: 0.2rem 0; z-index: 1000; min-width: 130px; box-shadow: 0 4px 12px rgba(0,0,0,0.6); }
|
||||
.context-menu-item { padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 0.4rem; }
|
||||
.context-menu-item:hover { background: #333; }
|
||||
.context-menu-item.danger { color: #e44; }
|
||||
.context-menu-item.danger:hover { background: #3a2a2a; }
|
||||
|
||||
/* Player bar */
|
||||
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; }
|
||||
#now-playing { width: 180px; flex-shrink: 0; }
|
||||
#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; }
|
||||
#track-name { font-size: 0.9rem; font-weight: 600; overflow: hidden; position: relative; }
|
||||
#track-name .marquee-inner { display: inline-block; white-space: nowrap; }
|
||||
#track-name.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; }
|
||||
@keyframes scroll-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
||||
#player-controls { flex: 1; }
|
||||
#progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; }
|
||||
#progress-row.denied { animation: flash-red 0.5s ease-out; }
|
||||
@keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } }
|
||||
#btn-sync { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; }
|
||||
#btn-sync:hover { color: #888; }
|
||||
#btn-sync.synced { color: #eb0; text-shadow: 0 0 8px #eb0, 0 0 12px #eb0; }
|
||||
#btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; }
|
||||
#btn-prev, #btn-next { font-size: 0.75rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
|
||||
#btn-prev:hover, #btn-next:hover { opacity: 1; }
|
||||
#btn-mode { font-size: 0.7rem; cursor: pointer; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||
#btn-mode.mode-once { color: #888; text-shadow: none; }
|
||||
#btn-mode.mode-repeat-all { color: #4e8; text-shadow: 0 0 6px #4e8; }
|
||||
#btn-mode.mode-repeat-one { color: #eb0; text-shadow: 0 0 6px #eb0; }
|
||||
#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; }
|
||||
#btn-mode:hover { opacity: 0.8; }
|
||||
#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; }
|
||||
#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; }
|
||||
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; }
|
||||
#progress-bar.playing.synced { background: #4e8; }
|
||||
#progress-bar.playing.local { background: #c4f; }
|
||||
#progress-bar.muted { background: #555 !important; }
|
||||
#seek-tooltip { position: absolute; bottom: 10px; background: #333; color: #eee; padding: 2px 5px; border-radius: 3px; font-size: 0.7rem; pointer-events: none; display: none; transform: translateX(-50%); }
|
||||
#time { font-size: 0.75rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; }
|
||||
#buffer-bar { display: flex; gap: 1px; margin-bottom: 0.2rem; }
|
||||
#buffer-bar .segment { flex: 1; height: 2px; background: #333; border-radius: 1px; }
|
||||
#buffer-bar .segment.available { background: #396; }
|
||||
#buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; }
|
||||
@keyframes throb { from { background: #444; } to { background: #888; } }
|
||||
#download-speed { font-size: 0.6rem; color: #555; text-align: right; }
|
||||
#volume-controls { display: flex; gap: 0.4rem; align-items: center; }
|
||||
#btn-stream-only { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; }
|
||||
#btn-stream-only:hover { color: #888; }
|
||||
#btn-stream-only.active { color: #4af; text-shadow: 0 0 6px #4af; }
|
||||
#btn-mute { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; }
|
||||
#btn-mute:hover { opacity: 1; }
|
||||
#volume { width: 120px; accent-color: #4e8; }
|
||||
|
||||
/* Common */
|
||||
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
|
||||
button:hover { background: #333; }
|
||||
#status { margin-top: 0.3rem; font-size: 0.75rem; color: #666; text-align: center; }
|
||||
.empty { color: #666; font-style: italic; font-size: 0.85rem; }
|
||||
|
||||
/* Login panel */
|
||||
#login-panel { display: flex; flex-direction: column; gap: 0.75rem; padding: 1.5rem; background: #1a1a1a; border-radius: 6px; border: 1px solid #333; max-width: 360px; margin: auto; }
|
||||
#login-panel.hidden { display: none; }
|
||||
#login-panel h2 { font-size: 1rem; color: #888; margin-bottom: 0.3rem; }
|
||||
#login-panel .tabs { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
#login-panel .tabs button { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0.4rem 0; border-bottom: 2px solid transparent; }
|
||||
#login-panel .tabs button.active { color: #4e8; border-bottom-color: #4e8; }
|
||||
#login-panel input { background: #222; color: #eee; border: 1px solid #333; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; }
|
||||
#login-panel .form-group { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
#login-panel .form-group.hidden { display: none; }
|
||||
#login-panel .submit-btn { background: #4e8; color: #111; border: none; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||
#login-panel .submit-btn:hover { background: #5fa; }
|
||||
#auth-error, #signup-error { color: #e44; font-size: 0.75rem; }
|
||||
#guest-section { margin-top: 0.75rem; }
|
||||
#guest-section.hidden { display: none; }
|
||||
#guest-section .divider { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; color: #666; font-size: 0.8rem; }
|
||||
#guest-section .divider::before, #guest-section .divider::after { content: ""; flex: 1; height: 1px; background: #333; }
|
||||
#guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; }
|
||||
#guest-section .guest-btn:hover { background: #444; }
|
||||
|
||||
#player-content { display: none; flex-direction: column; flex: 1; }
|
||||
#player-content.visible { display: flex; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb { background-color: #333; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
||||
|
||||
/* Toast notifications */
|
||||
#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-height: 80vh; overflow-y: auto; }
|
||||
.toast { background: #1a3a2a; color: #4e8; padding: 0.5rem 0.75rem; border-radius: 5px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.8rem; animation: toast-in 0.3s ease-out; max-width: 280px; }
|
||||
.toast.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; }
|
||||
.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; }
|
||||
.toast.fade-out { animation: toast-out 0.3s ease-in forwards; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } }
|
||||
|
||||
/* Toast history panel */
|
||||
#btn-history { position: fixed; bottom: 0.5rem; left: 0.5rem; background: #222; border: 1px solid #333; color: #888; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.7rem; z-index: 999; }
|
||||
#btn-history:hover { background: #333; color: #ccc; }
|
||||
#toast-history { position: fixed; bottom: 3rem; left: 0.5rem; width: 320px; max-height: 400px; background: #1a1a1a; border: 1px solid #333; border-radius: 6px; z-index: 1001; display: flex; flex-direction: column; }
|
||||
#toast-history.hidden { display: none; }
|
||||
.history-header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid #333; color: #888; font-size: 0.8rem; }
|
||||
.history-header button { background: none; border: none; color: #888; cursor: pointer; font-size: 1rem; padding: 0; width: 20px; height: 20px; }
|
||||
.history-header button:hover { color: #ccc; }
|
||||
#toast-history-list { flex: 1; overflow-y: auto; padding: 0.3rem; max-height: 350px; }
|
||||
.history-item { padding: 0.3rem 0.5rem; font-size: 0.75rem; border-radius: 3px; margin-bottom: 0.2rem; color: #4e8; background: #1a2a1a; }
|
||||
.history-item.history-warning { color: #ea4; background: #2a2a1a; }
|
||||
.history-item.history-error { color: #e44; background: #2a1a1a; }
|
||||
.history-time { color: #666; margin-right: 0.4rem; }
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
// Track Storage Abstraction Layer
|
||||
// Provides a unified interface for storing/retrieving audio blobs
|
||||
// Default implementation uses IndexedDB, can be swapped for Electron file API
|
||||
|
||||
const TrackStorage = (function() {
|
||||
const DB_NAME = 'blastoise';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'tracks';
|
||||
|
||||
let db = null;
|
||||
let initPromise = null;
|
||||
|
||||
// Initialize IndexedDB
|
||||
function init() {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
console.warn('TrackStorage: IndexedDB failed to open');
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
db = request.result;
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const database = event.target.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'filename' });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
// Check if a track is cached
|
||||
async function has(filename) {
|
||||
await init();
|
||||
if (!db) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.getKey(filename);
|
||||
|
||||
request.onsuccess = () => resolve(request.result !== undefined);
|
||||
request.onerror = () => resolve(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Get a track blob, returns { blob, contentType } or null
|
||||
async function get(filename) {
|
||||
await init();
|
||||
if (!db) return null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.get(filename);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result;
|
||||
if (result) {
|
||||
resolve({ blob: result.blob, contentType: result.contentType });
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
request.onerror = () => resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
// Store a track blob
|
||||
async function set(filename, blob, contentType) {
|
||||
await init();
|
||||
if (!db) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.put({ filename, blob, contentType, cachedAt: Date.now() });
|
||||
|
||||
request.onsuccess = () => resolve(true);
|
||||
request.onerror = () => resolve(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove a track from cache
|
||||
async function remove(filename) {
|
||||
await init();
|
||||
if (!db) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.delete(filename);
|
||||
|
||||
request.onsuccess = () => resolve(true);
|
||||
request.onerror = () => resolve(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all cached tracks
|
||||
async function clear() {
|
||||
await init();
|
||||
if (!db) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve(true);
|
||||
request.onerror = () => resolve(false);
|
||||
});
|
||||
}
|
||||
|
||||
// List all cached track filenames
|
||||
async function list() {
|
||||
await init();
|
||||
if (!db) return [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.getAllKeys();
|
||||
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
// Get storage stats
|
||||
async function getStats() {
|
||||
await init();
|
||||
if (!db) return { count: 0, totalSize: 0 };
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const tracks = request.result || [];
|
||||
const totalSize = tracks.reduce((sum, t) => sum + (t.blob?.size || 0), 0);
|
||||
resolve({ count: tracks.length, totalSize });
|
||||
};
|
||||
request.onerror = () => resolve({ count: 0, totalSize: 0 });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
has,
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
clear,
|
||||
list,
|
||||
getStats
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
// MusicRoom - UI module
|
||||
// Progress bar, buffer display, and UI state updates
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// Create buffer segments on load
|
||||
for (let i = 0; i < M.SEGMENTS; i++) {
|
||||
const seg = document.createElement("div");
|
||||
seg.className = "segment";
|
||||
M.$("#buffer-bar").appendChild(seg);
|
||||
}
|
||||
|
||||
// Update general UI state
|
||||
M.updateUI = function() {
|
||||
const isConnecting = M.wantSync && !M.synced;
|
||||
// While connecting, treat as not playing (paused state)
|
||||
const isPlaying = M.synced ? !M.serverPaused : (!isConnecting && !M.audio.paused);
|
||||
M.$("#btn-sync").classList.toggle("synced", M.wantSync);
|
||||
M.$("#btn-sync").classList.toggle("connected", M.synced);
|
||||
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
|
||||
M.$("#status").textContent = M.synced ? "Synced" : (M.wantSync ? "Connecting..." : "Local");
|
||||
M.$("#sync-indicator").classList.toggle("visible", M.synced);
|
||||
M.$("#progress-bar").classList.toggle("synced", M.synced);
|
||||
M.$("#progress-bar").classList.toggle("local", !M.synced);
|
||||
M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0);
|
||||
M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊";
|
||||
M.$("#status-icon").textContent = isPlaying ? "⏸" : "▶";
|
||||
|
||||
// Show/hide controls based on permissions
|
||||
const hasControl = M.canControl();
|
||||
M.$("#status-icon").style.cursor = hasControl || !M.synced ? "pointer" : "default";
|
||||
};
|
||||
|
||||
// Update auth-related UI
|
||||
M.updateAuthUI = function() {
|
||||
if (M.currentUser) {
|
||||
M.$("#login-panel").classList.add("hidden");
|
||||
M.$("#player-content").classList.add("visible");
|
||||
if (M.currentUser.isGuest) {
|
||||
M.$("#current-username").textContent = "Guest";
|
||||
M.$("#btn-logout").textContent = "Sign In";
|
||||
} else {
|
||||
M.$("#current-username").textContent = M.currentUser.username;
|
||||
M.$("#btn-logout").textContent = "Logout";
|
||||
}
|
||||
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
||||
} else {
|
||||
M.$("#login-panel").classList.remove("hidden");
|
||||
M.$("#player-content").classList.remove("visible");
|
||||
// Pause and unsync when login panel is shown
|
||||
if (!M.audio.paused) {
|
||||
M.localTimestamp = M.audio.currentTime;
|
||||
M.audio.pause();
|
||||
}
|
||||
if (M.synced && M.ws) {
|
||||
M.synced = false;
|
||||
M.ws.close();
|
||||
M.ws = null;
|
||||
}
|
||||
// Show guest button if server allows guests
|
||||
if (M.serverStatus?.allowGuests) {
|
||||
M.$("#guest-section").classList.remove("hidden");
|
||||
} else {
|
||||
M.$("#guest-section").classList.add("hidden");
|
||||
}
|
||||
}
|
||||
M.updateUI();
|
||||
};
|
||||
|
||||
// Progress bar and buffer update loop (250ms interval)
|
||||
setInterval(() => {
|
||||
if (M.serverTrackDuration <= 0) return;
|
||||
let t, dur;
|
||||
if (M.synced) {
|
||||
t = M.audio.paused ? M.getServerTime() : M.audio.currentTime;
|
||||
dur = M.audio.duration || M.serverTrackDuration;
|
||||
} else {
|
||||
t = M.audio.paused ? M.localTimestamp : M.audio.currentTime;
|
||||
dur = M.audio.duration || M.serverTrackDuration;
|
||||
}
|
||||
const pct = Math.min((t / dur) * 100, 100);
|
||||
if (Math.abs(pct - M.lastProgressPct) > 0.1) {
|
||||
M.$("#progress-bar").style.width = pct + "%";
|
||||
M.lastProgressPct = pct;
|
||||
}
|
||||
|
||||
const timeCurrent = M.fmt(t);
|
||||
const timeTotal = M.fmt(dur);
|
||||
if (timeCurrent !== M.lastTimeCurrent) {
|
||||
M.$("#time-current").textContent = timeCurrent;
|
||||
M.lastTimeCurrent = timeCurrent;
|
||||
}
|
||||
if (timeTotal !== M.lastTimeTotal) {
|
||||
M.$("#time-total").textContent = timeTotal;
|
||||
M.lastTimeTotal = timeTotal;
|
||||
}
|
||||
|
||||
// Update buffer segments
|
||||
const segments = M.$("#buffer-bar").children;
|
||||
const segmentDur = dur / M.SEGMENTS;
|
||||
let availableCount = 0;
|
||||
const trackCache = M.getTrackCache(M.currentTrackId);
|
||||
for (let i = 0; i < M.SEGMENTS; i++) {
|
||||
const segStart = i * segmentDur;
|
||||
const segEnd = (i + 1) * segmentDur;
|
||||
let available = trackCache.has(i); // Check our cache first
|
||||
if (!available) {
|
||||
// Check browser's native buffer
|
||||
for (let j = 0; j < M.audio.buffered.length; j++) {
|
||||
const bufStart = M.audio.buffered.start(j);
|
||||
const bufEnd = M.audio.buffered.end(j);
|
||||
if (bufStart <= segStart && bufEnd >= segEnd) {
|
||||
available = true;
|
||||
// Sync browser buffer to our trackCache
|
||||
trackCache.add(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (available) availableCount++;
|
||||
const isAvailable = segments[i].classList.contains("available");
|
||||
const isLoading = segments[i].classList.contains("loading");
|
||||
const shouldBeLoading = !available && M.loadingSegments.has(i);
|
||||
if (available !== isAvailable) segments[i].classList.toggle("available", available);
|
||||
if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading);
|
||||
}
|
||||
|
||||
// Check if all segments now cached - trigger full cache
|
||||
if (trackCache.size >= M.SEGMENTS && !M.cachedTracks.has(M.currentTrackId)) {
|
||||
M.checkAndCacheComplete(M.currentTrackId);
|
||||
}
|
||||
|
||||
// Update download speed display
|
||||
const kbps = M.downloadSpeed > 0 ? M.downloadSpeed * 8 / 1000 : 0;
|
||||
const bufferPct = Math.round(availableCount / M.SEGMENTS * 100);
|
||||
let speedText = "";
|
||||
if (kbps > 0) {
|
||||
speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`;
|
||||
}
|
||||
if (bufferPct !== M.lastBufferPct || speedText !== M.lastSpeedText) {
|
||||
M.$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`;
|
||||
M.lastBufferPct = bufferPct;
|
||||
M.lastSpeedText = speedText;
|
||||
}
|
||||
}, 250);
|
||||
|
||||
// Prefetch loop (1s interval)
|
||||
setInterval(() => {
|
||||
if (M.currentTrackId && M.audio.src) {
|
||||
M.prefetchSegments();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Cache status check (5s interval) - updates indicators when tracks finish caching
|
||||
let lastCacheSize = 0;
|
||||
setInterval(async () => {
|
||||
const currentSize = M.cachedTracks.size;
|
||||
if (currentSize !== lastCacheSize) {
|
||||
lastCacheSize = currentSize;
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
}
|
||||
}, 5000);
|
||||
})();
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const addBtn = M.$("#btn-add");
|
||||
const addPanel = M.$("#add-panel");
|
||||
const addCloseBtn = M.$("#btn-add-close");
|
||||
const uploadFilesBtn = M.$("#btn-upload-files");
|
||||
const fileInput = M.$("#file-input");
|
||||
const dropzone = M.$("#upload-dropzone");
|
||||
const libraryPanel = M.$("#library-panel");
|
||||
const tasksList = M.$("#tasks-list");
|
||||
const tasksEmpty = M.$("#tasks-empty");
|
||||
|
||||
if (!addBtn || !fileInput || !dropzone) return;
|
||||
|
||||
function openPanel() {
|
||||
addPanel.classList.remove("hidden", "closing");
|
||||
addBtn.classList.add("hidden");
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
addPanel.classList.add("closing");
|
||||
addBtn.classList.remove("hidden");
|
||||
addPanel.addEventListener("animationend", () => {
|
||||
if (addPanel.classList.contains("closing")) {
|
||||
addPanel.classList.add("hidden");
|
||||
addPanel.classList.remove("closing");
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Open add panel
|
||||
addBtn.onclick = () => {
|
||||
if (!M.currentUser) {
|
||||
M.showToast("Sign in to add tracks");
|
||||
return;
|
||||
}
|
||||
openPanel();
|
||||
};
|
||||
|
||||
// Close add panel
|
||||
addCloseBtn.onclick = () => {
|
||||
closePanel();
|
||||
};
|
||||
|
||||
// Upload files option
|
||||
uploadFilesBtn.onclick = () => {
|
||||
fileInput.click();
|
||||
};
|
||||
|
||||
// Fetch from URL option
|
||||
const fetchUrlBtn = M.$("#btn-fetch-url");
|
||||
const fetchDialog = M.$("#fetch-dialog");
|
||||
const fetchCloseBtn = M.$("#btn-fetch-close");
|
||||
const fetchUrlInput = M.$("#fetch-url-input");
|
||||
const fetchSubmitBtn = M.$("#btn-fetch-submit");
|
||||
|
||||
function openFetchDialog() {
|
||||
closePanel();
|
||||
fetchDialog.classList.remove("hidden", "closing");
|
||||
fetchUrlInput.value = "";
|
||||
fetchUrlInput.focus();
|
||||
}
|
||||
|
||||
function closeFetchDialog() {
|
||||
fetchDialog.classList.add("closing");
|
||||
fetchDialog.addEventListener("animationend", () => {
|
||||
if (fetchDialog.classList.contains("closing")) {
|
||||
fetchDialog.classList.add("hidden");
|
||||
fetchDialog.classList.remove("closing");
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
if (fetchUrlBtn) {
|
||||
fetchUrlBtn.onclick = openFetchDialog;
|
||||
}
|
||||
|
||||
if (fetchCloseBtn) {
|
||||
fetchCloseBtn.onclick = closeFetchDialog;
|
||||
}
|
||||
|
||||
if (fetchSubmitBtn) {
|
||||
fetchSubmitBtn.onclick = async () => {
|
||||
const url = fetchUrlInput.value.trim();
|
||||
if (!url) {
|
||||
M.showToast("Please enter a URL");
|
||||
return;
|
||||
}
|
||||
closeFetchDialog();
|
||||
M.showToast("Checking URL...");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
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.`);
|
||||
|
||||
if (confirmed) {
|
||||
// Confirm playlist download
|
||||
const confirmRes = await fetch("/api/fetch/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: data.items })
|
||||
});
|
||||
|
||||
if (confirmRes.ok) {
|
||||
const confirmData = await confirmRes.json();
|
||||
M.showToast(confirmData.message);
|
||||
// Tasks will be created by WebSocket progress messages
|
||||
} else {
|
||||
const err = await confirmRes.json().catch(() => ({}));
|
||||
M.showToast(err.error || "Failed to queue playlist", "error");
|
||||
}
|
||||
}
|
||||
} else if (data.type === "single") {
|
||||
M.showToast(`Queued: ${data.title}`);
|
||||
// Task will be created by WebSocket progress messages
|
||||
} else {
|
||||
M.showToast(data.message || "Fetch started");
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
M.showToast(err.error || "Fetch failed", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
M.showToast("Fetch failed", "error");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (fetchUrlInput) {
|
||||
fetchUrlInput.onkeydown = (e) => {
|
||||
if (e.key === "Enter") fetchSubmitBtn.click();
|
||||
if (e.key === "Escape") closeFetchDialog();
|
||||
};
|
||||
}
|
||||
|
||||
// File input change
|
||||
fileInput.onchange = () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
closePanel();
|
||||
uploadFiles(fileInput.files);
|
||||
fileInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and drop on library panel
|
||||
let dragCounter = 0;
|
||||
|
||||
libraryPanel.ondragenter = (e) => {
|
||||
if (!M.currentUser) return;
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
dropzone.classList.remove("hidden");
|
||||
};
|
||||
|
||||
libraryPanel.ondragleave = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) {
|
||||
dropzone.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
libraryPanel.ondragover = (e) => {
|
||||
if (!M.currentUser) return;
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
libraryPanel.ondrop = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropzone.classList.add("hidden");
|
||||
|
||||
if (!M.currentUser) {
|
||||
M.showToast("Sign in to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Task management
|
||||
const fetchTasks = new Map(); // Map<id, taskHandle>
|
||||
|
||||
function updateTasksEmpty() {
|
||||
const hasTasks = tasksList.children.length > 0;
|
||||
tasksEmpty.classList.toggle("hidden", hasTasks);
|
||||
}
|
||||
|
||||
// Handle WebSocket fetch progress messages
|
||||
M.handleFetchProgress = function(data) {
|
||||
let task = fetchTasks.get(data.id);
|
||||
|
||||
// Create task if we don't have one for this id
|
||||
if (!task && data.status !== "complete" && data.status !== "error") {
|
||||
task = createTask(data.title || "Downloading...", data.id);
|
||||
}
|
||||
|
||||
if (!task) return;
|
||||
|
||||
if (data.status === "downloading" || data.status === "queued") {
|
||||
task.setProgress(data.progress || 0);
|
||||
} else if (data.status === "complete") {
|
||||
task.setComplete();
|
||||
fetchTasks.delete(data.id);
|
||||
} else if (data.status === "error") {
|
||||
task.setError(data.error || "Failed");
|
||||
fetchTasks.delete(data.id);
|
||||
}
|
||||
};
|
||||
|
||||
function createTask(filename, fetchId) {
|
||||
const task = document.createElement("div");
|
||||
task.className = "task-item";
|
||||
task.innerHTML = `
|
||||
<span class="task-spinner"></span>
|
||||
<span class="task-icon"></span>
|
||||
<span class="task-name">${filename}</span>
|
||||
<span class="task-progress">0%</span>
|
||||
<div class="task-bar" style="width: 0%"></div>
|
||||
`;
|
||||
tasksList.appendChild(task);
|
||||
updateTasksEmpty();
|
||||
|
||||
// Switch to tasks tab
|
||||
const tasksTab = document.querySelector('.panel-tab[data-tab="tasks"]');
|
||||
if (tasksTab) tasksTab.click();
|
||||
|
||||
const taskHandle = {
|
||||
setProgress(percent) {
|
||||
task.querySelector(".task-progress").textContent = `${Math.round(percent)}%`;
|
||||
task.querySelector(".task-bar").style.width = `${percent}%`;
|
||||
},
|
||||
setComplete() {
|
||||
task.classList.add("complete");
|
||||
task.querySelector(".task-progress").textContent = "Done";
|
||||
task.querySelector(".task-bar").style.width = "100%";
|
||||
// Remove after delay
|
||||
setTimeout(() => {
|
||||
task.remove();
|
||||
updateTasksEmpty();
|
||||
}, 3000);
|
||||
},
|
||||
setError(msg) {
|
||||
task.classList.add("error");
|
||||
task.querySelector(".task-progress").textContent = msg || "Failed";
|
||||
// Remove after delay
|
||||
setTimeout(() => {
|
||||
task.remove();
|
||||
updateTasksEmpty();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Store fetch tasks for WebSocket updates
|
||||
if (fetchId) {
|
||||
fetchTasks.set(fetchId, taskHandle);
|
||||
}
|
||||
|
||||
return taskHandle;
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
return new Promise((resolve) => {
|
||||
const task = createTask(file.name);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = (e.loaded / e.total) * 100;
|
||||
task.setProgress(percent);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
task.setComplete();
|
||||
resolve({ success: true });
|
||||
} else if (xhr.status === 409) {
|
||||
task.setError("Duplicate");
|
||||
resolve({ success: false, duplicate: true });
|
||||
} else {
|
||||
task.setError("Failed");
|
||||
resolve({ success: false });
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
task.setError("Error");
|
||||
resolve({ success: false });
|
||||
};
|
||||
|
||||
xhr.open("POST", "/api/upload");
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFiles(files) {
|
||||
const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"];
|
||||
const audioFiles = [...files].filter(f => {
|
||||
const ext = f.name.toLowerCase().match(/\.[^.]+$/)?.[0];
|
||||
return ext && validExts.includes(ext);
|
||||
});
|
||||
|
||||
if (audioFiles.length === 0) {
|
||||
M.showToast("No valid audio files");
|
||||
return;
|
||||
}
|
||||
|
||||
let uploaded = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Upload files in parallel (max 3 concurrent)
|
||||
const concurrency = 3;
|
||||
const queue = [...audioFiles];
|
||||
const active = [];
|
||||
|
||||
while (queue.length > 0 || active.length > 0) {
|
||||
while (active.length < concurrency && queue.length > 0) {
|
||||
const file = queue.shift();
|
||||
const promise = uploadFile(file).then(result => {
|
||||
if (result.success) uploaded++;
|
||||
else failed++;
|
||||
active.splice(active.indexOf(promise), 1);
|
||||
});
|
||||
active.push(promise);
|
||||
}
|
||||
if (active.length > 0) {
|
||||
await Promise.race(active);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded > 0) {
|
||||
M.showToast(`Uploaded ${uploaded} track${uploaded > 1 ? 's' : ''}${failed > 0 ? `, ${failed} failed` : ''}`);
|
||||
} else if (failed > 0) {
|
||||
M.showToast(`Upload failed`);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
// MusicRoom - Utilities module
|
||||
// DOM helpers, formatting, toast notifications
|
||||
|
||||
(function() {
|
||||
const M = window.MusicRoom;
|
||||
|
||||
// DOM selector helper
|
||||
M.$ = (s) => document.querySelector(s);
|
||||
|
||||
// Format seconds as m:ss
|
||||
M.fmt = function(sec) {
|
||||
if (!sec || !isFinite(sec)) return "0:00";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return m + ":" + String(s).padStart(2, "0");
|
||||
};
|
||||
|
||||
// Toast history
|
||||
M.toastHistory = [];
|
||||
|
||||
// Toast notifications (log style - multiple visible)
|
||||
M.showToast = function(message, type = "info", duration = 5000) {
|
||||
const container = M.$("#toast-container");
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast toast-" + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.classList.add("fade-out");
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
|
||||
// Add to history
|
||||
M.toastHistory.push({
|
||||
message,
|
||||
type,
|
||||
time: new Date()
|
||||
});
|
||||
M.updateToastHistory();
|
||||
};
|
||||
|
||||
// Update toast history panel
|
||||
M.updateToastHistory = function() {
|
||||
const list = M.$("#toast-history-list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
// Show newest first
|
||||
const items = [...M.toastHistory].reverse().slice(0, 50);
|
||||
for (const item of items) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "history-item history-" + item.type;
|
||||
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
div.innerHTML = `<span class="history-time">${time}</span> ${item.message}`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle toast history panel
|
||||
M.toggleToastHistory = function() {
|
||||
const panel = M.$("#toast-history");
|
||||
if (panel) {
|
||||
panel.classList.toggle("hidden");
|
||||
if (!panel.classList.contains("hidden")) {
|
||||
M.updateToastHistory();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Flash permission denied animation
|
||||
M.flashPermissionDenied = function() {
|
||||
const row = M.$("#progress-row");
|
||||
row.classList.remove("denied");
|
||||
void row.offsetWidth; // Trigger reflow to restart animation
|
||||
row.classList.add("denied");
|
||||
setTimeout(() => row.classList.remove("denied"), 500);
|
||||
};
|
||||
|
||||
// Set track title (UI and document title)
|
||||
M.setTrackTitle = function(title) {
|
||||
M.currentTitle = title;
|
||||
const containerEl = M.$("#track-name");
|
||||
const marqueeEl = containerEl?.querySelector(".marquee-inner");
|
||||
|
||||
if (!containerEl || !marqueeEl) return;
|
||||
|
||||
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
|
||||
|
||||
// First set simple content to measure
|
||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
|
||||
|
||||
// Check if title overflows and needs scrolling
|
||||
requestAnimationFrame(() => {
|
||||
const titleEl = M.$("#track-title");
|
||||
const needsScroll = titleEl && titleEl.scrollWidth > containerEl.clientWidth;
|
||||
containerEl.classList.toggle("scrolling", needsScroll);
|
||||
|
||||
// Duplicate text for seamless wrap-around scrolling
|
||||
if (needsScroll) {
|
||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer"> • </span><span>${title}</span><span class="marquee-spacer"> • </span>`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Get current server time (extrapolated)
|
||||
M.getServerTime = function() {
|
||||
if (M.serverPaused) return M.serverTimestamp;
|
||||
return M.serverTimestamp + (Date.now() - M.lastServerUpdate) / 1000;
|
||||
};
|
||||
|
||||
// Check if current user can control playback
|
||||
M.canControl = function() {
|
||||
if (!M.currentUser) return false;
|
||||
if (M.currentUser.isAdmin) return true;
|
||||
return M.currentUser.permissions?.some(p =>
|
||||
p.resource_type === "channel" &&
|
||||
(p.resource_id === M.currentChannelId || p.resource_id === null) &&
|
||||
p.permission === "control"
|
||||
);
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun-types"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
// MusicRoom - yt-dlp integration module
|
||||
// Handles fetching audio from URLs via yt-dlp
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { join } from "path";
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
userId: number;
|
||||
status: "queued" | "downloading" | "complete" | "error";
|
||||
progress: number;
|
||||
queueType: "fast" | "slow";
|
||||
error?: string;
|
||||
filename?: string;
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
export interface YtdlpStatus {
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
version: string | null;
|
||||
ffmpeg: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
type: "playlist";
|
||||
title: string;
|
||||
count: number;
|
||||
items: { id: string; url: string; title: string }[];
|
||||
requiresConfirmation: true;
|
||||
}
|
||||
|
||||
export interface SingleVideoInfo {
|
||||
type: "single";
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type ProgressCallback = (item: QueueItem) => void;
|
||||
|
||||
// Configuration
|
||||
let ytdlpCommand = "yt-dlp";
|
||||
let ffmpegCommand = "ffmpeg";
|
||||
let musicDir = "./music";
|
||||
let fastQueueConcurrent = 2;
|
||||
let slowQueueInterval = 180;
|
||||
let allowPlaylists = true;
|
||||
|
||||
// Status
|
||||
let ytdlpAvailable = false;
|
||||
let ytdlpVersion: string | null = null;
|
||||
let ffmpegAvailable = false;
|
||||
let featureEnabled = false;
|
||||
|
||||
// Queues
|
||||
const fastQueue: QueueItem[] = [];
|
||||
const slowQueue: QueueItem[] = [];
|
||||
let activeDownloads = 0;
|
||||
let slowQueueTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastSlowDownload = 0;
|
||||
|
||||
// Callbacks
|
||||
let onProgress: ProgressCallback | null = null;
|
||||
|
||||
// Generate unique ID
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
// Initialize ytdlp module
|
||||
export async function initYtdlp(config: {
|
||||
enabled: boolean;
|
||||
command: string;
|
||||
ffmpegCommand: string;
|
||||
musicDir: string;
|
||||
fastQueueConcurrent: number;
|
||||
slowQueueInterval: number;
|
||||
allowPlaylists: boolean;
|
||||
}): Promise<YtdlpStatus> {
|
||||
featureEnabled = config.enabled;
|
||||
ytdlpCommand = config.command;
|
||||
ffmpegCommand = config.ffmpegCommand;
|
||||
musicDir = config.musicDir;
|
||||
fastQueueConcurrent = config.fastQueueConcurrent;
|
||||
slowQueueInterval = config.slowQueueInterval;
|
||||
allowPlaylists = config.allowPlaylists;
|
||||
|
||||
if (!featureEnabled) {
|
||||
console.log("[ytdlp] Feature disabled in config");
|
||||
return { available: false, enabled: false, version: null, ffmpeg: false };
|
||||
}
|
||||
|
||||
// Check yt-dlp availability
|
||||
try {
|
||||
ytdlpVersion = await runCommand(ytdlpCommand, ["--version"]);
|
||||
ytdlpAvailable = true;
|
||||
console.log(`[ytdlp] Found yt-dlp version: ${ytdlpVersion.trim()}`);
|
||||
} catch (e) {
|
||||
console.error(`[ytdlp] yt-dlp not found (command: ${ytdlpCommand})`);
|
||||
ytdlpAvailable = false;
|
||||
featureEnabled = false;
|
||||
}
|
||||
|
||||
// Check ffmpeg availability
|
||||
try {
|
||||
await runCommand(ffmpegCommand, ["-version"]);
|
||||
ffmpegAvailable = true;
|
||||
console.log("[ytdlp] ffmpeg available");
|
||||
} catch (e) {
|
||||
console.warn("[ytdlp] ffmpeg not found - audio extraction may fail");
|
||||
ffmpegAvailable = false;
|
||||
}
|
||||
|
||||
// Start slow queue processor
|
||||
if (featureEnabled) {
|
||||
startSlowQueueProcessor();
|
||||
}
|
||||
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
// Run a command and return stdout
|
||||
function runCommand(cmd: string, args: string[]): Promise<string> {
|
||||
const fullCmd = `${cmd} ${args.join(" ")}`;
|
||||
console.log(`[ytdlp] Running: ${fullCmd}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data) => { stdout += data; });
|
||||
proc.stderr.on("data", (data) => { stderr += data; });
|
||||
proc.on("close", (code) => {
|
||||
console.log(`[ytdlp] Command exited with code ${code}`);
|
||||
if (code === 0) resolve(stdout);
|
||||
else reject(new Error(stderr || `Exit code ${code}`));
|
||||
});
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Get current status
|
||||
export function getStatus(): YtdlpStatus {
|
||||
return {
|
||||
available: ytdlpAvailable,
|
||||
enabled: featureEnabled,
|
||||
version: ytdlpVersion,
|
||||
ffmpeg: ffmpegAvailable
|
||||
};
|
||||
}
|
||||
|
||||
// Check if feature is enabled and available
|
||||
export function isAvailable(): boolean {
|
||||
return featureEnabled && ytdlpAvailable;
|
||||
}
|
||||
|
||||
// Set progress callback
|
||||
export function setProgressCallback(callback: ProgressCallback): void {
|
||||
onProgress = callback;
|
||||
}
|
||||
|
||||
// Get all queue items
|
||||
export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
|
||||
const now = Date.now();
|
||||
const nextIn = Math.max(0, Math.floor((lastSlowDownload + slowQueueInterval * 1000 - now) / 1000));
|
||||
return {
|
||||
fastQueue: [...fastQueue],
|
||||
slowQueue: [...slowQueue],
|
||||
slowQueueNextIn: nextIn
|
||||
};
|
||||
}
|
||||
|
||||
// Get queue items for a specific user
|
||||
export function getUserQueues(userId: number): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
|
||||
const queues = getQueues();
|
||||
return {
|
||||
fastQueue: queues.fastQueue.filter(i => i.userId === userId),
|
||||
slowQueue: queues.slowQueue.filter(i => i.userId === userId),
|
||||
slowQueueNextIn: queues.slowQueueNextIn
|
||||
};
|
||||
}
|
||||
|
||||
// Check URL and detect if it's a playlist
|
||||
export async function checkUrl(url: string): Promise<PlaylistInfo | SingleVideoInfo> {
|
||||
const args = ["--flat-playlist", "--dump-json", "--no-warnings", url];
|
||||
const output = await runCommand(ytdlpCommand, args);
|
||||
|
||||
// Parse JSON lines
|
||||
const lines = output.trim().split("\n").filter(l => l);
|
||||
|
||||
if (lines.length === 0) {
|
||||
throw new Error("No video found");
|
||||
}
|
||||
|
||||
if (lines.length === 1) {
|
||||
const data = JSON.parse(lines[0]);
|
||||
if (data._type === "playlist") {
|
||||
// It's a playlist with entries
|
||||
const items = (data.entries || []).map((e: any) => ({
|
||||
id: generateId(),
|
||||
url: e.url || e.webpage_url || `https://youtube.com/watch?v=${e.id}`,
|
||||
title: e.title || "Unknown"
|
||||
}));
|
||||
return {
|
||||
type: "playlist",
|
||||
title: data.title || "Playlist",
|
||||
count: items.length,
|
||||
items,
|
||||
requiresConfirmation: true
|
||||
};
|
||||
} else {
|
||||
// Single video
|
||||
return {
|
||||
type: "single",
|
||||
id: generateId(),
|
||||
title: data.title || "Unknown",
|
||||
url
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Multiple JSON lines = playlist
|
||||
const items = lines.map(line => {
|
||||
const data = JSON.parse(line);
|
||||
return {
|
||||
id: generateId(),
|
||||
url: data.url || data.webpage_url || url,
|
||||
title: data.title || "Unknown"
|
||||
};
|
||||
});
|
||||
return {
|
||||
type: "playlist",
|
||||
title: "Playlist",
|
||||
count: items.length,
|
||||
items,
|
||||
requiresConfirmation: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add single video to fast queue
|
||||
export function addToFastQueue(url: string, title: string, userId: number): QueueItem {
|
||||
const item: QueueItem = {
|
||||
id: generateId(),
|
||||
url,
|
||||
title,
|
||||
userId,
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
queueType: "fast",
|
||||
createdAt: Date.now()
|
||||
};
|
||||
fastQueue.push(item);
|
||||
processNextFast();
|
||||
return item;
|
||||
}
|
||||
|
||||
// Add items to slow queue (for playlists)
|
||||
export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] {
|
||||
const queueItems: QueueItem[] = items.map(item => ({
|
||||
id: generateId(),
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
userId,
|
||||
status: "queued" as const,
|
||||
progress: 0,
|
||||
queueType: "slow" as const,
|
||||
createdAt: Date.now()
|
||||
}));
|
||||
slowQueue.push(...queueItems);
|
||||
return queueItems;
|
||||
}
|
||||
|
||||
// Process next item in fast queue
|
||||
function processNextFast(): void {
|
||||
if (activeDownloads >= fastQueueConcurrent) return;
|
||||
|
||||
const item = fastQueue.find(i => i.status === "queued");
|
||||
if (!item) return;
|
||||
|
||||
activeDownloads++;
|
||||
downloadItem(item).finally(() => {
|
||||
activeDownloads--;
|
||||
processNextFast();
|
||||
});
|
||||
}
|
||||
|
||||
// Start slow queue processor
|
||||
function startSlowQueueProcessor(): void {
|
||||
if (slowQueueTimer) return;
|
||||
|
||||
const processNext = () => {
|
||||
const item = slowQueue.find(i => i.status === "queued");
|
||||
if (item) {
|
||||
lastSlowDownload = Date.now();
|
||||
downloadItem(item).finally(() => {
|
||||
slowQueueTimer = setTimeout(processNext, slowQueueInterval * 1000);
|
||||
});
|
||||
} else {
|
||||
slowQueueTimer = setTimeout(processNext, 5000); // Check again in 5s
|
||||
}
|
||||
};
|
||||
|
||||
// Start immediately if there are items
|
||||
const hasQueued = slowQueue.some(i => i.status === "queued");
|
||||
if (hasQueued) {
|
||||
processNext();
|
||||
} else {
|
||||
slowQueueTimer = setTimeout(processNext, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Download a single item
|
||||
async function downloadItem(item: QueueItem): Promise<void> {
|
||||
item.status = "downloading";
|
||||
item.progress = 0;
|
||||
notifyProgress(item);
|
||||
|
||||
console.log(`[ytdlp] Starting download: ${item.title} (${item.url})`);
|
||||
|
||||
try {
|
||||
const outputTemplate = join(musicDir, "%(title)s.%(ext)s");
|
||||
const args = [
|
||||
"-x",
|
||||
"--audio-format", "mp3",
|
||||
"-o", outputTemplate,
|
||||
"--progress",
|
||||
"--newline",
|
||||
"--no-warnings",
|
||||
item.url
|
||||
];
|
||||
|
||||
const fullCmd = `${ytdlpCommand} ${args.join(" ")}`;
|
||||
console.log(`[ytdlp] Running: ${fullCmd}`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(ytdlpCommand, args);
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
const line = data.toString();
|
||||
console.log(`[ytdlp] ${line.trim()}`);
|
||||
// Parse progress from yt-dlp output
|
||||
const match = line.match(/(\d+\.?\d*)%/);
|
||||
if (match) {
|
||||
item.progress = parseFloat(match[1]);
|
||||
notifyProgress(item);
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
console.error(`[ytdlp] stderr: ${data}`);
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
console.log(`[ytdlp] Download finished with code ${code}`);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`yt-dlp exited with code ${code}`));
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
|
||||
console.log(`[ytdlp] Complete: ${item.title}`);
|
||||
item.status = "complete";
|
||||
item.progress = 100;
|
||||
item.completedAt = Date.now();
|
||||
notifyProgress(item);
|
||||
|
||||
// Remove from queue after delay
|
||||
setTimeout(() => removeFromQueue(item), 5000);
|
||||
|
||||
} catch (e: any) {
|
||||
item.status = "error";
|
||||
item.error = e.message || "Download failed";
|
||||
notifyProgress(item);
|
||||
|
||||
// Remove from queue after delay
|
||||
setTimeout(() => removeFromQueue(item), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove item from queue
|
||||
function removeFromQueue(item: QueueItem): void {
|
||||
if (item.queueType === "fast") {
|
||||
const idx = fastQueue.findIndex(i => i.id === item.id);
|
||||
if (idx !== -1) fastQueue.splice(idx, 1);
|
||||
} else {
|
||||
const idx = slowQueue.findIndex(i => i.id === item.id);
|
||||
if (idx !== -1) slowQueue.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify progress callback
|
||||
function notifyProgress(item: QueueItem): void {
|
||||
if (onProgress) {
|
||||
onProgress(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 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") &&
|
||||
now - item.createdAt > maxAge) {
|
||||
queue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanup(fastQueue);
|
||||
cleanup(slowQueue);
|
||||
}
|
||||
Loading…
Reference in New Issue