// 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.setTrackTitle("No channels available");
return;
}
M.channels = channels;
M.renderChannelList();
// Try saved channel first, fall back to default
const savedChannelId = localStorage.getItem("blastoise_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.setTrackTitle("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;
}
};
// Delete a channel
M.deleteChannel = async function(channelId) {
const channel = M.channels?.find(c => c.id === channelId);
if (!channel) return;
if (channel.isDefault) {
M.showToast("Cannot delete default channel");
return;
}
if (!confirm(`Delete channel "${channel.name}"?`)) return;
try {
const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
M.showToast(err.error || "Failed to delete channel");
return;
}
M.showToast(`Channel "${channel.name}" deleted`);
} catch (e) {
M.showToast("Failed to delete channel");
}
};
// 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...";
input.maxLength = 64;
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("");
// Show delete button for non-default channels if user is admin or creator
const canDelete = !ch.isDefault && M.currentUser &&
(M.currentUser.isAdmin || ch.createdBy === M.currentUser.id);
const deleteBtn = canDelete ? `` : "";
// Show rename button for signed-in non-guests
const canRename = M.currentUser && !M.currentUser.isGuest;
const renameBtn = canRename ? `` : "";
div.innerHTML = `
${listenersHtml}
`;
const headerEl = div.querySelector(".channel-header");
const nameSpan = headerEl.querySelector(".channel-name");
const nameInput = headerEl.querySelector(".channel-name-input");
nameSpan.onclick = () => M.switchChannel(ch.id);
const renBtn = headerEl.querySelector(".btn-rename-channel");
if (renBtn) {
renBtn.onclick = (e) => {
e.stopPropagation();
nameSpan.style.display = "none";
renBtn.style.display = "none";
nameInput.style.display = "inline-block";
nameInput.focus();
nameInput.select();
};
}
// Handle inline rename
const submitRename = async () => {
const newName = nameInput.value.trim();
if (newName && newName !== ch.name) {
await M.renameChannel(ch.id, newName);
} else {
// Restore original display
nameSpan.style.display = "";
if (renBtn) renBtn.style.display = "";
nameInput.style.display = "none";
}
};
nameInput.onblur = submitRename;
nameInput.onkeydown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
nameInput.blur();
} else if (e.key === "Escape") {
nameInput.value = ch.name;
nameInput.blur();
}
};
const delBtn = headerEl.querySelector(".btn-delete-channel");
if (delBtn) {
delBtn.onclick = (e) => {
e.stopPropagation();
M.deleteChannel(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.onerror = null;
oldWs.close();
}
M.currentChannelId = id;
localStorage.setItem("blastoise_channel", id);
const proto = location.protocol === "https:" ? "wss:" : "ws:";
M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws");
// Track if we've ever connected successfully
let wasConnected = false;
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 kick command
if (data.type === "kick") {
M.showToast("Disconnected: " + (data.reason || "Kicked by another session"));
M.wantSync = false;
M.synced = false;
M.audio.pause();
if (M.ws) {
const oldWs = M.ws;
M.ws = null;
oldWs.onclose = null;
oldWs.onerror = null;
oldWs.close();
}
M.updateUI();
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;
}
// Handle scan progress
if (data.type === "scan_progress") {
const el = M.$("#scan-progress");
const wasScanning = !el.classList.contains("complete") && !el.classList.contains("hidden");
if (data.scanning) {
el.innerHTML = `Scanning: ${data.processed}/${data.total} files`;
el.classList.remove("hidden");
el.classList.remove("complete");
} else {
// Show track count when not scanning
const count = M.library.length;
el.innerHTML = `${count} song${count !== 1 ? 's' : ''} in library`;
el.classList.remove("hidden");
el.classList.add("complete");
// Show toast if scan just finished
if (wasScanning) {
M.showToast("Scanning complete!");
}
}
return;
}
// Handle server-sent toasts
if (data.type === "toast") {
M.showToast(data.message, data.toastType || "info");
return;
}
// Handle fetch progress from ytdlp
if (data.type && data.type.startsWith("fetch_")) {
if (M.handleFetchProgress) {
M.handleFetchProgress(data);
}
return;
}
// Normal channel state update
M.handleUpdate(data);
};
M.ws.onerror = () => {
console.log("[WS] Connection error");
};
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
// Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting
if (M.wantSync) {
const delay = wasConnected ? 3000 : 2000;
setTimeout(() => M.connectChannel(id), delay);
}
};
M.ws.onopen = () => {
wasConnected = true;
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
});
M.$("#channel-name").textContent = data.channelName || "";
// Update queue if provided (do this before early return for no track)
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();
}
// Update playback mode if provided
if (data.playbackMode && data.playbackMode !== M.playbackMode) {
M.playbackMode = data.playbackMode;
if (M.updateModeButton) M.updateModeButton();
}
if (!data.track) {
M.setTrackTitle("No tracks");
return;
}
M.serverTimestamp = data.currentTimestamp;
M.serverTrackDuration = data.track.duration;
M.lastServerUpdate = Date.now();
const wasServerPaused = M.serverPaused;
M.serverPaused = data.paused ?? true;
// 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.setTrackTitle(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();
};
// Rename channel
M.renameChannel = async function(channelId, newName) {
try {
const res = await fetch(`/api/channels/${channelId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() })
});
const data = await res.json();
if (!res.ok) {
M.showToast(data.error || "Failed to rename channel", "error");
return;
}
M.showToast(`Channel renamed to "${data.name}"`);
} catch (e) {
M.showToast("Failed to rename channel", "error");
}
};
})();