blastoise/public/channelSync.js

477 lines
15 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("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>` : "";
// Show rename button for signed-in non-guests
const canRename = M.currentUser && !M.currentUser.isGuest;
const renameBtn = canRename ? `<button class="btn-rename-channel" title="Rename channel">✏️</button>` : "";
div.innerHTML = `
<div class="channel-header">
<span class="channel-name">${ch.name}</span>
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '&quot;')}" style="display:none;">
${renameBtn}
${deleteBtn}
<span class="listener-count">${ch.listenerCount}</span>
</div>
<div class="channel-listeners">${listenersHtml}</div>
`;
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") {
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 = `<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;
}
// 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");
}
};
})();