Something went wrong. Try again.
A local, collaborative music player (main-branch-only mirror of forge.ejuarezg.com/ejuarezg/jam-session)
Something went wrong. Try again.
40 kB · 1262 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263var 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 + '<button class="toast-dismiss">\u2715</button>'; 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 = '<span class="arrow-btns">' + '<button class="arrow-up" data-track-id="' + track.id + '" aria-label="Move up">\u2191</button>' + '<button class="arrow-down" data-track-id="' + track.id + '" aria-label="Move down">\u2193</button>' + '</span>'; }
li.innerHTML = '<div class="queue-actions-left">' + '<button class="vote-btn' + (hasVoted ? ' voted' : '') + '" data-track-id="' + track.id + '" aria-label="' + (hasVoted ? 'Remove vote for ' + track.title : 'Vote for ' + track.title) + '">' + (hasVoted ? '\u25B2' : '\u25B3') + '</button>' + '<span class="vote-count" data-track-id="' + track.id + '">0</span>' + arrowsHtml + '</div>' + '<div class="queue-item-main">' + '<span class="track-title">' + track.title + '</span>' + '</div>' + '<div class="queue-actions-right">' + '<span class="drag-handle">\u2630</span>' + '<span class="track-duration">' + formatDuration(track.duration) + '</span>' + '<button class="remove-btn" data-track-id="' + track.id + '">\u2715</button>' + '</div>'; 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 = '<span class="empty-state-icon">\u266B</span>No songs in the queue yet.<br>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 + ' <span style="color:#e94560;font-size:14px;font-weight:400;">(host)</span>'; }}
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 + '<button class="placeholder-dismiss">\u2715</button>'; 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 = '<p class="picker-empty">No saved sessions found.</p>'; picker.style.display = "block"; return; }
var html = '<ul class="session-picker-list">'; for (var i = 0; i < sessions.length; i++) { var s = sessions[i]; var savedLabel = s.saved_at ? s.saved_at.substring(0, 10) : "unknown date"; html += '<li class="session-picker-item" data-session-name="' + s.name + '">' + '<span class="session-picker-name">' + s.name + '</span>' + '<span class="session-picker-meta">' + savedLabel + ' — ' + s.track_count + ' tracks</span>' + '</li>'; } html += '</ul>'; 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' + '<button class="placeholder-dismiss">\u2715</button>'; 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 = '<span>Resolving\u2026</span>'; 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" + '<button class="placeholder-dismiss">\u2715</button>'; 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"; } }});