blastoise-archive/public/app.js

874 lines
28 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 wantSync = true; // User intent - do they want to be synced?
let synced = false; // Actual state - are we currently synced?
let preMuteVolume = 1;
let localTimestamp = 0;
let playlist = [];
let currentIndex = 0;
let currentUser = null;
let serverStatus = null;
let library = [];
let 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");
if (currentUser.isGuest) {
$("#current-username").textContent = "Guest";
$("#btn-logout").textContent = "Sign In";
} else {
$("#current-username").textContent = currentUser.username;
$("#btn-logout").textContent = "Logout";
}
$("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none";
} else {
$("#login-panel").classList.remove("hidden");
$("#player-content").classList.remove("visible");
// Pause and unsync when login panel is shown
if (!audio.paused) {
localTimestamp = audio.currentTime;
audio.pause();
}
if (synced && ws) {
synced = false;
ws.close();
ws = null;
}
// Show guest button if server allows guests
if (serverStatus?.allowGuests) {
$("#guest-section").classList.remove("hidden");
} else {
$("#guest-section").classList.add("hidden");
}
}
updateUI();
}
async function loadCurrentUser() {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
currentUser = data.user;
if (currentUser && data.permissions) {
currentUser.permissions = data.permissions;
}
updateAuthUI();
} catch (e) {
currentUser = null;
updateAuthUI();
}
}
function updateUI() {
const isPlaying = synced ? !serverPaused : !audio.paused;
$("#btn-sync").classList.toggle("synced", wantSync);
$("#btn-sync").classList.toggle("connected", synced);
$("#btn-sync").title = wantSync ? "Unsync" : "Sync";
$("#status").textContent = synced ? "Synced" : (wantSync ? "Connecting..." : "Local");
$("#sync-indicator").classList.toggle("visible", synced);
$("#progress-bar").classList.toggle("synced", synced);
$("#progress-bar").classList.toggle("local", !synced);
$("#progress-bar").classList.toggle("muted", audio.volume === 0);
$("#btn-mute").textContent = audio.volume === 0 ? "🔇" : "🔊";
$("#status-icon").textContent = isPlaying ? "⏸" : "▶";
// Show/hide controls based on permissions
const hasControl = canControl();
$("#status-icon").style.cursor = hasControl || !synced ? "pointer" : "default";
}
// Track last values to avoid unnecessary DOM updates
let lastProgressPct = -1;
let lastTimeCurrent = "";
let lastTimeTotal = "";
let lastBufferPct = -1;
let lastSpeedText = "";
// Update progress bar and buffer segments
setInterval(() => {
if (serverTrackDuration <= 0) return;
let t, dur;
if (synced) {
t = audio.paused ? getServerTime() : audio.currentTime;
dur = audio.duration || serverTrackDuration;
} else {
t = audio.paused ? localTimestamp : audio.currentTime;
dur = audio.duration || serverTrackDuration;
}
const pct = Math.min((t / dur) * 100, 100);
if (Math.abs(pct - lastProgressPct) > 0.1) {
$("#progress-bar").style.width = pct + "%";
lastProgressPct = pct;
}
const timeCurrent = fmt(t);
const timeTotal = fmt(dur);
if (timeCurrent !== lastTimeCurrent) {
$("#time-current").textContent = timeCurrent;
lastTimeCurrent = timeCurrent;
}
if (timeTotal !== lastTimeTotal) {
$("#time-total").textContent = timeTotal;
lastTimeTotal = timeTotal;
}
// Update buffer segments
const segments = $("#buffer-bar").children;
const segmentDur = dur / SEGMENTS;
let availableCount = 0;
for (let i = 0; i < SEGMENTS; i++) {
const segStart = i * segmentDur;
const segEnd = (i + 1) * segmentDur;
const trackCache = getTrackCache(currentFilename);
let available = trackCache.has(i); // Check our cache first
if (!available) {
for (let j = 0; j < audio.buffered.length; j++) {
const bufStart = audio.buffered.start(j);
const bufEnd = audio.buffered.end(j);
if (bufStart <= segStart && bufEnd >= segEnd) {
available = true;
break;
}
}
}
if (available) availableCount++;
const isAvailable = segments[i].classList.contains("available");
const isLoading = segments[i].classList.contains("loading");
const shouldBeLoading = !available && loadingSegments.has(i);
if (available !== isAvailable) segments[i].classList.toggle("available", available);
if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading);
}
// Update download speed display
const kbps = downloadSpeed > 0 ? downloadSpeed * 8 / 1000 : 0;
const bufferPct = Math.round(availableCount / SEGMENTS * 100);
let speedText = "";
if (kbps > 0) {
speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`;
}
if (bufferPct !== lastBufferPct || speedText !== lastSpeedText) {
$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`;
lastBufferPct = bufferPct;
lastSpeedText = speedText;
}
}, 250);
// Prefetch missing segments
let prefetching = false;
let bulkDownloadStarted = new Map(); // Track if bulk download started per filename
const FAST_THRESHOLD = 10 * 1024 * 1024; // 10 MB/s
async function fetchSegment(i, segStart, segEnd) {
const trackCache = getTrackCache(currentFilename);
if (loadingSegments.has(i) || trackCache.has(i)) return;
loadingSegments.add(i);
try {
const byteStart = Math.floor(segStart * audioBytesPerSecond);
const byteEnd = Math.floor(segEnd * audioBytesPerSecond);
const startTime = performance.now();
const res = await fetch("/api/tracks/" + encodeURIComponent(currentFilename), {
headers: { "Range": `bytes=${byteStart}-${byteEnd}` }
});
const data = await res.arrayBuffer();
const elapsed = (performance.now() - startTime) / 1000;
// Mark segment as cached
trackCache.add(i);
// Update audio bitrate estimate
const bytesReceived = data.byteLength;
const durationCovered = segEnd - segStart;
if (bytesReceived > 0 && durationCovered > 0) {
audioBytesPerSecond = Math.round(bytesReceived / durationCovered);
}
// Update download speed (rolling average of last 5 downloads)
if (elapsed > 0 && bytesReceived > 0) {
recentDownloads.push(bytesReceived / elapsed);
if (recentDownloads.length > 5) recentDownloads.shift();
downloadSpeed = recentDownloads.reduce((a, b) => a + b, 0) / recentDownloads.length;
}
} catch (e) {}
loadingSegments.delete(i);
}
// Background bulk download - runs independently
async function startBulkDownload() {
const filename = currentFilename;
if (!filename || bulkDownloadStarted.get(filename)) return;
const blobUrl = await downloadAndCacheTrack(filename);
// Switch to blob URL if still on this track
if (blobUrl && currentFilename === filename && audio.src && !audio.src.startsWith("blob:")) {
const currentTime = audio.currentTime;
const wasPlaying = !audio.paused;
audio.src = blobUrl;
audio.currentTime = currentTime;
if (wasPlaying) audio.play().catch(() => {});
}
}
async function prefetchSegments() {
if (prefetching || !currentFilename || !audio.src || serverTrackDuration <= 0) return;
prefetching = true;
const segmentDur = serverTrackDuration / SEGMENTS;
const missingSegments = [];
const trackCache = getTrackCache(currentFilename);
// Find all missing segments (not in audio buffer AND not in our cache)
for (let i = 0; i < SEGMENTS; i++) {
if (trackCache.has(i) || loadingSegments.has(i)) continue;
const segStart = i * segmentDur;
const segEnd = (i + 1) * segmentDur;
let available = false;
for (let j = 0; j < audio.buffered.length; j++) {
if (audio.buffered.start(j) <= segStart && audio.buffered.end(j) >= segEnd) {
available = true;
break;
}
}
if (!available) {
missingSegments.push({ i, segStart, segEnd });
}
}
if (missingSegments.length > 0) {
// Fast connection: also start bulk download in background
if (downloadSpeed >= FAST_THRESHOLD && !bulkDownloadStarted.get(currentFilename)) {
startBulkDownload(); // Fire and forget
}
// Always fetch segments one at a time for seek support
const s = missingSegments[0];
await fetchSegment(s.i, s.segStart, s.segEnd);
}
prefetching = false;
}
// Run prefetch loop
setInterval(() => {
if (currentFilename && audio.src) {
prefetchSegments();
}
}, 1000);
// Load streams and try to connect
async function loadStreams() {
try {
const res = await fetch("/api/streams");
const streams = await res.json();
if (streams.length === 0) {
$("#track-title").textContent = "No streams available";
return;
}
if (streams.length > 1) {
const sel = document.createElement("select");
for (const s of streams) {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = s.name;
sel.appendChild(opt);
}
sel.onchange = () => connectStream(sel.value);
$("#stream-select").appendChild(sel);
}
connectStream(streams[0].id);
} catch (e) {
$("#track-title").textContent = "Server unavailable";
$("#status").textContent = "Local (offline)";
synced = false;
updateUI();
}
}
function connectStream(id) {
if (ws) {
const oldWs = ws;
ws = null;
oldWs.onclose = null;
oldWs.close();
}
currentStreamId = id;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
ws.onclose = () => {
synced = false;
ws = null;
$("#sync-indicator").classList.add("disconnected");
updateUI();
// Auto-reconnect if user wants to be synced
if (wantSync) {
setTimeout(() => connectStream(id), 3000);
}
};
ws.onopen = () => {
synced = true;
$("#sync-indicator").classList.remove("disconnected");
updateUI();
};
}
function flashPermissionDenied() {
const row = $("#progress-row");
row.classList.remove("denied");
// Trigger reflow to restart animation
void row.offsetWidth;
row.classList.add("denied");
setTimeout(() => row.classList.remove("denied"), 500);
}
function renderPlaylist() {
const container = $("#playlist");
container.innerHTML = "";
if (playlist.length === 0) {
container.innerHTML = '<div class="empty">Playlist empty</div>';
return;
}
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) {
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: i })
});
if (res.status === 403) flashPermissionDenied();
} else {
currentIndex = i;
currentFilename = 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);
});
}
function renderLibrary() {
const container = $("#library");
container.innerHTML = "";
if (library.length === 0) {
container.innerHTML = '<div class="empty">No tracks discovered</div>';
return;
}
library.forEach((track) => {
const div = document.createElement("div");
div.className = "track";
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
div.innerHTML = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
div.onclick = async () => {
// In local mode, play directly from library
if (!synced) {
currentFilename = track.filename;
serverTrackDuration = track.duration;
$("#track-title").textContent = title;
loadingSegments.clear();
const cachedUrl = await loadTrackBlob(track.filename);
audio.src = cachedUrl || getTrackUrl(track.filename);
audio.currentTime = 0;
localTimestamp = 0;
audio.play();
}
};
container.appendChild(div);
});
}
async function loadLibrary() {
try {
const res = await fetch("/api/library");
library = await res.json();
renderLibrary();
} catch (e) {
console.warn("Failed to load library");
}
}
async function handleUpdate(data) {
if (!data.track) {
$("#track-title").textContent = "No tracks";
return;
}
$("#stream-name").textContent = data.streamName || "";
serverTimestamp = data.currentTimestamp;
serverTrackDuration = data.track.duration;
lastServerUpdate = Date.now();
const wasServerPaused = serverPaused;
serverPaused = data.paused ?? true;
// Update playlist if provided
if (data.playlist) {
playlist = data.playlist;
currentIndex = data.currentIndex ?? 0;
renderPlaylist();
} else if (data.currentIndex !== undefined && data.currentIndex !== currentIndex) {
currentIndex = data.currentIndex;
renderPlaylist();
}
// Cache track info for local mode
const isNewTrack = data.track.filename !== currentFilename;
if (isNewTrack) {
currentFilename = data.track.filename;
currentTitle = data.track.title;
$("#track-title").textContent = data.track.title;
loadingSegments.clear();
}
if (synced) {
if (!serverPaused) {
// Server is playing - ensure we're playing and synced
if (isNewTrack || !audio.src) {
// Try cache first
const cachedUrl = await loadTrackBlob(currentFilename);
audio.src = cachedUrl || getTrackUrl(currentFilename);
}
if (audio.paused) {
audio.currentTime = data.currentTimestamp;
audio.play().catch(() => {});
} else {
// Check drift
const drift = Math.abs(audio.currentTime - data.currentTimestamp);
if (drift >= 2) {
audio.currentTime = data.currentTimestamp;
}
}
} else if (!wasServerPaused && serverPaused) {
// Server just paused
audio.pause();
}
}
updateUI();
}
$("#btn-sync").onclick = () => {
wantSync = !wantSync;
if (wantSync) {
// User wants to sync - try to connect
if (currentStreamId) {
connectStream(currentStreamId);
}
} else {
// User wants local mode - disconnect
synced = false;
localTimestamp = audio.currentTime || getServerTime();
if (ws) {
const oldWs = ws;
ws = null;
oldWs.onclose = null;
oldWs.close();
}
}
updateUI();
};
function togglePlayback() {
if (!currentFilename) return;
if (synced) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: serverPaused ? "unpause" : "pause" }));
}
} else {
if (audio.paused) {
if (!audio.src) {
audio.src = getTrackUrl(currentFilename);
audio.currentTime = localTimestamp;
}
audio.play();
} else {
localTimestamp = audio.currentTime;
audio.pause();
}
updateUI();
}
}
$("#status-icon").onclick = togglePlayback;
async function jumpToTrack(index) {
if (playlist.length === 0) return;
const newIndex = (index + playlist.length) % playlist.length;
if (synced && currentStreamId) {
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: newIndex })
});
if (res.status === 403) flashPermissionDenied();
} else {
const track = playlist[newIndex];
currentIndex = newIndex;
currentFilename = track.filename;
serverTrackDuration = track.duration;
$("#track-title").textContent = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
loadingSegments.clear();
const cachedUrl = await loadTrackBlob(track.filename);
audio.src = cachedUrl || getTrackUrl(track.filename);
audio.currentTime = 0;
localTimestamp = 0;
audio.play();
renderPlaylist();
}
}
$("#btn-prev").onclick = () => jumpToTrack(currentIndex - 1);
$("#btn-next").onclick = () => jumpToTrack(currentIndex + 1);
$("#progress-container").onmousemove = (e) => {
if (serverTrackDuration <= 0) return;
const rect = $("#progress-container").getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const hoverTime = pct * serverTrackDuration;
const tooltip = $("#seek-tooltip");
tooltip.textContent = fmt(hoverTime);
tooltip.style.left = (pct * 100) + "%";
tooltip.style.display = "block";
};
$("#progress-container").onmouseleave = () => {
$("#seek-tooltip").style.display = "none";
};
$("#progress-container").onclick = (e) => {
const dur = synced ? serverTrackDuration : (audio.duration || serverTrackDuration);
if (!currentFilename || dur <= 0) return;
const rect = $("#progress-container").getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = pct * dur;
if (synced && currentStreamId) {
fetch("/api/streams/" + currentStreamId + "/seek", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timestamp: seekTime })
}).then(res => { if (res.status === 403) flashPermissionDenied(); });
} else {
if (!audio.src) {
audio.src = getTrackUrl(currentFilename);
}
audio.currentTime = seekTime;
localTimestamp = seekTime;
}
};
$("#btn-mute").onclick = () => {
if (audio.volume > 0) {
preMuteVolume = audio.volume;
audio.volume = 0;
$("#volume").value = 0;
} else {
audio.volume = preMuteVolume;
$("#volume").value = preMuteVolume;
}
localStorage.setItem(STORAGE_KEY, audio.volume);
updateUI();
};
$("#volume").oninput = (e) => {
audio.volume = e.target.value;
localStorage.setItem(STORAGE_KEY, e.target.value);
updateUI();
};
audio.onplay = () => { $("#progress-bar").classList.add("playing"); updateUI(); };
audio.onpause = () => { $("#progress-bar").classList.remove("playing"); updateUI(); };
// Track loading state from audio element's progress
audio.onprogress = () => {
if (serverTrackDuration <= 0) return;
const segmentDur = serverTrackDuration / SEGMENTS;
loadingSegments.clear();
for (let i = 0; i < SEGMENTS; i++) {
const segStart = i * segmentDur;
const segEnd = (i + 1) * segmentDur;
let fullyBuffered = false;
let partiallyBuffered = false;
for (let j = 0; j < audio.buffered.length; j++) {
const bufStart = audio.buffered.start(j);
const bufEnd = audio.buffered.end(j);
if (bufStart <= segStart && bufEnd >= segEnd) {
fullyBuffered = true;
break;
}
// Check if buffer is actively loading into this segment
if (bufEnd > segStart && bufEnd < segEnd && bufStart <= segStart) {
partiallyBuffered = true;
}
}
if (partiallyBuffered && !fullyBuffered) {
loadingSegments.add(i);
}
}
};
// Auth event handlers - tab switching
$("#tab-login").onclick = () => {
$("#tab-login").classList.add("active");
$("#tab-signup").classList.remove("active");
$("#login-fields").classList.remove("hidden");
$("#signup-fields").classList.add("hidden");
$("#auth-error").textContent = "";
$("#signup-error").textContent = "";
};
$("#tab-signup").onclick = () => {
$("#tab-signup").classList.add("active");
$("#tab-login").classList.remove("active");
$("#signup-fields").classList.remove("hidden");
$("#login-fields").classList.add("hidden");
$("#auth-error").textContent = "";
$("#signup-error").textContent = "";
};
$("#btn-login").onclick = async () => {
const username = $("#login-username").value.trim();
const password = $("#login-password").value;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) {
$("#auth-error").textContent = data.error || "Login failed";
return;
}
$("#login-username").value = "";
$("#login-password").value = "";
await loadCurrentUser();
loadStreams();
} catch (e) {
$("#auth-error").textContent = "Login failed";
}
};
$("#btn-signup").onclick = async () => {
const username = $("#signup-username").value.trim();
const password = $("#signup-password").value;
try {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) {
$("#signup-error").textContent = data.error || "Signup failed";
return;
}
$("#signup-username").value = "";
$("#signup-password").value = "";
await loadCurrentUser();
loadStreams();
} catch (e) {
$("#signup-error").textContent = "Signup failed";
}
};
$("#btn-guest").onclick = async () => {
// Fetch /api/auth/me which will create a guest session
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
currentUser = data.user;
if (currentUser && data.permissions) {
currentUser.permissions = data.permissions;
}
updateAuthUI();
if (currentUser) loadStreams();
} catch (e) {
$("#auth-error").textContent = "Failed to continue as guest";
}
};
$("#btn-logout").onclick = async () => {
const wasGuest = currentUser?.isGuest;
await fetch("/api/auth/logout", { method: "POST" });
currentUser = null;
if (wasGuest) {
// Guest clicking "Sign In" - show login panel
updateAuthUI();
} else {
// Regular user logging out - reload to get new guest session
updateAuthUI();
}
};
// Fetch server status
async function loadServerStatus() {
try {
const res = await fetch("/api/status");
serverStatus = await res.json();
console.log("Server status:", serverStatus);
} catch (e) {
console.warn("Failed to load server status");
serverStatus = null;
}
}
// Initialize storage and load cached tracks
async function initStorage() {
await TrackStorage.init();
const cached = await TrackStorage.list();
console.log(`TrackStorage: ${cached.length} tracks cached`);
}
// Initialize
Promise.all([initStorage(), loadServerStatus()]).then(() => {
loadLibrary();
loadCurrentUser().then(() => {
if (currentUser) loadStreams();
});
});
})();