diff --git a/kkkmeet-server.el b/kkkmeet-server.el index 7708831..67b4036 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -8,6 +8,7 @@ (require 'cl-lib) (require 'json) +(require 'seq) (require 'subr-x) (require 'url-util) @@ -79,6 +80,30 @@ "Guest" (substring normalized 0 (min 48 (length normalized)))))) +(defun kkkmeet-server--normalize-playlist (playlist) + "Return a sanitized YouTube PLAYLIST vector." + (let ((seen (make-hash-table :test 'equal)) + items) + (dolist (item (cond + ((vectorp playlist) (append playlist nil)) + ((listp playlist) playlist) + (t nil))) + (let* ((video-id (and (listp item) (alist-get 'videoId item))) + (title (and (listp item) (alist-get 'title item))) + (safe-video-id (and video-id (format "%s" video-id))) + (safe-title (string-trim (format "%s" (or title ""))))) + (when (and safe-video-id + (string-match-p "\\`[A-Za-z0-9_-]\\{11\\}\\'" safe-video-id) + (not (gethash safe-video-id seen)) + (< (length items) 50)) + (puthash safe-video-id t seen) + (push `((videoId . ,safe-video-id) + (title . ,(if (string-empty-p safe-title) + (format "YouTube video %s" safe-video-id) + safe-title))) + items)))) + (vconcat (nreverse items)))) + (defun kkkmeet-server--json (value) "Encode VALUE as JSON." (let ((json-array-type 'list) @@ -271,25 +296,33 @@ When KEEP-OPEN is nil, close PROCESS after writing." (payload (json-read-from-string body)) (from (alist-get 'from payload)) (type (alist-get 'type payload)) - (video-id (alist-get 'videoId payload)) + (video-id (format "%s" (or (alist-get 'videoId payload) ""))) + (playlist (kkkmeet-server--normalize-playlist (alist-get 'playlist payload))) (safe-room-id (if (string-empty-p room-id) kkkmeet-server--default-room-id room-id))) - (if (and from (equal type "watch-state") video-id) - (let ((watch-state `((type . "watch-state") - (from . ,from) - (name . ,(kkkmeet-server--normalize-name - (alist-get 'name payload))) - (videoId . ,video-id) - (position . ,(or (alist-get 'position payload) 0)) - (playing . ,(if (alist-get 'playing payload) t :false)) - (updatedAt . ,(or (alist-get 'updatedAt payload) - (floor (* 1000 (float-time)))))))) + (if (and from (equal type "watch-state")) + (let* ((active-video-id (if (seq-some + (lambda (item) + (equal video-id (alist-get 'videoId item))) + (append playlist nil)) + video-id + "")) + (watch-state `((type . "watch-state") + (from . ,from) + (name . ,(kkkmeet-server--normalize-name + (alist-get 'name payload))) + (videoId . ,active-video-id) + (playlist . ,playlist) + (position . ,(or (alist-get 'position payload) 0)) + (playing . ,(if (alist-get 'playing payload) t :false)) + (updatedAt . ,(or (alist-get 'updatedAt payload) + (floor (* 1000 (float-time)))))))) (puthash safe-room-id watch-state kkkmeet-server--watch-states) (kkkmeet-server--broadcast safe-room-id from watch-state) (kkkmeet-server--send-json process 202 '((ok . t)))) (kkkmeet-server--send-json - process 400 '((error . "Watch messages require from, type=watch-state, and videoId."))))) + process 400 '((error . "Watch messages require from and type=watch-state."))))) (error (kkkmeet-server--send-json process 400 `((error . ,(error-message-string error))))))) diff --git a/public/app.js b/public/app.js index f91babd..8d41509 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,6 @@ const localVideo = document.querySelector("#local-video"); 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"); @@ -8,6 +9,10 @@ const statusText = document.querySelector("#status"); const youtubeForm = document.querySelector("#youtube-form"); const youtubeInput = document.querySelector("#youtube-input"); const youtubeShell = document.querySelector("#youtube-shell"); +const watchPanel = document.querySelector(".watch-panel"); +const watchResizeHandle = document.querySelector("#watch-resize-handle"); +const playlistList = document.querySelector("#playlist-list"); +const clearPlaylistButton = document.querySelector("#clear-playlist-button"); const cameraSelect = document.querySelector("#camera-select"); const micSelect = document.querySelector("#mic-select"); const cameraButton = document.querySelector("#camera-button"); @@ -18,6 +23,7 @@ const roomId = "main"; const nameStorageKey = "kkkmeet.displayName"; const cameraStorageKey = "kkkmeet.cameraDeviceId"; const micStorageKey = "kkkmeet.micDeviceId"; +const watchHeightStorageKey = "kkkmeet.watchPanelHeight"; const rtcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; @@ -31,8 +37,20 @@ let youtubePlayer; let youtubeReady = false; let currentYoutubeVideoId = ""; let applyingWatchStateUntil = 0; +let watchPlaylist = []; const peers = new Map(); +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function applyStoredWatchHeight() { + const savedHeight = Number(localStorage.getItem(watchHeightStorageKey)); + if (savedHeight > 0) { + stage.style.setProperty("--watch-panel-height", `${savedHeight}px`); + } +} + function setStatus(message) { statusText.textContent = message; } @@ -74,6 +92,65 @@ function parseYoutubeId(value) { return ""; } +async function fetchYoutubeTitle(videoId) { + try { + const url = new URL("https://www.youtube.com/oembed"); + url.searchParams.set("url", `https://www.youtube.com/watch?v=${videoId}`); + url.searchParams.set("format", "json"); + const response = await fetch(url); + if (!response.ok) throw new Error("Title lookup failed"); + const data = await response.json(); + return data.title || `YouTube video ${videoId}`; + } catch { + return `YouTube video ${videoId}`; + } +} + +function normalizedPlaylist(items) { + if (!Array.isArray(items)) return []; + + const seen = new Set(); + return items + .map((item) => ({ + videoId: String(item.videoId || "").trim(), + title: String(item.title || "").trim() + })) + .filter((item) => { + if (!/^[a-zA-Z0-9_-]{11}$/.test(item.videoId) || seen.has(item.videoId)) { + return false; + } + seen.add(item.videoId); + if (!item.title) item.title = `YouTube video ${item.videoId}`; + return true; + }); +} + +function renderPlaylist() { + playlistList.replaceChildren(); + + watchPlaylist.forEach((item) => { + const row = document.createElement("li"); + row.className = `playlist-item${item.videoId === currentYoutubeVideoId ? " is-active" : ""}`; + row.dataset.videoId = item.videoId; + + const playButton = document.createElement("button"); + playButton.type = "button"; + playButton.className = "playlist-play"; + playButton.textContent = item.title; + playButton.title = item.title; + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "playlist-delete"; + deleteButton.textContent = "×"; + deleteButton.title = "Delete video"; + deleteButton.setAttribute("aria-label", `Delete ${item.title}`); + + row.append(playButton, deleteButton); + playlistList.append(row); + }); +} + function loadYoutubeApi() { return new Promise((resolve) => { if (window.YT?.Player) { @@ -116,8 +193,8 @@ async function ensureYoutubePlayer() { return youtubePlayer; } -async function sendWatchState({ videoId, position, playing }) { - if (!peerId || !videoId) return; +async function sendWatchState({ videoId = currentYoutubeVideoId, position = 0, playing = false }) { + if (!peerId) return; await fetch(`/watch/${encodeURIComponent(roomId)}`, { method: "POST", @@ -127,6 +204,7 @@ async function sendWatchState({ videoId, position, playing }) { from: peerId, name: displayName, videoId, + playlist: watchPlaylist, position, playing, updatedAt: Date.now() @@ -138,6 +216,7 @@ function currentWatchState(playing) { if (!youtubeReady || !currentYoutubeVideoId) return null; return { videoId: currentYoutubeVideoId, + playlist: watchPlaylist, position: youtubePlayer.getCurrentTime?.() || 0, playing }; @@ -157,19 +236,26 @@ function handleYoutubeStateChange(event) { } async function applyWatchState(state) { - if (!state?.videoId) return; + watchPlaylist = normalizedPlaylist(state?.playlist); + currentYoutubeVideoId = state?.videoId || ""; + renderPlaylist(); + + if (!currentYoutubeVideoId) { + youtubePlayer?.stopVideo?.(); + youtubeShell.classList.remove("is-active"); + return; + } youtubeShell.classList.add("is-active"); const player = await ensureYoutubePlayer(); - currentYoutubeVideoId = state.videoId; applyingWatchStateUntil = Date.now() + 1200; const elapsed = state.playing ? Math.max(0, (Date.now() - state.updatedAt) / 1000) : 0; const position = Math.max(0, Number(state.position || 0) + elapsed); if (youtubeReady) { - if (player.getVideoData?.().video_id !== state.videoId) { - player.loadVideoById(state.videoId, position); + if (player.getVideoData?.().video_id !== currentYoutubeVideoId) { + player.loadVideoById(currentYoutubeVideoId, position); } else { player.seekTo(position, true); } @@ -479,6 +565,71 @@ function updateToggleButton(button, enabled, enabledLabel, disabledLabel) { button.textContent = enabled ? "●" : "○"; } +async function addYoutubeVideo(value) { + const videoId = parseYoutubeId(value); + if (!videoId) { + setStatus("Enter a valid YouTube URL."); + youtubeInput.focus(); + return; + } + + let item = watchPlaylist.find((entry) => entry.videoId === videoId); + if (!item) { + item = { videoId, title: await fetchYoutubeTitle(videoId) }; + watchPlaylist = [...watchPlaylist, item]; + } + + youtubeInput.value = ""; + await applyWatchState({ + videoId, + playlist: watchPlaylist, + position: 0, + playing: true, + updatedAt: Date.now() + }); + await sendWatchState({ videoId, position: 0, playing: true }); +} + +async function switchYoutubeVideo(videoId) { + if (!watchPlaylist.some((item) => item.videoId === videoId)) return; + + await applyWatchState({ + videoId, + playlist: watchPlaylist, + position: 0, + playing: true, + updatedAt: Date.now() + }); + await sendWatchState({ videoId, position: 0, playing: true }); +} + +async function deleteYoutubeVideo(videoId) { + const wasActive = currentYoutubeVideoId === videoId; + watchPlaylist = watchPlaylist.filter((item) => item.videoId !== videoId); + const nextVideoId = wasActive ? watchPlaylist[0]?.videoId || "" : currentYoutubeVideoId; + + await applyWatchState({ + videoId: nextVideoId, + playlist: watchPlaylist, + position: 0, + playing: Boolean(nextVideoId), + updatedAt: Date.now() + }); + await sendWatchState({ videoId: nextVideoId, position: 0, playing: Boolean(nextVideoId) }); +} + +async function clearYoutubePlaylist() { + watchPlaylist = []; + await applyWatchState({ + videoId: "", + playlist: watchPlaylist, + position: 0, + playing: false, + updatedAt: Date.now() + }); + await sendWatchState({ videoId: "", position: 0, playing: false }); +} + nameForm.addEventListener("submit", (event) => { event.preventDefault(); setDisplayName(nameInput.value); @@ -526,22 +677,37 @@ micSelect.addEventListener("change", () => { youtubeForm.addEventListener("submit", async (event) => { event.preventDefault(); - const videoId = parseYoutubeId(youtubeInput.value); + addYoutubeVideo(youtubeInput.value).catch((error) => { + console.error(error); + setStatus("Could not add YouTube video."); + }); +}); - if (!videoId) { - setStatus("Enter a valid YouTube URL."); - youtubeInput.focus(); +playlistList.addEventListener("click", (event) => { + const item = event.target.closest(".playlist-item"); + if (!item) return; + + if (event.target.closest(".playlist-delete")) { + deleteYoutubeVideo(item.dataset.videoId).catch((error) => { + console.error(error); + setStatus("Could not delete YouTube video."); + }); return; } - currentYoutubeVideoId = videoId; - await applyWatchState({ - videoId, - position: 0, - playing: true, - updatedAt: Date.now() + if (event.target.closest(".playlist-play")) { + switchYoutubeVideo(item.dataset.videoId).catch((error) => { + console.error(error); + setStatus("Could not switch YouTube video."); + }); + } +}); + +clearPlaylistButton.addEventListener("click", () => { + clearYoutubePlaylist().catch((error) => { + console.error(error); + setStatus("Could not clear YouTube playlist."); }); - await sendWatchState({ videoId, position: 0, playing: true }); }); shareButton.addEventListener("click", async () => { @@ -582,3 +748,34 @@ if (savedName) { navigator.mediaDevices?.addEventListener?.("devicechange", () => { refreshDeviceLists().catch(console.error); }); + +applyStoredWatchHeight(); + +watchResizeHandle.addEventListener("pointerdown", (event) => { + event.preventDefault(); + watchResizeHandle.setPointerCapture(event.pointerId); + stage.classList.add("is-resizing-watch"); + + const stageRect = stage.getBoundingClientRect(); + const controlsReserve = Math.max(190, stageRect.height * 0.24); + const minHeight = window.matchMedia("(max-width: 680px)").matches ? 300 : 220; + const maxHeight = Math.max(minHeight, stageRect.height - controlsReserve); + + const resize = (moveEvent) => { + const nextHeight = clamp(moveEvent.clientY - stageRect.top, minHeight, maxHeight); + stage.style.setProperty("--watch-panel-height", `${Math.round(nextHeight)}px`); + }; + + const stop = () => { + const height = Math.round(watchPanel.getBoundingClientRect().height); + localStorage.setItem(watchHeightStorageKey, String(height)); + stage.classList.remove("is-resizing-watch"); + watchResizeHandle.removeEventListener("pointermove", resize); + watchResizeHandle.removeEventListener("pointerup", stop); + watchResizeHandle.removeEventListener("pointercancel", stop); + }; + + watchResizeHandle.addEventListener("pointermove", resize); + watchResizeHandle.addEventListener("pointerup", stop); + watchResizeHandle.addEventListener("pointercancel", stop); +}); diff --git a/public/index.html b/public/index.html index 2872059..676d17e 100644 --- a/public/index.html +++ b/public/index.html @@ -21,20 +21,36 @@
-
- - -
-
-
+
+
+ + +
+
+
+
+
+
Waiting for others to join
diff --git a/public/styles.css b/public/styles.css index 6846c77..c7b8790 100644 --- a/public/styles.css +++ b/public/styles.css @@ -104,17 +104,27 @@ button[aria-pressed="false"] { .stage { display: grid; - grid-template-rows: auto 1fr; - gap: 14px; + grid-template-rows: var(--watch-panel-height, 320px) 14px minmax(0, 1fr); + gap: 0; min-height: 0; padding: 18px 18px 12px; } .watch-panel { display: grid; - grid-template-columns: minmax(260px, 420px) minmax(320px, 1fr); + grid-template-columns: minmax(320px, 1fr) minmax(240px, 360px); + grid-template-rows: 100%; gap: 14px; align-items: stretch; + min-height: 0; + overflow: auto; +} + +.watch-main { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; + min-height: 0; } .youtube-form { @@ -134,8 +144,7 @@ button[aria-pressed="false"] { .youtube-shell { display: none; overflow: hidden; - min-height: 180px; - aspect-ratio: 16 / 9; + min-height: 0; border-radius: 8px; background: #171b20; border: 1px solid rgba(255, 255, 255, 0.1); @@ -151,6 +160,113 @@ button[aria-pressed="false"] { height: 100%; } +.watch-resize-handle { + position: relative; + min-width: 0; + width: 100%; + height: 14px; + border: 0; + border-radius: 0; + background: transparent; + cursor: row-resize; +} + +.watch-resize-handle::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: min(140px, 32vw); + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.24); + transform: translate(-50%, -50%); +} + +.watch-resize-handle:hover::before, +.watch-resize-handle:focus-visible::before, +.stage.is-resizing-watch .watch-resize-handle::before { + background: rgba(255, 255, 255, 0.46); +} + +.stage.is-resizing-watch { + user-select: none; +} + +.playlist-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.playlist-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #d9dee7; + font-weight: 700; +} + +.playlist-header button { + min-width: 86px; + height: 34px; + background: #4a535f; + font-size: 12px; +} + +.playlist-list { + display: grid; + align-content: start; + gap: 6px; + min-height: 0; + margin: 0; + padding: 10px; + overflow: auto; + list-style: none; +} + +.playlist-list:empty::before { + content: "No videos added"; + color: #aeb7c3; + font-size: 13px; +} + +.playlist-item { + display: grid; + grid-template-columns: minmax(0, 1fr) 34px; + gap: 6px; +} + +.playlist-item button { + height: 36px; + min-width: 0; + border-radius: 6px; +} + +.playlist-play { + overflow: hidden; + background: rgba(255, 255, 255, 0.08); + color: #f2f4f7; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +.playlist-item.is-active .playlist-play { + background: #1f8f5f; +} + +.playlist-delete { + background: #4a535f; +} + .remote-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); @@ -371,10 +487,13 @@ button[aria-pressed="false"] { .stage { padding: 10px; + grid-template-rows: var(--watch-panel-height, 430px) 14px minmax(0, 1fr); } .watch-panel { grid-template-columns: 1fr; + grid-template-rows: minmax(220px, 1fr) minmax(160px, auto); + min-height: 430px; } .youtube-form { diff --git a/server.js b/server.js index 0a08900..32bb3e6 100644 --- a/server.js +++ b/server.js @@ -38,6 +38,26 @@ function normalizeName(name) { return normalized.slice(0, 48) || "Guest"; } +function normalizePlaylist(playlist) { + if (!Array.isArray(playlist)) return []; + + const seen = new Set(); + return playlist + .map((item) => ({ + videoId: String(item?.videoId || "").trim(), + title: String(item?.title || "").trim() + })) + .filter((item) => { + if (!/^[a-zA-Z0-9_-]{11}$/.test(item.videoId) || seen.has(item.videoId)) { + return false; + } + seen.add(item.videoId); + item.title ||= `YouTube video ${item.videoId}`; + return true; + }) + .slice(0, 50); +} + function broadcast(roomId, senderId, event) { const room = rooms.get(roomId); if (!room) return; @@ -157,16 +177,19 @@ const server = http.createServer(async (req, res) => { const roomId = url.pathname.split("/").at(-1) || defaultRoomId; const payload = await parseJson(req); - if (!payload.from || payload.type !== "watch-state" || !payload.videoId) { - sendJson(res, 400, { error: "Watch messages require from, type=watch-state, and videoId." }); + if (!payload.from || payload.type !== "watch-state") { + sendJson(res, 400, { error: "Watch messages require from and type=watch-state." }); return; } + const playlist = normalizePlaylist(payload.playlist); + const videoId = String(payload.videoId || "").trim(); const watchState = { type: "watch-state", from: payload.from, name: normalizeName(payload.name), - videoId: String(payload.videoId).trim(), + videoId: playlist.some((item) => item.videoId === videoId) ? videoId : "", + playlist, position: Number(payload.position) || 0, playing: Boolean(payload.playing), updatedAt: Number(payload.updatedAt) || Date.now()