Refine naming and playlist resume

This commit is contained in:
codex
2026-06-09 22:43:33 +02:00
parent b48aa88e32
commit a953a3a9b1
5 changed files with 122 additions and 58 deletions
+9 -1
View File
@@ -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))))
+91 -39
View File
@@ -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);
}
+3 -6
View File
@@ -13,10 +13,7 @@
<h1>kkkmeet</h1>
<p id="status">Enter your name to join the call.</p>
</div>
<form id="name-form" class="name-form">
<input id="name-input" autocomplete="name" maxlength="48" placeholder="Your name" required />
<button type="submit">Rename</button>
</form>
<button id="rename-button" type="button">Rename</button>
</header>
<section class="stage" aria-label="Video call">
@@ -123,7 +120,7 @@
<dialog id="name-dialog" class="name-dialog">
<form id="initial-name-form" class="initial-name-form">
<h2>Choose your name</h2>
<h2 id="name-dialog-title">Choose your name</h2>
<input
id="initial-name-input"
autocomplete="name"
@@ -131,7 +128,7 @@
placeholder="Your name"
required
/>
<button type="submit">Join call</button>
<button id="name-dialog-submit" type="submit">Join call</button>
</form>
</dialog>
+16 -11
View File
@@ -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%;
}
+3 -1
View File
@@ -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)) {