From a953a3a9b18b2c24e124775cc08708c31fa83e54 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 9 Jun 2026 22:43:33 +0200 Subject: [PATCH] Refine naming and playlist resume --- kkkmeet-server.el | 10 +++- public/app.js | 130 ++++++++++++++++++++++++++++++++-------------- public/index.html | 9 ++-- public/styles.css | 27 ++++++---- server.js | 4 +- 5 files changed, 122 insertions(+), 58 deletions(-) diff --git a/kkkmeet-server.el b/kkkmeet-server.el index 67b4036..959e784 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -80,6 +80,10 @@ "Guest" (substring normalized 0 (min 48 (length normalized)))))) +(defun kkkmeet-server--nonnegative-number (value) + "Return VALUE as a nonnegative number, or zero." + (max 0 (if (numberp value) value 0))) + (defun kkkmeet-server--normalize-playlist (playlist) "Return a sanitized YouTube PLAYLIST vector." (let ((seen (make-hash-table :test 'equal)) @@ -90,6 +94,8 @@ (t nil))) (let* ((video-id (and (listp item) (alist-get 'videoId item))) (title (and (listp item) (alist-get 'title item))) + (position (and (listp item) (alist-get 'position item))) + (duration (and (listp item) (alist-get 'duration item))) (safe-video-id (and video-id (format "%s" video-id))) (safe-title (string-trim (format "%s" (or title ""))))) (when (and safe-video-id @@ -100,7 +106,9 @@ (push `((videoId . ,safe-video-id) (title . ,(if (string-empty-p safe-title) (format "YouTube video %s" safe-video-id) - safe-title))) + safe-title)) + (position . ,(kkkmeet-server--nonnegative-number position)) + (duration . ,(kkkmeet-server--nonnegative-number duration))) items)))) (vconcat (nreverse items)))) diff --git a/public/app.js b/public/app.js index ff1cdbc..282dbe0 100644 --- a/public/app.js +++ b/public/app.js @@ -3,9 +3,10 @@ const localNameLabel = document.querySelector("#local-name"); const stage = document.querySelector(".stage"); const remoteGrid = document.querySelector("#remote-grid"); const remoteTemplate = document.querySelector("#remote-template"); -const nameForm = document.querySelector("#name-form"); -const nameInput = document.querySelector("#name-input"); +const renameButton = document.querySelector("#rename-button"); const nameDialog = document.querySelector("#name-dialog"); +const nameDialogTitle = document.querySelector("#name-dialog-title"); +const nameDialogSubmit = document.querySelector("#name-dialog-submit"); const initialNameForm = document.querySelector("#initial-name-form"); const initialNameInput = document.querySelector("#initial-name-input"); const statusText = document.querySelector("#status"); @@ -89,10 +90,20 @@ function normalizeName(name) { function setDisplayName(nextName) { displayName = normalizeName(nextName); localStorage.setItem(nameStorageKey, displayName); - nameInput.value = displayName; localNameLabel.textContent = displayName ? `${displayName} (you)` : "You"; } +function openNameDialog(mode) { + const renaming = mode === "rename"; + nameDialogTitle.textContent = renaming ? "Rename yourself" : "Choose your name"; + nameDialogSubmit.textContent = renaming ? "Save name" : "Join call"; + initialNameInput.value = renaming ? displayName : ""; + nameDialog.dataset.mode = mode; + nameDialog.showModal(); + initialNameInput.focus(); + initialNameInput.select(); +} + function labelForPeer(peer) { return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim(); } @@ -140,7 +151,9 @@ function normalizedPlaylist(items) { return items .map((item) => ({ videoId: String(item.videoId || "").trim(), - title: String(item.title || "").trim() + title: String(item.title || "").trim(), + position: Math.max(0, Number(item.position) || 0), + duration: Math.max(0, Number(item.duration) || 0) })) .filter((item) => { if (!/^[a-zA-Z0-9_-]{11}$/.test(item.videoId) || seen.has(item.videoId)) { @@ -152,6 +165,30 @@ function normalizedPlaylist(items) { }); } +function currentYoutubeSnapshot(fallbackPosition = 0) { + if (!youtubeReady || !youtubePlayer || !currentYoutubeVideoId) { + return { position: Math.max(0, Number(fallbackPosition) || 0), duration: 0 }; + } + + const duration = Math.max(0, Number(youtubePlayer.getDuration?.()) || 0); + const currentTime = Math.max(0, Number(youtubePlayer.getCurrentTime?.()) || 0); + const position = duration > 0 && duration - currentTime <= 2 ? 0 : currentTime; + return { position, duration }; +} + +function playlistWithActivePosition(fallbackPosition = 0) { + if (!currentYoutubeVideoId) return normalizedPlaylist(watchPlaylist); + + const snapshot = currentYoutubeSnapshot(fallbackPosition); + return normalizedPlaylist(watchPlaylist).map((item) => ( + item.videoId === currentYoutubeVideoId ? { ...item, ...snapshot } : item + )); +} + +function savedPositionForVideo(videoId) { + return Math.max(0, Number(watchPlaylist.find((item) => item.videoId === videoId)?.position) || 0); +} + function renderPlaylist() { playlistList.replaceChildren(); @@ -223,6 +260,8 @@ async function ensureYoutubePlayer() { async function sendWatchState({ videoId = currentYoutubeVideoId, position = 0, playing = false }) { if (!peerId) return; + const playlist = playlistWithActivePosition(position); + await fetch(`/watch/${encodeURIComponent(roomId)}`, { method: "POST", headers: { "content-type": "application/json" }, @@ -231,7 +270,7 @@ async function sendWatchState({ videoId = currentYoutubeVideoId, position = 0, p from: peerId, name: displayName, videoId, - playlist: watchPlaylist, + playlist, position, playing, updatedAt: Date.now() @@ -241,20 +280,31 @@ async function sendWatchState({ videoId = currentYoutubeVideoId, position = 0, p function currentWatchState(playing) { if (!youtubeReady || !currentYoutubeVideoId) return null; + const snapshot = currentYoutubeSnapshot(); + watchPlaylist = playlistWithActivePosition(snapshot.position); return { videoId: currentYoutubeVideoId, playlist: watchPlaylist, - position: youtubePlayer.getCurrentTime?.() || 0, + position: snapshot.position, playing }; } function handleYoutubeStateChange(event) { if (Date.now() < applyingWatchStateUntil) return; - if (![YT.PlayerState.PLAYING, YT.PlayerState.PAUSED].includes(event.data)) return; + if (![YT.PlayerState.PLAYING, YT.PlayerState.PAUSED, YT.PlayerState.ENDED].includes(event.data)) { + return; + } const state = currentWatchState(event.data === YT.PlayerState.PLAYING); if (state) { + if (event.data === YT.PlayerState.ENDED) { + state.position = 0; + state.playing = false; + watchPlaylist = playlistWithActivePosition(0); + state.playlist = watchPlaylist; + renderPlaylist(); + } sendWatchState(state).catch((error) => { console.error(error); setStatus("Could not sync YouTube playback."); @@ -265,6 +315,12 @@ function handleYoutubeStateChange(event) { async function applyWatchState(state) { watchPlaylist = normalizedPlaylist(state?.playlist); currentYoutubeVideoId = state?.videoId || ""; + if (currentYoutubeVideoId) { + const position = Math.max(0, Number(state.position) || 0); + watchPlaylist = watchPlaylist.map((item) => ( + item.videoId === currentYoutubeVideoId ? { ...item, position } : item + )); + } renderPlaylist(); if (!currentYoutubeVideoId) { @@ -557,7 +613,7 @@ function closePeer(remotePeerId) { async function joinCall() { if (!displayName) { setStatus("Enter your name to join the call."); - nameInput.focus(); + openNameDialog("join"); return; } @@ -602,47 +658,52 @@ async function addYoutubeVideo(value) { let item = watchPlaylist.find((entry) => entry.videoId === videoId); if (!item) { - item = { videoId, title: await fetchYoutubeTitle(videoId) }; + item = { videoId, title: await fetchYoutubeTitle(videoId), position: 0, duration: 0 }; watchPlaylist = [...watchPlaylist, item]; } + const playlist = playlistWithActivePosition(); youtubeInput.value = ""; await applyWatchState({ videoId, - playlist: watchPlaylist, - position: 0, + playlist, + position: savedPositionForVideo(videoId), playing: true, updatedAt: Date.now() }); - await sendWatchState({ videoId, position: 0, playing: true }); + await sendWatchState({ videoId, position: savedPositionForVideo(videoId), playing: true }); } async function switchYoutubeVideo(videoId) { if (!watchPlaylist.some((item) => item.videoId === videoId)) return; + watchPlaylist = playlistWithActivePosition(); + const position = savedPositionForVideo(videoId); await applyWatchState({ videoId, playlist: watchPlaylist, - position: 0, + position, playing: true, updatedAt: Date.now() }); - await sendWatchState({ videoId, position: 0, playing: true }); + await sendWatchState({ videoId, position, playing: true }); } async function deleteYoutubeVideo(videoId) { const wasActive = currentYoutubeVideoId === videoId; + watchPlaylist = playlistWithActivePosition(); watchPlaylist = watchPlaylist.filter((item) => item.videoId !== videoId); const nextVideoId = wasActive ? watchPlaylist[0]?.videoId || "" : currentYoutubeVideoId; + const position = nextVideoId ? savedPositionForVideo(nextVideoId) : 0; await applyWatchState({ videoId: nextVideoId, playlist: watchPlaylist, - position: 0, + position, playing: Boolean(nextVideoId), updatedAt: Date.now() }); - await sendWatchState({ videoId: nextVideoId, position: 0, playing: Boolean(nextVideoId) }); + await sendWatchState({ videoId: nextVideoId, position, playing: Boolean(nextVideoId) }); } async function clearYoutubePlaylist() { @@ -657,37 +718,29 @@ async function clearYoutubePlaylist() { await sendWatchState({ videoId: "", position: 0, playing: false }); } -nameForm.addEventListener("submit", (event) => { - event.preventDefault(); - setDisplayName(nameInput.value); - joinCall().catch((error) => { - console.error(error); - setStatus("Could not start camera or join the call."); - }); -}); - initialNameForm.addEventListener("submit", (event) => { event.preventDefault(); setDisplayName(initialNameInput.value); nameDialog.close(); - joinCall().catch((error) => { - console.error(error); - setStatus("Could not start camera or join the call."); - }); + if (events) { + joinCall().catch((error) => { + console.error(error); + setStatus("Could not rejoin with the updated name."); + }); + } else { + joinCall().catch((error) => { + console.error(error); + setStatus("Could not start camera or join the call."); + }); + } }); nameDialog.addEventListener("cancel", (event) => { event.preventDefault(); }); -nameInput.addEventListener("change", () => { - setDisplayName(nameInput.value); - if (events) { - joinCall().catch((error) => { - console.error(error); - setStatus("Could not rejoin with the updated name."); - }); - } +renameButton.addEventListener("click", () => { + openNameDialog("rename"); }); cameraButton.addEventListener("click", () => { @@ -783,8 +836,7 @@ if (savedName) { }); } else { setStatus("Choose your name to join the call."); - nameDialog.showModal(); - initialNameInput.focus(); + openNameDialog("join"); refreshDeviceLists().catch(console.error); } diff --git a/public/index.html b/public/index.html index e540bf6..4e31f9b 100644 --- a/public/index.html +++ b/public/index.html @@ -13,10 +13,7 @@

kkkmeet

Enter your name to join the call.

-
- - -
+
@@ -123,7 +120,7 @@
-

Choose your name

+

Choose your name

- +
diff --git a/public/styles.css b/public/styles.css index c1290cd..8489abd 100644 --- a/public/styles.css +++ b/public/styles.css @@ -62,9 +62,8 @@ h1 { font-size: 14px; } -.name-form { - display: flex; - gap: 10px; +#rename-button { + min-width: 92px; } .name-dialog { @@ -421,10 +420,13 @@ button[aria-pressed="false"] { } .device-row { + position: relative; display: grid; grid-template-columns: 40px minmax(150px, 240px); - align-items: end; - gap: 8px; + grid-template-rows: 14px 40px; + align-items: center; + column-gap: 8px; + row-gap: 3px; min-width: 0; } @@ -450,17 +452,24 @@ button[aria-pressed="false"] { } .device-row label { - display: grid; - gap: 6px; + display: contents; min-width: 0; } .device-row label span { + position: absolute; + left: 0; + top: 0; color: #aeb7c3; font-size: 12px; font-weight: 650; } +.device-row label select { + grid-column: 2; + grid-row: 2; +} + .control-actions { display: flex; justify-content: center; @@ -543,10 +552,6 @@ button[aria-pressed="false"] { padding: 14px; } - .name-form { - width: 100%; - } - input { width: 100%; } diff --git a/server.js b/server.js index 32bb3e6..7dcc06b 100644 --- a/server.js +++ b/server.js @@ -45,7 +45,9 @@ function normalizePlaylist(playlist) { return playlist .map((item) => ({ videoId: String(item?.videoId || "").trim(), - title: String(item?.title || "").trim() + title: String(item?.title || "").trim(), + position: Math.max(0, Number(item?.position) || 0), + duration: Math.max(0, Number(item?.duration) || 0) })) .filter((item) => { if (!/^[a-zA-Z0-9_-]{11}$/.test(item.videoId) || seen.has(item.videoId)) {