Compare commits
17 Commits
dev/androi
...
integratio
| Author | SHA1 | Date |
|---|---|---|
|
|
b557cb678f | |
|
|
0c30f7a252 | |
|
|
4de1364f74 | |
|
|
053aaa8ef6 | |
|
|
ee63f61e01 | |
|
|
e69e955ff9 | |
|
|
a9b3421402 | |
|
|
038851813c | |
|
|
9c87e2d1f7 | |
|
|
0ea8ee47ea | |
|
|
dcbb0a36e9 | |
|
|
ac740a46bb | |
|
|
cd8c1814ca | |
|
|
adc450f14f | |
|
|
d184c6a663 | |
|
|
091e54c599 | |
|
|
910b25e7c7 |
|
|
@ -33,6 +33,26 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
DerivedData/
|
||||||
|
ios/**/build/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcscmblueprint
|
||||||
|
*.xccheckout
|
||||||
|
*.moved-aside
|
||||||
|
xcuserdata/
|
||||||
|
*.xcresult
|
||||||
|
*.xcarchive
|
||||||
|
*.app
|
||||||
|
*.appex
|
||||||
|
*.dSYM
|
||||||
|
*.dSYM.zip
|
||||||
|
*.ipa
|
||||||
|
|
||||||
|
# Swift Package Manager / Xcode package scratch
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
library_cache.db
|
library_cache.db
|
||||||
musicroom.db
|
musicroom.db
|
||||||
|
|
@ -40,3 +60,6 @@ blastoise.db
|
||||||
config.json
|
config.json
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
|
# machine-local ops notes (see AGENTS.md)
|
||||||
|
HiddenAgents.md
|
||||||
|
|
|
||||||
13
AGENTS.md
13
AGENTS.md
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## HiddenAgents.md
|
||||||
|
|
||||||
|
If a `HiddenAgents.md` file exists in this repository, read it as well. It is git-ignored and contains machine-local operational details about how this server is deployed and managed on this host.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
@ -73,6 +77,15 @@ The client uses `track.id` for:
|
||||||
- Fetching audio (`/api/tracks/:trackId`)
|
- Fetching audio (`/api/tracks/:trackId`)
|
||||||
- Checking cache status (`M.cachedTracks.has(trackId)`)
|
- Checking cache status (`M.cachedTracks.has(trackId)`)
|
||||||
|
|
||||||
|
## XSS Prevention
|
||||||
|
|
||||||
|
All server-controlled strings (channel names, usernames, track titles, playlist names, toast messages) must be escaped before reaching `innerHTML`:
|
||||||
|
|
||||||
|
- **`M.escapeHtml(str)`** (`public/utils.js`) — the single shared escaping helper. Do not define local copies.
|
||||||
|
- **`M.trackComponent.getTitle(track)`** (`public/trackComponent.js`) — the single source of truth for a track's display title. Use it instead of inline `track.title || track.filename` fallbacks.
|
||||||
|
|
||||||
|
A Content-Security-Policy header is set on all static responses in `routes/static.ts` (`script-src 'self'`, no inline scripts) as defense-in-depth.
|
||||||
|
|
||||||
## Client Caching System
|
## Client Caching System
|
||||||
|
|
||||||
### Segment-Based Buffering
|
### Segment-Based Buffering
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3
|
|
||||||
|
|
@ -18,6 +18,8 @@ export interface ReplayGainConfig {
|
||||||
command: string;
|
command: string;
|
||||||
truePeak: boolean;
|
truePeak: boolean;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
maxConcurrent?: number;
|
||||||
|
maxOutputBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|
@ -51,7 +53,9 @@ export const DEFAULT_CONFIG: Config = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
command: "rsgain",
|
command: "rsgain",
|
||||||
truePeak: false,
|
truePeak: false,
|
||||||
timeoutMs: 120000
|
timeoutMs: 120000,
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxOutputBytes: 262144
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
34
db.ts
34
db.ts
|
|
@ -55,6 +55,17 @@ db.run(`
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
PRIMARY KEY (user_id, key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -249,6 +260,29 @@ export function getUserPermissions(userId: number): Permission[] {
|
||||||
return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[];
|
return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User preferences functions (key-value store, per account)
|
||||||
|
export function getUserPreference(userId: number, key: string): string | null {
|
||||||
|
const row = db.query("SELECT value FROM user_preferences WHERE user_id = ? AND key = ?").get(userId, key) as { value: string } | null;
|
||||||
|
return row ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllUserPreferences(userId: number): Record<string, string> {
|
||||||
|
const rows = db.query("SELECT key, value FROM user_preferences WHERE user_id = ?").all(userId) as { key: string; value: string }[];
|
||||||
|
const prefs: Record<string, string> = {};
|
||||||
|
for (const row of rows) prefs[row.key] = row.value;
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserPreference(userId: number, key: string, value: string): void {
|
||||||
|
db.query(`
|
||||||
|
INSERT INTO user_preferences (user_id, key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?, unixepoch())
|
||||||
|
ON CONFLICT(user_id, key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(userId, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllUsers(): Omit<User, 'password_hash'>[] {
|
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[];
|
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 }));
|
return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false }));
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,176 @@
|
||||||
|
# Blastoise API Reference
|
||||||
|
|
||||||
|
Blastoise is a synchronized music server. The server owns channel time and
|
||||||
|
queues; clients play audio locally.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Reference HTTP: http://mhsgroove.peterino.com:3001
|
||||||
|
Reference WS: ws://mhsgroove.peterino.com:3001
|
||||||
|
Local HTTP: http://localhost:3001
|
||||||
|
Local WS: ws://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth is an HttpOnly cookie named `blastoise_session`. Same-origin browser apps
|
||||||
|
can use normal `fetch`. Separate-origin browser apps need a same-origin proxy
|
||||||
|
or CORS with credentials. Native apps must store `Set-Cookie` and send it as
|
||||||
|
`Cookie` on HTTP and WebSocket requests.
|
||||||
|
|
||||||
|
Full details: [api-reference-full.md](./api-reference-full.md)
|
||||||
|
|
||||||
|
## Golden Rule
|
||||||
|
|
||||||
|
Use `track.id` for every machine operation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/tracks/:trackId
|
||||||
|
```
|
||||||
|
|
||||||
|
`track.id` is a content hash like `sha256:...`. `filename` and `title` are only
|
||||||
|
for display. Queue entries, playlists, cache keys, direct links, and audio URLs
|
||||||
|
should all use `track.id`.
|
||||||
|
|
||||||
|
## Core Shapes
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";
|
||||||
|
|
||||||
|
type Track = {
|
||||||
|
id: string; filename: string; title: string | null;
|
||||||
|
artist?: string | null; album?: string | null; duration: number;
|
||||||
|
replayGainDb?: number | null; replayPeak?: number | null; available?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChannelInfo = {
|
||||||
|
id: string; name: string; description: string; trackCount: number;
|
||||||
|
listenerCount: number; listeners: string[]; isDefault: boolean;
|
||||||
|
createdBy: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChannelState = {
|
||||||
|
track: Track | null; currentTimestamp: number; channelName: string;
|
||||||
|
channelId: string; description: string; paused: boolean; currentIndex: number;
|
||||||
|
listenerCount: number; isDefault: boolean; playbackMode: PlaybackMode;
|
||||||
|
queue?: Track[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Playlist = {
|
||||||
|
id: string; name: string; description: string; ownerId: number;
|
||||||
|
ownerName?: string; isPublic: boolean; shareToken: string | null;
|
||||||
|
trackIds: string[]; createdAt: number; updatedAt: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChannelState.queue` is optional. It appears on WebSocket connect, queue
|
||||||
|
changes, and periodic refreshes. Keep the last known queue when omitted.
|
||||||
|
|
||||||
|
## Startup
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/status
|
||||||
|
GET /api/auth/me
|
||||||
|
GET /api/library
|
||||||
|
GET /api/channels
|
||||||
|
WS /api/channels/:channelId/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose a channel: saved channel, else `isDefault`, else first channel.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Area | Endpoints |
|
||||||
|
|---|---|
|
||||||
|
| Status | `GET /api/status` |
|
||||||
|
| Auth | `POST /api/auth/signup`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/kick-others` |
|
||||||
|
| Channels | `GET /api/channels`, `POST /api/channels`, `GET/PATCH/DELETE /api/channels/:id` |
|
||||||
|
| Playback control | `POST /api/channels/:id/jump`, `POST /api/channels/:id/seek`, `POST /api/channels/:id/mode` |
|
||||||
|
| Queue | `PATCH /api/channels/:id/queue` |
|
||||||
|
| Library/audio | `GET /api/library`, `GET /api/tracks/:trackId`, `POST /api/upload` |
|
||||||
|
| Playlists | `GET/POST /api/playlists`, `GET/PATCH/DELETE /api/playlists/:id`, `PATCH /api/playlists/:id/tracks` |
|
||||||
|
| Sharing | `POST/DELETE /api/playlists/:id/share`, `GET/POST /api/playlists/shared/:token` |
|
||||||
|
| URL import | `POST /api/fetch`, `POST /api/fetch/confirm`, `GET /api/fetch`, `DELETE /api/fetch/:itemId`, `DELETE /api/fetch` |
|
||||||
|
|
||||||
|
Common bodies:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "username": "test", "password": "testuser" }
|
||||||
|
{ "name": "Channel or playlist name", "description": "optional" }
|
||||||
|
{ "mode": "repeat-all" }
|
||||||
|
{ "index": 3 }
|
||||||
|
{ "timestamp": 45.5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Queue and playlist track mutation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "set": ["sha256:a", "sha256:b"] }
|
||||||
|
{ "add": ["sha256:c"], "insertAt": 2 }
|
||||||
|
{ "remove": [3, 4] }
|
||||||
|
{ "move": [5, 6], "to": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove/move use positions, not track IDs. Duplicate tracks are allowed.
|
||||||
|
|
||||||
|
Audio supports range requests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Range: bytes=0-999999
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
Connect to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://mhsgroove.peterino.com:3001/api/channels/:channelId/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Client messages:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "action": "switch", "channelId": "abc123" }
|
||||||
|
{ "action": "pause" }
|
||||||
|
{ "action": "unpause" }
|
||||||
|
{ "action": "seek", "timestamp": 45.5 }
|
||||||
|
{ "action": "jump", "index": 3 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Server messages:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "channel_list", "channels": [] }
|
||||||
|
{ "type": "switched", "channelId": "abc123" }
|
||||||
|
{ "type": "kick", "reason": "Kicked by another session" }
|
||||||
|
{ "type": "toast", "message": "Added: Song", "toastType": "info" }
|
||||||
|
{ "type": "scan_progress", "scanning": true, "processed": 1, "total": 20 }
|
||||||
|
{ "type": "fetch_progress", "id": "job", "status": "downloading", "progress": 50 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Any message without `type` is a `ChannelState`.
|
||||||
|
|
||||||
|
Guests can listen and switch channels, but cannot control playback or mutate
|
||||||
|
queues. Unauthorized WebSocket control messages are ignored.
|
||||||
|
|
||||||
|
## Sync Algorithm
|
||||||
|
|
||||||
|
On every `ChannelState`:
|
||||||
|
|
||||||
|
1. Store the state and `performance.now()`.
|
||||||
|
2. If `state.queue` exists, replace the local queue cache.
|
||||||
|
3. If `state.track` is null, pause and clear the player.
|
||||||
|
4. If `state.track.id` changed, set `audio.src` to `/api/tracks/:trackId` and
|
||||||
|
seek to `state.currentTimestamp`.
|
||||||
|
5. If same track and drift is `>= 2s`, seek to `state.currentTimestamp`.
|
||||||
|
6. If `state.paused`, pause. Otherwise call `audio.play()`.
|
||||||
|
7. Between WebSocket updates, estimate time as
|
||||||
|
`state.currentTimestamp + elapsedSeconds`, unless paused.
|
||||||
|
|
||||||
|
The server is the source of truth.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Some errors are JSON `{ "error": "..." }`; some are plain text. Handle both.
|
||||||
|
- `GET /api/channels/:id` does not include the queue. WebSocket connect does.
|
||||||
|
- `POST /api/playlists/shared/:token` copies a playlist; there is no `/copy`.
|
||||||
|
- Cache by `track.id`, never by filename.
|
||||||
|
- The server does not decode audio. Clients are synchronized local players.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,585 @@
|
||||||
|
# Build Me A Blastoise Frontend
|
||||||
|
|
||||||
|
This is a pasteable build brief for an LLM or coding agent. It tells the agent
|
||||||
|
how to build a frontend for a Blastoise music server without needing to read the
|
||||||
|
server code.
|
||||||
|
|
||||||
|
Reference the short API contract in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/api-reference.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the full reference for edge cases:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/api-reference-full.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paste This Prompt Into Your LLM
|
||||||
|
|
||||||
|
```text
|
||||||
|
You are building a frontend for Blastoise, a synchronized music streaming
|
||||||
|
server. Build the actual app, not a landing page.
|
||||||
|
|
||||||
|
Use the Blastoise API documented in docs/api-reference.md. The server owns
|
||||||
|
channel state and time. The client owns UI, local audio playback, local caching,
|
||||||
|
and drift correction.
|
||||||
|
|
||||||
|
Reference server for testing:
|
||||||
|
- HTTP base URL: http://mhsgroove.peterino.com:3001
|
||||||
|
- WebSocket base URL: ws://mhsgroove.peterino.com:3001
|
||||||
|
|
||||||
|
Core rule:
|
||||||
|
- Always identify tracks by track.id.
|
||||||
|
- Always play audio from /api/tracks/:trackId.
|
||||||
|
- filename and title are display fields only.
|
||||||
|
|
||||||
|
Build an app with:
|
||||||
|
- Auth screen: login, signup, and guest mode when /api/status says guests are
|
||||||
|
allowed.
|
||||||
|
- Channel list: load /api/channels, show listener counts, connect to a channel
|
||||||
|
WebSocket, support switching channels.
|
||||||
|
- Now playing player: show current track, time, duration, play/pause, seek,
|
||||||
|
previous/next, playback mode.
|
||||||
|
- Library: list tracks from /api/library, search/filter, click a track to play
|
||||||
|
locally, add tracks to queue.
|
||||||
|
- Queue: render the current channel queue, highlight currentIndex, add/remove,
|
||||||
|
move/reorder when the user has control permission.
|
||||||
|
- Playlists: list /api/playlists, show playlist details, add playlists/tracks
|
||||||
|
to queue, create/edit/delete owned playlists.
|
||||||
|
- Optional URL import UI if /api/status reports ytdlp.enabled and
|
||||||
|
ytdlp.available.
|
||||||
|
|
||||||
|
Do not assume the WebSocket always includes queue. It includes queue on connect,
|
||||||
|
after queue changes, and periodic refreshes. Keep the last known queue until a
|
||||||
|
new queue arrives.
|
||||||
|
|
||||||
|
Do not use alert() or prompt(). Use inline inputs, modals, toasts, or standard
|
||||||
|
UI components.
|
||||||
|
|
||||||
|
Auth uses an HttpOnly cookie named blastoise_session. If this app is served
|
||||||
|
from the same origin as the server, browser fetch calls can use relative URLs.
|
||||||
|
If this app is hosted separately, either proxy API requests through the same
|
||||||
|
origin or add CORS/credentials support to the server.
|
||||||
|
|
||||||
|
Implement robust API helpers that handle JSON errors and plain text errors.
|
||||||
|
Some Blastoise endpoints return JSON error objects, while some return plain
|
||||||
|
text.
|
||||||
|
|
||||||
|
Synced playback algorithm:
|
||||||
|
1. Connect to WS /api/channels/:channelId/ws.
|
||||||
|
2. When a normal ChannelState message arrives, store it with performance.now().
|
||||||
|
3. If state.queue exists, replace the local queue cache.
|
||||||
|
4. If state.track is null, pause and clear the player.
|
||||||
|
5. If track.id changed, set audio.src to /api/tracks/:trackId, seek to
|
||||||
|
state.currentTimestamp, then play unless state.paused.
|
||||||
|
6. If track.id is the same and abs(audio.currentTime - state.currentTimestamp)
|
||||||
|
>= 2, seek to state.currentTimestamp.
|
||||||
|
7. If state.paused, pause locally. If not paused, play locally.
|
||||||
|
8. Between WebSocket updates, estimate server time as
|
||||||
|
state.currentTimestamp + elapsedSeconds since receipt, unless paused.
|
||||||
|
|
||||||
|
Control actions:
|
||||||
|
- Send WebSocket { action: "pause" } and { action: "unpause" } for play/pause.
|
||||||
|
- Send WebSocket { action: "seek", timestamp } for seek.
|
||||||
|
- Send WebSocket { action: "jump", index } for queue jumps.
|
||||||
|
- Send WebSocket { action: "switch", channelId } to switch channels.
|
||||||
|
- Use REST PATCH /api/channels/:channelId/queue for add/remove/move/set queue.
|
||||||
|
- Use REST POST /api/channels/:channelId/mode for playback mode.
|
||||||
|
|
||||||
|
Use track.id for local caching. If you build caching, store complete audio blobs
|
||||||
|
in IndexedDB under track.id. Range requests to /api/tracks/:trackId are
|
||||||
|
supported.
|
||||||
|
|
||||||
|
Make the interface responsive. Desktop can use panels for Channels, Library,
|
||||||
|
Queue, and Playlists. Mobile should use tabs or a single-panel navigation.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
Follow this order. It keeps the project useful from the first milestone and
|
||||||
|
prevents sync bugs from getting buried under UI.
|
||||||
|
|
||||||
|
### Step 1: Create The API Client
|
||||||
|
|
||||||
|
Build a small wrapper around `fetch`.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Use relative URLs when the frontend is same-origin.
|
||||||
|
- Allow an `API_BASE` override for native or separately hosted builds.
|
||||||
|
- Send `credentials: "include"` for browser fetch calls.
|
||||||
|
- Parse successful JSON.
|
||||||
|
- On errors, try JSON first, then fall back to text.
|
||||||
|
- Expose helpers for JSON, form upload, and raw audio URLs.
|
||||||
|
|
||||||
|
Recommended shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const API_BASE = "";
|
||||||
|
|
||||||
|
async function apiJson(path: string, options: RequestInit = {}) {
|
||||||
|
const res = await fetch(API_BASE + path, {
|
||||||
|
credentials: "include",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data: any = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
typeof data === "object" && data
|
||||||
|
? data.error || data.message || `HTTP ${res.status}`
|
||||||
|
: data || `HTTP ${res.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackUrl(trackId: string) {
|
||||||
|
return `${API_BASE}/api/tracks/${encodeURIComponent(trackId)}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Native apps should store `Set-Cookie` from login/signup/me and send it as
|
||||||
|
`Cookie` in later HTTP and WebSocket requests.
|
||||||
|
|
||||||
|
### Step 2: Load Status And Session
|
||||||
|
|
||||||
|
On app start:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/status
|
||||||
|
GET /api/auth/me
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `/api/status` to decide whether to show:
|
||||||
|
|
||||||
|
- guest mode,
|
||||||
|
- signup,
|
||||||
|
- URL import.
|
||||||
|
|
||||||
|
Use `/api/auth/me` to get the user and effective permissions. When guests are
|
||||||
|
enabled, this call can create a guest session.
|
||||||
|
|
||||||
|
Auth actions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/auth/login { username, password }
|
||||||
|
POST /api/auth/signup { username, password }
|
||||||
|
POST /api/auth/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
After login, signup, logout, or guest creation, reload:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/auth/me
|
||||||
|
GET /api/library
|
||||||
|
GET /api/channels
|
||||||
|
GET /api/playlists
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Load Library And Channels
|
||||||
|
|
||||||
|
Load:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/library
|
||||||
|
GET /api/channels
|
||||||
|
```
|
||||||
|
|
||||||
|
Store tracks in two forms:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const library: Track[] = [];
|
||||||
|
const tracksById = new Map<string, Track>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick the channel:
|
||||||
|
|
||||||
|
1. Last saved channel ID if still present.
|
||||||
|
2. The channel with `isDefault: true`.
|
||||||
|
3. The first channel.
|
||||||
|
|
||||||
|
Then connect the WebSocket.
|
||||||
|
|
||||||
|
### Step 4: Build WebSocket State Handling
|
||||||
|
|
||||||
|
Connect:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function wsUrl(channelId: string) {
|
||||||
|
const base = API_BASE || window.location.origin;
|
||||||
|
const url = new URL(base);
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
url.pathname = `/api/channels/${encodeURIComponent(channelId)}/ws`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handle message types:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function onSocketMessage(data: any) {
|
||||||
|
if (data.type === "channel_list") {
|
||||||
|
setChannels(data.channels);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "switched") {
|
||||||
|
setCurrentChannelId(data.channelId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "kick") {
|
||||||
|
disconnectAndShowLoginOrToast(data.reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "toast") {
|
||||||
|
showToast(data.message, data.toastType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "scan_progress") {
|
||||||
|
updateScanProgress(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.type === "string" && data.type.startsWith("fetch_")) {
|
||||||
|
updateFetchTask(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyChannelState(data);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconnect while the user wants sync. Use a short delay such as 2 or 3 seconds.
|
||||||
|
|
||||||
|
### Step 5: Implement The Player Correctly
|
||||||
|
|
||||||
|
Keep this state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let channelState: ChannelState | null = null;
|
||||||
|
let channelStateReceivedAt = 0;
|
||||||
|
let currentTrackId: string | null = null;
|
||||||
|
let queue: Track[] = [];
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function applyChannelState(state: ChannelState) {
|
||||||
|
channelState = state;
|
||||||
|
channelStateReceivedAt = performance.now();
|
||||||
|
|
||||||
|
if (state.queue) queue = state.queue;
|
||||||
|
|
||||||
|
if (!state.track) {
|
||||||
|
audio.pause();
|
||||||
|
currentTrackId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = state.currentTimestamp;
|
||||||
|
const nextTrackId = state.track.id;
|
||||||
|
|
||||||
|
if (nextTrackId !== currentTrackId) {
|
||||||
|
currentTrackId = nextTrackId;
|
||||||
|
audio.src = getPlayableUrl(nextTrackId);
|
||||||
|
audio.currentTime = target;
|
||||||
|
} else if (Math.abs(audio.currentTime - target) >= 2) {
|
||||||
|
audio.currentTime = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.paused) {
|
||||||
|
audio.pause();
|
||||||
|
} else {
|
||||||
|
audio.play().catch(() => showClickToPlay());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Estimate current synced time for progress UI:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function syncedTime() {
|
||||||
|
if (!channelState?.track) return 0;
|
||||||
|
if (channelState.paused) return channelState.currentTimestamp;
|
||||||
|
return channelState.currentTimestamp + (performance.now() - channelStateReceivedAt) / 1000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the audio element's actual `currentTime` while audio is playing, but use
|
||||||
|
`syncedTime()` while waiting to play, paused, reconnecting, or rendering remote
|
||||||
|
state.
|
||||||
|
|
||||||
|
### Step 6: Add Controls
|
||||||
|
|
||||||
|
Use WebSocket for simple channel controls:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ws.send(JSON.stringify({ action: "pause" }));
|
||||||
|
ws.send(JSON.stringify({ action: "unpause" }));
|
||||||
|
ws.send(JSON.stringify({ action: "seek", timestamp }));
|
||||||
|
ws.send(JSON.stringify({ action: "jump", index }));
|
||||||
|
ws.send(JSON.stringify({ action: "switch", channelId }));
|
||||||
|
```
|
||||||
|
|
||||||
|
Use REST for queue mutation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PATCH /api/channels/:channelId/queue
|
||||||
|
```
|
||||||
|
|
||||||
|
Bodies:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "add": ["sha256:track"], "insertAt": 3 }
|
||||||
|
{ "remove": [2] }
|
||||||
|
{ "move": [5], "to": 1 }
|
||||||
|
{ "set": ["sha256:a", "sha256:b"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Use REST for playback mode:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/channels/:channelId/mode
|
||||||
|
{ "mode": "shuffle" }
|
||||||
|
```
|
||||||
|
|
||||||
|
If a control returns `403`, show a permission toast. Guests can listen but
|
||||||
|
cannot control.
|
||||||
|
|
||||||
|
### Step 7: Render Library, Queue, And Local Playback
|
||||||
|
|
||||||
|
Library:
|
||||||
|
|
||||||
|
- Render `/api/library`.
|
||||||
|
- Search over title, filename, artist, and album.
|
||||||
|
- Add selected tracks to queue with `PATCH /api/channels/:id/queue`.
|
||||||
|
- Play a track locally by setting the audio source to `/api/tracks/:trackId`
|
||||||
|
and disconnecting or marking the player unsynced.
|
||||||
|
|
||||||
|
Queue:
|
||||||
|
|
||||||
|
- Render the last known `queue`.
|
||||||
|
- Highlight `currentIndex`.
|
||||||
|
- Jump by index.
|
||||||
|
- Remove by index.
|
||||||
|
- Reorder by index.
|
||||||
|
- Remember that duplicate track IDs can exist in the queue. Queue operations
|
||||||
|
that remove or move tracks must use positions, not IDs.
|
||||||
|
|
||||||
|
Local playback:
|
||||||
|
|
||||||
|
- It is okay to let users preview/play a single track outside channel sync.
|
||||||
|
- Keep this mode visually distinct from synced playback.
|
||||||
|
- Offer a "sync" button to reconnect to the selected channel.
|
||||||
|
|
||||||
|
### Step 8: Add Playlists
|
||||||
|
|
||||||
|
Load:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/playlists
|
||||||
|
```
|
||||||
|
|
||||||
|
Render two lists:
|
||||||
|
|
||||||
|
- `mine`
|
||||||
|
- `shared`
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/playlists/:playlistId
|
||||||
|
```
|
||||||
|
|
||||||
|
Join `playlist.trackIds` with `tracksById` from the library to render track
|
||||||
|
titles.
|
||||||
|
|
||||||
|
Common actions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/playlists
|
||||||
|
PATCH /api/playlists/:id
|
||||||
|
DELETE /api/playlists/:id
|
||||||
|
PATCH /api/playlists/:id/tracks
|
||||||
|
POST /api/playlists/:id/share
|
||||||
|
DELETE /api/playlists/:id/share
|
||||||
|
POST /api/playlists/shared/:token
|
||||||
|
```
|
||||||
|
|
||||||
|
To add a playlist to queue:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "add": ["sha256:a", "sha256:b"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
To play next:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "add": ["sha256:a", "sha256:b"], "insertAt": currentIndex + 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Add Upload And URL Import
|
||||||
|
|
||||||
|
Upload:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/upload
|
||||||
|
multipart/form-data field: file
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted file extensions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.mp3 .ogg .flac .wav .m4a .aac .opus .wma .mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
URL import is optional. Show it only when:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
status.ytdlp?.enabled && status.ytdlp?.available
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/fetch { url }
|
||||||
|
```
|
||||||
|
|
||||||
|
If response is `type: "single"`, show a queued/download task.
|
||||||
|
|
||||||
|
If response is `type: "playlist"`, show a confirmation modal, then:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/fetch/confirm { playlistTitle, items }
|
||||||
|
```
|
||||||
|
|
||||||
|
Poll:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
Listen for WebSocket progress messages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fetch_progress
|
||||||
|
fetch_complete
|
||||||
|
fetch_error
|
||||||
|
fetch_cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 10: Add Optional Local Caching
|
||||||
|
|
||||||
|
Caching is not needed for a valid frontend, but it is one of Blastoise's best
|
||||||
|
features.
|
||||||
|
|
||||||
|
Use IndexedDB:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CachedTrack {
|
||||||
|
id: string;
|
||||||
|
blob: Blob;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Key by `track.id`.
|
||||||
|
- Never key by filename.
|
||||||
|
- Prefer cached blob URLs for playback.
|
||||||
|
- Fall back to `/api/tracks/:trackId`.
|
||||||
|
- Use range requests to prefetch seek segments if you want a buffer bar.
|
||||||
|
- Revoke blob URLs when replacing or deleting cached blobs.
|
||||||
|
|
||||||
|
Simple mode:
|
||||||
|
|
||||||
|
1. When a user plays a track, fetch the full file in the background.
|
||||||
|
2. Store it in IndexedDB under `track.id`.
|
||||||
|
3. Next time, play from `URL.createObjectURL(blob)`.
|
||||||
|
|
||||||
|
Advanced mode:
|
||||||
|
|
||||||
|
1. Divide each track into virtual segments.
|
||||||
|
2. Use `Range: bytes=start-end` requests to fill missing segments.
|
||||||
|
3. When all segments are present, download and persist the full blob.
|
||||||
|
|
||||||
|
### Step 11: Validate The App
|
||||||
|
|
||||||
|
Manual smoke test:
|
||||||
|
|
||||||
|
1. Start the server with `bun run server.ts`.
|
||||||
|
2. Open the frontend.
|
||||||
|
3. Load status and auth state.
|
||||||
|
4. Continue as guest or log in with the test user if configured.
|
||||||
|
5. Load library and channels.
|
||||||
|
6. Connect to the default channel WebSocket.
|
||||||
|
7. Confirm first WebSocket state includes `queue`.
|
||||||
|
8. Confirm audio source uses `/api/tracks/:trackId`.
|
||||||
|
9. Seek locally after a state update and confirm drift correction snaps back.
|
||||||
|
10. Pause/unpause from one client and confirm another client follows.
|
||||||
|
11. Add a track to queue and confirm both clients receive a state with `queue`.
|
||||||
|
12. Switch channels and confirm the server sends `switched`.
|
||||||
|
13. Test mobile layout.
|
||||||
|
|
||||||
|
Permission smoke test:
|
||||||
|
|
||||||
|
1. Use a guest session.
|
||||||
|
2. Confirm listening works.
|
||||||
|
3. Try pause/seek/jump.
|
||||||
|
4. Confirm the UI reports lack of permission or no-ops gracefully.
|
||||||
|
|
||||||
|
Playlist smoke test:
|
||||||
|
|
||||||
|
1. Create a playlist as a non-guest user.
|
||||||
|
2. Add tracks to it.
|
||||||
|
3. Add the playlist to queue.
|
||||||
|
4. Make it public or generate a share token.
|
||||||
|
5. Load it through the shared endpoint.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
| Symptom | Likely Cause |
|
||||||
|
|---|---|
|
||||||
|
| Audio 404s | The app used `filename` instead of `track.id` in `/api/tracks/:id`. |
|
||||||
|
| Queue disappears after a state update | The client replaced queue with `undefined`; WebSocket queue is optional. |
|
||||||
|
| Sync slowly drifts | The client only uses local audio time and does not correct against server timestamps. |
|
||||||
|
| Guests can see controls that do nothing | Guests cannot control playback even if they can listen. |
|
||||||
|
| Queue remove deletes the wrong duplicate | The UI removed by track ID instead of queue position. |
|
||||||
|
| Login works in same-origin dev but not hosted frontend | Cookie auth needs same-origin, a reverse proxy, or CORS with credentials. |
|
||||||
|
| Shared playlist copy fails | The route is `POST /api/playlists/shared/:token`, with no `/copy` suffix. |
|
||||||
|
| Native WebSocket connects as guest after login | The client did not send the stored session cookie in the WebSocket request. |
|
||||||
|
|
||||||
|
## Minimal Viable Scope
|
||||||
|
|
||||||
|
If you want the smallest useful Blastoise frontend, build only:
|
||||||
|
|
||||||
|
- `GET /api/auth/me`
|
||||||
|
- `GET /api/library`
|
||||||
|
- `GET /api/channels`
|
||||||
|
- `WS /api/channels/:id/ws`
|
||||||
|
- `GET /api/tracks/:trackId`
|
||||||
|
- WebSocket actions: `switch`, `pause`, `unpause`, `seek`, `jump`
|
||||||
|
|
||||||
|
That is enough to make a synchronized player.
|
||||||
43
init.ts
43
init.ts
|
|
@ -85,6 +85,21 @@ export function getAllLibraryTracks(lib: Library): Track[] {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeLibraryTrack(track: ReturnType<Library["getAllTracks"]>[number]) {
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
filename: track.filename,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration,
|
||||||
|
replayGainDb: track.replayGainDb,
|
||||||
|
replayPeak: track.replayPeak,
|
||||||
|
createdAt: track.created_at,
|
||||||
|
available: track.available,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function init(): Promise<void> {
|
export async function init(): Promise<void> {
|
||||||
// Initialize yt-dlp if configured
|
// Initialize yt-dlp if configured
|
||||||
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
|
||||||
|
|
@ -215,7 +230,7 @@ export async function init(): Promise<void> {
|
||||||
setTimeout(() => checkPendingPlaylistAddition(track), 100);
|
setTimeout(() => checkPendingPlaylistAddition(track), 100);
|
||||||
});
|
});
|
||||||
library.on("changed", (track) => {
|
library.on("changed", (track) => {
|
||||||
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
|
console.log(`Track metadata updated: ${track.title || track.filename}`);
|
||||||
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
|
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -352,22 +367,10 @@ export async function init(): Promise<void> {
|
||||||
// Listen for library changes and notify clients
|
// Listen for library changes and notify clients
|
||||||
library.on("added", (track) => {
|
library.on("added", (track) => {
|
||||||
console.log(`New track detected: ${track.title}`);
|
console.log(`New track detected: ${track.title}`);
|
||||||
const allTracks = library.getAllTracks().map(t => ({
|
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
duration: t.duration,
|
|
||||||
replayGainDb: t.replayGainDb,
|
|
||||||
replayPeak: t.replayPeak,
|
|
||||||
}));
|
|
||||||
broadcastToAll({
|
broadcastToAll({
|
||||||
type: "track_added",
|
type: "track_added",
|
||||||
track: {
|
track: serializeLibraryTrack(track),
|
||||||
id: track.id,
|
|
||||||
title: track.title,
|
|
||||||
duration: track.duration,
|
|
||||||
replayGainDb: track.replayGainDb,
|
|
||||||
replayPeak: track.replayPeak,
|
|
||||||
},
|
|
||||||
library: allTracks
|
library: allTracks
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -375,16 +378,10 @@ export async function init(): Promise<void> {
|
||||||
library.on("removed", (track) => {
|
library.on("removed", (track) => {
|
||||||
console.log(`Track removed: ${track.title}`);
|
console.log(`Track removed: ${track.title}`);
|
||||||
removeTrackFromQueues(track.id);
|
removeTrackFromQueues(track.id);
|
||||||
const allTracks = library.getAllTracks().map(t => ({
|
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
duration: t.duration,
|
|
||||||
replayGainDb: t.replayGainDb,
|
|
||||||
replayPeak: t.replayPeak,
|
|
||||||
}));
|
|
||||||
broadcastToAll({
|
broadcastToAll({
|
||||||
type: "track_removed",
|
type: "track_removed",
|
||||||
track: { id: track.id, title: track.title },
|
track: serializeLibraryTrack(track),
|
||||||
library: allTracks
|
library: allTracks
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000102 /* ContentView.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */; };
|
||||||
|
1A2B3C4D5E6F700000000010 /* AppTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000110 /* AppTypes.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000011 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000111 /* AppModel.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000012 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000112 /* Theme.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000013 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000113 /* AuthView.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000014 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000114 /* HeaderView.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000015 /* PlayerDeckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000016 /* Panels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000116 /* Panels.swift */; };
|
||||||
|
1A2B3C4D5E6F700000000017 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000117 /* Components.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
1A2B3C4D5E6F700000000100 /* BlastoisePing.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlastoisePing.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlastoisePingApp.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000103 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Fonts/pixelify_sans.ttf; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000110 /* AppTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTypes.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000111 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000112 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000113 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000114 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerDeckView.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000116 /* Panels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F700000000117 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
1A2B3C4D5E6F700000000200 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
1A2B3C4D5E6F700000000300 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F700000000301 /* BlastoisePing */,
|
||||||
|
1A2B3C4D5E6F700000000302 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000301 /* BlastoisePing */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */,
|
||||||
|
1A2B3C4D5E6F700000000102 /* ContentView.swift */,
|
||||||
|
1A2B3C4D5E6F700000000304 /* State */,
|
||||||
|
1A2B3C4D5E6F700000000303 /* Models */,
|
||||||
|
1A2B3C4D5E6F700000000305 /* UI */,
|
||||||
|
1A2B3C4D5E6F700000000306 /* Views */,
|
||||||
|
1A2B3C4D5E6F700000000103 /* Info.plist */,
|
||||||
|
1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */,
|
||||||
|
);
|
||||||
|
path = BlastoisePing;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000303 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F700000000110 /* AppTypes.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000304 /* State */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F700000000111 /* AppModel.swift */,
|
||||||
|
);
|
||||||
|
path = State;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000305 /* UI */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F700000000112 /* Theme.swift */,
|
||||||
|
);
|
||||||
|
path = UI;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000306 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F700000000113 /* AuthView.swift */,
|
||||||
|
1A2B3C4D5E6F700000000114 /* HeaderView.swift */,
|
||||||
|
1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */,
|
||||||
|
1A2B3C4D5E6F700000000116 /* Panels.swift */,
|
||||||
|
1A2B3C4D5E6F700000000117 /* Components.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000302 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F700000000100 /* BlastoisePing.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
1A2B3C4D5E6F700000000400 /* BlastoisePing */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */;
|
||||||
|
buildPhases = (
|
||||||
|
1A2B3C4D5E6F700000000500 /* Sources */,
|
||||||
|
1A2B3C4D5E6F700000000600 /* Resources */,
|
||||||
|
1A2B3C4D5E6F700000000200 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = BlastoisePing;
|
||||||
|
productName = BlastoisePing;
|
||||||
|
productReference = 1A2B3C4D5E6F700000000100 /* BlastoisePing.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
1A2B3C4D5E6F700000000800 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1540;
|
||||||
|
LastUpgradeCheck = 1540;
|
||||||
|
TargetAttributes = {
|
||||||
|
1A2B3C4D5E6F700000000400 = {
|
||||||
|
CreatedOnToolsVersion = 15.4;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 1A2B3C4D5E6F700000000300;
|
||||||
|
productRefGroup = 1A2B3C4D5E6F700000000302 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
1A2B3C4D5E6F700000000400 /* BlastoisePing */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
1A2B3C4D5E6F700000000600 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
1A2B3C4D5E6F700000000500 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000010 /* AppTypes.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000011 /* AppModel.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000012 /* Theme.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000013 /* AuthView.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000014 /* HeaderView.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000015 /* PlayerDeckView.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000016 /* Panels.swift in Sources */,
|
||||||
|
1A2B3C4D5E6F700000000017 /* Components.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
1A2B3C4D5E6F700000000900 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000901 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000902 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = BlastoisePing/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000903 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = BlastoisePing/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1A2B3C4D5E6F700000000900 /* Debug */,
|
||||||
|
1A2B3C4D5E6F700000000901 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1A2B3C4D5E6F700000000902 /* Debug */,
|
||||||
|
1A2B3C4D5E6F700000000903 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 1A2B3C4D5E6F700000000800 /* Project object */;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct BlastoisePingApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var model = AppModel()
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var selectedTab: MainTab = .rooms
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Theme.background.ignoresSafeArea()
|
||||||
|
|
||||||
|
if model.authState == .signedIn {
|
||||||
|
mainApp
|
||||||
|
} else {
|
||||||
|
AuthView(
|
||||||
|
model: model,
|
||||||
|
username: $username,
|
||||||
|
password: $password
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Blastoise")
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.toolbarBackground(Theme.background, for: .navigationBar)
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
.font(Theme.bodyFont)
|
||||||
|
.buttonBorderShape(.roundedRectangle(radius: Theme.corner))
|
||||||
|
}
|
||||||
|
.onChange(of: model.authState) { _, authState in
|
||||||
|
if authState == .signedIn {
|
||||||
|
password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainApp: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
HeaderView(model: model)
|
||||||
|
PlayerDeckView(model: model)
|
||||||
|
tabStrip
|
||||||
|
selectedPanel
|
||||||
|
DebugFooterView(model: model)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tabStrip: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(MainTab.allCases) { tab in
|
||||||
|
Button {
|
||||||
|
selectedTab = tab
|
||||||
|
if tab == .library {
|
||||||
|
Task { await model.loadLibraryIfNeeded() }
|
||||||
|
} else if tab == .playlists {
|
||||||
|
Task { await model.loadPlaylistsIfNeeded() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(tab.title, systemImage: tab.icon)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.frame(width: 44, height: 40)
|
||||||
|
.background(selectedTab == tab ? Theme.accent : Theme.panel2)
|
||||||
|
.foregroundStyle(selectedTab == tab ? Theme.background : Theme.text)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
.accessibilityLabel(tab.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var selectedPanel: some View {
|
||||||
|
switch selectedTab {
|
||||||
|
case .rooms:
|
||||||
|
RoomsPanel(model: model)
|
||||||
|
case .queue:
|
||||||
|
QueuePanel(model: model)
|
||||||
|
case .people:
|
||||||
|
PeoplePanel(model: model)
|
||||||
|
case .library:
|
||||||
|
LibraryPanel(model: model)
|
||||||
|
case .playlists:
|
||||||
|
PlaylistsPanel(model: model)
|
||||||
|
case .debug:
|
||||||
|
DebugPanel(model: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum MainTab: String, CaseIterable, Identifiable {
|
||||||
|
case rooms
|
||||||
|
case queue
|
||||||
|
case people
|
||||||
|
case library
|
||||||
|
case playlists
|
||||||
|
case debug
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .rooms: return "Rooms"
|
||||||
|
case .queue: return "Queue"
|
||||||
|
case .people: return "People"
|
||||||
|
case .library: return "Library"
|
||||||
|
case .playlists: return "Lists"
|
||||||
|
case .debug: return "Debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .rooms: return "radio"
|
||||||
|
case .queue: return "list.bullet"
|
||||||
|
case .people: return "person.2"
|
||||||
|
case .library: return "music.note.list"
|
||||||
|
case .playlists: return "rectangle.stack"
|
||||||
|
case .debug: return "waveform.path.ecg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Blastoise</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Blastoise Ping can check a server running on your local network.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>UIAppFonts</key>
|
||||||
|
<array>
|
||||||
|
<string>pixelify_sans.ttf</string>
|
||||||
|
</array>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SourceMode: String {
|
||||||
|
case radio = "RADIO"
|
||||||
|
case library = "LIBRARY"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthState: String {
|
||||||
|
case checking = "CHECKING"
|
||||||
|
case signedOut = "SIGNED OUT"
|
||||||
|
case signedIn = "SIGNED IN"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum APIError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case file(String)
|
||||||
|
case http(Int, String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid URL"
|
||||||
|
case .file(let message):
|
||||||
|
return message
|
||||||
|
case .http(let status, let body):
|
||||||
|
return "HTTP \(status): \(body)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Track: Codable, Hashable, Identifiable {
|
||||||
|
var id: String
|
||||||
|
var filename: String
|
||||||
|
var title: String
|
||||||
|
var duration: Double
|
||||||
|
var artist: String?
|
||||||
|
var album: String?
|
||||||
|
var available: Bool?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String,
|
||||||
|
filename: String,
|
||||||
|
title: String,
|
||||||
|
duration: Double,
|
||||||
|
artist: String? = nil,
|
||||||
|
album: String? = nil,
|
||||||
|
available: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.filename = filename
|
||||||
|
self.title = title
|
||||||
|
self.duration = duration
|
||||||
|
self.artist = artist
|
||||||
|
self.album = album
|
||||||
|
self.available = available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChannelInfo: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let listenerCount: Int
|
||||||
|
let isDefault: Bool
|
||||||
|
let trackCount: Int
|
||||||
|
let listeners: [String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case description
|
||||||
|
case listenerCount
|
||||||
|
case isDefault
|
||||||
|
case trackCount
|
||||||
|
case listeners
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Room"
|
||||||
|
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
|
||||||
|
listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0
|
||||||
|
isDefault = try c.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
|
||||||
|
trackCount = try c.decodeIfPresent(Int.self, forKey: .trackCount) ?? 0
|
||||||
|
listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChannelState: Decodable {
|
||||||
|
let track: Track?
|
||||||
|
let currentTimestamp: Double
|
||||||
|
let channelName: String
|
||||||
|
let channelId: String
|
||||||
|
let paused: Bool
|
||||||
|
let queue: [Track]?
|
||||||
|
let currentIndex: Int
|
||||||
|
let playbackMode: String
|
||||||
|
let listenerCount: Int
|
||||||
|
let listeners: [String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case track
|
||||||
|
case currentTimestamp
|
||||||
|
case channelName
|
||||||
|
case channelId
|
||||||
|
case paused
|
||||||
|
case queue
|
||||||
|
case currentIndex
|
||||||
|
case playbackMode
|
||||||
|
case listenerCount
|
||||||
|
case listeners
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
track = try c.decodeIfPresent(Track.self, forKey: .track)
|
||||||
|
currentTimestamp = try c.decodeIfPresent(Double.self, forKey: .currentTimestamp) ?? 0
|
||||||
|
channelName = try c.decodeIfPresent(String.self, forKey: .channelName) ?? ""
|
||||||
|
channelId = try c.decodeIfPresent(String.self, forKey: .channelId) ?? ""
|
||||||
|
paused = try c.decodeIfPresent(Bool.self, forKey: .paused) ?? true
|
||||||
|
queue = try c.decodeIfPresent([Track].self, forKey: .queue)
|
||||||
|
currentIndex = try c.decodeIfPresent(Int.self, forKey: .currentIndex) ?? 0
|
||||||
|
playbackMode = try c.decodeIfPresent(String.self, forKey: .playbackMode) ?? "repeat-all"
|
||||||
|
listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0
|
||||||
|
listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistBundle: Decodable {
|
||||||
|
let mine: [Playlist]
|
||||||
|
let shared: [Playlist]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Playlist: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let ownerId: Int
|
||||||
|
let ownerName: String
|
||||||
|
let isPublic: Bool
|
||||||
|
let shareToken: String?
|
||||||
|
let trackIds: [String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case description
|
||||||
|
case ownerId
|
||||||
|
case ownerName
|
||||||
|
case isPublic
|
||||||
|
case shareToken
|
||||||
|
case trackIds
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Playlist"
|
||||||
|
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
|
||||||
|
ownerId = try c.decodeIfPresent(Int.self, forKey: .ownerId) ?? 0
|
||||||
|
ownerName = try c.decodeIfPresent(String.self, forKey: .ownerName) ?? ""
|
||||||
|
isPublic = try c.decodeIfPresent(Bool.self, forKey: .isPublic) ?? false
|
||||||
|
shareToken = try c.decodeIfPresent(String.self, forKey: .shareToken)
|
||||||
|
trackIds = try c.decodeIfPresent([String].self, forKey: .trackIds) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserSession: Decodable {
|
||||||
|
let id: Int
|
||||||
|
let username: String
|
||||||
|
let isAdmin: Bool
|
||||||
|
let isGuest: Bool
|
||||||
|
let permissions: [Permission]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case username
|
||||||
|
case isAdmin
|
||||||
|
case isAdminSnake = "is_admin"
|
||||||
|
case isGuest
|
||||||
|
case isGuestSnake = "is_guest"
|
||||||
|
case permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||||
|
username = try c.decodeIfPresent(String.self, forKey: .username) ?? "guest"
|
||||||
|
isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin)
|
||||||
|
?? c.decodeIfPresent(Bool.self, forKey: .isAdminSnake)
|
||||||
|
?? false
|
||||||
|
isGuest = try c.decodeIfPresent(Bool.self, forKey: .isGuest)
|
||||||
|
?? c.decodeIfPresent(Bool.self, forKey: .isGuestSnake)
|
||||||
|
?? false
|
||||||
|
permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Permission: Decodable {
|
||||||
|
let resourceType: String
|
||||||
|
let resourceId: String?
|
||||||
|
let permission: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case resourceType
|
||||||
|
case resourceTypeSnake = "resource_type"
|
||||||
|
case resourceId
|
||||||
|
case resourceIdSnake = "resource_id"
|
||||||
|
case permission
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
resourceType = try c.decodeIfPresent(String.self, forKey: .resourceType)
|
||||||
|
?? c.decodeIfPresent(String.self, forKey: .resourceTypeSnake)
|
||||||
|
?? ""
|
||||||
|
resourceId = try c.decodeIfPresent(String.self, forKey: .resourceId)
|
||||||
|
?? c.decodeIfPresent(String.self, forKey: .resourceIdSnake)
|
||||||
|
permission = try c.decodeIfPresent(String.self, forKey: .permission) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthEnvelope: Decodable {
|
||||||
|
let user: UserSession?
|
||||||
|
let permissions: [Permission]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case user
|
||||||
|
case permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
user = try c.decodeIfPresent(UserSession.self, forKey: .user)
|
||||||
|
permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QueueResponse: Decodable {
|
||||||
|
let success: Bool?
|
||||||
|
let queueLength: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ModeResponse: Decodable {
|
||||||
|
let success: Bool?
|
||||||
|
let playbackMode: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FetchItem: Codable, Hashable {
|
||||||
|
let id: String?
|
||||||
|
let url: String
|
||||||
|
let title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FetchPlaylistResponse: Decodable {
|
||||||
|
let type: String
|
||||||
|
let title: String
|
||||||
|
let count: Int
|
||||||
|
let items: [FetchItem]
|
||||||
|
let requiresConfirmation: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FetchSingleResponse: Decodable {
|
||||||
|
let type: String
|
||||||
|
let id: String?
|
||||||
|
let title: String
|
||||||
|
let queueType: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FetchResponse: Decodable {
|
||||||
|
case single(FetchSingleResponse)
|
||||||
|
case playlist(FetchPlaylistResponse)
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let type = try c.decodeIfPresent(String.self, forKey: .type)
|
||||||
|
switch type {
|
||||||
|
case "playlist":
|
||||||
|
self = .playlist(try FetchPlaylistResponse(from: decoder))
|
||||||
|
default:
|
||||||
|
self = .single(try FetchSingleResponse(from: decoder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FetchConfirmResponse: Decodable {
|
||||||
|
let message: String
|
||||||
|
let queueType: String?
|
||||||
|
let estimatedTime: String?
|
||||||
|
let playlistId: String?
|
||||||
|
let playlistName: String?
|
||||||
|
let items: [FetchItem]?
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,77 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Theme {
|
||||||
|
static let background = Color(red: 0.055, green: 0.052, blue: 0.067)
|
||||||
|
static let panel = Color(red: 0.112, green: 0.105, blue: 0.135)
|
||||||
|
static let panel2 = Color(red: 0.170, green: 0.157, blue: 0.205)
|
||||||
|
static let stroke = Color(red: 0.475, green: 0.425, blue: 0.545)
|
||||||
|
static let text = Color(red: 0.965, green: 0.930, blue: 0.760)
|
||||||
|
static let muted = Color(red: 0.640, green: 0.585, blue: 0.710)
|
||||||
|
static let accent = Color(red: 1.000, green: 0.812, blue: 0.176)
|
||||||
|
static let ready = Color(red: 0.350, green: 0.820, blue: 1.000)
|
||||||
|
static let amber = Color(red: 1.000, green: 0.570, blue: 0.240)
|
||||||
|
static let red = Color(red: 1.000, green: 0.310, blue: 0.340)
|
||||||
|
|
||||||
|
static let corner: CGFloat = 0
|
||||||
|
static let smallCorner: CGFloat = 0
|
||||||
|
|
||||||
|
static func pixel(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||||
|
.custom("PixelifySans-Regular", size: size).weight(weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||||
|
pixel(size, weight: weight).monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
static let bodyFont = pixel(16)
|
||||||
|
static let headlineFont = pixel(19, weight: .semibold)
|
||||||
|
static let captionFont = pixel(13)
|
||||||
|
static let microFont = mono(11, weight: .semibold)
|
||||||
|
static func display(_ size: CGFloat) -> Font { pixel(size, weight: .bold) }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func panel() -> some View {
|
||||||
|
self
|
||||||
|
.padding(14)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.corner)
|
||||||
|
.stroke(Theme.stroke, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowStyle(isActive: Bool = false) -> some View {
|
||||||
|
self
|
||||||
|
.padding(10)
|
||||||
|
.background(isActive ? Theme.panel2.opacity(1.0) : Theme.panel2.opacity(0.76))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.corner)
|
||||||
|
.stroke(isActive ? Theme.accent : Theme.stroke.opacity(0.38), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldStyle() -> some View {
|
||||||
|
self
|
||||||
|
.padding(12)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.corner)
|
||||||
|
.stroke(Theme.stroke, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(_ ms: Int64) -> String {
|
||||||
|
let total = max(0, Int(ms / 1000))
|
||||||
|
return "\(total / 60):" + String(format: "%02d", total % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(_ duration: TimeInterval) -> String {
|
||||||
|
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||||
|
return formatTime(Int64(duration * 1000))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AuthView: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
@Binding var username: String
|
||||||
|
@Binding var password: String
|
||||||
|
@FocusState private var focused: Field?
|
||||||
|
|
||||||
|
private enum Field {
|
||||||
|
case server
|
||||||
|
case username
|
||||||
|
case password
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("BLASTOISE")
|
||||||
|
.font(Theme.display(40))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Text("Tune into a shared room, stream the queue, and keep your local player in sync.")
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Label("Server", systemImage: "server.rack")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.font(Theme.headlineFont)
|
||||||
|
field("http://host:3001", text: $model.serverURL, field: .server)
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
model.serverURL = "http://mhsgroove.peterino.com:3001"
|
||||||
|
} label: {
|
||||||
|
Label("Default", systemImage: "radio")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.serverURL = "http://localhost:3001"
|
||||||
|
} label: {
|
||||||
|
Label("Local", systemImage: "desktopcomputer")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Label("Account", systemImage: "person.crop.circle")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.font(Theme.headlineFont)
|
||||||
|
field("username", text: $username, field: .username)
|
||||||
|
SecureField("password", text: $password)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focused, equals: .password)
|
||||||
|
.textFieldStyle()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
focused = nil
|
||||||
|
Task { await model.signIn(username: username, password: password) }
|
||||||
|
} label: {
|
||||||
|
Label("Sign In", systemImage: "arrow.right.circle")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Theme.accent)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
focused = nil
|
||||||
|
Task { await model.signUp(username: username, password: password) }
|
||||||
|
} label: {
|
||||||
|
Label("Sign Up", systemImage: "person.badge.plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
|
||||||
|
StatusStrip(model: model)
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.frame(maxWidth: 640, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(_ placeholder: String, text: Binding<String>, field: Field) -> some View {
|
||||||
|
TextField(placeholder, text: text)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(field == .server ? .URL : .default)
|
||||||
|
.focused($focused, equals: field)
|
||||||
|
.textFieldStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DebugFooterView: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(model.authState == .signedIn ? Theme.ready : Theme.amber)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(model.status)
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusStrip: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(model.authState == .checking ? Theme.amber : model.authState == .signedIn ? Theme.ready : Theme.red)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(model.status)
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanelTitle: View {
|
||||||
|
private let title: String
|
||||||
|
private let icon: String
|
||||||
|
|
||||||
|
init(_ title: String, icon: String) {
|
||||||
|
self.title = title
|
||||||
|
self.icon = icon
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
.font(Theme.headlineFont)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyLine: View {
|
||||||
|
private let text: String
|
||||||
|
|
||||||
|
init(_ text: String) {
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(Theme.captionFont)
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrackLine<Actions: View>: View {
|
||||||
|
let track: Track
|
||||||
|
let isActive: Bool
|
||||||
|
let subtitle: String
|
||||||
|
@ViewBuilder let actions: () -> Actions
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(isActive ? Theme.ready : Theme.amber)
|
||||||
|
.frame(width: 4)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(track.title)
|
||||||
|
.font(Theme.pixel(16, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(Theme.captionFont)
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
actions()
|
||||||
|
}
|
||||||
|
.rowStyle(isActive: isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HeaderView: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("BLASTOISE")
|
||||||
|
.font(Theme.display(28))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Text(model.currentUser?.username ?? "signed out")
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await model.connectToServer() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.frame(width: 38, height: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await model.logout() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
|
.frame(width: 38, height: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "server.rack")
|
||||||
|
.foregroundStyle(Theme.amber)
|
||||||
|
TextField("server", text: $model.serverURL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Theme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,431 @@
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct RoomsPanel: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
PanelTitle("Rooms", icon: "radio")
|
||||||
|
if model.channels.isEmpty {
|
||||||
|
EmptyLine("No rooms loaded")
|
||||||
|
} else {
|
||||||
|
ForEach(model.channels) { channel in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(channel.name)
|
||||||
|
.font(Theme.headlineFont)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
if channel.isDefault {
|
||||||
|
Text("DEFAULT")
|
||||||
|
.font(Theme.microFont)
|
||||||
|
.foregroundStyle(Theme.background)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Theme.amber)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(channel.description.isEmpty ? "\(channel.trackCount) tracks" : channel.description)
|
||||||
|
.font(Theme.captionFont)
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
Text("\(channel.listenerCount) listener(s)")
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.ready)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await model.joinChannel(channel.id) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: model.currentChannelId == channel.id ? "checkmark.circle.fill" : "dot.radiowaves.left.and.right")
|
||||||
|
.frame(width: 44, height: 38)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(model.currentChannelId == channel.id ? Theme.ready : Theme.accent)
|
||||||
|
}
|
||||||
|
.rowStyle(isActive: model.currentChannelId == channel.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QueuePanel: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
PanelTitle("Queue", icon: "list.bullet")
|
||||||
|
if model.queue.isEmpty {
|
||||||
|
EmptyLine(model.queueLoaded ? "Queue is empty" : "Queue not loaded")
|
||||||
|
} else {
|
||||||
|
ForEach(Array(model.queue.prefix(80).enumerated()), id: \.offset) { index, track in
|
||||||
|
TrackLine(
|
||||||
|
track: track,
|
||||||
|
isActive: index == model.currentIndex,
|
||||||
|
subtitle: "#\(index + 1) \(formatDuration(track.duration))"
|
||||||
|
) {
|
||||||
|
Button {
|
||||||
|
model.jumpToQueueIndex(index)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await model.removeQueueIndex(index) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PeoplePanel: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
PanelTitle("People", icon: "person.2")
|
||||||
|
if model.listeners.isEmpty {
|
||||||
|
EmptyLine("No listener names in this room yet")
|
||||||
|
} else {
|
||||||
|
ForEach(model.listeners, id: \.self) { listener in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: listener == model.currentUser?.username ? "person.fill.checkmark" : "person.fill")
|
||||||
|
.foregroundStyle(listener == model.currentUser?.username ? Theme.ready : Theme.muted)
|
||||||
|
Text(listener)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
if listener == model.currentUser?.username {
|
||||||
|
Text("YOU")
|
||||||
|
.font(Theme.microFont)
|
||||||
|
.foregroundStyle(Theme.ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rowStyle(isActive: listener == model.currentUser?.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LibraryPanel: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var fetchURL = ""
|
||||||
|
@State private var fileImporterPresented = false
|
||||||
|
|
||||||
|
var matches: [Track] {
|
||||||
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let base = model.libraryTracks
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
return Array(base.prefix(80))
|
||||||
|
}
|
||||||
|
return Array(base.filter {
|
||||||
|
$0.title.lowercased().contains(trimmed) ||
|
||||||
|
$0.filename.lowercased().contains(trimmed) ||
|
||||||
|
($0.artist ?? "").lowercased().contains(trimmed)
|
||||||
|
}.prefix(80))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
PanelTitle("Library", icon: "music.note.list")
|
||||||
|
importTools
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
TextField("Search tracks", text: $query)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
|
||||||
|
if !model.libraryLoaded {
|
||||||
|
EmptyLine("Loading library")
|
||||||
|
} else if matches.isEmpty {
|
||||||
|
EmptyLine("No matching tracks")
|
||||||
|
} else {
|
||||||
|
ForEach(matches) { track in
|
||||||
|
TrackLine(
|
||||||
|
track: track,
|
||||||
|
isActive: model.sourceMode == .library && model.currentTrackId == track.id,
|
||||||
|
subtitle: track.artist ?? track.filename
|
||||||
|
) {
|
||||||
|
Button {
|
||||||
|
model.playLibraryTrack(track)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Theme.accent)
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Button("Add to Queue") {
|
||||||
|
Task { await model.queueTrack(track, playNext: false) }
|
||||||
|
}
|
||||||
|
Button("Play Next") {
|
||||||
|
Task { await model.queueTrack(track, playNext: true) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $fileImporterPresented,
|
||||||
|
allowedContentTypes: [.audio, .movie],
|
||||||
|
allowsMultipleSelection: true
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
Task { await model.uploadFiles(urls) }
|
||||||
|
case .failure(let error):
|
||||||
|
model.importStatus = "File picker failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var importTools: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
fileImporterPresented = true
|
||||||
|
} label: {
|
||||||
|
Label(model.isUploading ? "Uploading" : "Upload Files", systemImage: "square.and.arrow.up")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Theme.accent)
|
||||||
|
.disabled(model.isUploading)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await model.loadLibrary() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.frame(width: 42, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.accessibilityLabel("Reload Library")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "link")
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
TextField("Fetch from website URL", text: $fetchURL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Button {
|
||||||
|
Task { await model.fetchFromWebsite(fetchURL) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: model.isFetching ? "hourglass" : "arrow.down.circle")
|
||||||
|
.frame(width: 40, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(model.isFetching)
|
||||||
|
.accessibilityLabel("Fetch URL")
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
|
||||||
|
if let playlist = model.pendingFetchPlaylist {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Playlist found")
|
||||||
|
.font(Theme.mono(12, weight: .bold))
|
||||||
|
.foregroundStyle(Theme.amber)
|
||||||
|
Text("\(playlist.title) · \(playlist.count) items")
|
||||||
|
.font(Theme.pixel(16, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(2)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
Task { await model.confirmFetchPlaylist() }
|
||||||
|
} label: {
|
||||||
|
Label("Queue Playlist", systemImage: "checkmark.circle")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Theme.ready)
|
||||||
|
.disabled(model.isFetching)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
model.cancelFetchPlaylist()
|
||||||
|
} label: {
|
||||||
|
Label("Cancel", systemImage: "xmark")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.importStatus.isEmpty {
|
||||||
|
Text(model.importStatus)
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistsPanel: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
PanelTitle("Playlists", icon: "rectangle.stack")
|
||||||
|
if model.allPlaylists.isEmpty {
|
||||||
|
EmptyLine(model.playlistsLoaded ? "No playlists" : "Loading playlists")
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(model.allPlaylists.prefix(40)) { playlist in
|
||||||
|
let isSelected = model.selectedPlaylistId == playlist.id
|
||||||
|
Button {
|
||||||
|
Task { await model.loadPlaylist(playlist.id) }
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(playlist.name)
|
||||||
|
.font(Theme.pixel(16, weight: .bold))
|
||||||
|
.foregroundStyle(isSelected ? Theme.background : Theme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(playlist.trackIds.count) tracks")
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(isSelected ? Theme.background.opacity(0.72) : Theme.muted)
|
||||||
|
}
|
||||||
|
.frame(width: 150, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(isSelected ? Theme.accent : Theme.panel2)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.corner)
|
||||||
|
.stroke(isSelected ? Theme.text : Theme.stroke.opacity(0.38), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let playlist = model.selectedPlaylist {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(playlist.name)
|
||||||
|
.font(Theme.headlineFont)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Text(playlist.ownerName.isEmpty ? "\(playlist.trackIds.count) tracks" : "by \(playlist.ownerName)")
|
||||||
|
.font(Theme.captionFont)
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await model.addPlaylistToQueue(playlist, playNext: false) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "text.badge.plus")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
Button {
|
||||||
|
Task { await model.addPlaylistToQueue(playlist, playNext: true) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(playlist.trackIds.prefix(80).enumerated()), id: \.offset) { index, trackId in
|
||||||
|
let track = model.track(for: trackId) ?? Track(id: trackId, filename: trackId, title: trackId, duration: 0)
|
||||||
|
TrackLine(
|
||||||
|
track: track,
|
||||||
|
isActive: model.currentTrackId == track.id,
|
||||||
|
subtitle: "#\(index + 1)"
|
||||||
|
) {
|
||||||
|
Button {
|
||||||
|
Task { await model.queueTrack(track, playNext: false) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
Button {
|
||||||
|
Task { await model.queueTrack(track, playNext: true) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.to.line")
|
||||||
|
.frame(width: 38, height: 34)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DebugPanel: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
PanelTitle("Diagnostics", icon: "waveform.path.ecg")
|
||||||
|
debugRow("Server", model.serverURL)
|
||||||
|
debugRow("Auth", model.authState.rawValue)
|
||||||
|
debugRow("User", model.currentUser?.username ?? "-")
|
||||||
|
debugRow("Room", model.currentChannelId ?? "-")
|
||||||
|
debugRow("Track", model.currentTrackId ?? "-")
|
||||||
|
debugRow("Expected", "\(model.expectedPositionMs)ms")
|
||||||
|
debugRow("Player", "\(model.playerPositionMs)ms")
|
||||||
|
debugRow("Drift", "\(model.driftMs)ms")
|
||||||
|
Divider().overlay(Theme.stroke)
|
||||||
|
ForEach(model.debugEvents, id: \.self) { event in
|
||||||
|
Text(event)
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func debugRow(_ label: String, _ value: String) -> some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(label)
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
.frame(width: 78, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PlayerDeckView: View {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard model.trackDuration > 0 else { return 0 }
|
||||||
|
return min(1, max(0, Double(model.playerPositionMs) / (model.trackDuration * 1000)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(model.sourceMode.rawValue)
|
||||||
|
.font(Theme.mono(12, weight: .bold))
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
Text(model.channelName)
|
||||||
|
.font(Theme.headlineFont)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Text(model.trackTitle)
|
||||||
|
.font(Theme.pixel(22, weight: .bold))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text(model.playbackMode.uppercased())
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.amber)
|
||||||
|
Text(model.playbackState.uppercased())
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(model.isPlaying ? Theme.ready : Theme.muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.tint(Theme.ready)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner))
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(formatTime(model.playerPositionMs))
|
||||||
|
Spacer()
|
||||||
|
Text(formatDuration(model.trackDuration))
|
||||||
|
}
|
||||||
|
.font(Theme.mono(12))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
iconButton("backward.end.fill") { model.previous() }
|
||||||
|
iconButton("gobackward.15") { model.seekBy(seconds: -15) }
|
||||||
|
Button {
|
||||||
|
model.togglePlay()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(Theme.pixel(24, weight: .bold))
|
||||||
|
.frame(width: 58, height: 48)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Theme.accent)
|
||||||
|
iconButton("goforward.15") { model.seekBy(seconds: 15) }
|
||||||
|
iconButton("forward.end.fill") { model.next() }
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
actionButton("Mode", icon: "repeat") {
|
||||||
|
Task { await model.cyclePlaybackMode() }
|
||||||
|
}
|
||||||
|
actionButton("Queue", icon: "text.badge.plus") {
|
||||||
|
Task { await model.queueCurrent(playNext: false) }
|
||||||
|
}
|
||||||
|
actionButton("Next", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||||
|
Task { await model.queueCurrent(playNext: true) }
|
||||||
|
}
|
||||||
|
actionButton("Stop", icon: "power") {
|
||||||
|
model.stopAndExit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
meter("DRIFT", "\(model.driftMs)ms", model.sourceMode == .radio && abs(model.driftMs) > 1800 ? Theme.amber : Theme.ready)
|
||||||
|
meter("ROOMS", "\(model.channels.count)", Theme.text)
|
||||||
|
meter("QUEUE", "\(model.queue.count)", Theme.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconButton(_ systemName: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 38)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.accessibilityLabel(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func meter(_ label: String, _ value: String, _ color: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(Theme.microFont)
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
Text(value)
|
||||||
|
.font(Theme.mono(13, weight: .bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(Theme.panel2)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Blastoise iOS Sketch
|
||||||
|
|
||||||
|
Native SwiftUI sketch for the Blastoise/MusicRoom server.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
- Defaults to `http://mhsgroove.peterino.com:3001`.
|
||||||
|
- Signs in or signs up with the server.
|
||||||
|
- Loads rooms, queue state, people, library, and playlists.
|
||||||
|
- Connects to a room WebSocket and streams `/api/tracks/:id` through `AVPlayer`.
|
||||||
|
- Applies server timestamp sync and drift correction.
|
||||||
|
- Supports local library playback, queue/play-next actions, queue jumps/removes, and playback mode cycling.
|
||||||
|
- Uses one compact broadcast-console theme.
|
||||||
|
|
||||||
|
## Code Layout
|
||||||
|
|
||||||
|
- `BlastoisePingApp.swift` - app entrypoint.
|
||||||
|
- `ContentView.swift` - signed-in/signed-out shell and tab routing.
|
||||||
|
- `Models/AppTypes.swift` - API response models and shared enums.
|
||||||
|
- `State/AppModel.swift` - app state, server requests, WebSocket sync, uploads, and playback coordination.
|
||||||
|
- `UI/Theme.swift` - pixel-art palette, typography, reusable view chrome, and time formatting.
|
||||||
|
- `Views/` - focused SwiftUI screens and reusable row/panel components.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open ios/BlastoisePing/BlastoisePing.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
The app currently allows arbitrary HTTP loads in `Info.plist` so it can reach the existing plain-HTTP test server and local development servers. Narrow that before any public distribution.
|
||||||
178
library.ts
178
library.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { spawn } from "child_process";
|
import { spawn, type ChildProcess } from "child_process";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { watch, type FSWatcher } from "fs";
|
import { watch, type FSWatcher } from "fs";
|
||||||
import { readdir, stat } from "fs/promises";
|
import { readdir, stat } from "fs/promises";
|
||||||
|
|
@ -9,11 +9,13 @@ import { type Track } from "./db";
|
||||||
|
|
||||||
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
|
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
|
||||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
|
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
|
||||||
const DEFAULT_REPLAY_GAIN_CONFIG: ReplayGainScanConfig = {
|
const DEFAULT_REPLAY_GAIN_CONFIG: Required<ReplayGainScanConfig> = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
command: "rsgain",
|
command: "rsgain",
|
||||||
truePeak: false,
|
truePeak: false,
|
||||||
timeoutMs: 120000,
|
timeoutMs: 120000,
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxOutputBytes: 256 * 1024,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReplayGainScanConfig {
|
export interface ReplayGainScanConfig {
|
||||||
|
|
@ -21,6 +23,8 @@ export interface ReplayGainScanConfig {
|
||||||
command?: string;
|
command?: string;
|
||||||
truePeak?: boolean;
|
truePeak?: boolean;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
maxConcurrent?: number;
|
||||||
|
maxOutputBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReplayGainScanResult {
|
interface ReplayGainScanResult {
|
||||||
|
|
@ -55,6 +59,9 @@ export class Library {
|
||||||
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
|
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
|
||||||
private replayGainConfig: Required<ReplayGainScanConfig>;
|
private replayGainConfig: Required<ReplayGainScanConfig>;
|
||||||
private replayGainAvailable: boolean | null = null;
|
private replayGainAvailable: boolean | null = null;
|
||||||
|
private replayGainAvailabilityCheck: Promise<boolean> | null = null;
|
||||||
|
private replayGainActive = 0;
|
||||||
|
private replayGainQueue: Array<() => void> = [];
|
||||||
|
|
||||||
// Scan progress tracking
|
// Scan progress tracking
|
||||||
private _scanProgress = { scanning: false, processed: 0, total: 0 };
|
private _scanProgress = { scanning: false, processed: 0, total: 0 };
|
||||||
|
|
@ -75,12 +82,19 @@ export class Library {
|
||||||
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
|
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
|
||||||
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
|
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
|
||||||
truePeak: replayGainConfig.truePeak ?? DEFAULT_REPLAY_GAIN_CONFIG.truePeak,
|
truePeak: replayGainConfig.truePeak ?? DEFAULT_REPLAY_GAIN_CONFIG.truePeak,
|
||||||
timeoutMs: replayGainConfig.timeoutMs ?? DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs,
|
timeoutMs: this.normalizePositiveInteger(replayGainConfig.timeoutMs, DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs!),
|
||||||
|
maxConcurrent: this.normalizePositiveInteger(replayGainConfig.maxConcurrent, DEFAULT_REPLAY_GAIN_CONFIG.maxConcurrent!),
|
||||||
|
maxOutputBytes: this.normalizePositiveInteger(replayGainConfig.maxOutputBytes, DEFAULT_REPLAY_GAIN_CONFIG.maxOutputBytes!),
|
||||||
};
|
};
|
||||||
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
this.cacheDb.run("PRAGMA journal_mode = WAL");
|
||||||
this.initCacheDb();
|
this.initCacheDb();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
||||||
|
if (value == null || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(1, Math.floor(value));
|
||||||
|
}
|
||||||
|
|
||||||
private initCacheDb(): void {
|
private initCacheDb(): void {
|
||||||
this.cacheDb.run(`
|
this.cacheDb.run(`
|
||||||
CREATE TABLE IF NOT EXISTS file_cache (
|
CREATE TABLE IF NOT EXISTS file_cache (
|
||||||
|
|
@ -229,19 +243,26 @@ export class Library {
|
||||||
private async ensureReplayGainAvailable(): Promise<boolean> {
|
private async ensureReplayGainAvailable(): Promise<boolean> {
|
||||||
if (!this.replayGainConfig.enabled) return false;
|
if (!this.replayGainConfig.enabled) return false;
|
||||||
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
|
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
|
||||||
|
if (this.replayGainAvailabilityCheck) return this.replayGainAvailabilityCheck;
|
||||||
|
|
||||||
try {
|
this.replayGainAvailabilityCheck = this.withReplayGainSlot(async () => {
|
||||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
|
try {
|
||||||
const version = stdout.trim().split(/\r?\n/)[0] || "available";
|
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
|
||||||
console.log(`[Library] rsgain found: ${version}`);
|
const version = stdout.trim().split(/\r?\n/)[0] || "available";
|
||||||
this.replayGainAvailable = true;
|
console.log(`[Library] rsgain found: ${version}`);
|
||||||
} catch (e) {
|
this.replayGainAvailable = true;
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
} catch (e) {
|
||||||
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
this.replayGainAvailable = false;
|
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
|
||||||
}
|
this.replayGainAvailable = false;
|
||||||
|
} finally {
|
||||||
|
this.replayGainAvailabilityCheck = null;
|
||||||
|
}
|
||||||
|
|
||||||
return this.replayGainAvailable;
|
return this.replayGainAvailable;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.replayGainAvailabilityCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
|
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
|
||||||
|
|
@ -262,7 +283,9 @@ export class Library {
|
||||||
args.push(filePath);
|
args.push(filePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs);
|
const { stdout } = await this.withReplayGainSlot(() =>
|
||||||
|
this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs)
|
||||||
|
);
|
||||||
return this.parseReplayGainOutput(stdout);
|
return this.parseReplayGainOutput(stdout);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
|
@ -295,44 +318,117 @@ export class Library {
|
||||||
return { replayGainDb, replayPeak };
|
return { replayGainDb, replayPeak };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withReplayGainSlot<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const run = () => {
|
||||||
|
this.replayGainActive++;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(task)
|
||||||
|
.then(resolve, reject)
|
||||||
|
.finally(() => {
|
||||||
|
this.replayGainActive--;
|
||||||
|
this.replayGainQueue.shift()?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.replayGainActive < this.replayGainConfig.maxConcurrent) {
|
||||||
|
run();
|
||||||
|
} else {
|
||||||
|
this.replayGainQueue.push(run);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminateProcess(proc: ChildProcess): void {
|
||||||
|
if (process.platform === "win32" && proc.pid) {
|
||||||
|
try {
|
||||||
|
const killer = spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
killer.unref();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall back to direct child termination below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
|
||||||
private runCommand(command: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
private runCommand(command: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(command, args, { windowsHide: true });
|
const proc = spawn(command, args, {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let finished = false;
|
let finished = false;
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
let timedOut = false;
|
||||||
|
let outputExceeded = false;
|
||||||
const finish = (callback: () => void) => {
|
const maxOutputBytes = this.replayGainConfig.maxOutputBytes;
|
||||||
if (finished) return;
|
const timeout = setTimeout(() => {
|
||||||
finished = true;
|
timedOut = true;
|
||||||
clearTimeout(timeout);
|
this.terminateProcess(proc);
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
proc.kill();
|
|
||||||
finish(() => reject(new Error(`timed out after ${timeoutMs}ms`)));
|
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
const killGraceTimeout = setTimeout(() => {
|
||||||
|
if (!finished && timedOut) {
|
||||||
|
finish(() => reject(new Error(`timed out after ${timeoutMs}ms and did not exit after termination`)));
|
||||||
|
}
|
||||||
|
}, timeoutMs + 5000);
|
||||||
|
|
||||||
proc.stdout.on("data", (data) => {
|
const onStdout = (data: Buffer) => {
|
||||||
stdout += data.toString();
|
stdout = appendOutput(stdout, data);
|
||||||
});
|
};
|
||||||
proc.stderr.on("data", (data) => {
|
const onStderr = (data: Buffer) => {
|
||||||
stderr += data.toString();
|
stderr = appendOutput(stderr, data);
|
||||||
});
|
};
|
||||||
proc.on("error", (e) => {
|
const onError = (e: Error) => {
|
||||||
finish(() => reject(e));
|
finish(() => reject(e));
|
||||||
});
|
};
|
||||||
proc.on("close", (code) => {
|
const onClose = (code: number | null) => {
|
||||||
finish(() => {
|
finish(() => {
|
||||||
if (code === 0) {
|
if (timedOut) {
|
||||||
|
reject(new Error(`timed out after ${timeoutMs}ms`));
|
||||||
|
} else if (outputExceeded) {
|
||||||
|
reject(new Error(`output exceeded ${maxOutputBytes} bytes`));
|
||||||
|
} else if (code === 0) {
|
||||||
resolve({ stdout, stderr });
|
resolve({ stdout, stderr });
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
clearTimeout(killGraceTimeout);
|
||||||
|
proc.stdout.off("data", onStdout);
|
||||||
|
proc.stderr.off("data", onStderr);
|
||||||
|
proc.off("error", onError);
|
||||||
|
proc.off("close", onClose);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendOutput = (current: string, data: Buffer): string => {
|
||||||
|
if (outputExceeded) return current;
|
||||||
|
|
||||||
|
const text = data.toString();
|
||||||
|
const next = current + text;
|
||||||
|
if (next.length <= maxOutputBytes) return next;
|
||||||
|
|
||||||
|
outputExceeded = true;
|
||||||
|
this.terminateProcess(proc);
|
||||||
|
return next.slice(0, maxOutputBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.stdout.on("data", onStdout);
|
||||||
|
proc.stderr.on("data", onStderr);
|
||||||
|
proc.on("error", onError);
|
||||||
|
proc.on("close", onClose);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,6 +729,10 @@ export class Library {
|
||||||
this.watcher.close();
|
this.watcher.close();
|
||||||
this.watcher = null;
|
this.watcher = null;
|
||||||
}
|
}
|
||||||
|
for (const timer of this.pendingFiles.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
this.pendingFiles.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event handling
|
// Event handling
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
if (M.currentUser && data.permissions) {
|
if (M.currentUser && data.permissions) {
|
||||||
M.currentUser.permissions = data.permissions;
|
M.currentUser.permissions = data.permissions;
|
||||||
}
|
}
|
||||||
|
if (data.preferences) {
|
||||||
|
M.loadReplayGainPrefs && M.loadReplayGainPrefs(data.preferences);
|
||||||
|
}
|
||||||
M.updateAuthUI();
|
M.updateAuthUI();
|
||||||
// Start slow queue polling if logged in
|
// Start slow queue polling if logged in
|
||||||
if (M.currentUser && !M.currentUser.isGuest && M.startSlowQueuePoll) {
|
if (M.currentUser && !M.currentUser.isGuest && M.startSlowQueuePoll) {
|
||||||
|
|
@ -100,6 +103,9 @@
|
||||||
if (M.currentUser && data.permissions) {
|
if (M.currentUser && data.permissions) {
|
||||||
M.currentUser.permissions = data.permissions;
|
M.currentUser.permissions = data.permissions;
|
||||||
}
|
}
|
||||||
|
if (data.preferences) {
|
||||||
|
M.loadReplayGainPrefs && M.loadReplayGainPrefs(data.preferences);
|
||||||
|
}
|
||||||
M.updateAuthUI();
|
M.updateAuthUI();
|
||||||
if (M.currentUser) M.loadStreams();
|
if (M.currentUser) M.loadStreams();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
M.channels = channels;
|
M.channels = channels;
|
||||||
M.renderChannelList();
|
M.renderChannelList();
|
||||||
// Try saved channel first, fall back to default
|
// Try saved channel first, fall back to default
|
||||||
const savedChannelId = localStorage.getItem("blastoise_channel");
|
const savedChannelId = M.getSavedChannelId();
|
||||||
const savedChannel = savedChannelId && channels.find(c => c.id === savedChannelId);
|
const savedChannel = savedChannelId && channels.find(c => c.id === savedChannelId);
|
||||||
const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0];
|
const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0];
|
||||||
M.connectChannel(targetChannel.id);
|
M.connectChannel(targetChannel.id);
|
||||||
|
|
@ -58,22 +58,31 @@
|
||||||
M.showToast("Cannot delete default channel");
|
M.showToast("Cannot delete default channel");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`Delete channel "${channel.name}"?`)) return;
|
M.showConfirmToast(`Delete channel "${channel.name}"?`, async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
|
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
M.showToast(err.error || "Failed to delete channel");
|
M.showToast(err.error || "Failed to delete channel", "error");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
if (M.getSavedChannelId() === channelId) {
|
||||||
|
M.clearRememberedChannel();
|
||||||
|
}
|
||||||
|
M.showToast(`Channel "${channel.name}" deleted`);
|
||||||
|
} catch (e) {
|
||||||
|
M.showToast("Failed to delete channel", "error");
|
||||||
}
|
}
|
||||||
M.showToast(`Channel "${channel.name}" deleted`);
|
}, { confirmText: "Delete", duration: 15000 });
|
||||||
} catch (e) {
|
|
||||||
M.showToast("Failed to delete channel");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// New channel creation with slideout input
|
// New channel creation with slideout input
|
||||||
M.createNewChannel = async function() {
|
M.createNewChannel = async function() {
|
||||||
|
if (!M.canCreateUserContent()) {
|
||||||
|
M.showToast("Sign in to create channels", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const header = M.$("#channels-panel .panel-header");
|
const header = M.$("#channels-panel .panel-header");
|
||||||
const btn = M.$("#btn-new-channel");
|
const btn = M.$("#btn-new-channel");
|
||||||
|
|
||||||
|
|
@ -150,7 +159,7 @@
|
||||||
counts[name] = (counts[name] || 0) + 1;
|
counts[name] = (counts[name] || 0) + 1;
|
||||||
}
|
}
|
||||||
const listenersHtml = Object.entries(counts).map(([name, count]) =>
|
const listenersHtml = Object.entries(counts).map(([name, count]) =>
|
||||||
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
`<div class="listener">${M.escapeHtml(name)}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
||||||
).join("");
|
).join("");
|
||||||
|
|
||||||
// Show delete button for non-default channels if user is admin or creator
|
// Show delete button for non-default channels if user is admin or creator
|
||||||
|
|
@ -164,8 +173,8 @@
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="channel-header">
|
<div class="channel-header">
|
||||||
<span class="channel-name">${ch.name}</span>
|
<span class="channel-name">${M.escapeHtml(ch.name)}</span>
|
||||||
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '"')}" style="display:none;">
|
<input class="channel-name-input" type="text" value="${M.escapeHtml(ch.name)}" style="display:none;">
|
||||||
${renameBtn}
|
${renameBtn}
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
<span class="listener-count">${ch.listenerCount}</span>
|
<span class="listener-count">${ch.listenerCount}</span>
|
||||||
|
|
@ -241,7 +250,8 @@
|
||||||
oldWs.close();
|
oldWs.close();
|
||||||
}
|
}
|
||||||
M.currentChannelId = id;
|
M.currentChannelId = id;
|
||||||
localStorage.setItem("blastoise_channel", id);
|
M.rememberChannel(id);
|
||||||
|
setPlaybackBlocked(false);
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
|
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
|
||||||
|
|
||||||
|
|
@ -259,6 +269,7 @@
|
||||||
// Handle channel switch confirmation
|
// Handle channel switch confirmation
|
||||||
if (data.type === "switched") {
|
if (data.type === "switched") {
|
||||||
M.currentChannelId = data.channelId;
|
M.currentChannelId = data.channelId;
|
||||||
|
M.rememberChannel(data.channelId);
|
||||||
M.renderChannelList();
|
M.renderChannelList();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +279,7 @@
|
||||||
M.wantSync = false;
|
M.wantSync = false;
|
||||||
M.synced = false;
|
M.synced = false;
|
||||||
M.audio.pause();
|
M.audio.pause();
|
||||||
|
setPlaybackBlocked(false);
|
||||||
if (M.ws) {
|
if (M.ws) {
|
||||||
const oldWs = M.ws;
|
const oldWs = M.ws;
|
||||||
M.ws = null;
|
M.ws = null;
|
||||||
|
|
@ -341,13 +353,18 @@
|
||||||
M.ws.onclose = () => {
|
M.ws.onclose = () => {
|
||||||
M.synced = false;
|
M.synced = false;
|
||||||
M.ws = null;
|
M.ws = null;
|
||||||
|
setPlaybackBlocked(false);
|
||||||
M.$("#sync-indicator").classList.add("disconnected");
|
M.$("#sync-indicator").classList.add("disconnected");
|
||||||
M.updateUI();
|
M.updateUI();
|
||||||
// Auto-reconnect if user wants to be synced
|
// Auto-reconnect if user wants to be synced
|
||||||
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
|
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
|
||||||
if (M.wantSync) {
|
if (M.wantSync) {
|
||||||
const delay = wasConnected ? 3000 : 2000;
|
const delay = wasConnected ? 3000 : 2000;
|
||||||
setTimeout(() => M.connectChannel(id), delay);
|
setTimeout(() => {
|
||||||
|
if (M.ws) return;
|
||||||
|
const reconnectId = M.currentChannelId || M.getSavedChannelId() || id;
|
||||||
|
M.connectChannel(reconnectId);
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -358,6 +375,85 @@
|
||||||
M.updateUI();
|
M.updateUI();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let playbackUnlockListenersInstalled = false;
|
||||||
|
let playbackBlockedToastShown = false;
|
||||||
|
|
||||||
|
function removePlaybackUnlockListeners() {
|
||||||
|
if (!playbackUnlockListenersInstalled) return;
|
||||||
|
document.removeEventListener("pointerdown", retryPlaybackFromUserActivation, true);
|
||||||
|
document.removeEventListener("keydown", retryPlaybackFromUserActivation, true);
|
||||||
|
playbackUnlockListenersInstalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPlaybackUnlockListeners() {
|
||||||
|
if (playbackUnlockListenersInstalled) return;
|
||||||
|
document.addEventListener("pointerdown", retryPlaybackFromUserActivation, true);
|
||||||
|
document.addEventListener("keydown", retryPlaybackFromUserActivation, true);
|
||||||
|
playbackUnlockListenersInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlaybackBlocked(blocked) {
|
||||||
|
const changed = M.playbackBlocked !== blocked;
|
||||||
|
M.playbackBlocked = blocked;
|
||||||
|
|
||||||
|
if (blocked) {
|
||||||
|
installPlaybackUnlockListeners();
|
||||||
|
if (!playbackBlockedToastShown) {
|
||||||
|
playbackBlockedToastShown = true;
|
||||||
|
M.showToast("Click play or press any key to resume this channel", "warning", 7000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playbackBlockedToastShown = false;
|
||||||
|
removePlaybackUnlockListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) M.updateUI?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSyncedAudioSource(timestamp, forceReload = false) {
|
||||||
|
if (!M.currentTrackId) return;
|
||||||
|
|
||||||
|
if (forceReload || !M.audio.src) {
|
||||||
|
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
||||||
|
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTime = Number.isFinite(timestamp) ? Math.max(0, timestamp) : 0;
|
||||||
|
try {
|
||||||
|
M.audio.currentTime = nextTime;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Playback] Unable to set synced time yet", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSyncedAudio(timestamp, forceReload = false) {
|
||||||
|
if (!M.currentTrackId || M.serverPaused) return false;
|
||||||
|
await ensureSyncedAudioSource(timestamp, forceReload);
|
||||||
|
M.resumeVisualizer?.();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await M.audio.play();
|
||||||
|
setPlaybackBlocked(false);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Playback] Browser blocked or delayed autoplay", error);
|
||||||
|
setPlaybackBlocked(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryPlaybackFromUserActivation() {
|
||||||
|
if (!M.playbackBlocked) return;
|
||||||
|
M.retryBlockedPlayback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
M.retryBlockedPlayback = async function() {
|
||||||
|
if (!M.currentTrackId || !M.synced || M.serverPaused) return false;
|
||||||
|
return playSyncedAudio(M.getServerTime(), !M.audio.src);
|
||||||
|
};
|
||||||
|
|
||||||
|
M.audio.addEventListener("play", () => setPlaybackBlocked(false));
|
||||||
|
|
||||||
// Handle channel state update from server
|
// Handle channel state update from server
|
||||||
M.handleUpdate = async function(data) {
|
M.handleUpdate = async function(data) {
|
||||||
|
|
@ -388,6 +484,7 @@
|
||||||
|
|
||||||
if (!data.track) {
|
if (!data.track) {
|
||||||
M.setTrackTitle("No tracks");
|
M.setTrackTitle("No tracks");
|
||||||
|
setPlaybackBlocked(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
M.serverTimestamp = data.currentTimestamp;
|
M.serverTimestamp = data.currentTimestamp;
|
||||||
|
|
@ -401,7 +498,8 @@
|
||||||
const isNewTrack = trackId !== M.currentTrackId;
|
const isNewTrack = trackId !== M.currentTrackId;
|
||||||
if (isNewTrack) {
|
if (isNewTrack) {
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.setTrackTitle(data.track.title);
|
M.setTrackTitle(M.trackComponent.getTitle(data.track));
|
||||||
|
M.applyReplayGain && M.applyReplayGain(data.track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
|
|
||||||
// Auto-scroll queue to current track
|
// Auto-scroll queue to current track
|
||||||
|
|
@ -426,15 +524,11 @@
|
||||||
if (!M.serverPaused) {
|
if (!M.serverPaused) {
|
||||||
// Server is playing - ensure we're playing and synced
|
// Server is playing - ensure we're playing and synced
|
||||||
if (isNewTrack || !M.audio.src) {
|
if (isNewTrack || !M.audio.src) {
|
||||||
// Try cache first
|
await playSyncedAudio(data.currentTimestamp, true);
|
||||||
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
} else if (M.audio.paused || M.playbackBlocked) {
|
||||||
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
await playSyncedAudio(data.currentTimestamp, false);
|
||||||
M.audio.currentTime = data.currentTimestamp;
|
|
||||||
M.audio.play().catch(() => {});
|
|
||||||
} else if (M.audio.paused) {
|
|
||||||
M.audio.currentTime = data.currentTimestamp;
|
|
||||||
M.audio.play().catch(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
|
setPlaybackBlocked(false);
|
||||||
// Check drift
|
// Check drift
|
||||||
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
|
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
|
||||||
if (drift >= 2) {
|
if (drift >= 2) {
|
||||||
|
|
@ -444,6 +538,7 @@
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Server is paused - ensure we're paused too
|
// Server is paused - ensure we're paused too
|
||||||
|
setPlaybackBlocked(false);
|
||||||
if (!M.audio.paused) {
|
if (!M.audio.paused) {
|
||||||
M.audio.pause();
|
M.audio.pause();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
const M = window.MusicRoom;
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
function blockSyncedControlIfNeeded() {
|
||||||
|
if (!M.synced || M.canControl()) return false;
|
||||||
|
M.flashPermissionDenied();
|
||||||
|
M.showToast("Sign in to control playback", "warning", 2500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Load saved volume
|
// Load saved volume
|
||||||
const savedVolume = localStorage.getItem(M.STORAGE_KEY);
|
const savedVolume = localStorage.getItem(M.STORAGE_KEY);
|
||||||
|
|
@ -17,8 +24,14 @@
|
||||||
// Toggle play/pause
|
// Toggle play/pause
|
||||||
function togglePlayback() {
|
function togglePlayback() {
|
||||||
if (!M.currentTrackId) return;
|
if (!M.currentTrackId) return;
|
||||||
|
M.resumeVisualizer?.();
|
||||||
|
|
||||||
if (M.synced) {
|
if (M.synced) {
|
||||||
|
if (!M.serverPaused && M.playbackBlocked) {
|
||||||
|
M.retryBlockedPlayback?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
|
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
|
||||||
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
|
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
|
||||||
}
|
}
|
||||||
|
|
@ -40,9 +53,11 @@
|
||||||
// Jump to a specific track index
|
// Jump to a specific track index
|
||||||
async function jumpToTrack(index) {
|
async function jumpToTrack(index) {
|
||||||
if (M.queue.length === 0) return;
|
if (M.queue.length === 0) return;
|
||||||
|
M.resumeVisualizer?.();
|
||||||
const newIndex = (index + M.queue.length) % M.queue.length;
|
const newIndex = (index + M.queue.length) % M.queue.length;
|
||||||
|
|
||||||
if (M.synced && M.currentChannelId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -56,7 +71,8 @@
|
||||||
M.currentIndex = newIndex;
|
M.currentIndex = newIndex;
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
M.setTrackTitle(track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown");
|
M.setTrackTitle(M.trackComponent.getTitle(track));
|
||||||
|
M.applyReplayGain && M.applyReplayGain(track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||||
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
||||||
|
|
@ -136,6 +152,7 @@
|
||||||
M.updateModeButton();
|
M.updateModeButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
|
|
||||||
// Synced mode - send to server
|
// Synced mode - send to server
|
||||||
const currentIdx = modeOrder.indexOf(M.playbackMode);
|
const currentIdx = modeOrder.indexOf(M.playbackMode);
|
||||||
|
|
@ -175,6 +192,7 @@
|
||||||
const seekTime = pct * dur;
|
const seekTime = pct * dur;
|
||||||
|
|
||||||
if (M.synced && M.currentChannelId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
|
if (blockSyncedControlIfNeeded()) return;
|
||||||
fetch("/api/channels/" + M.currentChannelId + "/seek", {
|
fetch("/api/channels/" + M.currentChannelId + "/seek", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ window.MusicRoom = {
|
||||||
serverTrackDuration: 0,
|
serverTrackDuration: 0,
|
||||||
lastServerUpdate: 0,
|
lastServerUpdate: 0,
|
||||||
serverPaused: true,
|
serverPaused: true,
|
||||||
|
|
||||||
|
// Current track cache (for ReplayGain re-application across graph init)
|
||||||
|
currentTrack: null,
|
||||||
|
|
||||||
// Channels list
|
// Channels list
|
||||||
channels: [],
|
channels: [],
|
||||||
|
|
@ -25,6 +28,9 @@ window.MusicRoom = {
|
||||||
// Volume
|
// Volume
|
||||||
preMuteVolume: 1,
|
preMuteVolume: 1,
|
||||||
STORAGE_KEY: "blastoise_volume",
|
STORAGE_KEY: "blastoise_volume",
|
||||||
|
CHANNEL_STORAGE_KEY: "blastoise_channel",
|
||||||
|
THEME_STORAGE_KEY: "blastoise_theme",
|
||||||
|
VISUALIZER_STORAGE_KEY: "blastoise_visualizer",
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
localTimestamp: 0,
|
localTimestamp: 0,
|
||||||
|
|
@ -65,3 +71,63 @@ window.MusicRoom = {
|
||||||
lastBufferPct: -1,
|
lastBufferPct: -1,
|
||||||
lastSpeedText: ""
|
lastSpeedText: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
const CHANNEL_COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
|
||||||
|
|
||||||
|
M.getCookieValue = function(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
return document.cookie
|
||||||
|
.split(";")
|
||||||
|
.map(cookie => cookie.trim())
|
||||||
|
.find(cookie => cookie.startsWith(prefix))
|
||||||
|
?.slice(prefix.length) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.setCookieValue = function(name, value, maxAge = CHANNEL_COOKIE_MAX_AGE) {
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=${maxAge}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.clearCookieValue = function(name) {
|
||||||
|
document.cookie = `${name}=; Path=/; SameSite=Lax; Max-Age=0`;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.getSavedChannelId = function() {
|
||||||
|
const cookieValue = M.getCookieValue(M.CHANNEL_STORAGE_KEY);
|
||||||
|
if (cookieValue) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(cookieValue);
|
||||||
|
} catch {
|
||||||
|
M.clearRememberedChannel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedValue = localStorage.getItem(M.CHANNEL_STORAGE_KEY);
|
||||||
|
if (storedValue) {
|
||||||
|
M.rememberChannel(storedValue);
|
||||||
|
return storedValue;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.rememberChannel = function(channelId) {
|
||||||
|
if (!channelId) return;
|
||||||
|
M.setCookieValue(M.CHANNEL_STORAGE_KEY, channelId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(M.CHANNEL_STORAGE_KEY, channelId);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.clearRememberedChannel = function() {
|
||||||
|
M.clearCookieValue(M.CHANNEL_STORAGE_KEY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(M.CHANNEL_STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Blastoise! A very special music server</title>
|
<title>Blastoise! A very special music server</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=20">
|
<link rel="stylesheet" href="/styles.css?v=23">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
@ -47,14 +47,44 @@
|
||||||
<button id="btn-logout">logout</button>
|
<button id="btn-logout">logout</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stream-select"></div>
|
<div id="stream-select">
|
||||||
|
<select id="theme-select" title="Theme">
|
||||||
|
<optgroup label="Themes">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="phosphor">Phosphor Noir</option>
|
||||||
|
<option value="vaporwave">Vaporwave Pool</option>
|
||||||
|
<option value="sunrise">Sunrise Radio</option>
|
||||||
|
<option value="paper">Paper Sleeve</option>
|
||||||
|
<option value="contrast">High Contrast</option>
|
||||||
|
<option value="aquarium">Aquarium Glass</option>
|
||||||
|
<option value="amber">Terminal Amber</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Layouts">
|
||||||
|
<option value="compact">Tiny Desk</option>
|
||||||
|
<option value="cinema">Cinema Queue</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Bad Ideas">
|
||||||
|
<option value="hotdog">Hot Dog Stand</option>
|
||||||
|
<option value="spreadsheet">Bad Spreadsheet</option>
|
||||||
|
<option value="myspace">MySpace Accident</option>
|
||||||
|
<option value="sideways">Sideways Doomscroll</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<select id="visualizer-select" title="Visualizer">
|
||||||
|
<option value="off">Visualizer off</option>
|
||||||
|
<option value="bars">Bars</option>
|
||||||
|
<option value="wave">Waveform</option>
|
||||||
|
<option value="radial">Radial</option>
|
||||||
|
<option value="pulse">Pulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile tab bar -->
|
<!-- Mobile tab bar -->
|
||||||
<div id="mobile-tabs">
|
<div id="mobile-tabs">
|
||||||
<button class="mobile-tab active" data-panel="channels-panel">Channels</button>
|
<button class="mobile-tab active" data-panel="channels-panel">Channels</button>
|
||||||
<button class="mobile-tab" data-panel="library-panel">Library</button>
|
|
||||||
<button class="mobile-tab" data-panel="queue-panel">Queue</button>
|
<button class="mobile-tab" data-panel="queue-panel">Queue</button>
|
||||||
|
<button class="mobile-tab" data-panel="library-panel">Library</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
|
|
@ -78,6 +108,17 @@
|
||||||
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
|
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div id="scan-progress" class="scan-progress hidden"></div>
|
<div id="scan-progress" class="scan-progress hidden"></div>
|
||||||
|
<div id="recent-library-section" class="library-section">
|
||||||
|
<div class="library-section-header">
|
||||||
|
<h4>Recently added</h4>
|
||||||
|
<span id="recent-library-count"></span>
|
||||||
|
</div>
|
||||||
|
<div id="recent-library"></div>
|
||||||
|
</div>
|
||||||
|
<div class="library-section-header library-all-header">
|
||||||
|
<h4>All songs</h4>
|
||||||
|
<span id="library-count"></span>
|
||||||
|
</div>
|
||||||
<div id="library"></div>
|
<div id="library"></div>
|
||||||
<div id="add-panel" class="add-panel hidden">
|
<div id="add-panel" class="add-panel hidden">
|
||||||
<button id="btn-add-close" class="add-panel-close">Close</button>
|
<button id="btn-add-close" class="add-panel-close">Close</button>
|
||||||
|
|
@ -139,6 +180,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="visualizer-shell" class="visualizer-shell hidden">
|
||||||
|
<canvas id="visualizer-canvas" aria-hidden="true"></canvas>
|
||||||
|
<button id="btn-visualizer-fullscreen" class="visualizer-fullscreen-btn" title="Fullscreen visualizer" aria-label="Fullscreen visualizer">⛶</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="player-bar">
|
<div id="player-bar">
|
||||||
<div id="now-playing">
|
<div id="now-playing">
|
||||||
<div id="channel-name"></div>
|
<div id="channel-name"></div>
|
||||||
|
|
@ -161,6 +207,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="volume-controls">
|
<div id="volume-controls">
|
||||||
<span id="btn-stream-only" title="Toggle stream-only mode (no caching)">stream</span>
|
<span id="btn-stream-only" title="Toggle stream-only mode (no caching)">stream</span>
|
||||||
|
<div id="replaygain-wrap">
|
||||||
|
<span id="btn-replaygain" title="ReplayGain loudness normalization">rg</span>
|
||||||
|
<div id="replaygain-popover" class="hidden">
|
||||||
|
<div class="rg-row">
|
||||||
|
<span>Normalize</span>
|
||||||
|
<input type="checkbox" id="rg-enabled">
|
||||||
|
</div>
|
||||||
|
<div class="rg-row">
|
||||||
|
<span>Pre-amp</span>
|
||||||
|
<input type="range" id="rg-preamp">
|
||||||
|
<span id="rg-preamp-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span id="btn-mute" title="Toggle mute">🔊</span>
|
<span id="btn-mute" title="Toggle mute">🔊</span>
|
||||||
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
|
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,7 +239,10 @@
|
||||||
</div>
|
</div>
|
||||||
<script src="/trackStorage.js"></script>
|
<script src="/trackStorage.js"></script>
|
||||||
<script src="/core.js"></script>
|
<script src="/core.js"></script>
|
||||||
|
<script src="/themes.js"></script>
|
||||||
<script src="/utils.js"></script>
|
<script src="/utils.js"></script>
|
||||||
|
<script src="/visualizer.js"></script>
|
||||||
|
<script src="/replayGain.js"></script>
|
||||||
<script src="/trackComponent.js"></script>
|
<script src="/trackComponent.js"></script>
|
||||||
<script src="/trackContainer.js"></script>
|
<script src="/trackContainer.js"></script>
|
||||||
<script src="/audioCache.js"></script>
|
<script src="/audioCache.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@
|
||||||
// Set up and play track
|
// Set up and play track
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
M.setTrackTitle(track.title || track.filename);
|
M.setTrackTitle(M.trackComponent.getTitle(track));
|
||||||
|
M.applyReplayGain && M.applyReplayGain(track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
|
|
||||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||||
|
|
@ -117,11 +118,7 @@
|
||||||
|
|
||||||
// Update UI based on server status
|
// Update UI based on server status
|
||||||
function updateFeatureVisibility() {
|
function updateFeatureVisibility() {
|
||||||
const fetchBtn = M.$("#btn-fetch-url");
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
if (fetchBtn) {
|
|
||||||
const ytdlpEnabled = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
|
|
||||||
fetchBtn.style.display = ytdlpEnabled ? "" : "none";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the application
|
// Initialize the application
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
const myContainer = $('#my-playlists');
|
const myContainer = $('#my-playlists');
|
||||||
const sharedContainer = $('#shared-playlists');
|
const sharedContainer = $('#shared-playlists');
|
||||||
if (!myContainer || !sharedContainer) return;
|
if (!myContainer || !sharedContainer) return;
|
||||||
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
|
|
||||||
// My playlists
|
// My playlists
|
||||||
if (myPlaylists.length === 0) {
|
if (myPlaylists.length === 0) {
|
||||||
|
|
@ -40,7 +41,7 @@
|
||||||
} else {
|
} else {
|
||||||
myContainer.innerHTML = myPlaylists.map(p => `
|
myContainer.innerHTML = myPlaylists.map(p => `
|
||||||
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
||||||
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
<span class="playlist-name">${M.escapeHtml(p.name)}</span>
|
||||||
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
|
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
|
||||||
<span class="playlist-count">${p.trackIds.length}</span>
|
<span class="playlist-count">${p.trackIds.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,8 +54,8 @@
|
||||||
} else {
|
} else {
|
||||||
sharedContainer.innerHTML = sharedPlaylists.map(p => `
|
sharedContainer.innerHTML = sharedPlaylists.map(p => `
|
||||||
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
|
||||||
<span class="playlist-name">${escapeHtml(p.name)}</span>
|
<span class="playlist-name">${M.escapeHtml(p.name)}</span>
|
||||||
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span>
|
<span class="playlist-owner">by ${M.escapeHtml(p.ownerName || 'Unknown')}</span>
|
||||||
<span class="playlist-count">${p.trackIds.length}</span>
|
<span class="playlist-count">${p.trackIds.length}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
@ -107,7 +108,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
header.textContent = selectedPlaylist.name;
|
header.textContent = selectedPlaylist.name;
|
||||||
actions.classList.remove('hidden');
|
actions.classList.toggle('hidden', !M.canControl());
|
||||||
|
|
||||||
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
|
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
|
||||||
|
|
||||||
|
|
@ -144,18 +145,23 @@
|
||||||
if (!playlist) return;
|
if (!playlist) return;
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
const canEditQueue = M.canControl();
|
||||||
|
|
||||||
// Add to queue options
|
// Add to queue options
|
||||||
items.push({
|
if (canEditQueue) {
|
||||||
label: '▶ Add to Queue',
|
items.push({
|
||||||
action: () => addPlaylistToQueue(playlistId)
|
label: '▶ Add to Queue',
|
||||||
});
|
action: () => addPlaylistToQueue(playlistId)
|
||||||
items.push({
|
});
|
||||||
label: '⏭ Play Next',
|
items.push({
|
||||||
action: () => addPlaylistToQueue(playlistId, true)
|
label: '⏭ Play Next',
|
||||||
});
|
action: () => addPlaylistToQueue(playlistId, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
items.push({ separator: true });
|
if (canEditQueue && (isMine || M.canCreateUserContent())) {
|
||||||
|
items.push({ separator: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (isMine) {
|
if (isMine) {
|
||||||
// Rename
|
// Rename
|
||||||
|
|
@ -183,9 +189,9 @@
|
||||||
items.push({
|
items.push({
|
||||||
label: '🗑️ Delete',
|
label: '🗑️ Delete',
|
||||||
action: () => deletePlaylist(playlistId),
|
action: () => deletePlaylist(playlistId),
|
||||||
className: 'danger'
|
danger: true
|
||||||
});
|
});
|
||||||
} else {
|
} else if (M.canCreateUserContent()) {
|
||||||
// Copy to my playlists
|
// Copy to my playlists
|
||||||
items.push({
|
items.push({
|
||||||
label: '📋 Copy to My Playlists',
|
label: '📋 Copy to My Playlists',
|
||||||
|
|
@ -193,10 +199,20 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (items[0]?.separator) items.shift();
|
||||||
|
while (items[items.length - 1]?.separator) items.pop();
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
M.contextMenu.show(e, items);
|
M.contextMenu.show(e, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addPlaylistToQueue(playlistId, playNext = false) {
|
async function addPlaylistToQueue(playlistId, playNext = false) {
|
||||||
|
if (!M.canControl()) {
|
||||||
|
M.flashPermissionDenied();
|
||||||
|
showToast('Sign in to control playback', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
|
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
|
||||||
if (!playlist || playlist.trackIds.length === 0) {
|
if (!playlist || playlist.trackIds.length === 0) {
|
||||||
showToast('Playlist is empty', 'error');
|
showToast('Playlist is empty', 'error');
|
||||||
|
|
@ -273,21 +289,23 @@
|
||||||
const playlist = myPlaylists.find(p => p.id === playlistId);
|
const playlist = myPlaylists.find(p => p.id === playlistId);
|
||||||
if (!playlist) return;
|
if (!playlist) return;
|
||||||
|
|
||||||
try {
|
M.showConfirmToast(`Delete playlist "${playlist.name}"?`, async () => {
|
||||||
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
|
try {
|
||||||
if (!res.ok) throw new Error('Failed to delete playlist');
|
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
|
||||||
showToast(`Deleted playlist "${playlist.name}"`);
|
if (!res.ok) throw new Error('Failed to delete playlist');
|
||||||
|
showToast(`Deleted playlist "${playlist.name}"`);
|
||||||
if (selectedPlaylistId === playlistId) {
|
|
||||||
selectedPlaylistId = null;
|
if (selectedPlaylistId === playlistId) {
|
||||||
selectedPlaylist = null;
|
selectedPlaylistId = null;
|
||||||
renderPlaylistContents();
|
selectedPlaylist = null;
|
||||||
|
renderPlaylistContents();
|
||||||
|
}
|
||||||
|
await loadPlaylists();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete playlist:', err);
|
||||||
|
showToast('Failed to delete playlist', 'error');
|
||||||
}
|
}
|
||||||
await loadPlaylists();
|
}, { confirmText: 'Delete', duration: 15000 });
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete playlist:', err);
|
|
||||||
showToast('Failed to delete playlist', 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRenamePlaylist(playlistId) {
|
function startRenamePlaylist(playlistId) {
|
||||||
|
|
@ -448,6 +466,7 @@
|
||||||
|
|
||||||
// Show "Add to Playlist" submenu
|
// Show "Add to Playlist" submenu
|
||||||
function showAddToPlaylistMenu(trackIds) {
|
function showAddToPlaylistMenu(trackIds) {
|
||||||
|
if (!M.canCreateUserContent()) return null;
|
||||||
if (myPlaylists.length === 0) {
|
if (myPlaylists.length === 0) {
|
||||||
showToast('Create a playlist first', 'info');
|
showToast('Create a playlist first', 'info');
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -459,18 +478,16 @@
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str.replace(/[&<>"']/g, c => ({
|
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
||||||
}[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPlaylists() {
|
function initPlaylists() {
|
||||||
// New playlist button
|
// New playlist button
|
||||||
const btnNew = $('#btn-new-playlist');
|
const btnNew = $('#btn-new-playlist');
|
||||||
if (btnNew) {
|
if (btnNew) {
|
||||||
btnNew.onclick = () => {
|
btnNew.onclick = () => {
|
||||||
|
if (!M.canCreateUserContent()) {
|
||||||
|
showToast('Sign in to create playlists', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Inline input for new playlist name
|
// Inline input for new playlist name
|
||||||
const container = $('#my-playlists');
|
const container = $('#my-playlists');
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
|
|
|
||||||
112
public/queue.js
112
public/queue.js
|
|
@ -12,9 +12,12 @@
|
||||||
// Container instances
|
// Container instances
|
||||||
let queueContainer = null;
|
let queueContainer = null;
|
||||||
let libraryContainer = null;
|
let libraryContainer = null;
|
||||||
|
let recentLibraryContainer = null;
|
||||||
|
|
||||||
// Library search state
|
// Library search state
|
||||||
M.librarySearchQuery = "";
|
M.librarySearchQuery = "";
|
||||||
|
const RECENT_LIBRARY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const RECENT_LIBRARY_LIMIT = 50;
|
||||||
|
|
||||||
// Download a track to user's device (uses cache if available)
|
// Download a track to user's device (uses cache if available)
|
||||||
async function downloadTrack(trackId, filename) {
|
async function downloadTrack(trackId, filename) {
|
||||||
|
|
@ -190,9 +193,70 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize containers
|
// Initialize containers
|
||||||
|
function trackMatchesLibrarySearch(track) {
|
||||||
|
const query = M.librarySearchQuery.toLowerCase();
|
||||||
|
if (!query) return true;
|
||||||
|
const title = track.title?.trim() || track.filename || "";
|
||||||
|
const artist = track.artist || "";
|
||||||
|
const album = track.album || "";
|
||||||
|
return [title, artist, album, track.filename || ""]
|
||||||
|
.some(value => value.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackCreatedMs(track) {
|
||||||
|
const value = Number(track.createdAt ?? track.created_at ?? 0);
|
||||||
|
if (!value || !Number.isFinite(value)) return 0;
|
||||||
|
return value > 1000000000000 ? value : value * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecentlyAdded(track) {
|
||||||
|
const createdMs = getTrackCreatedMs(track);
|
||||||
|
if (!createdMs) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
return createdMs >= now - RECENT_LIBRARY_WINDOW_MS && createdMs <= now + 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLibraryRows() {
|
||||||
|
return M.library
|
||||||
|
.map((track, i) => ({ track, originalIndex: i }))
|
||||||
|
.filter(({ track }) => trackMatchesLibrarySearch(track));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecentLibraryRows(limit = true) {
|
||||||
|
const rows = M.library
|
||||||
|
.map((track, i) => ({ track, originalIndex: i }))
|
||||||
|
.filter(({ track }) => isRecentlyAdded(track) && trackMatchesLibrarySearch(track))
|
||||||
|
.sort((a, b) => getTrackCreatedMs(b.track) - getTrackCreatedMs(a.track));
|
||||||
|
return limit ? rows.slice(0, RECENT_LIBRARY_LIMIT) : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLibrarySectionLabels() {
|
||||||
|
const recentCount = getRecentLibraryRows(false).length;
|
||||||
|
const visibleRecentCount = Math.min(recentCount, RECENT_LIBRARY_LIMIT);
|
||||||
|
const libraryCount = getLibraryRows().length;
|
||||||
|
const recentCountEl = M.$("#recent-library-count");
|
||||||
|
const libraryCountEl = M.$("#library-count");
|
||||||
|
const hasQuery = M.librarySearchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
if (recentCountEl) {
|
||||||
|
const baseText = hasQuery
|
||||||
|
? `${recentCount} match${recentCount === 1 ? "" : "es"} from 7 days`
|
||||||
|
: `${recentCount} from 7 days`;
|
||||||
|
recentCountEl.textContent = recentCount > visibleRecentCount
|
||||||
|
? `${baseText} · newest ${visibleRecentCount}`
|
||||||
|
: baseText;
|
||||||
|
}
|
||||||
|
if (libraryCountEl) {
|
||||||
|
libraryCountEl.textContent = hasQuery
|
||||||
|
? `${libraryCount} match${libraryCount === 1 ? "" : "es"}`
|
||||||
|
: `${libraryCount} total`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initContainers() {
|
function initContainers() {
|
||||||
const queueEl = M.$("#queue");
|
const queueEl = M.$("#queue");
|
||||||
const libraryEl = M.$("#library");
|
const libraryEl = M.$("#library");
|
||||||
|
const recentLibraryEl = M.$("#recent-library");
|
||||||
|
|
||||||
if (queueEl && !queueContainer) {
|
if (queueEl && !queueContainer) {
|
||||||
queueContainer = M.trackContainer.createContainer({
|
queueContainer = M.trackContainer.createContainer({
|
||||||
|
|
@ -209,18 +273,18 @@
|
||||||
type: 'library',
|
type: 'library',
|
||||||
element: libraryEl,
|
element: libraryEl,
|
||||||
getTracks: () => M.library,
|
getTracks: () => M.library,
|
||||||
getFilteredTracks: () => {
|
getFilteredTracks: getLibraryRows,
|
||||||
const query = M.librarySearchQuery.toLowerCase();
|
emptyMessage: "No library matches"
|
||||||
if (!query) {
|
});
|
||||||
return M.library.map((track, i) => ({ track, originalIndex: i }));
|
}
|
||||||
}
|
|
||||||
return M.library
|
if (recentLibraryEl && !recentLibraryContainer) {
|
||||||
.map((track, i) => ({ track, originalIndex: i }))
|
recentLibraryContainer = M.trackContainer.createContainer({
|
||||||
.filter(({ track }) => {
|
type: 'library',
|
||||||
const title = track.title?.trim() || track.filename || '';
|
element: recentLibraryEl,
|
||||||
return title.toLowerCase().includes(query);
|
getTracks: () => M.library,
|
||||||
});
|
getFilteredTracks: getRecentLibraryRows,
|
||||||
}
|
emptyMessage: "No songs added in the last 7 days"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -254,6 +318,10 @@
|
||||||
|
|
||||||
M.renderLibrary = function() {
|
M.renderLibrary = function() {
|
||||||
initContainers();
|
initContainers();
|
||||||
|
updateLibrarySectionLabels();
|
||||||
|
if (recentLibraryContainer) {
|
||||||
|
recentLibraryContainer.render();
|
||||||
|
}
|
||||||
if (libraryContainer) {
|
if (libraryContainer) {
|
||||||
libraryContainer.render();
|
libraryContainer.render();
|
||||||
}
|
}
|
||||||
|
|
@ -270,8 +338,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
const title = M.trackComponent.getTitle(track);
|
||||||
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`;
|
bar.innerHTML = `<span class="label">Now playing:</span> ${M.escapeHtml(title)}`;
|
||||||
bar.title = title;
|
bar.title = title;
|
||||||
bar.classList.remove("hidden");
|
bar.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
|
|
@ -282,7 +350,21 @@
|
||||||
|
|
||||||
const activeTrack = container.querySelector(".track.active");
|
const activeTrack = container.querySelector(".track.active");
|
||||||
if (activeTrack) {
|
if (activeTrack) {
|
||||||
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const trackRect = activeTrack.getBoundingClientRect();
|
||||||
|
const trackTop = trackRect.top - containerRect.top + container.scrollTop;
|
||||||
|
const targetTop = trackTop - (container.clientHeight - activeTrack.offsetHeight) / 2;
|
||||||
|
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
top: Math.min(Math.max(0, targetTop), maxTop),
|
||||||
|
behavior: "smooth"
|
||||||
|
});
|
||||||
|
container.scrollLeft = 0;
|
||||||
|
|
||||||
|
if (window.scrollX !== 0) {
|
||||||
|
window.scrollTo({ left: 0, top: window.scrollY, behavior: "auto" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
// MusicRoom - ReplayGain module
|
||||||
|
// Applies per-track loudness normalization using server-provided rsgain metadata.
|
||||||
|
// Gain is applied client-side via the shared Web Audio gain node (M.gainNode),
|
||||||
|
// so it only affects this listener's playback. Preferences are persisted
|
||||||
|
// per-account via /api/auth/me/preferences (localStorage fallback).
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
const LS_ENABLED = "blastoise_replaygain_enabled";
|
||||||
|
const LS_PREAMP = "blastoise_replaygain_preamp";
|
||||||
|
const PREAMP_MIN = -12;
|
||||||
|
const PREAMP_MAX = 12;
|
||||||
|
const PREAMP_STEP = 0.5;
|
||||||
|
const MAX_LINEAR = 10; // +20 dB hard cap to avoid extreme boosts
|
||||||
|
|
||||||
|
M.replayGain = {
|
||||||
|
enabled: localStorage.getItem(LS_ENABLED) !== "false", // default true
|
||||||
|
preampDb: clampPreamp(Number.parseFloat(localStorage.getItem(LS_PREAMP)) || 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
M.currentTrack = null;
|
||||||
|
|
||||||
|
function clampPreamp(v) {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
|
return Math.max(PREAMP_MIN, Math.min(PREAMP_MAX, Math.round(v / PREAMP_STEP) * PREAMP_STEP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// linear gain factor for a track, honoring enable flag, preamp, and peak clipping
|
||||||
|
function computeLinearGain(track) {
|
||||||
|
if (!M.replayGain.enabled) return 1.0;
|
||||||
|
const preamp = M.replayGain.preampDb || 0;
|
||||||
|
let gainDb = preamp;
|
||||||
|
const rgDb = track ? track.replayGainDb : null;
|
||||||
|
if (Number.isFinite(rgDb)) gainDb += rgDb;
|
||||||
|
let linear = Math.pow(10, gainDb / 20);
|
||||||
|
const peak = track ? track.replayPeak : null;
|
||||||
|
if (Number.isFinite(peak) && peak > 0 && peak * linear > 1) {
|
||||||
|
linear = 1 / peak;
|
||||||
|
}
|
||||||
|
return Math.min(linear, MAX_LINEAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGain(node, linear) {
|
||||||
|
const ctx = node.context;
|
||||||
|
if (ctx.state === "running") {
|
||||||
|
const t = ctx.currentTime;
|
||||||
|
node.gain.cancelScheduledValues(t);
|
||||||
|
node.gain.setTargetAtTime(linear, t, 0.02); // ~20ms ramp, avoids clicks
|
||||||
|
} else {
|
||||||
|
node.gain.value = linear; // context suspended; apply directly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply gain for the current (or given) track. Safe to call before the graph
|
||||||
|
// exists — the value is re-applied when the graph initializes.
|
||||||
|
M.applyReplayGain = function(track) {
|
||||||
|
if (track) M.currentTrack = track;
|
||||||
|
const t = M.currentTrack;
|
||||||
|
const hasData = Number.isFinite(t && t.replayGainDb) || Number.isFinite(t && t.replayPeak);
|
||||||
|
// Only build the audio graph when there's actually something to apply (or it
|
||||||
|
// already exists), preserving default behavior for libraries without RG.
|
||||||
|
if (M.replayGain.enabled && (hasData || M.gainNode || M.replayGain.preampDb)) {
|
||||||
|
M.ensureAudioGraph && M.ensureAudioGraph();
|
||||||
|
}
|
||||||
|
const node = M.gainNode;
|
||||||
|
if (!node) return;
|
||||||
|
setGain(node, computeLinearGain(t));
|
||||||
|
};
|
||||||
|
|
||||||
|
M.setReplayGainEnabled = function(enabled) {
|
||||||
|
M.replayGain.enabled = !!enabled;
|
||||||
|
localStorage.setItem(LS_ENABLED, M.replayGain.enabled ? "true" : "false");
|
||||||
|
M.applyReplayGain();
|
||||||
|
M.updateReplayGainUI && M.updateReplayGainUI();
|
||||||
|
M.saveReplayGainPrefs && M.saveReplayGainPrefs();
|
||||||
|
};
|
||||||
|
|
||||||
|
M.setReplayGainPreamp = function(db) {
|
||||||
|
M.replayGain.preampDb = clampPreamp(db);
|
||||||
|
localStorage.setItem(LS_PREAMP, String(M.replayGain.preampDb));
|
||||||
|
M.applyReplayGain();
|
||||||
|
M.updateReplayGainUI && M.updateReplayGainUI();
|
||||||
|
M.saveReplayGainPrefs && M.saveReplayGainPrefs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced persistence to the server (per-account). localStorage is the
|
||||||
|
// immediate fallback for guests/offline.
|
||||||
|
let saveTimer = null;
|
||||||
|
M.saveReplayGainPrefs = function() {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = null;
|
||||||
|
const body = {
|
||||||
|
replaygain_enabled: M.replayGain.enabled ? "true" : "false",
|
||||||
|
replaygain_preamp: String(M.replayGain.preampDb),
|
||||||
|
};
|
||||||
|
fetch("/api/auth/me/preferences", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hydrate from the /api/auth/me preferences object (called by auth.js).
|
||||||
|
M.loadReplayGainPrefs = function(prefs) {
|
||||||
|
if (!prefs) return;
|
||||||
|
if (prefs.replaygain_enabled != null) {
|
||||||
|
M.replayGain.enabled = prefs.replaygain_enabled === "true";
|
||||||
|
localStorage.setItem(LS_ENABLED, M.replayGain.enabled ? "true" : "false");
|
||||||
|
}
|
||||||
|
if (prefs.replaygain_preamp != null) {
|
||||||
|
const v = Number.parseFloat(prefs.replaygain_preamp);
|
||||||
|
if (Number.isFinite(v)) {
|
||||||
|
M.replayGain.preampDb = clampPreamp(v);
|
||||||
|
localStorage.setItem(LS_PREAMP, String(M.replayGain.preampDb));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
M.applyReplayGain();
|
||||||
|
M.updateReplayGainUI && M.updateReplayGainUI();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- UI ----
|
||||||
|
function initReplayGainUI() {
|
||||||
|
const btn = M.$("#btn-replaygain");
|
||||||
|
const popover = M.$("#replaygain-popover");
|
||||||
|
const enabledCheckbox = M.$("#rg-enabled");
|
||||||
|
const preampSlider = M.$("#rg-preamp");
|
||||||
|
const preampValue = M.$("#rg-preamp-value");
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!popover) return;
|
||||||
|
popover.classList.remove("hidden");
|
||||||
|
document.addEventListener("pointerdown", onOutside, true);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (!popover) return;
|
||||||
|
popover.classList.add("hidden");
|
||||||
|
document.removeEventListener("pointerdown", onOutside, true);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
if (popover && popover.classList.contains("hidden")) open();
|
||||||
|
else close();
|
||||||
|
}
|
||||||
|
function onOutside(e) {
|
||||||
|
if (popover && !popover.contains(e.target) && e.target !== btn) close();
|
||||||
|
}
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (enabledCheckbox) {
|
||||||
|
enabledCheckbox.onchange = () => M.setReplayGainEnabled(enabledCheckbox.checked);
|
||||||
|
}
|
||||||
|
if (preampSlider) {
|
||||||
|
preampSlider.min = String(PREAMP_MIN);
|
||||||
|
preampSlider.max = String(PREAMP_MAX);
|
||||||
|
preampSlider.step = String(PREAMP_STEP);
|
||||||
|
preampSlider.oninput = () => M.setReplayGainPreamp(Number.parseFloat(preampSlider.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
M.updateReplayGainUI = function() {
|
||||||
|
if (btn) btn.classList.toggle("active", M.replayGain.enabled);
|
||||||
|
if (enabledCheckbox) enabledCheckbox.checked = M.replayGain.enabled;
|
||||||
|
if (preampSlider) preampSlider.value = String(M.replayGain.preampDb);
|
||||||
|
if (preampValue) {
|
||||||
|
const db = M.replayGain.preampDb;
|
||||||
|
preampValue.textContent = (db > 0 ? "+" : "") + (Number.isInteger(db) ? db : db.toFixed(1)) + " dB";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.updateReplayGainUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", initReplayGainUI);
|
||||||
|
})();
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { 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; }
|
html { width: 100%; max-width: 100%; overflow-x: hidden; overscroll-behavior-x: none; }
|
||||||
#app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; }
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; width: 100%; max-width: 100%; min-height: 100vh; overflow-x: hidden; }
|
||||||
h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
|
#app { width: 100%; max-width: 1700px; min-width: 0; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; overflow-x: hidden; }
|
||||||
h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
h1 { font-size: 1rem; color: #aaa; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
h3 { font-size: 0.8rem; color: #999; 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 { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; }
|
||||||
#sync-indicator.visible { display: inline-block; }
|
#sync-indicator.visible { display: inline-block; }
|
||||||
#sync-indicator.disconnected { background: #e44; }
|
#sync-indicator.disconnected { background: #e44; }
|
||||||
|
|
@ -26,8 +27,8 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; }
|
#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 - 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; }
|
#main-content { display: flex; gap: 0.5rem; flex: 1; width: 100%; min-width: 0; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; overflow-x: hidden; }
|
||||||
#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-panel { order: 1; flex: 0 0 160px; min-width: 0; 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 { 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 { 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-item.active { background: #2a4a3a; color: #4e8; }
|
||||||
|
|
@ -35,7 +36,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#channels-list .channel-header:hover { background: #222; }
|
#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 .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
|
||||||
#channels-list .channel-name-input { flex: 1; font-size: 0.8rem; background: #111; color: #eee; border: 1px solid #4e8; border-radius: 3px; padding: 0.1rem 0.3rem; outline: none; min-width: 0; }
|
#channels-list .channel-name-input { flex: 1; font-size: 0.8rem; background: #111; color: #eee; border: 1px solid #4e8; border-radius: 3px; padding: 0.1rem 0.3rem; outline: none; min-width: 0; }
|
||||||
#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; }
|
#channels-list .listener-count { font-size: 0.65rem; color: #aaa; 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 .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 .channel-header:hover .btn-delete-channel { opacity: 1; }
|
||||||
#channels-list .btn-delete-channel:hover { color: #e44; }
|
#channels-list .btn-delete-channel:hover { color: #e44; }
|
||||||
|
|
@ -45,10 +46,12 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; }
|
#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 { 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::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; }
|
#channels-list .listener-mult { color: #999; 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; }
|
#library-panel, #queue-panel { flex: 1 1 0; min-width: 0; max-width: 100%; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
|
||||||
|
#queue-panel { order: 2; }
|
||||||
|
#library-panel { order: 3; }
|
||||||
.panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; }
|
.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 { background: #252525; border: none; color: #999; 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:hover { color: #aaa; background: #2a2a2a; }
|
||||||
.panel-tab.active { color: #eee; background: #222; }
|
.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-views { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; position: relative; background: #222; border-radius: 0 4px 4px 4px; }
|
||||||
|
|
@ -133,7 +136,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.now-playing-bar:hover { background: #2a3a2a; }
|
.now-playing-bar:hover { background: #2a3a2a; }
|
||||||
.now-playing-bar.hidden { display: none; }
|
.now-playing-bar.hidden { display: none; }
|
||||||
.now-playing-bar .label { color: #666; }
|
.now-playing-bar .label { color: #aaa; }
|
||||||
.panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
|
.panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
|
||||||
.panel-header h3 { margin: 0; flex-shrink: 0; }
|
.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 select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
|
||||||
|
|
@ -144,18 +147,26 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.btn-submit-channel:hover { background: #3a5a4a; }
|
.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 { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
|
||||||
.search-input::placeholder { color: #666; }
|
.search-input::placeholder { color: #666; }
|
||||||
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
|
#library, #queue, #recent-library { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
|
||||||
#library .track, #queue .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
|
.library-section { flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0.45rem; }
|
||||||
#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
|
.library-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin: 0.15rem 0 0.25rem; color: #888; flex-shrink: 0; }
|
||||||
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
|
.library-section-header h4 { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #aaa; margin: 0; }
|
||||||
|
.library-section-header span { color: #666; font-size: 0.68rem; white-space: nowrap; }
|
||||||
|
.library-all-header { margin-top: 0.1rem; }
|
||||||
|
#recent-library { flex: 0 1 auto; max-height: min(34vh, 220px); border-bottom: 1px solid #262626; padding-bottom: 0.35rem; }
|
||||||
|
#library .track, #queue .track, #recent-library .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
|
||||||
|
#library .track[title], #queue .track[title], #recent-library .track[title], #playlist-tracks .track[title] { cursor: pointer; }
|
||||||
|
#library .track:hover, #queue .track:hover, #recent-library .track:hover, #playlist-tracks .track:hover { background: #222; }
|
||||||
#queue .track.active { background: #2a4a3a; color: #4e8; }
|
#queue .track.active { background: #2a4a3a; color: #4e8; }
|
||||||
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
|
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
|
||||||
.track.cached .cache-indicator { background: #4e8; }
|
.track.cached .cache-indicator { background: #4e8; }
|
||||||
.track.not-cached .cache-indicator { background: #ea4; }
|
.track.not-cached .cache-indicator { background: #ea4; }
|
||||||
.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; }
|
.track-number { color: #888; 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-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||||
.track-actions .duration { color: #666; font-size: 0.75rem; }
|
.track-actions .duration { color: #999; font-size: 0.75rem; }
|
||||||
|
#queue .track.active .track-number,
|
||||||
|
#queue .track.active .duration { color: #d6e6dc; }
|
||||||
.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
||||||
.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
|
.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
|
||||||
.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
|
||||||
|
|
@ -165,6 +176,10 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
|
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
|
||||||
.track-actions .track-remove { color: #e44; }
|
.track-actions .track-remove { color: #e44; }
|
||||||
.track-actions .track-add { color: #4e4; }
|
.track-actions .track-add { color: #4e4; }
|
||||||
|
.track-actions .track-menu-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; color: #ccc; cursor: pointer; opacity: 0; font-size: 1rem; line-height: 1; padding: 0; transition: opacity 0.2s, background 0.2s; }
|
||||||
|
.track-actions .track-menu-btn::before { content: "..."; }
|
||||||
|
.track:hover .track-menu-btn, .track-menu-btn:focus-visible { opacity: 0.75; }
|
||||||
|
.track-actions .track-menu-btn:hover { opacity: 1; background: #444; }
|
||||||
|
|
||||||
/* Track selection */
|
/* Track selection */
|
||||||
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
|
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
|
||||||
|
|
@ -187,14 +202,32 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
.context-menu-item.danger:hover { background: #3a2a2a; }
|
.context-menu-item.danger:hover { background: #3a2a2a; }
|
||||||
|
|
||||||
/* Player bar */
|
/* Player bar */
|
||||||
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; }
|
.visualizer-shell { --visualizer-bg: rgba(10, 14, 12, 0.9); --visualizer-accent: #44ee88; --visualizer-secondary: #66aaff; --visualizer-muted: rgba(255, 255, 255, 0.16); position: relative; isolation: isolate; width: 100%; max-width: 1600px; height: 118px; margin: 0 auto 0.5rem auto; background: #121816; border: 1px solid #26382f; border-radius: 6px; overflow: hidden; min-width: 0; flex: 0 0 auto; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03), 0 12px 30px rgba(0,0,0,0.18); }
|
||||||
#now-playing { width: 180px; flex-shrink: 0; }
|
#visualizer-canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.visualizer-fullscreen-btn { position: absolute; top: 0.45rem; right: 0.45rem; z-index: 2; width: 30px; height: 30px; padding: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.42); border: 1px solid rgba(255,255,255,0.24); border-radius: 4px; color: #fff; font-size: 1rem; line-height: 1; opacity: 0; transition: opacity 0.15s, background 0.15s, transform 0.15s; backdrop-filter: blur(4px); }
|
||||||
|
.visualizer-shell:hover .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:focus-within .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:fullscreen .visualizer-fullscreen-btn { opacity: 0.86; }
|
||||||
|
.visualizer-fullscreen-btn:hover { background: rgba(0,0,0,0.68); transform: scale(1.04); }
|
||||||
|
.visualizer-shell.is-fullscreen,
|
||||||
|
.visualizer-shell:fullscreen,
|
||||||
|
.visualizer-shell:-webkit-full-screen { width: 100vw; height: 100vh; max-width: none; margin: 0; border: 0; border-radius: 0; background: #050505; box-shadow: none; }
|
||||||
|
.visualizer-shell.is-fullscreen { position: fixed; inset: 0; z-index: 3000; }
|
||||||
|
.visualizer-shell.is-fullscreen #visualizer-canvas,
|
||||||
|
.visualizer-shell:fullscreen #visualizer-canvas,
|
||||||
|
.visualizer-shell:-webkit-full-screen #visualizer-canvas { width: 100vw; height: 100vh; }
|
||||||
|
.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:fullscreen .visualizer-fullscreen-btn,
|
||||||
|
.visualizer-shell:-webkit-full-screen .visualizer-fullscreen-btn { top: 1rem; right: 1rem; width: 40px; height: 40px; font-size: 1.4rem; }
|
||||||
|
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; min-width: 0; max-width: 100%; overflow-x: hidden; }
|
||||||
|
#now-playing { width: 180px; flex: 0 1 180px; min-width: 0; }
|
||||||
#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; }
|
#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 { font-size: 0.9rem; font-weight: 600; overflow: hidden; position: relative; min-width: 0; }
|
||||||
#track-name .marquee-inner { display: inline-block; white-space: nowrap; }
|
#track-name .marquee-inner { display: inline-block; white-space: nowrap; }
|
||||||
#track-name.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; }
|
#track-name.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; }
|
||||||
@keyframes scroll-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
@keyframes scroll-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
||||||
#player-controls { flex: 1; }
|
#player-controls { flex: 1 1 auto; min-width: 0; }
|
||||||
#progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; }
|
#progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; }
|
||||||
#progress-row.denied { animation: flash-red 0.5s ease-out; }
|
#progress-row.denied { animation: flash-red 0.5s ease-out; }
|
||||||
@keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } }
|
@keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } }
|
||||||
|
|
@ -211,20 +244,22 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; }
|
#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; }
|
||||||
#btn-mode:hover { opacity: 0.8; }
|
#btn-mode:hover { opacity: 0.8; }
|
||||||
#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; }
|
#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; }
|
#status-icon.playback-blocked { color: #eb0; text-shadow: 0 0 8px rgba(238, 187, 0, 0.65); }
|
||||||
|
.control-disabled { opacity: 0.45 !important; cursor: not-allowed !important; }
|
||||||
|
#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; min-width: 0; }
|
||||||
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; }
|
#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.synced { background: #4e8; }
|
||||||
#progress-bar.playing.local { background: #c4f; }
|
#progress-bar.playing.local { background: #c4f; }
|
||||||
#progress-bar.muted { background: #555 !important; }
|
#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%); }
|
#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; }
|
#time { font-size: 0.75rem; color: #aaa; margin: 0; line-height: 1; white-space: nowrap; }
|
||||||
#buffer-bar { display: flex; gap: 1px; margin-bottom: 0.2rem; }
|
#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 { flex: 1; height: 2px; background: #333; border-radius: 1px; }
|
||||||
#buffer-bar .segment.available { background: #396; }
|
#buffer-bar .segment.available { background: #396; }
|
||||||
#buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; }
|
#buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; }
|
||||||
@keyframes throb { from { background: #444; } to { background: #888; } }
|
@keyframes throb { from { background: #444; } to { background: #888; } }
|
||||||
#download-speed { font-size: 0.6rem; color: #555; text-align: right; }
|
#download-speed { font-size: 0.6rem; color: #888; text-align: right; }
|
||||||
#volume-controls { display: flex; gap: 0.4rem; align-items: center; }
|
#volume-controls { display: flex; gap: 0.4rem; align-items: center; min-width: 0; }
|
||||||
#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 { 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:hover { color: #888; }
|
||||||
#btn-stream-only.active { color: #4af; text-shadow: 0 0 6px #4af; }
|
#btn-stream-only.active { color: #4af; text-shadow: 0 0 6px #4af; }
|
||||||
|
|
@ -232,11 +267,39 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
|
||||||
#btn-mute:hover { opacity: 1; }
|
#btn-mute:hover { opacity: 1; }
|
||||||
#volume { width: 120px; accent-color: #4e8; }
|
#volume { width: 120px; accent-color: #4e8; }
|
||||||
|
|
||||||
|
/* ReplayGain control */
|
||||||
|
#replaygain-wrap { position: relative; }
|
||||||
|
#btn-replaygain { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; user-select: none; }
|
||||||
|
#btn-replaygain:hover { color: #888; }
|
||||||
|
#btn-replaygain.active { color: #4e8; text-shadow: 0 0 6px #4e8; }
|
||||||
|
#replaygain-popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 0.5rem);
|
||||||
|
right: 0;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 210px;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.rg-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||||
|
.rg-row > span:first-child { color: #aaa; }
|
||||||
|
#rg-preamp { width: 90px; accent-color: #4e8; }
|
||||||
|
#rg-preamp-value { font-size: 0.7rem; color: #888; min-width: 3rem; text-align: right; }
|
||||||
|
#rg-enabled { accent-color: #4e8; width: 16px; height: 16px; }
|
||||||
|
|
||||||
/* Common */
|
/* Common */
|
||||||
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
|
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; }
|
button:hover { background: #333; }
|
||||||
#status { margin-top: 0.3rem; font-size: 0.75rem; color: #666; text-align: center; }
|
.hidden { display: none !important; }
|
||||||
.empty { color: #666; font-style: italic; font-size: 0.85rem; }
|
#status { margin-top: 0.3rem; font-size: 0.75rem; color: #888; text-align: center; }
|
||||||
|
.empty { color: #999; font-style: italic; font-size: 0.85rem; }
|
||||||
|
|
||||||
/* Login panel */
|
/* 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 { 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; }
|
||||||
|
|
@ -258,8 +321,10 @@ button:hover { 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 { 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; }
|
#guest-section .guest-btn:hover { background: #444; }
|
||||||
|
|
||||||
#player-content { display: none; flex-direction: column; flex: 1; }
|
#player-content { display: none; flex-direction: column; flex: 1; min-height: 0; }
|
||||||
#player-content.visible { display: flex; }
|
#player-content.visible { display: flex; }
|
||||||
|
#player-content.visualizer-active #main-content { flex: 0 1 60vh; max-height: 60vh; }
|
||||||
|
#player-content.visualizer-active .visualizer-shell { flex: 1 1 118px; height: auto; min-height: 118px; }
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; }
|
::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; }
|
||||||
|
|
@ -267,10 +332,16 @@ button:hover { background: #333; }
|
||||||
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
||||||
|
|
||||||
/* Toast notifications */
|
/* 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-container { position: fixed; top: 0.5rem; right: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-width: 320px; max-height: 50vh; 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 { 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-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; }
|
||||||
.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; }
|
.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; }
|
||||||
|
.toast-confirm { pointer-events: auto; color: #eee; max-width: 320px; }
|
||||||
|
.toast-confirm-message { display: block; margin-bottom: 0.5rem; line-height: 1.35; }
|
||||||
|
.toast-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
|
||||||
|
.toast-actions button { padding: 0.3rem 0.65rem; font-size: 0.75rem; min-height: 32px; }
|
||||||
|
.toast-action-confirm { background: #4e8; border-color: #4e8; color: #111; font-weight: 600; }
|
||||||
|
.toast-action-cancel { background: #222; border-color: #555; color: #ddd; }
|
||||||
.toast.fade-out { animation: toast-out 0.3s ease-in forwards; }
|
.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-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); } }
|
@keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } }
|
||||||
|
|
@ -330,6 +401,43 @@ button:hover { background: #333; }
|
||||||
/* Mobile tab bar - hidden on desktop */
|
/* Mobile tab bar - hidden on desktop */
|
||||||
#mobile-tabs { display: none; }
|
#mobile-tabs { display: none; }
|
||||||
|
|
||||||
|
/* Medium-width layout: keep the queue closer to the user's hand */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
#main-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channels-panel {
|
||||||
|
flex-basis: 145px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queue-panel {
|
||||||
|
order: 2;
|
||||||
|
flex-grow: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
#library-panel {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#now-playing {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-controls {
|
||||||
|
flex: 999 1 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volume-controls {
|
||||||
|
flex: 0 1 180px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile responsive styles */
|
/* Mobile responsive styles */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
html, body {
|
html, body {
|
||||||
|
|
@ -352,6 +460,7 @@ button:hover { background: #333; }
|
||||||
#site-header { margin-bottom: 0.3rem; flex-shrink: 0; }
|
#site-header { margin-bottom: 0.3rem; flex-shrink: 0; }
|
||||||
#site-header h1 { font-size: 0.9rem; }
|
#site-header h1 { font-size: 0.9rem; }
|
||||||
#btn-report-bug { font-size: 0.7rem; padding: 0.2rem 0.4rem; }
|
#btn-report-bug { font-size: 0.7rem; padding: 0.2rem 0.4rem; }
|
||||||
|
#btn-logout.guest-signin { min-height: 44px; }
|
||||||
|
|
||||||
#header-row { flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.3rem; flex-shrink: 0; }
|
#header-row { flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.3rem; flex-shrink: 0; }
|
||||||
#auth-section .user-info { flex-wrap: wrap; gap: 0.3rem; }
|
#auth-section .user-info { flex-wrap: wrap; gap: 0.3rem; }
|
||||||
|
|
@ -374,7 +483,8 @@ button:hover { background: #333; }
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.5rem;
|
min-height: 44px;
|
||||||
|
padding: 0.65rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
@ -418,6 +528,9 @@ button:hover { background: #333; }
|
||||||
#library-panel .panel-tabs {
|
#library-panel .panel-tabs {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
#library-panel .panel-tab {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
#library-panel .panel-views {
|
#library-panel .panel-views {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
@ -443,6 +556,10 @@ button:hover { background: #333; }
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
#recent-library {
|
||||||
|
max-height: 28vh;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.add-btn {
|
.add-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|
@ -483,6 +600,25 @@ button:hover { background: #333; }
|
||||||
.track-title { font-size: 0.85rem; }
|
.track-title { font-size: 0.85rem; }
|
||||||
|
|
||||||
/* Player bar - stacked layout */
|
/* Player bar - stacked layout */
|
||||||
|
.visualizer-shell {
|
||||||
|
height: 74px;
|
||||||
|
min-height: 74px;
|
||||||
|
max-height: 74px;
|
||||||
|
flex: 0 0 74px;
|
||||||
|
margin: 0 0 0.3rem 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
#player-content.visualizer-active #main-content {
|
||||||
|
flex: 1;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
#player-content.visualizer-active .visualizer-shell {
|
||||||
|
flex: 0 0 74px;
|
||||||
|
height: 74px;
|
||||||
|
min-height: 74px;
|
||||||
|
max-height: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
#player-bar {
|
#player-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
@ -495,6 +631,7 @@ button:hover { background: #333; }
|
||||||
}
|
}
|
||||||
#now-playing {
|
#now-playing {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
@ -504,8 +641,42 @@ button:hover { background: #333; }
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
#player-controls { width: 100%; }
|
#player-controls { width: 100%; flex: 0 0 auto; }
|
||||||
#progress-row { gap: 0.5rem; }
|
#progress-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(42px, auto) 44px 44px 44px minmax(58px, auto);
|
||||||
|
grid-template-areas:
|
||||||
|
"sync prev play next mode"
|
||||||
|
"progress progress progress progress time";
|
||||||
|
gap: 0.25rem 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#btn-sync {
|
||||||
|
grid-area: sync;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#btn-prev { grid-area: prev; }
|
||||||
|
#status-icon { grid-area: play; }
|
||||||
|
#btn-next { grid-area: next; }
|
||||||
|
#btn-mode {
|
||||||
|
grid-area: mode;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#progress-container {
|
||||||
|
grid-area: progress;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
#time {
|
||||||
|
grid-area: time;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
#status-icon {
|
#status-icon {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
|
|
@ -528,11 +699,36 @@ button:hover { background: #333; }
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
}
|
}
|
||||||
#volume-controls {
|
#volume-controls {
|
||||||
justify-content: flex-end;
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
#btn-stream-only { display: none; }
|
#btn-stream-only { display: none; }
|
||||||
#volume-slider { width: 80px; }
|
#btn-mute {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#volume { width: min(160px, 55vw); height: 44px; }
|
||||||
|
#btn-replaygain {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#replaygain-popover { right: auto; left: 50%; transform: translateX(-50%); }
|
||||||
|
.track-actions .track-menu-btn {
|
||||||
|
opacity: 1;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide history button on mobile */
|
/* Hide history button on mobile */
|
||||||
#btn-history { display: none; }
|
#btn-history { display: none; }
|
||||||
|
|
@ -540,12 +736,572 @@ button:hover { background: #333; }
|
||||||
/* Toast positioning */
|
/* Toast positioning */
|
||||||
#toast-container {
|
#toast-container {
|
||||||
top: auto;
|
top: auto;
|
||||||
bottom: 5rem;
|
bottom: calc(9.25rem + env(safe-area-inset-bottom));
|
||||||
left: 0.3rem;
|
left: 0.3rem;
|
||||||
right: 0.3rem;
|
right: 0.3rem;
|
||||||
|
max-width: none;
|
||||||
|
max-height: 30vh;
|
||||||
}
|
}
|
||||||
.toast { max-width: none; }
|
.toast { max-width: none; }
|
||||||
|
|
||||||
/* Login panel */
|
/* Login panel */
|
||||||
#login-panel { padding: 1rem; }
|
#login-panel { padding: 1rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme selector */
|
||||||
|
#stream-select { margin-left: auto; display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
#theme-select { min-width: 170px; }
|
||||||
|
#visualizer-select { min-width: 140px; }
|
||||||
|
|
||||||
|
/* Shared theme surfaces */
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #channels-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #library-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #queue-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .visualizer-shell,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #player-bar,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #login-panel,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .panel-views,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #playlist-contents-header,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .context-menu,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #toast-history {
|
||||||
|
background: var(--panel-bg, #1a1a1a);
|
||||||
|
border-color: var(--panel-border, #333);
|
||||||
|
color: var(--text-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #site-header h1,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) h3,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .panel-tab.active,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #selected-playlist-name {
|
||||||
|
color: var(--heading-color, var(--text-color, #eee));
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) button,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) select,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) input,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .panel-tab,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-menu-btn,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-play-btn,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-preview-btn {
|
||||||
|
background: var(--control-bg, #222);
|
||||||
|
border-color: var(--control-border, #444);
|
||||||
|
color: var(--control-color, var(--text-color, #eee));
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .visualizer-fullscreen-btn {
|
||||||
|
background: rgba(0,0,0,0.48);
|
||||||
|
border-color: rgba(255,255,255,0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track:hover,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .playlist-item:hover,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .context-menu-item:hover {
|
||||||
|
background: var(--hover-bg, #222);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #queue .track.active,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .playlist-item.selected,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .mobile-tab.active,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .now-playing-bar,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .toast {
|
||||||
|
background: var(--active-bg, #2a4a3a);
|
||||||
|
border-color: var(--accent, #4e8);
|
||||||
|
color: var(--active-color, var(--accent, #4e8));
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track.cached .cache-indicator,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #progress-bar.playing.synced,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #buffer-bar .segment.available,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #sync-indicator.visible {
|
||||||
|
background: var(--accent, #4e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .track.not-cached .cache-indicator,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) #btn-sync.synced,
|
||||||
|
body[data-ui-theme]:not([data-ui-theme="default"]) .toast.toast-warning {
|
||||||
|
color: var(--warning, #ea4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="phosphor"] {
|
||||||
|
--panel-bg: #07140f;
|
||||||
|
--panel-border: #1f7a48;
|
||||||
|
--text-color: #caf7d9;
|
||||||
|
--heading-color: #73ff9c;
|
||||||
|
--control-bg: #0b1f17;
|
||||||
|
--control-border: #24965a;
|
||||||
|
--control-color: #dfffe8;
|
||||||
|
--hover-bg: #123321;
|
||||||
|
--active-bg: #123d29;
|
||||||
|
--active-color: #8cffad;
|
||||||
|
--accent: #51f080;
|
||||||
|
--warning: #f5de72;
|
||||||
|
--visualizer-bg: rgba(3, 13, 8, 0.95);
|
||||||
|
--visualizer-accent: #51f080;
|
||||||
|
--visualizer-secondary: #f5de72;
|
||||||
|
--visualizer-muted: rgba(81, 240, 128, 0.18);
|
||||||
|
background: #020806;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="phosphor"] #app { filter: drop-shadow(0 0 8px rgba(81, 240, 128, 0.12)); }
|
||||||
|
body[data-ui-theme="phosphor"] #track-name,
|
||||||
|
body[data-ui-theme="phosphor"] #btn-sync.synced.connected { text-shadow: 0 0 10px rgba(81, 240, 128, 0.55); }
|
||||||
|
|
||||||
|
body[data-ui-theme="vaporwave"] {
|
||||||
|
--panel-bg: #21123a;
|
||||||
|
--panel-border: #ff71ce;
|
||||||
|
--text-color: #f8d7ff;
|
||||||
|
--heading-color: #05ffa1;
|
||||||
|
--control-bg: #351a54;
|
||||||
|
--control-border: #b967ff;
|
||||||
|
--control-color: #ffffff;
|
||||||
|
--hover-bg: #46206d;
|
||||||
|
--active-bg: #2d567a;
|
||||||
|
--active-color: #05ffa1;
|
||||||
|
--accent: #01cdfe;
|
||||||
|
--warning: #fffb96;
|
||||||
|
--visualizer-bg: rgba(25, 9, 45, 0.92);
|
||||||
|
--visualizer-accent: #01cdfe;
|
||||||
|
--visualizer-secondary: #ff71ce;
|
||||||
|
--visualizer-muted: rgba(255, 113, 206, 0.22);
|
||||||
|
background: linear-gradient(135deg, #12091f 0%, #2f1856 46%, #0b4857 100%);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="vaporwave"] #main-content { gap: 0.8rem; }
|
||||||
|
body[data-ui-theme="vaporwave"] #channels-panel,
|
||||||
|
body[data-ui-theme="vaporwave"] #library-panel,
|
||||||
|
body[data-ui-theme="vaporwave"] #queue-panel,
|
||||||
|
body[data-ui-theme="vaporwave"] #player-bar { box-shadow: 0 0 22px rgba(1, 205, 254, 0.2), inset 0 0 0 1px rgba(255, 113, 206, 0.22); }
|
||||||
|
|
||||||
|
body[data-ui-theme="sunrise"] {
|
||||||
|
--panel-bg: #241b2d;
|
||||||
|
--panel-border: #f18f01;
|
||||||
|
--text-color: #fff0d4;
|
||||||
|
--heading-color: #ffcf56;
|
||||||
|
--control-bg: #3a2340;
|
||||||
|
--control-border: #f45d48;
|
||||||
|
--control-color: #fff7e8;
|
||||||
|
--hover-bg: #50314f;
|
||||||
|
--active-bg: #5f342e;
|
||||||
|
--active-color: #ffd166;
|
||||||
|
--accent: #ff9f1c;
|
||||||
|
--warning: #2ec4b6;
|
||||||
|
--visualizer-bg: rgba(36, 20, 43, 0.92);
|
||||||
|
--visualizer-accent: #ff9f1c;
|
||||||
|
--visualizer-secondary: #2ec4b6;
|
||||||
|
--visualizer-muted: rgba(255, 207, 86, 0.18);
|
||||||
|
background: radial-gradient(circle at 20% 0%, #7a2f5c 0, #2b1838 45%, #101018 100%);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="paper"] {
|
||||||
|
--panel-bg: #f4efe3;
|
||||||
|
--panel-border: #3b3327;
|
||||||
|
--text-color: #1d1a17;
|
||||||
|
--heading-color: #0b0b0b;
|
||||||
|
--control-bg: #fffaf0;
|
||||||
|
--control-border: #5a4a36;
|
||||||
|
--control-color: #1d1a17;
|
||||||
|
--hover-bg: #e5d8bd;
|
||||||
|
--active-bg: #d9c69f;
|
||||||
|
--active-color: #1d1a17;
|
||||||
|
--accent: #285f47;
|
||||||
|
--warning: #a75a00;
|
||||||
|
--visualizer-bg: rgba(255, 250, 240, 0.95);
|
||||||
|
--visualizer-accent: #285f47;
|
||||||
|
--visualizer-secondary: #a75a00;
|
||||||
|
--visualizer-muted: rgba(29, 26, 23, 0.16);
|
||||||
|
background: #d7c9aa;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="paper"] #channels-panel,
|
||||||
|
body[data-ui-theme="paper"] #library-panel,
|
||||||
|
body[data-ui-theme="paper"] #queue-panel,
|
||||||
|
body[data-ui-theme="paper"] #player-bar {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
box-shadow: 4px 4px 0 #3b3327;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="contrast"] {
|
||||||
|
--panel-bg: #000;
|
||||||
|
--panel-border: #fff;
|
||||||
|
--text-color: #fff;
|
||||||
|
--heading-color: #ffff00;
|
||||||
|
--control-bg: #000;
|
||||||
|
--control-border: #fff;
|
||||||
|
--control-color: #fff;
|
||||||
|
--hover-bg: #202020;
|
||||||
|
--active-bg: #ffff00;
|
||||||
|
--active-color: #000;
|
||||||
|
--accent: #00ffff;
|
||||||
|
--warning: #ff00ff;
|
||||||
|
--visualizer-bg: #000000;
|
||||||
|
--visualizer-accent: #00ffff;
|
||||||
|
--visualizer-secondary: #ffff00;
|
||||||
|
--visualizer-muted: rgba(255, 255, 255, 0.32);
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="contrast"] #channels-panel,
|
||||||
|
body[data-ui-theme="contrast"] #library-panel,
|
||||||
|
body[data-ui-theme="contrast"] #queue-panel,
|
||||||
|
body[data-ui-theme="contrast"] #player-bar { border: 2px solid #fff; border-radius: 0; }
|
||||||
|
|
||||||
|
body[data-ui-theme="aquarium"] {
|
||||||
|
--panel-bg: rgba(8, 42, 58, 0.86);
|
||||||
|
--panel-border: #52e0c4;
|
||||||
|
--text-color: #d7fff8;
|
||||||
|
--heading-color: #a3fff1;
|
||||||
|
--control-bg: #10394d;
|
||||||
|
--control-border: #59bde0;
|
||||||
|
--control-color: #ebfffb;
|
||||||
|
--hover-bg: #174d62;
|
||||||
|
--active-bg: #0d5f65;
|
||||||
|
--active-color: #adfff0;
|
||||||
|
--accent: #52e0c4;
|
||||||
|
--warning: #ffd166;
|
||||||
|
--visualizer-bg: rgba(5, 35, 49, 0.82);
|
||||||
|
--visualizer-accent: #52e0c4;
|
||||||
|
--visualizer-secondary: #59bde0;
|
||||||
|
--visualizer-muted: rgba(163, 255, 241, 0.18);
|
||||||
|
background: linear-gradient(180deg, #062c45, #03161d);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="aquarium"] #app { backdrop-filter: blur(2px); }
|
||||||
|
body[data-ui-theme="aquarium"] #channels-panel,
|
||||||
|
body[data-ui-theme="aquarium"] #library-panel,
|
||||||
|
body[data-ui-theme="aquarium"] #queue-panel,
|
||||||
|
body[data-ui-theme="aquarium"] #player-bar { border: 1px solid rgba(163, 255, 241, 0.42); }
|
||||||
|
|
||||||
|
body[data-ui-theme="amber"] {
|
||||||
|
--panel-bg: #1b1204;
|
||||||
|
--panel-border: #8c5d13;
|
||||||
|
--text-color: #ffd98a;
|
||||||
|
--heading-color: #ffb000;
|
||||||
|
--control-bg: #2a1b06;
|
||||||
|
--control-border: #b97919;
|
||||||
|
--control-color: #ffe8ad;
|
||||||
|
--hover-bg: #3a2508;
|
||||||
|
--active-bg: #4c3109;
|
||||||
|
--active-color: #ffc857;
|
||||||
|
--accent: #ffb000;
|
||||||
|
--warning: #f77f00;
|
||||||
|
--visualizer-bg: rgba(18, 12, 3, 0.94);
|
||||||
|
--visualizer-accent: #ffb000;
|
||||||
|
--visualizer-secondary: #f77f00;
|
||||||
|
--visualizer-muted: rgba(255, 176, 0, 0.2);
|
||||||
|
background: #080502;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: "Cascadia Mono", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="amber"] * { letter-spacing: 0; }
|
||||||
|
body[data-ui-theme="amber"] #track-name { text-transform: uppercase; }
|
||||||
|
|
||||||
|
body[data-ui-theme="compact"] {
|
||||||
|
--panel-bg: #15181b;
|
||||||
|
--panel-border: #35414a;
|
||||||
|
--text-color: #e6edf3;
|
||||||
|
--heading-color: #9fc7ff;
|
||||||
|
--control-bg: #20262c;
|
||||||
|
--control-border: #3e4d58;
|
||||||
|
--control-color: #edf4fb;
|
||||||
|
--hover-bg: #242c33;
|
||||||
|
--active-bg: #22364b;
|
||||||
|
--active-color: #9fc7ff;
|
||||||
|
--accent: #79c0ff;
|
||||||
|
--warning: #e3b341;
|
||||||
|
--visualizer-bg: rgba(13, 17, 23, 0.94);
|
||||||
|
--visualizer-accent: #79c0ff;
|
||||||
|
--visualizer-secondary: #e3b341;
|
||||||
|
--visualizer-muted: rgba(121, 192, 255, 0.18);
|
||||||
|
background: #0d1117;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="compact"] #app { max-width: 1200px; padding: 0.25rem; }
|
||||||
|
body[data-ui-theme="compact"] #main-content { gap: 0.25rem; }
|
||||||
|
body[data-ui-theme="compact"] #channels-panel { flex-basis: 130px; }
|
||||||
|
body[data-ui-theme="compact"] #library-panel,
|
||||||
|
body[data-ui-theme="compact"] #queue-panel { padding: 0.25rem; max-height: 66vh; }
|
||||||
|
body[data-ui-theme="compact"] #library .track,
|
||||||
|
body[data-ui-theme="compact"] #queue .track,
|
||||||
|
body[data-ui-theme="compact"] #recent-library .track,
|
||||||
|
body[data-ui-theme="compact"] #playlist-tracks .track { padding: 0.16rem 0.35rem; font-size: 0.78rem; }
|
||||||
|
body[data-ui-theme="compact"] #player-bar { padding: 0.35rem 0.5rem; }
|
||||||
|
|
||||||
|
body[data-ui-theme="cinema"] {
|
||||||
|
--panel-bg: #101010;
|
||||||
|
--panel-border: #41362a;
|
||||||
|
--text-color: #e8dfcc;
|
||||||
|
--heading-color: #f6c65b;
|
||||||
|
--control-bg: #1f1a15;
|
||||||
|
--control-border: #6d5735;
|
||||||
|
--control-color: #f3ead7;
|
||||||
|
--hover-bg: #292118;
|
||||||
|
--active-bg: #3d2b18;
|
||||||
|
--active-color: #f6c65b;
|
||||||
|
--accent: #f6c65b;
|
||||||
|
--warning: #db5461;
|
||||||
|
--visualizer-bg: rgba(5, 5, 5, 0.95);
|
||||||
|
--visualizer-accent: #f6c65b;
|
||||||
|
--visualizer-secondary: #db5461;
|
||||||
|
--visualizer-muted: rgba(246, 198, 91, 0.18);
|
||||||
|
background: #050505;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="cinema"] #main-content { display: grid; grid-template-columns: 150px minmax(280px, 0.8fr) minmax(420px, 1.45fr); }
|
||||||
|
body[data-ui-theme="cinema"] #queue-panel { order: 2; max-height: 70vh; }
|
||||||
|
body[data-ui-theme="cinema"] #queue .track.active { font-size: 1rem; padding: 0.65rem 0.75rem; }
|
||||||
|
body[data-ui-theme="cinema"] #player-bar { border-top: 4px solid var(--accent); }
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] {
|
||||||
|
--panel-bg: #ffff00;
|
||||||
|
--panel-border: #000000;
|
||||||
|
--text-color: #000000;
|
||||||
|
--heading-color: #ffff00;
|
||||||
|
--control-bg: #ffffff;
|
||||||
|
--control-border: #000000;
|
||||||
|
--control-color: #000000;
|
||||||
|
--hover-bg: #ff0000;
|
||||||
|
--active-bg: #ff0000;
|
||||||
|
--active-color: #ffffff;
|
||||||
|
--accent: #ff0000;
|
||||||
|
--warning: #000000;
|
||||||
|
--visualizer-bg: #ffff00;
|
||||||
|
--visualizer-accent: #ff0000;
|
||||||
|
--visualizer-secondary: #000000;
|
||||||
|
--visualizer-muted: rgba(0, 0, 0, 0.22);
|
||||||
|
background: #ff0000;
|
||||||
|
color: #000000;
|
||||||
|
font-family: "MS Sans Serif", "Arial", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #site-header h1,
|
||||||
|
body[data-ui-theme="hotdog"] h3,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab.active {
|
||||||
|
background: #ff0000;
|
||||||
|
color: #ffff00;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #channels-panel,
|
||||||
|
body[data-ui-theme="hotdog"] #library-panel,
|
||||||
|
body[data-ui-theme="hotdog"] #queue-panel,
|
||||||
|
body[data-ui-theme="hotdog"] .visualizer-shell,
|
||||||
|
body[data-ui-theme="hotdog"] #player-bar,
|
||||||
|
body[data-ui-theme="hotdog"] #login-panel,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-views,
|
||||||
|
body[data-ui-theme="hotdog"] #toast-history,
|
||||||
|
body[data-ui-theme="hotdog"] .context-menu {
|
||||||
|
border: 3px solid #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 5px 5px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] button,
|
||||||
|
body[data-ui-theme="hotdog"] select,
|
||||||
|
body[data-ui-theme="hotdog"] input,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab,
|
||||||
|
body[data-ui-theme="hotdog"] .track-actions .track-menu-btn,
|
||||||
|
body[data-ui-theme="hotdog"] .track-actions .track-play-btn,
|
||||||
|
body[data-ui-theme="hotdog"] .track-actions .track-preview-btn {
|
||||||
|
border: 2px outset #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] button:active,
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab.active {
|
||||||
|
border-style: inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] .panel-tab,
|
||||||
|
body[data-ui-theme="hotdog"] .track,
|
||||||
|
body[data-ui-theme="hotdog"] .playlist-item,
|
||||||
|
body[data-ui-theme="hotdog"] .context-menu-item,
|
||||||
|
body[data-ui-theme="hotdog"] .channel-item {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] .track:hover,
|
||||||
|
body[data-ui-theme="hotdog"] .context-menu-item:hover,
|
||||||
|
body[data-ui-theme="hotdog"] .playlist-item:hover {
|
||||||
|
color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #queue .track.active,
|
||||||
|
body[data-ui-theme="hotdog"] .now-playing-bar,
|
||||||
|
body[data-ui-theme="hotdog"] .mobile-tab.active,
|
||||||
|
body[data-ui-theme="hotdog"] .toast {
|
||||||
|
border: 2px solid #000000;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #progress-container,
|
||||||
|
body[data-ui-theme="hotdog"] #buffer-bar .segment {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] #progress-bar.playing.synced,
|
||||||
|
body[data-ui-theme="hotdog"] #buffer-bar .segment.available,
|
||||||
|
body[data-ui-theme="hotdog"] .track.cached .cache-indicator {
|
||||||
|
background: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="hotdog"] .visualizer-shell {
|
||||||
|
background: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-ui-theme="spreadsheet"],
|
||||||
|
html[data-ui-theme="myspace"],
|
||||||
|
html[data-ui-theme="sideways"] {
|
||||||
|
max-width: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-ui-theme="spreadsheet"] { min-width: 2100px; }
|
||||||
|
html[data-ui-theme="myspace"] { min-width: 1850px; }
|
||||||
|
html[data-ui-theme="sideways"] { min-width: 2600px; }
|
||||||
|
|
||||||
|
body[data-ui-theme="spreadsheet"],
|
||||||
|
body[data-ui-theme="myspace"],
|
||||||
|
body[data-ui-theme="sideways"] {
|
||||||
|
max-width: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="spreadsheet"] {
|
||||||
|
--panel-bg: #ffffff;
|
||||||
|
--panel-border: #8b8b8b;
|
||||||
|
--text-color: #000000;
|
||||||
|
--heading-color: #008000;
|
||||||
|
--control-bg: #efefef;
|
||||||
|
--control-border: #777777;
|
||||||
|
--control-color: #000000;
|
||||||
|
--hover-bg: #ffffcc;
|
||||||
|
--active-bg: #00ff00;
|
||||||
|
--active-color: #000000;
|
||||||
|
--accent: #0000ff;
|
||||||
|
--warning: #ff0000;
|
||||||
|
--visualizer-bg: #ffffff;
|
||||||
|
--visualizer-accent: #0000ff;
|
||||||
|
--visualizer-secondary: #ff0000;
|
||||||
|
--visualizer-muted: rgba(0, 0, 0, 0.18);
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
font-family: "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="spreadsheet"] #app { width: 2100px; max-width: none; overflow-x: visible; }
|
||||||
|
body[data-ui-theme="spreadsheet"] #main-content { width: 2050px; max-width: none; overflow-x: visible; gap: 0; }
|
||||||
|
body[data-ui-theme="spreadsheet"] .visualizer-shell { width: 2050px; max-width: none; }
|
||||||
|
body[data-ui-theme="spreadsheet"] #channels-panel,
|
||||||
|
body[data-ui-theme="spreadsheet"] #library-panel,
|
||||||
|
body[data-ui-theme="spreadsheet"] #queue-panel,
|
||||||
|
body[data-ui-theme="spreadsheet"] #player-bar {
|
||||||
|
border: 1px solid #000;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
body[data-ui-theme="spreadsheet"] .track { border-bottom: 1px solid #999; border-radius: 0; }
|
||||||
|
body[data-ui-theme="spreadsheet"] #library,
|
||||||
|
body[data-ui-theme="spreadsheet"] #recent-library,
|
||||||
|
body[data-ui-theme="spreadsheet"] #queue { overflow-x: scroll; }
|
||||||
|
|
||||||
|
body[data-ui-theme="myspace"] {
|
||||||
|
--panel-bg: #220044;
|
||||||
|
--panel-border: #00ff66;
|
||||||
|
--text-color: #ffff00;
|
||||||
|
--heading-color: #ff00ff;
|
||||||
|
--control-bg: #0000cc;
|
||||||
|
--control-border: #ffff00;
|
||||||
|
--control-color: #ffffff;
|
||||||
|
--hover-bg: #ff00ff;
|
||||||
|
--active-bg: #00ffff;
|
||||||
|
--active-color: #000000;
|
||||||
|
--accent: #00ff66;
|
||||||
|
--warning: #ff6600;
|
||||||
|
--visualizer-bg: rgba(34, 0, 68, 0.92);
|
||||||
|
--visualizer-accent: #00ff66;
|
||||||
|
--visualizer-secondary: #ff00ff;
|
||||||
|
--visualizer-muted: rgba(255, 255, 0, 0.22);
|
||||||
|
background: repeating-linear-gradient(45deg, #ff00ff 0 18px, #00ffff 18px 36px, #ffff00 36px 54px);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: Impact, "Arial Black", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="myspace"] #app { width: 1850px; max-width: none; overflow-x: visible; transform: rotate(-0.35deg); }
|
||||||
|
body[data-ui-theme="myspace"] #main-content { width: 1780px; max-width: none; overflow-x: visible; gap: 1.25rem; }
|
||||||
|
body[data-ui-theme="myspace"] .visualizer-shell { width: 1780px; max-width: none; }
|
||||||
|
body[data-ui-theme="myspace"] #channels-panel,
|
||||||
|
body[data-ui-theme="myspace"] #library-panel,
|
||||||
|
body[data-ui-theme="myspace"] #queue-panel,
|
||||||
|
body[data-ui-theme="myspace"] #player-bar {
|
||||||
|
border: 6px ridge #00ff66;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 12px 12px 0 #ff00ff, -9px -7px 0 #00ffff;
|
||||||
|
}
|
||||||
|
body[data-ui-theme="myspace"] .track:nth-child(odd) { transform: rotate(0.45deg); }
|
||||||
|
body[data-ui-theme="myspace"] .track:nth-child(even) { transform: rotate(-0.45deg); }
|
||||||
|
body[data-ui-theme="myspace"] #track-name { font-size: 1.25rem; color: #00ff66; text-shadow: 2px 2px #ff00ff; }
|
||||||
|
|
||||||
|
body[data-ui-theme="sideways"] {
|
||||||
|
--panel-bg: #241313;
|
||||||
|
--panel-border: #cc3333;
|
||||||
|
--text-color: #f8dcdc;
|
||||||
|
--heading-color: #ff6b35;
|
||||||
|
--control-bg: #351b1b;
|
||||||
|
--control-border: #8c2f39;
|
||||||
|
--control-color: #fff1f1;
|
||||||
|
--hover-bg: #482121;
|
||||||
|
--active-bg: #5a231f;
|
||||||
|
--active-color: #ffb199;
|
||||||
|
--accent: #ff6b35;
|
||||||
|
--warning: #ffd166;
|
||||||
|
--visualizer-bg: rgba(25, 11, 11, 0.94);
|
||||||
|
--visualizer-accent: #ff6b35;
|
||||||
|
--visualizer-secondary: #ffd166;
|
||||||
|
--visualizer-muted: rgba(248, 220, 220, 0.16);
|
||||||
|
background: #190b0b;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="sideways"] #app { width: 2600px; max-width: none; overflow-x: visible; }
|
||||||
|
body[data-ui-theme="sideways"] .visualizer-shell { width: 2480px; max-width: none; }
|
||||||
|
body[data-ui-theme="sideways"] #main-content {
|
||||||
|
width: 2480px;
|
||||||
|
max-width: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1060px 1060px;
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
body[data-ui-theme="sideways"] #channels-panel,
|
||||||
|
body[data-ui-theme="sideways"] #library-panel,
|
||||||
|
body[data-ui-theme="sideways"] #queue-panel { max-height: 42vh; }
|
||||||
|
body[data-ui-theme="sideways"] #player-bar { width: 2480px; max-width: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#stream-select { width: 100%; margin-left: 0; justify-content: stretch; }
|
||||||
|
#theme-select,
|
||||||
|
#visualizer-select { flex: 1 1 150px; min-height: 44px; min-width: 0; }
|
||||||
|
|
||||||
|
body[data-ui-theme="compact"] #app,
|
||||||
|
body[data-ui-theme="cinema"] #app {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-ui-theme="cinema"] #main-content {
|
||||||
|
display: flex;
|
||||||
|
grid-template-columns: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
// MusicRoom - UI theme and layout experiments
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
const THEMES = new Set([
|
||||||
|
"default",
|
||||||
|
"phosphor",
|
||||||
|
"vaporwave",
|
||||||
|
"sunrise",
|
||||||
|
"paper",
|
||||||
|
"contrast",
|
||||||
|
"aquarium",
|
||||||
|
"amber",
|
||||||
|
"compact",
|
||||||
|
"cinema",
|
||||||
|
"hotdog",
|
||||||
|
"spreadsheet",
|
||||||
|
"myspace",
|
||||||
|
"sideways"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function decodeCookieValue(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
M.clearCookieValue(M.THEME_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTheme(theme) {
|
||||||
|
return THEMES.has(theme) ? theme : "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
M.getSavedTheme = function() {
|
||||||
|
const cookieTheme = normalizeTheme(decodeCookieValue(M.getCookieValue(M.THEME_STORAGE_KEY)));
|
||||||
|
if (cookieTheme !== "default") return cookieTheme;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedTheme = normalizeTheme(localStorage.getItem(M.THEME_STORAGE_KEY));
|
||||||
|
if (storedTheme !== "default") {
|
||||||
|
M.rememberTheme(storedTheme);
|
||||||
|
return storedTheme;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return "default";
|
||||||
|
};
|
||||||
|
|
||||||
|
M.rememberTheme = function(theme) {
|
||||||
|
const normalized = normalizeTheme(theme);
|
||||||
|
if (normalized === "default") {
|
||||||
|
M.clearThemePreference();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
M.setCookieValue(M.THEME_STORAGE_KEY, normalized);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(M.THEME_STORAGE_KEY, normalized);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.clearThemePreference = function() {
|
||||||
|
M.clearCookieValue(M.THEME_STORAGE_KEY);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(M.THEME_STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.applyTheme = function(theme, remember = true) {
|
||||||
|
const normalized = normalizeTheme(theme);
|
||||||
|
document.documentElement.dataset.uiTheme = normalized;
|
||||||
|
document.body.dataset.uiTheme = normalized;
|
||||||
|
|
||||||
|
const selector = document.getElementById("theme-select");
|
||||||
|
if (selector) selector.value = normalized;
|
||||||
|
|
||||||
|
if (remember) M.rememberTheme(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const selector = document.getElementById("theme-select");
|
||||||
|
M.applyTheme(M.getSavedTheme(), false);
|
||||||
|
|
||||||
|
if (selector) {
|
||||||
|
selector.onchange = () => M.applyTheme(selector.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -4,6 +4,11 @@
|
||||||
(function() {
|
(function() {
|
||||||
const M = window.MusicRoom;
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
// Single source of truth for a track's display title
|
||||||
|
function getTitle(track) {
|
||||||
|
return track?.title?.trim() || (track?.filename || track?.id || "Unknown").replace(/\.[^.]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a track row element (pure rendering, no handlers)
|
* Render a track row element (pure rendering, no handlers)
|
||||||
* @param {Object} track - Track object with id, title, filename, duration
|
* @param {Object} track - Track object with id, title, filename, duration
|
||||||
|
|
@ -45,7 +50,7 @@
|
||||||
div.dataset.view = view;
|
div.dataset.view = view;
|
||||||
|
|
||||||
// Build title
|
// Build title
|
||||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
const title = getTitle(track);
|
||||||
div.title = title;
|
div.title = title;
|
||||||
|
|
||||||
// Build HTML
|
// Build HTML
|
||||||
|
|
@ -56,9 +61,10 @@
|
||||||
${checkmark}
|
${checkmark}
|
||||||
<span class="cache-indicator"></span>
|
<span class="cache-indicator"></span>
|
||||||
${trackNum}
|
${trackNum}
|
||||||
<span class="track-title">${escapeHtml(title)}</span>
|
<span class="track-title">${M.escapeHtml(title)}</span>
|
||||||
<span class="track-actions">
|
<span class="track-actions">
|
||||||
<span class="duration">${M.fmt(track.duration)}</span>
|
<span class="duration">${M.fmt(track.duration)}</span>
|
||||||
|
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -69,17 +75,10 @@
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML escape helper
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str.replace(/[&<>"']/g, c => ({
|
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
||||||
})[c]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
M.trackComponent = {
|
M.trackComponent = {
|
||||||
render,
|
render,
|
||||||
escapeHtml
|
getTitle,
|
||||||
|
escapeHtml: M.escapeHtml
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,39 @@
|
||||||
|
|
||||||
// Active context menu
|
// Active context menu
|
||||||
let activeContextMenu = null;
|
let activeContextMenu = null;
|
||||||
|
const TRACK_DRAG_TYPE = "application/x-blastoise-tracks";
|
||||||
|
|
||||||
|
function getDataTransferTypes(e) {
|
||||||
|
return [...(e.dataTransfer?.types || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTrackDrag(e, allowedSources = null) {
|
||||||
|
if (dragSource) return !allowedSources || allowedSources.includes(dragSource);
|
||||||
|
return getDataTransferTypes(e).includes(TRACK_DRAG_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDragState(e) {
|
||||||
|
if (dragSource && draggedTrackIds.length > 0) return true;
|
||||||
|
|
||||||
|
const raw = e.dataTransfer?.getData(TRACK_DRAG_TYPE);
|
||||||
|
if (!raw) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
if (!["queue", "library", "playlist"].includes(payload.source)) return false;
|
||||||
|
const trackIds = Array.isArray(payload.trackIds) ? payload.trackIds.filter(Boolean) : [];
|
||||||
|
if (trackIds.length === 0) return false;
|
||||||
|
|
||||||
|
dragSource = payload.source;
|
||||||
|
draggedTrackIds = trackIds;
|
||||||
|
draggedIndices = Array.isArray(payload.indices)
|
||||||
|
? payload.indices.filter(i => Number.isInteger(i))
|
||||||
|
: [];
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a track container manager
|
* Create a track container manager
|
||||||
|
|
@ -41,6 +74,7 @@
|
||||||
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
|
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
|
||||||
* @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
|
* @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
|
||||||
* @param {string} [config.playlistId] - Playlist ID (for playlist type)
|
* @param {string} [config.playlistId] - Playlist ID (for playlist type)
|
||||||
|
* @param {string} [config.emptyMessage] - Message when the container has no tracks
|
||||||
* @param {Function} [config.onRender] - Callback after render
|
* @param {Function} [config.onRender] - Callback after render
|
||||||
*/
|
*/
|
||||||
function createContainer(config) {
|
function createContainer(config) {
|
||||||
|
|
@ -52,6 +86,7 @@
|
||||||
canReorder = false,
|
canReorder = false,
|
||||||
isPlaylistOwner = false,
|
isPlaylistOwner = false,
|
||||||
playlistId = null,
|
playlistId = null,
|
||||||
|
emptyMessage = null,
|
||||||
onRender
|
onRender
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
|
|
@ -85,9 +120,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTracks.length === 0) {
|
if (currentTracks.length === 0) {
|
||||||
const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here'
|
const emptyMsg = emptyMessage || (type === 'queue' ? 'Queue empty - drag tracks here'
|
||||||
: type === 'library' ? 'No tracks'
|
: type === 'library' ? 'No tracks'
|
||||||
: 'No tracks - drag here to add';
|
: 'No tracks - drag here to add');
|
||||||
element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
|
element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
|
||||||
if (onRender) onRender();
|
if (onRender) onRender();
|
||||||
return;
|
return;
|
||||||
|
|
@ -131,7 +166,7 @@
|
||||||
|
|
||||||
function wirePlaylistContainerDrop(container) {
|
function wirePlaylistContainerDrop(container) {
|
||||||
container.ondragover = (e) => {
|
container.ondragover = (e) => {
|
||||||
if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') {
|
if (hasTrackDrag(e, ['queue', 'library', 'playlist'])) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
|
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
|
||||||
container.classList.add("drop-target");
|
container.classList.add("drop-target");
|
||||||
|
|
@ -151,6 +186,7 @@
|
||||||
el.classList.remove("drop-above", "drop-below");
|
el.classList.remove("drop-above", "drop-below");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
restoreDragState(e);
|
||||||
if (draggedTrackIds.length > 0) {
|
if (draggedTrackIds.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -287,6 +323,19 @@
|
||||||
showContextMenu(e, track, originalIndex, canEditQueue);
|
showContextMenu(e, track, originalIndex, canEditQueue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const menuBtn = div.querySelector(".track-menu-btn");
|
||||||
|
if (menuBtn) {
|
||||||
|
menuBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
|
showContextMenu({
|
||||||
|
preventDefault() {},
|
||||||
|
clientX: Math.min(rect.right, window.innerWidth - 8),
|
||||||
|
clientY: Math.min(rect.bottom, window.innerHeight - 8)
|
||||||
|
}, track, originalIndex, canEditQueue);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Drag start/end handlers - library/playlist always (read access), queue needs edit permission
|
// Drag start/end handlers - library/playlist always (read access), queue needs edit permission
|
||||||
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
|
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
|
||||||
if (canDrag) {
|
if (canDrag) {
|
||||||
|
|
@ -376,6 +425,11 @@
|
||||||
div.classList.add("dragging");
|
div.classList.add("dragging");
|
||||||
// Use "copyMove" to allow both copy and move operations
|
// Use "copyMove" to allow both copy and move operations
|
||||||
e.dataTransfer.effectAllowed = "copyMove";
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
|
e.dataTransfer.setData(TRACK_DRAG_TYPE, JSON.stringify({
|
||||||
|
source: type,
|
||||||
|
trackIds: draggedTrackIds,
|
||||||
|
indices: draggedIndices
|
||||||
|
}));
|
||||||
e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`);
|
e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,6 +453,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(e, div, index) {
|
function handleDragOver(e, div, index) {
|
||||||
|
if (!hasTrackDrag(e, ['queue', 'library', 'playlist'])) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Set drop effect based on source
|
// Set drop effect based on source
|
||||||
|
|
@ -429,6 +484,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(e, div, index) {
|
function handleDrop(e, div, index) {
|
||||||
|
restoreDragState(e);
|
||||||
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
|
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -474,7 +530,7 @@
|
||||||
|
|
||||||
function wireQueueContainerDrop(container) {
|
function wireQueueContainerDrop(container) {
|
||||||
container.ondragover = (e) => {
|
container.ondragover = (e) => {
|
||||||
if (dragSource === 'library' || dragSource === 'playlist') {
|
if (hasTrackDrag(e, ['library', 'playlist'])) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
if (M.queue.length === 0) {
|
if (M.queue.length === 0) {
|
||||||
|
|
@ -491,6 +547,7 @@
|
||||||
|
|
||||||
container.ondrop = (e) => {
|
container.ondrop = (e) => {
|
||||||
container.classList.remove("drop-target");
|
container.classList.remove("drop-target");
|
||||||
|
restoreDragState(e);
|
||||||
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
|
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
|
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
|
||||||
|
|
@ -540,11 +597,16 @@
|
||||||
|
|
||||||
async function playTrack(track, index) {
|
async function playTrack(track, index) {
|
||||||
const trackId = track.id || track.filename;
|
const trackId = track.id || track.filename;
|
||||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
const title = M.trackComponent.getTitle(track);
|
||||||
|
|
||||||
if (type === 'queue') {
|
if (type === 'queue') {
|
||||||
// Jump to track in queue
|
// Jump to track in queue
|
||||||
if (M.synced && M.currentChannelId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
|
if (!M.canControl()) {
|
||||||
|
M.flashPermissionDenied();
|
||||||
|
M.showToast("Sign in to control playback", "warning", 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -557,6 +619,7 @@
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
M.setTrackTitle(title);
|
M.setTrackTitle(title);
|
||||||
|
M.applyReplayGain && M.applyReplayGain(track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||||
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
||||||
|
|
@ -570,11 +633,12 @@
|
||||||
|
|
||||||
async function previewTrack(track) {
|
async function previewTrack(track) {
|
||||||
const trackId = track.id || track.filename;
|
const trackId = track.id || track.filename;
|
||||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
const title = M.trackComponent.getTitle(track);
|
||||||
|
|
||||||
M.currentTrackId = trackId;
|
M.currentTrackId = trackId;
|
||||||
M.serverTrackDuration = track.duration;
|
M.serverTrackDuration = track.duration;
|
||||||
M.setTrackTitle(title);
|
M.setTrackTitle(title);
|
||||||
|
M.applyReplayGain && M.applyReplayGain(track);
|
||||||
M.loadingSegments.clear();
|
M.loadingSegments.clear();
|
||||||
|
|
||||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||||
|
|
@ -594,7 +658,7 @@
|
||||||
|
|
||||||
function showContextMenu(e, track, index, canEditQueue) {
|
function showContextMenu(e, track, index, canEditQueue) {
|
||||||
const trackId = track.id || track.filename;
|
const trackId = track.id || track.filename;
|
||||||
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
|
const title = M.trackComponent.getTitle(track);
|
||||||
|
|
||||||
const sel = selection[type];
|
const sel = selection[type];
|
||||||
const hasSelection = sel.size > 0;
|
const hasSelection = sel.size > 0;
|
||||||
|
|
@ -617,7 +681,7 @@
|
||||||
const menuItems = [];
|
const menuItems = [];
|
||||||
|
|
||||||
// Play (queue only, single track or single selection)
|
// Play (queue only, single track or single selection)
|
||||||
if (type === 'queue' && selectedCount === 1) {
|
if (type === 'queue' && selectedCount === 1 && (canEditQueue || !M.synced)) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
label: "▶ Play",
|
label: "▶ Play",
|
||||||
action: () => playTrack(track, index)
|
action: () => playTrack(track, index)
|
||||||
|
|
@ -701,7 +765,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to Playlist
|
// Add to Playlist
|
||||||
if (M.playlists && !M.currentUser?.is_guest) {
|
if (M.playlists && !M.currentUser?.isGuest) {
|
||||||
const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
|
const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
|
||||||
if (submenu && submenu.length > 0) {
|
if (submenu && submenu.length > 0) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
|
|
@ -870,10 +934,10 @@
|
||||||
// Adjust if off-screen
|
// Adjust if off-screen
|
||||||
const rect = menu.getBoundingClientRect();
|
const rect = menu.getBoundingClientRect();
|
||||||
if (rect.right > window.innerWidth) {
|
if (rect.right > window.innerWidth) {
|
||||||
menu.style.left = (window.innerWidth - rect.width - 5) + "px";
|
menu.style.left = Math.max(5, window.innerWidth - rect.width - 5) + "px";
|
||||||
}
|
}
|
||||||
if (rect.bottom > window.innerHeight) {
|
if (rect.bottom > window.innerHeight) {
|
||||||
menu.style.top = (window.innerHeight - rect.height - 5) + "px";
|
menu.style.top = Math.max(5, window.innerHeight - rect.height - 5) + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
activeContextMenu = menu;
|
activeContextMenu = menu;
|
||||||
|
|
|
||||||
22
public/ui.js
22
public/ui.js
|
|
@ -14,8 +14,9 @@
|
||||||
// Update general UI state
|
// Update general UI state
|
||||||
M.updateUI = function() {
|
M.updateUI = function() {
|
||||||
const isConnecting = M.wantSync && !M.synced;
|
const isConnecting = M.wantSync && !M.synced;
|
||||||
|
const playbackBlocked = M.synced && M.playbackBlocked && !M.serverPaused;
|
||||||
// While connecting, treat as not playing (paused state)
|
// While connecting, treat as not playing (paused state)
|
||||||
const isPlaying = M.synced ? !M.serverPaused : (!isConnecting && !M.audio.paused);
|
const isPlaying = M.synced ? (!M.serverPaused && !M.audio.paused && !playbackBlocked) : (!isConnecting && !M.audio.paused);
|
||||||
M.$("#btn-sync").classList.toggle("synced", M.wantSync);
|
M.$("#btn-sync").classList.toggle("synced", M.wantSync);
|
||||||
M.$("#btn-sync").classList.toggle("connected", M.synced);
|
M.$("#btn-sync").classList.toggle("connected", M.synced);
|
||||||
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
|
M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync";
|
||||||
|
|
@ -25,11 +26,22 @@
|
||||||
M.$("#progress-bar").classList.toggle("local", !M.synced);
|
M.$("#progress-bar").classList.toggle("local", !M.synced);
|
||||||
M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0);
|
M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0);
|
||||||
M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊";
|
M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊";
|
||||||
M.$("#status-icon").textContent = isPlaying ? "⏸" : "▶";
|
const statusIcon = M.$("#status-icon");
|
||||||
|
statusIcon.textContent = isPlaying ? "⏸" : "▶";
|
||||||
|
statusIcon.classList.toggle("playback-blocked", playbackBlocked);
|
||||||
|
statusIcon.title = playbackBlocked ? "Resume this channel" : (isPlaying ? "Pause" : "Play");
|
||||||
|
|
||||||
// Show/hide controls based on permissions
|
// Show/hide controls based on permissions
|
||||||
const hasControl = M.canControl();
|
const hasControl = M.canControl();
|
||||||
M.$("#status-icon").style.cursor = hasControl || !M.synced ? "pointer" : "default";
|
const controlsDisabled = M.synced && !hasControl;
|
||||||
|
["#status-icon", "#btn-prev", "#btn-next", "#btn-mode", "#progress-container"].forEach(sel => {
|
||||||
|
const el = M.$(sel);
|
||||||
|
if (!el) return;
|
||||||
|
const disabled = controlsDisabled && !(sel === "#status-icon" && playbackBlocked);
|
||||||
|
el.classList.toggle("control-disabled", disabled);
|
||||||
|
if ("ariaDisabled" in el) el.ariaDisabled = disabled ? "true" : "false";
|
||||||
|
});
|
||||||
|
statusIcon.style.cursor = controlsDisabled && !playbackBlocked ? "not-allowed" : "pointer";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update auth-related UI
|
// Update auth-related UI
|
||||||
|
|
@ -49,6 +61,7 @@
|
||||||
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
|
||||||
// Re-render channel list to update rename/delete buttons
|
// Re-render channel list to update rename/delete buttons
|
||||||
if (M.renderChannelList) M.renderChannelList();
|
if (M.renderChannelList) M.renderChannelList();
|
||||||
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
} else {
|
} else {
|
||||||
M.$("#login-panel").classList.remove("hidden");
|
M.$("#login-panel").classList.remove("hidden");
|
||||||
M.$("#player-content").classList.remove("visible");
|
M.$("#player-content").classList.remove("visible");
|
||||||
|
|
@ -68,6 +81,7 @@
|
||||||
} else {
|
} else {
|
||||||
M.$("#guest-section").classList.add("hidden");
|
M.$("#guest-section").classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
if (M.updatePermissionUI) M.updatePermissionUI();
|
||||||
}
|
}
|
||||||
M.updateUI();
|
M.updateUI();
|
||||||
};
|
};
|
||||||
|
|
@ -177,7 +191,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
// Restore last active tab
|
// Restore last active tab
|
||||||
const savedTab = localStorage.getItem("blastoise_mobile_tab") || "channels-panel";
|
const savedTab = localStorage.getItem("blastoise_mobile_tab") || "queue-panel";
|
||||||
|
|
||||||
function setActiveTab(panelId) {
|
function setActiveTab(panelId) {
|
||||||
tabs.forEach(t => t.classList.toggle("active", t.dataset.panel === panelId));
|
tabs.forEach(t => t.classList.toggle("active", t.dataset.panel === panelId));
|
||||||
|
|
|
||||||
|
|
@ -106,11 +106,7 @@
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.type === "playlist") {
|
if (data.type === "playlist") {
|
||||||
// Ask user to confirm playlist download
|
const queuePlaylist = async () => {
|
||||||
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`);
|
|
||||||
|
|
||||||
if (confirmed) {
|
|
||||||
// Confirm playlist download with title for auto-playlist creation
|
|
||||||
const confirmRes = await fetch("/api/fetch/confirm", {
|
const confirmRes = await fetch("/api/fetch/confirm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -128,7 +124,13 @@
|
||||||
const err = await confirmRes.json().catch(() => ({}));
|
const err = await confirmRes.json().catch(() => ({}));
|
||||||
M.showToast(err.error || "Failed to queue playlist", "error");
|
M.showToast(err.error || "Failed to queue playlist", "error");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
M.showConfirmToast(
|
||||||
|
`Download playlist "${data.title}" with ${data.count} items? Items download slowly and a playlist will be created automatically.`,
|
||||||
|
queuePlaylist,
|
||||||
|
{ confirmText: "Download", duration: 20000 }
|
||||||
|
);
|
||||||
} else if (data.type === "single") {
|
} else if (data.type === "single") {
|
||||||
M.showToast(`Queued: ${data.title}`);
|
M.showToast(`Queued: ${data.title}`);
|
||||||
// Task will be created by WebSocket progress messages
|
// Task will be created by WebSocket progress messages
|
||||||
|
|
@ -163,18 +165,23 @@
|
||||||
|
|
||||||
// Drag and drop on library panel
|
// Drag and drop on library panel
|
||||||
let dragCounter = 0;
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
function isFileDrag(e) {
|
||||||
|
return [...(e.dataTransfer?.types || [])].includes("Files");
|
||||||
|
}
|
||||||
|
|
||||||
libraryPanel.ondragenter = (e) => {
|
libraryPanel.ondragenter = (e) => {
|
||||||
if (!M.currentUser) return;
|
if (!M.currentUser) return;
|
||||||
if (!e.dataTransfer.types.includes("Files")) return;
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounter++;
|
dragCounter++;
|
||||||
dropzone.classList.remove("hidden");
|
dropzone.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
libraryPanel.ondragleave = (e) => {
|
libraryPanel.ondragleave = (e) => {
|
||||||
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounter--;
|
dragCounter = Math.max(0, dragCounter - 1);
|
||||||
if (dragCounter === 0) {
|
if (dragCounter === 0) {
|
||||||
dropzone.classList.add("hidden");
|
dropzone.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
@ -182,12 +189,13 @@
|
||||||
|
|
||||||
libraryPanel.ondragover = (e) => {
|
libraryPanel.ondragover = (e) => {
|
||||||
if (!M.currentUser) return;
|
if (!M.currentUser) return;
|
||||||
if (!e.dataTransfer.types.includes("Files")) return;
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
};
|
};
|
||||||
|
|
||||||
libraryPanel.ondrop = (e) => {
|
libraryPanel.ondrop = (e) => {
|
||||||
|
if (!isFileDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounter = 0;
|
dragCounter = 0;
|
||||||
dropzone.classList.add("hidden");
|
dropzone.classList.add("hidden");
|
||||||
|
|
@ -296,14 +304,14 @@
|
||||||
|
|
||||||
for (const [playlistId, group] of byPlaylist) {
|
for (const [playlistId, group] of byPlaylist) {
|
||||||
if (group.name) {
|
if (group.name) {
|
||||||
html += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`;
|
html += `<div class="slow-queue-playlist-header">📁 ${M.escapeHtml(group.name)}</div>`;
|
||||||
}
|
}
|
||||||
html += group.items.map((item, i) => {
|
html += group.items.map((item, i) => {
|
||||||
const isNext = queuedItems.indexOf(item) === 0;
|
const isNext = queuedItems.indexOf(item) === 0;
|
||||||
return `
|
return `
|
||||||
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
|
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
|
||||||
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
|
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
|
||||||
<span class="slow-queue-item-title">${item.title}</span>
|
<span class="slow-queue-item-title">${M.escapeHtml(item.title)}</span>
|
||||||
<button class="slow-queue-cancel" title="Cancel">✕</button>
|
<button class="slow-queue-cancel" title="Cancel">✕</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,18 @@
|
||||||
|
|
||||||
// DOM selector helper
|
// DOM selector helper
|
||||||
M.$ = (s) => document.querySelector(s);
|
M.$ = (s) => document.querySelector(s);
|
||||||
|
|
||||||
|
// Shared HTML escaping helper - use for any server-controlled string
|
||||||
|
// interpolated into innerHTML (text and attribute contexts)
|
||||||
|
M.escapeHtml = function(str) {
|
||||||
|
if (str == null) return "";
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
};
|
||||||
|
|
||||||
// Format seconds as m:ss
|
// Format seconds as m:ss
|
||||||
M.fmt = function(sec) {
|
M.fmt = function(sec) {
|
||||||
|
|
@ -17,6 +29,14 @@
|
||||||
|
|
||||||
// Toast history
|
// Toast history
|
||||||
M.toastHistory = [];
|
M.toastHistory = [];
|
||||||
|
|
||||||
|
function pruneVisibleToasts(container) {
|
||||||
|
const limit = window.matchMedia("(max-width: 768px)").matches ? 2 : 3;
|
||||||
|
const toasts = [...container.querySelectorAll(".toast:not(.toast-confirm)")];
|
||||||
|
while (toasts.length > limit) {
|
||||||
|
toasts.shift().remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toast notifications (log style - multiple visible)
|
// Toast notifications (log style - multiple visible)
|
||||||
M.showToast = function(message, type = "info", duration = 5000) {
|
M.showToast = function(message, type = "info", duration = 5000) {
|
||||||
|
|
@ -25,6 +45,7 @@
|
||||||
toast.className = "toast toast-" + type;
|
toast.className = "toast toast-" + type;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
|
pruneVisibleToasts(container);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.add("fade-out");
|
toast.classList.add("fade-out");
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
|
@ -38,6 +59,56 @@
|
||||||
});
|
});
|
||||||
M.updateToastHistory();
|
M.updateToastHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
M.showConfirmToast = function(message, onConfirm, options = {}) {
|
||||||
|
const container = M.$("#toast-container");
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = "toast toast-warning toast-confirm";
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "toast-confirm-message";
|
||||||
|
text.textContent = message;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "toast-actions";
|
||||||
|
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.className = "toast-action-cancel";
|
||||||
|
cancel.textContent = options.cancelText || "Cancel";
|
||||||
|
|
||||||
|
const confirm = document.createElement("button");
|
||||||
|
confirm.type = "button";
|
||||||
|
confirm.className = "toast-action-confirm";
|
||||||
|
confirm.textContent = options.confirmText || "Confirm";
|
||||||
|
|
||||||
|
actions.append(cancel, confirm);
|
||||||
|
toast.append(text, actions);
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const close = () => {
|
||||||
|
toast.classList.add("fade-out");
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
};
|
||||||
|
const finish = async (confirmed) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
close();
|
||||||
|
if (confirmed) await onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancel.onclick = () => finish(false);
|
||||||
|
confirm.onclick = () => finish(true);
|
||||||
|
setTimeout(() => finish(false), options.duration || 12000);
|
||||||
|
|
||||||
|
M.toastHistory.push({
|
||||||
|
message: `Confirm: ${message}`,
|
||||||
|
type: "warning",
|
||||||
|
time: new Date()
|
||||||
|
});
|
||||||
|
M.updateToastHistory();
|
||||||
|
};
|
||||||
|
|
||||||
// Update toast history panel
|
// Update toast history panel
|
||||||
M.updateToastHistory = function() {
|
M.updateToastHistory = function() {
|
||||||
|
|
@ -51,7 +122,7 @@
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "history-item history-" + item.type;
|
div.className = "history-item history-" + item.type;
|
||||||
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
div.innerHTML = `<span class="history-time">${time}</span> ${item.message}`;
|
div.innerHTML = `<span class="history-time">${time}</span> ${M.escapeHtml(item.message)}`;
|
||||||
list.appendChild(div);
|
list.appendChild(div);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -87,7 +158,7 @@
|
||||||
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
|
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
|
||||||
|
|
||||||
// First set simple content to measure
|
// First set simple content to measure
|
||||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`;
|
marqueeEl.innerHTML = `<span id="track-title">${M.escapeHtml(title)}</span>`;
|
||||||
|
|
||||||
// Check if title overflows and needs scrolling
|
// Check if title overflows and needs scrolling
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -97,7 +168,7 @@
|
||||||
|
|
||||||
// Duplicate text for seamless wrap-around scrolling
|
// Duplicate text for seamless wrap-around scrolling
|
||||||
if (needsScroll) {
|
if (needsScroll) {
|
||||||
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer"> • </span><span>${title}</span><span class="marquee-spacer"> • </span>`;
|
marqueeEl.innerHTML = `<span id="track-title">${M.escapeHtml(title)}</span><span class="marquee-spacer"> • </span><span>${M.escapeHtml(title)}</span><span class="marquee-spacer"> • </span>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -112,10 +183,29 @@
|
||||||
M.canControl = function() {
|
M.canControl = function() {
|
||||||
if (!M.currentUser) return false;
|
if (!M.currentUser) return false;
|
||||||
if (M.currentUser.isAdmin) return true;
|
if (M.currentUser.isAdmin) return true;
|
||||||
|
if (M.currentUser.isGuest) return false;
|
||||||
return M.currentUser.permissions?.some(p =>
|
return M.currentUser.permissions?.some(p =>
|
||||||
p.resource_type === "channel" &&
|
p.resource_type === "channel" &&
|
||||||
(p.resource_id === M.currentChannelId || p.resource_id === null) &&
|
(p.resource_id === M.currentChannelId || p.resource_id === null) &&
|
||||||
p.permission === "control"
|
p.permission === "control"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
M.canCreateUserContent = function() {
|
||||||
|
return !!M.currentUser && !M.currentUser.isGuest;
|
||||||
|
};
|
||||||
|
|
||||||
|
M.updatePermissionUI = function() {
|
||||||
|
const canCreate = M.canCreateUserContent();
|
||||||
|
const newChannel = M.$("#btn-new-channel");
|
||||||
|
const newPlaylist = M.$("#btn-new-playlist");
|
||||||
|
const fetchUrl = M.$("#btn-fetch-url");
|
||||||
|
|
||||||
|
if (newChannel) newChannel.classList.toggle("hidden", !canCreate);
|
||||||
|
if (newPlaylist) newPlaylist.classList.toggle("hidden", !canCreate);
|
||||||
|
if (fetchUrl) {
|
||||||
|
const ytdlpReady = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
|
||||||
|
fetchUrl.style.display = canCreate && ytdlpReady ? "" : "none";
|
||||||
|
}
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,803 @@
|
||||||
|
// MusicRoom - Audio visualizer
|
||||||
|
// Pure frontend Web Audio + canvas display for the shared audio element.
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
|
const MODES = new Set(["off", "bars", "wave", "radial", "pulse"]);
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
|
||||||
|
const TWO_PI = Math.PI * 2;
|
||||||
|
const PARTICLE_COUNT = 140;
|
||||||
|
|
||||||
|
let mode = "off";
|
||||||
|
let shell = null;
|
||||||
|
let canvas = null;
|
||||||
|
let ctx = null;
|
||||||
|
let selector = null;
|
||||||
|
let fullscreenButton = null;
|
||||||
|
let audioContext = null;
|
||||||
|
let source = null;
|
||||||
|
let gainNode = null;
|
||||||
|
let analyser = null;
|
||||||
|
let frequencyData = null;
|
||||||
|
let waveformData = null;
|
||||||
|
let animationId = 0;
|
||||||
|
let graphUnavailable = false;
|
||||||
|
let particles = [];
|
||||||
|
let rememberedWidth = 0;
|
||||||
|
let rememberedHeight = 0;
|
||||||
|
let beatMemory = 0;
|
||||||
|
let energyMemory = 0;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
function normalizeMode(value) {
|
||||||
|
return MODES.has(value) ? value : "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeStoredValue(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedMode() {
|
||||||
|
const cookieMode = normalizeMode(decodeStoredValue(M.getCookieValue(M.VISUALIZER_STORAGE_KEY)));
|
||||||
|
if (cookieMode !== "off") return cookieMode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedMode = normalizeMode(localStorage.getItem(M.VISUALIZER_STORAGE_KEY));
|
||||||
|
if (storedMode !== "off") {
|
||||||
|
M.setCookieValue(M.VISUALIZER_STORAGE_KEY, storedMode, COOKIE_MAX_AGE);
|
||||||
|
return storedMode;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberMode(nextMode) {
|
||||||
|
const normalized = normalizeMode(nextMode);
|
||||||
|
if (normalized === "off") {
|
||||||
|
M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(M.VISUALIZER_STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
M.setCookieValue(M.VISUALIZER_STORAGE_KEY, normalized, COOKIE_MAX_AGE);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(M.VISUALIZER_STORAGE_KEY, normalized);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColors() {
|
||||||
|
const styles = getComputedStyle(shell || document.body);
|
||||||
|
const value = (name) => {
|
||||||
|
const raw = styles.getPropertyValue(name).trim();
|
||||||
|
return raw && !raw.includes("var(") ? raw : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accent: value("--visualizer-accent") || value("--accent") || "#44ee88",
|
||||||
|
secondary: value("--visualizer-secondary") || value("--warning") || "#66aaff",
|
||||||
|
background: value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)",
|
||||||
|
muted: value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
const width = Math.max(1, Math.round(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.round(rect.height * dpr));
|
||||||
|
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
if (rememberedWidth !== canvas.clientWidth || rememberedHeight !== canvas.clientHeight) {
|
||||||
|
rememberedWidth = canvas.clientWidth;
|
||||||
|
rememberedHeight = canvas.clientHeight;
|
||||||
|
seedParticles(rememberedWidth, rememberedHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas() {
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
resizeCanvas();
|
||||||
|
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedParticles(width, height) {
|
||||||
|
particles = [];
|
||||||
|
const safeWidth = Math.max(width, 1);
|
||||||
|
const safeHeight = Math.max(height, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: Math.random() * safeWidth,
|
||||||
|
y: Math.random() * safeHeight,
|
||||||
|
px: Math.random() * safeWidth,
|
||||||
|
py: Math.random() * safeHeight,
|
||||||
|
vx: (Math.random() - 0.5) * 0.55,
|
||||||
|
vy: (Math.random() - 0.5) * 0.55,
|
||||||
|
size: 0.8 + Math.random() * 2.4,
|
||||||
|
phase: Math.random() * TWO_PI,
|
||||||
|
spin: (Math.random() - 0.5) * 0.04,
|
||||||
|
lane: Math.random()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAudioGraph() {
|
||||||
|
if (source) return true;
|
||||||
|
if (graphUnavailable) return false;
|
||||||
|
|
||||||
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContextCtor) {
|
||||||
|
graphUnavailable = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioContext = audioContext || new AudioContextCtor();
|
||||||
|
analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
analyser.minDecibels = -96;
|
||||||
|
analyser.maxDecibels = -10;
|
||||||
|
analyser.smoothingTimeConstant = 0.68;
|
||||||
|
|
||||||
|
source = audioContext.createMediaElementSource(M.audio);
|
||||||
|
// Topology: source -> gainNode -> analyser -> destination.
|
||||||
|
// gainNode is shared with ReplayGain (M.gainNode); analyser is a
|
||||||
|
// transparent pass-through, so visualizer-off is unaffected.
|
||||||
|
gainNode = audioContext.createGain();
|
||||||
|
source.connect(gainNode);
|
||||||
|
gainNode.connect(analyser);
|
||||||
|
analyser.connect(audioContext.destination);
|
||||||
|
M.gainNode = gainNode;
|
||||||
|
|
||||||
|
frequencyData = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
waveformData = new Uint8Array(analyser.fftSize);
|
||||||
|
|
||||||
|
// Graph may be built after the first track arrives; re-apply current gain.
|
||||||
|
M.applyReplayGain?.(M.currentTrack);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
graphUnavailable = true;
|
||||||
|
console.warn("[Visualizer] Unable to attach audio graph", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the audio graph (if not already) and resume the context if needed.
|
||||||
|
// Used by ReplayGain so the gain node exists even with the visualizer off.
|
||||||
|
M.ensureAudioGraph = function() {
|
||||||
|
if (!initAudioGraph()) return false;
|
||||||
|
if (audioContext && audioContext.state === "suspended") {
|
||||||
|
audioContext.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
function startVisualizer() {
|
||||||
|
if (animationId || mode === "off") return;
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVisualizer() {
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
animationId = 0;
|
||||||
|
}
|
||||||
|
clearCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(data, start, end) {
|
||||||
|
let total = 0;
|
||||||
|
const first = Math.max(0, Math.min(start, data.length));
|
||||||
|
const last = Math.max(first + 1, Math.min(end, data.length));
|
||||||
|
for (let i = first; i < last; i++) total += data[i];
|
||||||
|
return total / (last - first);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandAverage(startRatio, endRatio) {
|
||||||
|
if (!frequencyData) return 0;
|
||||||
|
const start = Math.floor(Math.max(0, Math.min(1, startRatio)) * frequencyData.length);
|
||||||
|
const end = Math.floor(Math.max(0, Math.min(1, endRatio)) * frequencyData.length);
|
||||||
|
return average(frequencyData, start, Math.max(start + 1, end)) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandValue(index, count) {
|
||||||
|
if (!frequencyData) return 0;
|
||||||
|
const a = Math.pow(index / count, 1.75);
|
||||||
|
const b = Math.pow((index + 1) / count, 1.75);
|
||||||
|
const start = Math.floor(a * frequencyData.length);
|
||||||
|
const end = Math.max(start + 2, Math.floor(b * frequencyData.length));
|
||||||
|
const raw = average(frequencyData, start, end) / 255;
|
||||||
|
return Math.pow(raw, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAudioState(timestamp, hasLiveGraph) {
|
||||||
|
if (hasLiveGraph) {
|
||||||
|
analyser.getByteFrequencyData(frequencyData);
|
||||||
|
analyser.getByteTimeDomainData(waveformData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bass = hasLiveGraph ? bandAverage(0.005, 0.06) : 0.55 + Math.sin(timestamp / 310) * 0.25;
|
||||||
|
const mids = hasLiveGraph ? bandAverage(0.06, 0.36) : 0.45 + Math.sin(timestamp / 470 + 1.4) * 0.22;
|
||||||
|
const treble = hasLiveGraph ? bandAverage(0.36, 0.92) : 0.38 + Math.sin(timestamp / 230 + 2.2) * 0.18;
|
||||||
|
const energy = Math.max(0, Math.min(1, bass * 0.42 + mids * 0.36 + treble * 0.22));
|
||||||
|
|
||||||
|
beatMemory = Math.max(beatMemory * 0.9, bass);
|
||||||
|
energyMemory = energyMemory * 0.82 + energy * 0.18;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bass,
|
||||||
|
mids,
|
||||||
|
treble,
|
||||||
|
energy,
|
||||||
|
beat: beatMemory,
|
||||||
|
smooth: energyMemory,
|
||||||
|
live: hasLiveGraph,
|
||||||
|
time: timestamp / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillWithTrails(width, height, colors, state) {
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.globalAlpha = state.live ? 0.24 : 0.42;
|
||||||
|
ctx.fillStyle = colors.background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
const glow = ctx.createRadialGradient(
|
||||||
|
width * (0.48 + Math.sin(state.time * 0.4) * 0.08),
|
||||||
|
height * (0.52 + Math.cos(state.time * 0.33) * 0.14),
|
||||||
|
1,
|
||||||
|
width * 0.5,
|
||||||
|
height * 0.5,
|
||||||
|
Math.max(width, height) * (0.4 + state.energy * 0.25)
|
||||||
|
);
|
||||||
|
glow.addColorStop(0, colors.secondary);
|
||||||
|
glow.addColorStop(0.38, colors.accent);
|
||||||
|
glow.addColorStop(1, "transparent");
|
||||||
|
ctx.globalAlpha = 0.08 + state.energy * 0.1;
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(width, height, colors, state) {
|
||||||
|
const spacing = Math.max(12, Math.min(34, width / 34));
|
||||||
|
const offset = (state.time * (30 + state.treble * 80)) % spacing;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.11 + state.energy * 0.08;
|
||||||
|
ctx.strokeStyle = colors.muted;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let x = -spacing + offset; x < width + spacing; x += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x + Math.sin(state.time + x * 0.01) * 18, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = height + spacing - offset; y > -spacing; y -= spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y + Math.cos(state.time + y * 0.02) * 8);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.09 + state.beat * 0.08;
|
||||||
|
ctx.strokeStyle = colors.secondary;
|
||||||
|
for (let y = 0; y < height; y += 4) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles(width, height, colors, state, intensity = 1) {
|
||||||
|
if (!particles.length) seedParticles(width, height);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (const p of particles) {
|
||||||
|
p.px = p.x;
|
||||||
|
p.py = p.y;
|
||||||
|
|
||||||
|
const wave = Math.sin(state.time * 2.2 + p.phase) * 0.65 + Math.cos(state.time + p.lane * 8) * 0.35;
|
||||||
|
const speed = (0.6 + state.energy * 3.2) * intensity;
|
||||||
|
p.vx += Math.cos(p.phase + state.time * (0.8 + p.lane)) * 0.015 * speed;
|
||||||
|
p.vy += Math.sin(p.phase * 1.7 + state.time * 1.15) * 0.018 * speed;
|
||||||
|
p.x += p.vx * speed + wave * state.treble * 1.8;
|
||||||
|
p.y += p.vy * speed + Math.sin(state.time * 1.4 + p.x * 0.015) * state.bass * 1.6;
|
||||||
|
p.vx *= 0.965;
|
||||||
|
p.vy *= 0.965;
|
||||||
|
p.phase += p.spin + state.energy * 0.012;
|
||||||
|
|
||||||
|
if (p.x < -20) p.x = width + 20;
|
||||||
|
if (p.x > width + 20) p.x = -20;
|
||||||
|
if (p.y < -20) p.y = height + 20;
|
||||||
|
if (p.y > height + 20) p.y = -20;
|
||||||
|
|
||||||
|
const useSecondary = p.lane > 0.52;
|
||||||
|
ctx.strokeStyle = useSecondary ? colors.secondary : colors.accent;
|
||||||
|
ctx.fillStyle = useSecondary ? colors.secondary : colors.accent;
|
||||||
|
ctx.globalAlpha = 0.14 + state.energy * 0.34;
|
||||||
|
ctx.lineWidth = Math.max(0.8, p.size * (0.5 + state.beat * 1.4));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.px, p.py);
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (frame % 2 === 0 || state.energy > 0.52) {
|
||||||
|
ctx.globalAlpha = 0.16 + state.treble * 0.45;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size * (0.7 + state.beat * 1.8), 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpectrumRibbon(width, height, colors, state, flipped = false) {
|
||||||
|
const count = Math.max(44, Math.min(150, Math.floor(width / 7)));
|
||||||
|
const step = width / count;
|
||||||
|
const baseY = flipped ? height * 0.74 : height * 0.26;
|
||||||
|
const direction = flipped ? -1 : 1;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = flipped ? colors.secondary : colors.accent;
|
||||||
|
ctx.fillStyle = flipped ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1.4 + state.energy * 2.4;
|
||||||
|
ctx.globalAlpha = 0.33 + state.energy * 0.25;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
for (let i = 0; i <= count; i++) {
|
||||||
|
const value = state.live ? bandValue(i % count, count) : 0.35 + Math.sin(state.time * 3 + i * 0.27) * 0.26;
|
||||||
|
const x = i * step;
|
||||||
|
const y = baseY + direction * (value * height * 0.3 + Math.sin(state.time * 2 + i * 0.18) * height * 0.035);
|
||||||
|
if (i === 0) ctx.moveTo(x, baseY);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.lineTo(width, baseY);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.78;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaveformRibbon(width, height, colors, state, amplitude = 0.32, offset = 0, color = colors.accent) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.2 + state.energy * 3.2;
|
||||||
|
ctx.globalAlpha = 0.4 + state.energy * 0.36;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const samples = state.live && waveformData ? waveformData.length : 720;
|
||||||
|
const step = Math.max(2, Math.floor(samples / Math.max(90, Math.min(240, width / 4))));
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i += step) {
|
||||||
|
const t = i / Math.max(1, samples - 1);
|
||||||
|
const raw = state.live ? (waveformData[i] - 128) / 128 : Math.sin(t * TWO_PI * 5 + state.time * 3 + offset) * 0.55 + Math.sin(t * TWO_PI * 17 - state.time * 2) * 0.15;
|
||||||
|
const x = t * width;
|
||||||
|
const y = height * 0.5 + raw * height * amplitude + Math.sin(t * TWO_PI * 2 + state.time + offset) * state.bass * height * 0.08;
|
||||||
|
if (first) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBars(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, false);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, true);
|
||||||
|
|
||||||
|
const count = Math.max(56, Math.min(180, Math.floor(width / 5)));
|
||||||
|
const gap = Math.max(1, Math.min(3, width / 380));
|
||||||
|
const barWidth = Math.max(2, width / count - gap);
|
||||||
|
const centerY = height / 2;
|
||||||
|
const gradient = ctx.createLinearGradient(0, height, 0, 0);
|
||||||
|
gradient.addColorStop(0, colors.secondary);
|
||||||
|
gradient.addColorStop(0.5, colors.accent);
|
||||||
|
gradient.addColorStop(1, colors.secondary);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.shadowColor = colors.accent;
|
||||||
|
ctx.shadowBlur = 10 + state.beat * 20;
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const value = state.live ? bandValue(i, count) : 0.28 + Math.sin(state.time * 4 + i * 0.21) * 0.25 + Math.sin(state.time * 2.1 + i * 0.07) * 0.16;
|
||||||
|
const pulse = Math.max(0, Math.min(1, value + state.beat * 0.18));
|
||||||
|
const barHeight = Math.max(2, pulse * height * 0.47);
|
||||||
|
const x = i * (barWidth + gap);
|
||||||
|
const skew = Math.sin(state.time * 3 + i * 0.25) * state.treble * 5;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.45 + pulse * 0.55;
|
||||||
|
ctx.fillRect(x + skew, centerY - barHeight, barWidth, barHeight);
|
||||||
|
ctx.fillRect(x - skew, centerY, barWidth, barHeight);
|
||||||
|
|
||||||
|
if (i % 3 === 0) {
|
||||||
|
ctx.globalAlpha = 0.35 + state.treble * 0.4;
|
||||||
|
ctx.fillRect(x, centerY - barHeight - 4 - state.treble * 9, barWidth, 2);
|
||||||
|
ctx.fillRect(x, centerY + barHeight + 2 + state.treble * 9, barWidth, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.18, 0, colors.secondary);
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.1, Math.PI, colors.accent);
|
||||||
|
drawParticles(width, height, colors, state, 1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWave(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
drawSpectrumRibbon(width, height, colors, state, false);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
for (let layer = 0; layer < 6; layer++) {
|
||||||
|
const color = layer % 2 ? colors.secondary : colors.accent;
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.13 + layer * 0.045, layer * 0.9 + state.time * 0.4, color);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const nodes = 34;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
for (let i = 0; i < nodes; i++) {
|
||||||
|
const t = i / (nodes - 1);
|
||||||
|
const sampleIndex = state.live ? Math.floor(t * (waveformData.length - 1)) : 0;
|
||||||
|
const wave = state.live ? (waveformData[sampleIndex] - 128) / 128 : Math.sin(t * TWO_PI * 8 + state.time * 3);
|
||||||
|
const value = state.live ? bandValue(i, nodes) : Math.abs(wave);
|
||||||
|
const x = t * width;
|
||||||
|
const y = height * 0.5 + wave * height * 0.28;
|
||||||
|
const radius = 2 + value * 13 + state.beat * 6;
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.14 + value * 0.55;
|
||||||
|
ctx.fillStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawParticles(width, height, colors, state, 1.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRadial(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const radius = Math.max(14, Math.min(width, height) * (0.14 + state.bass * 0.08));
|
||||||
|
const spokes = 192;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(state.time * (0.18 + state.treble * 0.22));
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
|
||||||
|
for (let ring = 0; ring < 3; ring++) {
|
||||||
|
const ringRadius = radius + ring * Math.min(width, height) * 0.105 + state.beat * 18;
|
||||||
|
ctx.globalAlpha = 0.18 + ring * 0.08;
|
||||||
|
ctx.strokeStyle = ring % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1 + state.energy * 2.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i <= spokes; i++) {
|
||||||
|
const value = state.live ? bandValue(i % spokes, spokes) : 0.34 + Math.sin(state.time * 4 + i * 0.16 + ring) * 0.24;
|
||||||
|
const angle = i / spokes * TWO_PI;
|
||||||
|
const waveRadius = ringRadius + value * Math.min(width, height) * (0.08 + ring * 0.035);
|
||||||
|
const x = Math.cos(angle) * waveRadius;
|
||||||
|
const y = Math.sin(angle) * waveRadius;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < spokes; i++) {
|
||||||
|
const value = state.live ? bandValue(i, spokes) : 0.28 + Math.sin(state.time * 6 + i * 0.23) * 0.24;
|
||||||
|
const angle = i / spokes * TWO_PI;
|
||||||
|
const start = radius * (0.68 + Math.sin(state.time + i) * 0.04);
|
||||||
|
const end = start + value * Math.min(width, height) * 0.42 + state.beat * 28;
|
||||||
|
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.globalAlpha = 0.16 + value * 0.72;
|
||||||
|
ctx.lineWidth = 0.9 + value * 3.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(Math.cos(angle) * start, Math.sin(angle) * start);
|
||||||
|
ctx.lineTo(Math.cos(angle) * end, Math.sin(angle) * end);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.22 + state.beat * 0.26;
|
||||||
|
ctx.fillStyle = colors.accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, radius * (0.55 + state.beat * 0.45), 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawParticles(width, height, colors, state, 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPulse(width, height, colors, state) {
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const maxRadius = Math.hypot(width, height) * 0.55;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const phase = (state.time * (0.18 + state.energy * 0.24) + i / 10) % 1;
|
||||||
|
const radius = phase * maxRadius;
|
||||||
|
ctx.globalAlpha = (1 - phase) * (0.1 + state.beat * 0.22);
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 1 + state.energy * 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, TWO_PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rays = 96;
|
||||||
|
for (let i = 0; i < rays; i++) {
|
||||||
|
const value = state.live ? bandValue(i, rays) : 0.32 + Math.sin(state.time * 7 + i * 0.31) * 0.25;
|
||||||
|
const angle = i / rays * TWO_PI + state.time * 0.25;
|
||||||
|
const length = Math.min(width, height) * (0.16 + value * 0.7 + state.beat * 0.22);
|
||||||
|
const wobble = Math.sin(state.time * 3 + i * 0.4) * state.treble * 18;
|
||||||
|
ctx.globalAlpha = 0.15 + value * 0.58;
|
||||||
|
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
|
||||||
|
ctx.lineWidth = 0.9 + value * 3.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(angle) * 8, cy + Math.sin(angle) * 8);
|
||||||
|
ctx.lineTo(cx + Math.cos(angle) * (length + wobble), cy + Math.sin(angle) * (length - wobble));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreRadius = Math.max(8, Math.min(width, height) * (0.1 + state.beat * 0.18));
|
||||||
|
ctx.globalAlpha = 0.3 + state.beat * 0.45;
|
||||||
|
ctx.fillStyle = colors.secondary;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, coreRadius * 2.2, 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.8;
|
||||||
|
ctx.fillStyle = colors.accent;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, coreRadius, 0, TWO_PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawWaveformRibbon(width, height, colors, state, 0.2, state.time, colors.secondary);
|
||||||
|
drawParticles(width, height, colors, state, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawIdle(width, height, colors, timestamp) {
|
||||||
|
const state = readAudioState(timestamp, false);
|
||||||
|
fillWithTrails(width, height, colors, state);
|
||||||
|
drawGrid(width, height, colors, state);
|
||||||
|
|
||||||
|
if (mode === "radial") drawRadial(width, height, colors, state);
|
||||||
|
else if (mode === "pulse") drawPulse(width, height, colors, state);
|
||||||
|
else if (mode === "wave") drawWave(width, height, colors, state);
|
||||||
|
else drawBars(width, height, colors, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(timestamp) {
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
if (!canvas || !ctx || mode === "off") return;
|
||||||
|
|
||||||
|
frame++;
|
||||||
|
resizeCanvas();
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
const colors = getColors();
|
||||||
|
const hasLiveGraph = analyser
|
||||||
|
&& frequencyData
|
||||||
|
&& waveformData
|
||||||
|
&& audioContext?.state === "running"
|
||||||
|
&& !M.audio.paused
|
||||||
|
&& !!M.audio.src;
|
||||||
|
|
||||||
|
if (!hasLiveGraph) {
|
||||||
|
drawIdle(width, height, colors, timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readAudioState(timestamp, true);
|
||||||
|
fillWithTrails(width, height, colors, state);
|
||||||
|
|
||||||
|
if (mode === "wave") drawWave(width, height, colors, state);
|
||||||
|
else if (mode === "radial") drawRadial(width, height, colors, state);
|
||||||
|
else if (mode === "pulse") drawPulse(width, height, colors, state);
|
||||||
|
else drawBars(width, height, colors, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullscreen() {
|
||||||
|
return document.fullscreenElement === shell
|
||||||
|
|| document.webkitFullscreenElement === shell
|
||||||
|
|| shell?.classList.contains("is-fullscreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFullscreenButton() {
|
||||||
|
if (!fullscreenButton || !shell) return;
|
||||||
|
const active = isFullscreen();
|
||||||
|
shell.classList.toggle("is-fullscreen", active);
|
||||||
|
fullscreenButton.textContent = active ? "×" : "⛶";
|
||||||
|
fullscreenButton.title = active ? "Exit fullscreen visualizer" : "Fullscreen visualizer";
|
||||||
|
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!shell) return;
|
||||||
|
if (mode === "off") M.applyVisualizer("bars");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement || document.webkitFullscreenElement || shell.classList.contains("is-fullscreen")) {
|
||||||
|
if (document.fullscreenElement && document.exitFullscreen) await document.exitFullscreen();
|
||||||
|
else if (document.webkitFullscreenElement && document.webkitExitFullscreen) await document.webkitExitFullscreen();
|
||||||
|
else shell.classList.remove("is-fullscreen");
|
||||||
|
} else if (shell.requestFullscreen) {
|
||||||
|
await shell.requestFullscreen();
|
||||||
|
} else if (shell.webkitRequestFullscreen) {
|
||||||
|
await shell.webkitRequestFullscreen();
|
||||||
|
} else {
|
||||||
|
shell.classList.add("is-fullscreen");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
shell.classList.toggle("is-fullscreen");
|
||||||
|
console.warn("[Visualizer] Fullscreen request failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
M.resumeVisualizer?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
M.applyVisualizer = function(nextMode, remember = true) {
|
||||||
|
mode = normalizeMode(nextMode);
|
||||||
|
if (selector) selector.value = mode;
|
||||||
|
if (shell) shell.classList.toggle("hidden", mode === "off");
|
||||||
|
document.getElementById("player-content")?.classList.toggle("visualizer-active", mode !== "off");
|
||||||
|
if (remember) rememberMode(mode);
|
||||||
|
|
||||||
|
if (mode === "off") {
|
||||||
|
stopVisualizer();
|
||||||
|
} else {
|
||||||
|
resizeCanvas();
|
||||||
|
startVisualizer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.resumeVisualizer = async function() {
|
||||||
|
if (mode === "off") return false;
|
||||||
|
if (!initAudioGraph()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (audioContext.state === "suspended") {
|
||||||
|
await audioContext.resume();
|
||||||
|
}
|
||||||
|
startVisualizer();
|
||||||
|
return audioContext.state === "running";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Visualizer] Unable to resume audio context", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
M.visualizer = {
|
||||||
|
getMode: () => mode,
|
||||||
|
hasGraph: () => !!source,
|
||||||
|
isRunning: () => audioContext?.state === "running",
|
||||||
|
isFullscreen,
|
||||||
|
toggleFullscreen
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
shell = document.getElementById("visualizer-shell");
|
||||||
|
canvas = document.getElementById("visualizer-canvas");
|
||||||
|
selector = document.getElementById("visualizer-select");
|
||||||
|
fullscreenButton = document.getElementById("btn-visualizer-fullscreen");
|
||||||
|
ctx = canvas?.getContext("2d") || null;
|
||||||
|
|
||||||
|
M.applyVisualizer(getSavedMode(), false);
|
||||||
|
updateFullscreenButton();
|
||||||
|
|
||||||
|
if (selector) {
|
||||||
|
selector.onchange = () => {
|
||||||
|
M.applyVisualizer(selector.value);
|
||||||
|
M.resumeVisualizer();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenButton) {
|
||||||
|
fullscreenButton.onclick = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleFullscreen();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.ResizeObserver && shell) {
|
||||||
|
const observer = new ResizeObserver(resizeCanvas);
|
||||||
|
observer.observe(shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", resizeCanvas);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("fullscreenchange", () => {
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
});
|
||||||
|
document.addEventListener("webkitfullscreenchange", () => {
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", () => {
|
||||||
|
if (mode !== "off") M.resumeVisualizer();
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && shell?.classList.contains("is-fullscreen") && !document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||||
|
shell.classList.remove("is-fullscreen");
|
||||||
|
updateFullscreenButton();
|
||||||
|
resizeCanvas();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode !== "off") M.resumeVisualizer();
|
||||||
|
});
|
||||||
|
|
||||||
|
M.audio.addEventListener("play", () => {
|
||||||
|
// Resume context whenever the graph exists (visualizer or replaygain path),
|
||||||
|
// since routing through it requires a running context to produce sound.
|
||||||
|
if (audioContext && audioContext.state === "suspended") {
|
||||||
|
audioContext.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
if (mode !== "off") {
|
||||||
|
startVisualizer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
M.audio.addEventListener("pause", () => {
|
||||||
|
if (mode !== "off") startVisualizer();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
grantPermission,
|
grantPermission,
|
||||||
revokePermission,
|
revokePermission,
|
||||||
|
getAllUserPreferences,
|
||||||
|
setUserPreference,
|
||||||
} from "../db";
|
} from "../db";
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
|
|
@ -93,6 +95,17 @@ export function handleLogout(req: Request): Response {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelisted per-account preference keys (key -> parser/normalizer).
|
||||||
|
// Add new keys here as features need per-account persistence.
|
||||||
|
const PREFERENCE_KEYS: Record<string, (raw: unknown) => string | null> = {
|
||||||
|
replaygain_enabled: (raw) => (raw === true || raw === "true" ? "true" : raw === false || raw === "false" ? "false" : null),
|
||||||
|
replaygain_preamp: (raw) => {
|
||||||
|
const n = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return String(Math.max(-24, Math.min(24, n)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Auth: get current user
|
// Auth: get current user
|
||||||
export function handleGetMe(req: Request, server: any): Response {
|
export function handleGetMe(req: Request, server: any): Response {
|
||||||
const { user, headers } = getOrCreateUser(req, server);
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
|
@ -116,9 +129,38 @@ export function handleGetMe(req: Request, server: any): Response {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest },
|
user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest },
|
||||||
permissions: effectivePermissions,
|
permissions: effectivePermissions,
|
||||||
|
preferences: getAllUserPreferences(user.id),
|
||||||
}, { headers });
|
}, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preferences: update per-account preferences (whitelisted keys only)
|
||||||
|
export async function handleUpdatePreferences(req: Request, server: any): Promise<Response> {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
}
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: "Expected an object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied: Record<string, string> = {};
|
||||||
|
for (const [key, raw] of Object.entries(body)) {
|
||||||
|
const normalize = PREFERENCE_KEYS[key];
|
||||||
|
if (!normalize) continue; // ignore unknown keys
|
||||||
|
const value = normalize(raw);
|
||||||
|
if (value == null) continue; // ignore invalid values
|
||||||
|
setUserPreference(user.id, key, value);
|
||||||
|
applied[key] = value;
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, preferences: applied });
|
||||||
|
}
|
||||||
|
|
||||||
// Kick all other clients for current user
|
// Kick all other clients for current user
|
||||||
export function handleKickOthers(req: Request, server: any): Response {
|
export function handleKickOthers(req: Request, server: any): Response {
|
||||||
const { user } = getOrCreateUser(req, server);
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
handleLogin,
|
handleLogin,
|
||||||
handleLogout,
|
handleLogout,
|
||||||
handleGetMe,
|
handleGetMe,
|
||||||
|
handleUpdatePreferences,
|
||||||
handleKickOthers,
|
handleKickOthers,
|
||||||
handleListUsers,
|
handleListUsers,
|
||||||
handleGrantPermission,
|
handleGrantPermission,
|
||||||
|
|
@ -212,6 +213,9 @@ export function createRouter() {
|
||||||
if (path === "/api/auth/me") {
|
if (path === "/api/auth/me") {
|
||||||
return handleGetMe(req, server);
|
return handleGetMe(req, server);
|
||||||
}
|
}
|
||||||
|
if (path === "/api/auth/me/preferences" && req.method === "PUT") {
|
||||||
|
return handleUpdatePreferences(req, server);
|
||||||
|
}
|
||||||
if (path === "/api/auth/kick-others" && req.method === "POST") {
|
if (path === "/api/auth/kick-others" && req.method === "POST") {
|
||||||
return handleKickOthers(req, server);
|
return handleKickOthers(req, server);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,27 @@ import { join } from "path";
|
||||||
import { PUBLIC_DIR } from "../config";
|
import { PUBLIC_DIR } from "../config";
|
||||||
|
|
||||||
// Serve static files
|
// Serve static files
|
||||||
|
// Defense-in-depth CSP: no inline scripts, same-origin + ws(s) connections,
|
||||||
|
// blob: media for cached audio, data:/blob: images, inline styles allowed
|
||||||
|
// (style attributes are used throughout the client).
|
||||||
|
const CSP = "default-src 'self'; script-src 'self'; connect-src 'self' ws: wss:; media-src 'self' blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'";
|
||||||
|
|
||||||
export async function handleStatic(path: string): Promise<Response | null> {
|
export async function handleStatic(path: string): Promise<Response | null> {
|
||||||
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
|
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
|
||||||
return new Response(file(join(PUBLIC_DIR, "index.html")), {
|
return new Response(file(join(PUBLIC_DIR, "index.html")), {
|
||||||
headers: { "Content-Type": "text/html" },
|
headers: { "Content-Type": "text/html", "Content-Security-Policy": CSP },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/styles.css") {
|
if (path === "/styles.css") {
|
||||||
return new Response(file(join(PUBLIC_DIR, "styles.css")), {
|
return new Response(file(join(PUBLIC_DIR, "styles.css")), {
|
||||||
headers: { "Content-Type": "text/css" },
|
headers: { "Content-Type": "text/css", "Content-Security-Policy": CSP },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/favicon.ico") {
|
if (path === "/favicon.ico") {
|
||||||
return new Response(file(join(PUBLIC_DIR, "favicon.ico")), {
|
return new Response(file(join(PUBLIC_DIR, "favicon.ico")), {
|
||||||
headers: { "Content-Type": "image/x-icon" },
|
headers: { "Content-Type": "image/x-icon", "Content-Security-Policy": CSP },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +31,7 @@ export async function handleStatic(path: string): Promise<Response | null> {
|
||||||
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
|
||||||
if (await jsFile.exists()) {
|
if (await jsFile.exists()) {
|
||||||
return new Response(jsFile, {
|
return new Response(jsFile, {
|
||||||
headers: { "Content-Type": "application/javascript" },
|
headers: { "Content-Type": "application/javascript", "Content-Security-Policy": CSP },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export function handleGetLibrary(req: Request, server: any): Response {
|
||||||
duration: t.duration,
|
duration: t.duration,
|
||||||
replayGainDb: t.replayGainDb,
|
replayGainDb: t.replayGainDb,
|
||||||
replayPeak: t.replayPeak,
|
replayPeak: t.replayPeak,
|
||||||
|
createdAt: t.created_at,
|
||||||
available: t.available,
|
available: t.available,
|
||||||
}));
|
}));
|
||||||
return Response.json(tracks, { headers });
|
return Response.json(tracks, { headers });
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { init } from "./init";
|
||||||
import { createRouter } from "./routes";
|
import { createRouter } from "./routes";
|
||||||
import { websocketHandlers } from "./websocket";
|
import { websocketHandlers } from "./websocket";
|
||||||
|
|
||||||
|
|
||||||
await init();
|
await init();
|
||||||
|
|
||||||
serve({
|
serve({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# TODO — Implementation Plans
|
||||||
|
|
||||||
|
Eight plans derived from the architecture assessment, ordered by priority and with
|
||||||
|
effort/risk so you can sequence the work. Each lives in `todo/<topic>/overview.md`.
|
||||||
|
|
||||||
|
## Critical / High — do first (all low effort, low risk)
|
||||||
|
|
||||||
|
| # | Topic | Priority | Effort | Depends on |
|
||||||
|
|---|-------|----------|--------|------------|
|
||||||
|
| 1 | [xss-fixes](xss-fixes/overview.md) — escape server data at `innerHTML`; add CSP | Critical | Low | — |
|
||||||
|
| 2 | [websocket-robustness](websocket-robustness/overview.md) — try/catch parse, reconnect backoff, dead-channel fallback | High | Low–Med | — |
|
||||||
|
| 3 | [ws-guest-control-permission](ws-guest-control-permission/overview.md) — route WS control through `userHasPermission` | High | Trivial | — |
|
||||||
|
| 4 | [channel-get-auth](channel-get-auth/overview.md) — add auth to `GET /api/channels/:id` | High | Trivial | — |
|
||||||
|
|
||||||
|
Items 1–4 are independent and can be done in parallel. Each is a small, isolated change.
|
||||||
|
|
||||||
|
## Medium — correctness and efficiency
|
||||||
|
|
||||||
|
| # | Topic | Priority | Effort | Depends on |
|
||||||
|
|---|-------|----------|--------|------------|
|
||||||
|
| 5 | [fetch-response-validation](fetch-response-validation/overview.md) — `res.ok` checks before `res.json()` | Medium | Low | — |
|
||||||
|
| 6 | [duplicate-download-race](duplicate-download-race/overview.md) — synchronous check-and-claim for bulk downloads | Medium | Low | — |
|
||||||
|
| 7 | [client-cache-correctness](client-cache-correctness/overview.md) — revoke blob URLs, LRU prune, real `streamOnly` | Medium | Medium | #6 (shares `downloadAndCacheTrack`) |
|
||||||
|
|
||||||
|
## Strategic — refactor, do last
|
||||||
|
|
||||||
|
| # | Topic | Priority | Effort | Depends on |
|
||||||
|
|---|-------|----------|--------|------------|
|
||||||
|
| 8 | [client-store-and-windowing](client-store-and-windowing/overview.md) — virtualize list + minimal store + decompose god module | Strategic | High | #1, #6, #7 (inherit correct behavior first) |
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1, 3, 4 (one-line/server-side, ship immediately) → 2 → 5 → 6 → 7 → 8.
|
||||||
|
|
||||||
|
All plans include affected file:line references, a step-by-step implementation
|
||||||
|
sketch, validation steps, and rollback notes.
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Add Authentication to `GET /api/channels/:id`
|
||||||
|
|
||||||
|
Priority: **High** · Effort: **Trivial (3 lines)** · Risk: **Low**
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Every sibling channel endpoint calls `getOrCreateUser(req, server)` and returns 401 if there is no user. The GET-state endpoint does not:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// routes/channels.ts:167-171
|
||||||
|
export function handleGetChannel(channelId: string): Response {
|
||||||
|
const channel = state.channels.get(channelId);
|
||||||
|
if (!channel) return new Response("Not found", { status: 404 });
|
||||||
|
return Response.json(channel.getState());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Channel.getState()` (`channel.ts:142-159`) returns the queue, `currentIndex`, listener count, the `isDefault` flag, and (when requested) the full track list. Any unauthenticated caller can enumerate and read the state of every channel.
|
||||||
|
|
||||||
|
## Affected Location
|
||||||
|
|
||||||
|
- `routes/channels.ts:167-171` — `handleGetChannel`, missing auth.
|
||||||
|
|
||||||
|
Also note: this handler's signature differs from its siblings (it takes only `channelId`, not `req, server, channelId`). The router call at `routes/index.ts:111-114` reflects this. Both must be updated.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1 — Update the handler signature and add the auth check
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// routes/channels.ts
|
||||||
|
import { getOrCreateUser } from "./helpers";
|
||||||
|
|
||||||
|
// GET /api/channels/:id - get channel state
|
||||||
|
export function handleGetChannel(req: Request, server: any, channelId: string): Response {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const channel = state.channels.get(channelId);
|
||||||
|
if (!channel) return new Response("Not found", { status: 404 });
|
||||||
|
return Response.json(channel.getState());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Update the router call site
|
||||||
|
|
||||||
|
`routes/index.ts:111-114`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const channelGetMatch = path.match(/^\/api\/channels\/([^/]+)$/);
|
||||||
|
if (channelGetMatch && req.method === "GET") {
|
||||||
|
return handleGetChannel(req, server, channelGetMatch[1]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Pass `req` and `server` through, matching the DELETE/PATCH handlers above.)
|
||||||
|
|
||||||
|
### Step 3 — Consider whether state should be filtered by listener
|
||||||
|
|
||||||
|
Today `getState()` does not return the `listeners` array (only `listenerCount`) — good. Confirm this remains true; if `listeners` is ever added to `getState()`, also gate it behind the same ownership/permission rules used by `getListInfo()` (`channel.ts:377-389`), which *does* expose usernames. (Out of scope for this fix, but worth a note: the channel *list* endpoint exposes listener usernames to any authenticated user — acceptable for a listen-along app, but verify it matches the product intent.)
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **Unauthenticated request is rejected**: `curl -i http://localhost:3001/api/channels/main` → expect `401` (previously `200` with full state).
|
||||||
|
- **Authenticated request still works**: `curl -i -b cookies.txt http://localhost:3001/api/channels/main` → expect `200` with state JSON.
|
||||||
|
- **Guest access**: with `allowGuests: true`, an unauthenticated curl should now receive a `Set-Cookie` guest session *and* still be able to read state on the next request (guests can listen). Confirm the second request returns 200.
|
||||||
|
- **Client still works**: load the app, switch channels, confirm no regression (the client always sends the session cookie).
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- The client already authenticates every request via cookie, so legitimate UI is unaffected.
|
||||||
|
- If any external/SSR/integration consumer relied on the open endpoint, they will now get 401 — re-grant via a real session. This was never intended public surface.
|
||||||
|
- Rollback = revert the handler signature and the router call.
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
# Client Cache Correctness: Revoke Blob URLs, LRU Pruning, Real `streamOnly`
|
||||||
|
|
||||||
|
Priority: **Medium** · Effort: **Medium** · Risk: **Low**
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Three independent correctness/efficiency bugs in the client caching layer:
|
||||||
|
|
||||||
|
1. **Blob URL leak.** `URL.createObjectURL(blob)` results stored in `M.trackBlobs` (`public/audioCache.js:80,127`) are never revoked. `M.trackBlobs.clear()` (`queue.js:188`) drops the references but the URLs stay live in the document until unload — a slow memory leak over long sessions with many cached tracks.
|
||||||
|
|
||||||
|
2. **`pruneCache` is expensive and non-LRU.** `public/audioCache.js:27-54` calls `TrackStorage.get(key)` for every cached track (pulling each blob out of IndexedDB) just to read `.size`, and evicts oldest-first by Map iteration order — *not* by the `cachedAt` timestamp that is actually written at `public/trackStorage.js:86`. So eviction is arbitrary, not least-recently-used.
|
||||||
|
|
||||||
|
3. **`streamOnly` doesn't do what it says.** The flag is stored (`audioCache.js:8-15`) and checked by exactly one context-menu item (`trackContainer.js:752`), but the background prefetch loop (`audioCache.js:226`) never consults it — so "stream-only mode" still fetches every segment in the background and still triggers bulk caching.
|
||||||
|
|
||||||
|
## Affected Locations
|
||||||
|
|
||||||
|
- `public/audioCache.js:27-54` — `pruneCache`
|
||||||
|
- `public/audioCache.js:80,127` — blob URL creation
|
||||||
|
- `public/audioCache.js:224-262` — prefetch loop (no `streamOnly` check)
|
||||||
|
- `public/trackStorage.js:139-155` — `getStats` (loads all blobs)
|
||||||
|
- `public/trackStorage.js:86` — `cachedAt` is written but unused
|
||||||
|
- `public/queue.js:188` — `M.trackBlobs.clear()` without revoke
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Part A — Track and revoke blob URLs
|
||||||
|
|
||||||
|
**Step A1: Track blob URLs in a single Map with a revocation helper.**
|
||||||
|
|
||||||
|
`M.trackBlobs` is already `Map<trackId, blobUrl>`. Add:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// public/audioCache.js
|
||||||
|
M.revokeTrackBlob = function (trackId) {
|
||||||
|
const url = M.trackBlobs.get(trackId);
|
||||||
|
if (url) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
M.trackBlobs.delete(trackId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step A2: Revoke on eviction and on explicit clear.**
|
||||||
|
|
||||||
|
- In `pruneCache` (after Part B), when a track is evicted from IndexedDB, also call `M.revokeTrackBlob(trackId)` so the in-memory URL is released.
|
||||||
|
- In `queue.js:188` (`clearAllCaches`), revoke every entry before clearing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
for (const url of M.trackBlobs.values()) URL.revokeObjectURL(url);
|
||||||
|
M.trackBlobs.clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
- When a track is removed from the library (`track_removed` handler in `channelSync.js`), revoke its blob if present (it can't be played anymore).
|
||||||
|
|
||||||
|
**Step A3: Don't double-revoke.** `URL.revokeObjectURL` is safe to call on an already-revoked URL, but guard with the Map check anyway to keep state clean.
|
||||||
|
|
||||||
|
### Part B — Make pruning LRU and cheap
|
||||||
|
|
||||||
|
**Step B1: Store size at write time, avoid loading blobs at prune time.**
|
||||||
|
|
||||||
|
Extend the IndexedDB record to carry `size` (the blob already has `.size` — store it alongside). In `trackStorage.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// in set(): store { filename (keyPath), blob, size, cachedAt }
|
||||||
|
const record = { filename: trackId, blob, size: blob.size, cachedAt: Date.now() };
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a lightweight metadata accessor that uses a cursor and reads only `size`/`cachedAt`, not the blob:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// trackStorage.js
|
||||||
|
getMetaList: function () {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const result = [];
|
||||||
|
const tx = db.transaction(STORE, "readonly");
|
||||||
|
const store = tx.objectStore(STORE);
|
||||||
|
const req = store.openCursor();
|
||||||
|
req.onsuccess = (e) => {
|
||||||
|
const cursor = e.target.result;
|
||||||
|
if (cursor) {
|
||||||
|
const v = cursor.value;
|
||||||
|
result.push({ id: v.filename, size: v.size || 0, cachedAt: v.cachedAt || 0 });
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onerror = () => resolve([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step B2: Rewrite `pruneCache` to be LRU and blob-free.**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// audioCache.js
|
||||||
|
async function pruneCache() {
|
||||||
|
const limit = M.cacheLimitBytes ?? 500 * 1024 * 1024; // tune as needed
|
||||||
|
const meta = await TrackStorage.getMetaList();
|
||||||
|
let total = meta.reduce((s, m) => s + m.size, 0);
|
||||||
|
if (total <= limit) return;
|
||||||
|
|
||||||
|
// Evict least-recently-used first.
|
||||||
|
meta.sort((a, b) => a.cachedAt - b.cachedAt);
|
||||||
|
for (const m of meta) {
|
||||||
|
if (total <= limit) break;
|
||||||
|
await TrackStorage.delete(m.id);
|
||||||
|
M.revokeTrackBlob(m.id);
|
||||||
|
M.cachedTracks.delete(m.id);
|
||||||
|
total -= m.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step B3: Update `cachedAt` on access** (optional, makes LRU reflect actual use). When a cached track is played (`loadTrackBlob` returns a URL), bump its `cachedAt`. If skipped, the policy becomes "first-in-first-out", which is still better than today's arbitrary order.
|
||||||
|
|
||||||
|
**Step B4: Rewrite `getStats`** to use `getMetaList` so the settings/stats panel doesn't load every blob either.
|
||||||
|
|
||||||
|
### Part C — Make `streamOnly` actually disable background fetching
|
||||||
|
|
||||||
|
**Step C1: Consult the flag in the prefetch loop.**
|
||||||
|
|
||||||
|
In `audioCache.js:226` (top of the prefetch loop):
|
||||||
|
|
||||||
|
```js
|
||||||
|
M.prefetchSegments = async function () {
|
||||||
|
if (prefetching) return;
|
||||||
|
if (M.streamOnly) return; // <-- new
|
||||||
|
prefetching = true;
|
||||||
|
try {
|
||||||
|
// ... existing logic ...
|
||||||
|
} finally {
|
||||||
|
prefetching = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step C2: Also short-circuit the buffered-range-scan-triggered bulk download when in stream-only mode**, since streaming clients don't want IndexedDB writes. Easiest: have `maybeStartBulkDownload` (see `todo/duplicate-download-race/overview.md`) check `M.streamOnly` and return `false`.
|
||||||
|
|
||||||
|
**Step C3: Decide intent for the already-playing track.** Even in stream-only mode the *current* track is being buffered by the browser's own media element — that's fine and desirable. The flag is about suppressing *additional* segment prefetching ahead of the playhead and *bulk* caching. Confirm the UX label reflects this ("Don't pre-cache ahead" rather than "stream nothing").
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **Blob revoke**: open DevTools → Memory, take a snapshot; play and fully cache ~10 tracks; confirm `trackBlobs` grows by 10 and the document's blob URL count grows accordingly. Trigger a prune (or set a tiny `cacheLimitBytes`) and confirm the count drops as tracks are evicted. Run `clearAllCaches()` and confirm all blob URLs are revoked.
|
||||||
|
- **LRU**: with a small cache limit, cache tracks A, B, C; play A again (bump cachedAt if Part B3 is implemented); cache D. Confirm B (not A) is evicted.
|
||||||
|
- **Prune performance**: with a large cache (e.g. 200 tracks), instrument `pruneCache` and confirm it no longer reads blob payloads — wall time should drop from "loads every blob" to a cheap cursor scan.
|
||||||
|
- **streamOnly**: toggle the flag on, play a track, seek around, and confirm via DevTools Network that only the media element's own range requests fire (no `/api/tracks/:id` segment prefetches), and IndexedDB write count stays flat. Toggle off and confirm prefetching resumes.
|
||||||
|
- **Regression**: with streamOnly off, confirm normal caching behavior (green indicators, blob swap, IndexedDB entries) is unchanged.
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- **IndexedDB schema change** (adding `size`/`cachedAt` fields to the record): old records written before this change will lack those fields. `getMetaList` must default missing fields to `0` (shown above), and `pruneCache` must handle `size === 0` gracefully (skip eviction of unknown-size entries rather than evicting everything). Consider a one-time migration that deletes records lacking `size`, or simply let them age out.
|
||||||
|
- Revoking a blob URL that is *currently* the `src` of the audio element will break playback. Only revoke blobs for tracks that are not the currently-playing track, or that have just been removed from the library. Add a guard: `if (trackId === M.currentTrackId) return;` in `revokeTrackBlob` callers that run during playback.
|
||||||
|
- streamOnly is additive; default stays off.
|
||||||
|
- Rollback per part is independent — A, B, C can land separately.
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
# Client Store & List Windowing (Strategic Refactor)
|
||||||
|
|
||||||
|
Priority: **Medium (strategic)** · Effort: **High** · Risk: **Medium**
|
||||||
|
|
||||||
|
This is the largest item of the eight. Unlike the others, it is a refactor rather than a bug fix. It should be sequenced *after* the XSS, cache, and WS work so the new structure inherits correct behavior. It can ship incrementally.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The client is a pre-module vanilla-JS app that has outgrown its structure:
|
||||||
|
|
||||||
|
1. **One mutable global** (`window.MusicRoom` / `M`, `public/core.js:4-73`) holds *all* state — audio element, WS, current track, queue, library, cache maps, permission flags, and UI bookkeeping (`lastProgressPct`, etc.). Every IIFE mutates `M` directly and calls `M.render*()`. There is no contract between modules; forward references are sometimes guarded with `M.foo && M.foo()` (`channelSync.js:502`), often not.
|
||||||
|
|
||||||
|
2. **Full re-render of every row on every change.** `trackContainer.js:101-165` clears `innerHTML` and rebuilds every track row — triggered by every WS state update, every 5s cache poll (`ui.js:175-182`), and every prefetch completion. No windowing/virtualization: a 10k-track library renders 10k `.track` divs with fresh closures each time. Search (`queue.js:196-204`) filters the full array synchronously per keystroke.
|
||||||
|
|
||||||
|
3. **God module.** `public/trackContainer.js` (972 lines) fuses rendering, drag state, selection, drop-zones, and context-menu DOM.
|
||||||
|
|
||||||
|
4. **Duplicated logic**: local-playback setup (4×), cookie get/set/clear (3×), `escapeHtml` (2×), segment-availability scan (3×).
|
||||||
|
|
||||||
|
## Affected Locations
|
||||||
|
|
||||||
|
- `public/core.js:4-73` — global state
|
||||||
|
- `public/trackContainer.js` (972 lines) — render + drag + selection + context menu
|
||||||
|
- `public/trackContainer.js:101-165` — `render()` full clear+rebuild
|
||||||
|
- `public/ui.js:175-182` — 5s cache-poll re-render trigger
|
||||||
|
- `public/queue.js:196-204` — synchronous per-keystroke filter
|
||||||
|
- All IIFEs in `public/*.js` — module pattern
|
||||||
|
|
||||||
|
## Goals (in priority order)
|
||||||
|
|
||||||
|
1. **Virtualize the library list** so 10k+ tracks render smoothly.
|
||||||
|
2. **Introduce a tiny reactive store** so state changes are explicit and modules subscribe rather than mutate globals.
|
||||||
|
3. **Decompose `trackContainer.js`** into focused modules.
|
||||||
|
4. **De-duplicate** playback setup, cookie helpers, escaping.
|
||||||
|
|
||||||
|
Non-goals: adopting a framework (React/Vue/etc.). Bun serves static files with no build step; introducing one is out of scope unless explicitly desired. This plan stays vanilla.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
The plan is staged so each phase ships value independently.
|
||||||
|
|
||||||
|
### Phase 1 — Virtualize the list (highest standalone value)
|
||||||
|
|
||||||
|
Even without a store refactor, windowing fixes the worst performance problem.
|
||||||
|
|
||||||
|
**1.1 Add a windowed renderer** for the library (and queue when long). Only render rows visible in the viewport plus a small overscan buffer (e.g. 10 rows above/below). Use a sentinel/spacer div with the full scroll height so the scrollbar stays accurate.
|
||||||
|
|
||||||
|
Sketch (`trackContainer.js`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ROW_HEIGHT = 44; // measure actual rendered row height
|
||||||
|
const OVERSCAN = 10;
|
||||||
|
|
||||||
|
function renderWindow(container, items, scrollTop, viewportHeight) {
|
||||||
|
const totalHeight = items.length * ROW_HEIGHT;
|
||||||
|
const firstVisible = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||||
|
const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN * 2;
|
||||||
|
const slice = items.slice(firstVisible, firstVisible + visibleCount);
|
||||||
|
|
||||||
|
container.innerHTML = ""; // or keep a stable spacer + content wrapper
|
||||||
|
const spacerTop = document.createElement("div");
|
||||||
|
spacerTop.style.height = `${firstVisible * ROW_HEIGHT}px`;
|
||||||
|
const spacerBottom = document.createElement("div");
|
||||||
|
spacerBottom.style.height = `${(items.length - firstVisible - slice.length) * ROW_HEIGHT}px`;
|
||||||
|
container.appendChild(spacerTop);
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
container.appendChild(renderRow(slice[i], firstVisible + i));
|
||||||
|
}
|
||||||
|
container.appendChild(spacerBottom);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1.2 Drive renders from `scroll` events** (passive listener) and `requestAnimationFrame`-throttled. Debounce on resize. Do *not* re-render on every WS update — instead mark rows dirty and only update the visible slice.
|
||||||
|
|
||||||
|
**1.3 Index rows by track id** so targeted updates (e.g. a single track's cache status changes) patch a specific DOM node instead of rebuilding the list.
|
||||||
|
|
||||||
|
**1.4 Move search filtering to the model layer**: maintain `M.libraryFiltered` as a computed array; the filter runs once per query (debounced ~150ms), and the windowed renderer reads from it. Never re-filter on every keystroke synchronously.
|
||||||
|
|
||||||
|
Deliverable: a library that scrolls smoothly at 10k tracks.
|
||||||
|
|
||||||
|
### Phase 2 — Introduce a minimal reactive store
|
||||||
|
|
||||||
|
Replace ad-hoc `M.x = …; M.render()` with a store that notifies subscribers.
|
||||||
|
|
||||||
|
**2.1 Store shape** (new file `public/store.js`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function () {
|
||||||
|
const M = window.MusicRoom;
|
||||||
|
const state = {}; // the actual values
|
||||||
|
const subs = new Map(); // key -> Set<callback>
|
||||||
|
|
||||||
|
M.store = {
|
||||||
|
get(key) { return state[key]; },
|
||||||
|
set(key, value) {
|
||||||
|
if (Object.is(state[key], value)) return; // skip no-op
|
||||||
|
state[key] = value;
|
||||||
|
subs.get(key)?.forEach((cb) => cb(value));
|
||||||
|
},
|
||||||
|
update(key, fn) { M.store.set(key, fn(state[key])); },
|
||||||
|
on(key, cb) {
|
||||||
|
if (!subs.has(key)) subs.set(key, new Set());
|
||||||
|
subs.get(key).add(cb);
|
||||||
|
return () => subs.get(key)?.delete(cb); // unsubscribe
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
**2.2 Migrate state field by field.** Don't do a big-bang rewrite. Start with the fields that change most and drive renders: `M.library`, `M.queue`, `M.cachedTracks`, `M.currentTrackId`. Each becomes `M.store.set("library", …)` and the renderer subscribes via `M.store.on("library", renderLibrary)`.
|
||||||
|
|
||||||
|
Keep the `M.library` getter as a compatibility shim (`Object.defineProperty(M, "library", { get: () => M.store.get("library") })`) so unmigrated callers keep working during the transition.
|
||||||
|
|
||||||
|
**2.3 Decouple renders from setters.** Today `audioCache.js` calls `M.renderQueue()` directly. After migration it just `M.store.set("cachedTracks", …)` and the queue renderer — which subscribed once — decides whether and what to re-render.
|
||||||
|
|
||||||
|
Deliverable: a single source of truth per field, explicit update points, and the ability to add per-field logging/invariants cheaply.
|
||||||
|
|
||||||
|
### Phase 3 — Decompose `trackContainer.js`
|
||||||
|
|
||||||
|
Split the 972-line file along its existing seams:
|
||||||
|
|
||||||
|
- `trackList.js` — the windowed renderer (from Phase 1) + list-level keyboard nav.
|
||||||
|
- `trackSelection.js` — the `selection`/`lastSelected` state (`trackContainer.js:11-22`) and click/shift-click range logic.
|
||||||
|
- `trackDrag.js` — drag state (`trackContainer.js:8,24-31`), drop-zone rendering, queue reorder calls.
|
||||||
|
- `trackContextMenu.js` — menu construction + DOM (`showContextMenuUI` at `:880-949`), built on top of the row events.
|
||||||
|
|
||||||
|
Each becomes a small module that subscribes to the store (Phase 2) and exposes a narrow API on `M.tracks.*`.
|
||||||
|
|
||||||
|
Deliverable: no file over ~400 lines in the client.
|
||||||
|
|
||||||
|
### Phase 4 — De-duplicate
|
||||||
|
|
||||||
|
- **Playback setup**: extract a single `M.loadAndPlay(track, { seek: 0 })` used by `init.js`, `controls.js`, and both call sites in `trackContainer.js`. Today it's copy-pasted 4× (see assessment).
|
||||||
|
- **Cookie helpers**: one `M.prefs.get/set/clear` (with `Secure` added — see XSS plan) replacing the three copies in `core.js`, `themes.js`, `visualizer.js`.
|
||||||
|
- **`escapeHtml`**: single `M.escapeHtml` (already part of the XSS plan).
|
||||||
|
- **Segment scan**: one `M.getBufferedSegments(audio, trackId)` used by `audioCache.js`, `ui.js`, `controls.js`.
|
||||||
|
|
||||||
|
Deliverable: fewer copies, clearer ownership.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **List performance**: load a fixture library of 10k tracks (synthesize or copy metadata rows). Measure initial render time, scroll FPS, and keystroke-to-render latency in DevTools Performance. Target: 60fps scroll, <100ms keystroke response.
|
||||||
|
- **Correctness parity**: after each phase, verify against a manual checklist — play, pause, seek, jump, add-to-queue, remove-from-queue, drag-reorder, multi-select, context-menu actions, cache indicator updates, search filter, channel switch. Each phase must preserve all of these.
|
||||||
|
- **Store**: add a temporary `M.store.on("library", (v) => console.count("library"))` and confirm the count matches expected update frequency (not 5× per WS message).
|
||||||
|
- **No regressions in cache behavior**: the windowed render must still update a row's cache indicator when its blob completes — verify by playing a track and watching its row in a scrolled-out list update without a full re-render.
|
||||||
|
- **Decomposition**: confirm no module exceeds ~400 lines and that each can be reasoned about in isolation.
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- This is the highest-risk item because it touches the most code. Mitigate by:
|
||||||
|
- Shipping phases in order; each is independently mergeable and each is a checkpoint.
|
||||||
|
- Keeping `M.library`-style getters as shims during Phase 2 so partial migrations don't break.
|
||||||
|
- Behind a fallback: if windowing introduces scroll glitches, the non-windowed path can be kept as `M.renderLegacyList` and re-enabled until fixed.
|
||||||
|
- No server or DB changes; rollback is purely client-side file reversion.
|
||||||
|
- Do **not** attempt this before the XSS, cache-race, and WS plans land — those fix correctness bugs that a refactor would otherwise carry forward or obscure.
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Fix Duplicate Full-Track Download Race in Client Cache
|
||||||
|
|
||||||
|
Priority: **Medium** · Effort: **Low** · Risk: **Low**
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`M.bulkDownloadStarted` is a per-track flag meant to ensure `downloadAndCacheTrack` runs at most once per track. But the flag is **set inside `downloadAndCacheTrack` after an `await`**, while multiple callers check it **before** that await resolves. When the last two segments complete near-simultaneously — one from the browser's buffered-range scan in `ui.js`, one from the explicit `fetchSegment` in `audioCache.js` — both callers observe `trackCache.size >= SEGMENTS` and both pass the `bulkDownloadStarted` guard before the first caller sets the flag. Result: the same track is fetched in full **twice**, wasting bandwidth and racing on the IndexedDB write.
|
||||||
|
|
||||||
|
## Affected Locations
|
||||||
|
|
||||||
|
The check+call pattern appears in three places, all racy:
|
||||||
|
|
||||||
|
- `public/audioCache.js:99-107` — `checkAndCacheComplete`
|
||||||
|
- `public/ui.js:148-150` — buffer-segment scan
|
||||||
|
- `public/audioCache.js:183-187` — inside `fetchSegment`
|
||||||
|
|
||||||
|
And the flag is set here:
|
||||||
|
|
||||||
|
- `public/audioCache.js:110-112` — inside `downloadAndCacheTrack`, **after** `await M.loadTrackBlob(trackId)`.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
Guard-then-do where the guard write is not atomic with respect to the awaits surrounding it. JavaScript's single-threaded execution means a synchronous "check-and-set" is race-free, but here the set happens after the first `await` inside the function being guarded, so a second caller can enter the function and pass the check before the first caller reaches the set.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1 — Move the flag set to be synchronous with the check
|
||||||
|
|
||||||
|
Refactor so the check and the set happen in the same synchronous tick, before any `await`. Create a single entry point:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// public/audioCache.js
|
||||||
|
M.maybeStartBulkDownload = function (trackId) {
|
||||||
|
if (M.bulkDownloadStarted.get(trackId)) return false; // already started
|
||||||
|
if (M.cachedTracks.has(trackId)) return false; // already cached
|
||||||
|
const trackCache = M.trackCaches.get(trackId);
|
||||||
|
if (!trackCache || trackCache.size < SEGMENTS) return false; // not fully buffered
|
||||||
|
// All conditions met — claim the slot synchronously:
|
||||||
|
M.bulkDownloadStarted.set(trackId, true);
|
||||||
|
// Fire the async work without awaiting here:
|
||||||
|
M.downloadAndCacheTrack(trackId).catch((err) => {
|
||||||
|
console.warn("[audioCache] bulk download failed for", trackId, err);
|
||||||
|
M.bulkDownloadStarted.delete(trackId); // allow a later retry
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Then make `downloadAndCacheTrack` **assume the flag is already set** (remove the guard inside it):
|
||||||
|
|
||||||
|
```js
|
||||||
|
M.downloadAndCacheTrack = async function (trackId) {
|
||||||
|
// Precondition: M.bulkDownloadStarted.get(trackId) === true (set by maybeStartBulkDownload)
|
||||||
|
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||||
|
if (cachedUrl) { // already cached under us
|
||||||
|
M.cachedTracks.add(trackId);
|
||||||
|
M.bulkDownloadStarted.delete(trackId);
|
||||||
|
return cachedUrl;
|
||||||
|
}
|
||||||
|
// ... existing download + TrackStorage.set + URL.createObjectURL logic ...
|
||||||
|
M.cachedTracks.add(trackId);
|
||||||
|
M.bulkDownloadStarted.delete(trackId); // clear after success
|
||||||
|
M.renderQueue && M.renderQueue();
|
||||||
|
M.renderLibrary && M.renderLibrary();
|
||||||
|
return blobUrl;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Replace all three call sites
|
||||||
|
|
||||||
|
Each caller becomes a single synchronous call:
|
||||||
|
|
||||||
|
**`audioCache.js` `checkAndCacheComplete`:**
|
||||||
|
```js
|
||||||
|
M.maybeStartBulkDownload(trackId);
|
||||||
|
```
|
||||||
|
(remove the old `if (...size >= SEGMENTS)` + `downloadAndCacheTrack` block.)
|
||||||
|
|
||||||
|
**`ui.js:148-150`** (buffered-range scan, after a segment is marked present):
|
||||||
|
```js
|
||||||
|
M.maybeStartBulkDownload(M.currentTrackId);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`audioCache.js:183-187`** (inside `fetchSegment`, after marking the segment):
|
||||||
|
```js
|
||||||
|
M.maybeStartBulkDownload(trackId);
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the check-and-claim is now synchronous, even if all three callers fire in the same tick, only the first will start the download.
|
||||||
|
|
||||||
|
### Step 3 — Guard the blob-URL swap during playback
|
||||||
|
|
||||||
|
`downloadAndCacheTrack` swaps `M.audio.src` to the blob URL mid-playback (`audioCache.js:213-220`). After the refactor, confirm this swap still checks `M.currentTrackId === trackId` and that `wasPlaying` is captured at the moment of the swap (not earlier). Keep the existing `play().catch(()=>{})`.
|
||||||
|
|
||||||
|
### Step 4 — (Related, recommended in same pass) Revoke blob URLs
|
||||||
|
|
||||||
|
While in this file, address the related memory leak: every `URL.createObjectURL` stored in `M.trackBlobs` is never revoked. See `todo/client-cache-correctness/overview.md` — that plan covers it. If doing both together, the `downloadAndCacheTrack` success path is the natural place to track the URL for later revocation.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **Race reproduction**: this is hard to trigger deterministically. Add a temporary `console.log("[bulk]", trackId)` at the top of the download body and another in `maybeStartBulkDownload` when it returns `true`. Play a track, let it fully buffer, and confirm exactly **one** "started" log and one completion per track — even when forcing the buffered-range scan and `fetchSegment` to both fire (e.g. seek near the end then back).
|
||||||
|
- **Error path**: temporarily make the download throw (e.g. point the track URL at a 404) and confirm `bulkDownloadStarted` is cleared so a later attempt can retry, rather than permanently preventing caching.
|
||||||
|
- **Already-cached path**: if a track is already in IndexedDB (`loadTrackBlob` returns a URL on the first line), confirm no full download is triggered and `cachedTracks` is updated.
|
||||||
|
- **UI**: confirm the buffer bar fills, the cache indicator turns green, and queue/library re-render exactly once per completed cache (not twice).
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- The refactor centralizes three near-duplicate code paths into one — net simplification.
|
||||||
|
- Risk: if `downloadAndCacheTrack` is called from anywhere else that relied on the internal guard, that caller must be migrated to `maybeStartBulkDownload`. Grep for `downloadAndCacheTrack(` before merging.
|
||||||
|
- If a download fails after the flag is set and the catch handler fails to clear it, the track becomes uncachable until reload. The `.catch` in `maybeStartBulkDownload` is the safety net — confirm it clears the flag on all rejection paths.
|
||||||
|
- Rollback = revert `audioCache.js` and the two call-site edits in `ui.js`.
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Validate `fetch()` Responses Before Parsing JSON
|
||||||
|
|
||||||
|
Priority: **Medium** · Effort: **Low** · Risk: **Low**
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Several client `fetch` call sites parse the response body as JSON **without checking `res.ok`** (or `res.status`). When the server returns an error envelope (e.g. `{ error: "Authentication required" }` with 401) or a non-JSON body (proxy 502, HTML error page), the parsed object is then **assigned directly to application state** and immediately rendered — silently corrupting the UI.
|
||||||
|
|
||||||
|
## Affected Locations
|
||||||
|
|
||||||
|
| File:Line | Call | Current behavior on error |
|
||||||
|
|-----------|------|---------------------------|
|
||||||
|
| `public/queue.js:382` | `M.library = await res.json();` then `M.renderLibrary()` | The entire library silently becomes the error object; render runs on garbage. |
|
||||||
|
| `public/auth.js:11` | guest session bootstrap | Error body used as the user object. |
|
||||||
|
| `public/auth.js:101` | `/api/auth/me` | Error body used as the current user. |
|
||||||
|
| `public/channelSync.js:11` | `/api/channels` list | Error body assigned to `M.channels`; `channels.length === 0` check then behaves confusingly. |
|
||||||
|
|
||||||
|
(Other call sites in `playlists.js`, `upload.js`, etc. already do `if (!res.ok)` correctly — follow those as the model.)
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1 — Add a shared helper in `public/utils.js`
|
||||||
|
|
||||||
|
Centralize the check so it can't be forgotten again:
|
||||||
|
|
||||||
|
```js
|
||||||
|
M.apiJson = async function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try { detail = (await res.clone().json()).error ?? ""; } catch { /* non-JSON body */ }
|
||||||
|
const err = new Error(`Request failed (${res.status})${detail ? ": " + detail : ""}`);
|
||||||
|
err.status = res.status;
|
||||||
|
err.detail = detail;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
(`res.clone()` so the original body remains readable if the caller wants it.)
|
||||||
|
|
||||||
|
### Step 2 — Update each affected call site
|
||||||
|
|
||||||
|
**`queue.js:~380`** (library load):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch("/api/library", { credentials: "include" });
|
||||||
|
if (res.status === 401) { M.handleAuthRequired?.(); return; } // or surface login UI
|
||||||
|
M.library = await M.apiJson(res);
|
||||||
|
M.renderLibrary();
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap the surrounding logic in `try/catch` and show a toast on failure (e.g. `M.showToast("Couldn't load library", "error")`) rather than leaving the UI empty/silent. Keep `M.library` as its previous value on failure rather than overwriting with garbage.
|
||||||
|
|
||||||
|
**`auth.js:11`** (guest bootstrap) and **`auth.js:101`** (`/api/auth/me`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||||
|
if (res.status === 401) { /* not logged in / guest expired — drive login UI */ return; }
|
||||||
|
const user = await M.apiJson(res);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`channelSync.js:11`** (channel list):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch("/api/channels", { credentials: "include" });
|
||||||
|
if (!res.ok) { M.showToast("Couldn't load channels", "error"); return; }
|
||||||
|
const channels = await M.apiJson(res);
|
||||||
|
M.channels = channels;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Audit remaining call sites
|
||||||
|
|
||||||
|
Grep for `await res.json()` and `await response.json()` across `public/`. Convert any that lack a preceding `res.ok` / status check to use `M.apiJson`. The ones already guarded can be left or migrated for consistency.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **Simulate 401**: clear the session cookie in DevTools, reload. Confirm the library view does **not** render an error object as tracks, and a login prompt / toast appears instead.
|
||||||
|
- **Simulate 500/502**: stop the server mid-session and trigger a library reload (e.g. via the refresh path). Confirm a toast shows and the previous library remains visible.
|
||||||
|
- **Happy path**: confirm normal load of library, channels, and `/api/auth/me` still works after the change.
|
||||||
|
- **Guest expiry**: let a guest session lapse (or delete it from the DB) and reload; confirm graceful handling rather than a broken render.
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- Behavior change only on the error path; success path is identical.
|
||||||
|
- One subtlety: previously-silent failures will now surface as toasts. That is the desired behavior, but verify the messages are user-friendly and not noisy in normal flaky-network conditions.
|
||||||
|
- Rollback = revert the helper and the four call sites; no data or schema impact.
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
# WebSocket Client Robustness: Parse Guard, Reconnect Backoff, Dead-Channel Fallback
|
||||||
|
|
||||||
|
Priority: **High** · Effort: **Low–Medium** · Risk: **Low**
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The WebSocket client in `public/channelSync.js` is brittle in three ways that can leave the UI silently desynced or hammering a dead server/channel:
|
||||||
|
|
||||||
|
1. **`JSON.parse(e.data)` has no try/catch** (`channelSync.js:262`). A single malformed or partial frame throws, the `onmessage` handler aborts, and the client stops processing all further state updates while the socket appears "open."
|
||||||
|
2. **Reconnect has no backoff, no jitter, no max-retries** (`channelSync.js:353-369`). A down server produces a fixed 2–3s reconnect storm with no cap.
|
||||||
|
3. **No fallback to the default channel**. If the current channel is deleted server-side while the client is disconnected, every reconnect 404s and the client loops forever against a dead channel ID.
|
||||||
|
|
||||||
|
Separately, the message dispatcher falls through to `M.handleUpdate(data)` for any unknown `data.type` (`channelSync.js:346`), so a new server message type silently corrupts state. Worth hardening in the same pass.
|
||||||
|
|
||||||
|
## Affected Locations
|
||||||
|
|
||||||
|
- `public/channelSync.js:262` — unguarded `JSON.parse`
|
||||||
|
- `public/channelSync.js:346` — `handleUpdate` fallback for unknown types
|
||||||
|
- `public/channelSync.js:353-369` — reconnect logic
|
||||||
|
- `public/channelSync.js:245-261` — socket setup (reference for structure)
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1 — Wrap `JSON.parse` in try/catch
|
||||||
|
|
||||||
|
```js
|
||||||
|
M.ws.onmessage = (e) => {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(e.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[channelSync] Dropping malformed WS frame:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... existing dispatch ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep behavior: a bad frame is dropped, the socket stays open, subsequent good frames still process.
|
||||||
|
|
||||||
|
### Step 2 — Strict dispatch with explicit unknown-type handling
|
||||||
|
|
||||||
|
Replace the implicit fall-through to `M.handleUpdate(data)`. Make the dispatch explicit:
|
||||||
|
|
||||||
|
```js
|
||||||
|
switch (data.type) {
|
||||||
|
case "channel_list": /* ... */ break;
|
||||||
|
case "switched": /* ... */ break;
|
||||||
|
case "track": /* fallthrough */
|
||||||
|
case "state": M.handleUpdate(data); break; // explicit state-shape types only
|
||||||
|
// ... all other known types ...
|
||||||
|
default:
|
||||||
|
console.warn("[channelSync] Unknown WS message type:", data.type, data);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the server currently sends `type` values other than the ones handled, gather the full set first via `grep` for `"type":` / `type: "` in the server broadcast paths and list them in this switch.)
|
||||||
|
|
||||||
|
### Step 3 — Reconnect with exponential backoff + jitter + max attempts
|
||||||
|
|
||||||
|
Track reconnect state on `M` (e.g. `M.wsReconnect = { attempts: 0, timer: null }`). On close:
|
||||||
|
|
||||||
|
```js
|
||||||
|
M.ws.onclose = () => {
|
||||||
|
M.ws = null;
|
||||||
|
// clear any user-facing "connected" state
|
||||||
|
if (!M.wantSync) return;
|
||||||
|
|
||||||
|
const recon = M.wsReconnect;
|
||||||
|
recon.attempts++;
|
||||||
|
const base = 1000; // 1s
|
||||||
|
const cap = 30000; // 30s max
|
||||||
|
const exp = Math.min(cap, base * 2 ** recon.attempts);
|
||||||
|
const jitter = Math.random() * 500; // 0–500ms
|
||||||
|
const delay = exp + jitter;
|
||||||
|
|
||||||
|
recon.timer = setTimeout(() => {
|
||||||
|
connectChannel(M.currentChannelId);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset `recon.attempts = 0` on a successful `open`. Cancel `recon.timer` on any explicit/manual `connectChannel` call to avoid double-connects.
|
||||||
|
|
||||||
|
**On max attempts**: rather than giving up entirely (a music app should keep trying), cap the delay at 30s but keep retrying. Optionally surface a "reconnecting…" indicator after the first few failures so the user knows the stream is stale.
|
||||||
|
|
||||||
|
### Step 4 — Dead-channel fallback
|
||||||
|
|
||||||
|
In `connectChannel`, if the upgrade/open fails or the server responds with a channel-not-found error message (the server already sends `{ type: "error", message: "Channel not found" }` per `websocket.ts:50`), fall back to the default channel:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// inside onmessage error handler:
|
||||||
|
if (data.type === "error" && /not found/i.test(data.message)) {
|
||||||
|
console.warn(`[channelSync] Channel ${M.currentChannelId} not found, falling back to default`);
|
||||||
|
// fetch default channel id from /api/channels (isDefault === true)
|
||||||
|
const def = await fetchDefaultChannelId();
|
||||||
|
if (def && def !== M.currentChannelId) {
|
||||||
|
M.currentChannelId = def;
|
||||||
|
M.saveChannelId(def);
|
||||||
|
connectChannel(def);
|
||||||
|
M.showToast("This channel no longer exists — switched to the default channel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a small helper `fetchDefaultChannelId()` that GETs `/api/channels` and returns the `id` where `isDefault === true`. Cache it on `M.defaultChannelId` after first successful list load so we don't re-fetch on every failure.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **Malformed frame**: from server console or a test, `ws.send("not json")` to a client — confirm the client logs a warning and continues processing subsequent valid frames (state still updates).
|
||||||
|
- **Server kill / restart**: stop the server, confirm reconnect attempts grow with backoff (log each attempt + delay) rather than firing every 2s. Restart, confirm the client reconnects and `attempts` resets.
|
||||||
|
- **Channel deletion while disconnected**: note a non-default channel ID, stop server, delete the channel row from `blastoise.db`, restart server, ensure the client reconnects → gets "not found" → falls back to the default channel and shows the toast.
|
||||||
|
- **Dispatch**: inject an unknown `{ type: "future_thing" }` message server-side (or via a debug WS send) and confirm it logs a warning and does **not** call `handleUpdate`.
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- All changes are additive hardening; normal-path behavior is preserved.
|
||||||
|
- Backoff introduces up to a 30s worst-case reconnect delay — acceptable and better than a tight loop. If a deployment wants faster recovery, tune `base`/`cap`.
|
||||||
|
- Default-channel fallback adds one HTTP fetch on the error path only; cache the result to avoid repeats.
|
||||||
|
- Rollback = revert the file; no schema or wire-protocol change.
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Fix WS Control-Permission Bypass for Guests
|
||||||
|
|
||||||
|
Priority: **High** · Effort: **Trivial (one location)** · Risk: **Low**
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Guests are *intended* to be unable to control playback (`routes/helpers.ts:30`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (user.is_guest && permission === "control") return false;
|
||||||
|
```
|
||||||
|
|
||||||
|
) but the **WebSocket message handler** does not route through `userHasPermission`. It re-implements the check inline and **omits the guest guard**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// websocket.ts:87-93
|
||||||
|
const canControl = user.is_admin
|
||||||
|
|| config.defaultPermissions?.includes("control")
|
||||||
|
|| hasPermission(userId, "channel", ws.data.channelId, "control");
|
||||||
|
if (!canControl) { ... return; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the default config ships `defaultPermissions: ["listen", "control"]` (`config.json:5-8`), **any guest can `pause`, `unpause`, `seek`, and `jump` on any channel**, affecting all listeners.
|
||||||
|
|
||||||
|
The HTTP control endpoints (`routes/channels.ts:176, 196, 216, 261`) correctly use `userHasPermission`, so this is an inconsistency between the two code paths that reach the same `Channel` mutators.
|
||||||
|
|
||||||
|
## Affected Location
|
||||||
|
|
||||||
|
- `websocket.ts:86-93` — inline permission check, guest-unaware.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
Duplicated permission logic in two places. The WS handler was written before / diverged from `helpers.userHasPermission`.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1 — Import `userHasPermission`
|
||||||
|
|
||||||
|
At the top of `websocket.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { userHasPermission } from "./routes/helpers";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Replace the inline check
|
||||||
|
|
||||||
|
Replace `websocket.ts:86-93` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!userHasPermission(user, "channel", ws.data.channelId, "control")) {
|
||||||
|
console.log("[WS] User lacks control permission:", user.username, "(guest=" + user.is_guest + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`userHasPermission` already encapsulates: admin bypass, the guest `control` denial (`helpers.ts:30`), the `defaultPermissions` config check, and the DB permission lookup. Routing through it makes WS and HTTP behavior identical.
|
||||||
|
|
||||||
|
### Step 3 — Verify no other call sites rely on the old behavior
|
||||||
|
|
||||||
|
`grep` the repo for the inline pattern (`config.defaultPermissions?.includes("control")`) — it should now appear only in `helpers.ts:33` (the canonical check). Any other duplicate should be replaced the same way.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **Guest cannot control**: sign in as a guest (or hit the server unauthenticated with `allowGuests: true`), open a channel WS, send `{"action":"pause"}`. Confirm the channel does **not** pause and the server logs the "lacks control permission" line.
|
||||||
|
- **Non-guest user still controls**: sign in as `test`/`testuser`, send the same action, confirm the channel pauses.
|
||||||
|
- **Admin controls**: sign in as admin, confirm control works on channels the admin does not own.
|
||||||
|
- **Guest still receives state**: confirm the guest client still gets `track`/state broadcasts (i.e. the *listen* path is unaffected — only `control` is denied).
|
||||||
|
- **Config matrix**: temporarily set `defaultPermissions` to `["listen"]` only and confirm a *non-guest* non-admin user is now also denied control via WS (matches the HTTP path).
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- The only behavioral change is denying control to guests (and, if `defaultPermissions` lacks `"control"`, to non-admin users) — which is the documented intent. If any deployment currently relies on guests controlling playback, that was an unintentional privilege and should be re-granted explicitly via the permissions table, not by reverting.
|
||||||
|
- No schema or DB change.
|
||||||
|
- Rollback = revert the single edit.
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
# XSS Sweep: Escape Server-Controlled Data at `innerHTML` Boundaries
|
||||||
|
|
||||||
|
Priority: **Critical** · Effort: **Low** · Risk: **Low**
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The client injects server-controlled strings into the DOM via `innerHTML` **without escaping** in several high-traffic render paths. Because the server broadcasts channel names, listener usernames, toast messages, and track titles over WebSocket to *every* connected client, a single malicious payload is a **stored/reflected XSS propagated peer-to-peer**.
|
||||||
|
|
||||||
|
There is also **no Content-Security-Policy** header set on the document or by the server, so once injected, script runs with full page privilege (same-origin as the session cookie).
|
||||||
|
|
||||||
|
## Affected Locations
|
||||||
|
|
||||||
|
| File:Line | Sink | Source |
|
||||||
|
|-----------|------|--------|
|
||||||
|
| `public/channelSync.js:174-183` | channel list `<span class="channel-name">${ch.name}</span>` and `<input value="${ch.name.replace(/"/g,'"')}">` | channel name (server) |
|
||||||
|
| `public/channelSync.js:161-163` | `listenersHtml` (listener usernames) | `ch.listeners` (server) |
|
||||||
|
| `public/utils.js:113` | toast history `... ${item.message}` | WS `toast` message (server) |
|
||||||
|
| `public/utils.js:149,159` | track title marquee | `track.title` (file metadata / yt-dlp) |
|
||||||
|
| `public/queue.js:342` | now-playing bar `${title}` | `track.title` (server) |
|
||||||
|
| `public/upload.js:307` | slow-queue list `${group.name}` | playlist name (server) |
|
||||||
|
| `public/upload.js:330` | slow-queue list `${item.title}` | `/api/fetch` response (server) |
|
||||||
|
|
||||||
|
Note: `trackComponent.js:59` and `playlists.js:44,57,58` **do** escape correctly today. The codebase is internally inconsistent — those are the model to follow.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
- No single shared escaping utility. `escapeHtml` is defined **twice** (`public/playlists.js:481-486` and `public/trackComponent.js:74-79`) as local copies.
|
||||||
|
- No lint rule or review guard preventing raw `${serverData}` inside template literals feeding `innerHTML`.
|
||||||
|
- No CSP as defense-in-depth.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1 — Create one shared `escapeHtml` in `public/utils.js`
|
||||||
|
|
||||||
|
Move/deduplicate the existing helper into `utils.js` and expose it on `M`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
M.escapeHtml = function (str) {
|
||||||
|
if (str == null) return "";
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete the two local copies in `playlists.js:481` and `trackComponent.js:74`; replace callers with `M.escapeHtml(...)`.
|
||||||
|
|
||||||
|
### Step 2 — Escape every server-sourced value at each sink above
|
||||||
|
|
||||||
|
For each location, wrap the interpolated value in `M.escapeHtml(...)`. For attribute contexts (e.g. `value="${...}"`), escaping with the helper above is sufficient since it includes `"`.
|
||||||
|
|
||||||
|
Worked example for `channelSync.js:174-183`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="channel-header">
|
||||||
|
<span class="channel-name">${M.escapeHtml(ch.name)}</span>
|
||||||
|
<input ... value="${M.escapeHtml(ch.name)}" ...>
|
||||||
|
...
|
||||||
|
<div class="channel-listeners">${listenersHtml}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
And `listenersHtml` itself (built at `channelSync.js:161-163`) must escape each username before joining.
|
||||||
|
|
||||||
|
### Step 3 — Add a defense-in-depth CSP header
|
||||||
|
|
||||||
|
In the static file route handler (`routes/static.ts`) — or centrally where index.html is served — add:
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self' ws: wss:; media-src 'self' blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'
|
||||||
|
```
|
||||||
|
|
||||||
|
(`'unsafe-inline'` for styles only — needed until stylesheets are consolidated. No `'unsafe-inline'` for scripts.)
|
||||||
|
|
||||||
|
Confirm this does not break the blob-URL audio playback (`media-src 'self' blob:`) or WebSocket (`connect-src ... ws: wss:`).
|
||||||
|
|
||||||
|
### Step 4 — (Optional, recommended) Centralize user-visible rendering
|
||||||
|
|
||||||
|
Longer-term, every track row / title render should go through `trackComponent.js`'s pure renderer which already escapes. Route now-playing-bar and marquee titles through the same path so escaping can't be forgotten again.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- **Manual payload test**: create a channel named `<img src=x onerror="alert(document.cookie)">`, connect a second client, confirm no alert fires and the name renders literally.
|
||||||
|
- **Username payload**: set a username (or guest) containing `<script>` and verify the channel-list `listenersHtml` shows it escaped.
|
||||||
|
- **Toast payload**: trigger a server toast containing markup (e.g. via yt-dlp add with a crafted title) and confirm toast history (`utils.js:113`) shows literal text.
|
||||||
|
- **Track-title payload**: upload an audio file whose metadata title is `<svg/onload=alert(1)>`; confirm queue and now-playing bar render it literally.
|
||||||
|
- **CSP**: open DevTools → Network → confirm the CSP header is present on the document response and the console shows no CSP violations during normal use (play, cache, switch channel).
|
||||||
|
|
||||||
|
## Risk / Rollback
|
||||||
|
|
||||||
|
- Escaping is additive and behavior-preserving for well-formed data. Only risk is over-escaping if a value is already HTML-safe by construction — review each call site to confirm the raw string is plain text (it is, in all listed cases).
|
||||||
|
- A too-strict CSP could break blob audio or an inline event handler. If so, loosen the specific directive per the violation report rather than reverting.
|
||||||
|
- Rollback = revert the commit; no schema or data migration involved.
|
||||||
Loading…
Reference in New Issue