saving
This commit is contained in:
parent
7f138543b4
commit
8fdd64de61
1127
public/app.js
1127
public/app.js
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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: ""
|
||||||
|
};
|
||||||
|
|
@ -93,6 +93,14 @@
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
</div>
|
</div>
|
||||||
<script src="trackStorage.js"></script>
|
<script src="trackStorage.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="core.js"></script>
|
||||||
|
<script src="utils.js"></script>
|
||||||
|
<script src="audioCache.js"></script>
|
||||||
|
<script src="streamSync.js"></script>
|
||||||
|
<script src="ui.js"></script>
|
||||||
|
<script src="playlist.js"></script>
|
||||||
|
<script src="controls.js"></script>
|
||||||
|
<script src="auth.js"></script>
|
||||||
|
<script src="init.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -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 = '<div class="empty">Playlist empty</div>';
|
||||||
|
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 ? `<span class="btn-remove" title="Remove">×</span>` : "";
|
||||||
|
div.innerHTML = `<span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||||||
|
|
||||||
|
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 = '<div class="empty">No tracks discovered</div>';
|
||||||
|
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 ? `<span class="btn-add" title="Add to playlist">+</span>` : "";
|
||||||
|
div.innerHTML = `<span class="track-title">${title}</span><span class="track-actions">${addBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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);
|
||||||
|
})();
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue