Add synchronized YouTube watching
This commit is contained in:
+41
-3
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user