Add synchronized YouTube watching

This commit is contained in:
codex
2026-06-09 21:33:04 +02:00
parent 073f4140f7
commit 654b3c6ee9
5 changed files with 308 additions and 4 deletions
+41 -3
View File
@@ -30,6 +30,7 @@
(defvar kkkmeet-server--process nil)
(defvar kkkmeet-server--rooms (make-hash-table :test 'equal))
(defvar kkkmeet-server--watch-states (make-hash-table :test 'equal))
(defconst kkkmeet-server--default-room-id "main")
(defconst kkkmeet-server--mime-types
@@ -146,7 +147,8 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(kkkmeet-server--broadcast
room-id peer-id `((type . "peer-left") (peerId . ,peer-id)))
(when (= (hash-table-count room) 0)
(remhash room-id kkkmeet-server--rooms)))))))
(remhash room-id kkkmeet-server--rooms)
(remhash room-id kkkmeet-server--watch-states)))))))
(defun kkkmeet-server--parse-request (raw)
"Parse RAW HTTP request into an alist."
@@ -235,7 +237,9 @@ When KEEP-OPEN is nil, close PROCESS after writing."
process `((type . "welcome")
(peerId . ,peer-id)
(name . ,safe-name)
(peers . ,peers)))
(peers . ,peers)
(watchState . ,(or (gethash safe-room-id kkkmeet-server--watch-states)
:null))))
(kkkmeet-server--broadcast
safe-room-id peer-id `((type . "peer-joined")
(peerId . ,peer-id)
@@ -260,6 +264,35 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(error
(kkkmeet-server--send-json process 400 `((error . ,(error-message-string error)))))))
(defun kkkmeet-server--handle-watch (process room-id body)
"Handle shared YouTube watch state BODY for ROOM-ID."
(condition-case error
(let* ((json-object-type 'alist)
(payload (json-read-from-string body))
(from (alist-get 'from payload))
(type (alist-get 'type payload))
(video-id (alist-get 'videoId 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))))))))
(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.")))))
(error
(kkkmeet-server--send-json process 400 `((error . ,(error-message-string error)))))))
(defun kkkmeet-server--handle-request (process request)
"Route REQUEST for PROCESS."
(let ((method (alist-get 'method request))
@@ -275,6 +308,9 @@ When KEEP-OPEN is nil, close PROCESS after writing."
((and (equal method "POST") (string-prefix-p "/signal/" path))
(kkkmeet-server--handle-signal
process (url-unhex-string (string-remove-prefix "/signal/" path)) body))
((and (equal method "POST") (string-prefix-p "/watch/" path))
(kkkmeet-server--handle-watch
process (url-unhex-string (string-remove-prefix "/watch/" path)) body))
((member method '("GET" "HEAD"))
(kkkmeet-server--serve-static process method path))
(t
@@ -302,6 +338,7 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(interactive)
(kkkmeet-server-stop)
(setq kkkmeet-server--rooms (make-hash-table :test 'equal))
(setq kkkmeet-server--watch-states (make-hash-table :test 'equal))
(setq kkkmeet-server--process
(make-network-process
:name "kkkmeet-server"
@@ -329,7 +366,8 @@ When KEEP-OPEN is nil, close PROCESS after writing."
room))
kkkmeet-server--rooms)
(setq kkkmeet-server--process nil)
(setq kkkmeet-server--rooms (make-hash-table :test 'equal)))
(setq kkkmeet-server--rooms (make-hash-table :test 'equal))
(setq kkkmeet-server--watch-states (make-hash-table :test 'equal)))
;;;###autoload
(defun kkkmeet-server-run-batch ()
+172
View File
@@ -5,6 +5,9 @@ const remoteTemplate = document.querySelector("#remote-template");
const nameForm = document.querySelector("#name-form");
const nameInput = document.querySelector("#name-input");
const statusText = document.querySelector("#status");
const youtubeForm = document.querySelector("#youtube-form");
const youtubeInput = document.querySelector("#youtube-input");
const youtubeShell = document.querySelector("#youtube-shell");
const cameraSelect = document.querySelector("#camera-select");
const micSelect = document.querySelector("#mic-select");
const cameraButton = document.querySelector("#camera-button");
@@ -24,6 +27,10 @@ let displayName = "";
let localStream;
let screenStream;
let events;
let youtubePlayer;
let youtubeReady = false;
let currentYoutubeVideoId = "";
let applyingWatchStateUntil = 0;
const peers = new Map();
function setStatus(message) {
@@ -45,6 +52,142 @@ function labelForPeer(peer) {
return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim();
}
function parseYoutubeId(value) {
const raw = value.trim();
if (/^[a-zA-Z0-9_-]{11}$/.test(raw)) return raw;
try {
const url = new URL(raw);
if (url.hostname.includes("youtu.be")) {
return url.pathname.split("/").filter(Boolean)[0] || "";
}
if (url.hostname.includes("youtube.com")) {
if (url.pathname.startsWith("/shorts/") || url.pathname.startsWith("/embed/")) {
return url.pathname.split("/").filter(Boolean)[1] || "";
}
return url.searchParams.get("v") || "";
}
} catch {
return "";
}
return "";
}
function loadYoutubeApi() {
return new Promise((resolve) => {
if (window.YT?.Player) {
resolve();
return;
}
const previousReady = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
previousReady?.();
resolve();
};
if (!document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) {
const script = document.createElement("script");
script.src = "https://www.youtube.com/iframe_api";
document.head.append(script);
}
});
}
async function ensureYoutubePlayer() {
await loadYoutubeApi();
if (youtubePlayer) return youtubePlayer;
youtubePlayer = new YT.Player("youtube-player", {
playerVars: {
controls: 1,
disablekb: 0,
modestbranding: 1,
rel: 0
},
events: {
onReady: () => {
youtubeReady = true;
},
onStateChange: handleYoutubeStateChange
}
});
return youtubePlayer;
}
async function sendWatchState({ videoId, position, playing }) {
if (!peerId || !videoId) return;
await fetch(`/watch/${encodeURIComponent(roomId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "watch-state",
from: peerId,
name: displayName,
videoId,
position,
playing,
updatedAt: Date.now()
})
});
}
function currentWatchState(playing) {
if (!youtubeReady || !currentYoutubeVideoId) return null;
return {
videoId: currentYoutubeVideoId,
position: youtubePlayer.getCurrentTime?.() || 0,
playing
};
}
function handleYoutubeStateChange(event) {
if (Date.now() < applyingWatchStateUntil) return;
if (![YT.PlayerState.PLAYING, YT.PlayerState.PAUSED].includes(event.data)) return;
const state = currentWatchState(event.data === YT.PlayerState.PLAYING);
if (state) {
sendWatchState(state).catch((error) => {
console.error(error);
setStatus("Could not sync YouTube playback.");
});
}
}
async function applyWatchState(state) {
if (!state?.videoId) 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);
} else {
player.seekTo(position, true);
}
if (state.playing) {
player.playVideo();
} else {
player.pauseVideo();
}
} else {
const interval = setInterval(() => {
if (!youtubeReady) return;
clearInterval(interval);
applyWatchState(state).catch(console.error);
}, 100);
}
}
function mediaConstraints() {
const cameraDeviceId = localStorage.getItem(cameraStorageKey);
const micDeviceId = localStorage.getItem(micStorageKey);
@@ -245,6 +388,9 @@ async function handleSignal(message) {
if (message.type === "welcome") {
peerId = message.peerId;
setStatus(`Joined the call as ${displayName}. ${message.peers.length} other participant(s).`);
if (message.watchState) {
await applyWatchState(message.watchState);
}
for (const existingPeer of message.peers) {
await callPeer(existingPeer);
}
@@ -268,6 +414,12 @@ async function handleSignal(message) {
return;
}
if (message.type === "watch-state") {
await applyWatchState(message);
setStatus(`${message.name || "Someone"} synced YouTube playback.`);
return;
}
const connection = peers.get(message.from)?.connection;
if (!connection) return;
@@ -372,6 +524,26 @@ micSelect.addEventListener("change", () => {
});
});
youtubeForm.addEventListener("submit", async (event) => {
event.preventDefault();
const videoId = parseYoutubeId(youtubeInput.value);
if (!videoId) {
setStatus("Enter a valid YouTube URL.");
youtubeInput.focus();
return;
}
currentYoutubeVideoId = videoId;
await applyWatchState({
videoId,
position: 0,
playing: true,
updatedAt: Date.now()
});
await sendWatchState({ videoId, position: 0, playing: true });
});
shareButton.addEventListener("click", async () => {
if (!localStream) return;
+15
View File
@@ -20,6 +20,21 @@
</header>
<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>
</section>
<div class="remote-grid" id="remote-grid">
<div class="empty-stage" id="empty-stage">Waiting for others to join</div>
</div>
+51
View File
@@ -104,10 +104,53 @@ button[aria-pressed="false"] {
.stage {
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
min-height: 0;
padding: 18px 18px 12px;
}
.watch-panel {
display: grid;
grid-template-columns: minmax(260px, 420px) minmax(320px, 1fr);
gap: 14px;
align-items: stretch;
}
.youtube-form {
display: flex;
align-items: end;
gap: 10px;
}
.youtube-form input {
width: 100%;
}
.youtube-form button {
height: 42px;
}
.youtube-shell {
display: none;
overflow: hidden;
min-height: 180px;
aspect-ratio: 16 / 9;
border-radius: 8px;
background: #171b20;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.youtube-shell.is-active {
display: block;
}
#youtube-player,
#youtube-player iframe {
width: 100%;
height: 100%;
}
.remote-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
@@ -330,6 +373,14 @@ button[aria-pressed="false"] {
padding: 10px;
}
.watch-panel {
grid-template-columns: 1fr;
}
.youtube-form {
align-items: stretch;
}
.remote-grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(190px, 1fr);
+29 -1
View File
@@ -7,6 +7,7 @@ const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "127.0.0.1";
const publicDir = join(process.cwd(), "public");
const rooms = new Map();
const watchStates = new Map();
const defaultRoomId = "main";
const mimeTypes = {
@@ -56,6 +57,7 @@ function removePeer(roomId, peerId) {
if (room.size === 0) {
rooms.delete(roomId);
watchStates.delete(roomId);
}
}
@@ -115,7 +117,8 @@ const server = http.createServer(async (req, res) => {
type: "welcome",
peerId,
name: displayName,
peers: existingPeers
peers: existingPeers,
watchState: watchStates.get(roomId) || null
})}\n\n`
);
broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName });
@@ -150,6 +153,31 @@ const server = http.createServer(async (req, res) => {
return;
}
if (req.method === "POST" && url.pathname.startsWith("/watch/")) {
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." });
return;
}
const watchState = {
type: "watch-state",
from: payload.from,
name: normalizeName(payload.name),
videoId: String(payload.videoId).trim(),
position: Number(payload.position) || 0,
playing: Boolean(payload.playing),
updatedAt: Number(payload.updatedAt) || Date.now()
};
watchStates.set(roomId, watchState);
broadcast(roomId, payload.from, watchState);
sendJson(res, 202, { ok: true });
return;
}
if (req.method === "GET" || req.method === "HEAD") {
await serveStatic(req, res);
return;