This commit is contained in:
peterino2 2026-02-02 22:48:22 -08:00
parent 629deaab3f
commit 12856b439c
8 changed files with 175 additions and 36 deletions

View File

@ -154,6 +154,7 @@ interface Track {
playlist: Track[], playlist: Track[],
currentIndex: number, currentIndex: number,
listenerCount: number, listenerCount: number,
listeners: string[], // usernames of connected users
isDefault: boolean isDefault: boolean
} }
``` ```

View File

@ -16,7 +16,7 @@ export interface ChannelConfig {
isDefault?: boolean; isDefault?: boolean;
} }
export type WsData = { channelId: string; userId: number | null }; export type WsData = { channelId: string; userId: number | null; username: string };
export class Channel { export class Channel {
id: string; id: string;
@ -131,6 +131,15 @@ export class Channel {
this.playlistDirty = true; 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() { broadcast() {
const now = Date.now(); const now = Date.now();
const includePlaylist = this.playlistDirty || (now - this.lastPlaylistBroadcast >= 60000); const includePlaylist = this.playlistDirty || (now - this.lastPlaylistBroadcast >= 60000);
@ -140,9 +149,6 @@ export class Channel {
} }
const msg = JSON.stringify(this.getState(includePlaylist)); 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) { for (const ws of this.clients) {
ws.send(msg); ws.send(msg);
} }
@ -164,12 +170,14 @@ export class Channel {
} }
getListInfo() { getListInfo() {
const listeners = Array.from(this.clients).map(ws => ws.data?.username ?? 'Unknown');
return { return {
id: this.id, id: this.id,
name: this.name, name: this.name,
description: this.description, description: this.description,
trackCount: this.playlist.length, trackCount: this.playlist.length,
listenerCount: this.clients.size, listenerCount: this.clients.size,
listeners,
isDefault: this.isDefault, isDefault: this.isDefault,
createdBy: this.createdBy, createdBy: this.createdBy,
}; };

Binary file not shown.

View File

@ -4,7 +4,7 @@
(function() { (function() {
const M = window.MusicRoom; 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() { M.loadChannels = async function() {
try { try {
const res = await fetch("/api/channels"); const res = await fetch("/api/channels");
@ -15,9 +15,11 @@
} }
M.channels = channels; M.channels = channels;
M.renderChannelList(); M.renderChannelList();
// Connect to first (default) channel // Try saved channel first, fall back to default
const defaultChannel = channels.find(c => c.isDefault) || channels[0]; const savedChannelId = localStorage.getItem("musicroom_channel");
M.connectChannel(defaultChannel.id); 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) { } catch (e) {
M.$("#track-title").textContent = "Server unavailable"; M.$("#track-title").textContent = "Server unavailable";
M.$("#status").textContent = "Local (offline)"; 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 // New channel button handler
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const btn = M.$("#btn-new-channel"); const btn = M.$("#btn-new-channel");
if (btn) { if (btn) {
btn.onclick = () => { btn.onclick = () => M.createNewChannel();
const name = prompt("Channel name:");
if (name && name.trim()) {
M.createChannel(name.trim());
}
};
} }
}); });
@ -69,11 +120,23 @@
for (const ch of M.channels || []) { for (const ch of M.channels || []) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "channel-item" + (ch.id === M.currentChannelId ? " active" : ""); 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]) =>
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
).join("");
div.innerHTML = ` div.innerHTML = `
<div class="channel-header">
<span class="channel-name">${ch.name}</span> <span class="channel-name">${ch.name}</span>
<span class="listener-count">${ch.listenerCount} 👤</span> <span class="listener-count">${ch.listenerCount}</span>
</div>
<div class="channel-listeners">${listenersHtml}</div>
`; `;
div.onclick = () => M.switchChannel(ch.id); div.querySelector(".channel-header").onclick = () => M.switchChannel(ch.id);
container.appendChild(div); container.appendChild(div);
} }
}; };
@ -95,6 +158,7 @@
oldWs.close(); oldWs.close();
} }
M.currentChannelId = id; M.currentChannelId = id;
localStorage.setItem("musicroom_channel", id);
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws"); M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
@ -234,10 +298,18 @@
M.audio.currentTime = data.currentTimestamp; M.audio.currentTime = data.currentTimestamp;
} }
} }
} else if (!wasServerPaused && M.serverPaused) { } else {
// Server just paused // Server is paused - ensure we're paused too
if (!M.audio.paused) {
M.audio.pause(); 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(); M.updateUI();
}; };

View File

@ -275,6 +275,30 @@
M.renderLibrary(); M.renderLibrary();
return; 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") { if (playlistId === "all") {
// Use library as playlist // Use library as playlist
M.playlist = [...M.library]; M.playlist = [...M.library];

View File

@ -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; } #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-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 { 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 { padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.9rem; display: flex; flex-direction: column; gap: 0.25rem; }
#channels-list .channel-item:hover { background: #222; }
#channels-list .channel-item.active { background: #2a4a3a; color: #4e8; } #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 .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 .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; } #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-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; } #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 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 { 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; } .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; } .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 { 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, .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 { background: #3a5a4a; } .btn-submit-playlist:hover, .btn-submit-channel:hover { background: #3a5a4a; }
#library, #playlist { flex: 1; overflow-y: auto; } #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, #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; } #library .track:hover, #playlist .track:hover { background: #222; }

View File

@ -48,8 +48,8 @@
if (!M.currentUser) return false; if (!M.currentUser) return false;
if (M.currentUser.isAdmin) return true; if (M.currentUser.isAdmin) return true;
return M.currentUser.permissions?.some(p => return M.currentUser.permissions?.some(p =>
p.resource_type === "stream" && p.resource_type === "channel" &&
(p.resource_id === M.currentStreamId || p.resource_id === null) && (p.resource_id === M.currentChannelId || p.resource_id === null) &&
p.permission === "control" p.permission === "control"
); );
}; };

View File

@ -125,7 +125,7 @@ function broadcastToAll(message: object) {
// Broadcast channel list to all clients // Broadcast channel list to all clients
function broadcastChannelList() { function broadcastChannelList() {
const list = [...channels.values()].map(c => c.getListInfo()); 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 }); broadcastToAll({ type: "channel_list", channels: list });
} }
@ -174,8 +174,6 @@ setInterval(() => {
} }
}, 1000); }, 1000);
type WsData = { streamId: string; userId: number | null };
// Helper to get or create guest session // Helper to get or create guest session
function getOrCreateUser(req: Request, server: any): { user: ReturnType<typeof getUser>, headers?: Headers } { function getOrCreateUser(req: Request, server: any): { user: ReturnType<typeof getUser>, headers?: Headers } {
let user = getUser(req, server); let user = getUser(req, server);
@ -221,7 +219,7 @@ serve({
const id = path.split("/")[3]; const id = path.split("/")[3];
if (!channels.has(id)) return new Response("Channel not found", { status: 404 }); if (!channels.has(id)) return new Response("Channel not found", { status: 404 });
const { user } = getOrCreateUser(req, server); 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; if (ok) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 }); 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) // API: serve audio file (requires auth or guest)
// Supports both filename and track ID (sha256:...) // Supports both filename and track ID (sha256:...)
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
@ -782,19 +809,20 @@ serve({
const channel = channels.get(ws.data.channelId); const channel = channels.get(ws.data.channelId);
if (channel) { if (channel) {
channel.addClient(ws); channel.addClient(ws);
// Send channel list on connect // Broadcast updated channel list to all clients
const list = [...channels.values()].map(c => c.getListInfo()); broadcastChannelList();
ws.send(JSON.stringify({ type: "channel_list", channels: list }));
} }
}, },
close(ws: ServerWebSocket<WsData>) { close(ws: ServerWebSocket<WsData>) {
const channel = channels.get(ws.data.channelId); const channel = channels.get(ws.data.channelId);
if (channel) channel.removeClient(ws); if (channel) {
channel.removeClient(ws);
broadcastChannelList();
}
}, },
message(ws: ServerWebSocket<WsData>, message: string | Buffer) { message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
try { try {
const data = JSON.parse(String(message)); const data = JSON.parse(String(message));
console.log("[WS] Received message:", data.action, "from streamId:", ws.data.streamId);
// Handle channel switching // Handle channel switching
if (data.action === "switch" && data.channelId) { if (data.action === "switch" && data.channelId) {
@ -808,6 +836,7 @@ serve({
ws.data.channelId = data.channelId; ws.data.channelId = data.channelId;
newChannel.addClient(ws); newChannel.addClient(ws);
ws.send(JSON.stringify({ type: "switched", channelId: data.channelId })); ws.send(JSON.stringify({ type: "switched", channelId: data.channelId }));
broadcastChannelList();
return; return;
} }