229 lines
7.3 KiB
JavaScript
229 lines
7.3 KiB
JavaScript
// 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();
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Prev/next buttons
|
|
M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1);
|
|
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
|
|
|
|
// Playback mode button
|
|
const modeLabels = {
|
|
"once": "once",
|
|
"repeat-all": "repeat",
|
|
"repeat-one": "single",
|
|
"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);
|
|
}
|
|
}
|
|
};
|
|
})();
|