diff --git a/kkkmeet-server.el b/kkkmeet-server.el index a825140..7708831 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -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 () diff --git a/public/app.js b/public/app.js index 81959e4..f91babd 100644 --- a/public/app.js +++ b/public/app.js @@ -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; diff --git a/public/index.html b/public/index.html index d095a95..2872059 100644 --- a/public/index.html +++ b/public/index.html @@ -20,6 +20,21 @@
+
+
+ + +
+
+
+
+
Waiting for others to join
diff --git a/public/styles.css b/public/styles.css index 1bab23e..6846c77 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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); diff --git a/server.js b/server.js index ed77a2b..0a08900 100644 --- a/server.js +++ b/server.js @@ -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;