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