// MusicRoom - Channel Sync module // WebSocket connection and server synchronization (function() { const M = window.MusicRoom; // Load available channels and connect to saved or default M.loadChannels = async function() { try { const res = await fetch("/api/channels"); const channels = await res.json(); if (channels.length === 0) { M.$("#track-title").textContent = "No channels available"; return; } M.channels = channels; M.renderChannelList(); // 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)"; M.synced = false; M.updateUI(); } }; // Create a new channel M.createChannel = async function(name, description) { try { const res = await fetch("/api/channels", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, description }) }); if (!res.ok) { const err = await res.json(); M.showToast(err.error || "Failed to create channel"); return null; } const channel = await res.json(); M.showToast(`Channel "${channel.name}" created`); return channel; } catch (e) { M.showToast("Failed to create channel"); return null; } }; // 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 = () => M.createNewChannel(); } }); // Render channel list in sidebar M.renderChannelList = function() { const container = M.$("#channels-list"); if (!container) return; container.innerHTML = ""; 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.querySelector(".channel-header").onclick = () => M.switchChannel(ch.id); container.appendChild(div); } }; // Switch to a different channel via WebSocket M.switchChannel = function(channelId) { if (channelId === M.currentChannelId) return; if (M.ws && M.ws.readyState === WebSocket.OPEN) { M.ws.send(JSON.stringify({ action: "switch", channelId })); } }; // Connect to a channel via WebSocket M.connectChannel = function(id) { if (M.ws) { const oldWs = M.ws; M.ws = null; oldWs.onclose = null; 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"); M.ws.onmessage = (e) => { const data = JSON.parse(e.data); // Handle channel list updates if (data.type === "channel_list") { console.log("[WS] Received channel_list:", data.channels.length, "channels"); M.channels = data.channels; M.renderChannelList(); return; } // Handle channel switch confirmation if (data.type === "switched") { M.currentChannelId = data.channelId; M.renderChannelList(); return; } // Handle library updates if (data.type === "track_added") { M.showToast(`"${data.track.title}" is now available`); if (data.library) { M.library = data.library; M.renderLibrary(); } return; } if (data.type === "track_removed") { M.showToast(`"${data.track.title}" was removed`); if (data.library) { M.library = data.library; M.renderLibrary(); } return; } // Normal channel state update M.handleUpdate(data); }; M.ws.onclose = () => { M.synced = false; M.ws = null; M.$("#sync-indicator").classList.add("disconnected"); M.updateUI(); // Auto-reconnect if user wants to be synced if (M.wantSync) { setTimeout(() => M.connectChannel(id), 3000); } }; M.ws.onopen = () => { M.synced = true; M.$("#sync-indicator").classList.remove("disconnected"); M.updateUI(); }; }; // Handle channel state update from server M.handleUpdate = async function(data) { console.log("[WS] State update:", { track: data.track?.title, timestamp: data.currentTimestamp?.toFixed(1), paused: data.paused, currentIndex: data.currentIndex }); if (!data.track) { M.$("#track-title").textContent = "No tracks"; return; } M.$("#channel-name").textContent = data.channelName || ""; M.serverTimestamp = data.currentTimestamp; M.serverTrackDuration = data.track.duration; M.lastServerUpdate = Date.now(); const wasServerPaused = M.serverPaused; M.serverPaused = data.paused ?? true; // Update queue if provided if (data.queue) { M.queue = data.queue; M.currentIndex = data.currentIndex ?? 0; M.renderQueue(); } else if (data.currentIndex !== undefined && data.currentIndex !== M.currentIndex) { M.currentIndex = data.currentIndex; M.renderQueue(); } // Cache track info for local mode - use track.id (content hash) as the identifier const trackId = data.track.id || data.track.filename; // Fallback for compatibility const isNewTrack = trackId !== M.currentTrackId; if (isNewTrack) { M.currentTrackId = trackId; M.currentTitle = data.track.title; M.$("#track-title").textContent = data.track.title; M.loadingSegments.clear(); // Debug: log cache state for this track const trackCache = M.trackCaches.get(trackId); console.log("[Playback] Starting track:", data.track.title, { trackId: trackId, segments: trackCache ? [...trackCache] : [], segmentCount: trackCache ? trackCache.size : 0, inCachedTracks: M.cachedTracks.has(trackId), bulkStarted: M.bulkDownloadStarted.get(trackId) || false, hasBlobUrl: M.trackBlobs.has(trackId) }); // Check if this track already has all segments cached M.checkAndCacheComplete(trackId); } if (M.synced) { if (!M.serverPaused) { // Server is playing - ensure we're playing and synced if (isNewTrack || !M.audio.src) { // Try cache first const cachedUrl = await M.loadTrackBlob(M.currentTrackId); M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId); M.audio.currentTime = data.currentTimestamp; M.audio.play().catch(() => {}); } else if (M.audio.paused) { M.audio.currentTime = data.currentTimestamp; M.audio.play().catch(() => {}); } else { // Check drift const drift = Math.abs(M.audio.currentTime - data.currentTimestamp); if (drift >= 2) { console.log("[Sync] Correcting drift:", drift.toFixed(1), "s"); M.audio.currentTime = data.currentTimestamp; } } } 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(); }; })();