diff --git a/AGENTS.md b/AGENTS.md
index 12704be..a80943c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -110,10 +110,26 @@ GET /api/library → List all tracks with id, filename, title, dura
## Files
### Server
-- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
+- **server.ts** — Bun entrypoint. Imports and starts the server.
+- **config.ts** — Config types, loading, and exports (port, musicDir, etc).
+- **state.ts** — Shared application state (channels Map, userConnections Map, library).
+- **init.ts** — Server initialization, channel loading, tick interval, library events.
+- **broadcast.ts** — Broadcast utilities (broadcastToAll, sendToUser, broadcastChannelList).
+- **websocket.ts** — WebSocket open/close/message handlers.
- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting.
- **library.ts** — `Library` class. Scans music directory, computes content hashes.
- **db.ts** — SQLite database for users, sessions, tracks.
+- **auth.ts** — Auth helpers (getUser, requirePermission, session cookies).
+- **ytdlp.ts** — yt-dlp integration for fetching audio from URLs.
+
+### Routes (routes/)
+- **index.ts** — Main router, dispatches to route handlers.
+- **helpers.ts** — Shared route helpers (getOrCreateUser, userHasPermission).
+- **auth.ts** — Auth endpoints (signup, login, logout, me, admin).
+- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
+- **tracks.ts** — Library listing, file upload, audio serving with range support.
+- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
+- **static.ts** — Static file serving (index.html, styles.css, JS files).
### Client (public/)
- **core.js** — Global state namespace (`window.MusicRoom`)
diff --git a/db.ts b/db.ts
index 775addc..ab8eb8d 100644
--- a/db.ts
+++ b/db.ts
@@ -384,6 +384,10 @@ export function deleteChannelFromDb(id: string): void {
db.query("DELETE FROM channels WHERE id = ?").run(id);
}
+export function updateChannelName(id: string, name: string): void {
+ db.query("UPDATE channels SET name = ? WHERE id = ?").run(name, id);
+}
+
// Queue persistence functions
export function saveChannelQueue(channelId: string, trackIds: string[]): void {
db.query("BEGIN").run();
diff --git a/public/channelSync.js b/public/channelSync.js
index 3d9db5b..a4b955a 100644
--- a/public/channelSync.js
+++ b/public/channelSync.js
@@ -158,16 +158,60 @@
(M.currentUser.isAdmin || ch.createdBy === M.currentUser.id);
const deleteBtn = canDelete ? `` : "";
+ // Show rename button for signed-in non-guests
+ const canRename = M.currentUser && !M.currentUser.isGuest;
+ const renameBtn = canRename ? `` : "";
+
div.innerHTML = `
${listenersHtml}
`;
const headerEl = div.querySelector(".channel-header");
- headerEl.querySelector(".channel-name").onclick = () => M.switchChannel(ch.id);
+ const nameSpan = headerEl.querySelector(".channel-name");
+ const nameInput = headerEl.querySelector(".channel-name-input");
+ nameSpan.onclick = () => M.switchChannel(ch.id);
+
+ const renBtn = headerEl.querySelector(".btn-rename-channel");
+ if (renBtn) {
+ renBtn.onclick = (e) => {
+ e.stopPropagation();
+ nameSpan.style.display = "none";
+ renBtn.style.display = "none";
+ nameInput.style.display = "inline-block";
+ nameInput.focus();
+ nameInput.select();
+ };
+ }
+
+ // Handle inline rename
+ const submitRename = async () => {
+ const newName = nameInput.value.trim();
+ if (newName && newName !== ch.name) {
+ await M.renameChannel(ch.id, newName);
+ } else {
+ // Restore original display
+ nameSpan.style.display = "";
+ if (renBtn) renBtn.style.display = "";
+ nameInput.style.display = "none";
+ }
+ };
+ nameInput.onblur = submitRename;
+ nameInput.onkeydown = (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ nameInput.blur();
+ } else if (e.key === "Escape") {
+ nameInput.value = ch.name;
+ nameInput.blur();
+ }
+ };
+
const delBtn = headerEl.querySelector(".btn-delete-channel");
if (delBtn) {
delBtn.onclick = (e) => {
@@ -411,4 +455,23 @@
}
M.updateUI();
};
+
+ // Rename channel
+ M.renameChannel = async function(channelId, newName) {
+ try {
+ const res = await fetch(`/api/channels/${channelId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: newName.trim() })
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ M.showToast(data.error || "Failed to rename channel", "error");
+ return;
+ }
+ M.showToast(`Channel renamed to "${data.name}"`);
+ } catch (e) {
+ M.showToast("Failed to rename channel", "error");
+ }
+ };
})();
diff --git a/public/index.html b/public/index.html
index 4b274d9..aafff6f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,7 +8,10 @@
-
Blastoise
+
Sign in to continue
diff --git a/public/styles.css b/public/styles.css
index 5a0262f..b3b0a3e 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -15,8 +15,14 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; }
#btn-logout { background: none; border: none; color: #e44; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
#btn-logout:hover { opacity: 1; text-decoration: underline; }
+#btn-logout.guest-signin { background: #4e8; color: #111; font-size: 0.85rem; font-weight: 600; padding: 0.4rem 1rem; border-radius: 4px; opacity: 1; }
+#btn-logout.guest-signin:hover { background: #5fa; text-decoration: none; }
#btn-kick-others { background: none; border: none; color: #ea4; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; }
#btn-kick-others:hover { opacity: 1; text-decoration: underline; }
+#site-header { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 0.5rem; }
+#site-header h1 { margin: 0; }
+#btn-report-bug { color: #4e8; font-size: 0.8rem; text-decoration: none; padding: 0.3rem 0.6rem; border: 1px solid #4e8; border-radius: 4px; }
+#btn-report-bug:hover { background: #4e8; color: #111; }
#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 */
@@ -28,10 +34,14 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; }
#channels-list .channel-header:hover { background: #222; }
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
+#channels-list .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 .btn-delete-channel { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
#channels-list .channel-header:hover .btn-delete-channel { opacity: 1; }
#channels-list .btn-delete-channel:hover { color: #e44; }
+#channels-list .btn-rename-channel { background: none; border: none; color: #666; font-size: 0.7rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; }
+#channels-list .channel-header:hover .btn-rename-channel { opacity: 1; }
+#channels-list .btn-rename-channel:hover { color: #4e8; }
#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; }
diff --git a/public/ui.js b/public/ui.js
index 26587cd..be05807 100644
--- a/public/ui.js
+++ b/public/ui.js
@@ -40,11 +40,15 @@
if (M.currentUser.isGuest) {
M.$("#current-username").textContent = "Guest";
M.$("#btn-logout").textContent = "Sign In";
+ M.$("#btn-logout").classList.add("guest-signin");
} else {
M.$("#current-username").textContent = M.currentUser.username;
M.$("#btn-logout").textContent = "Logout";
+ M.$("#btn-logout").classList.remove("guest-signin");
}
M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none";
+ // Re-render channel list to update rename/delete buttons
+ if (M.renderChannelList) M.renderChannelList();
} else {
M.$("#login-panel").classList.remove("hidden");
M.$("#player-content").classList.remove("visible");
diff --git a/routes/channels.ts b/routes/channels.ts
index 2bac563..29c29d2 100644
--- a/routes/channels.ts
+++ b/routes/channels.ts
@@ -4,6 +4,7 @@ import {
deleteChannelFromDb,
saveChannelQueue,
updateChannelState,
+ updateChannelName,
} from "../db";
import { state } from "../state";
import { broadcastChannelList } from "../broadcast";
@@ -130,6 +131,38 @@ export function handleDeleteChannel(req: Request, server: any, channelId: string
return Response.json({ success: true });
}
+// PATCH /api/channels/:id - rename channel
+export async function handleRenameChannel(req: Request, server: any, channelId: string): Promise {
+ const { user } = getOrCreateUser(req, server);
+ if (!user) {
+ return Response.json({ error: "Authentication required" }, { status: 401 });
+ }
+ if (user.is_guest) {
+ return Response.json({ error: "Guests cannot rename channels" }, { status: 403 });
+ }
+ const channel = state.channels.get(channelId);
+ if (!channel) {
+ return Response.json({ error: "Channel not found" }, { status: 404 });
+ }
+ try {
+ const { name } = await req.json();
+ if (!name || typeof name !== "string" || name.trim().length === 0) {
+ return Response.json({ error: "Name is required" }, { status: 400 });
+ }
+ if (name.trim().length > 64) {
+ return Response.json({ error: "Name must be 64 characters or less" }, { status: 400 });
+ }
+ const newName = name.trim();
+ channel.name = newName;
+ updateChannelName(channelId, newName);
+ console.log(`[Channel] Renamed "${channelId}" to "${newName}" by user ${user.id}`);
+ broadcastChannelList();
+ return Response.json({ success: true, name: newName });
+ } catch {
+ return Response.json({ error: "Invalid request" }, { status: 400 });
+ }
+}
+
// GET /api/channels/:id - get channel state
export function handleGetChannel(channelId: string): Response {
const channel = state.channels.get(channelId);
diff --git a/routes/index.ts b/routes/index.ts
index eba25f7..9f9d33e 100644
--- a/routes/index.ts
+++ b/routes/index.ts
@@ -25,6 +25,7 @@ import {
handleSeek,
handleModifyQueue,
handleSetMode,
+ handleRenameChannel,
} from "./channels";
// Track routes
@@ -85,6 +86,11 @@ export function createRouter() {
return handleDeleteChannel(req, server, channelDeleteMatch[1]);
}
+ const channelPatchMatch = path.match(/^\/api\/channels\/([^/]+)$/);
+ if (channelPatchMatch && req.method === "PATCH") {
+ return handleRenameChannel(req, server, channelPatchMatch[1]);
+ }
+
const channelGetMatch = path.match(/^\/api\/channels\/([^/]+)$/);
if (channelGetMatch && req.method === "GET") {
return handleGetChannel(channelGetMatch[1]);