Files
kkkmeet/public/app.js
T

1238 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 announceButton = document.querySelector("#announce-button");
const announcementDialog = document.querySelector("#announcement-dialog");
const announcementForm = document.querySelector("#announcement-form");
const announcementInput = document.querySelector("#announcement-input");
const announcementCancelButton = document.querySelector("#announcement-cancel-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 syncedPlaybackRetryUntil = 0;
let desiredYoutubePlaying = false;
let pendingWatchState = null;
let pendingWatchStateOptions = null;
let youtubePlaybackRequestId = 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`);
}
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 normalizeAnnouncement(text) {
return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240);
}
function speakText(text) {
if (typeof window.speak !== "function") return;
try {
window.speak(text, {
amplitude: 100,
pitch: 32,
speed: 135,
wordgap: 4
});
} catch (error) {
console.warn("Could not play voice announcement.", error);
}
}
function announceParticipantJoined(name) {
const participantName = normalizeName(name || "A participant") || "A participant";
speakText(`${participantName} has joined.`);
}
function speakAnnouncement(message) {
const text = normalizeAnnouncement(message?.text || "");
if (!text) return;
speakText(text);
}
function openAnnouncementDialog() {
announcementInput.value = "";
announcementDialog.showModal();
announcementInput.focus();
}
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,
playsinline: 1,
autoplay: 1,
origin: window.location.origin,
rel: 0
},
events: {
onReady: () => {
youtubeReady = true;
const iframe = youtubePlayer?.getIframe?.();
iframe?.setAttribute("allow", "autoplay; encrypted-media; fullscreen; picture-in-picture");
if (pendingWatchState) {
const state = pendingWatchState;
const options = pendingWatchStateOptions || {};
pendingWatchState = null;
pendingWatchStateOptions = null;
applyWatchState(state, options).catch(console.error);
}
},
onStateChange: handleYoutubeStateChange
}
});
return youtubePlayer;
}
function loadYoutubeVideo(player, videoId, position) {
player.loadVideoById({
videoId,
startSeconds: position
});
}
function requestYoutubePlayback(player, videoId, position) {
const requestId = ++youtubePlaybackRequestId;
const startedAt = Date.now();
for (const delay of [0, 150, 750, 1500, 3000]) {
setTimeout(() => {
if (
requestId !== youtubePlaybackRequestId ||
!desiredYoutubePlaying ||
currentYoutubeVideoId !== videoId
) {
return;
}
const playerState = player.getPlayerState?.();
if (playerState === YT.PlayerState.PLAYING) return;
const elapsed = Math.max(0, (Date.now() - startedAt) / 1000);
const nextPosition = Math.max(0, position + elapsed);
const loadedVideoId = player.getVideoData?.().video_id;
if (loadedVideoId !== videoId) {
loadYoutubeVideo(player, videoId, nextPosition);
}
if (
loadedVideoId === videoId &&
![YT.PlayerState.CUED, YT.PlayerState.UNSTARTED].includes(playerState)
) {
player.seekTo(nextPosition, true);
}
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) {
const now = Date.now();
if (now < applyingWatchStateUntil) return;
if (
desiredYoutubePlaying &&
now < syncedPlaybackRetryUntil &&
[YT.PlayerState.UNSTARTED, YT.PlayerState.CUED, YT.PlayerState.PAUSED].includes(event.data)
) {
requestYoutubePlayback(youtubePlayer, currentYoutubeVideoId, currentYoutubeSnapshot().position);
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";
if (desiredYoutubePlaying) {
syncedPlaybackRetryUntil = 0;
}
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;
youtubePlaybackRequestId++;
syncedPlaybackRetryUntil = 0;
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;
if (suppressEvents) {
syncedPlaybackRetryUntil = Date.now() + 8000;
}
} else {
youtubePlaybackRequestId++;
syncedPlaybackRetryUntil = 0;
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 && ![YT.PlayerState.UNSTARTED, YT.PlayerState.CUED].includes(playerState)) {
player.seekTo(position, true);
} else {
loadYoutubeVideo(player, currentYoutubeVideoId, position);
}
if (playerState !== YT.PlayerState.PLAYING) {
player.playVideo();
}
requestYoutubePlayback(player, currentYoutubeVideoId, position);
} 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 {
pendingWatchState = state;
pendingWatchStateOptions = { suppressEvents };
}
}
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);
announceParticipantJoined(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;
}
if (message.type === "voice-announcement") {
speakAnnouncement(message);
setStatus(`${message.name || "Someone"} sent an announcement.`);
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 sendVoiceAnnouncement(text) {
const announcementText = normalizeAnnouncement(text);
if (!announcementText) return;
await sendRoomEvent({ type: "voice-announcement", text: announcementText });
speakAnnouncement({ text: announcementText });
setStatus("Announcement sent.");
}
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");
});
announceButton.addEventListener("click", () => {
openAnnouncementDialog();
});
announcementCancelButton.addEventListener("click", () => {
announcementDialog.close();
});
announcementDialog.addEventListener("cancel", () => {
announcementDialog.close();
});
announcementForm.addEventListener("submit", (event) => {
event.preventDefault();
const text = announcementInput.value;
announcementDialog.close();
sendVoiceAnnouncement(text).catch((error) => {
console.error(error);
setStatus("Could not send announcement.");
});
});
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();
});