This commit is contained in:
peterino2 2026-02-02 21:10:38 -08:00
parent 7f138543b4
commit 8fdd64de61
11 changed files with 1241 additions and 1128 deletions

File diff suppressed because it is too large Load Diff

174
public/audioCache.js Normal file
View File

@ -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;
};
})();

128
public/auth.js Normal file
View File

@ -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();
};
})();

188
public/controls.js vendored Normal file
View File

@ -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);
}
}
};
})();

62
public/core.js Normal file
View File

@ -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: ""
};

View File

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

36
public/init.js Normal file
View File

@ -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();
}
});
})();

290
public/playlist.js Normal file
View File

@ -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);
}
};
})();

154
public/streamSync.js Normal file
View File

@ -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();
};
})();

144
public/ui.js Normal file
View File

@ -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);
})();

56
public/utils.js Normal file
View File

@ -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"
);
};
})();