Compare commits

...

19 Commits

Author SHA1 Message Date
Slop Master Flex b557cb678f saving 2026-07-25 00:26:25 +00:00
Slop Master Flex 0c30f7a252 add mise for installing bun 2026-07-24 23:30:24 +00:00
Slop Master Flex 4de1364f74 rsgain frontend 2026-07-24 23:14:56 +00:00
peterino2 053aaa8ef6 Fix player layout and refresh playback 2026-07-05 10:32:23 -07:00
peterino2 ee63f61e01 Add recently added library section 2026-07-05 10:11:46 -07:00
peterino2 e69e955ff9 Intensify visualizer and add fullscreen control 2026-07-02 15:35:11 -07:00
peterino2 a9b3421402 Add frontend audio visualizer 2026-07-02 15:23:11 -07:00
peterino2 038851813c Reconnect to current channel after disconnect 2026-07-02 15:12:57 -07:00
peterino2 9c87e2d1f7 Add Hot Dog Stand theme 2026-07-02 14:56:21 -07:00
peterino2 0ea8ee47ea Add experimental UI themes 2026-07-02 14:53:18 -07:00
peterino2 dcbb0a36e9 Improve responsive queue layout and remember channels 2026-07-02 13:21:31 -07:00
peterino2 ac740a46bb styling and website fixes 2026-07-02 13:21:27 -07:00
peterino cd8c1814ca Merge pull request 'Update Android default server URL' (#18) from dev/android into integration
Reviewed-on: #18
2026-06-10 05:09:08 +00:00
peterino2 a3334cb2a7 Update Android default server URL 2026-06-09 21:04:13 -07:00
Peter Li adc450f14f reference api brief 2026-06-07 19:59:32 -07:00
Peter Li d184c6a663 updating reference server 2026-06-07 19:48:13 -07:00
Peter Li 091e54c599 ios 2026-06-07 19:45:38 -07:00
Peter Li 910b25e7c7 blastoise ios 2026-06-07 17:49:42 -07:00
peterino2 ec194c3c9a Add iOS port design and explicit library search 2026-06-06 22:42:22 -07:00
60 changed files with 9739 additions and 253 deletions

23
.gitignore vendored
View File

@ -33,6 +33,26 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.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/
library_cache.db
musicroom.db
@ -40,3 +60,6 @@ blastoise.db
config.json
*.db-shm
*.db-wal
# machine-local ops notes (see AGENTS.md)
HiddenAgents.md

View File

@ -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.
## 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
### Server
@ -73,6 +77,15 @@ The client uses `track.id` for:
- Fetching audio (`/api/tracks/: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
### Segment-Based Buffering

View File

@ -33,7 +33,7 @@ adb shell am start -n com.peterino.blastoise/.MainActivity
## MVP scope
- Defaults to `http://mhsgroove.peterino.com:3001` and auto-connects on launch.
- Defaults to `https://tunes.peterino.com/` and auto-connects on launch.
- Saves a server URL locally.
- Uses `/api/channels` to establish an authenticated or guest session.
- Supports `/api/auth/login` for named users.

View File

@ -18,14 +18,13 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.text.InputType
import android.text.TextUtils
import android.text.TextWatcher
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
@ -530,10 +529,6 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
}
}
private val renderLibraryRunnable = Runnable {
renderLibrary()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SessionStore.load(this)
@ -549,7 +544,6 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
override fun onDestroy() {
mainHandler.removeCallbacks(ticker)
mainHandler.removeCallbacks(renderLibraryRunnable)
PlaybackBridge.unregister(this)
controllerFuture?.let { MediaController.releaseFuture(it) }
controllerFuture = null
@ -807,7 +801,7 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
}
val deckTop = row().apply { gravity = Gravity.CENTER_VERTICAL }
val badge = TextView(this).apply {
text = "3001"
text = "TUNE"
textSize = 22f
typeface = displayFont
letterSpacing = 0f
@ -1234,6 +1228,12 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
?: Track(trackId, trackId, trackId.take(24), 0.0)
}
private fun submitLibrarySearch(value: String) {
libraryQuery = value.trim()
renderLibrary()
updateDeck()
}
private fun renderStations() {
if (!::radioContent.isInitialized) return
radioContent.removeAllViews()
@ -1329,8 +1329,12 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
MUTED,
), matchWrapWithTop(dp(2)))
val searchRow = row().apply {
gravity = Gravity.CENTER_VERTICAL
}
val searchInput = EditText(this).apply {
setSingleLine(true)
imeOptions = EditorInfo.IME_ACTION_SEARCH
textSize = 15f
typeface = bodyFont
setTextColor(TEXT)
@ -1340,17 +1344,22 @@ class MainActivity : Activity(), PlaybackSnapshotListener {
setSelection(text.length)
setPadding(dp(12), 0, dp(12), 0)
background = box(PANEL2, STROKE)
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
libraryQuery = s?.toString().orEmpty()
mainHandler.removeCallbacks(renderLibraryRunnable)
mainHandler.postDelayed(renderLibraryRunnable, 120)
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
submitLibrarySearch(text.toString())
true
} else {
false
}
override fun afterTextChanged(s: Editable?) = Unit
})
}
}
libraryContent.addView(searchInput, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply {
searchRow.addView(searchInput, LinearLayout.LayoutParams(0, dp(48), 1f))
searchRow.addView(button("SEARCH", ACCENT, BG, Typeface.BOLD, R.drawable.ic_search, 17).apply {
setOnClickListener { submitLibrarySearch(searchInput.text.toString()) }
}, LinearLayout.LayoutParams(dp(124), dp(48)).apply {
leftMargin = dp(8)
})
libraryContent.addView(searchRow, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply {
topMargin = dp(10)
})

View File

@ -6,7 +6,8 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object SessionStore {
const val defaultServerBaseUrl = "http://mhsgroove.peterino.com:3001"
const val defaultServerBaseUrl = "https://tunes.peterino.com"
private const val legacyDefaultServerBaseUrl = "http://mhsgroove.peterino.com:3001"
const val userAgent = "BlastoiseAndroid/0.1"
@Volatile
@ -20,9 +21,11 @@ object SessionStore {
fun load(context: Context) {
val prefs = context.getSharedPreferences("blastoise", Context.MODE_PRIVATE)
serverBaseUrl = prefs.getString("serverBaseUrl", defaultServerBaseUrl) ?: defaultServerBaseUrl
if (serverBaseUrl.isBlank()) {
serverBaseUrl = defaultServerBaseUrl
val storedBaseUrl = prefs.getString("serverBaseUrl", defaultServerBaseUrl).orEmpty().trim()
serverBaseUrl = when {
storedBaseUrl.isBlank() -> defaultServerBaseUrl
storedBaseUrl.trimEnd('/') == legacyDefaultServerBaseUrl -> defaultServerBaseUrl
else -> storedBaseUrl.trimEnd('/')
}
cookieHeader = prefs.getString("cookieHeader", "") ?: ""
themeKey = prefs.getString("themeKey", "seraph") ?: "seraph"

@ -1 +0,0 @@
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3

View File

@ -18,6 +18,8 @@ export interface ReplayGainConfig {
command: string;
truePeak: boolean;
timeoutMs: number;
maxConcurrent?: number;
maxOutputBytes?: number;
}
export interface Config {
@ -51,7 +53,9 @@ export const DEFAULT_CONFIG: Config = {
enabled: true,
command: "rsgain",
truePeak: false,
timeoutMs: 120000
timeoutMs: 120000,
maxConcurrent: 1,
maxOutputBytes: 262144
}
};

34
db.ts
View File

@ -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
export interface User {
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[];
}
// 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'>[] {
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 }));

1527
docs/api-reference-full.md Normal file

File diff suppressed because it is too large Load Diff

176
docs/api-reference.md Normal file
View File

@ -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.

585
docs/buildme.md Normal file
View File

@ -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
View File

@ -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> {
// Initialize yt-dlp if configured
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
@ -215,7 +230,7 @@ export async function init(): Promise<void> {
setTimeout(() => checkPendingPlaylistAddition(track), 100);
});
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 });
});
@ -352,22 +367,10 @@ export async function init(): Promise<void> {
// Listen for library changes and notify clients
library.on("added", (track) => {
console.log(`New track detected: ${track.title}`);
const allTracks = library.getAllTracks().map(t => ({
id: t.id,
title: t.title,
duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
}));
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
broadcastToAll({
type: "track_added",
track: {
id: track.id,
title: track.title,
duration: track.duration,
replayGainDb: track.replayGainDb,
replayPeak: track.replayPeak,
},
track: serializeLibraryTrack(track),
library: allTracks
});
});
@ -375,16 +378,10 @@ export async function init(): Promise<void> {
library.on("removed", (track) => {
console.log(`Track removed: ${track.title}`);
removeTrackFromQueues(track.id);
const allTracks = library.getAllTracks().map(t => ({
id: t.id,
title: t.title,
duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
}));
const allTracks = library.getAllTracks().map(serializeLibraryTrack);
broadcastToAll({
type: "track_removed",
track: { id: track.id, title: track.title },
track: serializeLibraryTrack(track),
library: allTracks
});
});

View File

@ -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 */;
}

View File

@ -0,0 +1,10 @@
import SwiftUI
@main
struct BlastoisePingApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@ -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"
}
}
}

View File

@ -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>

View File

@ -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

View File

@ -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))
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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))
}
}

View File

@ -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.

748
ios/design.md Normal file
View File

@ -0,0 +1,748 @@
# Blastoise iOS Port Design
This document describes how to build a native iOS version of the current native Android Blastoise app. It is not a marketing spec or a prompt. It is an implementation design for a Swift/iOS port that preserves the same product model, features, playback behavior, and visual direction.
## Goal
Create a native iOS app that behaves like the Android app:
- Defaults to `http://mhsgroove.peterino.com:3001`.
- Requires sign in/sign up before room, queue, library, and playlist operations.
- Lets a user join a room and hear that room's server-owned queue.
- Keeps audio playing when the phone is locked, like a normal music app.
- Exposes lock-screen / Control Center playback controls.
- Shows room, queue, people, library, and playlist views.
- Lets users search the library, play local library tracks, add tracks/playlists to the current room queue, play next, jump in the queue, remove queue items, and cycle playback modes.
- Preserves the current stylized themes: Pixel, Aura/angel, and Black Cat/Game Boy.
- Shows debug/status info for server, auth, socket, playback drift, and loaded data.
## Recommended Technology
Use a native Swift app, not a web wrapper.
- UI: SwiftUI.
- Audio playback: AVFoundation `AVPlayer`.
- Background audio: `AVAudioSession` with `.playback` plus the Xcode Background Modes capability for Audio, AirPlay, and Picture in Picture.
- Lock-screen controls and metadata: MediaPlayer `MPRemoteCommandCenter` and `MPNowPlayingInfoCenter`.
- HTTP API: `URLSession`.
- WebSocket: `URLSessionWebSocketTask`.
- Credential/session storage: Keychain for session cookie and username; `UserDefaults` only for non-secret settings like server URL and selected theme.
- State model: one observable app/playback model that owns networking, player state, socket state, and UI snapshots.
Useful Apple references:
- Background modes: https://developer.apple.com/documentation/xcode/configuring-background-execution-modes
- AVAudioSession: https://developer.apple.com/documentation/avfaudio/avaudiosession
- AVPlayer: https://developer.apple.com/documentation/avfoundation/avplayer
- Now Playing / remote controls: https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/MediaPlaybackGuide/Contents/Resources/en.lproj/RefiningTheUserExperience/RefiningTheUserExperience.html
- URLSessionWebSocketTask: https://developer.apple.com/documentation/foundation/urlsessionwebsockettask
- URLSession and ATS: https://developer.apple.com/documentation/foundation/urlsession
- App Transport Security: https://developer.apple.com/documentation/security/preventing-insecure-network-connections
- Keychain-backed credential persistence: https://developer.apple.com/documentation/foundation/urlcredential/persistence-swift.enum
## Current Android Behavior To Preserve
The Android native app is structured around:
- `PlaybackService`: long-lived playback and networking owner.
- `MusicRoomClient`: HTTP and WebSocket API client.
- `PlaybackSnapshot`: immutable UI state published from service to activity.
- `MainActivity`: programmatic themed UI.
- `SessionStore`: server URL, auth cookie, selected theme.
iOS should use the same separation even though it will not have an Android-style foreground service.
Android feature set to mirror:
- Auth:
- `POST /api/auth/login`
- `POST /api/auth/signup`
- `POST /api/auth/logout`
- `GET /api/auth/me`
- cache session cookie
- block room/library/playlist/queue actions unless signed in
- Room/radio playback:
- `GET /api/channels`
- auto-join default room after sign in
- `WS /api/channels/:id/ws`
- receive channel state: track, timestamp, paused, queue, current index, listeners, playback mode
- play `/api/tracks/:id`
- seek/sync to server timestamp
- pause/unpause/seek/jump via WebSocket messages
- reconnect WebSocket with backoff
- Library/local playback:
- `GET /api/library`
- search tracks by title/filename
- tap a track to play locally
- queue track into current room
- play track next by inserting after current queue index
- Playlists:
- `GET /api/playlists`
- `GET /api/playlists/:id`
- display mine and shared playlists
- select playlist and view tracks
- add playlist to current room queue
- play playlist next
- add individual playlist tracks to room queue
- Queue:
- render current room queue
- highlight now-playing item
- tap track to jump
- remove queue item
- add current track to queue or play next
- People:
- show listeners in current room
- mark current user
- Playback controls:
- previous/jump back in radio queue or local library
- seek back 15 seconds
- play/pause
- seek forward 15 seconds
- next/jump forward
- cycle playback mode: once, repeat-all, repeat-one, shuffle
- queue current
- play current next
- stop and exit
- Debug/status:
- connection status
- server URL
- auth state and username
- room count and current room ID
- library count and search query
- playlist counts
- current track ID/title
- expected/player timestamps and drift
- recent event log
## iOS Architecture
Use this module layout:
```text
ios/
Blastoise/
BlastoiseApp.swift
AppModel.swift
Models/
Track.swift
Channel.swift
Playlist.swift
UserSession.swift
PlaybackSnapshot.swift
Services/
SessionStore.swift
KeychainStore.swift
MusicRoomAPI.swift
RoomWebSocket.swift
PlaybackEngine.swift
NowPlayingController.swift
Views/
RootView.swift
AuthView.swift
PlayerDeckView.swift
RoomsView.swift
QueueView.swift
PeopleView.swift
LibraryView.swift
PlaylistsView.swift
DebugView.swift
Theme/
ThemeSpec.swift
PixelFrame.swift
SeraphFrame.swift
PixelCatView.swift
```
### State Ownership
`AppModel` should be the single source of truth for UI state. It replaces `PlaybackBridge` and most of `PlaybackService`'s public surface.
Suggested shape:
```swift
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var snapshot = PlaybackSnapshot()
private let sessionStore: SessionStore
private let api: MusicRoomAPI
private let socket: RoomWebSocket
private let playback: PlaybackEngine
private let nowPlaying: NowPlayingController
}
```
Do not scatter `AVPlayer`, WebSocket, auth cookie, and queue state across views. SwiftUI views should call intent methods such as:
- `signIn(username:password:)`
- `signUp(username:password:)`
- `connectToServer(_:)`
- `joinRoom(_:)`
- `enterLibraryMode()`
- `playLibraryTrack(index:)`
- `queueTrack(_:playNext:)`
- `addPlaylistToQueue(_:playNext:)`
- `jumpToQueueIndex(_:)`
- `removeQueueIndex(_:)`
- `togglePlay()`
- `seek(to:)`
- `seekBy(seconds:)`
- `cyclePlaybackMode()`
- `stopAndExit()`
- `cycleTheme()`
### PlaybackSnapshot
Mirror the Android `PlaybackSnapshot` so views stay dumb:
```swift
struct PlaybackSnapshot: Equatable {
var sourceMode: PlaybackSourceMode = .radio
var authState: AuthState = .checking
var currentUser: UserSession?
var status = "Starting"
var channels: [ChannelInfo] = []
var libraryTracks: [Track] = []
var libraryLoaded = false
var myPlaylists: [Playlist] = []
var sharedPlaylists: [Playlist] = []
var playlistsLoaded = false
var selectedPlaylistId: String?
var selectedPlaylist: Playlist?
var currentChannelId: String?
var currentTrackId: String?
var localLibraryIndex = -1
var currentRoomListeners: [String] = []
var paused = true
var queue: [Track] = []
var queueLoaded = false
var currentIndex = 0
var playbackMode = "repeat-all"
var channelName = "No channel"
var trackTitle = "No track"
var trackDuration: TimeInterval = 0
var serverTimestampMs: Int64 = 0
var stateMonotonicTime: TimeInterval = 0
var expectedPositionMs: Int64 = 0
var playerPositionMs: Int64 = 0
var driftMs: Int64 = 0
var playbackState = "none"
var isPlaying = false
var debugEvents: [String] = []
}
```
Use `CACurrentMediaTime()` or `ProcessInfo.processInfo.systemUptime` for monotonic timing, not `Date()`, when calculating drift.
## Networking Design
### SessionStore
Defaults:
- `serverBaseURL = "http://mhsgroove.peterino.com:3001"`
- `userAgent = "BlastoiseiOS/0.1"`
- `themeKey = "seraph"`
Store:
- server URL in `UserDefaults`
- theme key in `UserDefaults`
- session cookie in Keychain
The Android app stores a literal cookie header string like `blastoise_session=...`. iOS can do the same to stay compatible. Every request and WebSocket handshake should send:
```text
User-Agent: BlastoiseiOS/0.1
Cookie: blastoise_session=...
```
### App Transport Security
The current test server is plain HTTP. iOS blocks insecure HTTP by default for modern apps unless configured otherwise. For development, add an ATS exception for `mhsgroove.peterino.com` in `Info.plist`. For any public release, use HTTPS and remove the exception.
Development-only `Info.plist` direction:
```xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>mhsgroove.peterino.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
```
### HTTP API Client
`MusicRoomAPI` should wrap `URLSession` and expose typed async methods:
```swift
struct MusicRoomAPI {
func login(username: String, password: String) async throws -> UserSession
func signup(username: String, password: String) async throws -> UserSession
func logout() async
func me() async throws -> UserSession?
func channels() async throws -> [ChannelInfo]
func library() async throws -> [Track]
func playlists() async throws -> PlaylistBundle
func playlist(id: String) async throws -> Playlist
func setPlaybackMode(channelId: String, mode: String) async throws
func addTracksToQueue(channelId: String, trackIds: [String], insertAt: Int?) async throws
func removeTracksFromQueue(channelId: String, indices: [Int]) async throws
func channelState(channelId: String) async throws -> ChannelState
}
```
Decode with `Codable`, but be tolerant of server naming differences already handled in Android:
- `resource_id` and `resourceId`
- `is_admin` and `isAdmin`
- `is_guest` and `isGuest`
- nullable `shareToken`
### WebSocket
Use `URLSessionWebSocketTask`.
Connection URL:
- `http://...` becomes `ws://.../api/channels/:id/ws`
- `https://...` becomes `wss://.../api/channels/:id/ws`
Client messages:
```json
{ "action": "pause" }
{ "action": "unpause" }
{ "action": "seek", "timestamp": 45.5 }
{ "action": "jump", "index": 3 }
```
Server messages:
- untyped channel state object
- `{ "type": "channel_list", "channels": [...] }`
- `{ "type": "switched", "channelId": "..." }`
- `{ "type": "kick", "reason": "..." }`
- `{ "type": "error", "message": "..." }`
Reconnect policy:
- close socket intentionally when entering local library mode or signing out
- reconnect only in radio mode
- backoff: `min(3s * (attempt + 1), 30s)`
- after reconnect, load channel state via `GET /api/channels/:id`
## Playback Design
### Audio Engine
Use `AVPlayer` with one current `AVPlayerItem`.
Build track URLs as:
```text
{serverBaseURL}/api/tracks/{percent-encoded track.id}
```
For cookie-protected audio requests, create an `AVURLAsset` with HTTP header options:
```swift
let headers = [
"User-Agent": SessionStore.userAgent,
"Cookie": sessionCookie
]
let asset = AVURLAsset(
url: trackURL,
options: ["AVURLAssetHTTPHeaderFieldsKey": headers]
)
let item = AVPlayerItem(asset: asset)
player.replaceCurrentItem(with: item)
```
Verify this with the current server because custom headers on AVFoundation assets are more brittle than `URLSession` requests. If this fails, use an authenticated streaming proxy inside the app only as a fallback, or move the server to signed short-lived track URLs.
### Background Audio
Set up audio once at app startup or before first playback:
```swift
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
```
In Xcode:
- Add Signing & Capabilities -> Background Modes.
- Enable Audio, AirPlay, and Picture in Picture.
- Confirm `Info.plist` includes `UIBackgroundModes` with `audio`.
iOS does not provide an Android-equivalent foreground media service. If audio is playing, the app may continue playback in the background. If audio is paused and the app backgrounds, assume the process can be suspended. Therefore:
- Keep `AVPlayer` as the durable playback object.
- Keep Now Playing metadata accurate.
- On foreground, app activation, socket reconnect, and remote command events, resync room state.
- Do not rely on an idle WebSocket staying alive forever while paused in the background.
### Radio Sync Logic
Use the same algorithm as Android:
1. Receive channel state from WebSocket or HTTP.
2. Record `serverTimestampMs = currentTimestamp * 1000`.
3. Record `stateMonotonicTime = ProcessInfo.processInfo.systemUptime`.
4. Expected position:
- if paused: `serverTimestampMs`
- if playing: `serverTimestampMs + elapsedMonotonicMs`
5. New track:
- replace AVPlayer item
- seek to expected position
- play unless server says paused
6. Same track:
- calculate drift = `player.currentTime - expected`
- if absolute drift >= 2000ms, seek to expected
7. Keep a 500ms ticker to update UI and perform drift correction while in radio mode.
### Local Library Mode
Local mode is not room-synced:
- close WebSocket intentionally
- play selected library track through `AVPlayer`
- previous/next walks local library list
- shuffle chooses a random index not equal to current
- repeat-one seeks to zero and plays again on end
- repeat-all advances to next local library track on end
- once stops at end
The app still allows queueing the local track into the current/default room.
### Lock Screen / Control Center
Use `MPNowPlayingInfoCenter`:
- title: current track title
- artist:
- radio mode: current room name
- local mode: `Blastoise Library`
- elapsed playback time
- duration
- playback rate: `0` or `1`
Use `MPRemoteCommandCenter`:
- play
- pause
- toggle play/pause
- next track
- previous track
- seek forward 15 seconds
- seek backward 15 seconds
- change playback position
Remote commands should call the same `AppModel` intent methods used by SwiftUI.
## UI Design
Keep the current room-first shape:
```text
Header: BLASTOISE | status | theme | stop/exit
Tabs: Rooms | Queue | People | Lib | Lists
Player deck:
room badge / mode / room name / meta
theme-specific art
track title
elapsed/duration
progress + black-cat sprite on cat theme
transport controls
queue/play-next controls
status meters
Selected tab content:
rooms, queue, people, library, playlists
Diagnostics panel
```
SwiftUI view mapping:
- `RootView`: owns header, tabs, player deck, selected panel, debug panel.
- `AuthView`: server URL, username, password, sign in, sign up.
- `PlayerDeckView`: now-playing and controls.
- `RoomsView`: room cards and up-next preview.
- `QueueView`: current room queue and per-track jump/remove controls.
- `PeopleView`: listener list.
- `LibraryView`: search field, local play, queue, play-next.
- `PlaylistsView`: playlist list, selected playlist details, queue/play-next actions.
- `DebugView`: compact diagnostic text.
### Touch Targets
Minimum touch target should be 44pt. Keep per-track action buttons compact but tappable.
### Main Controls
Use icon buttons for:
- previous
- seek back 15
- play/pause
- seek forward 15
- next
- shuffle/repeat mode
- queue
- play next
- stop/exit
- theme switcher
Avoid text-only controls where an icon is clearer.
### Search and Lists
- Library search should update as the user types.
- Show first 80 matching tracks initially, matching Android.
- Playlist list can show first 40 initially.
- Queue and selected playlist track views can show first 80 initially.
## Themes
Implement `ThemeSpec` in Swift:
```swift
struct ThemeSpec: Equatable {
var key: String
var label: String
var background: Color
var panel: Color
var panel2: Color
var stroke: Color
var text: Color
var muted: Color
var accent: Color
var green: Color
var amber: Color
var red: Color
var purple: Color
var frameStyle: FrameStyle
var lightSystemBars: Bool
}
```
Initial themes:
- `arcade` / `PIXEL`
- dark warm background
- orange/green arcade accents
- pixel frame language
- Pixelify Sans or bundled pixel font
- `seraph` / `AURA`
- pale lavender body
- gold/sky/mint accents
- angel banner background
- small angel room badges
- softer angular frame
- `cat` / `BLACK CAT`
- near-black background
- lime Game Boy accents
- pixel frame language
- animated pixel cat above the progress line
### Pixel/Seraph Frames
Recreate Android's custom drawables as SwiftUI `Shape`s:
- `PixelFrame`: hard 8-bit stepped corners and square chips.
- `SeraphFrame`: angled beveled corners and fine horizontal ornaments.
Use these as backgrounds/strokes for panels, buttons, and cards. Keep cards angular; do not drift back to rounded default iOS cards.
### Angel Art
Use the same source assets as Android if licensing is acceptable for this private app:
- `android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_banner.png`
- `android/BlastoiseNative/app/src/main/res/drawable-nodpi/anime_angel_room_badge.png`
Copy them into the iOS asset catalog:
```text
Assets.xcassets/
anime_angel_banner.imageset/
anime_angel_room_badge.imageset/
```
In AURA, the banner should be both:
- a large deck image
- a subtle body background with a readability wash
### Pixel Cat
Implement as a SwiftUI `Canvas` or custom `View`, not as a raster PNG, so it can animate crisply.
Behavior:
- visible only in `BLACK CAT`
- fixed perch above the progress line, not tied to playhead progress
- leave a clear gap so the seek line remains visible
- idle animation only: small tail flick, tiny breathing bounce, optional sparkle
- reference style: chunky square head, two bright square eyes, upright ears, white chest/paws, curled tail
## iOS Project Setup
Create a new Xcode project:
- Product name: `Blastoise`
- Bundle identifier: `com.peterino.blastoise`
- Interface: SwiftUI
- Language: Swift
- Minimum iOS: iOS 17 is a pragmatic target if using modern SwiftUI observation. Use iOS 16 if broader device support matters.
Capabilities:
- Background Modes -> Audio, AirPlay, and Picture in Picture
Info.plist:
- `UIBackgroundModes` includes `audio`
- development ATS exception for `mhsgroove.peterino.com`
Assets:
- app icon
- angel banner
- angel room badge
- icon vectors if not drawn directly in SwiftUI
- bundled fonts if license permits:
- Pixelify Sans for pixel/cat themes
- Rajdhani for AURA
- JetBrains Mono for diagnostics
## Implementation Plan
### Phase 1: Skeleton and Auth
- Create SwiftUI app shell.
- Add `SessionStore` and `KeychainStore`.
- Add models and JSON decoding.
- Add login, signup, logout, me.
- Show auth panel and signed-in/signed-out state.
- Default server URL to `http://mhsgroove.peterino.com:3001`.
### Phase 2: Rooms and Radio Playback
- Add channels API.
- Auto-join default room after sign in.
- Add WebSocket room state.
- Add `AVPlayer` playback for `/api/tracks/:id`.
- Implement server timestamp sync.
- Add play/pause/seek/jump WebSocket actions.
- Add reconnect backoff.
### Phase 3: Background Audio
- Configure `AVAudioSession`.
- Enable Background Modes audio.
- Add Now Playing metadata.
- Add remote command center handlers.
- Test:
- screen off
- app backgrounded
- lock-screen pause/play
- Control Center seek
- headphone route changes
### Phase 4: Library and Local Mode
- Add library loading/search.
- Implement local playback mode.
- Implement local previous/next/shuffle/repeat.
- Add queue/play-next actions from library.
### Phase 5: Queue, People, Playlists
- Add room queue screen.
- Add jump/remove queue item.
- Add people/listeners screen.
- Add playlist list and selected playlist details.
- Add playlist queue/play-next actions.
### Phase 6: Themes and Polish
- Implement Pixel, AURA, and BLACK CAT theme specs.
- Port pixel/seraph frame shapes.
- Add angel assets and AURA body background.
- Add animated pixel cat.
- Add compact diagnostics panel.
- Tune dynamic type and small-screen layout.
### Phase 7: Packaging
- Build a debug `.ipa` for side loading if needed.
- For TestFlight/App Store:
- switch server to HTTPS
- remove or narrow ATS exception
- add app icons/launch assets
- verify background audio declaration is justified by actual music playback
- review privacy text for server auth and audio playback
## Testing Checklist
- Fresh install opens to auth screen.
- Existing session cookie restores signed-in state.
- Invalid session clears cookie and blocks app actions.
- Sign in loads rooms, library, and playlists.
- Default room auto-joins.
- WebSocket state starts audio at server timestamp.
- Drift under 2s is ignored.
- Drift over 2s seeks to expected timestamp.
- Pause/unpause controls affect room playback.
- Seek sends room seek in radio mode.
- Seek changes local player position in library mode.
- Previous/next jump room queue in radio mode.
- Previous/next navigate library in local mode.
- Queue/play-next track actions patch room queue.
- Playlist queue/play-next actions patch room queue.
- Queue item jump sends WebSocket jump.
- Queue item remove patches room queue.
- People tab shows listeners and current user.
- Stop/exit stops playback, clears player, closes socket.
- Audio keeps playing with screen locked.
- Lock-screen controls work.
- Reopening app resyncs room state.
- Theme switch persists.
- AURA shows angel background/art.
- BLACK CAT shows fixed pixel cat above the progress line.
- Debug panel shows server/auth/socket/drift data.
## Known Risks
- Cleartext HTTP is development-only on iOS. Production should use HTTPS.
- AVFoundation custom HTTP headers for protected audio URLs must be tested early. If cookies do not reliably reach `/api/tracks/:id`, the server should add short-lived signed track URLs.
- iOS background execution is narrower than Android foreground services. Playback can continue, but idle sockets while paused/backgrounded should not be treated as durable.
- The existing Android app streams tracks but does not yet implement full offline caching like the web client. iOS can add content-hash disk caching later, but it should not block the first port.
- SwiftUI custom pixel frames and cat animation should be performance-light: draw simple shapes, avoid image-heavy recomposition on every 500ms tick.
## Open Decisions
- Minimum iOS version: iOS 17 for modern SwiftUI observation, or iOS 16 for broader compatibility.
- Whether to ship the private/friend build outside TestFlight.
- Whether to add room creation/rename/delete in the first iOS version. The server supports it, but the current Android UI focuses on joining and queueing.
- Whether to implement full offline track caching in v1 or defer it.
- Whether to move the server behind HTTPS before iOS testing on physical devices.

View File

@ -1,5 +1,5 @@
import { Database } from "bun:sqlite";
import { spawn } from "child_process";
import { spawn, type ChildProcess } from "child_process";
import { createHash } from "crypto";
import { watch, type FSWatcher } from "fs";
import { readdir, stat } from "fs/promises";
@ -9,11 +9,13 @@ import { type Track } from "./db";
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
const DEFAULT_REPLAY_GAIN_CONFIG: ReplayGainScanConfig = {
const DEFAULT_REPLAY_GAIN_CONFIG: Required<ReplayGainScanConfig> = {
enabled: true,
command: "rsgain",
truePeak: false,
timeoutMs: 120000,
maxConcurrent: 1,
maxOutputBytes: 256 * 1024,
};
export interface ReplayGainScanConfig {
@ -21,6 +23,8 @@ export interface ReplayGainScanConfig {
command?: string;
truePeak?: boolean;
timeoutMs?: number;
maxConcurrent?: number;
maxOutputBytes?: number;
}
interface ReplayGainScanResult {
@ -55,6 +59,9 @@ export class Library {
private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
private replayGainConfig: Required<ReplayGainScanConfig>;
private replayGainAvailable: boolean | null = null;
private replayGainAvailabilityCheck: Promise<boolean> | null = null;
private replayGainActive = 0;
private replayGainQueue: Array<() => void> = [];
// Scan progress tracking
private _scanProgress = { scanning: false, processed: 0, total: 0 };
@ -75,12 +82,19 @@ export class Library {
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
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.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 {
this.cacheDb.run(`
CREATE TABLE IF NOT EXISTS file_cache (
@ -229,19 +243,26 @@ export class Library {
private async ensureReplayGainAvailable(): Promise<boolean> {
if (!this.replayGainConfig.enabled) return false;
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
if (this.replayGainAvailabilityCheck) return this.replayGainAvailabilityCheck;
try {
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
const version = stdout.trim().split(/\r?\n/)[0] || "available";
console.log(`[Library] rsgain found: ${version}`);
this.replayGainAvailable = true;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
this.replayGainAvailable = false;
}
this.replayGainAvailabilityCheck = this.withReplayGainSlot(async () => {
try {
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
const version = stdout.trim().split(/\r?\n/)[0] || "available";
console.log(`[Library] rsgain found: ${version}`);
this.replayGainAvailable = true;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
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> {
@ -262,7 +283,9 @@ export class Library {
args.push(filePath);
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);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
@ -295,44 +318,117 @@ export class Library {
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 }> {
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 stderr = "";
let finished = false;
let timeout: ReturnType<typeof setTimeout>;
const finish = (callback: () => void) => {
if (finished) return;
finished = true;
clearTimeout(timeout);
callback();
};
timeout = setTimeout(() => {
proc.kill();
finish(() => reject(new Error(`timed out after ${timeoutMs}ms`)));
let timedOut = false;
let outputExceeded = false;
const maxOutputBytes = this.replayGainConfig.maxOutputBytes;
const timeout = setTimeout(() => {
timedOut = true;
this.terminateProcess(proc);
}, 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) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("error", (e) => {
const onStdout = (data: Buffer) => {
stdout = appendOutput(stdout, data);
};
const onStderr = (data: Buffer) => {
stderr = appendOutput(stderr, data);
};
const onError = (e: Error) => {
finish(() => reject(e));
});
proc.on("close", (code) => {
};
const onClose = (code: number | null) => {
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 });
} else {
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 = null;
}
for (const timer of this.pendingFiles.values()) {
clearTimeout(timer);
}
this.pendingFiles.clear();
}
// Event handling

2
mise.toml Normal file
View File

@ -0,0 +1,2 @@
[tools]
bun = "1.3.14"

View File

@ -13,6 +13,9 @@
if (M.currentUser && data.permissions) {
M.currentUser.permissions = data.permissions;
}
if (data.preferences) {
M.loadReplayGainPrefs && M.loadReplayGainPrefs(data.preferences);
}
M.updateAuthUI();
// Start slow queue polling if logged in
if (M.currentUser && !M.currentUser.isGuest && M.startSlowQueuePoll) {
@ -100,6 +103,9 @@
if (M.currentUser && data.permissions) {
M.currentUser.permissions = data.permissions;
}
if (data.preferences) {
M.loadReplayGainPrefs && M.loadReplayGainPrefs(data.preferences);
}
M.updateAuthUI();
if (M.currentUser) M.loadStreams();
} catch (e) {

View File

@ -16,7 +16,7 @@
M.channels = channels;
M.renderChannelList();
// 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 targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0];
M.connectChannel(targetChannel.id);
@ -58,22 +58,31 @@
M.showToast("Cannot delete default channel");
return;
}
if (!confirm(`Delete channel "${channel.name}"?`)) return;
try {
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
M.showToast(err.error || "Failed to delete channel");
return;
M.showConfirmToast(`Delete channel "${channel.name}"?`, async () => {
try {
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
M.showToast(err.error || "Failed to delete channel", "error");
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`);
} catch (e) {
M.showToast("Failed to delete channel");
}
}, { confirmText: "Delete", duration: 15000 });
};
// New channel creation with slideout input
M.createNewChannel = async function() {
if (!M.canCreateUserContent()) {
M.showToast("Sign in to create channels", "warning");
return;
}
const header = M.$("#channels-panel .panel-header");
const btn = M.$("#btn-new-channel");
@ -150,7 +159,7 @@
counts[name] = (counts[name] || 0) + 1;
}
const listenersHtml = Object.entries(counts).map(([name, count]) =>
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
`<div class="listener">${M.escapeHtml(name)}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
).join("");
// Show delete button for non-default channels if user is admin or creator
@ -164,8 +173,8 @@
div.innerHTML = `
<div class="channel-header">
<span class="channel-name">${ch.name}</span>
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '&quot;')}" style="display:none;">
<span class="channel-name">${M.escapeHtml(ch.name)}</span>
<input class="channel-name-input" type="text" value="${M.escapeHtml(ch.name)}" style="display:none;">
${renameBtn}
${deleteBtn}
<span class="listener-count">${ch.listenerCount}</span>
@ -241,7 +250,8 @@
oldWs.close();
}
M.currentChannelId = id;
localStorage.setItem("blastoise_channel", id);
M.rememberChannel(id);
setPlaybackBlocked(false);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
@ -259,6 +269,7 @@
// Handle channel switch confirmation
if (data.type === "switched") {
M.currentChannelId = data.channelId;
M.rememberChannel(data.channelId);
M.renderChannelList();
return;
}
@ -268,6 +279,7 @@
M.wantSync = false;
M.synced = false;
M.audio.pause();
setPlaybackBlocked(false);
if (M.ws) {
const oldWs = M.ws;
M.ws = null;
@ -341,13 +353,18 @@
M.ws.onclose = () => {
M.synced = false;
M.ws = null;
setPlaybackBlocked(false);
M.$("#sync-indicator").classList.add("disconnected");
M.updateUI();
// Auto-reconnect if user wants to be synced
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
if (M.wantSync) {
const delay = wasConnected ? 3000 : 2000;
setTimeout(() => M.connectChannel(id), delay);
setTimeout(() => {
if (M.ws) return;
const reconnectId = M.currentChannelId || M.getSavedChannelId() || id;
M.connectChannel(reconnectId);
}, delay);
}
};
@ -359,6 +376,85 @@
};
};
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
M.handleUpdate = async function(data) {
console.log("[WS] State update:", {
@ -388,6 +484,7 @@
if (!data.track) {
M.setTrackTitle("No tracks");
setPlaybackBlocked(false);
return;
}
M.serverTimestamp = data.currentTimestamp;
@ -401,7 +498,8 @@
const isNewTrack = trackId !== M.currentTrackId;
if (isNewTrack) {
M.currentTrackId = trackId;
M.setTrackTitle(data.track.title);
M.setTrackTitle(M.trackComponent.getTitle(data.track));
M.applyReplayGain && M.applyReplayGain(data.track);
M.loadingSegments.clear();
// Auto-scroll queue to current track
@ -426,15 +524,11 @@
if (!M.serverPaused) {
// Server is playing - ensure we're playing and synced
if (isNewTrack || !M.audio.src) {
// Try cache first
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
M.audio.currentTime = data.currentTimestamp;
M.audio.play().catch(() => {});
} else if (M.audio.paused) {
M.audio.currentTime = data.currentTimestamp;
M.audio.play().catch(() => {});
await playSyncedAudio(data.currentTimestamp, true);
} else if (M.audio.paused || M.playbackBlocked) {
await playSyncedAudio(data.currentTimestamp, false);
} else {
setPlaybackBlocked(false);
// Check drift
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
if (drift >= 2) {
@ -444,6 +538,7 @@
}
} else {
// Server is paused - ensure we're paused too
setPlaybackBlocked(false);
if (!M.audio.paused) {
M.audio.pause();
}

20
public/controls.js vendored
View File

@ -4,6 +4,13 @@
(function() {
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
const savedVolume = localStorage.getItem(M.STORAGE_KEY);
if (savedVolume !== null) {
@ -17,8 +24,14 @@
// Toggle play/pause
function togglePlayback() {
if (!M.currentTrackId) return;
M.resumeVisualizer?.();
if (M.synced) {
if (!M.serverPaused && M.playbackBlocked) {
M.retryBlockedPlayback?.();
return;
}
if (blockSyncedControlIfNeeded()) return;
if (M.ws && M.ws.readyState === WebSocket.OPEN) {
M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" }));
}
@ -40,9 +53,11 @@
// Jump to a specific track index
async function jumpToTrack(index) {
if (M.queue.length === 0) return;
M.resumeVisualizer?.();
const newIndex = (index + M.queue.length) % M.queue.length;
if (M.synced && M.currentChannelId) {
if (blockSyncedControlIfNeeded()) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -56,7 +71,8 @@
M.currentIndex = newIndex;
M.currentTrackId = trackId;
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();
const cachedUrl = await M.loadTrackBlob(trackId);
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
@ -136,6 +152,7 @@
M.updateModeButton();
return;
}
if (blockSyncedControlIfNeeded()) return;
// Synced mode - send to server
const currentIdx = modeOrder.indexOf(M.playbackMode);
@ -175,6 +192,7 @@
const seekTime = pct * dur;
if (M.synced && M.currentChannelId) {
if (blockSyncedControlIfNeeded()) return;
fetch("/api/channels/" + M.currentChannelId + "/seek", {
method: "POST",
headers: { "Content-Type": "application/json" },

View File

@ -15,6 +15,9 @@ window.MusicRoom = {
lastServerUpdate: 0,
serverPaused: true,
// Current track cache (for ReplayGain re-application across graph init)
currentTrack: null,
// Channels list
channels: [],
@ -25,6 +28,9 @@ window.MusicRoom = {
// Volume
preMuteVolume: 1,
STORAGE_KEY: "blastoise_volume",
CHANNEL_STORAGE_KEY: "blastoise_channel",
THEME_STORAGE_KEY: "blastoise_theme",
VISUALIZER_STORAGE_KEY: "blastoise_visualizer",
// Playback state
localTimestamp: 0,
@ -65,3 +71,63 @@ window.MusicRoom = {
lastBufferPct: -1,
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 {}
};
})();

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<body>
<div id="app">
@ -47,14 +47,44 @@
<button id="btn-logout">logout</button>
</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>
<!-- Mobile tab bar -->
<div id="mobile-tabs">
<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="library-panel">Library</button>
</div>
<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">
</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="add-panel" class="add-panel hidden">
<button id="btn-add-close" class="add-panel-close">Close</button>
@ -139,6 +180,11 @@
</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="now-playing">
<div id="channel-name"></div>
@ -161,6 +207,20 @@
</div>
<div id="volume-controls">
<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>
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
</div>
@ -179,7 +239,10 @@
</div>
<script src="/trackStorage.js"></script>
<script src="/core.js"></script>
<script src="/themes.js"></script>
<script src="/utils.js"></script>
<script src="/visualizer.js"></script>
<script src="/replayGain.js"></script>
<script src="/trackComponent.js"></script>
<script src="/trackContainer.js"></script>
<script src="/audioCache.js"></script>

View File

@ -37,7 +37,8 @@
// Set up and play track
M.currentTrackId = trackId;
M.serverTrackDuration = track.duration;
M.setTrackTitle(track.title || track.filename);
M.setTrackTitle(M.trackComponent.getTitle(track));
M.applyReplayGain && M.applyReplayGain(track);
M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(trackId);
@ -117,11 +118,7 @@
// Update UI based on server status
function updateFeatureVisibility() {
const fetchBtn = M.$("#btn-fetch-url");
if (fetchBtn) {
const ytdlpEnabled = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available;
fetchBtn.style.display = ytdlpEnabled ? "" : "none";
}
if (M.updatePermissionUI) M.updatePermissionUI();
}
// Initialize the application

View File

@ -33,6 +33,7 @@
const myContainer = $('#my-playlists');
const sharedContainer = $('#shared-playlists');
if (!myContainer || !sharedContainer) return;
if (M.updatePermissionUI) M.updatePermissionUI();
// My playlists
if (myPlaylists.length === 0) {
@ -40,7 +41,7 @@
} else {
myContainer.innerHTML = myPlaylists.map(p => `
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
<span class="playlist-name">${escapeHtml(p.name)}</span>
<span class="playlist-name">${M.escapeHtml(p.name)}</span>
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
<span class="playlist-count">${p.trackIds.length}</span>
</div>
@ -53,8 +54,8 @@
} else {
sharedContainer.innerHTML = sharedPlaylists.map(p => `
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
<span class="playlist-name">${escapeHtml(p.name)}</span>
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span>
<span class="playlist-name">${M.escapeHtml(p.name)}</span>
<span class="playlist-owner">by ${M.escapeHtml(p.ownerName || 'Unknown')}</span>
<span class="playlist-count">${p.trackIds.length}</span>
</div>
`).join('');
@ -107,7 +108,7 @@
}
header.textContent = selectedPlaylist.name;
actions.classList.remove('hidden');
actions.classList.toggle('hidden', !M.canControl());
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
@ -144,18 +145,23 @@
if (!playlist) return;
const items = [];
const canEditQueue = M.canControl();
// Add to queue options
items.push({
label: '▶ Add to Queue',
action: () => addPlaylistToQueue(playlistId)
});
items.push({
label: '⏭ Play Next',
action: () => addPlaylistToQueue(playlistId, true)
});
if (canEditQueue) {
items.push({
label: '▶ Add to Queue',
action: () => addPlaylistToQueue(playlistId)
});
items.push({
label: '⏭ Play Next',
action: () => addPlaylistToQueue(playlistId, true)
});
}
items.push({ separator: true });
if (canEditQueue && (isMine || M.canCreateUserContent())) {
items.push({ separator: true });
}
if (isMine) {
// Rename
@ -183,9 +189,9 @@
items.push({
label: '🗑️ Delete',
action: () => deletePlaylist(playlistId),
className: 'danger'
danger: true
});
} else {
} else if (M.canCreateUserContent()) {
// Copy to my playlists
items.push({
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);
}
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);
if (!playlist || playlist.trackIds.length === 0) {
showToast('Playlist is empty', 'error');
@ -273,21 +289,23 @@
const playlist = myPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
try {
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete playlist');
showToast(`Deleted playlist "${playlist.name}"`);
M.showConfirmToast(`Delete playlist "${playlist.name}"?`, async () => {
try {
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete playlist');
showToast(`Deleted playlist "${playlist.name}"`);
if (selectedPlaylistId === playlistId) {
selectedPlaylistId = null;
selectedPlaylist = null;
renderPlaylistContents();
if (selectedPlaylistId === playlistId) {
selectedPlaylistId = null;
selectedPlaylist = null;
renderPlaylistContents();
}
await loadPlaylists();
} catch (err) {
console.error('Failed to delete playlist:', err);
showToast('Failed to delete playlist', 'error');
}
await loadPlaylists();
} catch (err) {
console.error('Failed to delete playlist:', err);
showToast('Failed to delete playlist', 'error');
}
}, { confirmText: 'Delete', duration: 15000 });
}
function startRenamePlaylist(playlistId) {
@ -448,6 +466,7 @@
// Show "Add to Playlist" submenu
function showAddToPlaylistMenu(trackIds) {
if (!M.canCreateUserContent()) return null;
if (myPlaylists.length === 0) {
showToast('Create a playlist first', 'info');
return null;
@ -459,18 +478,16 @@
}));
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function initPlaylists() {
// New playlist button
const btnNew = $('#btn-new-playlist');
if (btnNew) {
btnNew.onclick = () => {
if (!M.canCreateUserContent()) {
showToast('Sign in to create playlists', 'warning');
return;
}
// Inline input for new playlist name
const container = $('#my-playlists');
const input = document.createElement('input');

View File

@ -12,9 +12,12 @@
// Container instances
let queueContainer = null;
let libraryContainer = null;
let recentLibraryContainer = null;
// Library search state
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)
async function downloadTrack(trackId, filename) {
@ -190,9 +193,70 @@
};
// 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() {
const queueEl = M.$("#queue");
const libraryEl = M.$("#library");
const recentLibraryEl = M.$("#recent-library");
if (queueEl && !queueContainer) {
queueContainer = M.trackContainer.createContainer({
@ -209,18 +273,18 @@
type: 'library',
element: libraryEl,
getTracks: () => M.library,
getFilteredTracks: () => {
const query = M.librarySearchQuery.toLowerCase();
if (!query) {
return M.library.map((track, i) => ({ track, originalIndex: i }));
}
return M.library
.map((track, i) => ({ track, originalIndex: i }))
.filter(({ track }) => {
const title = track.title?.trim() || track.filename || '';
return title.toLowerCase().includes(query);
});
}
getFilteredTracks: getLibraryRows,
emptyMessage: "No library matches"
});
}
if (recentLibraryEl && !recentLibraryContainer) {
recentLibraryContainer = M.trackContainer.createContainer({
type: 'library',
element: recentLibraryEl,
getTracks: () => M.library,
getFilteredTracks: getRecentLibraryRows,
emptyMessage: "No songs added in the last 7 days"
});
}
}
@ -254,6 +318,10 @@
M.renderLibrary = function() {
initContainers();
updateLibrarySectionLabels();
if (recentLibraryContainer) {
recentLibraryContainer.render();
}
if (libraryContainer) {
libraryContainer.render();
}
@ -270,8 +338,8 @@
return;
}
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`;
const title = M.trackComponent.getTitle(track);
bar.innerHTML = `<span class="label">Now playing:</span> ${M.escapeHtml(title)}`;
bar.title = title;
bar.classList.remove("hidden");
};
@ -282,7 +350,21 @@
const activeTrack = container.querySelector(".track.active");
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" });
}
}
};

185
public/replayGain.js Normal file
View File

@ -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);
})();

View File

@ -1,8 +1,9 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; }
#app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; }
h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; }
html { width: 100%; max-width: 100%; overflow-x: hidden; overscroll-behavior-x: none; }
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; }
#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; }
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.visible { display: inline-block; }
#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; }
/* Main content - library and queue */
#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; }
#channels-panel { flex: 0 0 160px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; }
#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 { 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 .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; }
@ -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-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 .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 .channel-header:hover .btn-delete-channel { opacity: 1; }
#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 .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; }
#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; }
#channels-list .listener-mult { color: #666; font-size: 0.55rem; }
#library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; }
#channels-list .listener-mult { color: #999; font-size: 0.55rem; }
#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-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.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; }
@ -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:hover { background: #2a3a2a; }
.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 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; }
@ -144,18 +147,26 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.btn-submit-channel:hover { background: #3a5a4a; }
.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
.search-input::placeholder { color: #666; }
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
#library .track, #queue .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
#library, #queue, #recent-library { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
.library-section { flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0.45rem; }
.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-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; }
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
.track.cached .cache-indicator { background: #4e8; }
.track.not-cached .cache-indicator { background: #ea4; }
.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; }
.track-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-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: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; }
@ -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-remove { color: #e44; }
.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-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; }
/* Player bar */
#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; }
#now-playing { width: 180px; flex-shrink: 0; }
.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); }
#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; }
#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.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; }
@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.denied { animation: flash-red 0.5s ease-out; }
@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:hover { opacity: 0.8; }
#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; }
#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; }
#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.playing.synced { background: #4e8; }
#progress-bar.playing.local { background: #c4f; }
#progress-bar.muted { background: #555 !important; }
#seek-tooltip { position: absolute; bottom: 10px; background: #333; color: #eee; padding: 2px 5px; border-radius: 3px; font-size: 0.7rem; pointer-events: none; display: none; transform: translateX(-50%); }
#time { font-size: 0.75rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; }
#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 .segment { flex: 1; height: 2px; background: #333; border-radius: 1px; }
#buffer-bar .segment.available { background: #396; }
#buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; }
@keyframes throb { from { background: #444; } to { background: #888; } }
#download-speed { font-size: 0.6rem; color: #555; text-align: right; }
#volume-controls { display: flex; gap: 0.4rem; align-items: center; }
#download-speed { font-size: 0.6rem; color: #888; text-align: right; }
#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:hover { color: #888; }
#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; }
#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 */
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
button:hover { background: #333; }
#status { margin-top: 0.3rem; font-size: 0.75rem; color: #666; text-align: center; }
.empty { color: #666; font-style: italic; font-size: 0.85rem; }
.hidden { display: none !important; }
#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 { 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: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.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-track { background-color: #111; border-radius: 3px; }
@ -267,10 +332,16 @@ button:hover { background: #333; }
::-webkit-scrollbar-thumb:hover { background-color: #555; }
/* Toast notifications */
#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-height: 80vh; overflow-y: auto; }
#toast-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.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; }
.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; }
@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); } }
@ -330,6 +401,43 @@ button:hover { background: #333; }
/* Mobile tab bar - hidden on desktop */
#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 */
@media (max-width: 768px) {
html, body {
@ -352,6 +460,7 @@ button:hover { background: #333; }
#site-header { margin-bottom: 0.3rem; flex-shrink: 0; }
#site-header h1 { font-size: 0.9rem; }
#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; }
#auth-section .user-info { flex-wrap: wrap; gap: 0.3rem; }
@ -374,7 +483,8 @@ button:hover { background: #333; }
color: #666;
font-size: 0.8rem;
font-weight: 600;
padding: 0.5rem;
min-height: 44px;
padding: 0.65rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
@ -418,6 +528,9 @@ button:hover { background: #333; }
#library-panel .panel-tabs {
flex-shrink: 0;
}
#library-panel .panel-tab {
min-height: 44px;
}
#library-panel .panel-views {
flex: 1;
min-height: 0;
@ -443,6 +556,10 @@ button:hover { background: #333; }
overflow-y: auto;
width: 100%;
}
#recent-library {
max-height: 28vh;
min-height: 0;
}
.add-btn {
flex-shrink: 0;
width: auto;
@ -483,6 +600,25 @@ button:hover { background: #333; }
.track-title { font-size: 0.85rem; }
/* 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 {
flex-direction: column;
gap: 0.4rem;
@ -495,6 +631,7 @@ button:hover { background: #333; }
}
#now-playing {
width: 100%;
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
@ -504,8 +641,42 @@ button:hover { background: #333; }
flex: 1;
font-size: 0.85rem;
}
#player-controls { width: 100%; }
#progress-row { gap: 0.5rem; }
#player-controls { width: 100%; flex: 0 0 auto; }
#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 {
font-size: 1.2rem;
width: 44px;
@ -528,11 +699,36 @@ button:hover { background: #333; }
padding: 0.2rem 0.4rem;
}
#volume-controls {
justify-content: flex-end;
flex: 0 0 auto;
justify-content: center;
gap: 0.3rem;
width: 100%;
margin-left: 0;
}
#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 */
#btn-history { display: none; }
@ -540,12 +736,572 @@ button:hover { background: #333; }
/* Toast positioning */
#toast-container {
top: auto;
bottom: 5rem;
bottom: calc(9.25rem + env(safe-area-inset-bottom));
left: 0.3rem;
right: 0.3rem;
max-width: none;
max-height: 30vh;
}
.toast { max-width: none; }
/* Login panel */
#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;
}
}

91
public/themes.js Normal file
View File

@ -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);
}
});
})();

View File

@ -4,6 +4,11 @@
(function() {
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)
* @param {Object} track - Track object with id, title, filename, duration
@ -45,7 +50,7 @@
div.dataset.view = view;
// Build title
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
const title = getTitle(track);
div.title = title;
// Build HTML
@ -56,9 +61,10 @@
${checkmark}
<span class="cache-indicator"></span>
${trackNum}
<span class="track-title">${escapeHtml(title)}</span>
<span class="track-title">${M.escapeHtml(title)}</span>
<span class="track-actions">
<span class="duration">${M.fmt(track.duration)}</span>
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
</span>
`;
@ -69,17 +75,10 @@
return div;
}
// HTML escape helper
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
// Export
M.trackComponent = {
render,
escapeHtml
getTitle,
escapeHtml: M.escapeHtml
};
})();

View File

@ -29,6 +29,39 @@
// Active context menu
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
@ -41,6 +74,7 @@
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
* @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
* @param {string} [config.playlistId] - Playlist ID (for playlist type)
* @param {string} [config.emptyMessage] - Message when the container has no tracks
* @param {Function} [config.onRender] - Callback after render
*/
function createContainer(config) {
@ -52,6 +86,7 @@
canReorder = false,
isPlaylistOwner = false,
playlistId = null,
emptyMessage = null,
onRender
} = config;
@ -85,9 +120,9 @@
}
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'
: 'No tracks - drag here to add';
: 'No tracks - drag here to add');
element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
if (onRender) onRender();
return;
@ -131,7 +166,7 @@
function wirePlaylistContainerDrop(container) {
container.ondragover = (e) => {
if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') {
if (hasTrackDrag(e, ['queue', 'library', 'playlist'])) {
e.preventDefault();
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
container.classList.add("drop-target");
@ -151,6 +186,7 @@
el.classList.remove("drop-above", "drop-below");
});
restoreDragState(e);
if (draggedTrackIds.length > 0) {
e.preventDefault();
@ -287,6 +323,19 @@
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
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
if (canDrag) {
@ -376,6 +425,11 @@
div.classList.add("dragging");
// Use "copyMove" to allow both copy and move operations
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(",")}`);
}
@ -399,6 +453,7 @@
}
function handleDragOver(e, div, index) {
if (!hasTrackDrag(e, ['queue', 'library', 'playlist'])) return;
e.preventDefault();
// Set drop effect based on source
@ -429,6 +484,7 @@
}
function handleDrop(e, div, index) {
restoreDragState(e);
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
e.preventDefault();
e.stopPropagation();
@ -474,7 +530,7 @@
function wireQueueContainerDrop(container) {
container.ondragover = (e) => {
if (dragSource === 'library' || dragSource === 'playlist') {
if (hasTrackDrag(e, ['library', 'playlist'])) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (M.queue.length === 0) {
@ -491,6 +547,7 @@
container.ondrop = (e) => {
container.classList.remove("drop-target");
restoreDragState(e);
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
e.preventDefault();
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
@ -540,11 +597,16 @@
async function playTrack(track, index) {
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') {
// Jump to track in queue
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", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -557,6 +619,7 @@
M.currentTrackId = trackId;
M.serverTrackDuration = track.duration;
M.setTrackTitle(title);
M.applyReplayGain && M.applyReplayGain(track);
M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(trackId);
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
@ -570,11 +633,12 @@
async function previewTrack(track) {
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.serverTrackDuration = track.duration;
M.setTrackTitle(title);
M.applyReplayGain && M.applyReplayGain(track);
M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(trackId);
@ -594,7 +658,7 @@
function showContextMenu(e, track, index, canEditQueue) {
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 hasSelection = sel.size > 0;
@ -617,7 +681,7 @@
const menuItems = [];
// Play (queue only, single track or single selection)
if (type === 'queue' && selectedCount === 1) {
if (type === 'queue' && selectedCount === 1 && (canEditQueue || !M.synced)) {
menuItems.push({
label: "▶ Play",
action: () => playTrack(track, index)
@ -701,7 +765,7 @@
}
// Add to Playlist
if (M.playlists && !M.currentUser?.is_guest) {
if (M.playlists && !M.currentUser?.isGuest) {
const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
if (submenu && submenu.length > 0) {
menuItems.push({
@ -870,10 +934,10 @@
// Adjust if off-screen
const rect = menu.getBoundingClientRect();
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) {
menu.style.top = (window.innerHeight - rect.height - 5) + "px";
menu.style.top = Math.max(5, window.innerHeight - rect.height - 5) + "px";
}
activeContextMenu = menu;

View File

@ -14,8 +14,9 @@
// Update general UI state
M.updateUI = function() {
const isConnecting = M.wantSync && !M.synced;
const playbackBlocked = M.synced && M.playbackBlocked && !M.serverPaused;
// 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("connected", M.synced);
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("muted", 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
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
@ -49,6 +61,7 @@
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
// Re-render channel list to update rename/delete buttons
if (M.renderChannelList) M.renderChannelList();
if (M.updatePermissionUI) M.updatePermissionUI();
} else {
M.$("#login-panel").classList.remove("hidden");
M.$("#player-content").classList.remove("visible");
@ -68,6 +81,7 @@
} else {
M.$("#guest-section").classList.add("hidden");
}
if (M.updatePermissionUI) M.updatePermissionUI();
}
M.updateUI();
};
@ -177,7 +191,7 @@
};
// 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) {
tabs.forEach(t => t.classList.toggle("active", t.dataset.panel === panelId));

View File

@ -106,11 +106,7 @@
const data = await res.json();
if (data.type === "playlist") {
// Ask user to confirm playlist download
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`);
if (confirmed) {
// Confirm playlist download with title for auto-playlist creation
const queuePlaylist = async () => {
const confirmRes = await fetch("/api/fetch/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -128,7 +124,13 @@
const err = await confirmRes.json().catch(() => ({}));
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") {
M.showToast(`Queued: ${data.title}`);
// Task will be created by WebSocket progress messages
@ -164,17 +166,22 @@
// Drag and drop on library panel
let dragCounter = 0;
function isFileDrag(e) {
return [...(e.dataTransfer?.types || [])].includes("Files");
}
libraryPanel.ondragenter = (e) => {
if (!M.currentUser) return;
if (!e.dataTransfer.types.includes("Files")) return;
if (!isFileDrag(e)) return;
e.preventDefault();
dragCounter++;
dropzone.classList.remove("hidden");
};
libraryPanel.ondragleave = (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
dragCounter--;
dragCounter = Math.max(0, dragCounter - 1);
if (dragCounter === 0) {
dropzone.classList.add("hidden");
}
@ -182,12 +189,13 @@
libraryPanel.ondragover = (e) => {
if (!M.currentUser) return;
if (!e.dataTransfer.types.includes("Files")) return;
if (!isFileDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
libraryPanel.ondrop = (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("hidden");
@ -296,14 +304,14 @@
for (const [playlistId, group] of byPlaylist) {
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) => {
const isNext = queuedItems.indexOf(item) === 0;
return `
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
<span class="slow-queue-item-title">${item.title}</span>
<span class="slow-queue-item-title">${M.escapeHtml(item.title)}</span>
<button class="slow-queue-cancel" title="Cancel"></button>
</div>
`;

View File

@ -7,6 +7,18 @@
// DOM selector helper
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
// Format seconds as m:ss
M.fmt = function(sec) {
if (!sec || !isFinite(sec)) return "0:00";
@ -18,6 +30,14 @@
// Toast history
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)
M.showToast = function(message, type = "info", duration = 5000) {
const container = M.$("#toast-container");
@ -25,6 +45,7 @@
toast.className = "toast toast-" + type;
toast.textContent = message;
container.appendChild(toast);
pruneVisibleToasts(container);
setTimeout(() => {
toast.classList.add("fade-out");
setTimeout(() => toast.remove(), 300);
@ -39,6 +60,56 @@
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
M.updateToastHistory = function() {
const list = M.$("#toast-history-list");
@ -51,7 +122,7 @@
const div = document.createElement("div");
div.className = "history-item history-" + item.type;
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
div.innerHTML = `<span class="history-time">${time}</span> ${item.message}`;
div.innerHTML = `<span class="history-time">${time}</span> ${M.escapeHtml(item.message)}`;
list.appendChild(div);
}
};
@ -87,7 +158,7 @@
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
// 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
requestAnimationFrame(() => {
@ -97,7 +168,7 @@
// Duplicate text for seamless wrap-around scrolling
if (needsScroll) {
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`;
marqueeEl.innerHTML = `<span id="track-title">${M.escapeHtml(title)}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${M.escapeHtml(title)}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`;
}
});
};
@ -112,10 +183,29 @@
M.canControl = function() {
if (!M.currentUser) return false;
if (M.currentUser.isAdmin) return true;
if (M.currentUser.isGuest) return false;
return M.currentUser.permissions?.some(p =>
p.resource_type === "channel" &&
(p.resource_id === M.currentChannelId || p.resource_id === null) &&
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";
}
};
})();

803
public/visualizer.js Normal file
View File

@ -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();
});
})();

View File

@ -9,6 +9,8 @@ import {
getAllUsers,
grantPermission,
revokePermission,
getAllUserPreferences,
setUserPreference,
} from "../db";
import {
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
export function handleGetMe(req: Request, server: any): Response {
const { user, headers } = getOrCreateUser(req, server);
@ -116,9 +129,38 @@ export function handleGetMe(req: Request, server: any): Response {
return Response.json({
user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest },
permissions: effectivePermissions,
preferences: getAllUserPreferences(user.id),
}, { 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
export function handleKickOthers(req: Request, server: any): Response {
const { user } = getOrCreateUser(req, server);

View File

@ -9,6 +9,7 @@ import {
handleLogin,
handleLogout,
handleGetMe,
handleUpdatePreferences,
handleKickOthers,
handleListUsers,
handleGrantPermission,
@ -212,6 +213,9 @@ export function createRouter() {
if (path === "/api/auth/me") {
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") {
return handleKickOthers(req, server);
}

View File

@ -3,22 +3,27 @@ import { join } from "path";
import { PUBLIC_DIR } from "../config";
// 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> {
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
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") {
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") {
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)));
if (await jsFile.exists()) {
return new Response(jsFile, {
headers: { "Content-Type": "application/javascript" },
headers: { "Content-Type": "application/javascript", "Content-Security-Policy": CSP },
});
}
}

View File

@ -19,6 +19,7 @@ export function handleGetLibrary(req: Request, server: any): Response {
duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
createdAt: t.created_at,
available: t.available,
}));
return Response.json(tracks, { headers });

View File

@ -4,6 +4,7 @@ import { init } from "./init";
import { createRouter } from "./routes";
import { websocketHandlers } from "./websocket";
await init();
serve({

36
todo/README.md Normal file
View File

@ -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 | LowMed | — |
| 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 14 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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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`.

View File

@ -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.

View File

@ -0,0 +1,121 @@
# WebSocket Client Robustness: Parse Guard, Reconnect Backoff, Dead-Channel Fallback
Priority: **High** · Effort: **LowMedium** · 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 23s 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; // 0500ms
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.

View File

@ -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.

View File

@ -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,'&quot;')}">` | 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
```
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.