Files
ladill-meet/resources/js/meet-room.js
T
isaaccladandCursor e698e75f17
Deploy Ladill Meet / deploy (push) Successful in 58s
Improve conference attendee controls and stage participant strip.
Give attendees reaction buttons instead of media controls, gate applause audio until multiple people clap, show the avatar strip in panel layout, and remove the plenary stage subtitle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 23:46:54 +00:00

2209 lines
80 KiB
JavaScript

import Alpine from 'alpinejs';
import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client';
function readMeetBootState() {
const el = document.getElementById('meet-config');
return {
isRecording: el?.dataset.recordingActive === '1',
isLocked: el?.dataset.locked === '1',
};
}
function meetRoom() {
const boot = readMeetBootState();
// 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: boot.isRecording,
isLocked: boot.isLocked,
isHost: false,
watermark: false,
liveCaptions: false,
captionText: '',
displayName: '',
canPublish: true,
muteOnJoin: true,
videoOffOnJoin: false,
audioPlaybackBlocked: false,
needsMediaUnlock: false,
waitingCount: 0,
isWebinar: false,
isConference: false,
isGreenRoom: false,
goingLive: false,
presenterRefs: [],
audioOnly: false,
isSpace: false,
usesStageLayout: false,
panelDiscussions: false,
stageLayout: 'plenary',
panels: [],
activePanelId: '',
spotlightSpeakerRef: '',
panelSetupOpen: false,
savingStageLayout: false,
_stageSnapshot: null,
qaItems: [],
activePolls: [],
breakoutBroadcast: '',
featuresOpen: false,
whiteboardOpen: false,
qaInput: '',
chatOpen: false,
participantsOpen: false,
chatInput: '',
shareOpen: false,
shareCopied: false,
breakoutModalOpen: false,
breakoutCount: '4',
endMeetingConfirmOpen: false,
activeRecordingUuid: null,
joinUrl: '',
passcode: '',
roomTitle: '',
connectionStatus: 'Connecting…',
participants: [],
sessionParticipants: [],
activeSpeakerIds: [],
roomHost: null,
floatingReactions: [],
reactionsInitialized: false,
pollTimer: null,
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,
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,
breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl,
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.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '1';
this.isConference = el.dataset.isConference === '1';
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.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;
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.pollTimer = setInterval(() => this.poll(), 3000);
this.applyStageLayout();
},
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();
if (track.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
this.updateParticipantTile(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) {
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.connectionStatus = 'Connected';
this.syncParticipants();
if (this.canPublish) {
await this.enableLocalMedia(room);
} else {
this.connectionStatus = this.audioOnly ? 'Listening' : 'View-only audience mode';
this.isMuted = true;
this.isVideoOff = true;
}
await this.ensureAudioPlayback();
this.setupCaptionPipeline();
this.attachExistingTracks(room.localParticipant);
room.remoteParticipants.forEach((participant) => this.attachExistingTracks(participant));
this.syncParticipants();
} 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.muteOnJoin || this.isHost;
const wantCamera = !this.videoOffOnJoin && !this.audioOnly;
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) {
await this.unlockLocalMicrophone();
}
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) {
await this.unlockLocalMicrophone();
}
},
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;
},
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 sizeClass = 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 (!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));
},
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 spotlightRef = this.resolveSpotlightSpeakerRef();
if (spotlightRef) {
const match = this.speakerParticipants().find((person) => person.user_ref === spotlightRef);
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 onPanelRefs = new Set(this.getActivePanelSpeakerRefs());
return this.inCallParticipants()
.filter((person) => {
if (!this.isSpeakerPerson(person)) {
return true;
}
return !person.user_ref || !onPanelRefs.has(person.user_ref);
})
.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);
},
resolveSpotlightSpeakerRef() {
const speakers = this.speakerParticipants();
if (this.spotlightSpeakerRef && speakers.some((person) => person.user_ref === this.spotlightSpeakerRef)) {
return this.spotlightSpeakerRef;
}
return speakers[0]?.user_ref || null;
},
getActivePanelSpeakerRefs() {
if (!this.panelDiscussions || this.stageLayout !== 'panel') {
return this.speakerParticipants()
.map((person) => person.user_ref)
.filter(Boolean);
}
if (!this.activePanelId) {
return this.speakerParticipants()
.map((person) => person.user_ref)
.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';
}
},
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 spotlightRef = this.resolveSpotlightSpeakerRef();
if (person?.user_ref && spotlightRef && person.user_ref === spotlightRef) {
return spotlight;
}
if (!spotlightRef && this.speakerParticipantsSorted()[0]?.uuid === identity) {
return spotlight;
}
return null;
}
if (this.stageLayout === 'panel') {
if (!this.isSpeakerPerson(person)) {
return null;
}
const refs = this.getActivePanelSpeakerRefs();
if (!person?.user_ref || !refs.includes(person.user_ref)) {
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();
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 tile = this.findParticipantTile(participant.identity);
if (!tile) return;
const target = this.getTileContainerForIdentity(participant.identity);
if (target) {
if (tile.parentElement !== target) {
target.appendChild(tile);
}
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);
} else {
tile.remove();
}
});
}
if (grid) {
grid.dataset.panelCount = String(this.getActivePanelSpeakerRefs().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(userRef) {
if (!userRef) {
return;
}
this.spotlightSpeakerRef = userRef;
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: userRef,
}),
});
},
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, userRef) {
if (!panel || !userRef) {
return;
}
const refs = panel.speaker_refs || [];
if (refs.includes(userRef)) {
panel.speaker_refs = refs.filter((ref) => ref !== userRef);
} else {
panel.speaker_refs = [...refs, userRef];
}
},
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;
},
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: 'Panelist',
attendee: '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 = document.createElement('div');
tile.dataset.participantTile = identity;
tile.className = this.audioOnly
? 'meet-audio-tile relative flex aspect-square w-full max-w-[9rem] 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 = `
<div data-tile-body class="relative min-h-0 flex-1">
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></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>`;
}
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;
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');
return;
}
avatar.classList.remove('hidden');
this.updateTileAvatarCircle(tile, participant);
stage.innerHTML = '';
},
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 = 'h-full w-full object-cover';
el.dataset.participant = participant.identity;
stage.appendChild(el);
},
setupCaptionPipeline() {
if (!this.liveCaptions || !this.config.captionUrl) return;
if (room?.localParticipant?.isMicrophoneEnabled) return;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) return;
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.onresult = (event) => {
const text = event.results[event.results.length - 1][0].transcript.trim();
if (!text) return;
this.captionText = text;
fetch(this.config.captionUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ text }),
}).catch(() => {});
};
recognition.onerror = () => {};
try {
recognition.start();
} catch (e) {
// browser may block without gesture
}
},
async toggleRecording() {
if (!this.isHost) return;
if (this.isRecording) {
await this.stopServerAndUploadRecording();
} else {
await this.startServerRecording();
}
},
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;
if (!this.startClientRecording()) {
this.isRecording = false;
this.activeRecordingUuid = null;
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;
const videoSource = screenVideo || document.getElementById('video-grid');
if (!videoSource?.captureStream) {
return null;
}
const videoStream = videoSource.captureStream(25);
const tracks = [...videoStream.getVideoTracks(), ...this.collectMeetingAudioTracks()];
return tracks.length ? new MediaStream(tracks) : null;
},
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 res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() });
const blob = await this.stopClientRecording();
this.isRecording = false;
if (!res.ok) {
this.activeRecordingUuid = null;
return;
}
const data = await res.json().catch(() => ({}));
const recordingUuid = data.recording_uuid || this.activeRecordingUuid;
if (blob && recordingUuid) {
const uploaded = await this.uploadRecording(blob, recordingUuid);
if (!uploaded) {
window.alert(`Recording upload failed. Open Recordings and try again, or re-record the ${this.sessionNoun()}.`);
}
} else if (recordingUuid) {
window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`);
}
this.activeRecordingUuid = null;
},
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() {
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.isHost || !this.config.breakoutsCreateUrl) return;
this.breakoutCount = '4';
this.breakoutModalOpen = true;
this.featuresOpen = false;
},
async submitBreakouts() {
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
const count = parseInt(this.breakoutCount, 10);
if (!count || count < 1) return;
await fetch(this.config.breakoutsCreateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ count }),
});
this.breakoutModalOpen = false;
},
async closeBreakouts() {
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
this.featuresOpen = false;
},
openUploadPicker() {
document.getElementById('meet-file-input')?.click();
this.featuresOpen = false;
},
async toggleRecordingFromMenu() {
await this.toggleRecording();
this.featuresOpen = false;
},
async toggleLockFromMenu() {
await this.toggleLock();
this.featuresOpen = false;
},
openEndMeetingConfirm() {
this.endMeetingConfirmOpen = true;
},
submitEndMeeting() {
if (this.isRecording) {
this.stopServerAndUploadRecording().finally(() => {
document.getElementById('meet-end-form')?.requestSubmit();
});
} else {
document.getElementById('meet-end-form')?.requestSubmit();
}
this.endMeetingConfirmOpen = false;
},
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);
},
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);
return;
}
if (publication?.isMuted) {
this.updateParticipantTile(participant);
return;
}
this.attachVideoToTile(participant, track);
},
syncParticipants() {
if (!room) 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.syncActiveSpeakers(room.activeSpeakers ?? []);
},
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();
}
});
this.applyStageLayout();
},
async toggleMute() {
if (!room || !this.canPublish) 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.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.canPublish) return;
this.isScreenSharing = !this.isScreenSharing;
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!this.isScreenSharing) {
const screen = document.getElementById('screen-share');
if (screen) {
screen.classList.add('hidden');
screen.innerHTML = '';
}
}
},
async raiseHand() {
await fetch(this.config.raiseHandUrl, {
method: 'POST',
headers: this.headers(),
});
},
async goLive() {
if (!this.config.goLiveUrl || this.goingLive) {
return;
}
this.goingLive = true;
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.href = data.room_url || `/room/${data.session_uuid}`;
} catch {
this.goingLive = false;
}
},
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();
},
toggleChat() {
this.chatOpen = !this.chatOpen;
if (this.chatOpen) {
this.participantsOpen = false;
this.featuresOpen = false;
this.shareOpen = false;
}
},
toggleParticipants() {
this.participantsOpen = !this.participantsOpen;
if (this.participantsOpen) {
this.chatOpen = false;
this.featuresOpen = false;
this.shareOpen = false;
}
},
toggleFeatures() {
this.featuresOpen = !this.featuresOpen;
if (this.featuresOpen) {
this.chatOpen = false;
this.participantsOpen = false;
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.participantsOpen = false;
this.chatOpen = false;
this.featuresOpen = false;
},
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() {
try {
const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } });
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) {
this.redirectToMeetingEnded(data.redirect);
return;
}
if (data.participants) {
this.sessionParticipants = data.participants;
this.pruneTilesFromSession();
this.refreshParticipantTileAvatars();
this.renderAudienceStrip();
}
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.participantsOpen = true;
}
}
this.updateChatFromPoll(data.messages || []);
this.qaItems = data.qa || [];
this.activePolls = data.polls || [];
if (data.broadcasts?.length) {
this.breakoutBroadcast = data.broadcasts[0].body;
}
this.processReactionsFromPoll(data.reactions || []);
} catch (e) {
// ignore poll errors
}
},
sessionNoun() {
return this.isConference ? 'conference' : 'meeting';
},
stageLayoutLabel() {
return this.stageLayout === 'panel' ? 'Panel discussion' : 'Plenary';
},
conferenceStatusLabel() {
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.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.';
}
return '';
},
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,
};
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.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();