';
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 = '
';
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 += '