blastoise-archive/public/app.js

694 lines
22 KiB
JavaScript

(function() {
const audio = new Audio();
let ws = null;
let currentStreamId = null;
let currentFilename = null;
let currentTitle = null;
let serverTimestamp = 0;
let serverTrackDuration = 0;
let lastServerUpdate = 0;
let serverPaused = true;
let synced = false;
let preMuteVolume = 1;
let localTimestamp = 0;
let playlist = [];
let currentIndex = 0;
let currentUser = null;
let prefetchController = null;
let loadingSegments = new Set();
let trackCaches = new Map(); // Map of filename -> Set of cached segment indices
let trackBlobs = new Map(); // Map of filename -> Blob URL for fully cached tracks
let audioBytesPerSecond = 20000; // Audio bitrate estimate for range requests
let downloadSpeed = 0; // Actual network download speed
let recentDownloads = []; // Track recent downloads for speed calculation
const $ = (s) => document.querySelector(s);
const SEGMENTS = 20;
const STORAGE_KEY = "musicroom_volume";
// Load saved volume
const savedVolume = localStorage.getItem(STORAGE_KEY);
if (savedVolume !== null) {
audio.volume = parseFloat(savedVolume);
$("#volume").value = savedVolume;
}
// Create buffer segments
for (let i = 0; i < SEGMENTS; i++) {
const seg = document.createElement("div");
seg.className = "segment";
$("#buffer-bar").appendChild(seg);
}
function fmt(sec) {
if (!sec || !isFinite(sec)) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ":" + String(s).padStart(2, "0");
}
function getTrackCache(filename) {
if (!filename) return new Set();
if (!trackCaches.has(filename)) {
trackCaches.set(filename, new Set());
}
return trackCaches.get(filename);
}
// Get track URL - prefers cached blob, falls back to API
function getTrackUrl(filename) {
return trackBlobs.get(filename) || "/api/tracks/" + encodeURIComponent(filename);
}
// Load a track blob from storage or fetch from server
async function loadTrackBlob(filename) {
// Check if already in memory
if (trackBlobs.has(filename)) {
return trackBlobs.get(filename);
}
// Check persistent storage
const cached = await TrackStorage.get(filename);
if (cached) {
const blobUrl = URL.createObjectURL(cached.blob);
trackBlobs.set(filename, blobUrl);
// Mark all segments as cached
const trackCache = getTrackCache(filename);
for (let i = 0; i < SEGMENTS; i++) trackCache.add(i);
bulkDownloadStarted.set(filename, true);
return blobUrl;
}
return null;
}
// Download and cache a track
async function downloadAndCacheTrack(filename) {
if (bulkDownloadStarted.get(filename)) return trackBlobs.get(filename);
bulkDownloadStarted.set(filename, true);
try {
const startTime = performance.now();
const res = await fetch("/api/tracks/" + encodeURIComponent(filename));
const data = await res.arrayBuffer();
const elapsed = (performance.now() - startTime) / 1000;
// Mark all segments as cached
const trackCache = getTrackCache(filename);
for (let i = 0; i < SEGMENTS; i++) trackCache.add(i);
// Create blob and URL
const contentType = res.headers.get("Content-Type") || "audio/mpeg";
const blob = new Blob([data], { type: contentType });
const blobUrl = URL.createObjectURL(blob);
trackBlobs.set(filename, blobUrl);
// Persist to storage
await TrackStorage.set(filename, blob, contentType);
// Update download speed
if (elapsed > 0 && data.byteLength > 0) {
recentDownloads.push(data.byteLength / elapsed);
if (recentDownloads.length > 5) recentDownloads.shift();
downloadSpeed = recentDownloads.reduce((a, b) => a + b, 0) / recentDownloads.length;
}
return blobUrl;
} catch (e) {
bulkDownloadStarted.set(filename, false);
return null;
}
}
function getServerTime() {
if (serverPaused) return serverTimestamp;
return serverTimestamp + (Date.now() - lastServerUpdate) / 1000;
}
function canControl() {
if (!currentUser) return false;
if (currentUser.isAdmin) return true;
// Check if user has control permission for current stream
return currentUser.permissions?.some(p =>
p.resource_type === "stream" &&
(p.resource_id === currentStreamId || p.resource_id === null) &&
p.permission === "control"
);
}
function updateAuthUI() {
if (currentUser) {
$("#login-panel").classList.add("hidden");
$("#player-content").classList.add("visible");
$("#current-username").textContent = currentUser.username;
$("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none";
} else {
$("#login-panel").classList.remove("hidden");
$("#player-content").classList.remove("visible");
}
updateUI();
}
async function loadCurrentUser() {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
currentUser = data.user;
if (currentUser && data.permissions) {
currentUser.permissions = data.permissions;
}
updateAuthUI();
} catch (e) {
currentUser = null;
updateAuthUI();
}
}
function updateUI() {
const isPlaying = synced ? !serverPaused : !audio.paused;
$("#btn-sync").classList.toggle("synced", synced);
$("#btn-sync").title = synced ? "Unsync" : "Sync";
$("#status").textContent = synced ? "Synced" : "Local";
$("#sync-indicator").classList.toggle("visible", synced);
$("#progress-bar").classList.toggle("synced", synced);
$("#progress-bar").classList.toggle("local", !synced);
$("#progress-bar").classList.toggle("muted", audio.volume === 0);
$("#btn-mute").textContent = audio.volume === 0 ? "🔇" : "🔊";
$("#status-icon").textContent = isPlaying ? "⏸" : "▶";
// Show/hide controls based on permissions
const hasControl = canControl();
$("#status-icon").style.cursor = hasControl || !synced ? "pointer" : "default";
}
// Update progress bar and buffer segments
setInterval(() => {
if (serverTrackDuration <= 0) return;
let t, dur;
if (synced) {
t = audio.paused ? getServerTime() : audio.currentTime;
dur = audio.duration || serverTrackDuration;
} else {
t = audio.paused ? localTimestamp : audio.currentTime;
dur = audio.duration || serverTrackDuration;
}
const pct = Math.min((t / dur) * 100, 100);
$("#progress-bar").style.width = pct + "%";
$("#time-current").textContent = fmt(t);
$("#time-total").textContent = fmt(dur);
// Update buffer segments
const segments = $("#buffer-bar").children;
const segmentDur = dur / SEGMENTS;
let availableCount = 0;
for (let i = 0; i < SEGMENTS; i++) {
const segStart = i * segmentDur;
const segEnd = (i + 1) * segmentDur;
const trackCache = getTrackCache(currentFilename);
let available = trackCache.has(i); // Check our cache first
if (!available) {
for (let j = 0; j < audio.buffered.length; j++) {
const bufStart = audio.buffered.start(j);
const bufEnd = audio.buffered.end(j);
if (bufStart <= segStart && bufEnd >= segEnd) {
available = true;
break;
}
}
}
if (available) availableCount++;
segments[i].classList.toggle("available", available);
segments[i].classList.toggle("loading", !available && loadingSegments.has(i));
}
// Update download speed display
const kbps = downloadSpeed > 0 ? downloadSpeed * 8 / 1000 : 0;
const bufferPct = Math.round(availableCount / SEGMENTS * 100);
let speedText = "";
if (kbps > 0) {
speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`;
}
$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`;
}, 250);
// Prefetch missing segments
let prefetching = false;
let bulkDownloadStarted = new Map(); // Track if bulk download started per filename
const FAST_THRESHOLD = 10 * 1024 * 1024; // 10 MB/s
async function fetchSegment(i, segStart, segEnd) {
const trackCache = getTrackCache(currentFilename);
if (loadingSegments.has(i) || trackCache.has(i)) return;
loadingSegments.add(i);
try {
const byteStart = Math.floor(segStart * audioBytesPerSecond);
const byteEnd = Math.floor(segEnd * audioBytesPerSecond);
const startTime = performance.now();
const res = await fetch("/api/tracks/" + encodeURIComponent(currentFilename), {
headers: { "Range": `bytes=${byteStart}-${byteEnd}` }
});
const data = await res.arrayBuffer();
const elapsed = (performance.now() - startTime) / 1000;
// Mark segment as cached
trackCache.add(i);
// Update audio bitrate estimate
const bytesReceived = data.byteLength;
const durationCovered = segEnd - segStart;
if (bytesReceived > 0 && durationCovered > 0) {
audioBytesPerSecond = Math.round(bytesReceived / durationCovered);
}
// Update download speed (rolling average of last 5 downloads)
if (elapsed > 0 && bytesReceived > 0) {
recentDownloads.push(bytesReceived / elapsed);
if (recentDownloads.length > 5) recentDownloads.shift();
downloadSpeed = recentDownloads.reduce((a, b) => a + b, 0) / recentDownloads.length;
}
} catch (e) {}
loadingSegments.delete(i);
}
// Background bulk download - runs independently
async function startBulkDownload() {
const filename = currentFilename;
if (!filename || bulkDownloadStarted.get(filename)) return;
const blobUrl = await downloadAndCacheTrack(filename);
// Switch to blob URL if still on this track
if (blobUrl && currentFilename === filename && audio.src && !audio.src.startsWith("blob:")) {
const currentTime = audio.currentTime;
const wasPlaying = !audio.paused;
audio.src = blobUrl;
audio.currentTime = currentTime;
if (wasPlaying) audio.play().catch(() => {});
}
}
async function prefetchSegments() {
if (prefetching || !currentFilename || !audio.src || serverTrackDuration <= 0) return;
prefetching = true;
const segmentDur = serverTrackDuration / SEGMENTS;
const missingSegments = [];
const trackCache = getTrackCache(currentFilename);
// Find all missing segments (not in audio buffer AND not in our cache)
for (let i = 0; i < SEGMENTS; i++) {
if (trackCache.has(i) || loadingSegments.has(i)) continue;
const segStart = i * segmentDur;
const segEnd = (i + 1) * segmentDur;
let available = false;
for (let j = 0; j < audio.buffered.length; j++) {
if (audio.buffered.start(j) <= segStart && audio.buffered.end(j) >= segEnd) {
available = true;
break;
}
}
if (!available) {
missingSegments.push({ i, segStart, segEnd });
}
}
if (missingSegments.length > 0) {
// Fast connection: also start bulk download in background
if (downloadSpeed >= FAST_THRESHOLD && !bulkDownloadStarted.get(currentFilename)) {
startBulkDownload(); // Fire and forget
}
// Always fetch segments one at a time for seek support
const s = missingSegments[0];
await fetchSegment(s.i, s.segStart, s.segEnd);
}
prefetching = false;
}
// Run prefetch loop
setInterval(() => {
if (currentFilename && audio.src) {
prefetchSegments();
}
}, 1000);
// Load streams and try to connect
async function loadStreams() {
try {
const res = await fetch("/api/streams");
const streams = await res.json();
if (streams.length === 0) {
$("#track-title").textContent = "No streams available";
return;
}
if (streams.length > 1) {
const sel = document.createElement("select");
for (const s of streams) {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = s.name;
sel.appendChild(opt);
}
sel.onchange = () => connectStream(sel.value);
$("#stream-select").appendChild(sel);
}
connectStream(streams[0].id);
} catch (e) {
$("#track-title").textContent = "Server unavailable";
$("#status").textContent = "Local (offline)";
synced = false;
updateUI();
}
}
function connectStream(id) {
if (ws) {
const oldWs = ws;
ws = null;
oldWs.onclose = null;
oldWs.close();
}
currentStreamId = id;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
ws.onclose = () => {
if (synced && ws) {
$("#status").textContent = "Disconnected. Reconnecting...";
$("#sync-indicator").classList.add("disconnected");
setTimeout(() => connectStream(id), 3000);
}
};
ws.onopen = () => {
synced = true;
$("#sync-indicator").classList.remove("disconnected");
updateUI();
};
}
function renderPlaylist() {
const container = $("#playlist");
container.innerHTML = "";
playlist.forEach((track, i) => {
const div = document.createElement("div");
div.className = "track" + (i === currentIndex ? " active" : "");
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
div.innerHTML = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
div.onclick = async () => {
if (synced && currentStreamId) {
fetch("/api/streams/" + currentStreamId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: i })
});
} else {
currentIndex = i;
currentFilename = track.filename;
serverTrackDuration = track.duration;
$("#track-title").textContent = title;
// Reset loading state for new track (cache persists)
loadingSegments.clear();
// Try to load from cache first
const cachedUrl = await loadTrackBlob(track.filename);
audio.src = cachedUrl || getTrackUrl(track.filename);
audio.currentTime = 0;
localTimestamp = 0;
audio.play();
renderPlaylist();
}
};
container.appendChild(div);
});
}
async function handleUpdate(data) {
if (!data.track) {
$("#track-title").textContent = "No tracks";
return;
}
$("#stream-name").textContent = data.streamName || "";
serverTimestamp = data.currentTimestamp;
serverTrackDuration = data.track.duration;
lastServerUpdate = Date.now();
const wasServerPaused = serverPaused;
serverPaused = data.paused ?? true;
// Update playlist if provided
if (data.playlist) {
playlist = data.playlist;
currentIndex = data.currentIndex ?? 0;
renderPlaylist();
} else if (data.currentIndex !== undefined && data.currentIndex !== currentIndex) {
currentIndex = data.currentIndex;
renderPlaylist();
}
// Cache track info for local mode
const isNewTrack = data.track.filename !== currentFilename;
if (isNewTrack) {
currentFilename = data.track.filename;
currentTitle = data.track.title;
$("#track-title").textContent = data.track.title;
loadingSegments.clear();
}
if (synced) {
if (!serverPaused) {
// Server is playing - ensure we're playing and synced
if (isNewTrack || !audio.src) {
// Try cache first
const cachedUrl = await loadTrackBlob(currentFilename);
audio.src = cachedUrl || getTrackUrl(currentFilename);
}
if (audio.paused) {
audio.currentTime = data.currentTimestamp;
audio.play().catch(() => {});
} else {
// Check drift
const drift = Math.abs(audio.currentTime - data.currentTimestamp);
if (drift >= 2) {
audio.currentTime = data.currentTimestamp;
}
}
} else if (!wasServerPaused && serverPaused) {
// Server just paused
audio.pause();
}
}
updateUI();
}
$("#btn-sync").onclick = () => {
if (synced) {
// Unsync - go to local mode
synced = false;
localTimestamp = audio.currentTime || getServerTime();
if (ws) ws.close();
ws = null;
} else {
// Try to sync
if (currentStreamId) {
connectStream(currentStreamId);
}
}
updateUI();
};
function togglePlayback() {
if (!currentFilename) return;
if (synced) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: serverPaused ? "unpause" : "pause" }));
}
} else {
if (audio.paused) {
if (!audio.src) {
audio.src = getTrackUrl(currentFilename);
audio.currentTime = localTimestamp;
}
audio.play();
} else {
localTimestamp = audio.currentTime;
audio.pause();
}
updateUI();
}
}
$("#status-icon").onclick = togglePlayback;
$("#progress-container").onmousemove = (e) => {
if (serverTrackDuration <= 0) return;
const rect = $("#progress-container").getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const hoverTime = pct * serverTrackDuration;
const tooltip = $("#seek-tooltip");
tooltip.textContent = fmt(hoverTime);
tooltip.style.left = (pct * 100) + "%";
tooltip.style.display = "block";
};
$("#progress-container").onmouseleave = () => {
$("#seek-tooltip").style.display = "none";
};
$("#progress-container").onclick = (e) => {
const dur = synced ? serverTrackDuration : (audio.duration || serverTrackDuration);
if (!currentFilename || dur <= 0) return;
const rect = $("#progress-container").getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = pct * dur;
if (synced && currentStreamId) {
fetch("/api/streams/" + currentStreamId + "/seek", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timestamp: seekTime })
});
} else {
if (!audio.src) {
audio.src = getTrackUrl(currentFilename);
}
audio.currentTime = seekTime;
localTimestamp = seekTime;
}
};
$("#btn-mute").onclick = () => {
if (audio.volume > 0) {
preMuteVolume = audio.volume;
audio.volume = 0;
$("#volume").value = 0;
} else {
audio.volume = preMuteVolume;
$("#volume").value = preMuteVolume;
}
localStorage.setItem(STORAGE_KEY, audio.volume);
updateUI();
};
$("#volume").oninput = (e) => {
audio.volume = e.target.value;
localStorage.setItem(STORAGE_KEY, e.target.value);
updateUI();
};
audio.onplay = () => { $("#progress-bar").classList.add("playing"); updateUI(); };
audio.onpause = () => { $("#progress-bar").classList.remove("playing"); updateUI(); };
// Track loading state from audio element's progress
audio.onprogress = () => {
if (serverTrackDuration <= 0) return;
const segmentDur = serverTrackDuration / SEGMENTS;
loadingSegments.clear();
for (let i = 0; i < SEGMENTS; i++) {
const segStart = i * segmentDur;
const segEnd = (i + 1) * segmentDur;
let fullyBuffered = false;
let partiallyBuffered = false;
for (let j = 0; j < audio.buffered.length; j++) {
const bufStart = audio.buffered.start(j);
const bufEnd = audio.buffered.end(j);
if (bufStart <= segStart && bufEnd >= segEnd) {
fullyBuffered = true;
break;
}
// Check if buffer is actively loading into this segment
if (bufEnd > segStart && bufEnd < segEnd && bufStart <= segStart) {
partiallyBuffered = true;
}
}
if (partiallyBuffered && !fullyBuffered) {
loadingSegments.add(i);
}
}
};
// Auth event handlers - tab switching
$("#tab-login").onclick = () => {
$("#tab-login").classList.add("active");
$("#tab-signup").classList.remove("active");
$("#login-fields").classList.remove("hidden");
$("#signup-fields").classList.add("hidden");
$("#auth-error").textContent = "";
$("#signup-error").textContent = "";
};
$("#tab-signup").onclick = () => {
$("#tab-signup").classList.add("active");
$("#tab-login").classList.remove("active");
$("#signup-fields").classList.remove("hidden");
$("#login-fields").classList.add("hidden");
$("#auth-error").textContent = "";
$("#signup-error").textContent = "";
};
$("#btn-login").onclick = async () => {
const username = $("#login-username").value.trim();
const password = $("#login-password").value;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) {
$("#auth-error").textContent = data.error || "Login failed";
return;
}
$("#login-username").value = "";
$("#login-password").value = "";
await loadCurrentUser();
loadStreams();
} catch (e) {
$("#auth-error").textContent = "Login failed";
}
};
$("#btn-signup").onclick = async () => {
const username = $("#signup-username").value.trim();
const password = $("#signup-password").value;
try {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) {
$("#signup-error").textContent = data.error || "Signup failed";
return;
}
$("#signup-username").value = "";
$("#signup-password").value = "";
await loadCurrentUser();
loadStreams();
} catch (e) {
$("#signup-error").textContent = "Signup failed";
}
};
$("#btn-logout").onclick = async () => {
await fetch("/api/auth/logout", { method: "POST" });
currentUser = null;
updateAuthUI();
};
// Initialize storage and load cached tracks
async function initStorage() {
await TrackStorage.init();
const cached = await TrackStorage.list();
console.log(`TrackStorage: ${cached.length} tracks cached`);
}
// Initialize
initStorage().then(() => {
loadCurrentUser().then(() => {
if (currentUser) loadStreams();
});
});
})();