Add shared YouTube playlist resizing
This commit is contained in:
+45
-12
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user