saving
This commit is contained in:
parent
629deaab3f
commit
12856b439c
|
|
@ -154,6 +154,7 @@ interface Track {
|
|||
playlist: Track[],
|
||||
currentIndex: number,
|
||||
listenerCount: number,
|
||||
listeners: string[], // usernames of connected users
|
||||
isDefault: boolean
|
||||
}
|
||||
```
|
||||
|
|
|
|||
16
channel.ts
16
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,
|
||||
};
|
||||
|
|
|
|||
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
|
|
@ -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 = `
|
||||
<div class="channel-header">
|
||||
<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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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,10 +298,18 @@
|
|||
M.audio.currentTime = data.currentTimestamp;
|
||||
}
|
||||
}
|
||||
} else if (!wasServerPaused && M.serverPaused) {
|
||||
// Server just paused
|
||||
} 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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
};
|
||||
|
|
|
|||
47
server.ts
47
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<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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue