blastoise-archive/public/app.js

1128 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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 userPlaylists = [];
let selectedPlaylistId = 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);
// Toast notifications
function showToast(message, duration = 4000) {
const container = $("#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);
}
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;
} else {
// No saved volume - sync audio to slider's default value
audio.volume = parseFloat($("#volume").value);
}
// 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) => {
const data = JSON.parse(e.data);
// Handle library updates
if (data.type === "track_added") {
showToast(`"${data.track.title}" is now available`);
if (data.library) {
library = data.library;
renderLibrary();
if (selectedPlaylistId === "all") {
playlist = [...library];
renderPlaylist();
}
}
return;
}
if (data.type === "track_removed") {
showToast(`"${data.track.title}" was removed`);
if (data.library) {
library = data.library;
renderLibrary();
if (selectedPlaylistId === "all") {
playlist = [...library];
renderPlaylist();
}
}
return;
}
// Normal stream update
handleUpdate(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.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
const trackId = track.id || track.filename;
// Show remove button only for user playlists (not stream playlists)
const removeBtn = selectedPlaylistId ? `<span class="btn-remove" title="Remove">×</span>` : "";
div.innerHTML = `<span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${fmt(track.duration)}</span></span>`;
div.querySelector(".track-title").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 = trackId;
serverTrackDuration = track.duration;
$("#track-title").textContent = title;
loadingSegments.clear();
const cachedUrl = await loadTrackBlob(trackId);
audio.src = cachedUrl || getTrackUrl(trackId);
audio.currentTime = 0;
localTimestamp = 0;
audio.play();
renderPlaylist();
}
};
const removeEl = div.querySelector(".btn-remove");
if (removeEl) {
removeEl.onclick = (e) => {
e.stopPropagation();
removeTrackFromCurrentPlaylist(i);
};
}
container.appendChild(div);
});
}
function renderLibrary() {
const container = $("#library");
container.innerHTML = "";
if (library.length === 0) {
container.innerHTML = '<div class="empty">No tracks discovered</div>';
return;
}
const canAdd = selectedPlaylistId && selectedPlaylistId !== "all";
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">${fmt(track.duration)}</span></span>`;
div.querySelector(".track-title").onclick = async () => {
// Play directly from library (uses track ID)
if (!synced) {
currentFilename = track.id; // Use track ID instead of filename
serverTrackDuration = track.duration;
$("#track-title").textContent = title;
loadingSegments.clear();
const cachedUrl = await loadTrackBlob(track.id);
audio.src = cachedUrl || getTrackUrl(track.id);
audio.currentTime = 0;
localTimestamp = 0;
audio.play();
}
};
const addBtnEl = div.querySelector(".btn-add");
if (addBtnEl) {
addBtnEl.onclick = (e) => {
e.stopPropagation();
addTrackToCurrentPlaylist(track.id);
};
}
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 loadPlaylists() {
try {
const res = await fetch("/api/playlists");
userPlaylists = await res.json();
renderPlaylistSelector();
} catch (e) {
console.warn("Failed to load playlists");
}
}
function renderPlaylistSelector() {
const list = $("#playlists-list");
if (!list) return;
list.innerHTML = "";
// Add "All Tracks" as default option
const allItem = document.createElement("div");
allItem.className = "playlist-item" + (selectedPlaylistId === "all" ? " active" : "");
allItem.textContent = "All Tracks";
allItem.onclick = () => loadSelectedPlaylist("all");
list.appendChild(allItem);
// Add user playlists
for (const pl of userPlaylists) {
const item = document.createElement("div");
item.className = "playlist-item" + (pl.id === selectedPlaylistId ? " active" : "");
item.textContent = pl.name;
item.onclick = () => loadSelectedPlaylist(pl.id);
list.appendChild(item);
}
// Update playlist panel title
const titleEl = $("#playlist-title");
if (selectedPlaylistId === "all") {
titleEl.textContent = "Playlist - All Tracks";
} else if (selectedPlaylistId) {
const pl = userPlaylists.find(p => p.id === selectedPlaylistId);
titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist";
} else {
titleEl.textContent = "Playlist";
}
}
async function loadSelectedPlaylist(playlistId) {
if (!playlistId) {
playlist = [];
selectedPlaylistId = null;
renderPlaylist();
renderPlaylistSelector();
renderLibrary();
return;
}
if (playlistId === "all") {
// Use library as playlist
playlist = [...library];
selectedPlaylistId = "all";
currentIndex = 0;
renderPlaylist();
renderPlaylistSelector();
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();
playlist = data.tracks || [];
selectedPlaylistId = playlistId;
currentIndex = 0;
renderPlaylist();
renderPlaylistSelector();
renderLibrary();
} catch (e) {
console.warn("Failed to load playlist:", e);
}
}
async function createNewPlaylist() {
const header = $("#playlists-panel .panel-header");
const btn = $("#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 loadPlaylists();
selectedPlaylistId = pl.id;
renderPlaylistSelector();
await 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);
};
}
async function addTrackToCurrentPlaylist(trackId) {
if (!selectedPlaylistId || selectedPlaylistId === "all") {
alert("Select or create a playlist first");
return;
}
try {
const res = await fetch("/api/playlists/" + 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 loadSelectedPlaylist(selectedPlaylistId);
} catch (e) {
console.warn("Failed to add track:", e);
}
}
async function removeTrackFromCurrentPlaylist(position) {
if (!selectedPlaylistId || selectedPlaylistId === "all") return;
try {
const res = await fetch("/api/playlists/" + selectedPlaylistId + "/tracks/" + position, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to remove track");
await loadSelectedPlaylist(selectedPlaylistId);
} catch (e) {
console.warn("Failed to remove track:", e);
}
}
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();
}
};
// Playlist selector handlers
$("#btn-new-playlist").onclick = () => {
if (!currentUser || currentUser.isGuest) {
alert("Sign in to create playlists");
return;
}
createNewPlaylist();
};
// 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(async () => {
await loadLibrary();
loadSelectedPlaylist("all"); // Default to All Tracks
await loadCurrentUser();
if (currentUser) {
loadStreams();
loadPlaylists();
}
});
})();