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 @@
-
+
+
+
+
+
+
+
+
+