245 lines
7.7 KiB
JavaScript
245 lines
7.7 KiB
JavaScript
// MusicRoom - Channel Sync module
|
|
// WebSocket connection and server synchronization
|
|
|
|
(function() {
|
|
const M = window.MusicRoom;
|
|
|
|
// Load available channels and connect to first one
|
|
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();
|
|
// Connect to first (default) channel
|
|
const defaultChannel = channels.find(c => c.isDefault) || channels[0];
|
|
M.connectChannel(defaultChannel.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 button handler
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const btn = M.$("#btn-new-channel");
|
|
if (btn) {
|
|
btn.onclick = () => {
|
|
const name = prompt("Channel name:");
|
|
if (name && name.trim()) {
|
|
M.createChannel(name.trim());
|
|
}
|
|
};
|
|
}
|
|
});
|
|
|
|
// 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" : "");
|
|
div.innerHTML = `
|
|
<span class="channel-name">${ch.name}</span>
|
|
<span class="listener-count">${ch.listenerCount} 👤</span>
|
|
`;
|
|
div.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;
|
|
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();
|
|
if (M.selectedPlaylistId === "all") {
|
|
M.playlist = [...M.library];
|
|
M.renderPlaylist();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (data.type === "track_removed") {
|
|
M.showToast(`"${data.track.title}" was removed`);
|
|
if (data.library) {
|
|
M.library = data.library;
|
|
M.renderLibrary();
|
|
if (M.selectedPlaylistId === "all") {
|
|
M.playlist = [...M.library];
|
|
M.renderPlaylist();
|
|
}
|
|
}
|
|
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 playlist if provided
|
|
if (data.playlist) {
|
|
M.playlist = data.playlist;
|
|
M.currentIndex = data.currentIndex ?? 0;
|
|
M.renderPlaylist();
|
|
} else if (data.currentIndex !== undefined && data.currentIndex !== M.currentIndex) {
|
|
M.currentIndex = data.currentIndex;
|
|
M.renderPlaylist();
|
|
}
|
|
|
|
// 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 if (!wasServerPaused && M.serverPaused) {
|
|
// Server just paused
|
|
M.audio.pause();
|
|
}
|
|
}
|
|
M.updateUI();
|
|
};
|
|
})();
|