// MusicRoom - Controls module // Play, pause, seek, volume, prev/next track (function() { const M = window.MusicRoom; // Load saved volume const savedVolume = localStorage.getItem(M.STORAGE_KEY); if (savedVolume !== null) { M.audio.volume = parseFloat(savedVolume); M.$("#volume").value = savedVolume; } else { // No saved volume - sync audio to slider's default value M.audio.volume = parseFloat(M.$("#volume").value); } // Toggle play/pause function togglePlayback() { if (!M.currentTrackId) return; if (M.synced) { if (M.ws && M.ws.readyState === WebSocket.OPEN) { M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" })); } } else { if (M.audio.paused) { if (!M.audio.src) { M.audio.src = M.getTrackUrl(M.currentTrackId); M.audio.currentTime = M.localTimestamp; } M.audio.play(); } else { M.localTimestamp = M.audio.currentTime; M.audio.pause(); } M.updateUI(); } } // Jump to a specific track index async function jumpToTrack(index) { if (M.queue.length === 0) return; const newIndex = (index + M.queue.length) % M.queue.length; if (M.synced && M.currentChannelId) { const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: newIndex }) }); if (res.status === 403) M.flashPermissionDenied(); if (res.status === 400) console.warn("Jump failed: 400 - newIndex:", newIndex, "queue length:", M.queue.length); } else { const track = M.queue[newIndex]; const trackId = track.id || track.filename; M.currentIndex = newIndex; M.currentTrackId = trackId; M.serverTrackDuration = track.duration; M.setTrackTitle(track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown"); M.loadingSegments.clear(); const cachedUrl = await M.loadTrackBlob(trackId); M.audio.src = cachedUrl || M.getTrackUrl(trackId); M.audio.currentTime = 0; M.localTimestamp = 0; M.audio.play(); M.renderQueue(); setTimeout(() => M.scrollToCurrentTrack(), 100); } } // Sync toggle M.$("#btn-sync").onclick = () => { M.wantSync = !M.wantSync; if (M.wantSync) { // User wants to sync - try to connect if (M.currentChannelId) { M.connectChannel(M.currentChannelId); } } else { // User wants local mode - disconnect M.synced = false; M.localTimestamp = M.audio.currentTime || M.getServerTime(); if (M.ws) { const oldWs = M.ws; M.ws = null; oldWs.onclose = null; oldWs.close(); } } M.updateUI(); }; // Play/pause button M.$("#status-icon").onclick = togglePlayback; // Expose jumpToTrack for double-click handling M.jumpToTrack = jumpToTrack; // Prev/next buttons M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1); M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1); // Stream-only toggle const streamBtn = M.$("#btn-stream-only"); M.updateStreamOnlyButton = function() { streamBtn.classList.toggle("active", M.streamOnly); streamBtn.title = M.streamOnly ? "Stream-only mode (click to enable caching)" : "Click to enable stream-only mode (no caching)"; }; streamBtn.onclick = () => { M.setStreamOnly(!M.streamOnly); M.updateStreamOnlyButton(); }; M.updateStreamOnlyButton(); // Playback mode button const modeLabels = { "once": "loop(off)", "repeat-all": "loop(all)", "repeat-one": "loop(one)", "shuffle": "shuffle" }; const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"]; M.updateModeButton = function() { const btn = M.$("#btn-mode"); btn.textContent = modeLabels[M.playbackMode] || "repeat"; btn.title = `Playback: ${M.playbackMode}`; btn.className = "mode-" + M.playbackMode; }; M.$("#btn-mode").onclick = async () => { if (!M.synced || !M.currentChannelId) { // Local mode - just cycle through modes const currentIdx = modeOrder.indexOf(M.playbackMode); M.playbackMode = modeOrder[(currentIdx + 1) % modeOrder.length]; M.updateModeButton(); return; } // Synced mode - send to server const currentIdx = modeOrder.indexOf(M.playbackMode); const newMode = modeOrder[(currentIdx + 1) % modeOrder.length]; const res = await fetch("/api/channels/" + M.currentChannelId + "/mode", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: newMode }) }); if (res.status === 403) M.flashPermissionDenied(); }; M.updateModeButton(); // Progress bar seek tooltip M.$("#progress-container").onmousemove = (e) => { if (M.serverTrackDuration <= 0) return; const rect = M.$("#progress-container").getBoundingClientRect(); const pct = (e.clientX - rect.left) / rect.width; const hoverTime = pct * M.serverTrackDuration; const tooltip = M.$("#seek-tooltip"); tooltip.textContent = M.fmt(hoverTime); tooltip.style.left = (pct * 100) + "%"; tooltip.style.display = "block"; }; M.$("#progress-container").onmouseleave = () => { M.$("#seek-tooltip").style.display = "none"; }; // Progress bar seek M.$("#progress-container").onclick = (e) => { const dur = M.synced ? M.serverTrackDuration : (M.audio.duration || M.serverTrackDuration); if (!M.currentTrackId || dur <= 0) return; const rect = M.$("#progress-container").getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const seekTime = pct * dur; if (M.synced && M.currentChannelId) { fetch("/api/channels/" + M.currentChannelId + "/seek", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timestamp: seekTime }) }).then(res => { if (res.status === 403) M.flashPermissionDenied(); }); } else { if (!M.audio.src) { M.audio.src = M.getTrackUrl(M.currentTrackId); } M.audio.currentTime = seekTime; M.localTimestamp = seekTime; } }; // Mute toggle M.$("#btn-mute").onclick = () => { if (M.audio.volume > 0) { M.preMuteVolume = M.audio.volume; M.audio.volume = 0; M.$("#volume").value = 0; } else { M.audio.volume = M.preMuteVolume; M.$("#volume").value = M.preMuteVolume; } localStorage.setItem(M.STORAGE_KEY, M.audio.volume); M.updateUI(); }; // Volume slider M.$("#volume").oninput = (e) => { M.audio.volume = e.target.value; localStorage.setItem(M.STORAGE_KEY, e.target.value); M.updateUI(); }; // Audio element events M.audio.onplay = () => { M.$("#progress-bar").classList.add("playing"); M.updateUI(); }; M.audio.onpause = () => { M.$("#progress-bar").classList.remove("playing"); M.updateUI(); }; // Track loading state from audio element's progress M.audio.onprogress = () => { if (M.serverTrackDuration <= 0) return; const segmentDur = M.serverTrackDuration / M.SEGMENTS; M.loadingSegments.clear(); for (let i = 0; i < M.SEGMENTS; i++) { const segStart = i * segmentDur; const segEnd = (i + 1) * segmentDur; let fullyBuffered = false; let partiallyBuffered = false; for (let j = 0; j < M.audio.buffered.length; j++) { const bufStart = M.audio.buffered.start(j); const bufEnd = M.audio.buffered.end(j); if (bufStart <= segStart && bufEnd >= segEnd) { fullyBuffered = true; break; } // Check if buffer is actively loading into this segment if (bufEnd > segStart && bufEnd < segEnd && bufStart <= segStart) { partiallyBuffered = true; } } if (partiallyBuffered && !fullyBuffered) { M.loadingSegments.add(i); } } }; })();