blastoise-archive/public/channelSync.js

357 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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("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.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;
}
};
// 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]) =>
`<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>
</div>
<div class="channel-listeners">${listenersHtml}</div>
`;
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 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
});
if (!data.track) {
M.setTrackTitle("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 playback mode if provided
if (data.playbackMode && data.playbackMode !== M.playbackMode) {
M.playbackMode = data.playbackMode;
if (M.updateModeButton) M.updateModeButton();
}
// 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.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();
};
})();