(function() { const audio = new Audio(); let ws = null; let currentStreamId = null; let currentFilename = null; let currentTitle = null; let serverTimestamp = 0; let serverTrackDuration = 0; let lastServerUpdate = 0; let serverPaused = true; let wantSync = true; // User intent - do they want to be synced? let synced = false; // Actual state - are we currently synced? let preMuteVolume = 1; let localTimestamp = 0; let playlist = []; let currentIndex = 0; let currentUser = null; let serverStatus = null; let library = []; let prefetchController = null; let loadingSegments = new Set(); let trackCaches = new Map(); // Map of filename -> Set of cached segment indices let trackBlobs = new Map(); // Map of filename -> Blob URL for fully cached tracks let audioBytesPerSecond = 20000; // Audio bitrate estimate for range requests let downloadSpeed = 0; // Actual network download speed let recentDownloads = []; // Track recent downloads for speed calculation const $ = (s) => document.querySelector(s); const SEGMENTS = 20; const STORAGE_KEY = "musicroom_volume"; // Load saved volume const savedVolume = localStorage.getItem(STORAGE_KEY); if (savedVolume !== null) { audio.volume = parseFloat(savedVolume); $("#volume").value = savedVolume; } // Create buffer segments for (let i = 0; i < SEGMENTS; i++) { const seg = document.createElement("div"); seg.className = "segment"; $("#buffer-bar").appendChild(seg); } function fmt(sec) { if (!sec || !isFinite(sec)) return "0:00"; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return m + ":" + String(s).padStart(2, "0"); } function getTrackCache(filename) { if (!filename) return new Set(); if (!trackCaches.has(filename)) { trackCaches.set(filename, new Set()); } return trackCaches.get(filename); } // Get track URL - prefers cached blob, falls back to API function getTrackUrl(filename) { return trackBlobs.get(filename) || "/api/tracks/" + encodeURIComponent(filename); } // Load a track blob from storage or fetch from server async function loadTrackBlob(filename) { // Check if already in memory if (trackBlobs.has(filename)) { return trackBlobs.get(filename); } // Check persistent storage const cached = await TrackStorage.get(filename); if (cached) { const blobUrl = URL.createObjectURL(cached.blob); trackBlobs.set(filename, blobUrl); // Mark all segments as cached const trackCache = getTrackCache(filename); for (let i = 0; i < SEGMENTS; i++) trackCache.add(i); bulkDownloadStarted.set(filename, true); return blobUrl; } return null; } // Download and cache a track async function downloadAndCacheTrack(filename) { if (bulkDownloadStarted.get(filename)) return trackBlobs.get(filename); bulkDownloadStarted.set(filename, true); try { const startTime = performance.now(); const res = await fetch("/api/tracks/" + encodeURIComponent(filename)); const data = await res.arrayBuffer(); const elapsed = (performance.now() - startTime) / 1000; // Mark all segments as cached const trackCache = getTrackCache(filename); for (let i = 0; i < SEGMENTS; i++) trackCache.add(i); // Create blob and URL const contentType = res.headers.get("Content-Type") || "audio/mpeg"; const blob = new Blob([data], { type: contentType }); const blobUrl = URL.createObjectURL(blob); trackBlobs.set(filename, blobUrl); // Persist to storage await TrackStorage.set(filename, blob, contentType); // Update download speed if (elapsed > 0 && data.byteLength > 0) { recentDownloads.push(data.byteLength / elapsed); if (recentDownloads.length > 5) recentDownloads.shift(); downloadSpeed = recentDownloads.reduce((a, b) => a + b, 0) / recentDownloads.length; } return blobUrl; } catch (e) { bulkDownloadStarted.set(filename, false); return null; } } function getServerTime() { if (serverPaused) return serverTimestamp; return serverTimestamp + (Date.now() - lastServerUpdate) / 1000; } function canControl() { if (!currentUser) return false; if (currentUser.isAdmin) return true; // Check if user has control permission for current stream return currentUser.permissions?.some(p => p.resource_type === "stream" && (p.resource_id === currentStreamId || p.resource_id === null) && p.permission === "control" ); } function updateAuthUI() { if (currentUser) { $("#login-panel").classList.add("hidden"); $("#player-content").classList.add("visible"); if (currentUser.isGuest) { $("#current-username").textContent = "Guest"; $("#btn-logout").textContent = "Sign In"; } else { $("#current-username").textContent = currentUser.username; $("#btn-logout").textContent = "Logout"; } $("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none"; } else { $("#login-panel").classList.remove("hidden"); $("#player-content").classList.remove("visible"); // Pause and unsync when login panel is shown if (!audio.paused) { localTimestamp = audio.currentTime; audio.pause(); } if (synced && ws) { synced = false; ws.close(); ws = null; } // Show guest button if server allows guests if (serverStatus?.allowGuests) { $("#guest-section").classList.remove("hidden"); } else { $("#guest-section").classList.add("hidden"); } } updateUI(); } async function loadCurrentUser() { try { const res = await fetch("/api/auth/me"); const data = await res.json(); currentUser = data.user; if (currentUser && data.permissions) { currentUser.permissions = data.permissions; } updateAuthUI(); } catch (e) { currentUser = null; updateAuthUI(); } } function updateUI() { const isPlaying = synced ? !serverPaused : !audio.paused; $("#btn-sync").classList.toggle("synced", wantSync); $("#btn-sync").classList.toggle("connected", synced); $("#btn-sync").title = wantSync ? "Unsync" : "Sync"; $("#status").textContent = synced ? "Synced" : (wantSync ? "Connecting..." : "Local"); $("#sync-indicator").classList.toggle("visible", synced); $("#progress-bar").classList.toggle("synced", synced); $("#progress-bar").classList.toggle("local", !synced); $("#progress-bar").classList.toggle("muted", audio.volume === 0); $("#btn-mute").textContent = audio.volume === 0 ? "🔇" : "🔊"; $("#status-icon").textContent = isPlaying ? "⏸" : "▶"; // Show/hide controls based on permissions const hasControl = canControl(); $("#status-icon").style.cursor = hasControl || !synced ? "pointer" : "default"; } // Track last values to avoid unnecessary DOM updates let lastProgressPct = -1; let lastTimeCurrent = ""; let lastTimeTotal = ""; let lastBufferPct = -1; let lastSpeedText = ""; // Update progress bar and buffer segments setInterval(() => { if (serverTrackDuration <= 0) return; let t, dur; if (synced) { t = audio.paused ? getServerTime() : audio.currentTime; dur = audio.duration || serverTrackDuration; } else { t = audio.paused ? localTimestamp : audio.currentTime; dur = audio.duration || serverTrackDuration; } const pct = Math.min((t / dur) * 100, 100); if (Math.abs(pct - lastProgressPct) > 0.1) { $("#progress-bar").style.width = pct + "%"; lastProgressPct = pct; } const timeCurrent = fmt(t); const timeTotal = fmt(dur); if (timeCurrent !== lastTimeCurrent) { $("#time-current").textContent = timeCurrent; lastTimeCurrent = timeCurrent; } if (timeTotal !== lastTimeTotal) { $("#time-total").textContent = timeTotal; lastTimeTotal = timeTotal; } // Update buffer segments const segments = $("#buffer-bar").children; const segmentDur = dur / SEGMENTS; let availableCount = 0; for (let i = 0; i < SEGMENTS; i++) { const segStart = i * segmentDur; const segEnd = (i + 1) * segmentDur; const trackCache = getTrackCache(currentFilename); let available = trackCache.has(i); // Check our cache first if (!available) { for (let j = 0; j < audio.buffered.length; j++) { const bufStart = audio.buffered.start(j); const bufEnd = audio.buffered.end(j); if (bufStart <= segStart && bufEnd >= segEnd) { available = true; break; } } } if (available) availableCount++; const isAvailable = segments[i].classList.contains("available"); const isLoading = segments[i].classList.contains("loading"); const shouldBeLoading = !available && loadingSegments.has(i); if (available !== isAvailable) segments[i].classList.toggle("available", available); if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading); } // Update download speed display const kbps = downloadSpeed > 0 ? downloadSpeed * 8 / 1000 : 0; const bufferPct = Math.round(availableCount / SEGMENTS * 100); let speedText = ""; if (kbps > 0) { speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`; } if (bufferPct !== lastBufferPct || speedText !== lastSpeedText) { $("#download-speed").textContent = `${bufferPct}% buffered${speedText}`; lastBufferPct = bufferPct; lastSpeedText = speedText; } }, 250); // Prefetch missing segments let prefetching = false; let bulkDownloadStarted = new Map(); // Track if bulk download started per filename const FAST_THRESHOLD = 10 * 1024 * 1024; // 10 MB/s async function fetchSegment(i, segStart, segEnd) { const trackCache = getTrackCache(currentFilename); if (loadingSegments.has(i) || trackCache.has(i)) return; loadingSegments.add(i); try { const byteStart = Math.floor(segStart * audioBytesPerSecond); const byteEnd = Math.floor(segEnd * audioBytesPerSecond); const startTime = performance.now(); const res = await fetch("/api/tracks/" + encodeURIComponent(currentFilename), { headers: { "Range": `bytes=${byteStart}-${byteEnd}` } }); const data = await res.arrayBuffer(); const elapsed = (performance.now() - startTime) / 1000; // Mark segment as cached trackCache.add(i); // Update audio bitrate estimate const bytesReceived = data.byteLength; const durationCovered = segEnd - segStart; if (bytesReceived > 0 && durationCovered > 0) { audioBytesPerSecond = Math.round(bytesReceived / durationCovered); } // Update download speed (rolling average of last 5 downloads) if (elapsed > 0 && bytesReceived > 0) { recentDownloads.push(bytesReceived / elapsed); if (recentDownloads.length > 5) recentDownloads.shift(); downloadSpeed = recentDownloads.reduce((a, b) => a + b, 0) / recentDownloads.length; } } catch (e) {} loadingSegments.delete(i); } // Background bulk download - runs independently async function startBulkDownload() { const filename = currentFilename; if (!filename || bulkDownloadStarted.get(filename)) return; const blobUrl = await downloadAndCacheTrack(filename); // Switch to blob URL if still on this track if (blobUrl && currentFilename === filename && audio.src && !audio.src.startsWith("blob:")) { const currentTime = audio.currentTime; const wasPlaying = !audio.paused; audio.src = blobUrl; audio.currentTime = currentTime; if (wasPlaying) audio.play().catch(() => {}); } } async function prefetchSegments() { if (prefetching || !currentFilename || !audio.src || serverTrackDuration <= 0) return; prefetching = true; const segmentDur = serverTrackDuration / SEGMENTS; const missingSegments = []; const trackCache = getTrackCache(currentFilename); // Find all missing segments (not in audio buffer AND not in our cache) for (let i = 0; i < SEGMENTS; i++) { if (trackCache.has(i) || loadingSegments.has(i)) continue; const segStart = i * segmentDur; const segEnd = (i + 1) * segmentDur; let available = false; for (let j = 0; j < audio.buffered.length; j++) { if (audio.buffered.start(j) <= segStart && audio.buffered.end(j) >= segEnd) { available = true; break; } } if (!available) { missingSegments.push({ i, segStart, segEnd }); } } if (missingSegments.length > 0) { // Fast connection: also start bulk download in background if (downloadSpeed >= FAST_THRESHOLD && !bulkDownloadStarted.get(currentFilename)) { startBulkDownload(); // Fire and forget } // Always fetch segments one at a time for seek support const s = missingSegments[0]; await fetchSegment(s.i, s.segStart, s.segEnd); } prefetching = false; } // Run prefetch loop setInterval(() => { if (currentFilename && audio.src) { prefetchSegments(); } }, 1000); // Load streams and try to connect async function loadStreams() { try { const res = await fetch("/api/streams"); const streams = await res.json(); if (streams.length === 0) { $("#track-title").textContent = "No streams available"; return; } if (streams.length > 1) { const sel = document.createElement("select"); for (const s of streams) { const opt = document.createElement("option"); opt.value = s.id; opt.textContent = s.name; sel.appendChild(opt); } sel.onchange = () => connectStream(sel.value); $("#stream-select").appendChild(sel); } connectStream(streams[0].id); } catch (e) { $("#track-title").textContent = "Server unavailable"; $("#status").textContent = "Local (offline)"; synced = false; updateUI(); } } function connectStream(id) { if (ws) { const oldWs = ws; ws = null; oldWs.onclose = null; oldWs.close(); } currentStreamId = id; const proto = location.protocol === "https:" ? "wss:" : "ws:"; ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws"); ws.onmessage = (e) => handleUpdate(JSON.parse(e.data)); ws.onclose = () => { synced = false; ws = null; $("#sync-indicator").classList.add("disconnected"); updateUI(); // Auto-reconnect if user wants to be synced if (wantSync) { setTimeout(() => connectStream(id), 3000); } }; ws.onopen = () => { synced = true; $("#sync-indicator").classList.remove("disconnected"); updateUI(); }; } function flashPermissionDenied() { const row = $("#progress-row"); row.classList.remove("denied"); // Trigger reflow to restart animation void row.offsetWidth; row.classList.add("denied"); setTimeout(() => row.classList.remove("denied"), 500); } function renderPlaylist() { const container = $("#playlist"); container.innerHTML = ""; if (playlist.length === 0) { container.innerHTML = '
Playlist empty
'; return; } playlist.forEach((track, i) => { const div = document.createElement("div"); div.className = "track" + (i === currentIndex ? " active" : ""); const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); div.innerHTML = `${title}${fmt(track.duration)}`; div.onclick = async () => { if (synced && currentStreamId) { const res = await fetch("/api/streams/" + currentStreamId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: i }) }); if (res.status === 403) flashPermissionDenied(); } else { currentIndex = i; currentFilename = track.filename; serverTrackDuration = track.duration; $("#track-title").textContent = title; // Reset loading state for new track (cache persists) loadingSegments.clear(); // Try to load from cache first const cachedUrl = await loadTrackBlob(track.filename); audio.src = cachedUrl || getTrackUrl(track.filename); audio.currentTime = 0; localTimestamp = 0; audio.play(); renderPlaylist(); } }; container.appendChild(div); }); } function renderLibrary() { const container = $("#library"); container.innerHTML = ""; if (library.length === 0) { container.innerHTML = '
No tracks discovered
'; return; } library.forEach((track) => { const div = document.createElement("div"); div.className = "track"; const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); div.innerHTML = `${title}${fmt(track.duration)}`; div.onclick = async () => { // In local mode, play directly from library if (!synced) { currentFilename = track.filename; serverTrackDuration = track.duration; $("#track-title").textContent = title; loadingSegments.clear(); const cachedUrl = await loadTrackBlob(track.filename); audio.src = cachedUrl || getTrackUrl(track.filename); audio.currentTime = 0; localTimestamp = 0; audio.play(); } }; container.appendChild(div); }); } async function loadLibrary() { try { const res = await fetch("/api/library"); library = await res.json(); renderLibrary(); } catch (e) { console.warn("Failed to load library"); } } async function handleUpdate(data) { if (!data.track) { $("#track-title").textContent = "No tracks"; return; } $("#stream-name").textContent = data.streamName || ""; serverTimestamp = data.currentTimestamp; serverTrackDuration = data.track.duration; lastServerUpdate = Date.now(); const wasServerPaused = serverPaused; serverPaused = data.paused ?? true; // Update playlist if provided if (data.playlist) { playlist = data.playlist; currentIndex = data.currentIndex ?? 0; renderPlaylist(); } else if (data.currentIndex !== undefined && data.currentIndex !== currentIndex) { currentIndex = data.currentIndex; renderPlaylist(); } // Cache track info for local mode const isNewTrack = data.track.filename !== currentFilename; if (isNewTrack) { currentFilename = data.track.filename; currentTitle = data.track.title; $("#track-title").textContent = data.track.title; loadingSegments.clear(); } if (synced) { if (!serverPaused) { // Server is playing - ensure we're playing and synced if (isNewTrack || !audio.src) { // Try cache first const cachedUrl = await loadTrackBlob(currentFilename); audio.src = cachedUrl || getTrackUrl(currentFilename); } if (audio.paused) { audio.currentTime = data.currentTimestamp; audio.play().catch(() => {}); } else { // Check drift const drift = Math.abs(audio.currentTime - data.currentTimestamp); if (drift >= 2) { audio.currentTime = data.currentTimestamp; } } } else if (!wasServerPaused && serverPaused) { // Server just paused audio.pause(); } } updateUI(); } $("#btn-sync").onclick = () => { wantSync = !wantSync; if (wantSync) { // User wants to sync - try to connect if (currentStreamId) { connectStream(currentStreamId); } } else { // User wants local mode - disconnect synced = false; localTimestamp = audio.currentTime || getServerTime(); if (ws) { const oldWs = ws; ws = null; oldWs.onclose = null; oldWs.close(); } } updateUI(); }; function togglePlayback() { if (!currentFilename) return; if (synced) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: serverPaused ? "unpause" : "pause" })); } } else { if (audio.paused) { if (!audio.src) { audio.src = getTrackUrl(currentFilename); audio.currentTime = localTimestamp; } audio.play(); } else { localTimestamp = audio.currentTime; audio.pause(); } updateUI(); } } $("#status-icon").onclick = togglePlayback; async function jumpToTrack(index) { if (playlist.length === 0) return; const newIndex = (index + playlist.length) % playlist.length; if (synced && currentStreamId) { const res = await fetch("/api/streams/" + currentStreamId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: newIndex }) }); if (res.status === 403) flashPermissionDenied(); } else { const track = playlist[newIndex]; currentIndex = newIndex; currentFilename = track.filename; serverTrackDuration = track.duration; $("#track-title").textContent = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); loadingSegments.clear(); const cachedUrl = await loadTrackBlob(track.filename); audio.src = cachedUrl || getTrackUrl(track.filename); audio.currentTime = 0; localTimestamp = 0; audio.play(); renderPlaylist(); } } $("#btn-prev").onclick = () => jumpToTrack(currentIndex - 1); $("#btn-next").onclick = () => jumpToTrack(currentIndex + 1); $("#progress-container").onmousemove = (e) => { if (serverTrackDuration <= 0) return; const rect = $("#progress-container").getBoundingClientRect(); const pct = (e.clientX - rect.left) / rect.width; const hoverTime = pct * serverTrackDuration; const tooltip = $("#seek-tooltip"); tooltip.textContent = fmt(hoverTime); tooltip.style.left = (pct * 100) + "%"; tooltip.style.display = "block"; }; $("#progress-container").onmouseleave = () => { $("#seek-tooltip").style.display = "none"; }; $("#progress-container").onclick = (e) => { const dur = synced ? serverTrackDuration : (audio.duration || serverTrackDuration); if (!currentFilename || dur <= 0) return; const rect = $("#progress-container").getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const seekTime = pct * dur; if (synced && currentStreamId) { fetch("/api/streams/" + currentStreamId + "/seek", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timestamp: seekTime }) }).then(res => { if (res.status === 403) flashPermissionDenied(); }); } else { if (!audio.src) { audio.src = getTrackUrl(currentFilename); } audio.currentTime = seekTime; localTimestamp = seekTime; } }; $("#btn-mute").onclick = () => { if (audio.volume > 0) { preMuteVolume = audio.volume; audio.volume = 0; $("#volume").value = 0; } else { audio.volume = preMuteVolume; $("#volume").value = preMuteVolume; } localStorage.setItem(STORAGE_KEY, audio.volume); updateUI(); }; $("#volume").oninput = (e) => { audio.volume = e.target.value; localStorage.setItem(STORAGE_KEY, e.target.value); updateUI(); }; audio.onplay = () => { $("#progress-bar").classList.add("playing"); updateUI(); }; audio.onpause = () => { $("#progress-bar").classList.remove("playing"); updateUI(); }; // Track loading state from audio element's progress audio.onprogress = () => { if (serverTrackDuration <= 0) return; const segmentDur = serverTrackDuration / SEGMENTS; loadingSegments.clear(); for (let i = 0; i < SEGMENTS; i++) { const segStart = i * segmentDur; const segEnd = (i + 1) * segmentDur; let fullyBuffered = false; let partiallyBuffered = false; for (let j = 0; j < audio.buffered.length; j++) { const bufStart = audio.buffered.start(j); const bufEnd = audio.buffered.end(j); if (bufStart <= segStart && bufEnd >= segEnd) { fullyBuffered = true; break; } // Check if buffer is actively loading into this segment if (bufEnd > segStart && bufEnd < segEnd && bufStart <= segStart) { partiallyBuffered = true; } } if (partiallyBuffered && !fullyBuffered) { loadingSegments.add(i); } } }; // Auth event handlers - tab switching $("#tab-login").onclick = () => { $("#tab-login").classList.add("active"); $("#tab-signup").classList.remove("active"); $("#login-fields").classList.remove("hidden"); $("#signup-fields").classList.add("hidden"); $("#auth-error").textContent = ""; $("#signup-error").textContent = ""; }; $("#tab-signup").onclick = () => { $("#tab-signup").classList.add("active"); $("#tab-login").classList.remove("active"); $("#signup-fields").classList.remove("hidden"); $("#login-fields").classList.add("hidden"); $("#auth-error").textContent = ""; $("#signup-error").textContent = ""; }; $("#btn-login").onclick = async () => { const username = $("#login-username").value.trim(); const password = $("#login-password").value; try { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }); const data = await res.json(); if (!res.ok) { $("#auth-error").textContent = data.error || "Login failed"; return; } $("#login-username").value = ""; $("#login-password").value = ""; await loadCurrentUser(); loadStreams(); } catch (e) { $("#auth-error").textContent = "Login failed"; } }; $("#btn-signup").onclick = async () => { const username = $("#signup-username").value.trim(); const password = $("#signup-password").value; try { const res = await fetch("/api/auth/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }); const data = await res.json(); if (!res.ok) { $("#signup-error").textContent = data.error || "Signup failed"; return; } $("#signup-username").value = ""; $("#signup-password").value = ""; await loadCurrentUser(); loadStreams(); } catch (e) { $("#signup-error").textContent = "Signup failed"; } }; $("#btn-guest").onclick = async () => { // Fetch /api/auth/me which will create a guest session try { const res = await fetch("/api/auth/me"); const data = await res.json(); currentUser = data.user; if (currentUser && data.permissions) { currentUser.permissions = data.permissions; } updateAuthUI(); if (currentUser) loadStreams(); } catch (e) { $("#auth-error").textContent = "Failed to continue as guest"; } }; $("#btn-logout").onclick = async () => { const wasGuest = currentUser?.isGuest; await fetch("/api/auth/logout", { method: "POST" }); currentUser = null; if (wasGuest) { // Guest clicking "Sign In" - show login panel updateAuthUI(); } else { // Regular user logging out - reload to get new guest session updateAuthUI(); } }; // Fetch server status async function loadServerStatus() { try { const res = await fetch("/api/status"); serverStatus = await res.json(); console.log("Server status:", serverStatus); } catch (e) { console.warn("Failed to load server status"); serverStatus = null; } } // Initialize storage and load cached tracks async function initStorage() { await TrackStorage.init(); const cached = await TrackStorage.list(); console.log(`TrackStorage: ${cached.length} tracks cached`); } // Initialize Promise.all([initStorage(), loadServerStatus()]).then(() => { loadLibrary(); loadCurrentUser().then(() => { if (currentUser) loadStreams(); }); }); })();