585 lines
17 KiB
JavaScript
585 lines
17 KiB
JavaScript
const localVideo = document.querySelector("#local-video");
|
|
const localNameLabel = document.querySelector("#local-name");
|
|
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 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 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;
|
|
const peers = new Map();
|
|
|
|
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 "";
|
|
}
|
|
|
|
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, position, playing }) {
|
|
if (!peerId || !videoId) return;
|
|
|
|
await fetch(`/watch/${encodeURIComponent(roomId)}`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "watch-state",
|
|
from: peerId,
|
|
name: displayName,
|
|
videoId,
|
|
position,
|
|
playing,
|
|
updatedAt: Date.now()
|
|
})
|
|
});
|
|
}
|
|
|
|
function currentWatchState(playing) {
|
|
if (!youtubeReady || !currentYoutubeVideoId) return null;
|
|
return {
|
|
videoId: currentYoutubeVideoId,
|
|
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) {
|
|
if (!state?.videoId) return;
|
|
|
|
youtubeShell.classList.add("is-active");
|
|
const player = await ensureYoutubePlayer();
|
|
currentYoutubeVideoId = state.videoId;
|
|
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 !== state.videoId) {
|
|
player.loadVideoById(state.videoId, 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 ? "●" : "○";
|
|
}
|
|
|
|
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();
|
|
const videoId = parseYoutubeId(youtubeInput.value);
|
|
|
|
if (!videoId) {
|
|
setStatus("Enter a valid YouTube URL.");
|
|
youtubeInput.focus();
|
|
return;
|
|
}
|
|
|
|
currentYoutubeVideoId = videoId;
|
|
await applyWatchState({
|
|
videoId,
|
|
position: 0,
|
|
playing: true,
|
|
updatedAt: Date.now()
|
|
});
|
|
await sendWatchState({ videoId, position: 0, playing: true });
|
|
});
|
|
|
|
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);
|
|
});
|