1723 lines
54 KiB
JavaScript
1723 lines
54 KiB
JavaScript
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();
|
||
});
|