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 renameButton = document.querySelector("#rename-button"); const nameDialog = document.querySelector("#name-dialog"); const nameDialogTitle = document.querySelector("#name-dialog-title"); const nameDialogSubmit = document.querySelector("#name-dialog-submit"); const initialNameForm = document.querySelector("#initial-name-form"); const initialNameInput = document.querySelector("#initial-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 watchPanel = document.querySelector(".watch-panel"); const watchResizeHandle = document.querySelector("#watch-resize-handle"); const watchCollapseButton = document.querySelector("#watch-collapse-button"); 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"); const micButton = document.querySelector("#mic-button"); const shareButton = document.querySelector("#share-button"); const roomId = "main"; const nameStorageKey = "kkkmeet.displayName"; const cameraStorageKey = "kkkmeet.cameraDeviceId"; const micStorageKey = "kkkmeet.micDeviceId"; const watchHeightStorageKey = "kkkmeet.watchPanelHeight"; const watchCollapsedStorageKey = "kkkmeet.watchPanelCollapsed"; const rtcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; let peerId = ""; let displayName = ""; let localStream; let screenStream; let localPresentationTile; let events; let youtubePlayer; let youtubeReady = false; let currentYoutubeVideoId = ""; let applyingWatchStateUntil = 0; let pauseEnforcementUntil = 0; let desiredYoutubePlaying = false; 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`); } setWatchCollapsed(true); } function setWatchCollapsed(collapsed) { stage.classList.toggle("is-watch-collapsed", collapsed); watchCollapseButton.textContent = collapsed ? "↓" : "↑"; watchCollapseButton.setAttribute( "aria-label", collapsed ? "Expand YouTube panel" : "Collapse YouTube panel" ); watchCollapseButton.title = collapsed ? "Expand YouTube panel" : "Collapse YouTube panel"; localStorage.setItem(watchCollapsedStorageKey, String(collapsed)); } function toggleWatchCollapsed() { if (!stage.classList.contains("is-watch-collapsed")) { const height = Math.round(watchPanel.getBoundingClientRect().height); if (height > 0) { localStorage.setItem(watchHeightStorageKey, String(height)); } } setWatchCollapsed(!stage.classList.contains("is-watch-collapsed")); } function setStatus(message) { statusText.textContent = message; } function normalizeName(name) { return name.trim().replace(/\s+/g, " ").slice(0, 48); } function setDisplayName(nextName) { displayName = normalizeName(nextName); localStorage.setItem(nameStorageKey, displayName); localNameLabel.textContent = displayName ? `${displayName} (you)` : "You"; } function openNameDialog(mode) { const renaming = mode === "rename"; nameDialogTitle.textContent = renaming ? "Rename yourself" : "Choose your name"; nameDialogSubmit.textContent = renaming ? "Save name" : "Join call"; initialNameInput.value = renaming ? displayName : ""; nameDialog.dataset.mode = mode; nameDialog.showModal(); initialNameInput.focus(); initialNameInput.select(); } 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 ""; } 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(), position: Math.max(0, Number(item.position) || 0), duration: Math.max(0, Number(item.duration) || 0) })) .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 currentYoutubeSnapshot(fallbackPosition = 0) { if (!youtubeReady || !youtubePlayer || !currentYoutubeVideoId) { return { position: Math.max(0, Number(fallbackPosition) || 0), duration: 0 }; } const duration = Math.max(0, Number(youtubePlayer.getDuration?.()) || 0); const currentTime = Math.max(0, Number(youtubePlayer.getCurrentTime?.()) || 0); const position = duration > 0 && duration - currentTime <= 2 ? 0 : currentTime; return { position, duration }; } function playlistWithActivePosition(fallbackPosition = 0) { if (!currentYoutubeVideoId) return normalizedPlaylist(watchPlaylist); const snapshot = currentYoutubeSnapshot(fallbackPosition); return normalizedPlaylist(watchPlaylist).map((item) => ( item.videoId === currentYoutubeVideoId ? { ...item, ...snapshot } : item )); } function savedPositionForVideo(videoId) { return Math.max(0, Number(watchPlaylist.find((item) => item.videoId === videoId)?.position) || 0); } function formatTime(totalSeconds) { const seconds = Math.max(0, Math.floor(Number(totalSeconds) || 0)); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainder = seconds % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`; } return `${minutes}:${String(remainder).padStart(2, "0")}`; } function playlistTimestamp(item) { const position = formatTime(item.position); return item.duration > 0 ? `${position} / ${formatTime(item.duration)}` : position; } 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.title = item.title; const title = document.createElement("span"); title.className = "playlist-title"; title.textContent = item.title; const timestamp = document.createElement("span"); timestamp.className = "playlist-timestamp"; timestamp.textContent = playlistTimestamp(item); playButton.append(title, timestamp); 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 refreshActivePlaylistTimestamp() { if (!currentYoutubeVideoId || !youtubeReady || !youtubePlayer) return; watchPlaylist = playlistWithActivePosition(); const activeItem = watchPlaylist.find((item) => item.videoId === currentYoutubeVideoId); const timestamp = document.querySelector( `[data-video-id="${currentYoutubeVideoId}"] .playlist-timestamp` ); if (activeItem && timestamp) { timestamp.textContent = playlistTimestamp(activeItem); } } 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; } function requestYoutubePlayback(player) { for (const delay of [0, 150, 750, 1500, 3000]) { setTimeout(() => { if (desiredYoutubePlaying) player.playVideo(); }, delay); } } async function sendWatchState({ videoId = currentYoutubeVideoId, position = 0, playing = false, action = playing ? "play" : "pause" }) { if (!peerId) return; const watchAction = action === "play" ? "play" : "pause"; const playlist = playlistWithActivePosition(position); await fetch(`/watch/${encodeURIComponent(roomId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "watch-state", from: peerId, name: displayName, videoId, playlist, position, playing: watchAction === "play" && Boolean(videoId), action: watchAction, updatedAt: Date.now() }) }); } function currentWatchState(playing) { if (!youtubeReady || !currentYoutubeVideoId) return null; const snapshot = currentYoutubeSnapshot(); watchPlaylist = playlistWithActivePosition(snapshot.position); return { videoId: currentYoutubeVideoId, playlist: watchPlaylist, position: snapshot.position, playing, action: playing ? "play" : "pause" }; } function handleYoutubeStateChange(event) { if (Date.now() < applyingWatchStateUntil) return; if (![YT.PlayerState.PLAYING, YT.PlayerState.PAUSED, YT.PlayerState.ENDED].includes(event.data)) { return; } if ( event.data === YT.PlayerState.PLAYING && !desiredYoutubePlaying && Date.now() < pauseEnforcementUntil ) { youtubePlayer?.pauseVideo?.(); return; } const action = event.data === YT.PlayerState.PLAYING ? "play" : "pause"; desiredYoutubePlaying = action === "play"; const state = currentWatchState(action === "play"); if (state) { state.action = action; if (event.data === YT.PlayerState.ENDED) { state.position = 0; state.playing = false; state.action = "pause"; desiredYoutubePlaying = false; watchPlaylist = playlistWithActivePosition(0); state.playlist = watchPlaylist; renderPlaylist(); } sendWatchState(state).catch((error) => { console.error(error); setStatus("Could not sync YouTube playback."); }); } } async function applyWatchState(state, { suppressEvents = true } = {}) { const shouldPlay = state?.action === "play" || (!state?.action && Boolean(state?.playing)); watchPlaylist = normalizedPlaylist(state?.playlist); currentYoutubeVideoId = state?.videoId || ""; desiredYoutubePlaying = shouldPlay; if (currentYoutubeVideoId) { const position = Math.max(0, Number(state.position) || 0); watchPlaylist = watchPlaylist.map((item) => ( item.videoId === currentYoutubeVideoId ? { ...item, position } : item )); } renderPlaylist(); if (!currentYoutubeVideoId) { desiredYoutubePlaying = false; youtubePlayer?.stopVideo?.(); youtubeShell.classList.remove("is-active"); setWatchCollapsed(true); return; } if (shouldPlay) { setWatchCollapsed(false); } youtubeShell.classList.add("is-active"); const player = await ensureYoutubePlayer(); if (suppressEvents) { applyingWatchStateUntil = Date.now() + 3000; } if (shouldPlay) { pauseEnforcementUntil = 0; } else { pauseEnforcementUntil = Math.max(pauseEnforcementUntil, Date.now() + 3000); } const elapsed = shouldPlay ? Math.max(0, (Date.now() - state.updatedAt) / 1000) : 0; const position = Math.max(0, Number(state.position || 0) + elapsed); if (youtubeReady) { if (shouldPlay) { const sameVideo = player.getVideoData?.().video_id === currentYoutubeVideoId; const playerState = player.getPlayerState?.(); if (sameVideo && playerState === YT.PlayerState.PLAYING) { player.seekTo(position, true); } else { player.loadVideoById(currentYoutubeVideoId, position); } requestYoutubePlayback(player); } else { if (player.getVideoData?.().video_id !== currentYoutubeVideoId) { player.cueVideoById(currentYoutubeVideoId, position); } else { player.pauseVideo(); player.seekTo(position, true); } player.pauseVideo(); setTimeout(() => { player.pauseVideo(); }, 150); setTimeout(() => { if (!desiredYoutubePlaying) player.pauseVideo(); }, 1200); setTimeout(() => { if (!desiredYoutubePlaying) player.pauseVideo(); }, 2500); } } else { const interval = setInterval(() => { if (!youtubeReady) return; clearInterval(interval); applyWatchState(state, { suppressEvents }).catch(console.error); }, 100); } } function mediaConstraints() { const cameraDeviceId = localStorage.getItem(cameraStorageKey); const micDeviceId = localStorage.getItem(micStorageKey); return { audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true, video: { width: { ideal: 1280 }, height: { ideal: 720 }, ...(cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : {}) } }; } async function ensureLocalStream() { if (localStream) return localStream; try { localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints()); } catch (error) { localStorage.removeItem(cameraStorageKey); localStorage.removeItem(micStorageKey); localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints()); } localVideo.srcObject = localStream; await refreshDeviceLists(); return localStream; } function trackDeviceId(kind) { return localStream?.getTracks().find((track) => track.kind === kind)?.getSettings().deviceId || ""; } function fillDeviceSelect(select, devices, selectedDeviceId, fallbackLabel) { select.replaceChildren(); if (devices.length === 0) { const option = new Option(`No ${fallbackLabel.toLowerCase()} found`, ""); select.add(option); select.disabled = true; return; } select.disabled = false; devices.forEach((device, index) => { const label = device.label || `${fallbackLabel} ${index + 1}`; select.add(new Option(label, device.deviceId)); }); select.value = selectedDeviceId && devices.some((device) => device.deviceId === selectedDeviceId) ? selectedDeviceId : devices[0].deviceId; } async function refreshDeviceLists() { if (!navigator.mediaDevices?.enumerateDevices) return; const devices = await navigator.mediaDevices.enumerateDevices(); fillDeviceSelect( cameraSelect, devices.filter((device) => device.kind === "videoinput"), trackDeviceId("video") || localStorage.getItem(cameraStorageKey), "Camera" ); fillDeviceSelect( micSelect, devices.filter((device) => device.kind === "audioinput"), trackDeviceId("audio") || localStorage.getItem(micStorageKey), "Microphone" ); } async function replaceLocalTrack(kind, deviceId) { if (!deviceId) return; if (!localStream) { localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId); return; } const oldTrack = localStream?.getTracks().find((track) => track.kind === kind); const wasEnabled = oldTrack?.enabled ?? true; const constraints = kind === "video" ? { video: { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } } : { audio: { deviceId: { exact: deviceId } } }; const stream = await navigator.mediaDevices.getUserMedia(constraints); const newTrack = stream.getTracks()[0]; newTrack.enabled = wasEnabled; if (!localStream) { localStream = new MediaStream([newTrack]); } else { if (oldTrack) { localStream.removeTrack(oldTrack); oldTrack.stop(); } localStream.addTrack(newTrack); } if (kind === "audio" || !screenStream) { for (const { connection } of peers.values()) { const sender = connection.getSenders().find((item) => item.track?.kind === kind); await sender?.replaceTrack(newTrack); } } if (kind === "video" && !screenStream) { localVideo.srcObject = localStream; } localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId); await refreshDeviceLists(); } function updateRemoteLabel(id, name) { const label = document.querySelector(`[data-peer-id="${id}"] span`); if (label) label.textContent = name || `Participant ${id.slice(0, 4)}`; } function createRemoteTile(peer) { const id = typeof peer === "string" ? peer : peer.id; const fragment = remoteTemplate.content.cloneNode(true); const tile = fragment.querySelector(".tile"); const video = fragment.querySelector("video"); const label = fragment.querySelector("span"); tile.dataset.peerId = id; label.textContent = labelForPeer(typeof peer === "string" ? { id } : peer); remoteGrid.append(tile); return { tile, video }; } function updatePresentationLayout() { remoteGrid.classList.toggle("has-presentation", Boolean(remoteGrid.querySelector(".presentation-tile"))); } function createLocalPresentationTile(stream) { removeLocalPresentationTile(); const fragment = remoteTemplate.content.cloneNode(true); const tile = fragment.querySelector(".tile"); const video = fragment.querySelector("video"); const label = fragment.querySelector("span"); tile.classList.add("presentation-tile", "local-presentation-tile"); tile.dataset.localPresentation = "true"; label.textContent = "Your screen"; video.srcObject = stream; remoteGrid.prepend(tile); localPresentationTile = tile; updatePresentationLayout(); } function removeLocalPresentationTile() { localPresentationTile?.remove(); localPresentationTile = undefined; updatePresentationLayout(); } function setRemotePresentation(peerId, sharing, name) { const tile = document.querySelector(`[data-peer-id="${peerId}"]`); if (!tile) return; tile.classList.toggle("presentation-tile", sharing); updateRemoteLabel(peerId, sharing ? `${name || "Participant"}'s screen` : name); updatePresentationLayout(); } function removeRemoteTile(id) { document.querySelector(`[data-peer-id="${id}"]`)?.remove(); updatePresentationLayout(); } async function sendSignal(to, payload) { await fetch(`/signal/${encodeURIComponent(roomId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, from: peerId, to, name: displayName }) }); } async function sendRoomEvent(payload) { if (!peerId) return; await fetch(`/room-event/${encodeURIComponent(roomId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, from: peerId, name: displayName }) }); } function outboundTracks() { const audioTracks = localStream?.getAudioTracks() || []; const videoTrack = screenStream?.getVideoTracks()[0] || localStream?.getVideoTracks()[0]; return [...audioTracks, videoTrack].filter(Boolean); } async function replaceOutboundVideoTrack(track) { for (const { connection } of peers.values()) { const sender = connection.getSenders().find((item) => item.track?.kind === "video"); await sender?.replaceTrack(track); } } function createPeerConnection(remotePeer) { const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id; if (peers.has(remotePeerId)) { updateRemoteLabel(remotePeerId, remotePeer.name); return peers.get(remotePeerId).connection; } const connection = new RTCPeerConnection(rtcConfig); const remoteStream = new MediaStream(); const { video } = createRemoteTile(remotePeer); video.srcObject = remoteStream; outboundTracks().forEach((track) => { connection.addTrack(track, track.kind === "video" && screenStream ? screenStream : localStream); }); connection.ontrack = (event) => { event.streams[0].getTracks().forEach((track) => { remoteStream.addTrack(track); }); }; connection.onicecandidate = (event) => { if (event.candidate) { sendSignal(remotePeerId, { type: "ice-candidate", candidate: event.candidate }); } }; connection.onconnectionstatechange = () => { if (["closed", "disconnected", "failed"].includes(connection.connectionState)) { closePeer(remotePeerId); } }; peers.set(remotePeerId, { connection, remoteStream, name: remotePeer.name }); return connection; } async function callPeer(remotePeer) { const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id; const connection = createPeerConnection(remotePeer); const offer = await connection.createOffer(); await connection.setLocalDescription(offer); await sendSignal(remotePeerId, { type: "offer", description: connection.localDescription }); } async function answerPeer(message) { const connection = createPeerConnection({ id: message.from, name: message.name }); await connection.setRemoteDescription(message.description); const answer = await connection.createAnswer(); await connection.setLocalDescription(answer); await sendSignal(message.from, { type: "answer", description: connection.localDescription }); } 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); } if (message.presentationState?.sharing) { setTimeout(() => { setRemotePresentation( message.presentationState.from || message.presentationState.peerId, true, message.presentationState.name ); }, 0); } for (const existingPeer of message.peers) { await callPeer(existingPeer); } return; } if (message.type === "peer-joined") { updateRemoteLabel(message.peerId, message.name); setStatus(`${message.name || "A participant"} joined.`); return; } if (message.type === "peer-left") { closePeer(message.peerId); setStatus("A participant left."); return; } if (message.type === "offer") { await answerPeer(message); return; } if (message.type === "watch-state") { await applyWatchState(message); setStatus(`${message.name || "Someone"} synced YouTube playback.`); return; } if (message.type === "presentation-state") { setRemotePresentation(message.from, message.sharing, message.name); setStatus(message.sharing ? `${message.name || "Someone"} is sharing their screen.` : `${message.name || "Someone"} stopped sharing their screen.`); return; } const connection = peers.get(message.from)?.connection; if (!connection) return; if (message.type === "answer") { await connection.setRemoteDescription(message.description); } if (message.type === "ice-candidate") { await connection.addIceCandidate(message.candidate); } } function closePeer(remotePeerId) { const peer = peers.get(remotePeerId); if (!peer) return; peer.connection.close(); peers.delete(remotePeerId); removeRemoteTile(remotePeerId); } async function joinCall() { if (!displayName) { setStatus("Enter your name to join the call."); openNameDialog("join"); return; } leaveCall(); await ensureLocalStream(); const params = new URLSearchParams({ name: displayName }); events = new EventSource(`/events/${encodeURIComponent(roomId)}?${params}`); events.onmessage = (event) => handleSignal(JSON.parse(event.data)).catch(console.error); events.onerror = () => setStatus("Connection to signaling server was interrupted."); setStatus(`Joining as ${displayName}...`); } function leaveCall() { events?.close(); events = undefined; peerId = ""; for (const remotePeerId of peers.keys()) closePeer(remotePeerId); setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call."); } function setTrackEnabled(kind, enabled) { localStream?.getTracks().forEach((track) => { if (track.kind === kind) track.enabled = enabled; }); } function updateToggleButton(button, enabled, enabledLabel, disabledLabel) { button.setAttribute("aria-pressed", String(enabled)); button.setAttribute("aria-label", enabled ? enabledLabel : disabledLabel); button.title = 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), position: 0, duration: 0 }; watchPlaylist = [...watchPlaylist, item]; } const playlist = playlistWithActivePosition(); youtubeInput.value = ""; await applyWatchState({ videoId, playlist, position: savedPositionForVideo(videoId), playing: true, updatedAt: Date.now() }, { suppressEvents: false }); await sendWatchState({ videoId, position: savedPositionForVideo(videoId), playing: true }); } async function switchYoutubeVideo(videoId) { if (!watchPlaylist.some((item) => item.videoId === videoId)) return; watchPlaylist = playlistWithActivePosition(); const position = savedPositionForVideo(videoId); await applyWatchState({ videoId, playlist: watchPlaylist, position, playing: true, updatedAt: Date.now() }, { suppressEvents: false }); await sendWatchState({ videoId, position, playing: true }); } async function deleteYoutubeVideo(videoId) { const wasActive = currentYoutubeVideoId === videoId; watchPlaylist = playlistWithActivePosition(); watchPlaylist = watchPlaylist.filter((item) => item.videoId !== videoId); const nextVideoId = wasActive ? watchPlaylist[0]?.videoId || "" : currentYoutubeVideoId; const position = nextVideoId ? savedPositionForVideo(nextVideoId) : 0; await applyWatchState({ videoId: nextVideoId, playlist: watchPlaylist, position, playing: Boolean(nextVideoId), updatedAt: Date.now() }, { suppressEvents: false }); await sendWatchState({ videoId: nextVideoId, position, playing: Boolean(nextVideoId) }); } async function clearYoutubePlaylist() { watchPlaylist = []; await applyWatchState({ videoId: "", playlist: watchPlaylist, position: 0, playing: false, updatedAt: Date.now() }, { suppressEvents: false }); await sendWatchState({ videoId: "", position: 0, playing: false, action: "pause" }); } async function startScreenShare() { if (!localStream || screenStream) return; screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); const screenTrack = screenStream.getVideoTracks()[0]; createLocalPresentationTile(screenStream); await replaceOutboundVideoTrack(screenTrack); shareButton.textContent = "Stop Sharing"; await sendRoomEvent({ type: "presentation-state", sharing: true }); screenTrack.onended = () => { stopScreenShare().catch((error) => { console.error(error); setStatus("Could not stop screen sharing cleanly."); }); }; } async function stopScreenShare() { if (!screenStream) return; const cameraTrack = localStream?.getVideoTracks()[0]; screenStream.getTracks().forEach((track) => track.stop()); screenStream = undefined; removeLocalPresentationTile(); if (cameraTrack) { await replaceOutboundVideoTrack(cameraTrack); } shareButton.textContent = "Share Screen"; await sendRoomEvent({ type: "presentation-state", sharing: false }); } initialNameForm.addEventListener("submit", (event) => { event.preventDefault(); setDisplayName(initialNameInput.value); nameDialog.close(); if (events) { joinCall().catch((error) => { console.error(error); setStatus("Could not rejoin with the updated name."); }); } else { joinCall().catch((error) => { console.error(error); setStatus("Could not start camera or join the call."); }); } }); nameDialog.addEventListener("cancel", (event) => { event.preventDefault(); }); renameButton.addEventListener("click", () => { openNameDialog("rename"); }); cameraButton.addEventListener("click", () => { const enabled = cameraButton.getAttribute("aria-pressed") !== "true"; updateToggleButton(cameraButton, enabled, "Turn camera off", "Turn camera on"); setTrackEnabled("video", enabled); }); micButton.addEventListener("click", () => { const enabled = micButton.getAttribute("aria-pressed") !== "true"; updateToggleButton(micButton, enabled, "Mute microphone", "Unmute microphone"); setTrackEnabled("audio", enabled); }); cameraSelect.addEventListener("change", () => { replaceLocalTrack("video", cameraSelect.value).catch((error) => { console.error(error); setStatus("Could not switch camera."); }); }); micSelect.addEventListener("change", () => { replaceLocalTrack("audio", micSelect.value).catch((error) => { console.error(error); setStatus("Could not switch microphone."); }); }); youtubeForm.addEventListener("submit", async (event) => { event.preventDefault(); addYoutubeVideo(youtubeInput.value).catch((error) => { console.error(error); setStatus("Could not add YouTube video."); }); }); 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; } 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."); }); }); shareButton.addEventListener("click", async () => { if (!localStream) return; if (screenStream) { await stopScreenShare(); } else { await startScreenShare(); } }); const savedName = localStorage.getItem(nameStorageKey); if (savedName) { setDisplayName(savedName); joinCall().catch((error) => { console.error(error); setStatus("Could not start camera or join the call."); }); } else { setStatus("Choose your name to join the call."); openNameDialog("join"); refreshDeviceLists().catch(console.error); } navigator.mediaDevices?.addEventListener?.("devicechange", () => { refreshDeviceLists().catch(console.error); }); setInterval(refreshActivePlaylistTimestamp, 1000); applyStoredWatchHeight(); watchResizeHandle.addEventListener("pointerdown", (event) => { if (event.target.closest("#watch-collapse-button") || stage.classList.contains("is-watch-collapsed")) { return; } 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); }); watchCollapseButton.addEventListener("click", (event) => { event.stopPropagation(); toggleWatchCollapsed(); }); watchCollapseButton.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); toggleWatchCollapsed(); });