Refine naming and playlist resume
This commit is contained in:
+9
-1
@@ -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
@@ -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
@@ -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
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user