Files
ladill-meet/resources/js/meet-room.js
T
isaaccladandCursor c108514b27
Deploy Ladill Meet / deploy (push) Successful in 48s
Extend service API for Events and fix panel layout reverting during edit.
Service rooms support town_hall/webinar types with source lookup, update, and cancel; stage poll no longer overwrites layout while the modal is open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:08:01 +00:00

1835 lines
68 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 = [];
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,
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: [],
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.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%))`,
};
},
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));
},
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 || this.stageLayout !== 'plenary') {
return;
}
const audience = this.audienceParticipantsSorted();
if (audience.length === 0) {
return;
}
const maxVisible = 12;
const visible = audience.slice(0, maxVisible);
const overflow = audience.length - visible.length;
visible.forEach((person, index) => {
const item = document.createElement('div');
item.className = 'meet-audience-avatar';
item.style.zIndex = String(visible.length - index);
item.title = person.display_name;
if (person.avatar_url) {
const img = document.createElement('img');
img.src = person.avatar_url;
img.alt = '';
img.className = 'meet-audience-avatar__image';
item.appendChild(img);
} else {
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);
item.appendChild(initials);
}
strip.appendChild(item);
});
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);
}
tile.classList.toggle('meet-stage-spotlight-tile', target === spotlight);
tile.classList.toggle('meet-stage-panel-tile', target === grid && this.stageLayout === 'panel');
} 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.mergeStageConfig(data);
this.panelSetupOpen = false;
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.applyStageLayout();
},
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 items-center justify-center overflow-hidden rounded-2xl bg-slate-900'
: 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900';
tile.innerHTML = `
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
<div data-tile-avatar class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
<div data-tile-avatar-circle class="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg"></div>
</div>
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-2 pt-8">
<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);
}
tile.classList.toggle('meet-stage-spotlight-tile', container.id === 'meet-stage-spotlight');
tile.classList.toggle('meet-stage-panel-tile', container.id === 'video-grid' && this.stageLayout === 'panel');
const displayName = participant.name || participant.identity;
const hue = this.avatarHue(displayName);
const circle = tile.querySelector('[data-tile-avatar-circle]');
circle.style.background = `linear-gradient(135deg, hsl(${hue} 45% 42%), hsl(${(hue + 40) % 360} 50% 28%))`;
circle.textContent = this.participantInitials(displayName);
tile.querySelector('[data-tile-name]').textContent = displayName;
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');
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) 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) 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) 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);
},
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();
}
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;
}
} catch (e) {
// ignore poll errors
}
},
sessionNoun() {
return this.isConference ? 'conference' : 'meeting';
},
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);
},
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();