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[],
currentIndex: number,
listenerCount: number,
listeners: string[], // usernames of connected users
isDefault: boolean
}
```

View File

@ -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,
};

Binary file not shown.

View File

@ -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]) =>
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
).join("");
div.innerHTML = `
<span class="channel-name">${ch.name}</span>
<span class="listener-count">${ch.listenerCount} 👤</span>
<div class="channel-header">
<span class="channel-name">${ch.name}</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);
}
};
@ -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();

View File

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

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; }
#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; }

View File

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

View File

@ -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<typeof getUser>, 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<WsData>) {
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) {
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;
}