(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 = '