diff --git a/public/app.js b/public/app.js deleted file mode 100644 index 7e64a62..0000000 --- a/public/app.js +++ /dev/null @@ -1,1127 +0,0 @@ -(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 userPlaylists = []; - let selectedPlaylistId = null; - 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); - - // Toast notifications - function showToast(message, duration = 4000) { - const container = $("#toast-container"); - const toast = document.createElement("div"); - toast.className = "toast"; - toast.textContent = message; - container.appendChild(toast); - setTimeout(() => { - toast.classList.add("fade-out"); - setTimeout(() => toast.remove(), 300); - }, duration); - } - 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; - } else { - // No saved volume - sync audio to slider's default value - audio.volume = parseFloat($("#volume").value); - } - - // 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) => { - const data = JSON.parse(e.data); - // Handle library updates - if (data.type === "track_added") { - showToast(`"${data.track.title}" is now available`); - if (data.library) { - library = data.library; - renderLibrary(); - if (selectedPlaylistId === "all") { - playlist = [...library]; - renderPlaylist(); - } - } - return; - } - if (data.type === "track_removed") { - showToast(`"${data.track.title}" was removed`); - if (data.library) { - library = data.library; - renderLibrary(); - if (selectedPlaylistId === "all") { - playlist = [...library]; - renderPlaylist(); - } - } - return; - } - // Normal stream update - handleUpdate(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.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); - const trackId = track.id || track.filename; - - // Show remove button only for user playlists (not stream playlists) - const removeBtn = selectedPlaylistId ? `×` : ""; - div.innerHTML = `${title}${removeBtn}${fmt(track.duration)}`; - - div.querySelector(".track-title").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 = trackId; - serverTrackDuration = track.duration; - $("#track-title").textContent = title; - loadingSegments.clear(); - const cachedUrl = await loadTrackBlob(trackId); - audio.src = cachedUrl || getTrackUrl(trackId); - audio.currentTime = 0; - localTimestamp = 0; - audio.play(); - renderPlaylist(); - } - }; - - const removeEl = div.querySelector(".btn-remove"); - if (removeEl) { - removeEl.onclick = (e) => { - e.stopPropagation(); - removeTrackFromCurrentPlaylist(i); - }; - } - - container.appendChild(div); - }); - } - - function renderLibrary() { - const container = $("#library"); - container.innerHTML = ""; - if (library.length === 0) { - container.innerHTML = '
No tracks discovered
'; - return; - } - const canAdd = selectedPlaylistId && selectedPlaylistId !== "all"; - library.forEach((track) => { - const div = document.createElement("div"); - div.className = "track"; - const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); - const addBtn = canAdd ? `+` : ""; - div.innerHTML = `${title}${addBtn}${fmt(track.duration)}`; - - div.querySelector(".track-title").onclick = async () => { - // Play directly from library (uses track ID) - if (!synced) { - currentFilename = track.id; // Use track ID instead of filename - serverTrackDuration = track.duration; - $("#track-title").textContent = title; - loadingSegments.clear(); - const cachedUrl = await loadTrackBlob(track.id); - audio.src = cachedUrl || getTrackUrl(track.id); - audio.currentTime = 0; - localTimestamp = 0; - audio.play(); - } - }; - - const addBtnEl = div.querySelector(".btn-add"); - if (addBtnEl) { - addBtnEl.onclick = (e) => { - e.stopPropagation(); - addTrackToCurrentPlaylist(track.id); - }; - } - - 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 loadPlaylists() { - try { - const res = await fetch("/api/playlists"); - userPlaylists = await res.json(); - renderPlaylistSelector(); - } catch (e) { - console.warn("Failed to load playlists"); - } - } - - function renderPlaylistSelector() { - const list = $("#playlists-list"); - if (!list) return; - list.innerHTML = ""; - // Add "All Tracks" as default option - const allItem = document.createElement("div"); - allItem.className = "playlist-item" + (selectedPlaylistId === "all" ? " active" : ""); - allItem.textContent = "All Tracks"; - allItem.onclick = () => loadSelectedPlaylist("all"); - list.appendChild(allItem); - // Add user playlists - for (const pl of userPlaylists) { - const item = document.createElement("div"); - item.className = "playlist-item" + (pl.id === selectedPlaylistId ? " active" : ""); - item.textContent = pl.name; - item.onclick = () => loadSelectedPlaylist(pl.id); - list.appendChild(item); - } - // Update playlist panel title - const titleEl = $("#playlist-title"); - if (selectedPlaylistId === "all") { - titleEl.textContent = "Playlist - All Tracks"; - } else if (selectedPlaylistId) { - const pl = userPlaylists.find(p => p.id === selectedPlaylistId); - titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist"; - } else { - titleEl.textContent = "Playlist"; - } - } - - async function loadSelectedPlaylist(playlistId) { - if (!playlistId) { - playlist = []; - selectedPlaylistId = null; - renderPlaylist(); - renderPlaylistSelector(); - renderLibrary(); - return; - } - if (playlistId === "all") { - // Use library as playlist - playlist = [...library]; - selectedPlaylistId = "all"; - currentIndex = 0; - renderPlaylist(); - renderPlaylistSelector(); - renderLibrary(); - return; - } - try { - const res = await fetch("/api/playlists/" + playlistId); - if (!res.ok) throw new Error("Failed to load playlist"); - const data = await res.json(); - playlist = data.tracks || []; - selectedPlaylistId = playlistId; - currentIndex = 0; - renderPlaylist(); - renderPlaylistSelector(); - renderLibrary(); - } catch (e) { - console.warn("Failed to load playlist:", e); - } - } - - async function createNewPlaylist() { - const header = $("#playlists-panel .panel-header"); - const btn = $("#btn-new-playlist"); - - // Already in edit mode? - if (header.querySelector(".new-playlist-input")) return; - - // Hide button, show input - btn.style.display = "none"; - - const input = document.createElement("input"); - input.type = "text"; - input.className = "new-playlist-input"; - input.placeholder = "Playlist name..."; - - const submit = document.createElement("button"); - submit.className = "btn-submit-playlist"; - 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 { - const res = await fetch("/api/playlists", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, visibility: "private" }) - }); - if (!res.ok) throw new Error("Failed to create playlist"); - const pl = await res.json(); - await loadPlaylists(); - selectedPlaylistId = pl.id; - renderPlaylistSelector(); - await loadSelectedPlaylist(pl.id); - } catch (e) { - alert("Failed to create playlist"); - } - cleanup(); - }; - - submit.onclick = doCreate; - input.onkeydown = (e) => { - if (e.key === "Enter") doCreate(); - if (e.key === "Escape") cleanup(); - }; - input.onblur = (e) => { - // Delay to allow click on submit button - setTimeout(() => { - if (document.activeElement !== submit) cleanup(); - }, 100); - }; - } - - async function addTrackToCurrentPlaylist(trackId) { - if (!selectedPlaylistId || selectedPlaylistId === "all") { - alert("Select or create a playlist first"); - return; - } - try { - const res = await fetch("/api/playlists/" + selectedPlaylistId + "/tracks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ trackIds: [trackId] }) - }); - if (!res.ok) throw new Error("Failed to add track"); - await loadSelectedPlaylist(selectedPlaylistId); - } catch (e) { - console.warn("Failed to add track:", e); - } - } - - async function removeTrackFromCurrentPlaylist(position) { - if (!selectedPlaylistId || selectedPlaylistId === "all") return; - try { - const res = await fetch("/api/playlists/" + selectedPlaylistId + "/tracks/" + position, { - method: "DELETE" - }); - if (!res.ok) throw new Error("Failed to remove track"); - await loadSelectedPlaylist(selectedPlaylistId); - } catch (e) { - console.warn("Failed to remove track:", e); - } - } - - 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(); - } - }; - - // Playlist selector handlers - $("#btn-new-playlist").onclick = () => { - if (!currentUser || currentUser.isGuest) { - alert("Sign in to create playlists"); - return; - } - createNewPlaylist(); - }; - - // 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(async () => { - await loadLibrary(); - loadSelectedPlaylist("all"); // Default to All Tracks - await loadCurrentUser(); - if (currentUser) { - loadStreams(); - loadPlaylists(); - } - }); -})(); diff --git a/public/audioCache.js b/public/audioCache.js new file mode 100644 index 0000000..b8abca3 --- /dev/null +++ b/public/audioCache.js @@ -0,0 +1,174 @@ +// MusicRoom - Audio Cache module +// Track caching, downloading, and prefetching + +(function() { + const M = window.MusicRoom; + + // Get or create cache for a track + M.getTrackCache = function(filename) { + if (!filename) return new Set(); + if (!M.trackCaches.has(filename)) { + M.trackCaches.set(filename, new Set()); + } + return M.trackCaches.get(filename); + }; + + // Get track URL - prefers cached blob, falls back to API + M.getTrackUrl = function(filename) { + return M.trackBlobs.get(filename) || "/api/tracks/" + encodeURIComponent(filename); + }; + + // Load a track blob from storage or fetch from server + M.loadTrackBlob = async function(filename) { + // Check if already in memory + if (M.trackBlobs.has(filename)) { + return M.trackBlobs.get(filename); + } + + // Check persistent storage + const cached = await TrackStorage.get(filename); + if (cached) { + const blobUrl = URL.createObjectURL(cached.blob); + M.trackBlobs.set(filename, blobUrl); + // Mark all segments as cached + const trackCache = M.getTrackCache(filename); + for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i); + M.bulkDownloadStarted.set(filename, true); + return blobUrl; + } + + return null; + }; + + // Download and cache a full track + M.downloadAndCacheTrack = async function(filename) { + if (M.bulkDownloadStarted.get(filename)) return M.trackBlobs.get(filename); + M.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 = M.getTrackCache(filename); + for (let i = 0; i < M.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); + M.trackBlobs.set(filename, blobUrl); + + // Persist to storage + await TrackStorage.set(filename, blob, contentType); + + // Update download speed + if (elapsed > 0 && data.byteLength > 0) { + M.recentDownloads.push(data.byteLength / elapsed); + if (M.recentDownloads.length > 5) M.recentDownloads.shift(); + M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length; + } + + return blobUrl; + } catch (e) { + M.bulkDownloadStarted.set(filename, false); + return null; + } + }; + + // Fetch a single segment with range request + async function fetchSegment(i, segStart, segEnd) { + const trackCache = M.getTrackCache(M.currentFilename); + if (M.loadingSegments.has(i) || trackCache.has(i)) return; + M.loadingSegments.add(i); + try { + const byteStart = Math.floor(segStart * M.audioBytesPerSecond); + const byteEnd = Math.floor(segEnd * M.audioBytesPerSecond); + const startTime = performance.now(); + const res = await fetch("/api/tracks/" + encodeURIComponent(M.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) { + M.audioBytesPerSecond = Math.round(bytesReceived / durationCovered); + } + + // Update download speed (rolling average of last 5 downloads) + if (elapsed > 0 && bytesReceived > 0) { + M.recentDownloads.push(bytesReceived / elapsed); + if (M.recentDownloads.length > 5) M.recentDownloads.shift(); + M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length; + } + } catch (e) {} + M.loadingSegments.delete(i); + } + + // Background bulk download - runs independently + async function startBulkDownload() { + const filename = M.currentFilename; + if (!filename || M.bulkDownloadStarted.get(filename)) return; + + const blobUrl = await M.downloadAndCacheTrack(filename); + + // Switch to blob URL if still on this track + if (blobUrl && M.currentFilename === filename && M.audio.src && !M.audio.src.startsWith("blob:")) { + const currentTime = M.audio.currentTime; + const wasPlaying = !M.audio.paused; + M.audio.src = blobUrl; + M.audio.currentTime = currentTime; + if (wasPlaying) M.audio.play().catch(() => {}); + } + } + + // Prefetch missing segments + let prefetching = false; + + M.prefetchSegments = async function() { + if (prefetching || !M.currentFilename || !M.audio.src || M.serverTrackDuration <= 0) return; + prefetching = true; + + const segmentDur = M.serverTrackDuration / M.SEGMENTS; + const missingSegments = []; + const trackCache = M.getTrackCache(M.currentFilename); + + // Find all missing segments (not in audio buffer AND not in our cache) + for (let i = 0; i < M.SEGMENTS; i++) { + if (trackCache.has(i) || M.loadingSegments.has(i)) continue; + + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + let available = false; + for (let j = 0; j < M.audio.buffered.length; j++) { + if (M.audio.buffered.start(j) <= segStart && M.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 (M.downloadSpeed >= M.FAST_THRESHOLD && !M.bulkDownloadStarted.get(M.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; + }; +})(); diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 0000000..b16171f --- /dev/null +++ b/public/auth.js @@ -0,0 +1,128 @@ +// MusicRoom - Auth module +// Login, signup, logout, guest authentication + +(function() { + const M = window.MusicRoom; + + // Load current user from session + M.loadCurrentUser = async function() { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + M.currentUser = data.user; + if (M.currentUser && data.permissions) { + M.currentUser.permissions = data.permissions; + } + M.updateAuthUI(); + } catch (e) { + M.currentUser = null; + M.updateAuthUI(); + } + }; + + // Tab switching + M.$("#tab-login").onclick = () => { + M.$("#tab-login").classList.add("active"); + M.$("#tab-signup").classList.remove("active"); + M.$("#login-fields").classList.remove("hidden"); + M.$("#signup-fields").classList.add("hidden"); + M.$("#auth-error").textContent = ""; + M.$("#signup-error").textContent = ""; + }; + + M.$("#tab-signup").onclick = () => { + M.$("#tab-signup").classList.add("active"); + M.$("#tab-login").classList.remove("active"); + M.$("#signup-fields").classList.remove("hidden"); + M.$("#login-fields").classList.add("hidden"); + M.$("#auth-error").textContent = ""; + M.$("#signup-error").textContent = ""; + }; + + // Login + M.$("#btn-login").onclick = async () => { + const username = M.$("#login-username").value.trim(); + const password = M.$("#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) { + M.$("#auth-error").textContent = data.error || "Login failed"; + return; + } + M.$("#login-username").value = ""; + M.$("#login-password").value = ""; + await M.loadCurrentUser(); + M.loadStreams(); + } catch (e) { + M.$("#auth-error").textContent = "Login failed"; + } + }; + + // Signup + M.$("#btn-signup").onclick = async () => { + const username = M.$("#signup-username").value.trim(); + const password = M.$("#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) { + M.$("#signup-error").textContent = data.error || "Signup failed"; + return; + } + M.$("#signup-username").value = ""; + M.$("#signup-password").value = ""; + await M.loadCurrentUser(); + M.loadStreams(); + } catch (e) { + M.$("#signup-error").textContent = "Signup failed"; + } + }; + + // Guest login + M.$("#btn-guest").onclick = async () => { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + M.currentUser = data.user; + if (M.currentUser && data.permissions) { + M.currentUser.permissions = data.permissions; + } + M.updateAuthUI(); + if (M.currentUser) M.loadStreams(); + } catch (e) { + M.$("#auth-error").textContent = "Failed to continue as guest"; + } + }; + + // Logout + M.$("#btn-logout").onclick = async () => { + const wasGuest = M.currentUser?.isGuest; + await fetch("/api/auth/logout", { method: "POST" }); + M.currentUser = null; + if (wasGuest) { + // Guest clicking "Sign In" - show login panel + M.updateAuthUI(); + } else { + // Regular user logging out - reload to get new guest session + M.updateAuthUI(); + } + }; + + // New playlist button + M.$("#btn-new-playlist").onclick = () => { + if (!M.currentUser || M.currentUser.isGuest) { + alert("Sign in to create playlists"); + return; + } + M.createNewPlaylist(); + }; +})(); diff --git a/public/controls.js b/public/controls.js new file mode 100644 index 0000000..8ba874c --- /dev/null +++ b/public/controls.js @@ -0,0 +1,188 @@ +// MusicRoom - Controls module +// Play, pause, seek, volume, prev/next track + +(function() { + const M = window.MusicRoom; + + // Load saved volume + const savedVolume = localStorage.getItem(M.STORAGE_KEY); + if (savedVolume !== null) { + M.audio.volume = parseFloat(savedVolume); + M.$("#volume").value = savedVolume; + } else { + // No saved volume - sync audio to slider's default value + M.audio.volume = parseFloat(M.$("#volume").value); + } + + // Toggle play/pause + function togglePlayback() { + if (!M.currentFilename) return; + + if (M.synced) { + if (M.ws && M.ws.readyState === WebSocket.OPEN) { + M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" })); + } + } else { + if (M.audio.paused) { + if (!M.audio.src) { + M.audio.src = M.getTrackUrl(M.currentFilename); + M.audio.currentTime = M.localTimestamp; + } + M.audio.play(); + } else { + M.localTimestamp = M.audio.currentTime; + M.audio.pause(); + } + M.updateUI(); + } + } + + // Jump to a specific track index + async function jumpToTrack(index) { + if (M.playlist.length === 0) return; + const newIndex = (index + M.playlist.length) % M.playlist.length; + + if (M.synced && M.currentStreamId) { + const res = await fetch("/api/streams/" + M.currentStreamId + "/jump", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index: newIndex }) + }); + if (res.status === 403) M.flashPermissionDenied(); + } else { + const track = M.playlist[newIndex]; + M.currentIndex = newIndex; + M.currentFilename = track.filename; + M.serverTrackDuration = track.duration; + M.$("#track-title").textContent = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(track.filename); + M.audio.src = cachedUrl || M.getTrackUrl(track.filename); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + M.renderPlaylist(); + } + } + + // Sync toggle + M.$("#btn-sync").onclick = () => { + M.wantSync = !M.wantSync; + if (M.wantSync) { + // User wants to sync - try to connect + if (M.currentStreamId) { + M.connectStream(M.currentStreamId); + } + } else { + // User wants local mode - disconnect + M.synced = false; + M.localTimestamp = M.audio.currentTime || M.getServerTime(); + if (M.ws) { + const oldWs = M.ws; + M.ws = null; + oldWs.onclose = null; + oldWs.close(); + } + } + M.updateUI(); + }; + + // Play/pause button + M.$("#status-icon").onclick = togglePlayback; + + // Prev/next buttons + M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1); + M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1); + + // Progress bar seek tooltip + M.$("#progress-container").onmousemove = (e) => { + if (M.serverTrackDuration <= 0) return; + const rect = M.$("#progress-container").getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + const hoverTime = pct * M.serverTrackDuration; + const tooltip = M.$("#seek-tooltip"); + tooltip.textContent = M.fmt(hoverTime); + tooltip.style.left = (pct * 100) + "%"; + tooltip.style.display = "block"; + }; + + M.$("#progress-container").onmouseleave = () => { + M.$("#seek-tooltip").style.display = "none"; + }; + + // Progress bar seek + M.$("#progress-container").onclick = (e) => { + const dur = M.synced ? M.serverTrackDuration : (M.audio.duration || M.serverTrackDuration); + if (!M.currentFilename || dur <= 0) return; + const rect = M.$("#progress-container").getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const seekTime = pct * dur; + + if (M.synced && M.currentStreamId) { + fetch("/api/streams/" + M.currentStreamId + "/seek", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timestamp: seekTime }) + }).then(res => { if (res.status === 403) M.flashPermissionDenied(); }); + } else { + if (!M.audio.src) { + M.audio.src = M.getTrackUrl(M.currentFilename); + } + M.audio.currentTime = seekTime; + M.localTimestamp = seekTime; + } + }; + + // Mute toggle + M.$("#btn-mute").onclick = () => { + if (M.audio.volume > 0) { + M.preMuteVolume = M.audio.volume; + M.audio.volume = 0; + M.$("#volume").value = 0; + } else { + M.audio.volume = M.preMuteVolume; + M.$("#volume").value = M.preMuteVolume; + } + localStorage.setItem(M.STORAGE_KEY, M.audio.volume); + M.updateUI(); + }; + + // Volume slider + M.$("#volume").oninput = (e) => { + M.audio.volume = e.target.value; + localStorage.setItem(M.STORAGE_KEY, e.target.value); + M.updateUI(); + }; + + // Audio element events + M.audio.onplay = () => { M.$("#progress-bar").classList.add("playing"); M.updateUI(); }; + M.audio.onpause = () => { M.$("#progress-bar").classList.remove("playing"); M.updateUI(); }; + + // Track loading state from audio element's progress + M.audio.onprogress = () => { + if (M.serverTrackDuration <= 0) return; + const segmentDur = M.serverTrackDuration / M.SEGMENTS; + M.loadingSegments.clear(); + for (let i = 0; i < M.SEGMENTS; i++) { + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + let fullyBuffered = false; + let partiallyBuffered = false; + for (let j = 0; j < M.audio.buffered.length; j++) { + const bufStart = M.audio.buffered.start(j); + const bufEnd = M.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) { + M.loadingSegments.add(i); + } + } + }; +})(); diff --git a/public/core.js b/public/core.js new file mode 100644 index 0000000..8bae519 --- /dev/null +++ b/public/core.js @@ -0,0 +1,62 @@ +// MusicRoom - Core module +// Shared state and namespace setup + +window.MusicRoom = { + // Audio element + audio: new Audio(), + + // WebSocket and stream state + ws: null, + currentStreamId: null, + currentFilename: null, + currentTitle: null, + serverTimestamp: 0, + serverTrackDuration: 0, + lastServerUpdate: 0, + serverPaused: true, + + // Sync state + wantSync: true, // User intent - do they want to be synced? + synced: false, // Actual state - are we currently synced? + + // Volume + preMuteVolume: 1, + STORAGE_KEY: "musicroom_volume", + + // Playback state + localTimestamp: 0, + playlist: [], + currentIndex: 0, + + // User state + currentUser: null, + serverStatus: null, + + // Library and playlists + library: [], + userPlaylists: [], + selectedPlaylistId: null, + + // Caching state + prefetchController: null, + loadingSegments: new Set(), + trackCaches: new Map(), // Map of filename -> Set of cached segment indices + trackBlobs: new Map(), // Map of filename -> Blob URL for fully cached tracks + bulkDownloadStarted: new Map(), + + // Download metrics + audioBytesPerSecond: 20000, // Audio bitrate estimate for range requests + downloadSpeed: 0, // Actual network download speed + recentDownloads: [], // Track recent downloads for speed calculation + + // Constants + SEGMENTS: 20, + FAST_THRESHOLD: 10 * 1024 * 1024, // 10 MB/s + + // UI update tracking (to avoid unnecessary DOM updates) + lastProgressPct: -1, + lastTimeCurrent: "", + lastTimeTotal: "", + lastBufferPct: -1, + lastSpeedText: "" +}; diff --git a/public/index.html b/public/index.html index 63995cd..d6700ad 100644 --- a/public/index.html +++ b/public/index.html @@ -93,6 +93,14 @@
- + + + + + + + + + diff --git a/public/init.js b/public/init.js new file mode 100644 index 0000000..8a9c6f5 --- /dev/null +++ b/public/init.js @@ -0,0 +1,36 @@ +// MusicRoom - Init module +// Application initialization sequence + +(function() { + const M = window.MusicRoom; + + // Fetch server status/config + async function loadServerStatus() { + try { + const res = await fetch("/api/status"); + M.serverStatus = await res.json(); + console.log("Server status:", M.serverStatus); + } catch (e) { + console.warn("Failed to load server status"); + M.serverStatus = null; + } + } + + // Initialize track storage + async function initStorage() { + await TrackStorage.init(); + const cached = await TrackStorage.list(); + console.log(`TrackStorage: ${cached.length} tracks cached`); + } + + // Initialize the application + Promise.all([initStorage(), loadServerStatus()]).then(async () => { + await M.loadLibrary(); + M.loadSelectedPlaylist("all"); // Default to All Tracks + await M.loadCurrentUser(); + if (M.currentUser) { + M.loadStreams(); + M.loadPlaylists(); + } + }); +})(); diff --git a/public/playlist.js b/public/playlist.js new file mode 100644 index 0000000..e17fe7f --- /dev/null +++ b/public/playlist.js @@ -0,0 +1,290 @@ +// MusicRoom - Playlist module +// Playlist CRUD, library rendering, playlist rendering + +(function() { + const M = window.MusicRoom; + + // Render the current playlist + M.renderPlaylist = function() { + const container = M.$("#playlist"); + container.innerHTML = ""; + if (M.playlist.length === 0) { + container.innerHTML = '
Playlist empty
'; + return; + } + M.playlist.forEach((track, i) => { + const div = document.createElement("div"); + div.className = "track" + (i === M.currentIndex ? " active" : ""); + const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); + const trackId = track.id || track.filename; + + // Show remove button only for user playlists (not stream playlists) + const removeBtn = M.selectedPlaylistId ? `×` : ""; + div.innerHTML = `${title}${removeBtn}${M.fmt(track.duration)}`; + + div.querySelector(".track-title").onclick = async () => { + if (M.synced && M.currentStreamId) { + const res = await fetch("/api/streams/" + M.currentStreamId + "/jump", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index: i }) + }); + if (res.status === 403) M.flashPermissionDenied(); + } else { + M.currentIndex = i; + M.currentFilename = trackId; + M.serverTrackDuration = track.duration; + M.$("#track-title").textContent = title; + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + M.renderPlaylist(); + } + }; + + const removeEl = div.querySelector(".btn-remove"); + if (removeEl) { + removeEl.onclick = (e) => { + e.stopPropagation(); + M.removeTrackFromCurrentPlaylist(i); + }; + } + + container.appendChild(div); + }); + }; + + // Render the library + M.renderLibrary = function() { + const container = M.$("#library"); + container.innerHTML = ""; + if (M.library.length === 0) { + container.innerHTML = '
No tracks discovered
'; + return; + } + const canAdd = M.selectedPlaylistId && M.selectedPlaylistId !== "all"; + M.library.forEach((track) => { + const div = document.createElement("div"); + div.className = "track"; + const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); + const addBtn = canAdd ? `+` : ""; + div.innerHTML = `${title}${addBtn}${M.fmt(track.duration)}`; + + div.querySelector(".track-title").onclick = async () => { + // Play directly from library (uses track ID) + if (!M.synced) { + M.currentFilename = track.id; + M.serverTrackDuration = track.duration; + M.$("#track-title").textContent = title; + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(track.id); + M.audio.src = cachedUrl || M.getTrackUrl(track.id); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + } + }; + + const addBtnEl = div.querySelector(".btn-add"); + if (addBtnEl) { + addBtnEl.onclick = (e) => { + e.stopPropagation(); + M.addTrackToCurrentPlaylist(track.id); + }; + } + + container.appendChild(div); + }); + }; + + // Load library from server + M.loadLibrary = async function() { + try { + const res = await fetch("/api/library"); + M.library = await res.json(); + M.renderLibrary(); + } catch (e) { + console.warn("Failed to load library"); + } + }; + + // Load user playlists from server + M.loadPlaylists = async function() { + try { + const res = await fetch("/api/playlists"); + M.userPlaylists = await res.json(); + M.renderPlaylistSelector(); + } catch (e) { + console.warn("Failed to load playlists"); + } + }; + + // Render playlist selector sidebar + M.renderPlaylistSelector = function() { + const list = M.$("#playlists-list"); + if (!list) return; + list.innerHTML = ""; + // Add "All Tracks" as default option + const allItem = document.createElement("div"); + allItem.className = "playlist-item" + (M.selectedPlaylistId === "all" ? " active" : ""); + allItem.textContent = "All Tracks"; + allItem.onclick = () => M.loadSelectedPlaylist("all"); + list.appendChild(allItem); + // Add user playlists + for (const pl of M.userPlaylists) { + const item = document.createElement("div"); + item.className = "playlist-item" + (pl.id === M.selectedPlaylistId ? " active" : ""); + item.textContent = pl.name; + item.onclick = () => M.loadSelectedPlaylist(pl.id); + list.appendChild(item); + } + // Update playlist panel title + const titleEl = M.$("#playlist-title"); + if (M.selectedPlaylistId === "all") { + titleEl.textContent = "Playlist - All Tracks"; + } else if (M.selectedPlaylistId) { + const pl = M.userPlaylists.find(p => p.id === M.selectedPlaylistId); + titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist"; + } else { + titleEl.textContent = "Playlist"; + } + }; + + // Load and display a specific playlist + M.loadSelectedPlaylist = async function(playlistId) { + if (!playlistId) { + M.playlist = []; + M.selectedPlaylistId = null; + M.renderPlaylist(); + M.renderPlaylistSelector(); + M.renderLibrary(); + return; + } + if (playlistId === "all") { + // Use library as playlist + M.playlist = [...M.library]; + M.selectedPlaylistId = "all"; + M.currentIndex = 0; + M.renderPlaylist(); + M.renderPlaylistSelector(); + M.renderLibrary(); + return; + } + try { + const res = await fetch("/api/playlists/" + playlistId); + if (!res.ok) throw new Error("Failed to load playlist"); + const data = await res.json(); + M.playlist = data.tracks || []; + M.selectedPlaylistId = playlistId; + M.currentIndex = 0; + M.renderPlaylist(); + M.renderPlaylistSelector(); + M.renderLibrary(); + } catch (e) { + console.warn("Failed to load playlist:", e); + } + }; + + // Create a new playlist + M.createNewPlaylist = async function() { + const header = M.$("#playlists-panel .panel-header"); + const btn = M.$("#btn-new-playlist"); + + // Already in edit mode? + if (header.querySelector(".new-playlist-input")) return; + + // Hide button, show input + btn.style.display = "none"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "new-playlist-input"; + input.placeholder = "Playlist name..."; + + const submit = document.createElement("button"); + submit.className = "btn-submit-playlist"; + 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 { + const res = await fetch("/api/playlists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, visibility: "private" }) + }); + if (!res.ok) throw new Error("Failed to create playlist"); + const pl = await res.json(); + await M.loadPlaylists(); + M.selectedPlaylistId = pl.id; + M.renderPlaylistSelector(); + await M.loadSelectedPlaylist(pl.id); + } catch (e) { + alert("Failed to create playlist"); + } + cleanup(); + }; + + submit.onclick = doCreate; + input.onkeydown = (e) => { + if (e.key === "Enter") doCreate(); + if (e.key === "Escape") cleanup(); + }; + input.onblur = (e) => { + // Delay to allow click on submit button + setTimeout(() => { + if (document.activeElement !== submit) cleanup(); + }, 100); + }; + }; + + // Add a track to current playlist + M.addTrackToCurrentPlaylist = async function(trackId) { + if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") { + alert("Select or create a playlist first"); + return; + } + try { + const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trackIds: [trackId] }) + }); + if (!res.ok) throw new Error("Failed to add track"); + await M.loadSelectedPlaylist(M.selectedPlaylistId); + } catch (e) { + console.warn("Failed to add track:", e); + } + }; + + // Remove a track from current playlist + M.removeTrackFromCurrentPlaylist = async function(position) { + if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") return; + try { + const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks/" + position, { + method: "DELETE" + }); + if (!res.ok) throw new Error("Failed to remove track"); + await M.loadSelectedPlaylist(M.selectedPlaylistId); + } catch (e) { + console.warn("Failed to remove track:", e); + } + }; +})(); diff --git a/public/streamSync.js b/public/streamSync.js new file mode 100644 index 0000000..66abaa4 --- /dev/null +++ b/public/streamSync.js @@ -0,0 +1,154 @@ +// MusicRoom - Stream Sync module +// WebSocket connection and server synchronization + +(function() { + const M = window.MusicRoom; + + // Load available streams and connect to first one + M.loadStreams = async function() { + try { + const res = await fetch("/api/streams"); + const streams = await res.json(); + if (streams.length === 0) { + M.$("#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 = () => M.connectStream(sel.value); + M.$("#stream-select").appendChild(sel); + } + M.connectStream(streams[0].id); + } catch (e) { + M.$("#track-title").textContent = "Server unavailable"; + M.$("#status").textContent = "Local (offline)"; + M.synced = false; + M.updateUI(); + } + }; + + // Connect to a stream via WebSocket + M.connectStream = function(id) { + if (M.ws) { + const oldWs = M.ws; + M.ws = null; + oldWs.onclose = null; + oldWs.close(); + } + M.currentStreamId = id; + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + M.ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws"); + + M.ws.onmessage = (e) => { + const data = JSON.parse(e.data); + // 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 stream 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.connectStream(id), 3000); + } + }; + + M.ws.onopen = () => { + M.synced = true; + M.$("#sync-indicator").classList.remove("disconnected"); + M.updateUI(); + }; + }; + + // Handle stream state update from server + M.handleUpdate = async function(data) { + if (!data.track) { + M.$("#track-title").textContent = "No tracks"; + return; + } + M.$("#stream-name").textContent = data.streamName || ""; + 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 + const isNewTrack = data.track.filename !== M.currentFilename; + if (isNewTrack) { + M.currentFilename = data.track.filename; + M.currentTitle = data.track.title; + M.$("#track-title").textContent = data.track.title; + M.loadingSegments.clear(); + } + + 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.currentFilename); + M.audio.src = cachedUrl || M.getTrackUrl(M.currentFilename); + } + 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) { + M.audio.currentTime = data.currentTimestamp; + } + } + } else if (!wasServerPaused && M.serverPaused) { + // Server just paused + M.audio.pause(); + } + } + M.updateUI(); + }; +})(); diff --git a/public/ui.js b/public/ui.js new file mode 100644 index 0000000..486b178 --- /dev/null +++ b/public/ui.js @@ -0,0 +1,144 @@ +// MusicRoom - UI module +// Progress bar, buffer display, and UI state updates + +(function() { + const M = window.MusicRoom; + + // Create buffer segments on load + for (let i = 0; i < M.SEGMENTS; i++) { + const seg = document.createElement("div"); + seg.className = "segment"; + M.$("#buffer-bar").appendChild(seg); + } + + // Update general UI state + M.updateUI = function() { + const isPlaying = M.synced ? !M.serverPaused : !M.audio.paused; + M.$("#btn-sync").classList.toggle("synced", M.wantSync); + M.$("#btn-sync").classList.toggle("connected", M.synced); + M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync"; + M.$("#status").textContent = M.synced ? "Synced" : (M.wantSync ? "Connecting..." : "Local"); + M.$("#sync-indicator").classList.toggle("visible", M.synced); + M.$("#progress-bar").classList.toggle("synced", M.synced); + M.$("#progress-bar").classList.toggle("local", !M.synced); + M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0); + M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊"; + M.$("#status-icon").textContent = isPlaying ? "⏸" : "▶"; + + // Show/hide controls based on permissions + const hasControl = M.canControl(); + M.$("#status-icon").style.cursor = hasControl || !M.synced ? "pointer" : "default"; + }; + + // Update auth-related UI + M.updateAuthUI = function() { + if (M.currentUser) { + M.$("#login-panel").classList.add("hidden"); + M.$("#player-content").classList.add("visible"); + if (M.currentUser.isGuest) { + M.$("#current-username").textContent = "Guest"; + M.$("#btn-logout").textContent = "Sign In"; + } else { + M.$("#current-username").textContent = M.currentUser.username; + M.$("#btn-logout").textContent = "Logout"; + } + M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none"; + } else { + M.$("#login-panel").classList.remove("hidden"); + M.$("#player-content").classList.remove("visible"); + // Pause and unsync when login panel is shown + if (!M.audio.paused) { + M.localTimestamp = M.audio.currentTime; + M.audio.pause(); + } + if (M.synced && M.ws) { + M.synced = false; + M.ws.close(); + M.ws = null; + } + // Show guest button if server allows guests + if (M.serverStatus?.allowGuests) { + M.$("#guest-section").classList.remove("hidden"); + } else { + M.$("#guest-section").classList.add("hidden"); + } + } + M.updateUI(); + }; + + // Progress bar and buffer update loop (250ms interval) + setInterval(() => { + if (M.serverTrackDuration <= 0) return; + let t, dur; + if (M.synced) { + t = M.audio.paused ? M.getServerTime() : M.audio.currentTime; + dur = M.audio.duration || M.serverTrackDuration; + } else { + t = M.audio.paused ? M.localTimestamp : M.audio.currentTime; + dur = M.audio.duration || M.serverTrackDuration; + } + const pct = Math.min((t / dur) * 100, 100); + if (Math.abs(pct - M.lastProgressPct) > 0.1) { + M.$("#progress-bar").style.width = pct + "%"; + M.lastProgressPct = pct; + } + + const timeCurrent = M.fmt(t); + const timeTotal = M.fmt(dur); + if (timeCurrent !== M.lastTimeCurrent) { + M.$("#time-current").textContent = timeCurrent; + M.lastTimeCurrent = timeCurrent; + } + if (timeTotal !== M.lastTimeTotal) { + M.$("#time-total").textContent = timeTotal; + M.lastTimeTotal = timeTotal; + } + + // Update buffer segments + const segments = M.$("#buffer-bar").children; + const segmentDur = dur / M.SEGMENTS; + let availableCount = 0; + for (let i = 0; i < M.SEGMENTS; i++) { + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + const trackCache = M.getTrackCache(M.currentFilename); + let available = trackCache.has(i); // Check our cache first + if (!available) { + for (let j = 0; j < M.audio.buffered.length; j++) { + const bufStart = M.audio.buffered.start(j); + const bufEnd = M.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 && M.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 = M.downloadSpeed > 0 ? M.downloadSpeed * 8 / 1000 : 0; + const bufferPct = Math.round(availableCount / M.SEGMENTS * 100); + let speedText = ""; + if (kbps > 0) { + speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`; + } + if (bufferPct !== M.lastBufferPct || speedText !== M.lastSpeedText) { + M.$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`; + M.lastBufferPct = bufferPct; + M.lastSpeedText = speedText; + } + }, 250); + + // Prefetch loop (1s interval) + setInterval(() => { + if (M.currentFilename && M.audio.src) { + M.prefetchSegments(); + } + }, 1000); +})(); diff --git a/public/utils.js b/public/utils.js new file mode 100644 index 0000000..08363aa --- /dev/null +++ b/public/utils.js @@ -0,0 +1,56 @@ +// MusicRoom - Utilities module +// DOM helpers, formatting, toast notifications + +(function() { + const M = window.MusicRoom; + + // DOM selector helper + M.$ = (s) => document.querySelector(s); + + // Format seconds as m:ss + M.fmt = function(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"); + }; + + // Toast notifications + M.showToast = function(message, duration = 4000) { + const container = M.$("#toast-container"); + const toast = document.createElement("div"); + toast.className = "toast"; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => { + toast.classList.add("fade-out"); + setTimeout(() => toast.remove(), 300); + }, duration); + }; + + // Flash permission denied animation + M.flashPermissionDenied = function() { + const row = M.$("#progress-row"); + row.classList.remove("denied"); + void row.offsetWidth; // Trigger reflow to restart animation + row.classList.add("denied"); + setTimeout(() => row.classList.remove("denied"), 500); + }; + + // Get current server time (extrapolated) + M.getServerTime = function() { + if (M.serverPaused) return M.serverTimestamp; + return M.serverTimestamp + (Date.now() - M.lastServerUpdate) / 1000; + }; + + // Check if current user can control playback + M.canControl = function() { + if (!M.currentUser) return false; + if (M.currentUser.isAdmin) return true; + return M.currentUser.permissions?.some(p => + p.resource_type === "stream" && + (p.resource_id === M.currentStreamId || p.resource_id === null) && + p.permission === "control" + ); + }; +})();