diff --git a/AGENTS.md b/AGENTS.md
index 3fb151e..ede7a4e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -154,6 +154,7 @@ interface Track {
playlist: Track[],
currentIndex: number,
listenerCount: number,
+ listeners: string[], // usernames of connected users
isDefault: boolean
}
```
diff --git a/channel.ts b/channel.ts
index 0d77d8b..d6724e3 100644
--- a/channel.ts
+++ b/channel.ts
@@ -16,7 +16,7 @@ export interface ChannelConfig {
isDefault?: boolean;
}
-export type WsData = { channelId: string; userId: number | null };
+export type WsData = { channelId: string; userId: number | null; username: string };
export class Channel {
id: string;
@@ -131,6 +131,15 @@ export class Channel {
this.playlistDirty = true;
}
+ setPlaylist(tracks: Track[]) {
+ this.playlist = tracks;
+ this.currentIndex = 0;
+ this.startedAt = Date.now();
+ this.pausedAt = 0;
+ this.playlistDirty = true;
+ this.broadcast();
+ }
+
broadcast() {
const now = Date.now();
const includePlaylist = this.playlistDirty || (now - this.lastPlaylistBroadcast >= 60000);
@@ -140,9 +149,6 @@ export class Channel {
}
const msg = JSON.stringify(this.getState(includePlaylist));
- const clientIds = Array.from(this.clients).map(ws => ws.data?.sessionId ?? 'unknown');
- console.log(`[Channel] "${this.name}" broadcasting to ${this.clients.size} clients: [${clientIds.join(', ')}]`);
-
for (const ws of this.clients) {
ws.send(msg);
}
@@ -164,12 +170,14 @@ export class Channel {
}
getListInfo() {
+ const listeners = Array.from(this.clients).map(ws => ws.data?.username ?? 'Unknown');
return {
id: this.id,
name: this.name,
description: this.description,
trackCount: this.playlist.length,
listenerCount: this.clients.size,
+ listeners,
isDefault: this.isDefault,
createdBy: this.createdBy,
};
diff --git a/musicroom.db b/musicroom.db
index e862c6e..a6982e8 100644
Binary files a/musicroom.db and b/musicroom.db differ
diff --git a/public/channelSync.js b/public/channelSync.js
index 9b8e8e5..9a664bf 100644
--- a/public/channelSync.js
+++ b/public/channelSync.js
@@ -4,7 +4,7 @@
(function() {
const M = window.MusicRoom;
- // Load available channels and connect to first one
+ // Load available channels and connect to saved or default
M.loadChannels = async function() {
try {
const res = await fetch("/api/channels");
@@ -15,9 +15,11 @@
}
M.channels = channels;
M.renderChannelList();
- // Connect to first (default) channel
- const defaultChannel = channels.find(c => c.isDefault) || channels[0];
- M.connectChannel(defaultChannel.id);
+ // Try saved channel first, fall back to default
+ const savedChannelId = localStorage.getItem("musicroom_channel");
+ const savedChannel = savedChannelId && channels.find(c => c.id === savedChannelId);
+ const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0];
+ M.connectChannel(targetChannel.id);
} catch (e) {
M.$("#track-title").textContent = "Server unavailable";
M.$("#status").textContent = "Local (offline)";
@@ -48,16 +50,65 @@
}
};
+ // New channel creation with slideout input
+ M.createNewChannel = async function() {
+ const header = M.$("#channels-panel .panel-header");
+ const btn = M.$("#btn-new-channel");
+
+ // Already in edit mode?
+ if (header.querySelector(".new-channel-input")) return;
+
+ // Hide button, show input
+ btn.style.display = "none";
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "new-channel-input";
+ input.placeholder = "Channel name...";
+
+ const submit = document.createElement("button");
+ submit.className = "btn-submit-channel";
+ submit.textContent = "›";
+
+ header.appendChild(input);
+ header.appendChild(submit);
+ input.focus();
+
+ const cleanup = () => {
+ input.remove();
+ submit.remove();
+ btn.style.display = "";
+ };
+
+ const doCreate = async () => {
+ const name = input.value.trim();
+ if (!name) {
+ cleanup();
+ return;
+ }
+ try {
+ await M.createChannel(name);
+ } catch (e) {
+ M.showToast("Failed to create channel");
+ }
+ cleanup();
+ };
+
+ submit.onclick = doCreate;
+ input.onkeydown = (e) => {
+ if (e.key === "Enter") doCreate();
+ if (e.key === "Escape") cleanup();
+ };
+ input.onblur = (e) => {
+ if (e.relatedTarget !== submit) cleanup();
+ };
+ };
+
// New channel button handler
document.addEventListener("DOMContentLoaded", () => {
const btn = M.$("#btn-new-channel");
if (btn) {
- btn.onclick = () => {
- const name = prompt("Channel name:");
- if (name && name.trim()) {
- M.createChannel(name.trim());
- }
- };
+ btn.onclick = () => M.createNewChannel();
}
});
@@ -69,11 +120,23 @@
for (const ch of M.channels || []) {
const div = document.createElement("div");
div.className = "channel-item" + (ch.id === M.currentChannelId ? " active" : "");
+ const listeners = ch.listeners || [];
+ // Count occurrences of each user
+ const counts = {};
+ for (const name of listeners) {
+ counts[name] = (counts[name] || 0) + 1;
+ }
+ const listenersHtml = Object.entries(counts).map(([name, count]) =>
+ `
${name}${count > 1 ? ` x${count}` : ""}
`
+ ).join("");
div.innerHTML = `
- ${ch.name}
- ${ch.listenerCount} 👤
+
+ ${listenersHtml}
`;
- div.onclick = () => M.switchChannel(ch.id);
+ div.querySelector(".channel-header").onclick = () => M.switchChannel(ch.id);
container.appendChild(div);
}
};
@@ -95,6 +158,7 @@
oldWs.close();
}
M.currentChannelId = id;
+ localStorage.setItem("musicroom_channel", id);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
@@ -234,9 +298,17 @@
M.audio.currentTime = data.currentTimestamp;
}
}
- } else if (!wasServerPaused && M.serverPaused) {
- // Server just paused
- M.audio.pause();
+ } else {
+ // Server is paused - ensure we're paused too
+ if (!M.audio.paused) {
+ M.audio.pause();
+ }
+ // Sync to paused position
+ if (isNewTrack || !M.audio.src) {
+ const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
+ M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
+ M.audio.currentTime = data.currentTimestamp;
+ }
}
}
M.updateUI();
diff --git a/public/playlist.js b/public/playlist.js
index 4f9c161..eb961b7 100644
--- a/public/playlist.js
+++ b/public/playlist.js
@@ -275,6 +275,30 @@
M.renderLibrary();
return;
}
+
+ // If synced to a channel, broadcast playlist change to server
+ if (M.synced && M.currentChannelId) {
+ try {
+ const res = await fetch("/api/channels/" + M.currentChannelId + "/playlist", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ playlistId: playlistId === "all" ? "all" : parseInt(playlistId) })
+ });
+ if (res.status === 403) {
+ M.flashPermissionDenied();
+ return;
+ }
+ if (!res.ok) throw new Error("Failed to set playlist");
+ M.selectedPlaylistId = playlistId;
+ M.renderPlaylistSelector();
+ // Server will broadcast new playlist via WebSocket
+ return;
+ } catch (e) {
+ console.warn("Failed to set channel playlist:", e);
+ }
+ }
+
+ // Local mode - load playlist directly
if (playlistId === "all") {
// Use library as playlist
M.playlist = [...M.library];
diff --git a/public/styles.css b/public/styles.css
index fdccddb..a6354c7 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -19,11 +19,16 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe
#main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; }
#channels-panel { flex: 0 0 180px; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; }
#channels-list { flex: 1; overflow-y: auto; }
-#channels-list .channel-item { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; }
-#channels-list .channel-item:hover { background: #222; }
+#channels-list .channel-item { padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.9rem; display: flex; flex-direction: column; gap: 0.25rem; }
#channels-list .channel-item.active { background: #2a4a3a; color: #4e8; }
+#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.25rem 0; border-radius: 4px; }
+#channels-list .channel-header:hover { background: #222; }
#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#channels-list .listener-count { font-size: 0.75rem; color: #666; flex-shrink: 0; margin-left: 0.5rem; }
+#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 1rem; border-left: 2px solid #333; padding-left: 0.5rem; }
+#channels-list .listener { font-size: 0.75rem; color: #aaa; padding: 0.15rem 0; position: relative; }
+#channels-list .listener::before { content: ""; position: absolute; left: -0.5rem; top: 50%; width: 0.4rem; height: 2px; background: #333; }
+#channels-list .listener-mult { color: #666; font-size: 0.65rem; }
#library-panel, #playlist-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; }
#playlists-panel { flex: 1; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; min-width: 180px; max-width: 250px; }
#playlists-list { flex: 1; overflow-y: auto; }
@@ -36,9 +41,9 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe
.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
.panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; }
.panel-header button:hover { background: #444; }
-.new-playlist-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
-.btn-submit-playlist { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; }
-.btn-submit-playlist:hover { background: #3a5a4a; }
+.new-playlist-input, .new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
+.btn-submit-playlist, .btn-submit-channel { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; }
+.btn-submit-playlist:hover, .btn-submit-channel:hover { background: #3a5a4a; }
#library, #playlist { flex: 1; overflow-y: auto; }
#library .track, #playlist .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; position: relative; }
#library .track:hover, #playlist .track:hover { background: #222; }
diff --git a/public/utils.js b/public/utils.js
index 08363aa..cc88fc9 100644
--- a/public/utils.js
+++ b/public/utils.js
@@ -48,8 +48,8 @@
if (!M.currentUser) return false;
if (M.currentUser.isAdmin) return true;
return M.currentUser.permissions?.some(p =>
- p.resource_type === "stream" &&
- (p.resource_id === M.currentStreamId || p.resource_id === null) &&
+ p.resource_type === "channel" &&
+ (p.resource_id === M.currentChannelId || p.resource_id === null) &&
p.permission === "control"
);
};
diff --git a/server.ts b/server.ts
index e3bdb17..80eef5c 100644
--- a/server.ts
+++ b/server.ts
@@ -125,7 +125,7 @@ function broadcastToAll(message: object) {
// Broadcast channel list to all clients
function broadcastChannelList() {
const list = [...channels.values()].map(c => c.getListInfo());
- console.log(`[Broadcast] Sending channel_list to all clients (${list.length} channels)`);
+ console.log(`[Broadcast] Sending channel_list to all clients (${list.length} channels)`, JSON.stringify(list.map(c => ({ id: c.id, listeners: c.listeners }))));
broadcastToAll({ type: "channel_list", channels: list });
}
@@ -174,8 +174,6 @@ setInterval(() => {
}
}, 1000);
-type WsData = { streamId: string; userId: number | null };
-
// Helper to get or create guest session
function getOrCreateUser(req: Request, server: any): { user: ReturnType, headers?: Headers } {
let user = getUser(req, server);
@@ -221,7 +219,7 @@ serve({
const id = path.split("/")[3];
if (!channels.has(id)) return new Response("Channel not found", { status: 404 });
const { user } = getOrCreateUser(req, server);
- const ok = server.upgrade(req, { data: { channelId: id, userId: user?.id ?? null } });
+ const ok = server.upgrade(req, { data: { channelId: id, userId: user?.id ?? null, username: user?.username ?? 'Guest' } });
if (ok) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 });
}
@@ -698,6 +696,35 @@ serve({
}
}
+ // API: set channel playlist
+ const playlistMatch = path.match(/^\/api\/channels\/([^/]+)\/playlist$/);
+ if (playlistMatch && req.method === "POST") {
+ const channelId = playlistMatch[1];
+ const { user } = getOrCreateUser(req, server);
+ if (!userHasPermission(user, "channel", channelId, "control")) {
+ return new Response("Forbidden", { status: 403 });
+ }
+ const channel = channels.get(channelId);
+ if (!channel) return new Response("Not found", { status: 404 });
+ try {
+ const body = await req.json();
+ let tracks: Track[] = [];
+ if (body.playlistId === "all") {
+ tracks = library.getAllTracks();
+ } else if (typeof body.playlistId === "number") {
+ const playlist = getPlaylist(body.playlistId);
+ if (!playlist) return new Response("Playlist not found", { status: 404 });
+ tracks = getPlaylistTracks(body.playlistId);
+ } else {
+ return new Response("Invalid playlistId", { status: 400 });
+ }
+ channel.setPlaylist(tracks);
+ return Response.json({ success: true, trackCount: tracks.length });
+ } catch {
+ return new Response("Invalid JSON", { status: 400 });
+ }
+ }
+
// API: serve audio file (requires auth or guest)
// Supports both filename and track ID (sha256:...)
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
@@ -782,19 +809,20 @@ serve({
const channel = channels.get(ws.data.channelId);
if (channel) {
channel.addClient(ws);
- // Send channel list on connect
- const list = [...channels.values()].map(c => c.getListInfo());
- ws.send(JSON.stringify({ type: "channel_list", channels: list }));
+ // Broadcast updated channel list to all clients
+ broadcastChannelList();
}
},
close(ws: ServerWebSocket) {
const channel = channels.get(ws.data.channelId);
- if (channel) channel.removeClient(ws);
+ if (channel) {
+ channel.removeClient(ws);
+ broadcastChannelList();
+ }
},
message(ws: ServerWebSocket, message: string | Buffer) {
try {
const data = JSON.parse(String(message));
- console.log("[WS] Received message:", data.action, "from streamId:", ws.data.streamId);
// Handle channel switching
if (data.action === "switch" && data.channelId) {
@@ -808,6 +836,7 @@ serve({
ws.data.channelId = data.channelId;
newChannel.addClient(ws);
ws.send(JSON.stringify({ type: "switched", channelId: data.channelId }));
+ broadcastChannelList();
return;
}