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