var ws = null; var userCount = 0; var reconnectDelay = 1000; var maxReconnectDelay = 16000; var myNickname = null; var isHost = false; var myVotes = []; var skipVoted = false; var skipCooldownRemaining = 0; var skipCooldownTimer = null; var skipThresholdPct = 50; var hostToken = null; var settingsDebounceTimer = null; var myConnId = null; var urlParams = new URLSearchParams(window.location.search); hostToken = urlParams.get("host"); function formatDuration(seconds) { if (seconds == null) return ""; var m = Math.floor(seconds / 60); var s = Math.floor(seconds % 60); return m + ":" + String(s).padStart(2, "0"); } function showToast(message, tier) { tier = tier || "error"; var toast = document.getElementById("error-toast"); toast.className = ""; toast.classList.add(tier); toast.innerHTML = message + ''; toast.style.display = "flex"; var dismissBtn = toast.querySelector(".toast-dismiss"); if (dismissBtn) { dismissBtn.onclick = function (e) { e.stopPropagation(); toast.style.display = "none"; }; } clearTimeout(toast._timer); var durations = { success: 2000, error: 5000, info: 3000 }; toast._timer = setTimeout(function () { toast.style.display = "none"; }, durations[tier] || 5000); } function renderQueueItem(track, idx) { var hasVoted = myVotes.indexOf(track.id) !== -1; var li = document.createElement("li"); li.setAttribute("data-track-id", track.id); li.setAttribute("data-track-index", idx); li.setAttribute("draggable", "true"); var arrowsHtml = ""; if (isHost) { arrowsHtml = '' + '' + '' + ''; } li.innerHTML = '
' + '' + '0' + arrowsHtml + '
' + '
' + '' + track.title + '' + '
' + '
' + '\u2630' + '' + formatDuration(track.duration) + '' + '' + '
'; return li; } function renderFullState(data) { var payload = data.payload; var queueState = payload.queue_state; if (payload.my_votes) { myVotes = payload.my_votes; } if (queueState.current_track) { document.getElementById("current-title").textContent = queueState.current_track.title; document.getElementById("current-duration").textContent = formatDuration(queueState.current_track.duration); } else { document.getElementById("current-title").textContent = "\u2014"; document.getElementById("current-duration").textContent = ""; } var queueList = document.getElementById("queue-list"); queueList.innerHTML = ""; if (queueState.queue.length === 0) { var li = document.createElement("li"); li.className = "empty-state"; li.innerHTML = '\u266BNo songs in the queue yet.
Add a YouTube link to get started!'; queueList.appendChild(li); } else { queueState.queue.forEach(function (track, idx) { var li = renderQueueItem(track, idx); queueList.appendChild(li); }); if (payload.vote_counts) { for (var tid in payload.vote_counts) { var countEl = queueList.querySelector('.vote-count[data-track-id="' + tid + '"]'); if (countEl) { countEl.textContent = payload.vote_counts[tid]; } } } } if (isHost) { updateArrowVisibility(); } if (payload.conn_id && !myConnId) { myConnId = payload.conn_id; } if (payload.server_url) { document.getElementById("server-url").textContent = payload.server_url; } userCount = payload.connected_count || 0; document.getElementById("user-count").textContent = userCount + " online"; if (payload.nickname) { myNickname = payload.nickname; localStorage.setItem("jam-session-nickname", myNickname); renderNicknameDisplay(); } isHost = payload.is_host || false; if (payload.my_skip_vote !== undefined) { skipVoted = payload.my_skip_vote; } if (payload.skip_threshold !== undefined) { skipThresholdPct = payload.skip_threshold; } skipCooldownRemaining = payload.skip_cooldown_remaining || 0; if (queueState.current_track) { var ct = queueState.current_track; document.getElementById("progress-time").textContent = formatSeconds(payload.playback_time); var fill = document.getElementById("progress-fill"); var total = ct.duration; var pbTime = payload.playback_time || 0; if (fill && total && total > 0) { fill.style.width = Math.min(100, (pbTime / total) * 100) + "%"; } var elapsedEl = document.getElementById("time-elapsed"); var remainingEl = document.getElementById("time-remaining"); var totalEl = document.getElementById("time-total"); if (elapsedEl) elapsedEl.textContent = formatSeconds(pbTime); if (totalEl && total > 0) totalEl.textContent = "/ " + formatSeconds(total); if (remainingEl && total > 0) remainingEl.textContent = "-" + formatSeconds(Math.max(0, total - pbTime)); } renderNicknameDisplay(); if (isHost) { renderHostControls(true); } renderSkipVoteState(); if (payload.settings) { applySettingsToUI(payload.settings); } } function formatSeconds(s) { if (s == null) return "--:--"; var m = Math.floor(s / 60); var sec = Math.floor(s % 60); return m + ":" + String(sec).padStart(2, "0"); } function updateArrowVisibility() { if (!isHost) return; var items = document.querySelectorAll("#queue-list li[data-track-id]"); for (var i = 0; i < items.length; i++) { var upBtn = items[i].querySelector(".arrow-up"); var downBtn = items[i].querySelector(".arrow-down"); if (i === 0) { if (upBtn) upBtn.style.display = "none"; } else { if (upBtn) upBtn.style.display = ""; } if (i === items.length - 1) { if (downBtn) downBtn.style.display = "none"; } else { if (downBtn) downBtn.style.display = ""; } } } function renderNicknameDisplay() { var display = document.getElementById("nickname-display"); if (!display) return; display.textContent = myNickname || ""; if (isHost) { display.innerHTML = myNickname + ' (host)'; } } function applyQueueDelta(data) { var payload = data.payload; var action = payload.action; if (action === "added") { var track = payload.track; var queueList = document.getElementById("queue-list"); var placeholder = queueList.querySelector(".queue-placeholder"); if (placeholder) { var li = renderQueueItem(track, 0); placeholder.parentNode.replaceChild(li, placeholder); if (isHost) updateArrowVisibility(); var addBtn = document.querySelector("#add-form button"); if (addBtn) { addBtn.disabled = false; addBtn.textContent = "Add"; } showToast("Song added", "success"); } else { var emptyMsg = queueList.querySelector(".empty-state"); if (emptyMsg) emptyMsg.remove(); var li = renderQueueItem(track, 0); queueList.appendChild(li); if (isHost) updateArrowVisibility(); } } else if (action === "removed" || action === "reordered") { fetchQueueRest(); } } function updatePlayback(data) { var payload = data.payload; if (payload.current_track) { document.getElementById("current-title").textContent = payload.current_track.title; document.getElementById("current-duration").textContent = formatDuration(payload.current_track.duration); } else { document.getElementById("current-title").textContent = "\u2014"; document.getElementById("current-duration").textContent = ""; } if (isHost) { var pauseBtn = document.getElementById("host-pause"); var resumeBtn = document.getElementById("host-resume"); if (pauseBtn && resumeBtn) { if (payload.paused) { pauseBtn.style.display = "none"; resumeBtn.style.display = "inline-flex"; } else { pauseBtn.style.display = "inline-flex"; resumeBtn.style.display = "none"; } } } } function updateTime(data) { var t = data.payload.playback_time; var total = data.payload.total_duration; var fill = document.getElementById("progress-fill"); var elapsedEl = document.getElementById("time-elapsed"); var remainingEl = document.getElementById("time-remaining"); var totalEl = document.getElementById("time-total"); if (fill && total && total > 0) { var pct = Math.min(100, (t / total) * 100); fill.style.width = pct + "%"; } if (elapsedEl) elapsedEl.textContent = formatSeconds(t); if (totalEl && total && total > 0) totalEl.textContent = "/ " + formatSeconds(total); if (remainingEl && total && total > 0) remainingEl.textContent = "-" + formatSeconds(Math.max(0, total - t)); document.getElementById("progress-time").textContent = formatSeconds(t); } function handleWsMessage(event) { var data = JSON.parse(event.data); switch (data.type) { case "server.sync": renderFullState(data); break; case "server.queue_update": applyQueueDelta(data); break; case "server.playback_update": updatePlayback(data); break; case "server.time_update": updateTime(data); break; case "server.error": showToast(data.payload.error, "error"); var ql = document.getElementById("queue-list"); if (ql) { var ph = ql.querySelector(".queue-placeholder.resolving"); if (ph) { ph.className = "queue-placeholder error"; ph.innerHTML = data.payload.error + ''; var dismissBtn = ph.querySelector(".placeholder-dismiss"); if (dismissBtn) { dismissBtn.onclick = function () { ph.classList.add("removing"); setTimeout(function () { if (ph.parentNode) ph.parentNode.removeChild(ph); }, 300); }; } var addBtn = document.querySelector("#add-form button"); if (addBtn) { addBtn.disabled = false; addBtn.textContent = "Add"; } } } break; case "server.skip_update": var sp = data.payload; if (sp.cooldown_remaining > 0) { skipCooldownRemaining = sp.cooldown_remaining; } if (sp.cooldown_remaining === 0 && skipCooldownRemaining > 0) { skipCooldownRemaining = 0; stopSkipCooldownTimer(); } var badge = document.getElementById("skip-badge"); if (badge && skipCooldownRemaining <= 0) { badge.textContent = sp.vote_count + "/" + sp.threshold + " to skip"; } if (sp.triggered) { skipVoted = false; if (badge) { badge.classList.add("flash"); setTimeout(function () { badge.classList.remove("flash"); }, 200); } } renderSkipVoteState(); break; case "server.settings_update": applySettingsToUI(data.payload); break; case "server.votes_update": var voteCounts = data.payload.vote_counts; if (voteCounts) { for (var tid in voteCounts) { var countEl = document.querySelector('.vote-count[data-track-id="' + tid + '"]'); if (countEl) { var newCount = voteCounts[tid]; if (countEl.textContent !== String(newCount)) { countEl.textContent = newCount; countEl.classList.add("flash"); setTimeout(function () { countEl.classList.remove("flash"); }, 200); } } } } break; case "server.queue_sort": var order = data.payload.order; if (order && order.length > 0) { var queueList = document.getElementById("queue-list"); var itemMap = {}; var items = queueList.querySelectorAll("li[data-track-id]"); items.forEach(function (item) { itemMap[item.getAttribute("data-track-id")] = item; }); var fragment = document.createDocumentFragment(); order.forEach(function (tid) { var item = itemMap[tid]; if (item) { fragment.appendChild(item); } }); queueList.innerHTML = ""; queueList.appendChild(fragment); if (isHost) { updateArrowVisibility(); } } break; case "server.nicknames_update": if (data.payload.nicknames && myConnId) { if (myConnId in data.payload.nicknames) { myNickname = data.payload.nicknames[myConnId]; renderNicknameDisplay(); } } break; case "server.nickname_assigned": myNickname = data.payload.nickname; localStorage.setItem("jam-session-nickname", myNickname); renderNicknameDisplay(); break; case "server.nickname_error": var errorEl = document.getElementById("nickname-error"); var input = document.getElementById("nickname-input"); if (errorEl && input) { errorEl.textContent = data.payload.error; errorEl.style.display = "block"; input.classList.add("nickname-error-border"); input.style.opacity = "1"; input.focus(); } break; case "server.role": if (data.payload && data.payload.role === "host") { isHost = true; renderNicknameDisplay(); renderHostControls(true); } break; case "server.shutdown": showToast("Server has stopped. " + (data.payload.reason || "Goodbye!"), "error"); if (ws) { ws.onclose = function () {}; ws.close(); } break; case "server.session_saved": showToast("Session saved — " + data.payload.track_count + " tracks", "success"); break; case "server.session_loaded": showToast("Session loaded — " + data.payload.track_count + " tracks", "success"); break; case "server.sessions_list": renderSessionPicker(data.payload.sessions || []); break; } } async function fetchQueueRest() { try { var res = await fetch("/api/queue"); var data = await res.json(); renderFullState({ type: "server.sync", payload: { queue_state: data, playback_time: 0, connected_count: userCount, }, }); } catch (err) { console.error("Failed to fetch queue:", err); } } function enableNicknameEdit() { var display = document.getElementById("nickname-display"); var editIcon = document.getElementById("nickname-edit-icon"); var input = document.getElementById("nickname-input"); var errorEl = document.getElementById("nickname-error"); if (!editIcon || !input) return; editIcon.onclick = function () { display.style.display = "none"; editIcon.style.display = "none"; input.style.display = "inline-block"; input.value = myNickname || ""; input.classList.remove("nickname-error-border"); if (errorEl) errorEl.style.display = "none"; input.focus(); input.select(); }; function submitNickname() { var newName = input.value.trim(); if (!newName) { cancelEdit(); return; } if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "client.nickname_set", payload: { nickname: newName } })); cancelEdit(); } else { cancelEdit(); } } function cancelEdit() { input.style.display = "none"; input.style.opacity = "1"; display.style.display = "inline"; editIcon.style.display = "inline"; if (errorEl) errorEl.style.display = "none"; input.classList.remove("nickname-error-border"); } input.onkeydown = function (e) { if (e.key === "Enter") { e.preventDefault(); submitNickname(); } else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } if (errorEl) errorEl.style.display = "none"; input.classList.remove("nickname-error-border"); }; input.onblur = function () { if (input.style.display !== "none") { submitNickname(); } }; } function renderHostControls(showHost) { var container = document.getElementById("host-controls"); if (!container) return; if (showHost) { container.style.display = "flex"; } else { container.style.display = "none"; } var hostSettings = document.getElementById("host-settings"); if (hostSettings) { hostSettings.style.display = showHost ? "block" : "none"; } } function renderSkipVoteState() { var btn = document.getElementById("skip-vote-btn"); var badge = document.getElementById("skip-badge"); if (!btn || !badge) return; if (skipCooldownRemaining > 0) { btn.classList.add("disabled"); btn.classList.remove("voted"); btn.setAttribute("aria-label", "Skip voting disabled — cooldown active"); btn.style.color = "#888"; btn.style.cursor = "not-allowed"; badge.textContent = "Skip in " + skipCooldownRemaining + "s"; badge.style.color = "#888"; startSkipCooldownTimer(); } else { btn.classList.remove("disabled"); btn.style.cursor = "pointer"; if (skipVoted) { btn.classList.add("voted"); btn.style.color = "#e94560"; btn.setAttribute("aria-label", "Remove skip vote"); } else { btn.classList.remove("voted"); btn.style.color = "#666"; btn.setAttribute("aria-label", "Vote to skip"); } if (badge.textContent.indexOf("Skip in") === 0) { badge.textContent = "0/1 to skip"; } badge.style.color = "#888"; } } function applySettingsToUI(settings) { skipThresholdPct = settings.skip_threshold_pct || 50; var thresholdSlider = document.getElementById("skip-threshold-slider"); var thresholdNumber = document.getElementById("skip-threshold-number"); if (thresholdSlider && thresholdNumber) { thresholdSlider.value = skipThresholdPct; thresholdNumber.value = skipThresholdPct; } var cooldown = settings.skip_cooldown_seconds !== undefined ? settings.skip_cooldown_seconds : 3; var cooldownSlider = document.getElementById("skip-cooldown-slider"); var cooldownNumber = document.getElementById("skip-cooldown-number"); if (cooldownSlider && cooldownNumber) { cooldownSlider.value = cooldown; cooldownNumber.value = cooldown; } var autoPersist = settings.auto_persist === true; var toggle = document.getElementById("auto-persist-toggle"); var toggleLabel = document.getElementById("auto-persist-toggle-label"); if (toggle && toggleLabel) { toggle.checked = autoPersist; var labelText = toggleLabel.querySelector(".toggle-label-text"); if (labelText) { labelText.textContent = autoPersist ? "ON" : "OFF"; } if (autoPersist) { toggleLabel.classList.add("active"); } else { toggleLabel.classList.remove("active"); } } var sessionInput = document.getElementById("session-name-input"); if (sessionInput && !sessionInput.value) { var now = new Date(); var yyyy = now.getFullYear(); var mm = String(now.getMonth() + 1).padStart(2, "0"); var dd = String(now.getDate()).padStart(2, "0"); sessionInput.value = "jam-session-" + yyyy + "-" + mm + "-" + dd; } } function startSkipCooldownTimer() { stopSkipCooldownTimer(); skipCooldownTimer = setInterval(function () { skipCooldownRemaining = Math.max(0, skipCooldownRemaining - 1); var badge = document.getElementById("skip-badge"); if (badge) { badge.textContent = "Skip in " + skipCooldownRemaining + "s"; } if (skipCooldownRemaining <= 0) { stopSkipCooldownTimer(); var btn = document.getElementById("skip-vote-btn"); if (btn) { btn.classList.remove("disabled"); btn.style.color = "#666"; btn.style.cursor = "pointer"; btn.setAttribute("aria-label", "Vote to skip"); } if (badge) { badge.textContent = "0/" + skipThresholdPct + " to skip"; } } }, 1000); } function stopSkipCooldownTimer() { if (skipCooldownTimer) { clearInterval(skipCooldownTimer); skipCooldownTimer = null; } } function sendSettingsToServer() { if (!ws || ws.readyState !== WebSocket.OPEN || !isHost) return; var threshold = parseInt(document.getElementById("skip-threshold-slider").value, 10) || 50; var cooldown = parseInt(document.getElementById("skip-cooldown-slider").value, 10) || 3; var autoPersist = document.getElementById("auto-persist-toggle").checked; ws.send(JSON.stringify({ type: "client.host.settings", payload: { skip_threshold_pct: threshold, auto_persist: autoPersist, skip_cooldown_seconds: cooldown, } })); showToast("Settings updated", "info"); } function debounceSettingsSend() { if (settingsDebounceTimer) clearTimeout(settingsDebounceTimer); settingsDebounceTimer = setTimeout(sendSettingsToServer, 300); } function renderSessionPicker(sessions) { var picker = document.getElementById("session-picker"); if (!picker) return; if (!sessions || sessions.length === 0) { picker.innerHTML = '

No saved sessions found.

'; picker.style.display = "block"; return; } var html = ''; picker.innerHTML = html; picker.style.display = "block"; var items = picker.querySelectorAll(".session-picker-item"); for (var j = 0; j < items.length; j++) { items[j].addEventListener("click", function () { var sessionName = this.getAttribute("data-session-name"); if (!ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: "client.host.load_session", payload: { name: sessionName } })); picker.style.display = "none"; }); } } function connectWs() { var proto = location.protocol === "https:" ? "wss" : "ws"; var url = proto + "://" + location.host + "/ws"; ws = new WebSocket(url); ws.onopen = function () { console.log("WebSocket connected"); reconnectDelay = 1000; var panel = document.getElementById("disconnect-panel"); if (panel) panel.style.display = "none"; if (hostToken) { ws.send(JSON.stringify({ type: "client.host_claim", payload: { token: hostToken } })); } var storedNickname = localStorage.getItem("jam-session-nickname"); if (storedNickname && !myNickname) { ws.send(JSON.stringify({ type: "client.nickname_set", payload: { nickname: storedNickname } })); } }; ws.onmessage = handleWsMessage; ws.onclose = function () { console.log("WebSocket disconnected, reconnecting in " + reconnectDelay + "ms"); var panel = document.getElementById("disconnect-panel"); if (panel) panel.style.display = "block"; var queueList = document.getElementById("queue-list"); if (queueList) { var placeholders = queueList.querySelectorAll(".queue-placeholder.resolving"); for (var p = 0; p < placeholders.length; p++) { placeholders[p].className = "queue-placeholder error"; placeholders[p].innerHTML = 'Connection lost \u2014 try again' + ''; var db = placeholders[p].querySelector(".placeholder-dismiss"); if (db) { (function (ph) { db.onclick = function () { ph.classList.add("removing"); setTimeout(function () { if (ph.parentNode) ph.parentNode.removeChild(ph); }, 300); }; })(placeholders[p]); } } } document.getElementById("user-count").textContent = "offline"; setTimeout(connectWs, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay); }; ws.onerror = function (err) { console.error("WebSocket error:", err); var addBtn = document.querySelector("#add-form button"); if (addBtn) { addBtn.disabled = false; addBtn.textContent = "Add"; } ws.close(); }; document.getElementById("skip-vote-btn").addEventListener("click", function () { if (!ws || ws.readyState !== WebSocket.OPEN) return; var btn = this; var badge = document.getElementById("skip-badge"); var hasVoted = skipVoted; if (hasVoted) { btn.classList.remove("voted"); btn.style.color = "#666"; btn.setAttribute("aria-label", "Vote to skip"); btn.classList.add("pulse"); setTimeout(function () { btn.classList.remove("pulse"); }, 150); skipVoted = false; } else { btn.classList.add("voted"); btn.style.color = "#e94560"; btn.setAttribute("aria-label", "Remove skip vote"); btn.classList.add("pulse"); setTimeout(function () { btn.classList.remove("pulse"); }, 150); skipVoted = true; if (badge) { badge.classList.add("flash"); setTimeout(function () { badge.classList.remove("flash"); }, 200); } } var action = hasVoted ? "remove" : "cast"; var toastMsg = hasVoted ? "Skip vote removed" : "Skip vote cast"; showToast(toastMsg, "success"); ws.send(JSON.stringify({ type: "client.skip_vote", payload: { action: action } })); }); } document.getElementById("reconnect-btn").addEventListener("click", function () { reconnectDelay = 1000; if (ws) { try { ws.close(); } catch (e) {} } connectWs(); }); document.getElementById("add-form").addEventListener("submit", function (e) { e.preventDefault(); var input = document.getElementById("url-input"); var url = input.value.trim(); if (!url) return; var btn = e.target.querySelector("button"); btn.disabled = true; btn.textContent = "Adding..."; var queueList = document.getElementById("queue-list"); var emptyMsg = queueList.querySelector(".empty-state"); if (emptyMsg) emptyMsg.remove(); var placeholder = document.createElement("li"); placeholder.className = "queue-placeholder resolving"; placeholder.innerHTML = 'Resolving\u2026'; placeholder.setAttribute("data-placeholder", "true"); queueList.appendChild(placeholder); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "client.add", payload: { url: url } })); input.value = ""; } else { placeholder.className = "queue-placeholder error"; placeholder.innerHTML = "Couldn't resolve URL \u2014 check the link and try again" + ''; var dismissBtn = placeholder.querySelector(".placeholder-dismiss"); if (dismissBtn) { dismissBtn.onclick = function () { placeholder.classList.add("removing"); setTimeout(function () { if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder); }, 300); }; } showToast("Not connected. Trying to reconnect...", "error"); btn.disabled = false; btn.textContent = "Add"; connectWs(); } }); document.getElementById("host-pause").addEventListener("click", function () { if (ws && ws.readyState === WebSocket.OPEN && isHost) { ws.send(JSON.stringify({ type: "client.host.pause", payload: {} })); this.style.display = "none"; document.getElementById("host-resume").style.display = "inline-flex"; } }); document.getElementById("host-resume").addEventListener("click", function () { if (ws && ws.readyState === WebSocket.OPEN && isHost) { ws.send(JSON.stringify({ type: "client.host.resume", payload: {} })); this.style.display = "none"; document.getElementById("host-pause").style.display = "inline-flex"; } }); document.getElementById("host-skip").addEventListener("click", function () { if (ws && ws.readyState === WebSocket.OPEN && isHost) { ws.send(JSON.stringify({ type: "client.host.skip", payload: {} })); } }); document.getElementById("queue-list").addEventListener("click", function (e) { /* --- Arrow button reorder --- */ var arrowBtn = e.target.closest(".arrow-up, .arrow-down"); if (arrowBtn) { var li = arrowBtn.closest("li"); var isUp = arrowBtn.classList.contains("arrow-up"); var targetLi = isUp ? li.previousElementSibling : li.nextElementSibling; if (!targetLi) return; if (isUp) { li.parentNode.insertBefore(li, targetLi); } else { li.parentNode.insertBefore(targetLi, li); } var items = document.querySelectorAll("#queue-list li[data-track-id]"); var newOrder = []; for (var i = 0; i < items.length; i++) { newOrder.push(items[i].getAttribute("data-track-id")); } updateArrowVisibility(); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "client.reorder", payload: { order: newOrder } })); } showToast("Queue reordered", "info"); return; } /* --- end arrow reorder --- */ var voteBtn = e.target.closest(".vote-btn"); if (voteBtn) { var trackId = voteBtn.getAttribute("data-track-id"); var hasVoted = voteBtn.classList.contains("voted"); var countEl = document.querySelector('.vote-count[data-track-id="' + trackId + '"]'); var currentCount = parseInt(countEl.textContent, 10) || 0; if (hasVoted) { voteBtn.classList.remove("voted"); voteBtn.innerHTML = "\u25B3"; voteBtn.setAttribute("aria-label", "Vote"); if (countEl) { countEl.textContent = Math.max(0, currentCount - 1); countEl.classList.add("flash"); setTimeout(function () { countEl.classList.remove("flash"); }, 200); } myVotes = myVotes.filter(function (id) { return id !== trackId; }); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "client.unvote", payload: { track_id: trackId } })); } showToast("Vote removed", "success"); } else { voteBtn.classList.add("voted"); voteBtn.classList.add("pulse"); voteBtn.innerHTML = "\u25B2"; voteBtn.setAttribute("aria-label", "Remove vote"); setTimeout(function () { voteBtn.classList.remove("pulse"); }, 150); if (countEl) { countEl.textContent = currentCount + 1; countEl.classList.add("flash"); setTimeout(function () { countEl.classList.remove("flash"); }, 200); } myVotes.push(trackId); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "client.vote", payload: { track_id: trackId } })); } showToast("Vote cast", "success"); } return; } var btn = e.target.closest(".remove-btn"); if (!btn) return; var trackId = btn.getAttribute("data-track-id"); var li = btn.closest("li"); if (li) { li.classList.add("removing"); setTimeout(function () { if (li.parentNode) li.parentNode.removeChild(li); }, 300); } if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "client.remove", payload: { track_id: trackId } })); } showToast("Song removed", "info"); }); document.getElementById("refresh-btn").addEventListener("click", fetchQueueRest); var draggedTrackId = null; document.getElementById("queue-list").addEventListener("dragstart", function (e) { var li = e.target.closest("li"); if (!li || !li.getAttribute("draggable")) return; draggedTrackId = li.getAttribute("data-track-id"); li.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", draggedTrackId); }); document.getElementById("queue-list").addEventListener("dragend", function (e) { var li = e.target.closest("li"); if (li) li.classList.remove("dragging"); draggedTrackId = null; }); document.getElementById("queue-list").addEventListener("dragover", function (e) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; var li = e.target.closest("li"); if (!li || !li.getAttribute("draggable")) return; document.querySelectorAll("#queue-list li.drag-over").forEach(function (el) { el.classList.remove("drag-over"); }); li.classList.add("drag-over"); }); document.getElementById("queue-list").addEventListener("dragleave", function (e) { var li = e.target.closest("li"); if (li) li.classList.remove("drag-over"); }); document.getElementById("queue-list").addEventListener("drop", function (e) { e.preventDefault(); var dropLi = e.target.closest("li"); if (!dropLi || !draggedTrackId) return; dropLi.classList.remove("drag-over"); var items = document.querySelectorAll("#queue-list li[data-track-id]"); var newOrder = []; items.forEach(function (item) { var id = item.getAttribute("data-track-id"); if (item === dropLi && id !== draggedTrackId) { newOrder.push(draggedTrackId); newOrder.push(id); } else if (id === draggedTrackId) { // skip, was already pushed } else { newOrder.push(id); } }); // deduplicate var seen = {}; var clean = []; newOrder.forEach(function (id) { if (!seen[id]) { seen[id] = true; clean.push(id); } }); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "client.reorder", payload: { order: clean } })); } }); document.getElementById("server-url").textContent = location.protocol + "//" + location.host; connectWs(); enableNicknameEdit(); // === Host Settings Event Listeners === var thresholdSlider = document.getElementById("skip-threshold-slider"); var thresholdNumber = document.getElementById("skip-threshold-number"); if (thresholdSlider && thresholdNumber) { thresholdSlider.addEventListener("input", function () { thresholdNumber.value = this.value; debounceSettingsSend(); }); thresholdNumber.addEventListener("input", function () { thresholdSlider.value = this.value; debounceSettingsSend(); }); thresholdNumber.addEventListener("change", function () { var v = parseInt(this.value, 10); if (v < 10) this.value = 10; if (v > 100) this.value = 100; thresholdSlider.value = this.value; debounceSettingsSend(); }); } var cooldownSlider = document.getElementById("skip-cooldown-slider"); var cooldownNumber = document.getElementById("skip-cooldown-number"); if (cooldownSlider && cooldownNumber) { cooldownSlider.addEventListener("input", function () { cooldownNumber.value = this.value; debounceSettingsSend(); }); cooldownNumber.addEventListener("input", function () { cooldownSlider.value = this.value; debounceSettingsSend(); }); cooldownNumber.addEventListener("change", function () { var v = parseInt(this.value, 10); if (v < 0) this.value = 0; if (v > 10) this.value = 10; cooldownSlider.value = this.value; debounceSettingsSend(); }); } var autoPersistToggle = document.getElementById("auto-persist-toggle"); if (autoPersistToggle) { autoPersistToggle.addEventListener("change", function () { var label = document.getElementById("auto-persist-toggle-label"); var labelText = label ? label.querySelector(".toggle-label-text") : null; if (labelText) labelText.textContent = this.checked ? "ON" : "OFF"; if (label) { if (this.checked) { label.classList.add("active"); } else { label.classList.remove("active"); } } sendSettingsToServer(); }); } var settingsHeading = document.getElementById("host-settings-heading"); if (settingsHeading) { settingsHeading.addEventListener("click", function () { var body = document.getElementById("host-settings-body"); var toggleIcon = document.getElementById("host-settings-toggle"); if (body) { if (body.style.display === "none") { body.style.display = "block"; if (toggleIcon) toggleIcon.textContent = "\u2212"; } else { body.style.display = "none"; if (toggleIcon) toggleIcon.textContent = "\u002B"; } } }); } // === Session Management Event Listeners === document.getElementById("save-session-btn").addEventListener("click", function () { if (!ws || ws.readyState !== WebSocket.OPEN || !isHost) return; var name = document.getElementById("session-name-input").value.trim(); if (!name) { showToast("Please enter a session name", "error"); return; } ws.send(JSON.stringify({ type: "client.host.save_session", payload: { name: name } })); }); document.getElementById("load-session-btn").addEventListener("click", function () { if (!ws || ws.readyState !== WebSocket.OPEN || !isHost) return; var picker = document.getElementById("session-picker"); if (!picker) return; if (picker.style.display === "block") { picker.style.display = "none"; return; } ws.send(JSON.stringify({ type: "client.host.list_sessions", payload: {} })); }); document.getElementById("stop-server-btn").addEventListener("click", function () { if (!isHost) return; var modal = document.getElementById("stop-server-modal"); var body = document.getElementById("modal-body"); var nameInput = document.getElementById("modal-session-name"); var saveStopBtn = document.getElementById("modal-save-stop"); var queueItems = document.querySelectorAll("#queue-list li[data-track-id]"); var n = queueItems.length; if (n > 0) { body.textContent = "The queue has " + n + " tracks. Save session before stopping?"; if (nameInput) nameInput.style.display = "block"; if (saveStopBtn) { saveStopBtn.style.display = "inline-block"; saveStopBtn.textContent = "Save & Stop"; } var now = new Date(); var yyyy = now.getFullYear(); var mm = String(now.getMonth() + 1).padStart(2, "0"); var dd = String(now.getDate()).padStart(2, "0"); if (nameInput) nameInput.value = "jam-session-" + yyyy + "-" + mm + "-" + dd; } else { body.textContent = "Stop the server? The music will stop."; if (nameInput) nameInput.style.display = "none"; if (saveStopBtn) { saveStopBtn.style.display = "none"; } } modal.style.display = "flex"; }); document.getElementById("modal-save-stop").addEventListener("click", function () { if (!ws || ws.readyState !== WebSocket.OPEN) return; var name = document.getElementById("modal-session-name").value.trim(); ws.send(JSON.stringify({ type: "client.host.save_and_stop", payload: { name: name } })); document.getElementById("stop-server-modal").style.display = "none"; }); document.getElementById("modal-stop-no-save").addEventListener("click", function () { if (!ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: "client.host.stop_server", payload: {} })); document.getElementById("stop-server-modal").style.display = "none"; }); document.getElementById("modal-cancel").addEventListener("click", function () { document.getElementById("stop-server-modal").style.display = "none"; }); document.getElementById("stop-server-modal").addEventListener("click", function (e) { if (e.target === this) { this.style.display = "none"; } }); document.addEventListener("keydown", function (e) { if (e.key === "Escape") { var modal = document.getElementById("stop-server-modal"); if (modal && modal.style.display === "flex") { modal.style.display = "none"; } } });