Files
ladill-meet/resources/js/meet-room.js
T
isaacclad 86fa37b9fd
Deploy Ladill Meet / deploy (push) Successful in 2m44s
Reduce Meet poll load with shared cache, ETag, and slower clients.
Heavy per-participant poll queries were saturating the shared host during
live rooms. Cache the shared room payload for 2s, return 304 when unchanged,
throttle breakout expiry and captions, and slow client polling (6s / 20s
when hidden) so concurrent attendees no longer fan out full Laravel work.
2026-07-15 19:59:52 +00:00

3621 lines
129 KiB
JavaScript

import Alpine from 'alpinejs';
import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client';
function readMeetRoomBootState() {
const el = document.getElementById('meet-config');
if (!el) {
return {};
}
let presenterRefs = [];
let panels = [];
let sessionParticipants = [];
let roomHost = null;
try {
presenterRefs = JSON.parse(el.dataset.presenterRefs || '[]');
} catch {
presenterRefs = [];
}
try {
panels = JSON.parse(el.dataset.panels || '[]');
} catch {
panels = [];
}
try {
sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]');
} catch {
sessionParticipants = [];
}
try {
roomHost = JSON.parse(el.dataset.roomHost || 'null');
} catch {
roomHost = null;
}
const audioOnly = el.dataset.audioOnly === '1';
let stageLayout = el.dataset.stageLayout || 'plenary';
const panelDiscussions = el.dataset.panelDiscussions === '1';
if (!panelDiscussions && stageLayout === 'panel') {
stageLayout = 'plenary';
}
const participantUuid = el.dataset.participant || '';
const selfParticipant = sessionParticipants.find((person) => person.uuid === participantUuid);
return {
isHost: el.dataset.isHost === '1',
canPublish: el.dataset.canPublish !== '0',
audioOnlyPublish: el.dataset.audioOnlyPublish === '1',
usesSpeakAccess: el.dataset.usesSpeakAccess === '1',
canManageSpeak: el.dataset.canManageSpeak === '1',
speakGranted: el.dataset.speakGranted === '1',
speakRequested: el.dataset.speakRequested === '1',
inBreakout: el.dataset.inBreakout === '1',
isWebinar: el.dataset.isWebinar === '1',
isConference: el.dataset.isConference === '1',
sessionNounLabel: el.dataset.sessionNoun || '',
isGreenRoom: el.dataset.isGreenRoom === '1',
audioOnly,
isSpace: el.dataset.isSpace === '1',
muteOnJoin: el.dataset.muteOnJoin === '1',
videoOffOnJoin: el.dataset.videoOffOnJoin === '1' || audioOnly,
isRecording: el.dataset.recordingActive === '1',
activeRecordingUuid: el.dataset.activeRecordingUuid || null,
recordingCaptureMode: el.dataset.captureMode || null,
serverEgress: el.dataset.serverEgress === '1',
isLocked: el.dataset.locked === '1',
watermark: el.dataset.watermark === '1',
liveCaptions: el.dataset.liveCaptions === '1',
displayName: el.dataset.displayName || '',
presenterRefs,
usesStageLayout: el.dataset.usesStageLayout === '1',
panelDiscussions,
stageLayout,
activePanelId: el.dataset.activePanelId || '',
spotlightSpeakerRef: el.dataset.spotlightSpeakerRef || '',
panels,
sessionParticipants,
roomHost,
waitingCount: sessionParticipants.filter((person) => person.status === 'waiting').length,
assignedBreakoutRoomId: selfParticipant?.breakout_room_id ?? null,
};
}
function meetRoom() {
const roomBoot = readMeetRoomBootState();
// LiveKit Room must stay outside Alpine's reactive proxy — wrapping it breaks
// structuredClone inside the SDK during connect and media setup.
let room = null;
let connectPromise = null;
let initialized = false;
let recordingMediaRecorder = null;
let recordingChunks = [];
let seenReactionKeys = new Set();
let applauseClapTimes = [];
let applauseAudio = null;
let lastApplauseSoundAt = 0;
const APPLAUSE_WINDOW_MS = 4000;
const APPLAUSE_MIN_CLAPPERS = 2;
const APPLAUSE_SOUND_COOLDOWN_MS = 3000;
const APPLAUSE_EMOJIS = new Set(['👍', '👏', '🙌']);
return {
isMuted: true,
isVideoOff: true,
isScreenSharing: false,
isRecording: roomBoot.isRecording ?? false,
isLocked: roomBoot.isLocked ?? false,
isHost: roomBoot.isHost ?? false,
watermark: roomBoot.watermark ?? false,
liveCaptions: roomBoot.liveCaptions ?? false,
displayName: roomBoot.displayName ?? '',
canPublish: roomBoot.canPublish ?? true,
muteOnJoin: roomBoot.muteOnJoin ?? true,
videoOffOnJoin: roomBoot.videoOffOnJoin ?? false,
waitingCount: roomBoot.waitingCount ?? 0,
isWebinar: roomBoot.isWebinar ?? false,
isConference: roomBoot.isConference ?? false,
sessionNounLabel: roomBoot.sessionNounLabel ?? '',
isGreenRoom: roomBoot.isGreenRoom ?? false,
presenterRefs: roomBoot.presenterRefs ?? [],
audioOnly: roomBoot.audioOnly ?? false,
audioOnlyPublish: roomBoot.audioOnlyPublish ?? false,
usesSpeakAccess: roomBoot.usesSpeakAccess ?? false,
canManageSpeak: roomBoot.canManageSpeak ?? false,
speakGranted: roomBoot.speakGranted ?? false,
speakRequested: roomBoot.speakRequested ?? false,
isSpace: roomBoot.isSpace ?? false,
usesStageLayout: roomBoot.usesStageLayout ?? false,
panelDiscussions: roomBoot.panelDiscussions ?? false,
stageLayout: roomBoot.stageLayout ?? 'plenary',
panels: roomBoot.panels ?? [],
activePanelId: roomBoot.activePanelId ?? '',
spotlightSpeakerRef: roomBoot.spotlightSpeakerRef ?? '',
inBreakout: roomBoot.inBreakout ?? false,
assignedBreakoutRoomId: roomBoot.assignedBreakoutRoomId ?? null,
activeRecordingUuid: roomBoot.activeRecordingUuid ?? null,
recordingCaptureMode: roomBoot.recordingCaptureMode ?? null,
serverEgress: roomBoot.serverEgress ?? false,
sessionParticipants: roomBoot.sessionParticipants ?? [],
roomHost: roomBoot.roomHost ?? null,
captionText: '',
audioPlaybackBlocked: false,
needsMediaUnlock: false,
goingLive: false,
panelSetupOpen: false,
savingStageLayout: false,
_stageSnapshot: null,
qaItems: [],
activePolls: [],
breakoutBroadcast: '',
breakoutBroadcastInput: '',
breakoutsActive: false,
activeBreakouts: [],
breakoutRoomName: '',
breakoutClosesAt: '',
sidebarPanel: null,
whiteboardOpen: false,
qaInput: '',
chatInput: '',
shareOpen: false,
shareCopied: false,
breakoutModalOpen: false,
breakoutCount: '4',
endMeetingConfirmOpen: false,
joinUrl: '',
passcode: '',
roomTitle: '',
connectionStatus: 'Connecting…',
participants: [],
activeSpeakerIds: [],
floatingReactions: [],
reactionsInitialized: false,
pictureInPictureActive: false,
pictureInPictureSupported: false,
pollTimer: null,
pollInFlight: false,
pollEtag: null,
pollIntervalMs: 6000,
pollHiddenIntervalMs: 20000,
_lastCaptionSentAt: 0,
config: {},
init() {
if (initialized) return;
initialized = true;
const el = document.getElementById('meet-config');
if (!el) return;
this.config = {
token: el.dataset.token,
url: el.dataset.url,
configured: el.dataset.configured === '1',
pollUrl: el.dataset.pollUrl,
endedUrl: el.dataset.endedUrl,
chatUrl: el.dataset.chatUrl,
reactionUrl: el.dataset.reactionUrl,
raiseHandUrl: el.dataset.raiseHandUrl,
speakRequestUrl: el.dataset.speakRequestUrl,
speakCancelUrl: el.dataset.speakCancelUrl,
speakGrantUrl: el.dataset.speakGrantUrl,
speakRevokeUrl: el.dataset.speakRevokeUrl,
speakDismissUrl: el.dataset.speakDismissUrl,
spacePromoteUrl: el.dataset.spacePromoteUrl,
spaceDemoteUrl: el.dataset.spaceDemoteUrl,
spaceSpeakAcceptUrl: el.dataset.spaceSpeakAcceptUrl,
spaceSpeakDeclineUrl: el.dataset.spaceSpeakDeclineUrl,
spaceSpeakDismissUrl: el.dataset.spaceSpeakDismissUrl,
spaceCoHostUrl: el.dataset.spaceCoHostUrl,
recordingStartUrl: el.dataset.recordingStartUrl,
recordingStopUrl: el.dataset.recordingStopUrl,
lockUrl: el.dataset.lockUrl,
unlockUrl: el.dataset.unlockUrl,
captionUrl: el.dataset.captionUrl,
qaUrl: el.dataset.qaUrl,
pollsCreateUrl: el.dataset.pollsCreateUrl,
breakoutsCreateUrl: el.dataset.breakoutsCreateUrl,
breakoutsCloseUrl: el.dataset.breakoutsCloseUrl,
breakoutsReturnUrl: el.dataset.breakoutsReturnUrl,
breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl,
mediaTokenUrl: el.dataset.mediaTokenUrl,
filesUploadUrl: el.dataset.filesUploadUrl,
whiteboardUrl: el.dataset.whiteboardUrl,
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
goLiveUrl: el.dataset.goLiveUrl,
addPresenterUrl: el.dataset.addPresenterUrl,
stageUpdateUrl: el.dataset.stageUpdateUrl,
csrf: el.dataset.csrf,
sessionUuid: el.dataset.sessionUuid,
};
this.joinUrl = el.dataset.joinUrl || '';
this.passcode = el.dataset.passcode || '';
this.roomTitle = el.dataset.roomTitle || '';
this.initApplauseAudio(el.dataset.applauseAudio || '');
this.isHost = el.dataset.isHost === '1';
this.config.participantUuid = el.dataset.participant || '';
this.canPublish = el.dataset.canPublish !== '0';
this.audioOnlyPublish = el.dataset.audioOnlyPublish === '1';
this.usesSpeakAccess = el.dataset.usesSpeakAccess === '1';
this.canManageSpeak = el.dataset.canManageSpeak === '1';
this.speakGranted = el.dataset.speakGranted === '1';
this.speakRequested = el.dataset.speakRequested === '1';
this.inBreakout = el.dataset.inBreakout === '1';
this.isWebinar = el.dataset.isWebinar === '1';
this.isConference = el.dataset.isConference === '1';
this.sessionNounLabel = el.dataset.sessionNoun || '';
this.isGreenRoom = el.dataset.isGreenRoom === '1';
this.audioOnly = el.dataset.audioOnly === '1';
this.isSpace = el.dataset.isSpace === '1';
this.muteOnJoin = el.dataset.muteOnJoin === '1';
this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly;
this.isRecording = el.dataset.recordingActive === '1';
this.activeRecordingUuid = el.dataset.activeRecordingUuid || null;
this.recordingCaptureMode = el.dataset.captureMode || null;
this.serverEgress = el.dataset.serverEgress === '1';
this.isLocked = el.dataset.locked === '1';
this.watermark = el.dataset.watermark === '1';
this.liveCaptions = el.dataset.liveCaptions === '1';
this.displayName = el.dataset.displayName || '';
try {
this.presenterRefs = JSON.parse(el.dataset.presenterRefs || '[]');
} catch {
this.presenterRefs = [];
}
this.usesStageLayout = el.dataset.usesStageLayout === '1';
this.panelDiscussions = el.dataset.panelDiscussions === '1';
this.stageLayout = el.dataset.stageLayout || 'plenary';
this.activePanelId = el.dataset.activePanelId || '';
this.spotlightSpeakerRef = el.dataset.spotlightSpeakerRef || '';
try {
this.panels = JSON.parse(el.dataset.panels || '[]');
} catch {
this.panels = [];
}
if (!this.panelDiscussions && this.stageLayout === 'panel') {
this.stageLayout = 'plenary';
}
try {
this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]');
} catch {
this.sessionParticipants = [];
}
this.waitingCount = this.sessionParticipants.filter((p) => p.status === 'waiting').length;
const selfParticipant = this.sessionParticipants.find((p) => p.uuid === this.config.participantUuid);
this.assignedBreakoutRoomId = selfParticipant?.breakout_room_id ?? null;
try {
this.roomHost = JSON.parse(el.dataset.roomHost || 'null');
} catch {
this.roomHost = null;
}
if (this.config.configured && this.config.token) {
this.connectLiveKit();
} else {
this.connectionStatus = 'Video unavailable — configure LiveKit';
}
this.startPolling();
this.applyStageLayout();
this.$nextTick(() => {
this.syncToolbarConditionalVisibility();
document.querySelector('.meet-toolbar__track')?.classList.add('is-ready');
});
this.setupPictureInPicture();
},
currentPollInterval() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return this.pollHiddenIntervalMs;
}
return this.pollIntervalMs;
},
startPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this.poll();
this.pollTimer = setInterval(() => this.poll(), this.currentPollInterval());
if (typeof document !== 'undefined' && !this._pollVisibilityBound) {
this._pollVisibilityBound = true;
document.addEventListener('visibilitychange', () => {
if (this.goingLive) {
return;
}
this.startPolling();
});
}
},
connectionErrorMessage(error) {
const name = error?.name || '';
const message = error?.message || '';
if (name === 'NotAllowedError' || message.includes('Permission denied')) {
return 'Connected — allow camera and microphone in your browser';
}
if (name === 'NotFoundError' || message.includes('Requested device not found')) {
return 'Connected — no camera or microphone found';
}
if (message.includes('401') || message.toLowerCase().includes('unauthorized')) {
return 'LiveKit rejected the session token';
}
if (message.includes('engine not connected within timeout')) {
return 'In call — camera/mic timed out. Use the mic/camera buttons to retry.';
}
if (message) {
return `Connection failed — ${message}`;
}
return 'Connection failed';
},
async connectLiveKit() {
if (connectPromise) {
return connectPromise;
}
connectPromise = this.startLiveKitSession();
try {
await connectPromise;
} catch (e) {
connectPromise = null;
throw e;
}
},
async startLiveKitSession() {
try {
room = new Room({
adaptiveStream: true,
dynacast: true,
disconnectOnPageLeave: true,
});
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
this.attachTrack(track, publication, participant);
});
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
track.detach();
const isScreen = publication?.source === Track.Source.ScreenShare
|| track.source === Track.Source.ScreenShare;
if (isScreen) {
this.clearScreenShareOverlay();
return;
}
if (track.kind === Track.Kind.Video) {
this.updateParticipantTile(participant);
this.reattachParticipantVideo(participant);
}
});
room.on(RoomEvent.TrackMuted, (publication, participant) => {
if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
publication.track?.detach();
this.updateParticipantTile(participant);
}
if (publication.kind === Track.Kind.Audio) {
publication.track?.detach();
this.updateParticipantTile(participant);
}
if (participant === room.localParticipant) {
this.syncLocalMediaState();
}
});
room.on(RoomEvent.TrackUnmuted, (publication, participant) => {
if (publication.kind === Track.Kind.Video && publication.track) {
this.attachTrack(publication.track, publication, participant);
}
if (publication.kind === Track.Kind.Audio && publication.track) {
this.attachTrack(publication.track, publication, participant);
this.updateParticipantTile(participant);
}
if (participant === room.localParticipant) {
this.syncLocalMediaState();
}
});
room.on(RoomEvent.LocalTrackPublished, (publication, participant) => {
if (publication.track?.kind === Track.Kind.Video) {
this.attachTrack(publication.track, publication, participant);
}
this.updateParticipantTile(participant);
this.syncLocalMediaState();
});
room.on(RoomEvent.LocalTrackUnpublished, (publication, participant) => {
if (publication.kind === Track.Kind.Video) {
if (publication.source === Track.Source.ScreenShare) {
this.clearScreenShareOverlay();
} else {
this.updateParticipantTile(participant);
}
}
this.syncLocalMediaState();
});
room.on(RoomEvent.MediaDevicesError, (error) => {
console.error(error);
this.needsMediaUnlock = true;
this.connectionStatus = this.connectionErrorMessage(error);
this.registerAudioUnlockGesture();
});
room.on(RoomEvent.ParticipantConnected, (participant) => {
this.attachExistingTracks(participant);
this.syncParticipants();
});
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
this.removeParticipantTile(participant.identity);
this.syncParticipants();
});
room.on(RoomEvent.Disconnected, () => {
this.connectionStatus = 'Disconnected';
});
room.on(RoomEvent.AudioPlaybackStatusChanged, () => {
this.audioPlaybackBlocked = room ? !room.canPlaybackAudio : false;
if (this.audioPlaybackBlocked) {
this.connectionStatus = 'Connected — click anywhere to enable audio';
this.registerAudioUnlockGesture();
} else if (this.connectionStatus.includes('enable audio')) {
this.connectionStatus = 'Connected';
}
});
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
this.syncActiveSpeakers(speakers);
});
await room.prepareConnection(this.config.url, this.config.token);
await room.connect(this.config.url, this.config.token, {
autoSubscribe: true,
peerConnectionTimeout: 20_000,
websocketTimeout: 20_000,
});
await this.waitForRoomConnected(room);
this.syncParticipants();
if (this.canPublish) {
await this.enableLocalMedia(room);
} else {
this.isMuted = true;
this.isVideoOff = true;
}
await this.ensureAudioPlayback();
this.setupCaptionPipeline();
this.attachExistingTracks(room.localParticipant);
room.remoteParticipants.forEach((participant) => this.attachExistingTracks(participant));
this.syncParticipants();
await this.maybeStartAutoRecording();
} catch (e) {
console.error(e);
this.connectionStatus = this.connectionErrorMessage(e);
}
},
waitForRoomConnected(activeRoom) {
if (activeRoom.state === ConnectionState.Connected) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
activeRoom.off(RoomEvent.Connected, onConnected);
reject(new Error('Timed out waiting for LiveKit room connection'));
}, 20_000);
const onConnected = () => {
clearTimeout(timeout);
resolve();
};
activeRoom.once(RoomEvent.Connected, onConnected);
});
},
async enableLocalMedia(activeRoom, attempts = 3) {
const wantMic = this.inBreakout ? !this.muteOnJoin : (!this.muteOnJoin || this.isHost);
const wantCamera = !this.videoOffOnJoin && !this.audioOnly && !this.audioOnlyPublish;
const audioOptions = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let lastError = null;
try {
if (wantMic && wantCamera) {
await activeRoom.localParticipant.enableCameraAndMicrophone();
} else if (wantCamera) {
await activeRoom.localParticipant.setCameraEnabled(true);
} else if (wantMic) {
await activeRoom.localParticipant.setMicrophoneEnabled(true, audioOptions);
}
} catch (error) {
lastError = error;
if (wantCamera) {
try {
await activeRoom.localParticipant.setCameraEnabled(true);
} catch (cameraError) {
lastError = cameraError;
}
}
if (wantMic) {
try {
await activeRoom.localParticipant.setMicrophoneEnabled(true, audioOptions);
} catch (micError) {
lastError = micError;
}
}
}
this.syncLocalMediaState();
this.updateParticipantTile(activeRoom.localParticipant);
if (wantCamera) {
activeRoom.localParticipant.videoTrackPublications.forEach((pub) => {
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
this.attachTrack(pub.track, pub, activeRoom.localParticipant);
}
});
}
const micMissing = wantMic && !activeRoom.localParticipant.isMicrophoneEnabled;
const cameraMissing = wantCamera && !activeRoom.localParticipant.isCameraEnabled;
if (micMissing) {
this.needsMediaUnlock = true;
this.connectionStatus = 'Connected — click mic to enable your microphone';
this.registerAudioUnlockGesture();
} else if (cameraMissing && lastError) {
this.connectionStatus = this.connectionErrorMessage(lastError);
} else {
this.needsMediaUnlock = false;
this.connectionStatus = 'Connected';
}
if (!micMissing && !cameraMissing) {
return;
}
const retryable = lastError?.message?.includes('engine not connected within timeout');
if (!retryable || attempt === attempts) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
},
async ensureAudioPlayback() {
if (!room || room.canPlaybackAudio) {
this.audioPlaybackBlocked = false;
return;
}
try {
await room.startAudio();
this.audioPlaybackBlocked = false;
if (this.connectionStatus.includes('enable audio')) {
this.connectionStatus = 'Connected';
}
} catch (e) {
this.audioPlaybackBlocked = true;
this.registerAudioUnlockGesture();
}
},
registerAudioUnlockGesture() {
if (this._audioUnlockRegistered) return;
this._audioUnlockRegistered = true;
const unlock = async () => {
await this.ensureAudioPlayback();
if (this.needsMediaUnlock) {
if (this.canPublish && this.showMicrophoneControl()) {
await this.unlockLocalMicrophone();
} else {
this.needsMediaUnlock = false;
}
}
if (!this.audioPlaybackBlocked && !this.needsMediaUnlock) {
document.removeEventListener('click', unlock, true);
document.removeEventListener('keydown', unlock, true);
}
};
document.addEventListener('click', unlock, true);
document.addEventListener('keydown', unlock, true);
},
async unlockAllAudio() {
await this.ensureAudioPlayback();
if (!this.needsMediaUnlock) {
return;
}
if (this.canPublish && this.showMicrophoneControl()) {
await this.unlockLocalMicrophone();
} else {
this.needsMediaUnlock = false;
}
},
async unlockLocalMicrophone() {
if (!room || !this.canPublish || !this.needsMediaUnlock) {
if (room?.localParticipant.isMicrophoneEnabled) {
this.needsMediaUnlock = false;
}
return;
}
try {
await room.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
});
this.needsMediaUnlock = false;
this.syncLocalMediaState();
this.updateParticipantTile(room.localParticipant);
if (this.connectionStatus.includes('microphone') || this.connectionStatus.includes('enable audio')) {
this.connectionStatus = 'Connected';
}
} catch (error) {
console.error(error);
this.needsMediaUnlock = true;
}
},
syncLocalMediaState() {
if (!room) return;
const local = room.localParticipant;
this.isMuted = !local.isMicrophoneEnabled;
this.isVideoOff = !local.isCameraEnabled;
this.isScreenSharing = Array.from(local.videoTrackPublications.values())
.some((pub) => pub.source === Track.Source.ScreenShare && pub.track && !pub.isMuted);
if (!this.isScreenSharing) {
const screen = document.getElementById('screen-share');
if (screen && !screen.classList.contains('hidden')) {
this.clearScreenShareOverlay();
}
}
},
participantInitials(name) {
const parts = (name || '?').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
},
avatarHue(name) {
let hash = 0;
const value = name || 'guest';
for (let i = 0; i < value.length; i += 1) {
hash = value.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash) % 360;
},
avatarStyle(name) {
if (!name) return {};
const hue = this.avatarHue(name);
return {
background: `linear-gradient(135deg, hsl(${hue} 70% 45%), hsl(${hue} 70% 32%))`,
};
},
participantForIdentity(identity) {
return this.sessionParticipants.find((entry) => entry.uuid === identity);
},
displayNameForParticipant(participant) {
const person = this.participantForIdentity(participant.identity);
return person?.display_name || participant.name || participant.identity;
},
avatarUrlForPerson(person) {
if (!person) {
return null;
}
if (person.avatar_url) {
return person.avatar_url;
}
if (['host', 'co_host'].includes(person.role) && this.roomHost?.avatar_url) {
return this.roomHost.avatar_url;
}
if (this.roomHost?.user_ref && person.user_ref === this.roomHost.user_ref && this.roomHost.avatar_url) {
return this.roomHost.avatar_url;
}
return null;
},
avatarUrlForParticipant(participant) {
return this.avatarUrlForPerson(this.participantForIdentity(participant.identity));
},
updateTileAvatarCircle(tile, participant) {
const circle = tile.querySelector('[data-tile-avatar-circle]');
if (!circle) {
return;
}
const displayName = this.displayNameForParticipant(participant);
const avatarUrl = this.avatarUrlForParticipant(participant);
const isAudioTile = tile.classList.contains('meet-audio-tile');
const isSpaceTile = tile.classList.contains('meet-space-tile');
const sizeClass = isSpaceTile
? 'h-[4.75rem] w-[4.75rem] text-lg sm:h-[5.25rem] sm:w-[5.25rem] sm:text-xl'
: isAudioTile ? 'h-[4.5rem] w-[4.5rem] text-xl' : 'h-20 w-20 text-2xl';
circle.replaceChildren();
if (avatarUrl) {
circle.className = `flex ${sizeClass} shrink-0 items-center justify-center overflow-hidden rounded-full shadow-lg`;
circle.style.background = '';
const img = document.createElement('img');
img.src = avatarUrl;
img.alt = '';
img.className = 'h-full w-full object-cover';
circle.appendChild(img);
return;
}
const hue = this.avatarHue(displayName);
circle.className = `flex ${sizeClass} shrink-0 items-center justify-center rounded-full font-semibold text-white shadow-lg`;
circle.style.background = `linear-gradient(135deg, hsl(${hue} 45% 42%), hsl(${(hue + 40) % 360} 50% 28%))`;
circle.textContent = this.participantInitials(displayName);
},
refreshParticipantTileAvatars() {
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
return;
}
if (!room) {
return;
}
[room.localParticipant, ...room.remoteParticipants.values()].forEach((participant) => {
const tile = this.findParticipantTile(participant.identity);
if (tile) {
this.updateTileAvatarCircle(tile, participant);
tile.querySelector('[data-tile-name]')?.replaceChildren(
document.createTextNode(this.displayNameForParticipant(participant)),
);
}
});
},
isSpeakerPerson(person) {
if (!person) return false;
if (['host', 'co_host', 'panelist'].includes(person.role)) {
return true;
}
return Boolean(person.user_ref && this.presenterRefs.includes(person.user_ref));
},
/** Stable key for spotlight and panel assignment (user_ref when logged in, else participant uuid). */
spotlightKey(person) {
if (!person) {
return '';
}
return person.user_ref || person.uuid || '';
},
matchesSpotlightKey(key, person) {
if (!key || !person) {
return false;
}
return key === person.user_ref || key === person.uuid;
},
findSpeakerBySpotlightKey(key) {
if (!key) {
return null;
}
return this.speakerParticipants().find((person) => this.matchesSpotlightKey(key, person)) || null;
},
speakerParticipants() {
return this.inCallParticipants().filter((person) => this.isSpeakerPerson(person));
},
speakerParticipantsSorted() {
const order = { host: 0, co_host: 1, panelist: 2 };
return [...this.speakerParticipants()].sort((a, b) => {
const aSpeaking = this.isSpeaking(a.uuid);
const bSpeaking = this.isSpeaking(b.uuid);
if (aSpeaking !== bSpeaking) {
return aSpeaking ? -1 : 1;
}
const roleDiff = (order[a.role] ?? 9) - (order[b.role] ?? 9);
if (roleDiff !== 0) {
return roleDiff;
}
return a.display_name.localeCompare(b.display_name);
});
},
speakerCount() {
return this.speakerParticipants().length;
},
audienceParticipants() {
return this.inCallParticipants().filter((person) => !this.isSpeakerPerson(person));
},
audienceParticipantsSorted() {
return [...this.audienceParticipants()].sort((a, b) => a.display_name.localeCompare(b.display_name));
},
resolveSpotlightParticipantUuid() {
const spotlightKey = this.resolveSpotlightKey();
if (spotlightKey) {
const match = this.findSpeakerBySpotlightKey(spotlightKey);
if (match) {
return match.uuid;
}
}
return this.speakerParticipantsSorted()[0]?.uuid || null;
},
plenaryStripParticipants() {
const spotlightUuid = this.resolveSpotlightParticipantUuid();
return this.inCallParticipants()
.filter((person) => person.uuid !== spotlightUuid)
.sort((a, b) => this.compareStripParticipants(a, b));
},
panelStripParticipants() {
const onPanelKeys = new Set(this.getActivePanelSpeakerKeys());
return this.inCallParticipants()
.filter((person) => {
if (!this.isSpeakerPerson(person)) {
return true;
}
const key = this.spotlightKey(person);
return !key || !onPanelKeys.has(key);
})
.sort((a, b) => this.compareStripParticipants(a, b));
},
compareStripParticipants(a, b) {
const aSpeaker = this.isSpeakerPerson(a);
const bSpeaker = this.isSpeakerPerson(b);
if (aSpeaker !== bSpeaker) {
return aSpeaker ? -1 : 1;
}
return a.display_name.localeCompare(b.display_name);
},
stripParticipants() {
if (this.stageLayout === 'panel') {
return this.panelStripParticipants();
}
return this.plenaryStripParticipants();
},
createStripInitials(person) {
const initials = document.createElement('span');
initials.className = 'meet-audience-avatar__initials';
initials.style.background = this.avatarStyle(person.display_name).background;
initials.textContent = this.participantInitials(person.display_name);
return initials;
},
appendStripAvatarItem(strip, person, index, total) {
const item = document.createElement('div');
item.className = 'meet-audience-avatar';
if (this.isSpeakerPerson(person)) {
item.classList.add('meet-audience-avatar--speaker');
}
item.style.zIndex = String(total - index);
item.title = person.display_name;
const avatarUrl = this.avatarUrlForPerson(person);
if (avatarUrl) {
const img = document.createElement('img');
img.src = avatarUrl;
img.alt = '';
img.className = 'meet-audience-avatar__image';
img.addEventListener('error', () => {
img.replaceWith(this.createStripInitials(person));
}, { once: true });
item.appendChild(img);
} else {
item.appendChild(this.createStripInitials(person));
}
strip.appendChild(item);
},
resolveSpotlightKey() {
const speakers = this.speakerParticipants();
if (this.spotlightSpeakerRef) {
const match = this.findSpeakerBySpotlightKey(this.spotlightSpeakerRef);
if (match) {
return this.spotlightKey(match);
}
}
const fallback = this.speakerParticipantsSorted()[0];
return fallback ? this.spotlightKey(fallback) : '';
},
getActivePanelSpeakerKeys() {
if (!this.panelDiscussions || this.stageLayout !== 'panel') {
return this.speakerParticipants()
.map((person) => this.spotlightKey(person))
.filter(Boolean);
}
if (!this.activePanelId) {
return this.speakerParticipants()
.map((person) => this.spotlightKey(person))
.filter(Boolean);
}
const panel = this.panels.find((entry) => entry.id === this.activePanelId);
return panel?.speaker_refs || [];
},
mergeStageConfig(data) {
const editingStage = this.panelSetupOpen;
if (!editingStage) {
if (typeof data.stage_layout === 'string') {
this.stageLayout = data.stage_layout;
}
if (Array.isArray(data.panels)) {
this.panels = data.panels;
}
if (data.active_panel_id !== undefined) {
this.activePanelId = data.active_panel_id || '';
}
}
if (typeof data.panel_discussions === 'boolean') {
this.panelDiscussions = data.panel_discussions;
}
if (data.spotlight_speaker_ref !== undefined) {
this.spotlightSpeakerRef = data.spotlight_speaker_ref || '';
}
if (!this.panelDiscussions && this.stageLayout === 'panel') {
this.stageLayout = 'plenary';
}
},
liveKitParticipantForIdentity(identity) {
if (!room) {
return null;
}
if (room.localParticipant.identity === identity) {
return room.localParticipant;
}
return room.remoteParticipants.get(identity) || null;
},
spaceRoleLabel(role) {
return {
host: 'Host',
co_host: 'Co-host',
panelist: 'Speaker',
attendee: 'Listener',
}[role] || '';
},
spaceStageParticipantsSorted() {
const order = { host: 0, co_host: 1, panelist: 2, attendee: 3, guest: 4, participant: 5 };
return [...this.inCallParticipants()].sort((a, b) => {
const roleDiff = (order[a.role] ?? 9) - (order[b.role] ?? 9);
if (roleDiff !== 0) {
return roleDiff;
}
return a.display_name.localeCompare(b.display_name);
});
},
spaceSpeakerTileShellHtml() {
return `
<div class="flex w-full justify-center">
<div data-tile-avatar-circle class="flex h-[4.75rem] w-[4.75rem] shrink-0 items-center justify-center rounded-full text-lg font-semibold text-white"></div>
</div>
<span data-tile-name class="mt-2 max-w-full truncate text-center text-sm font-bold leading-tight text-white"></span>
<div data-tile-role-row class="mt-1 flex max-w-full items-center justify-center gap-1">
<span data-tile-speaking class="hidden shrink-0 text-sky-400" title="Speaking">
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--2" x="6.75" y="3" width="2.5" height="10" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--3" x="12.5" y="6" width="2.5" height="4" rx="1"/>
</svg>
</span>
<span data-tile-mic class="hidden shrink-0 text-slate-500" title="Muted">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</span>
<span data-tile-role class="truncate text-xs text-slate-400"></span>
</div>`;
},
participantTileShellHtml() {
return `
<div data-tile-body class="relative min-h-0 flex-1 overflow-hidden">
<div data-tile-stage class="absolute inset-0 overflow-hidden"></div>
<div data-tile-avatar class="absolute inset-0 flex items-center justify-center">
<div data-tile-avatar-circle class="flex h-20 w-20 shrink-0 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg"></div>
</div>
</div>
<div data-tile-footer class="relative flex shrink-0 items-center justify-between gap-2 border-t border-slate-800/80 bg-slate-950/95 px-3 py-2">
<span data-tile-name class="truncate text-xs font-medium text-white"></span>
<div class="flex shrink-0 items-center gap-1.5">
<span data-tile-speaking class="hidden meet-speaking-badge rounded-full bg-emerald-500/90 p-1 text-white" title="Speaking">
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect class="meet-speaking-bar meet-speaking-bar--1" x="1" y="5" width="2.5" height="6" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--2" x="6.75" y="3" width="2.5" height="10" rx="1"/>
<rect class="meet-speaking-bar meet-speaking-bar--3" x="12.5" y="6" width="2.5" height="4" rx="1"/>
</svg>
</span>
<span data-tile-mic class="hidden rounded-full bg-black/50 p-1 text-red-400" title="Muted">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</span>
<span data-tile-cam class="hidden rounded-full bg-black/50 p-1 text-slate-300" title="Camera off">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</span>
</div>
</div>`;
},
ensureParticipantTileShell(identity) {
let tile = this.findParticipantTile(identity);
const wantsSpaceTile = this.isSpace && !this.usesStageLayout;
if (tile && wantsSpaceTile !== tile.classList.contains('meet-space-tile')) {
tile.remove();
tile = null;
}
if (!tile) {
tile = document.createElement('div');
tile.dataset.participantTile = identity;
if (wantsSpaceTile) {
tile.className = 'meet-space-tile flex w-full min-w-0 flex-col items-center px-1 py-1';
tile.innerHTML = this.spaceSpeakerTileShellHtml();
} else {
tile.className = this.audioOnly
? 'meet-audio-tile relative flex aspect-square w-full flex-col overflow-hidden rounded-2xl bg-slate-900'
: 'relative flex aspect-video w-full flex-col overflow-hidden rounded-xl bg-slate-900 min-h-0';
tile.innerHTML = this.participantTileShellHtml();
}
}
return tile;
},
updateSessionParticipantTile(person, liveParticipant = null, tile = null) {
tile = tile || this.findParticipantTile(person.uuid);
if (!tile || !person) {
return;
}
const pseudoParticipant = { identity: person.uuid, name: person.display_name };
this.updateTileAvatarCircle(tile, pseudoParticipant);
const speaking = this.isSpeaking(person.uuid);
const muted = liveParticipant
? !liveParticipant.isMicrophoneEnabled
: Boolean(person.is_muted);
if (tile.classList.contains('meet-space-tile')) {
const isListener = person.role === 'attendee';
tile.querySelector('[data-tile-name]').textContent = person.display_name;
tile.querySelector('[data-tile-role]').textContent = this.spaceRoleLabel(person.role);
tile.classList.toggle('meet-tile-speaking', speaking && !isListener);
tile.classList.toggle('meet-space-tile--listener', isListener);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking || isListener);
tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', isListener ? false : (!muted || speaking));
return;
}
tile.querySelector('[data-tile-name]').textContent = person.display_name;
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
tile.querySelector('[data-tile-mic]')?.classList.toggle('hidden', !muted);
tile.querySelector('[data-tile-cam]')?.classList.add('hidden');
},
renderSpaceSpeakerTiles() {
if (!this.isSpace || this.usesStageLayout) {
return;
}
const grid = document.getElementById('video-grid');
if (!grid) {
return;
}
const people = this.spaceStageParticipantsSorted();
const peopleIds = new Set(people.map((person) => person.uuid));
people.forEach((person) => {
const liveParticipant = this.liveKitParticipantForIdentity(person.uuid);
const tile = this.ensureParticipantTileShell(person.uuid);
if (tile.parentElement !== grid) {
grid.appendChild(tile);
}
this.updateSessionParticipantTile(person, liveParticipant, tile);
});
grid.querySelectorAll('[data-participant-tile]').forEach((tile) => {
if (!peopleIds.has(tile.dataset.participantTile)) {
tile.remove();
}
});
this.updateAudioGridLayout();
},
findParticipantTile(identity) {
return document.querySelector(`[data-participant-tile="${identity}"]`);
},
getTileContainerForIdentity(identity) {
if (!this.usesStageLayout) {
return document.getElementById('video-grid');
}
const person = this.sessionParticipants.find((entry) => entry.uuid === identity);
const spotlight = document.getElementById('meet-stage-spotlight');
const grid = document.getElementById('video-grid');
if (this.stageLayout === 'plenary') {
if (!this.isSpeakerPerson(person)) {
return null;
}
const spotlightKey = this.resolveSpotlightKey();
if (spotlightKey && this.matchesSpotlightKey(spotlightKey, person)) {
return spotlight;
}
if (!spotlightKey && this.speakerParticipantsSorted()[0]?.uuid === identity) {
return spotlight;
}
return null;
}
if (this.stageLayout === 'panel') {
if (!this.isSpeakerPerson(person)) {
return null;
}
const keys = this.getActivePanelSpeakerKeys();
const personKey = this.spotlightKey(person);
if (!personKey || !keys.includes(personKey)) {
return null;
}
return grid;
}
return grid;
},
renderAudienceStrip() {
const strip = document.getElementById('meet-audience-strip');
if (!strip) return;
strip.replaceChildren();
if (!this.usesStageLayout) {
return;
}
const audience = this.stripParticipants();
if (audience.length === 0) {
return;
}
const maxVisible = 12;
const visible = audience.slice(0, maxVisible);
const overflow = audience.length - visible.length;
visible.forEach((person, index) => {
this.appendStripAvatarItem(strip, person, index, visible.length);
});
if (overflow > 0) {
const more = document.createElement('div');
more.className = 'meet-audience-avatar meet-audience-avatar--more';
more.style.zIndex = '0';
more.textContent = `+${overflow} others`;
strip.appendChild(more);
}
},
applyStageLayout() {
if (!this.usesStageLayout) {
document.getElementById('meet-stage-spotlight')?.replaceChildren();
document.getElementById('meet-audience-strip')?.replaceChildren();
if (this.isSpace) {
this.renderSpaceSpeakerTiles();
} else if (room) {
const all = [room.localParticipant, ...room.remoteParticipants.values()];
all.forEach((participant) => {
const target = this.getTileContainerForIdentity(participant.identity);
if (!target) {
this.removeParticipantTile(participant.identity);
return;
}
const tile = this.getOrCreateParticipantTile(participant);
if (!tile) {
return;
}
if (tile.parentElement !== target) {
target.appendChild(tile);
}
this.updateParticipantTile(participant);
});
}
if (!this.isSpace) {
this.updateAudioGridLayout();
}
return;
}
const spotlight = document.getElementById('meet-stage-spotlight');
const grid = document.getElementById('video-grid');
if (room) {
const all = [room.localParticipant, ...room.remoteParticipants.values()];
all.forEach((participant) => {
const target = this.getTileContainerForIdentity(participant.identity);
if (!target) {
this.removeParticipantTile(participant.identity);
return;
}
const existingTile = this.findParticipantTile(participant.identity);
const previousParent = existingTile?.parentElement ?? null;
const tile = this.getOrCreateParticipantTile(participant);
if (!tile) {
return;
}
const isSpotlightTile = target === spotlight;
tile.classList.toggle('meet-stage-spotlight-tile', isSpotlightTile);
tile.classList.toggle('meet-stage-panel-tile', target === grid && this.stageLayout === 'panel');
tile.classList.toggle('aspect-video', !isSpotlightTile && !this.audioOnly);
tile.classList.toggle('rounded-xl', !isSpotlightTile && !this.audioOnly);
if (!existingTile || previousParent !== target) {
this.reattachParticipantVideo(participant);
} else {
this.updateParticipantTile(participant);
}
});
}
if (grid) {
grid.dataset.panelCount = String(this.getActivePanelSpeakerKeys().length || this.speakerCount());
}
this.renderAudienceStrip();
},
async saveStageLayout() {
if (!this.config.stageUpdateUrl || this.savingStageLayout) {
return;
}
this.savingStageLayout = true;
try {
const res = await fetch(this.config.stageUpdateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({
stage_layout: this.stageLayout,
panel_discussions: this.panelDiscussions || this.stageLayout === 'panel',
panels: this.panels,
active_panel_id: this.activePanelId || null,
spotlight_speaker_ref: this.spotlightSpeakerRef || null,
}),
});
if (!res.ok) {
return;
}
const data = await res.json();
this._stageSnapshot = null;
this.panelSetupOpen = false;
this.mergeStageConfig(data);
this.applyStageLayout();
} finally {
this.savingStageLayout = false;
}
},
async setSpotlight(speakerKey) {
if (!speakerKey) {
return;
}
this.spotlightSpeakerRef = speakerKey;
this.stageLayout = 'plenary';
this.applyStageLayout();
if (!this.config.stageUpdateUrl) {
return;
}
await fetch(this.config.stageUpdateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({
stage_layout: 'plenary',
spotlight_speaker_ref: speakerKey,
}),
});
},
addPanel() {
this.panels.push({
id: `panel-${Math.random().toString(36).slice(2, 10)}`,
name: `Panel ${this.panels.length + 1}`,
speaker_refs: [],
});
},
removePanel(index) {
const removed = this.panels.splice(index, 1)[0];
if (removed && this.activePanelId === removed.id) {
this.activePanelId = this.panels[0]?.id || '';
}
},
setActivePanel(panelId) {
this.activePanelId = panelId;
this.stageLayout = 'panel';
this.panelDiscussions = true;
},
togglePanelSpeaker(panel, speakerKey) {
if (!panel || !speakerKey) {
return;
}
const refs = panel.speaker_refs || [];
if (refs.includes(speakerKey)) {
panel.speaker_refs = refs.filter((ref) => ref !== speakerKey);
} else {
panel.speaker_refs = [...refs, speakerKey];
}
},
hostParticipant() {
const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host');
if (hosts.length) {
return hosts[0];
}
if (this.roomHost?.user_ref) {
const inCall = this.sessionParticipants.find((p) => p.user_ref === this.roomHost.user_ref);
if (inCall) {
return inCall;
}
return this.roomHost;
}
return null;
},
participantCount() {
return this.inCallParticipants().length;
},
headerParticipantCount() {
if (this.usesStageLayout) {
return String(this.speakerCount());
}
return String(this.participantCount());
},
headerParticipantSuffix() {
if (this.usesStageLayout) {
const count = this.speakerCount();
return count === 1 ? ' speaker' : ' speakers';
}
return ' in call';
},
headerParticipantLabel() {
return this.headerParticipantCount() + this.headerParticipantSuffix();
},
inCallParticipants() {
return this.sessionParticipants.filter((p) => p.status === 'joined');
},
waitingParticipants() {
return this.sessionParticipants.filter((p) => p.status === 'waiting');
},
sortedParticipants() {
const order = { host: 0, co_host: 1, panelist: 2, participant: 3, guest: 4, attendee: 5 };
return [...this.inCallParticipants()].sort((a, b) => {
const diff = (order[a.role] ?? 9) - (order[b.role] ?? 9);
return diff !== 0 ? diff : a.display_name.localeCompare(b.display_name);
});
},
roleLabel(role) {
const labels = {
host: 'Host',
co_host: 'Co-host',
panelist: this.isSpace ? 'Speaker' : 'Panelist',
attendee: this.isSpace ? 'Listener' : 'Attendee',
guest: 'Guest',
participant: 'Participant',
};
return labels[role] || 'Participant';
},
isSpeaking(uuid) {
return this.activeSpeakerIds.includes(uuid);
},
activeSpeakerNames() {
return this.activeSpeakerIds
.map((id) => {
const person = this.sessionParticipants.find((p) => p.uuid === id);
if (person?.display_name) {
return person.display_name;
}
if (room?.localParticipant?.identity === id) {
return room.localParticipant.name || 'You';
}
return room?.remoteParticipants.get(id)?.name || 'Someone';
})
.filter(Boolean);
},
activeSpeakersMessage() {
const names = this.activeSpeakerNames();
if (names.length === 0) return '';
if (names.length === 1) return `${names[0]} is speaking`;
if (names.length === 2) return `${names[0]} and ${names[1]} are speaking`;
const last = names[names.length - 1];
const rest = names.slice(0, -1).join(', ');
return `${rest}, and ${last} are speaking`;
},
activeSpeakerCount() {
return this.activeSpeakerIds.length;
},
inCallParticipantsSorted() {
return [...this.inCallParticipants()].sort((a, b) => {
const aSpeaking = this.isSpeaking(a.uuid);
const bSpeaking = this.isSpeaking(b.uuid);
if (aSpeaking !== bSpeaking) {
return aSpeaking ? -1 : 1;
}
return a.display_name.localeCompare(b.display_name);
});
},
syncActiveSpeakers(speakers) {
this.activeSpeakerIds = speakers.map((p) => p.identity);
document.querySelectorAll('[data-participant-tile]').forEach((tile) => {
const identity = tile.dataset.participantTile;
const speaking = this.activeSpeakerIds.includes(identity);
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
});
},
async admitParticipant(uuid) {
if (!this.isHost || !this.config.sessionUuid) return;
await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/admit`, {
method: 'POST',
headers: this.headers(),
});
await this.poll();
},
async denyParticipant(uuid) {
if (!this.isHost || !this.config.sessionUuid) return;
await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/deny`, {
method: 'POST',
headers: this.headers(),
});
await this.poll();
},
removeParticipantTile(identity) {
this.findParticipantTile(identity)?.remove();
},
getOrCreateParticipantTile(participant) {
const container = this.getTileContainerForIdentity(participant.identity);
const identity = participant.identity;
const existing = this.findParticipantTile(identity);
if (!container) {
existing?.remove();
return null;
}
let tile = existing;
if (!tile) {
tile = this.ensureParticipantTileShell(identity);
}
if (tile.parentElement !== container) {
container.appendChild(tile);
}
const isSpotlightTile = container.id === 'meet-stage-spotlight';
tile.classList.toggle('meet-stage-spotlight-tile', isSpotlightTile);
tile.classList.toggle('meet-stage-panel-tile', container.id === 'video-grid' && this.stageLayout === 'panel');
tile.classList.toggle('aspect-video', !isSpotlightTile && !this.audioOnly);
tile.classList.toggle('rounded-xl', !isSpotlightTile && !this.audioOnly);
this.updateTileAvatarCircle(tile, participant);
tile.querySelector('[data-tile-name]').textContent = this.displayNameForParticipant(participant);
const speaking = this.activeSpeakerIds.includes(identity);
tile.classList.toggle('meet-tile-speaking', speaking);
tile.querySelector('[data-tile-speaking]')?.classList.toggle('hidden', !speaking);
return tile;
},
isParticipantVideoActive(participant) {
if (!participant.isCameraEnabled) return false;
const publication = Array.from(participant.videoTrackPublications.values())
.find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted);
return Boolean(publication?.track);
},
isParticipantMuted(participant) {
return !participant.isMicrophoneEnabled;
},
updateParticipantTile(participant) {
const tile = this.getOrCreateParticipantTile(participant);
if (!tile) return;
if (tile.classList.contains('meet-space-tile')) {
const person = this.participantForIdentity(participant.identity);
if (person) {
this.updateSessionParticipantTile(person, participant, tile);
}
return;
}
const stage = tile.querySelector('[data-tile-stage]');
const avatar = tile.querySelector('[data-tile-avatar]');
const micBadge = tile.querySelector('[data-tile-mic]');
const camBadge = tile.querySelector('[data-tile-cam]');
const hasVideo = this.isParticipantVideoActive(participant);
micBadge?.classList.toggle('hidden', !this.isParticipantMuted(participant));
camBadge?.classList.toggle('hidden', hasVideo || this.audioOnly);
if (hasVideo) {
avatar?.classList.add('hidden');
const publication = Array.from(participant.videoTrackPublications.values())
.find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted);
const video = stage?.querySelector('video');
const needsAttach = !video
|| video.readyState === HTMLMediaElement.HAVE_NOTHING
|| !video.srcObject;
if (publication?.track && needsAttach) {
this.attachVideoToTile(participant, publication.track);
}
return;
}
avatar?.classList.remove('hidden');
this.updateTileAvatarCircle(tile, participant);
stage?.replaceChildren();
},
attachVideoToTile(participant, track) {
const tile = this.getOrCreateParticipantTile(participant);
if (!tile) return;
const stage = tile.querySelector('[data-tile-stage]');
const avatar = tile.querySelector('[data-tile-avatar]');
const camBadge = tile.querySelector('[data-tile-cam]');
avatar.classList.add('hidden');
camBadge.classList.add('hidden');
stage.innerHTML = '';
const el = track.attach();
el.className = 'meet-tile-video';
el.dataset.participant = participant.identity;
stage.appendChild(el);
},
reattachParticipantVideo(participant) {
if (this.audioOnly || !participant) {
return;
}
const publication = Array.from(participant.videoTrackPublications.values())
.find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted);
if (publication?.track) {
this.attachVideoToTile(participant, publication.track);
return;
}
this.updateParticipantTile(participant);
},
publishCaption(text) {
const normalized = text.trim();
if (!normalized || !this.config.captionUrl) {
return;
}
this.captionText = normalized;
if (normalized === this._lastCaptionSent) {
return;
}
// Throttle caption posts — speech recognition fires very frequently.
const now = Date.now();
if (now - (this._lastCaptionSentAt || 0) < 2000) {
return;
}
this._lastCaptionSent = normalized;
this._lastCaptionSentAt = now;
fetch(this.config.captionUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ text: normalized }),
}).catch(() => {});
},
setupCaptionPipeline() {
if (!this.liveCaptions || !this.config.captionUrl) return;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) return;
if (this._captionRecognition) {
return;
}
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.onresult = (event) => {
let interim = '';
for (let index = event.resultIndex; index < event.results.length; index += 1) {
const result = event.results[index];
const text = result[0]?.transcript?.trim() ?? '';
if (!text) {
continue;
}
if (result.isFinal) {
this.publishCaption(text);
} else {
interim = text;
}
}
if (interim) {
this.captionText = interim;
}
};
recognition.onerror = () => {};
recognition.onend = () => {
if (!this.liveCaptions || !room) {
return;
}
try {
recognition.start();
} catch (e) {
// browser may block without gesture
}
};
this._captionRecognition = recognition;
try {
recognition.start();
} catch (e) {
// browser may block without gesture
}
},
usesServerEgressCapture() {
return this.recordingCaptureMode === 'egress';
},
async maybeStartAutoRecording() {
if (!this.isHost || !this.isRecording || recordingMediaRecorder) {
return;
}
if (this.usesServerEgressCapture()) {
return;
}
const el = document.getElementById('meet-config');
const existingUuid = this.activeRecordingUuid || el?.dataset.activeRecordingUuid || '';
if (existingUuid) {
this.activeRecordingUuid = existingUuid;
if (this.serverEgress && !this.usesServerEgressCapture()) {
await this.resumeServerEgress();
if (this.usesServerEgressCapture()) {
return;
}
}
} else {
await this.startServerRecording();
return;
}
const started = await this.tryStartClientRecordingWithRetry();
if (!started) {
console.warn('Auto-record client capture could not start.');
}
},
async tryStartClientRecordingWithRetry() {
if (this.startClientRecording()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 1500));
return this.startClientRecording();
},
async toggleRecording() {
if (!this.isHost) return;
if (this.isRecording) {
await this.stopServerAndUploadRecording();
} else {
await this.startServerRecording();
}
},
async resumeServerEgress() {
const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() });
if (!res.ok) {
return;
}
const data = await res.json();
this.activeRecordingUuid = data.recording_uuid || this.activeRecordingUuid;
if (data.capture_mode) {
this.recordingCaptureMode = data.capture_mode;
}
},
async startServerRecording() {
const res = await fetch(this.config.recordingStartUrl, { method: 'POST', headers: this.headers() });
if (!res.ok) return;
const data = await res.json();
this.activeRecordingUuid = data.recording_uuid;
this.recordingCaptureMode = data.capture_mode || 'browser';
if (this.usesServerEgressCapture()) {
this.isRecording = true;
return;
}
if (! await this.tryStartClientRecordingWithRetry()) {
await this.abortServerRecording('Could not capture meeting video in this browser.');
this.isRecording = false;
this.activeRecordingUuid = null;
this.recordingCaptureMode = null;
window.alert(`Recording could not start because this browser could not capture the ${this.sessionNoun()} video. Try Chrome on desktop, or disable stage-only layouts temporarily.`);
return;
}
this.isRecording = true;
},
startClientRecording() {
const stream = this.buildRecordingStream();
if (!stream || typeof MediaRecorder === 'undefined') {
return false;
}
recordingChunks = [];
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: (MediaRecorder.isTypeSupported('video/webm') ? 'video/webm' : '');
try {
recordingMediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_500_000 })
: new MediaRecorder(stream, { videoBitsPerSecond: 2_500_000 });
} catch {
return false;
}
recordingMediaRecorder.ondataavailable = (event) => {
if (event.data?.size > 0) {
recordingChunks.push(event.data);
}
};
recordingMediaRecorder.start(1000);
return true;
},
buildRecordingStream() {
if (!room) return null;
const screenEl = document.getElementById('screen-share');
const screenVideo = screenEl && !screenEl.classList.contains('hidden')
? screenEl.querySelector('video')
: null;
let videoSource = screenVideo;
if (!videoSource) {
videoSource = this.recordingVideoCaptureElement();
}
if (!videoSource?.captureStream) {
return null;
}
const videoStream = videoSource.captureStream(25);
const tracks = [...videoStream.getVideoTracks(), ...this.collectMeetingAudioTracks()];
return tracks.length ? new MediaStream(tracks) : null;
},
recordingVideoCaptureElement() {
if (!this.usesStageLayout) {
return document.getElementById('video-grid');
}
const spotlight = document.getElementById('meet-stage-spotlight');
const grid = document.getElementById('video-grid');
const stage = document.getElementById('meet-stage');
if (spotlight?.querySelector('video')) {
return spotlight;
}
if (grid?.querySelector('video')) {
return grid;
}
return stage || spotlight || grid;
},
collectMeetingAudioTracks() {
const tracks = [];
const addFromParticipant = (participant) => {
participant?.audioTrackPublications?.forEach((publication) => {
const mediaTrack = publication.track?.mediaStreamTrack;
if (mediaTrack && mediaTrack.readyState === 'live') {
tracks.push(mediaTrack);
}
});
};
if (room?.localParticipant) {
addFromParticipant(room.localParticipant);
}
room?.remoteParticipants?.forEach(addFromParticipant);
return tracks;
},
async stopServerAndUploadRecording() {
const isServerEgress = this.usesServerEgressCapture();
const hadClientRecorder = !!recordingMediaRecorder;
const blob = isServerEgress ? null : await this.stopClientRecording();
this.isRecording = false;
let recordingUuid = this.activeRecordingUuid;
const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() });
if (res.ok) {
const data = await res.json().catch(() => ({}));
recordingUuid = data.recording_uuid || recordingUuid;
if (data.capture_mode) {
this.recordingCaptureMode = data.capture_mode;
}
} else if (res.status !== 404 || !recordingUuid) {
this.activeRecordingUuid = null;
this.recordingCaptureMode = null;
return;
}
if (isServerEgress) {
this.activeRecordingUuid = null;
this.recordingCaptureMode = null;
return;
}
if (blob && recordingUuid) {
const uploaded = await this.uploadRecording(blob, recordingUuid);
if (!uploaded) {
window.alert(`Recording upload failed. Open Recordings and refresh shortly, or re-record the ${this.sessionNoun()}.`);
}
} else if (recordingUuid) {
await this.failServerRecording(
recordingUuid,
hadClientRecorder
? `No recording was captured from this ${this.sessionNoun()}.`
: 'Recording capture stopped before any video was saved.',
);
if (hadClientRecorder) {
window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`);
}
}
this.activeRecordingUuid = null;
this.recordingCaptureMode = null;
},
async abortServerRecording(reason) {
await fetch(this.config.recordingStopUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ failed: true, reason }),
});
},
async failServerRecording(recordingUuid, reason) {
const url = `/room/${this.config.sessionUuid}/recordings/${recordingUuid}/fail`;
await fetch(url, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ reason }),
});
},
stopClientRecording() {
return new Promise((resolve) => {
const recorder = recordingMediaRecorder;
if (!recorder || recorder.state === 'inactive') {
recordingMediaRecorder = null;
recordingChunks = [];
resolve(null);
return;
}
recorder.onstop = () => {
const blob = new Blob(recordingChunks, {
type: recorder.mimeType || 'video/webm',
});
recordingMediaRecorder = null;
recordingChunks = [];
recorder.stream?.getTracks().forEach((track) => track.stop());
resolve(blob.size > 0 ? blob : null);
};
recorder.stop();
});
},
async uploadRecording(blob, recordingUuid) {
const form = new FormData();
form.append('recording', blob, `${recordingUuid}.webm`);
const res = await fetch(`/room/${this.config.sessionUuid}/recordings/${recordingUuid}/upload`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' },
body: form,
});
return res.ok;
},
async toggleLock() {
if (!this.isHost) return;
const url = this.isLocked ? this.config.unlockUrl : this.config.lockUrl;
const res = await fetch(url, { method: 'POST', headers: this.headers() });
if (res.ok) {
this.isLocked = !this.isLocked;
}
},
async submitQuestion() {
if (this.isSpace) {
return;
}
const question = this.qaInput.trim();
if (!question || !this.config.qaUrl) return;
this.qaInput = '';
await fetch(this.config.qaUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ question }),
});
},
async createBreakouts() {
if (this.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return;
this.breakoutCount = '4';
this.breakoutModalOpen = true;
this.closeSidebar();
},
async submitBreakouts() {
if (this.isSpace || !this.isHost || !this.config.breakoutsCreateUrl) return;
const count = parseInt(this.breakoutCount, 10);
if (!count || count < 1) return;
const res = await fetch(this.config.breakoutsCreateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ count }),
});
if (!res.ok) {
return;
}
this.breakoutModalOpen = false;
this.breakoutsActive = true;
},
async closeBreakouts() {
if (this.isSpace || !this.isHost || !this.config.breakoutsCloseUrl) return;
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
this.closeSidebar();
this.breakoutsActive = false;
this.activeBreakouts = [];
},
async broadcastToBreakouts() {
if (this.isSpace) {
return;
}
const message = this.breakoutBroadcastInput.trim();
if (!this.isHost || !message || !this.config.breakoutsBroadcastUrl) {
return;
}
const res = await fetch(this.config.breakoutsBroadcastUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ message }),
});
if (!res.ok) {
return;
}
this.breakoutBroadcastInput = '';
this.breakoutBroadcast = message;
},
applyMediaSessionState(media) {
if (!media) {
return;
}
this.inBreakout = Boolean(media.in_breakout);
this.assignedBreakoutRoomId = media.breakout_room_id ?? null;
this.canPublish = Boolean(media.can_publish);
this.audioOnlyPublish = Boolean(media.audio_only_publish);
this.speakGranted = Boolean(media.speak_granted);
this.speakRequested = Boolean(media.speak_requested);
this.usesStageLayout = Boolean(media.uses_stage_layout);
this.breakoutRoomName = media.breakout?.name || '';
this.breakoutClosesAt = media.breakout?.closes_at || '';
this.syncToolbarConditionalVisibility();
},
async syncMediaSessionFromPoll(data) {
const media = data.media;
if (!media) {
return;
}
const previousBreakoutId = this.assignedBreakoutRoomId;
const previousCanPublish = this.canPublish;
const previousAudioOnlyPublish = this.audioOnlyPublish;
const previousSpeakGranted = this.speakGranted;
const previousUsesStageLayout = this.usesStageLayout;
this.applyMediaSessionState(media);
this.breakoutsActive = Boolean(data.breakouts_active);
this.activeBreakouts = Array.isArray(data.breakouts) ? data.breakouts : [];
const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId
|| previousCanPublish !== this.canPublish
|| previousAudioOnlyPublish !== this.audioOnlyPublish
|| previousSpeakGranted !== this.speakGranted
|| previousUsesStageLayout !== this.usesStageLayout;
if (needsReconnect && this.config.configured) {
await this.reconnectLiveKit();
}
},
async reconnectLiveKit() {
if (!this.config.mediaTokenUrl) {
return;
}
try {
const res = await fetch(this.config.mediaTokenUrl, { headers: { Accept: 'application/json' } });
if (!res.ok) {
return;
}
const data = await res.json();
this.config.token = data.token;
this.applyMediaSessionState(data);
this.breakoutsActive = Boolean(data.breakouts_active);
if (room) {
await room.disconnect();
room = null;
}
connectPromise = null;
document.getElementById('video-grid')?.replaceChildren();
document.getElementById('meet-stage-spotlight')?.replaceChildren();
document.getElementById('meet-audience-strip')?.replaceChildren();
this.connectionStatus = this.inBreakout ? 'Joining breakout…' : 'Returning to main room…';
await this.connectLiveKit();
this.applyStageLayout();
} catch (error) {
console.error(error);
this.connectionStatus = this.connectionErrorMessage(error);
}
},
breakoutTimeRemaining() {
if (!this.breakoutClosesAt) {
return '';
}
const closes = Date.parse(this.breakoutClosesAt);
if (Number.isNaN(closes)) {
return '';
}
const seconds = Math.max(0, Math.floor((closes - Date.now()) / 1000));
if (seconds === 0) {
return 'Ending soon';
}
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return minutes > 0
? `${minutes}m ${remainder}s remaining`
: `${remainder}s remaining`;
},
toggleProgramme() {
if (this.isSpace) {
return;
}
this.toggleSidebarPanel('programme');
},
openUploadPicker() {
if (this.isSpace) {
return;
}
document.getElementById('meet-file-input')?.click();
this.closeSidebar();
},
async toggleRecordingFromMenu() {
await this.toggleRecording();
this.closeSidebar();
},
async toggleLockFromMenu() {
if (this.isSpace) {
return;
}
await this.toggleLock();
this.closeSidebar();
},
openEndMeetingConfirm() {
this.endMeetingConfirmOpen = true;
},
submitEndMeeting() {
if (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder) {
this.stopServerAndUploadRecording().finally(() => {
document.getElementById('meet-end-form')?.requestSubmit();
});
} else {
document.getElementById('meet-end-form')?.requestSubmit();
}
this.endMeetingConfirmOpen = false;
},
async submitLeave() {
if (this.isHost && (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder)) {
await this.stopServerAndUploadRecording();
}
document.getElementById('meet-leave-form')?.submit();
},
async uploadFile(event) {
const file = event.target.files?.[0];
if (!file || !this.config.filesUploadUrl) return;
const form = new FormData();
form.append('file', file);
await fetch(this.config.filesUploadUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' },
body: form,
});
},
attachExistingTracks(participant) {
if (!participant) return;
const isLocal = room && participant === room.localParticipant;
if (!isLocal) {
participant.audioTrackPublications.forEach((pub) => {
if (pub.track) {
this.attachTrack(pub.track, pub, participant);
}
});
}
participant.videoTrackPublications.forEach((pub) => {
if (this.audioOnly) return;
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
this.attachTrack(pub.track, pub, participant);
}
});
this.updateParticipantTile(participant);
},
clearScreenShareOverlay() {
const screen = document.getElementById('screen-share');
if (!screen) {
return;
}
screen.querySelectorAll('video').forEach((element) => {
element.srcObject = null;
});
screen.classList.add('hidden');
screen.replaceChildren();
if (room?.localParticipant) {
this.isScreenSharing = Array.from(room.localParticipant.videoTrackPublications.values())
.some((pub) => pub.source === Track.Source.ScreenShare && pub.track && !pub.isMuted);
} else {
this.isScreenSharing = false;
}
if (!room || this.audioOnly) {
return;
}
const participants = [room.localParticipant, ...room.remoteParticipants.values()];
participants.forEach((participant) => this.reattachParticipantVideo(participant));
this.syncParticipants();
},
attachTrack(track, publication, participant) {
if (track.kind === Track.Kind.Audio) {
if (participant === room?.localParticipant) return;
const element = track.attach();
element.className = 'meet-remote-audio';
element.style.display = 'none';
if (!element.parentElement) {
document.body.appendChild(element);
}
element.play().catch(() => {
this.audioPlaybackBlocked = true;
this.registerAudioUnlockGesture();
});
this.ensureAudioPlayback();
return;
}
if (track.kind !== Track.Kind.Video) return;
if (this.audioOnly) {
this.updateParticipantTile(participant);
return;
}
const isScreen = publication?.source === Track.Source.ScreenShare
|| track.source === Track.Source.ScreenShare;
if (isScreen) {
const container = document.getElementById('screen-share');
if (!container) return;
container.classList.remove('hidden');
container.innerHTML = '';
const el = track.attach();
el.className = 'h-full w-full rounded-xl object-contain';
container.appendChild(el);
track.mediaStreamTrack?.addEventListener('ended', () => {
this.clearScreenShareOverlay();
}, { once: true });
if (participant === room?.localParticipant) {
this.isScreenSharing = true;
}
return;
}
if (publication?.isMuted) {
this.updateParticipantTile(participant);
return;
}
this.attachVideoToTile(participant, track);
},
syncParticipants() {
if (!room) return;
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
this.syncActiveSpeakers(room.activeSpeakers ?? []);
return;
}
const all = [room.localParticipant, ...room.remoteParticipants.values()];
const activeIdentities = new Set(all.map((p) => p.identity));
this.participants = all.map((p) => ({ identity: p.identity, name: p.name }));
all.forEach((participant) => this.updateParticipantTile(participant));
const grid = document.getElementById('video-grid');
if (!grid) return;
grid.querySelectorAll('[data-participant-tile]').forEach((tile) => {
if (!activeIdentities.has(tile.dataset.participantTile)) {
tile.remove();
}
});
this.applyStageLayout();
this.updateGalleryGridLayout();
this.updateAudioGridLayout();
this.syncActiveSpeakers(room.activeSpeakers ?? []);
},
updateAudioGridLayout() {
const grid = document.getElementById('video-grid');
if (!grid || !this.audioOnly || this.usesStageLayout) {
return;
}
const count = grid.querySelectorAll('[data-participant-tile]').length;
grid.classList.toggle('meet-audio-grid--solo', count === 1);
grid.classList.toggle('meet-space-grid--few', count > 1 && count <= 3);
},
updateGalleryGridLayout() {
const grid = document.getElementById('video-grid');
if (!grid || this.usesStageLayout || this.audioOnly) {
return;
}
const count = grid.querySelectorAll('[data-participant-tile]').length;
grid.classList.toggle('meet-gallery-grid--solo', count === 1);
grid.classList.toggle('meet-gallery-grid--many', count >= 5);
grid.classList.toggle('meet-gallery-grid--dense', count >= 9);
},
pruneTilesFromSession() {
const joinedIds = new Set(
this.sessionParticipants
.filter((p) => p.status === 'joined')
.map((p) => p.uuid),
);
document.querySelectorAll('[data-participant-tile]').forEach((tile) => {
const identity = tile.dataset.participantTile;
if (room?.localParticipant?.identity === identity) return;
if (!joinedIds.has(identity)) {
tile.remove();
}
});
if (room) {
[room.localParticipant, ...room.remoteParticipants.values()].forEach((participant) => {
if (this.isSpace && !this.usesStageLayout) {
return;
}
if (!this.getTileContainerForIdentity(participant.identity)) {
this.removeParticipantTile(participant.identity);
}
});
}
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
return;
}
this.applyStageLayout();
},
async toggleMute() {
if (!room || !this.showMicrophoneControl()) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isMicrophoneEnabled;
if (enabled) {
await room.localParticipant.setMicrophoneEnabled(false);
} else {
try {
await room.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
});
this.needsMediaUnlock = false;
} catch (error) {
console.error(error);
this.connectionStatus = this.connectionErrorMessage(error);
this.needsMediaUnlock = true;
return;
}
}
this.syncLocalMediaState();
this.updateParticipantTile(room.localParticipant);
},
async toggleVideo() {
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isCameraEnabled;
await room.localParticipant.setCameraEnabled(!enabled);
this.syncLocalMediaState();
const local = room.localParticipant;
const videoPub = Array.from(local.videoTrackPublications.values())
.find((pub) => pub.source !== Track.Source.ScreenShare);
if (!enabled && videoPub?.track) {
this.attachTrack(videoPub.track, videoPub, local);
} else {
videoPub?.track?.detach();
this.updateParticipantTile(local);
}
},
async toggleScreenShare() {
if (!room || this.audioOnly || this.audioOnlyPublish || !this.canPublish) return;
this.isScreenSharing = !this.isScreenSharing;
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!this.isScreenSharing) {
this.clearScreenShareOverlay();
}
},
setupPictureInPicture() {
this.pictureInPictureSupported = typeof document !== 'undefined'
&& 'pictureInPictureEnabled' in document
&& document.pictureInPictureEnabled !== false;
if (!this.pictureInPictureSupported) {
return;
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.maybeAutoEnterPictureInPicture();
}
});
document.addEventListener('leavepictureinpicture', () => {
this.pictureInPictureActive = false;
});
},
showPictureInPictureControl() {
return this.pictureInPictureSupported && !this.audioOnly;
},
findPictureInPictureVideo() {
if (this.isScreenSharing) {
const screenVideo = document.querySelector('#screen-share video');
if (screenVideo) {
return screenVideo;
}
}
const localId = this.config.participantUuid;
const priorities = [];
if (room?.localParticipant?.isCameraEnabled) {
priorities.push(localId);
}
this.activeSpeakerIds.forEach((id) => {
if (!priorities.includes(id)) {
priorities.push(id);
}
});
for (const id of priorities) {
const video = this.findParticipantTile(id)?.querySelector('[data-tile-stage] video');
if (video) {
return video;
}
}
return document.querySelector('[data-participant-tile] [data-tile-stage] video');
},
async maybeAutoEnterPictureInPicture() {
if (!this.pictureInPictureSupported || this.pictureInPictureActive || this.audioOnly) {
return;
}
if (document.pictureInPictureElement) {
return;
}
await this.enterPictureInPicture({ silent: true });
},
async togglePictureInPicture() {
if (!this.pictureInPictureSupported) {
return;
}
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
this.pictureInPictureActive = false;
return;
}
await this.enterPictureInPicture();
},
async enterPictureInPicture({ silent = false } = {}) {
const video = this.findPictureInPictureVideo();
if (!video) {
if (!silent) {
this.connectionStatus = 'No video available to pop out';
}
return;
}
try {
await video.requestPictureInPicture();
this.pictureInPictureActive = true;
} catch {
if (!silent) {
this.connectionStatus = 'Pop-out blocked — try again from the toolbar';
}
}
},
async raiseHand() {
await fetch(this.config.raiseHandUrl, {
method: 'POST',
headers: this.headers(),
});
},
showMicrophoneControl() {
if (!this.canPublish) {
return false;
}
if (this.audioOnlyPublish) {
return this.speakGranted;
}
return true;
},
syncToolbarConditionalVisibility() {
document.querySelectorAll('[data-toolbar-conditional]').forEach((element) => {
element.removeAttribute('hidden');
});
},
showVideoControl() {
return this.canPublish && !this.audioOnly && !this.audioOnlyPublish;
},
showAudienceControls() {
if (this.isSpace) {
return !this.canPublish;
}
if (!this.usesSpeakAccess) {
return this.canPublish;
}
return !this.canPublish || this.audioOnlyPublish;
},
/** Mobile primary toolbar — webinars/conferences and audio room listeners. */
showPrimaryToolbarAudienceControls() {
if (this.isSpace) {
return !this.canPublish;
}
if (!this.usesSpeakAccess) {
return false;
}
return this.showAudienceControls();
},
showStagePublisherControls() {
return this.usesSpeakAccess && this.canPublish && !this.audioOnlyPublish;
},
speakRequestParticipants() {
return this.inCallParticipants().filter((person) => person.speak_requested && !person.speak_granted);
},
handRaisedParticipants() {
return this.inCallParticipants().filter((person) => (
person.hand_raised
&& person.role === 'attendee'
&& !person.speak_requested
));
},
spaceSpeakInvitePendingParticipants() {
return this.inCallParticipants().filter((person) => (
person.role === 'attendee'
&& person.speak_requested
));
},
spaceSpeakerUrl(template, participantUuid) {
return template?.replace('__UUID__', participantUuid) || '';
},
mergeSessionParticipant(participant) {
if (!participant?.uuid) {
return;
}
const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid);
if (index >= 0) {
this.sessionParticipants[index] = { ...this.sessionParticipants[index], ...participant };
}
if (participant.uuid === this.config.participantUuid) {
this.canPublish = ['host', 'co_host', 'panelist'].includes(participant.role);
this.speakRequested = Boolean(participant.speak_requested);
this.syncToolbarConditionalVisibility();
}
if (this.isSpace && !this.usesStageLayout) {
this.renderSpaceSpeakerTiles();
}
},
async refreshMediaAccess() {
if (!this.config.configured) {
return;
}
await this.reconnectLiveKit();
},
async inviteSpaceSpeaker(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spacePromoteUrl, participantUuid);
if (!this.isSpace || !this.isHost || !url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
async acceptSpaceSpeakInvitation() {
if (!this.isSpace || !this.config.spaceSpeakAcceptUrl) {
return;
}
const res = await fetch(this.config.spaceSpeakAcceptUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
await this.refreshMediaAccess();
},
async declineSpaceSpeakInvitation() {
if (!this.isSpace || !this.config.spaceSpeakDeclineUrl) {
return;
}
const res = await fetch(this.config.spaceSpeakDeclineUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
async dismissSpaceSpeakInvitation(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spaceSpeakDismissUrl, participantUuid);
if (!this.isSpace || !this.isHost || !url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
async promoteSpaceCoHost(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spaceCoHostUrl, participantUuid);
if (!this.isSpace || !this.isHost || !url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
async demoteSpaceSpeaker(participantUuid) {
const url = this.spaceSpeakerUrl(this.config.spaceDemoteUrl, participantUuid);
if (!this.isSpace || !this.isHost || !url) {
return;
}
const res = await fetch(url, {
method: 'DELETE',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSessionParticipant(data.participant);
},
syncSelfSpeakState() {
const self = this.sessionParticipants.find((person) => person.uuid === this.config.participantUuid);
if (!self) {
return;
}
this.speakGranted = Boolean(self.speak_granted);
this.speakRequested = Boolean(self.speak_requested);
},
mergeSpeakParticipant(participant) {
if (!participant?.uuid) {
return;
}
const index = this.sessionParticipants.findIndex((person) => person.uuid === participant.uuid);
if (index >= 0) {
this.sessionParticipants[index] = {
...this.sessionParticipants[index],
speak_requested: Boolean(participant.speak_requested),
speak_granted: Boolean(participant.speak_granted),
};
}
if (participant.uuid === this.config.participantUuid) {
this.speakGranted = Boolean(participant.speak_granted);
this.speakRequested = Boolean(participant.speak_requested);
}
},
speakActionUrl(template, participantUuid) {
return template?.replace('__UUID__', participantUuid) || '';
},
async requestSpeak() {
if (!this.config.speakRequestUrl) {
return;
}
const res = await fetch(this.config.speakRequestUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
},
async cancelSpeakRequest() {
if (!this.config.speakCancelUrl) {
return;
}
const res = await fetch(this.config.speakCancelUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
},
async grantSpeakAccess(participantUuid) {
const url = this.speakActionUrl(this.config.speakGrantUrl, participantUuid);
if (!url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
await this.poll();
},
async revokeSpeakAccess(participantUuid) {
const url = this.speakActionUrl(this.config.speakRevokeUrl, participantUuid);
if (!url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
await this.poll();
},
async dismissSpeakRequest(participantUuid) {
const url = this.speakActionUrl(this.config.speakDismissUrl, participantUuid);
if (!url) {
return;
}
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
return;
}
const data = await res.json();
this.mergeSpeakParticipant(data.participant);
},
async goLive() {
if (!this.config.goLiveUrl || this.goingLive) {
return;
}
this.goingLive = true;
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
try {
const res = await fetch(this.config.goLiveUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
throw new Error('Go live failed');
}
const data = await res.json();
window.location.replace(data.room_url || `/room/${data.session_uuid}`);
} catch {
this.goingLive = false;
this.startPolling();
}
},
async makeSpeaker(userRef) {
if (!this.config.addPresenterUrl || !userRef) {
return;
}
const res = await fetch(this.config.addPresenterUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ user_ref: userRef }),
});
if (! res.ok) {
return;
}
const data = await res.json();
this.presenterRefs = data.presenter_refs || this.presenterRefs;
this.sessionParticipants = this.sessionParticipants.map((person) => (
person.user_ref === userRef ? { ...person, role: 'panelist' } : person
));
this.applyStageLayout();
},
closeSidebar() {
this.sidebarPanel = null;
},
toggleSidebarPanel(name) {
this.sidebarPanel = this.sidebarPanel === name ? null : name;
if (this.sidebarPanel) {
this.shareOpen = false;
}
},
toggleChat() {
this.toggleSidebarPanel('chat');
},
toggleParticipants() {
this.toggleSidebarPanel('participants');
},
toggleFeatures() {
this.toggleSidebarPanel('features');
},
openParticipantsPanel() {
this.sidebarPanel = 'participants';
this.shareOpen = false;
},
async openShare() {
const useNativeShare = navigator.share
&& this.joinUrl
&& window.matchMedia('(max-width: 767px)').matches;
if (useNativeShare) {
try {
await navigator.share({
title: this.roomTitle || 'Ladill Meet',
text: `Join my ${this.sessionNoun()} on Ladill Meet`,
url: this.joinUrl,
});
return;
} catch (e) {
// User cancelled or native share failed — fall through to copy-link panel.
}
}
this.shareOpen = true;
this.shareCopied = false;
this.closeSidebar();
},
async copyJoinLink() {
if (!this.joinUrl) return;
try {
await navigator.clipboard.writeText(this.joinUrl);
this.shareCopied = true;
setTimeout(() => {
this.shareCopied = false;
}, 2000);
} catch (e) {
console.error(e);
}
},
async sendChat() {
const body = this.chatInput.trim();
if (!body) return;
this.chatInput = '';
const res = await fetch(this.config.chatUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ body }),
});
const data = await res.json();
if (data.message) this.appendChatMessage(data.message);
},
async sendReaction(kind) {
const emoji = kind === 'heart' ? '❤️' : '👍';
await fetch(this.config.reactionUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ emoji }),
});
this.showFloatingReaction(kind);
if (kind === 'thumbs') {
this.registerApplauseClap(this.displayName);
}
},
async poll() {
if (this.pollInFlight || !this.config.pollUrl) {
return;
}
this.pollInFlight = true;
try {
const headers = { Accept: 'application/json' };
if (this.pollEtag) {
headers['If-None-Match'] = this.pollEtag;
}
const res = await fetch(this.config.pollUrl, { headers, credentials: 'same-origin' });
const responseEtag = res.headers.get('ETag');
if (responseEtag) {
this.pollEtag = responseEtag;
}
if (res.status === 304) {
return;
}
if (!res.ok) {
if (res.status === 404 && this.config.endedUrl) {
this.redirectToMeetingEnded(this.config.endedUrl);
}
return;
}
const data = await res.json();
if (data.ended && data.redirect) {
if (this.goingLive) {
return;
}
this.redirectToMeetingEnded(data.redirect);
return;
}
if (data.participants) {
const previousSpeakRequestCount = this.speakRequestParticipants().length;
this.sessionParticipants = data.participants;
if (this.usesSpeakAccess) {
this.syncSelfSpeakState();
}
this.pruneTilesFromSession();
this.refreshParticipantTileAvatars();
this.renderAudienceStrip();
const speakRequestCount = this.speakRequestParticipants().length;
if (this.canManageSpeak && speakRequestCount > previousSpeakRequestCount) {
this.openParticipantsPanel();
}
}
if (typeof data.uses_stage_layout === 'boolean' || typeof data.stage_layout === 'string') {
this.mergeStageConfig(data);
this.applyStageLayout();
}
if (typeof data.waiting_count === 'number') {
const previous = this.waitingCount;
this.waitingCount = data.waiting_count;
if (this.isHost && data.waiting_count > previous) {
this.openParticipantsPanel();
}
}
this.updateChatFromPoll(data.messages || []);
this.qaItems = data.qa || [];
this.activePolls = data.polls || [];
if (data.broadcasts?.length) {
this.breakoutBroadcast = data.broadcasts[0].body;
}
await this.syncMediaSessionFromPoll(data);
this.processReactionsFromPoll(data.reactions || []);
} catch (e) {
// ignore poll errors
} finally {
this.pollInFlight = false;
}
},
sessionNoun() {
if (this.sessionNounLabel) {
return this.sessionNounLabel;
}
return this.isConference ? 'conference' : 'meeting';
},
stageLayoutLabel() {
return this.stageLayout === 'panel' ? 'Panel discussion' : 'Plenary';
},
conferenceStatusLabel() {
if (this.inBreakout) {
return 'Breakout · meeting mode';
}
if (this.isGreenRoom) {
if (this.usesStageLayout) {
return `Green room · ${this.stageLayoutLabel()}`;
}
return 'Green room';
}
if (!this.usesStageLayout) {
return 'Conference live';
}
return this.stageLayout === 'panel' ? 'Panel discussion live' : 'Plenary live';
},
conferenceStatusClass() {
if (this.inBreakout) {
return 'text-amber-400/90';
}
if (this.isGreenRoom) {
return 'text-violet-400/90';
}
if (this.stageLayout === 'panel') {
return 'text-violet-400/90';
}
return 'text-sky-400/90';
},
stageLayoutDescription() {
if (this.stageLayout === 'panel') {
const panelName = this.activePanelName();
if (panelName) {
return `Showing ${panelName} — multiple speakers in a split grid.`;
}
return 'Multiple speakers shown together in a split-screen grid.';
}
const spotlight = this.findSpeakerBySpotlightKey(this.resolveSpotlightKey());
if (spotlight) {
return `${spotlight.display_name} is on stage.`;
}
return 'Choose who fills the stage from Stage layout or Spotlight on a speaker.';
},
spotlightSpeakerName() {
return this.findSpeakerBySpotlightKey(this.resolveSpotlightKey())?.display_name || '';
},
activePanelName() {
if (!this.activePanelId) {
return '';
}
const panel = this.panels.find((entry) => entry.id === this.activePanelId);
return panel?.name || '';
},
openPanelSetup() {
this._stageSnapshot = {
stageLayout: this.stageLayout,
panels: JSON.parse(JSON.stringify(this.panels)),
activePanelId: this.activePanelId,
panelDiscussions: this.panelDiscussions,
spotlightSpeakerRef: this.spotlightSpeakerRef,
};
this.panelSetupOpen = true;
},
cancelPanelSetup() {
if (this._stageSnapshot) {
this.stageLayout = this._stageSnapshot.stageLayout;
this.panels = JSON.parse(JSON.stringify(this._stageSnapshot.panels));
this.activePanelId = this._stageSnapshot.activePanelId;
this.panelDiscussions = this._stageSnapshot.panelDiscussions;
this.spotlightSpeakerRef = this._stageSnapshot.spotlightSpeakerRef || '';
this.applyStageLayout();
}
this._stageSnapshot = null;
this.panelSetupOpen = false;
},
selectStageLayout(layout) {
this.stageLayout = layout;
if (layout === 'panel') {
this.panelDiscussions = true;
if (this.panels.length === 0) {
this.addPanel();
}
if (!this.activePanelId && this.panels[0]) {
this.activePanelId = this.panels[0].id;
}
}
},
sessionNounTitle() {
return this.isConference ? 'Conference' : 'Meeting';
},
redirectToMeetingEnded(url) {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (room) {
room.disconnect().catch(() => {});
}
window.location.href = url;
},
updateChatFromPoll(messages) {
const container = document.getElementById('chat-messages');
if (!container) return;
const existing = new Set([...container.querySelectorAll('[data-uuid]')].map((el) => el.dataset.uuid));
messages.forEach((m) => {
if (!existing.has(m.uuid)) this.appendChatMessage(m);
});
},
appendChatMessage(message) {
const container = document.getElementById('chat-messages');
if (!container) return;
const isOwn = message.sender_name === this.displayName;
const div = document.createElement('div');
div.className = `meet-chat-bubble${isOwn ? ' meet-chat-bubble--own' : ''}`;
div.dataset.uuid = message.uuid;
const time = message.created_at
? new Date(message.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: '';
div.innerHTML = `
<div class="rounded-2xl px-3 py-2 ${isOwn ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-100'}">
${isOwn ? '' : `<p class="mb-0.5 text-xs font-medium text-sky-300">${this.escape(message.sender_name)}</p>`}
<p class="text-sm leading-relaxed">${this.escape(message.body)}</p>
</div>
<p class="mt-1 text-[10px] text-slate-500 ${isOwn ? 'text-right' : ''}">${this.escape(time)}</p>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
},
showFloatingReaction(kind) {
const id = Date.now();
this.floatingReactions.push({ id, kind });
setTimeout(() => {
this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id);
}, 2000);
},
initApplauseAudio(url) {
if (!url) {
return;
}
applauseAudio = new Audio(url);
applauseAudio.preload = 'auto';
},
isApplauseEmoji(emoji) {
return APPLAUSE_EMOJIS.has(emoji);
},
registerApplauseClap(senderName) {
if (!senderName) {
return;
}
applauseClapTimes.push({ at: Date.now(), sender: senderName });
this.maybePlayApplauseSound();
},
maybePlayApplauseSound() {
if (!applauseAudio?.src) {
return;
}
const now = Date.now();
applauseClapTimes = applauseClapTimes.filter((entry) => now - entry.at <= APPLAUSE_WINDOW_MS);
const clappers = new Set(applauseClapTimes.map((entry) => entry.sender));
if (clappers.size < APPLAUSE_MIN_CLAPPERS) {
return;
}
if (now - lastApplauseSoundAt < APPLAUSE_SOUND_COOLDOWN_MS) {
return;
}
const volume = 0.35 + Math.min(clappers.size - APPLAUSE_MIN_CLAPPERS + 1, 4) * 0.12;
this.playApplauseSound(volume);
},
playApplauseSound(volume = 0.7) {
if (!applauseAudio?.src) {
return;
}
try {
applauseAudio.volume = Math.min(1, Math.max(0.2, volume));
applauseAudio.currentTime = 0;
applauseAudio.play().catch(() => {});
lastApplauseSoundAt = Date.now();
} catch {
// Browser blocked playback until user gesture.
}
},
processReactionsFromPoll(reactions) {
if (!Array.isArray(reactions)) {
return;
}
if (!this.reactionsInitialized) {
reactions.forEach((reaction) => {
seenReactionKeys.add(`${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`);
});
this.reactionsInitialized = true;
return;
}
reactions.forEach((reaction) => {
const key = `${reaction.created_at}|${reaction.sender_name}|${reaction.emoji}`;
if (seenReactionKeys.has(key)) {
return;
}
seenReactionKeys.add(key);
if (this.isApplauseEmoji(reaction.emoji)) {
if (reaction.sender_name !== this.displayName) {
this.showFloatingReaction('thumbs');
}
this.registerApplauseClap(reaction.sender_name);
return;
}
if (reaction.emoji === '❤️' && reaction.sender_name !== this.displayName) {
this.showFloatingReaction('heart');
}
});
if (seenReactionKeys.size > 200) {
seenReactionKeys = new Set([...seenReactionKeys].slice(-100));
}
},
headers() {
return {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': this.config.csrf,
'X-Requested-With': 'XMLHttpRequest',
};
},
escape(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
};
}
window.meetRoom = meetRoom;
Alpine.data('meetRoom', meetRoom);
Alpine.start();