874 lines
28 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
})();
|