Add shared YouTube playlist resizing

This commit is contained in:
codex
2026-06-09 21:44:59 +02:00
parent 654b3c6ee9
commit 6cb1cc2230
5 changed files with 437 additions and 49 deletions
+45 -12
View File
@@ -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)))))))
+214 -17
View File
@@ -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);
});
+28 -12
View File
@@ -21,20 +21,36 @@
<section class="stage" aria-label="Video call">
<section class="watch-panel" aria-label="Watch together">
<form id="youtube-form" class="youtube-form">
<input
id="youtube-input"
type="url"
inputmode="url"
placeholder="YouTube URL"
autocomplete="off"
/>
<button type="submit">Watch</button>
</form>
<div class="youtube-shell" id="youtube-shell">
<div id="youtube-player"></div>
<div class="watch-main">
<form id="youtube-form" class="youtube-form">
<input
id="youtube-input"
type="url"
inputmode="url"
placeholder="YouTube URL"
autocomplete="off"
/>
<button type="submit">Add</button>
</form>
<div class="youtube-shell" id="youtube-shell">
<div id="youtube-player"></div>
</div>
</div>
<aside class="playlist-panel">
<div class="playlist-header">
<span>Playlist</span>
<button id="clear-playlist-button" type="button">Clear list</button>
</div>
<ul id="playlist-list" class="playlist-list"></ul>
</aside>
</section>
<button
id="watch-resize-handle"
class="watch-resize-handle"
type="button"
aria-label="Resize YouTube panel"
title="Resize YouTube panel"
></button>
<div class="remote-grid" id="remote-grid">
<div class="empty-stage" id="empty-stage">Waiting for others to join</div>
</div>
+124 -5
View File
@@ -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 {
+26 -3
View File
@@ -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()