Files
kkkmeet/public/app.js
T

1723 lines
54 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 captchaControl = document.querySelector("#captcha-control");
const captchaCanvas = document.querySelector("#captcha-canvas");
const captchaInput = document.querySelector("#captcha-input");
const statusText = document.querySelector("#status");
const activityStatusText = document.querySelector("#activity-status");
const youtubeForm = document.querySelector("#youtube-form");
const youtubeInput = document.querySelector("#youtube-input");
const youtubeShell = document.querySelector("#youtube-shell");
const youtubeStartButton = document.querySelector("#youtube-start-button");
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 selfMicVolumeInput = document.querySelector("#self-mic-volume");
const selfMicMeter = document.querySelector("#self-mic-meter");
const noiseSuppressionInput = document.querySelector("#noise-suppression");
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 clientIdStorageKey = "kkkmeet.clientId";
const captchaAnswerStorageKey = "kkkmeet.captchaAnswer";
const cameraStorageKey = "kkkmeet.cameraDeviceId";
const micStorageKey = "kkkmeet.micDeviceId";
const micVolumeStorageKey = "kkkmeet.selfMicVolume";
const noiseSuppressionStorageKey = "kkkmeet.noiseSuppression";
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 localProcessedAudioTrack;
let localMicSourceNode;
let localMicGainNode;
let localMicAnalyserNode;
let localMicDestination;
let micMeterAnimationFrame = 0;
let micMeterLevel = 0;
let screenStream;
let localPresentationTile;
let events;
let audioContext;
let youtubePlayer;
let youtubeReady = false;
let currentYoutubeVideoId = "";
let applyingWatchStateUntil = 0;
let pauseEnforcementUntil = 0;
let syncedPlaybackRetryUntil = 0;
let youtubeStartPromptAllowedUntil = 0;
let allowJoinedYoutubeStartPrompt = false;
let desiredYoutubePlaying = false;
let youtubeReadyResolvers = [];
let watchApplySequence = 0;
let youtubePlaybackRequestId = 0;
let watchPlaylist = [];
let remoteParticipantCount = 0;
const peers = new Map();
function persistentClientId() {
let storedClientId = localStorage.getItem(clientIdStorageKey);
if (!storedClientId) {
storedClientId = crypto.randomUUID?.() ||
`client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem(clientIdStorageKey, storedClientId);
}
return storedClientId;
}
const clientId = persistentClientId();
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() {
const wasCollapsed = stage.classList.contains("is-watch-collapsed");
if (!stage.classList.contains("is-watch-collapsed")) {
const height = Math.round(watchPanel.getBoundingClientRect().height);
if (height > 0) {
localStorage.setItem(watchHeightStorageKey, String(height));
}
}
setWatchCollapsed(!wasCollapsed);
if (wasCollapsed) {
requestAnimationFrame(() => {
ensureCurrentYoutubeVideoLoaded().catch((error) => {
console.error(error);
setStatus("Could not load the paused YouTube video.");
});
});
}
}
function setStatus(message) {
statusText.textContent = message;
}
function participantDisplayName(name, fallback = "Someone") {
return normalizeName(String(name || "")) || fallback;
}
function setSessionSummary() {
if (!peerId) {
setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call.");
return;
}
const suffix = remoteParticipantCount === 1 ? "participant" : "participants";
setStatus(`You are ${displayName}. ${remoteParticipantCount} other ${suffix}.`);
}
function setActivity(name, action) {
const actor = participantDisplayName(name);
const message = `${actor} ${action}`;
activityStatusText.textContent = message;
console.info(`[kkkmeet activity] ${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";
setSessionSummary();
}
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 : displayName || "";
captchaControl.hidden = renaming;
captchaInput.required = !renaming;
if (!renaming) {
captchaInput.value = localStorage.getItem(captchaAnswerStorageKey) || "";
drawCaptchaPassphrase().catch((error) => {
console.error(error);
setStatus("Could not load captcha image.");
});
}
nameDialog.dataset.mode = mode;
nameDialog.showModal();
initialNameInput.focus();
initialNameInput.select();
}
async function drawCaptchaPassphrase() {
const context = captchaCanvas.getContext("2d");
if (!context) return;
const width = captchaCanvas.width;
const height = captchaCanvas.height;
context.clearRect(0, 0, width, height);
context.fillStyle = "#eef2f6";
context.fillRect(0, 0, width, height);
const image = new Image();
image.decoding = "async";
image.src = `/captcha.svg?${Date.now()}`;
await image.decode();
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
}
function captchaAnswer() {
return String(captchaInput.value || localStorage.getItem(captchaAnswerStorageKey) || "").trim();
}
async function captchaIsCorrect() {
const answer = captchaAnswer();
if (!answer) return false;
const params = new URLSearchParams({ answer });
const response = await fetch(`/captcha/verify?${params}`, { cache: "no-store" });
if (!response.ok) return false;
const result = await response.json();
return Boolean(result.ok);
}
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(initialState = {}) {
await loadYoutubeApi();
if (youtubePlayer) return youtubePlayer;
const initialVideoId = initialState.videoId || "";
const initialPosition = Math.max(0, Number(initialState.position) || 0);
const shouldAutoplay = Boolean(initialState.autoplay && initialVideoId);
const options = {
playerVars: {
controls: 1,
disablekb: 0,
modestbranding: 1,
playsinline: 1,
autoplay: shouldAutoplay ? 1 : 0,
origin: window.location.origin,
rel: 0
},
events: {
onReady: () => {
youtubeReady = true;
const iframe = youtubePlayer?.getIframe?.();
iframe?.setAttribute("allow", "autoplay; encrypted-media; fullscreen; picture-in-picture");
youtubeReadyResolvers.splice(0).forEach((resolve) => resolve());
if (shouldAutoplay) {
requestYoutubePlayback(
youtubePlayer,
initialVideoId,
initialPosition,
Boolean(initialState.allowStartPrompt)
);
}
},
onStateChange: handleYoutubeStateChange
}
};
if (initialVideoId) {
options.videoId = initialVideoId;
options.playerVars.start = Math.floor(initialPosition);
}
youtubePlayer = new YT.Player("youtube-player", options);
return youtubePlayer;
}
function whenYoutubeReady() {
if (youtubeReady) return Promise.resolve();
return new Promise((resolve) => {
youtubeReadyResolvers.push(resolve);
});
}
function loadYoutubeVideo(player, videoId, position) {
player.loadVideoById({
videoId,
startSeconds: position
});
}
function setYoutubeStartPromptVisible(visible) {
youtubeStartButton.hidden = !visible;
}
function requestYoutubePlayback(player, videoId, position, allowStartPrompt = false) {
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);
}
setTimeout(() => {
if (
requestId === youtubePlaybackRequestId &&
desiredYoutubePlaying &&
currentYoutubeVideoId === videoId &&
allowStartPrompt &&
Date.now() < youtubeStartPromptAllowedUntil &&
player.getPlayerState?.() !== YT.PlayerState.PLAYING
) {
setYoutubeStartPromptVisible(true);
}
}, 900);
}
async function ensureCurrentYoutubeVideoLoaded() {
if (!currentYoutubeVideoId) return;
const position = currentYoutubeSnapshot(savedPositionForVideo(currentYoutubeVideoId)).position;
youtubeShell.classList.add("is-active");
const player = await ensureYoutubePlayer({
videoId: currentYoutubeVideoId,
position,
autoplay: desiredYoutubePlaying
});
await whenYoutubeReady();
if (!currentYoutubeVideoId) return;
const loadedVideoId = player.getVideoData?.().video_id;
const playerState = player.getPlayerState?.();
if (desiredYoutubePlaying) {
requestYoutubePlayback(player, currentYoutubeVideoId, position);
return;
}
if (
loadedVideoId !== currentYoutubeVideoId ||
[YT.PlayerState.UNSTARTED, YT.PlayerState.CUED].includes(playerState)
) {
player.cueVideoById(currentYoutubeVideoId, position);
} else {
player.pauseVideo?.();
player.seekTo?.(position, true);
}
}
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()
})
});
setActivity(displayName, "synced YouTube playback.");
}
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,
Date.now() < youtubeStartPromptAllowedUntil
);
return;
}
if (event.data === YT.PlayerState.PLAYING) {
setYoutubeStartPromptVisible(false);
setTimeout(() => {
if (
Date.now() < youtubeStartPromptAllowedUntil &&
desiredYoutubePlaying &&
youtubePlayer?.getPlayerState?.() !== YT.PlayerState.PLAYING
) {
setYoutubeStartPromptVisible(true);
}
}, 700);
}
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;
setYoutubeStartPromptVisible(false);
} else {
youtubeStartPromptAllowedUntil = 0;
setYoutubeStartPromptVisible(false);
}
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, allowStartPrompt = false } = {}) {
const applySequence = ++watchApplySequence;
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;
allowJoinedYoutubeStartPrompt = false;
youtubePlaybackRequestId++;
syncedPlaybackRetryUntil = 0;
youtubeStartPromptAllowedUntil = 0;
setYoutubeStartPromptVisible(false);
youtubePlayer?.stopVideo?.();
youtubeShell.classList.remove("is-active");
setWatchCollapsed(true);
return;
}
if (shouldPlay) {
setWatchCollapsed(false);
}
const updatedAt = Number(state.updatedAt) || Date.now();
const elapsed = shouldPlay ? Math.max(0, (Date.now() - updatedAt) / 1000) : 0;
const position = Math.max(0, Number(state.position || 0) + elapsed);
youtubeShell.classList.add("is-active");
const player = await ensureYoutubePlayer({
videoId: currentYoutubeVideoId,
position,
autoplay: shouldPlay,
allowStartPrompt
});
await whenYoutubeReady();
if (applySequence !== watchApplySequence) return;
if (suppressEvents) {
applyingWatchStateUntil = Date.now() + 3000;
}
if (shouldPlay) {
pauseEnforcementUntil = 0;
if (suppressEvents) {
syncedPlaybackRetryUntil = Date.now() + 8000;
}
youtubeStartPromptAllowedUntil = allowStartPrompt ? Date.now() + 10000 : 0;
} else {
youtubePlaybackRequestId++;
syncedPlaybackRetryUntil = 0;
youtubeStartPromptAllowedUntil = 0;
setYoutubeStartPromptVisible(false);
pauseEnforcementUntil = Math.max(pauseEnforcementUntil, Date.now() + 3000);
}
if (shouldPlay) {
if (allowStartPrompt) {
setYoutubeStartPromptVisible(true);
}
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, allowStartPrompt);
} 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);
}
}
function mediaConstraints() {
const cameraDeviceId = localStorage.getItem(cameraStorageKey);
const micDeviceId = localStorage.getItem(micStorageKey);
return {
audio: audioConstraints(micDeviceId),
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
...(cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : {})
}
};
}
function savedNoiseSuppression() {
return localStorage.getItem(noiseSuppressionStorageKey) !== "false";
}
function audioConstraints(deviceId = "") {
return {
noiseSuppression: savedNoiseSuppression(),
...(deviceId ? { deviceId: { exact: deviceId } } : {})
};
}
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;
rebuildLocalMicGraph(localStream.getAudioTracks()[0]);
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"
);
}
function savedSelfMicVolume() {
const saved = Number(localStorage.getItem(micVolumeStorageKey));
return Number.isFinite(saved) ? clamp(saved, 0, 200) : 100;
}
function setSelfMicVolume(value) {
const normalizedVolume = clamp(Number(value) || 0, 0, 200);
selfMicVolumeInput.value = String(normalizedVolume);
localStorage.setItem(micVolumeStorageKey, String(normalizedVolume));
if (localMicGainNode) {
localMicGainNode.gain.value = normalizedVolume / 100;
}
}
function stopMicMeter() {
if (micMeterAnimationFrame) {
cancelAnimationFrame(micMeterAnimationFrame);
micMeterAnimationFrame = 0;
}
micMeterLevel = 0;
selfMicMeter.value = 0;
}
function startMicMeter() {
stopMicMeter();
if (!localMicAnalyserNode) return;
const samples = new Uint8Array(localMicAnalyserNode.fftSize);
const tick = () => {
localMicAnalyserNode.getByteTimeDomainData(samples);
let sum = 0;
for (const sample of samples) {
const centered = (sample - 128) / 128;
sum += centered * centered;
}
const targetLevel = Math.min(1, Math.sqrt(sum / samples.length) * 3);
const smoothing = targetLevel > micMeterLevel ? 0.35 : 0.12;
micMeterLevel += (targetLevel - micMeterLevel) * smoothing;
selfMicMeter.value = micMeterLevel;
micMeterAnimationFrame = requestAnimationFrame(tick);
};
tick();
}
function rebuildLocalMicGraph(track) {
localMicSourceNode?.disconnect();
localMicGainNode?.disconnect();
localMicAnalyserNode?.disconnect();
stopMicMeter();
localProcessedAudioTrack?.stop();
localProcessedAudioTrack = undefined;
localMicSourceNode = undefined;
localMicGainNode = undefined;
localMicAnalyserNode = undefined;
localMicDestination = undefined;
if (!track) return;
const context = ensureAudioContext();
const sourceStream = new MediaStream([track]);
localMicSourceNode = context.createMediaStreamSource(sourceStream);
localMicGainNode = context.createGain();
localMicAnalyserNode = context.createAnalyser();
localMicAnalyserNode.fftSize = 256;
localMicDestination = context.createMediaStreamDestination();
localMicSourceNode.connect(localMicGainNode);
localMicGainNode.connect(localMicAnalyserNode);
localMicGainNode.connect(localMicDestination);
localProcessedAudioTrack = localMicDestination.stream.getAudioTracks()[0];
localProcessedAudioTrack.enabled = track.enabled;
setSelfMicVolume(selfMicVolumeInput.value);
startMicMeter();
}
function outboundAudioTrack() {
return localProcessedAudioTrack || localStream?.getAudioTracks()[0];
}
async function replaceLocalTrack(kind, deviceId) {
if (kind !== "audio" && !deviceId) return;
if (!localStream) {
if (deviceId) {
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: audioConstraints(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") {
rebuildLocalMicGraph(newTrack);
}
if (kind === "audio" || !screenStream) {
for (const { connection } of peers.values()) {
const sender = connection.getSenders().find((item) => item.track?.kind === kind);
await sender?.replaceTrack(kind === "audio" ? outboundAudioTrack() : newTrack);
}
}
if (kind === "video" && !screenStream) {
localVideo.srcObject = localStream;
}
localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId);
await refreshDeviceLists();
}
function volumeStorageKey(remoteClientId) {
return `kkkmeet.remoteVolume.${remoteClientId}`;
}
function savedRemoteVolume(remoteClientId) {
if (!remoteClientId) return 100;
const saved = Number(localStorage.getItem(volumeStorageKey(remoteClientId)));
return Number.isFinite(saved) ? clamp(saved, 0, 200) : 100;
}
function ensureAudioContext() {
if (!audioContext) {
audioContext = new AudioContext();
}
if (audioContext.state === "suspended") {
audioContext.resume().catch(console.error);
}
return audioContext;
}
function applyRemoteVolume(remotePeerId, volume) {
const peer = peers.get(remotePeerId);
if (!peer) return;
const normalizedVolume = clamp(Number(volume) || 0, 0, 200);
if (peer.gainNode) {
peer.gainNode.gain.value = normalizedVolume / 100;
}
if (peer.clientId) {
localStorage.setItem(volumeStorageKey(peer.clientId), String(normalizedVolume));
}
}
function rebuildRemoteAudio(peerId) {
const peer = peers.get(peerId);
if (!peer) return;
peer.sourceNode?.disconnect();
peer.gainNode?.disconnect();
peer.sourceNode = undefined;
peer.gainNode = undefined;
peer.audioDestination = undefined;
const audioTracks = peer.remoteStream.getAudioTracks();
if (audioTracks.length === 0) return;
const context = ensureAudioContext();
const audioStream = new MediaStream(audioTracks);
peer.sourceNode = context.createMediaStreamSource(audioStream);
peer.gainNode = context.createGain();
peer.audioDestination = context.createMediaStreamDestination();
peer.sourceNode.connect(peer.gainNode).connect(peer.audioDestination);
peer.audio.srcObject = peer.audioDestination.stream;
peer.audio.play().catch(() => {});
applyRemoteVolume(peerId, peer.volumeInput.value);
}
function updateRemoteLabel(id, name) {
const label = document.querySelector(`[data-peer-id="${id}"] .tile-name`);
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 audio = document.createElement("audio");
const label = fragment.querySelector(".tile-name");
const volumeInput = fragment.querySelector(".participant-volume");
const remoteClientId = typeof peer === "string" ? "" : peer.clientId || "";
tile.dataset.peerId = id;
tile.dataset.clientId = remoteClientId;
video.muted = true;
audio.autoplay = true;
audio.playsInline = true;
audio.hidden = true;
volumeInput.value = savedRemoteVolume(remoteClientId);
volumeInput.disabled = !remoteClientId;
volumeInput.addEventListener("input", () => {
applyRemoteVolume(id, volumeInput.value);
});
label.textContent = labelForPeer(typeof peer === "string" ? { id } : peer);
tile.append(audio);
remoteGrid.append(tile);
return { tile, video, audio, volumeInput };
}
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(".tile-name");
fragment.querySelector(".participant-audio-controls")?.remove();
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, clientId })
});
}
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, clientId })
});
}
function outboundTracks() {
const audioTracks = [outboundAudioTrack()].filter(Boolean);
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)) {
const peer = peers.get(remotePeerId);
if (remotePeer.clientId && peer.clientId !== remotePeer.clientId) {
peer.clientId = remotePeer.clientId;
peer.volumeInput.disabled = false;
peer.volumeInput.value = savedRemoteVolume(peer.clientId);
applyRemoteVolume(remotePeerId, peer.volumeInput.value);
}
updateRemoteLabel(remotePeerId, remotePeer.name);
return peer.connection;
}
const connection = new RTCPeerConnection(rtcConfig);
const remoteStream = new MediaStream();
const { video, audio, volumeInput } = 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);
});
rebuildRemoteAudio(remotePeerId);
};
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,
clientId: remotePeer.clientId || "",
audio,
volumeInput
});
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;
remoteParticipantCount = message.peers.length;
setSessionSummary();
setActivity(displayName, "joined.");
if (message.watchState) {
const watchShouldPlay = message.watchState.action === "play" ||
(!message.watchState.action && Boolean(message.watchState.playing));
allowJoinedYoutubeStartPrompt = Boolean(message.watchState.videoId && !watchShouldPlay);
await applyWatchState(message.watchState, { allowStartPrompt: watchShouldPlay });
}
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);
remoteParticipantCount += 1;
setSessionSummary();
setActivity(message.name, "joined.");
return;
}
if (message.type === "peer-left") {
const peerName = message.name ||
peers.get(message.peerId)?.name ||
`Participant ${message.peerId?.slice(0, 4) || ""}`.trim();
closePeer(message.peerId);
remoteParticipantCount = Math.max(0, remoteParticipantCount - 1);
setSessionSummary();
setActivity(peerName, "left.");
return;
}
if (message.type === "offer") {
await answerPeer(message);
return;
}
if (message.type === "watch-state") {
const watchShouldPlay = message.action === "play" || (!message.action && Boolean(message.playing));
const allowStartPrompt = allowJoinedYoutubeStartPrompt && watchShouldPlay;
if (allowStartPrompt) {
allowJoinedYoutubeStartPrompt = false;
}
await applyWatchState(message, { allowStartPrompt });
setActivity(message.name, "synced YouTube playback.");
return;
}
if (message.type === "presentation-state") {
setRemotePresentation(message.from, message.sharing, message.name);
setActivity(
message.name,
message.sharing ? "started sharing their screen." : "stopped sharing their screen."
);
return;
}
if (message.type === "voice-announcement") {
speakAnnouncement(message);
setActivity(message.name, "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.sourceNode?.disconnect();
peer.gainNode?.disconnect();
peer.audio.srcObject = null;
peer.connection.close();
peers.delete(remotePeerId);
removeRemoteTile(remotePeerId);
}
async function joinCall() {
if (!displayName) {
setStatus("Enter your name to join the call.");
openNameDialog("join");
return;
}
if (!(await captchaIsCorrect())) {
setStatus("Enter the captcha passphrase to join the call.");
openNameDialog("join");
captchaInput.focus();
return;
}
leaveCall();
await ensureLocalStream();
const params = new URLSearchParams({ name: displayName, clientId, captcha: captchaAnswer() });
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 or captcha was denied.");
setStatus(`Joining as ${displayName}...`);
}
function leaveCall() {
events?.close();
events = undefined;
peerId = "";
remoteParticipantCount = 0;
for (const remotePeerId of peers.keys()) closePeer(remotePeerId);
setSessionSummary();
}
function setTrackEnabled(kind, enabled) {
localStream?.getTracks().forEach((track) => {
if (track.kind === kind) track.enabled = enabled;
});
if (kind === "audio" && localProcessedAudioTrack) {
localProcessedAudioTrack.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 });
setActivity(displayName, "sent an announcement.");
}
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 });
setActivity(displayName, "started sharing their screen.");
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 });
setActivity(displayName, "stopped sharing their screen.");
}
initialNameForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (nameDialog.dataset.mode !== "rename" && !(await captchaIsCorrect())) {
localStorage.removeItem(captchaAnswerStorageKey);
captchaInput.value = "";
drawCaptchaPassphrase().catch((error) => {
console.error(error);
setStatus("Could not load captcha image.");
});
setStatus("Captcha passphrase did not match.");
captchaInput.focus();
return;
}
setDisplayName(initialNameInput.value);
if (nameDialog.dataset.mode !== "rename") {
localStorage.setItem(captchaAnswerStorageKey, captchaAnswer());
}
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.");
});
});
youtubeStartButton.addEventListener("click", () => {
if (!youtubePlayer || !currentYoutubeVideoId) return;
const position = currentYoutubeSnapshot().position;
if (youtubePlayer.getVideoData?.().video_id !== currentYoutubeVideoId) {
loadYoutubeVideo(youtubePlayer, currentYoutubeVideoId, position);
} else {
youtubePlayer.seekTo?.(position, true);
}
youtubePlayer.unMute?.();
youtubePlayer.playVideo?.();
requestYoutubePlayback(youtubePlayer, currentYoutubeVideoId, position);
setYoutubeStartPromptVisible(false);
});
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);
});
selfMicVolumeInput.addEventListener("input", () => {
setSelfMicVolume(selfMicVolumeInput.value);
});
noiseSuppressionInput.addEventListener("change", () => {
localStorage.setItem(noiseSuppressionStorageKey, String(noiseSuppressionInput.checked));
replaceLocalTrack("audio", micSelect.value).catch((error) => {
console.error(error);
noiseSuppressionInput.checked = !noiseSuppressionInput.checked;
localStorage.setItem(noiseSuppressionStorageKey, String(noiseSuppressionInput.checked));
setStatus("Could not change noise suppression.");
});
});
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();
}
});
selfMicVolumeInput.value = String(savedSelfMicVolume());
selfMicMeter.value = 0;
noiseSuppressionInput.checked = savedNoiseSuppression();
async function initializeApp() {
const savedName = localStorage.getItem(nameStorageKey);
if (savedName) {
setDisplayName(savedName);
if (await captchaIsCorrect()) {
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
return;
}
}
setStatus(savedName
? "Enter the captcha passphrase to join the call."
: "Choose your name to join the call.");
openNameDialog("join");
refreshDeviceLists().catch(console.error);
}
initializeApp().catch((error) => {
console.error(error);
setStatus("Could not initialize the application.");
});
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();
});