396 lines
13 KiB
JavaScript
396 lines
13 KiB
JavaScript
// 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]) =>
|
||
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
|
||
).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 ? `<button class="btn-delete-channel" title="Delete channel">×</button>` : "";
|
||
|
||
div.innerHTML = `
|
||
<div class="channel-header">
|
||
<span class="channel-name">${ch.name}</span>
|
||
${deleteBtn}
|
||
<span class="listener-count">${ch.listenerCount}</span>
|
||
</div>
|
||
<div class="channel-listeners">${listenersHtml}</div>
|
||
`;
|
||
const headerEl = div.querySelector(".channel-header");
|
||
headerEl.querySelector(".channel-name").onclick = () => M.switchChannel(ch.id);
|
||
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.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");
|
||
|
||
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.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 = `<span class="spinner"></span>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;
|
||
}
|
||
// 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
|
||
});
|
||
|
||
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();
|
||
};
|
||
})();
|