Files
kkkmeet/public/app.js
T
2026-06-09 22:33:43 +02:00

821 lines
25 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 nameForm = document.querySelector("#name-form");
const nameInput = document.querySelector("#name-input");
const statusText = document.querySelector("#status");
const youtubeForm = document.querySelector("#youtube-form");
const youtubeInput = document.querySelector("#youtube-input");
const youtubeShell = document.querySelector("#youtube-shell");
const watchPanel = document.querySelector(".watch-panel");
const watchResizeHandle = document.querySelector("#watch-resize-handle");
const watchCollapseButton = document.querySelector("#watch-collapse-button");
const playlistList = document.querySelector("#playlist-list");
const clearPlaylistButton = document.querySelector("#clear-playlist-button");
const cameraSelect = document.querySelector("#camera-select");
const micSelect = document.querySelector("#mic-select");
const cameraButton = document.querySelector("#camera-button");
const micButton = document.querySelector("#mic-button");
const shareButton = document.querySelector("#share-button");
const roomId = "main";
const nameStorageKey = "kkkmeet.displayName";
const cameraStorageKey = "kkkmeet.cameraDeviceId";
const micStorageKey = "kkkmeet.micDeviceId";
const watchHeightStorageKey = "kkkmeet.watchPanelHeight";
const watchCollapsedStorageKey = "kkkmeet.watchPanelCollapsed";
const rtcConfig = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
};
let peerId = "";
let displayName = "";
let localStream;
let screenStream;
let events;
let youtubePlayer;
let youtubeReady = false;
let currentYoutubeVideoId = "";
let applyingWatchStateUntil = 0;
let watchPlaylist = [];
const peers = new Map();
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function applyStoredWatchHeight() {
const savedHeight = Number(localStorage.getItem(watchHeightStorageKey));
if (savedHeight > 0) {
stage.style.setProperty("--watch-panel-height", `${savedHeight}px`);
}
setWatchCollapsed(localStorage.getItem(watchCollapsedStorageKey) === "true");
}
function setWatchCollapsed(collapsed) {
stage.classList.toggle("is-watch-collapsed", collapsed);
watchCollapseButton.textContent = collapsed ? "↓" : "↑";
watchCollapseButton.setAttribute(
"aria-label",
collapsed ? "Expand YouTube panel" : "Collapse YouTube panel"
);
watchCollapseButton.title = collapsed ? "Expand YouTube panel" : "Collapse YouTube panel";
localStorage.setItem(watchCollapsedStorageKey, String(collapsed));
}
function toggleWatchCollapsed() {
if (!stage.classList.contains("is-watch-collapsed")) {
const height = Math.round(watchPanel.getBoundingClientRect().height);
if (height > 0) {
localStorage.setItem(watchHeightStorageKey, String(height));
}
}
setWatchCollapsed(!stage.classList.contains("is-watch-collapsed"));
}
function setStatus(message) {
statusText.textContent = message;
}
function normalizeName(name) {
return name.trim().replace(/\s+/g, " ").slice(0, 48);
}
function setDisplayName(nextName) {
displayName = normalizeName(nextName);
localStorage.setItem(nameStorageKey, displayName);
nameInput.value = displayName;
localNameLabel.textContent = displayName ? `${displayName} (you)` : "You";
}
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()
}))
.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 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.textContent = item.title;
playButton.title = item.title;
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 loadYoutubeApi() {
return new Promise((resolve) => {
if (window.YT?.Player) {
resolve();
return;
}
const previousReady = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
previousReady?.();
resolve();
};
if (!document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) {
const script = document.createElement("script");
script.src = "https://www.youtube.com/iframe_api";
document.head.append(script);
}
});
}
async function ensureYoutubePlayer() {
await loadYoutubeApi();
if (youtubePlayer) return youtubePlayer;
youtubePlayer = new YT.Player("youtube-player", {
playerVars: {
controls: 1,
disablekb: 0,
modestbranding: 1,
rel: 0
},
events: {
onReady: () => {
youtubeReady = true;
},
onStateChange: handleYoutubeStateChange
}
});
return youtubePlayer;
}
async function sendWatchState({ videoId = currentYoutubeVideoId, position = 0, playing = false }) {
if (!peerId) return;
await fetch(`/watch/${encodeURIComponent(roomId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "watch-state",
from: peerId,
name: displayName,
videoId,
playlist: watchPlaylist,
position,
playing,
updatedAt: Date.now()
})
});
}
function currentWatchState(playing) {
if (!youtubeReady || !currentYoutubeVideoId) return null;
return {
videoId: currentYoutubeVideoId,
playlist: watchPlaylist,
position: youtubePlayer.getCurrentTime?.() || 0,
playing
};
}
function handleYoutubeStateChange(event) {
if (Date.now() < applyingWatchStateUntil) return;
if (![YT.PlayerState.PLAYING, YT.PlayerState.PAUSED].includes(event.data)) return;
const state = currentWatchState(event.data === YT.PlayerState.PLAYING);
if (state) {
sendWatchState(state).catch((error) => {
console.error(error);
setStatus("Could not sync YouTube playback.");
});
}
}
async function applyWatchState(state) {
watchPlaylist = normalizedPlaylist(state?.playlist);
currentYoutubeVideoId = state?.videoId || "";
renderPlaylist();
if (!currentYoutubeVideoId) {
youtubePlayer?.stopVideo?.();
youtubeShell.classList.remove("is-active");
return;
}
youtubeShell.classList.add("is-active");
const player = await ensureYoutubePlayer();
applyingWatchStateUntil = Date.now() + 1200;
const elapsed = state.playing ? Math.max(0, (Date.now() - state.updatedAt) / 1000) : 0;
const position = Math.max(0, Number(state.position || 0) + elapsed);
if (youtubeReady) {
if (player.getVideoData?.().video_id !== currentYoutubeVideoId) {
player.loadVideoById(currentYoutubeVideoId, position);
} else {
player.seekTo(position, true);
}
if (state.playing) {
player.playVideo();
} else {
player.pauseVideo();
}
} else {
const interval = setInterval(() => {
if (!youtubeReady) return;
clearInterval(interval);
applyWatchState(state).catch(console.error);
}, 100);
}
}
function mediaConstraints() {
const cameraDeviceId = localStorage.getItem(cameraStorageKey);
const micDeviceId = localStorage.getItem(micStorageKey);
return {
audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true,
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
...(cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : {})
}
};
}
async function ensureLocalStream() {
if (localStream) return localStream;
try {
localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints());
} catch (error) {
localStorage.removeItem(cameraStorageKey);
localStorage.removeItem(micStorageKey);
localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints());
}
localVideo.srcObject = localStream;
await refreshDeviceLists();
return localStream;
}
function trackDeviceId(kind) {
return localStream?.getTracks().find((track) => track.kind === kind)?.getSettings().deviceId || "";
}
function fillDeviceSelect(select, devices, selectedDeviceId, fallbackLabel) {
select.replaceChildren();
if (devices.length === 0) {
const option = new Option(`No ${fallbackLabel.toLowerCase()} found`, "");
select.add(option);
select.disabled = true;
return;
}
select.disabled = false;
devices.forEach((device, index) => {
const label = device.label || `${fallbackLabel} ${index + 1}`;
select.add(new Option(label, device.deviceId));
});
select.value = selectedDeviceId && devices.some((device) => device.deviceId === selectedDeviceId)
? selectedDeviceId
: devices[0].deviceId;
}
async function refreshDeviceLists() {
if (!navigator.mediaDevices?.enumerateDevices) return;
const devices = await navigator.mediaDevices.enumerateDevices();
fillDeviceSelect(
cameraSelect,
devices.filter((device) => device.kind === "videoinput"),
trackDeviceId("video") || localStorage.getItem(cameraStorageKey),
"Camera"
);
fillDeviceSelect(
micSelect,
devices.filter((device) => device.kind === "audioinput"),
trackDeviceId("audio") || localStorage.getItem(micStorageKey),
"Microphone"
);
}
async function replaceLocalTrack(kind, deviceId) {
if (!deviceId) return;
if (!localStream) {
localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId);
return;
}
const oldTrack = localStream?.getTracks().find((track) => track.kind === kind);
const wasEnabled = oldTrack?.enabled ?? true;
const constraints = kind === "video"
? { video: { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } }
: { audio: { deviceId: { exact: deviceId } } };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const newTrack = stream.getTracks()[0];
newTrack.enabled = wasEnabled;
if (!localStream) {
localStream = new MediaStream([newTrack]);
} else {
if (oldTrack) {
localStream.removeTrack(oldTrack);
oldTrack.stop();
}
localStream.addTrack(newTrack);
}
if (kind === "audio" || !screenStream) {
for (const { connection } of peers.values()) {
const sender = connection.getSenders().find((item) => item.track?.kind === kind);
await sender?.replaceTrack(newTrack);
}
}
if (kind === "video" && !screenStream) {
localVideo.srcObject = localStream;
}
localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId);
await refreshDeviceLists();
}
function updateRemoteLabel(id, name) {
const label = document.querySelector(`[data-peer-id="${id}"] span`);
if (label) label.textContent = name || `Participant ${id.slice(0, 4)}`;
}
function createRemoteTile(peer) {
const id = typeof peer === "string" ? peer : peer.id;
const fragment = remoteTemplate.content.cloneNode(true);
const tile = fragment.querySelector(".tile");
const video = fragment.querySelector("video");
const label = fragment.querySelector("span");
tile.dataset.peerId = id;
label.textContent = labelForPeer(typeof peer === "string" ? { id } : peer);
remoteGrid.append(tile);
return { tile, video };
}
function removeRemoteTile(id) {
document.querySelector(`[data-peer-id="${id}"]`)?.remove();
}
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 })
});
}
function createPeerConnection(remotePeer) {
const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id;
if (peers.has(remotePeerId)) {
updateRemoteLabel(remotePeerId, remotePeer.name);
return peers.get(remotePeerId).connection;
}
const connection = new RTCPeerConnection(rtcConfig);
const remoteStream = new MediaStream();
const { video } = createRemoteTile(remotePeer);
video.srcObject = remoteStream;
localStream.getTracks().forEach((track) => {
connection.addTrack(track, localStream);
});
connection.ontrack = (event) => {
event.streams[0].getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
};
connection.onicecandidate = (event) => {
if (event.candidate) {
sendSignal(remotePeerId, { type: "ice-candidate", candidate: event.candidate });
}
};
connection.onconnectionstatechange = () => {
if (["closed", "disconnected", "failed"].includes(connection.connectionState)) {
closePeer(remotePeerId);
}
};
peers.set(remotePeerId, { connection, remoteStream, name: remotePeer.name });
return connection;
}
async function callPeer(remotePeer) {
const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id;
const connection = createPeerConnection(remotePeer);
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
await sendSignal(remotePeerId, { type: "offer", description: connection.localDescription });
}
async function answerPeer(message) {
const connection = createPeerConnection({ id: message.from, name: message.name });
await connection.setRemoteDescription(message.description);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
await sendSignal(message.from, { type: "answer", description: connection.localDescription });
}
async function handleSignal(message) {
if (message.type === "welcome") {
peerId = message.peerId;
setStatus(`Joined the call as ${displayName}. ${message.peers.length} other participant(s).`);
if (message.watchState) {
await applyWatchState(message.watchState);
}
for (const existingPeer of message.peers) {
await callPeer(existingPeer);
}
return;
}
if (message.type === "peer-joined") {
updateRemoteLabel(message.peerId, message.name);
setStatus(`${message.name || "A participant"} joined.`);
return;
}
if (message.type === "peer-left") {
closePeer(message.peerId);
setStatus("A participant left.");
return;
}
if (message.type === "offer") {
await answerPeer(message);
return;
}
if (message.type === "watch-state") {
await applyWatchState(message);
setStatus(`${message.name || "Someone"} synced YouTube playback.`);
return;
}
const connection = peers.get(message.from)?.connection;
if (!connection) return;
if (message.type === "answer") {
await connection.setRemoteDescription(message.description);
}
if (message.type === "ice-candidate") {
await connection.addIceCandidate(message.candidate);
}
}
function closePeer(remotePeerId) {
const peer = peers.get(remotePeerId);
if (!peer) return;
peer.connection.close();
peers.delete(remotePeerId);
removeRemoteTile(remotePeerId);
}
async function joinCall() {
if (!displayName) {
setStatus("Enter your name to join the call.");
nameInput.focus();
return;
}
leaveCall();
await ensureLocalStream();
const params = new URLSearchParams({ name: displayName });
events = new EventSource(`/events/${encodeURIComponent(roomId)}?${params}`);
events.onmessage = (event) => handleSignal(JSON.parse(event.data)).catch(console.error);
events.onerror = () => setStatus("Connection to signaling server was interrupted.");
setStatus(`Joining as ${displayName}...`);
}
function leaveCall() {
events?.close();
events = undefined;
peerId = "";
for (const remotePeerId of peers.keys()) closePeer(remotePeerId);
setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call.");
}
function setTrackEnabled(kind, enabled) {
localStream?.getTracks().forEach((track) => {
if (track.kind === kind) track.enabled = enabled;
});
}
function updateToggleButton(button, enabled, enabledLabel, disabledLabel) {
button.setAttribute("aria-pressed", String(enabled));
button.setAttribute("aria-label", enabled ? enabledLabel : disabledLabel);
button.title = enabled ? enabledLabel : disabledLabel;
button.textContent = enabled ? "●" : "○";
}
async function addYoutubeVideo(value) {
const videoId = parseYoutubeId(value);
if (!videoId) {
setStatus("Enter a valid YouTube URL.");
youtubeInput.focus();
return;
}
let item = watchPlaylist.find((entry) => entry.videoId === videoId);
if (!item) {
item = { videoId, title: await fetchYoutubeTitle(videoId) };
watchPlaylist = [...watchPlaylist, item];
}
youtubeInput.value = "";
await applyWatchState({
videoId,
playlist: watchPlaylist,
position: 0,
playing: true,
updatedAt: Date.now()
});
await sendWatchState({ videoId, position: 0, playing: true });
}
async function switchYoutubeVideo(videoId) {
if (!watchPlaylist.some((item) => item.videoId === videoId)) return;
await applyWatchState({
videoId,
playlist: watchPlaylist,
position: 0,
playing: true,
updatedAt: Date.now()
});
await sendWatchState({ videoId, position: 0, playing: true });
}
async function deleteYoutubeVideo(videoId) {
const wasActive = currentYoutubeVideoId === videoId;
watchPlaylist = watchPlaylist.filter((item) => item.videoId !== videoId);
const nextVideoId = wasActive ? watchPlaylist[0]?.videoId || "" : currentYoutubeVideoId;
await applyWatchState({
videoId: nextVideoId,
playlist: watchPlaylist,
position: 0,
playing: Boolean(nextVideoId),
updatedAt: Date.now()
});
await sendWatchState({ videoId: nextVideoId, position: 0, playing: Boolean(nextVideoId) });
}
async function clearYoutubePlaylist() {
watchPlaylist = [];
await applyWatchState({
videoId: "",
playlist: watchPlaylist,
position: 0,
playing: false,
updatedAt: Date.now()
});
await sendWatchState({ videoId: "", position: 0, playing: false });
}
nameForm.addEventListener("submit", (event) => {
event.preventDefault();
setDisplayName(nameInput.value);
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
});
nameInput.addEventListener("change", () => {
setDisplayName(nameInput.value);
if (events) {
joinCall().catch((error) => {
console.error(error);
setStatus("Could not rejoin with the updated name.");
});
}
});
cameraButton.addEventListener("click", () => {
const enabled = cameraButton.getAttribute("aria-pressed") !== "true";
updateToggleButton(cameraButton, enabled, "Turn camera off", "Turn camera on");
setTrackEnabled("video", enabled);
});
micButton.addEventListener("click", () => {
const enabled = micButton.getAttribute("aria-pressed") !== "true";
updateToggleButton(micButton, enabled, "Mute microphone", "Unmute microphone");
setTrackEnabled("audio", enabled);
});
cameraSelect.addEventListener("change", () => {
replaceLocalTrack("video", cameraSelect.value).catch((error) => {
console.error(error);
setStatus("Could not switch camera.");
});
});
micSelect.addEventListener("change", () => {
replaceLocalTrack("audio", micSelect.value).catch((error) => {
console.error(error);
setStatus("Could not switch microphone.");
});
});
youtubeForm.addEventListener("submit", async (event) => {
event.preventDefault();
addYoutubeVideo(youtubeInput.value).catch((error) => {
console.error(error);
setStatus("Could not add YouTube video.");
});
});
playlistList.addEventListener("click", (event) => {
const item = event.target.closest(".playlist-item");
if (!item) return;
if (event.target.closest(".playlist-delete")) {
deleteYoutubeVideo(item.dataset.videoId).catch((error) => {
console.error(error);
setStatus("Could not delete YouTube video.");
});
return;
}
if (event.target.closest(".playlist-play")) {
switchYoutubeVideo(item.dataset.videoId).catch((error) => {
console.error(error);
setStatus("Could not switch YouTube video.");
});
}
});
clearPlaylistButton.addEventListener("click", () => {
clearYoutubePlaylist().catch((error) => {
console.error(error);
setStatus("Could not clear YouTube playlist.");
});
});
shareButton.addEventListener("click", async () => {
if (!localStream) return;
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = screenStream.getVideoTracks()[0];
for (const { connection } of peers.values()) {
const sender = connection.getSenders().find((item) => item.track?.kind === "video");
await sender?.replaceTrack(screenTrack);
}
localVideo.srcObject = screenStream;
screenTrack.onended = async () => {
const cameraTrack = localStream.getVideoTracks()[0];
for (const { connection } of peers.values()) {
const sender = connection.getSenders().find((item) => item.track?.kind === "video");
await sender?.replaceTrack(cameraTrack);
}
localVideo.srcObject = localStream;
screenStream = undefined;
};
});
const savedName = localStorage.getItem(nameStorageKey);
if (savedName) {
setDisplayName(savedName);
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
} else {
nameInput.focus();
refreshDeviceLists().catch(console.error);
}
navigator.mediaDevices?.addEventListener?.("devicechange", () => {
refreshDeviceLists().catch(console.error);
});
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();
});