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} 👤 +
+ ${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; }