Deploy Ladill Meet / deploy (push) Successful in 39s
Unlock LiveKit audio after user gesture, respect join mute/video settings, and replace plain links with btn-secondary pills on the meeting details page. Co-authored-by: Cursor <cursoragent@cursor.com>
938 lines
36 KiB
JavaScript
938 lines
36 KiB
JavaScript
import Alpine from 'alpinejs';
|
|
import { ConnectionState, Room, RoomEvent, Track } from 'livekit-client';
|
|
|
|
function readMeetBootState() {
|
|
const el = document.getElementById('meet-config');
|
|
|
|
return {
|
|
isRecording: el?.dataset.recordingActive === '1',
|
|
isLocked: el?.dataset.locked === '1',
|
|
};
|
|
}
|
|
|
|
function meetRoom() {
|
|
const boot = readMeetBootState();
|
|
// LiveKit Room must stay outside Alpine's reactive proxy — wrapping it breaks
|
|
// structuredClone inside the SDK during connect and media setup.
|
|
let room = null;
|
|
let connectPromise = null;
|
|
let initialized = false;
|
|
|
|
return {
|
|
isMuted: true,
|
|
isVideoOff: true,
|
|
isScreenSharing: false,
|
|
isRecording: boot.isRecording,
|
|
isLocked: boot.isLocked,
|
|
isHost: false,
|
|
watermark: false,
|
|
liveCaptions: false,
|
|
captionText: '',
|
|
displayName: '',
|
|
canPublish: true,
|
|
muteOnJoin: true,
|
|
videoOffOnJoin: false,
|
|
audioPlaybackBlocked: false,
|
|
isWebinar: false,
|
|
qaItems: [],
|
|
activePolls: [],
|
|
breakoutBroadcast: '',
|
|
featuresOpen: false,
|
|
whiteboardOpen: false,
|
|
qaInput: '',
|
|
chatOpen: false,
|
|
participantsOpen: false,
|
|
chatInput: '',
|
|
shareOpen: false,
|
|
shareCopied: false,
|
|
breakoutModalOpen: false,
|
|
breakoutCount: '4',
|
|
endMeetingConfirmOpen: false,
|
|
joinUrl: '',
|
|
passcode: '',
|
|
roomTitle: '',
|
|
connectionStatus: 'Connecting…',
|
|
participants: [],
|
|
sessionParticipants: [],
|
|
roomHost: null,
|
|
floatingReactions: [],
|
|
pollTimer: null,
|
|
config: {},
|
|
|
|
init() {
|
|
if (initialized) return;
|
|
initialized = true;
|
|
|
|
const el = document.getElementById('meet-config');
|
|
if (!el) return;
|
|
|
|
this.config = {
|
|
token: el.dataset.token,
|
|
url: el.dataset.url,
|
|
configured: el.dataset.configured === '1',
|
|
pollUrl: el.dataset.pollUrl,
|
|
chatUrl: el.dataset.chatUrl,
|
|
reactionUrl: el.dataset.reactionUrl,
|
|
raiseHandUrl: el.dataset.raiseHandUrl,
|
|
recordingStartUrl: el.dataset.recordingStartUrl,
|
|
recordingStopUrl: el.dataset.recordingStopUrl,
|
|
lockUrl: el.dataset.lockUrl,
|
|
unlockUrl: el.dataset.unlockUrl,
|
|
captionUrl: el.dataset.captionUrl,
|
|
qaUrl: el.dataset.qaUrl,
|
|
pollsCreateUrl: el.dataset.pollsCreateUrl,
|
|
breakoutsCreateUrl: el.dataset.breakoutsCreateUrl,
|
|
breakoutsCloseUrl: el.dataset.breakoutsCloseUrl,
|
|
breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl,
|
|
filesUploadUrl: el.dataset.filesUploadUrl,
|
|
whiteboardUrl: el.dataset.whiteboardUrl,
|
|
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
|
|
csrf: el.dataset.csrf,
|
|
sessionUuid: el.dataset.sessionUuid,
|
|
};
|
|
|
|
this.joinUrl = el.dataset.joinUrl || '';
|
|
this.passcode = el.dataset.passcode || '';
|
|
this.roomTitle = el.dataset.roomTitle || '';
|
|
|
|
this.isHost = el.dataset.isHost === '1';
|
|
this.canPublish = el.dataset.canPublish !== '0';
|
|
this.isWebinar = el.dataset.isWebinar === '1';
|
|
this.muteOnJoin = el.dataset.muteOnJoin === '1';
|
|
this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1';
|
|
this.isRecording = el.dataset.recordingActive === '1';
|
|
this.isLocked = el.dataset.locked === '1';
|
|
this.watermark = el.dataset.watermark === '1';
|
|
this.liveCaptions = el.dataset.liveCaptions === '1';
|
|
this.displayName = el.dataset.displayName || '';
|
|
|
|
try {
|
|
this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]');
|
|
} catch {
|
|
this.sessionParticipants = [];
|
|
}
|
|
|
|
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);
|
|
},
|
|
|
|
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);
|
|
if (track.kind === Track.Kind.Audio) {
|
|
this.ensureAudioPlayback();
|
|
}
|
|
});
|
|
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) {
|
|
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) {
|
|
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.ParticipantConnected, () => this.syncParticipants());
|
|
room.on(RoomEvent.ParticipantDisconnected, () => 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';
|
|
}
|
|
});
|
|
|
|
await room.prepareConnection(this.config.url, this.config.token);
|
|
|
|
await room.connect(this.config.url, this.config.token, {
|
|
autoSubscribe: true,
|
|
peerConnectionTimeout: 20_000,
|
|
websocketTimeout: 20_000,
|
|
});
|
|
|
|
await this.waitForRoomConnected(room);
|
|
|
|
this.connectionStatus = 'Connected';
|
|
this.syncParticipants();
|
|
|
|
if (this.canPublish) {
|
|
await this.enableLocalMedia(room);
|
|
} else {
|
|
this.connectionStatus = 'View-only audience mode';
|
|
this.isMuted = true;
|
|
this.isVideoOff = true;
|
|
}
|
|
|
|
await this.ensureAudioPlayback();
|
|
|
|
this.setupCaptionPipeline();
|
|
|
|
this.updateParticipantTile(room.localParticipant);
|
|
room.localParticipant.videoTrackPublications.forEach((pub) => {
|
|
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
|
|
this.attachTrack(pub.track, pub, room.localParticipant);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
this.connectionStatus = this.connectionErrorMessage(e);
|
|
}
|
|
},
|
|
|
|
waitForRoomConnected(activeRoom) {
|
|
if (activeRoom.state === ConnectionState.Connected) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
activeRoom.off(RoomEvent.Connected, onConnected);
|
|
reject(new Error('Timed out waiting for LiveKit room connection'));
|
|
}, 20_000);
|
|
|
|
const onConnected = () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
};
|
|
|
|
activeRoom.once(RoomEvent.Connected, onConnected);
|
|
});
|
|
},
|
|
|
|
async enableLocalMedia(activeRoom, attempts = 3) {
|
|
const wantMic = !this.muteOnJoin || this.isHost;
|
|
const wantCamera = !this.videoOffOnJoin;
|
|
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
try {
|
|
await activeRoom.localParticipant.setMicrophoneEnabled(wantMic);
|
|
await activeRoom.localParticipant.setCameraEnabled(wantCamera);
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.connectionStatus = 'Connected';
|
|
return;
|
|
} catch (mediaError) {
|
|
console.error(mediaError);
|
|
const retryable = mediaError?.message?.includes('engine not connected within timeout');
|
|
|
|
if (!retryable || attempt === attempts) {
|
|
this.syncLocalMediaState();
|
|
this.updateParticipantTile(activeRoom.localParticipant);
|
|
this.connectionStatus = this.connectionErrorMessage(mediaError);
|
|
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.audioPlaybackBlocked) {
|
|
document.removeEventListener('click', unlock, true);
|
|
document.removeEventListener('keydown', unlock, true);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('click', unlock, true);
|
|
document.addEventListener('keydown', unlock, 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%))`,
|
|
};
|
|
},
|
|
|
|
hostParticipant() {
|
|
const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host');
|
|
if (hosts.length) {
|
|
return hosts[0];
|
|
}
|
|
|
|
if (this.roomHost?.user_ref) {
|
|
const inCall = this.sessionParticipants.find((p) => p.user_ref === this.roomHost.user_ref);
|
|
if (inCall) {
|
|
return inCall;
|
|
}
|
|
|
|
return this.roomHost;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
participantCount() {
|
|
return this.inCallParticipants().length;
|
|
},
|
|
|
|
inCallParticipants() {
|
|
return this.sessionParticipants.filter((p) => p.status === 'joined');
|
|
},
|
|
|
|
waitingParticipants() {
|
|
return this.sessionParticipants.filter((p) => p.status === 'waiting');
|
|
},
|
|
|
|
sortedParticipants() {
|
|
const order = { host: 0, co_host: 1, panelist: 2, participant: 3, guest: 4, attendee: 5 };
|
|
|
|
return [...this.inCallParticipants()].sort((a, b) => {
|
|
const diff = (order[a.role] ?? 9) - (order[b.role] ?? 9);
|
|
|
|
return diff !== 0 ? diff : a.display_name.localeCompare(b.display_name);
|
|
});
|
|
},
|
|
|
|
roleLabel(role) {
|
|
const labels = {
|
|
host: 'Host',
|
|
co_host: 'Co-host',
|
|
panelist: 'Panelist',
|
|
attendee: 'Attendee',
|
|
guest: 'Guest',
|
|
participant: 'Participant',
|
|
};
|
|
|
|
return labels[role] || 'Participant';
|
|
},
|
|
|
|
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();
|
|
},
|
|
|
|
getOrCreateParticipantTile(participant) {
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid) return null;
|
|
|
|
const identity = participant.identity;
|
|
let tile = grid.querySelector(`[data-participant-tile="${identity}"]`);
|
|
|
|
if (!tile) {
|
|
tile = document.createElement('div');
|
|
tile.dataset.participantTile = identity;
|
|
tile.className = 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900';
|
|
tile.innerHTML = `
|
|
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
|
|
<div data-tile-avatar class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
|
|
<div data-tile-avatar-circle class="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-semibold text-white shadow-lg"></div>
|
|
</div>
|
|
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-2 pt-8">
|
|
<span data-tile-name class="truncate text-xs font-medium text-white"></span>
|
|
<div class="flex shrink-0 items-center gap-1.5">
|
|
<span data-tile-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>`;
|
|
grid.appendChild(tile);
|
|
}
|
|
|
|
const displayName = participant.name || participant.identity;
|
|
const hue = this.avatarHue(displayName);
|
|
const circle = tile.querySelector('[data-tile-avatar-circle]');
|
|
circle.style.background = `linear-gradient(135deg, hsl(${hue} 45% 42%), hsl(${(hue + 40) % 360} 50% 28%))`;
|
|
circle.textContent = this.participantInitials(displayName);
|
|
tile.querySelector('[data-tile-name]').textContent = displayName;
|
|
|
|
return tile;
|
|
},
|
|
|
|
isParticipantVideoActive(participant) {
|
|
if (!participant.isCameraEnabled) return false;
|
|
|
|
const publication = Array.from(participant.videoTrackPublications.values())
|
|
.find((pub) => pub.source !== Track.Source.ScreenShare && pub.track && !pub.isMuted);
|
|
|
|
return Boolean(publication?.track);
|
|
},
|
|
|
|
isParticipantMuted(participant) {
|
|
return !participant.isMicrophoneEnabled;
|
|
},
|
|
|
|
updateParticipantTile(participant) {
|
|
const tile = this.getOrCreateParticipantTile(participant);
|
|
if (!tile) return;
|
|
|
|
const stage = tile.querySelector('[data-tile-stage]');
|
|
const avatar = tile.querySelector('[data-tile-avatar]');
|
|
const micBadge = tile.querySelector('[data-tile-mic]');
|
|
const camBadge = tile.querySelector('[data-tile-cam]');
|
|
const hasVideo = this.isParticipantVideoActive(participant);
|
|
|
|
micBadge.classList.toggle('hidden', !this.isParticipantMuted(participant));
|
|
camBadge.classList.toggle('hidden', hasVideo);
|
|
|
|
if (hasVideo) {
|
|
avatar.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
avatar.classList.remove('hidden');
|
|
stage.innerHTML = '';
|
|
},
|
|
|
|
attachVideoToTile(participant, track) {
|
|
const tile = this.getOrCreateParticipantTile(participant);
|
|
if (!tile) return;
|
|
|
|
const stage = tile.querySelector('[data-tile-stage]');
|
|
const avatar = tile.querySelector('[data-tile-avatar]');
|
|
const camBadge = tile.querySelector('[data-tile-cam]');
|
|
|
|
avatar.classList.add('hidden');
|
|
camBadge.classList.add('hidden');
|
|
stage.innerHTML = '';
|
|
|
|
const el = track.attach();
|
|
el.className = 'h-full w-full object-cover';
|
|
el.dataset.participant = participant.identity;
|
|
stage.appendChild(el);
|
|
},
|
|
|
|
setupCaptionPipeline() {
|
|
if (!this.liveCaptions || !this.config.captionUrl) return;
|
|
if (room?.localParticipant?.isMicrophoneEnabled) return;
|
|
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
if (!SpeechRecognition) return;
|
|
|
|
const recognition = new SpeechRecognition();
|
|
recognition.continuous = true;
|
|
recognition.interimResults = true;
|
|
recognition.onresult = (event) => {
|
|
const text = event.results[event.results.length - 1][0].transcript.trim();
|
|
if (!text) return;
|
|
this.captionText = text;
|
|
fetch(this.config.captionUrl, {
|
|
method: 'POST',
|
|
headers: this.headers(),
|
|
body: JSON.stringify({ text }),
|
|
}).catch(() => {});
|
|
};
|
|
recognition.onerror = () => {};
|
|
try {
|
|
recognition.start();
|
|
} catch (e) {
|
|
// browser may block without gesture
|
|
}
|
|
},
|
|
|
|
async toggleRecording() {
|
|
if (!this.isHost) return;
|
|
const url = this.isRecording ? this.config.recordingStopUrl : this.config.recordingStartUrl;
|
|
const res = await fetch(url, { method: 'POST', headers: this.headers() });
|
|
if (res.ok) {
|
|
this.isRecording = !this.isRecording;
|
|
}
|
|
},
|
|
|
|
async toggleLock() {
|
|
if (!this.isHost) return;
|
|
const url = this.isLocked ? this.config.unlockUrl : this.config.lockUrl;
|
|
const res = await fetch(url, { method: 'POST', headers: this.headers() });
|
|
if (res.ok) {
|
|
this.isLocked = !this.isLocked;
|
|
}
|
|
},
|
|
|
|
async submitQuestion() {
|
|
const question = this.qaInput.trim();
|
|
if (!question || !this.config.qaUrl) return;
|
|
this.qaInput = '';
|
|
await fetch(this.config.qaUrl, {
|
|
method: 'POST',
|
|
headers: this.headers(),
|
|
body: JSON.stringify({ question }),
|
|
});
|
|
},
|
|
|
|
async createBreakouts() {
|
|
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
|
|
this.breakoutCount = '4';
|
|
this.breakoutModalOpen = true;
|
|
this.featuresOpen = false;
|
|
},
|
|
|
|
async submitBreakouts() {
|
|
if (!this.isHost || !this.config.breakoutsCreateUrl) return;
|
|
|
|
const count = parseInt(this.breakoutCount, 10);
|
|
if (!count || count < 1) return;
|
|
|
|
await fetch(this.config.breakoutsCreateUrl, {
|
|
method: 'POST',
|
|
headers: this.headers(),
|
|
body: JSON.stringify({ count }),
|
|
});
|
|
|
|
this.breakoutModalOpen = false;
|
|
},
|
|
|
|
async closeBreakouts() {
|
|
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
|
|
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
|
|
this.featuresOpen = false;
|
|
},
|
|
|
|
openUploadPicker() {
|
|
document.getElementById('meet-file-input')?.click();
|
|
this.featuresOpen = false;
|
|
},
|
|
|
|
async toggleRecordingFromMenu() {
|
|
await this.toggleRecording();
|
|
this.featuresOpen = false;
|
|
},
|
|
|
|
async toggleLockFromMenu() {
|
|
await this.toggleLock();
|
|
this.featuresOpen = false;
|
|
},
|
|
|
|
openEndMeetingConfirm() {
|
|
this.endMeetingConfirmOpen = true;
|
|
},
|
|
|
|
submitEndMeeting() {
|
|
document.getElementById('meet-end-form')?.requestSubmit();
|
|
this.endMeetingConfirmOpen = false;
|
|
},
|
|
|
|
async uploadFile(event) {
|
|
const file = event.target.files?.[0];
|
|
if (!file || !this.config.filesUploadUrl) return;
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
await fetch(this.config.filesUploadUrl, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' },
|
|
body: form,
|
|
});
|
|
},
|
|
|
|
attachTrack(track, publication, participant) {
|
|
if (track.kind !== Track.Kind.Video) return;
|
|
|
|
const isScreen = publication?.source === Track.Source.ScreenShare
|
|
|| track.source === Track.Source.ScreenShare;
|
|
|
|
if (isScreen) {
|
|
const container = document.getElementById('screen-share');
|
|
if (!container) return;
|
|
container.classList.remove('hidden');
|
|
container.innerHTML = '';
|
|
const el = track.attach();
|
|
el.className = 'h-full w-full rounded-xl object-contain';
|
|
container.appendChild(el);
|
|
return;
|
|
}
|
|
|
|
if (publication?.isMuted) {
|
|
this.updateParticipantTile(participant);
|
|
return;
|
|
}
|
|
|
|
this.attachVideoToTile(participant, track);
|
|
},
|
|
|
|
syncParticipants() {
|
|
if (!room) return;
|
|
const all = [room.localParticipant, ...room.remoteParticipants.values()];
|
|
this.participants = all.map((p) => ({ identity: p.identity, name: p.name }));
|
|
all.forEach((participant) => this.updateParticipantTile(participant));
|
|
},
|
|
|
|
async toggleMute() {
|
|
if (!room) return;
|
|
await this.ensureAudioPlayback();
|
|
const enabled = room.localParticipant.isMicrophoneEnabled;
|
|
await room.localParticipant.setMicrophoneEnabled(!enabled);
|
|
this.syncLocalMediaState();
|
|
this.updateParticipantTile(room.localParticipant);
|
|
},
|
|
|
|
async toggleVideo() {
|
|
if (!room) 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) 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(),
|
|
});
|
|
},
|
|
|
|
toggleChat() {
|
|
this.chatOpen = !this.chatOpen;
|
|
if (this.chatOpen) {
|
|
this.participantsOpen = false;
|
|
this.featuresOpen = false;
|
|
this.shareOpen = false;
|
|
}
|
|
},
|
|
|
|
toggleParticipants() {
|
|
this.participantsOpen = !this.participantsOpen;
|
|
if (this.participantsOpen) {
|
|
this.chatOpen = false;
|
|
this.featuresOpen = false;
|
|
this.shareOpen = false;
|
|
}
|
|
},
|
|
|
|
toggleFeatures() {
|
|
this.featuresOpen = !this.featuresOpen;
|
|
if (this.featuresOpen) {
|
|
this.chatOpen = false;
|
|
this.participantsOpen = false;
|
|
this.shareOpen = false;
|
|
}
|
|
},
|
|
|
|
async openShare() {
|
|
const useNativeShare = navigator.share
|
|
&& this.joinUrl
|
|
&& window.matchMedia('(max-width: 767px)').matches;
|
|
|
|
if (useNativeShare) {
|
|
try {
|
|
await navigator.share({
|
|
title: this.roomTitle || 'Ladill Meet',
|
|
text: 'Join my meeting on Ladill Meet',
|
|
url: this.joinUrl,
|
|
});
|
|
|
|
return;
|
|
} catch (e) {
|
|
// User cancelled or native share failed — fall through to copy-link panel.
|
|
}
|
|
}
|
|
|
|
this.shareOpen = true;
|
|
this.shareCopied = false;
|
|
this.participantsOpen = false;
|
|
this.chatOpen = false;
|
|
this.featuresOpen = false;
|
|
},
|
|
|
|
async copyJoinLink() {
|
|
if (!this.joinUrl) return;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(this.joinUrl);
|
|
this.shareCopied = true;
|
|
setTimeout(() => {
|
|
this.shareCopied = false;
|
|
}, 2000);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
},
|
|
|
|
async sendChat() {
|
|
const body = this.chatInput.trim();
|
|
if (!body) return;
|
|
this.chatInput = '';
|
|
const res = await fetch(this.config.chatUrl, {
|
|
method: 'POST',
|
|
headers: this.headers(),
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.message) this.appendChatMessage(data.message);
|
|
},
|
|
|
|
async sendReaction(kind) {
|
|
const emoji = kind === 'heart' ? '❤️' : '👍';
|
|
await fetch(this.config.reactionUrl, {
|
|
method: 'POST',
|
|
headers: this.headers(),
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
this.showFloatingReaction(kind);
|
|
},
|
|
|
|
async poll() {
|
|
try {
|
|
const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } });
|
|
const data = await res.json();
|
|
if (data.participants) {
|
|
this.sessionParticipants = data.participants;
|
|
}
|
|
this.updateChatFromPoll(data.messages || []);
|
|
this.qaItems = data.qa || [];
|
|
this.activePolls = data.polls || [];
|
|
if (data.broadcasts?.length) {
|
|
this.breakoutBroadcast = data.broadcasts[0].body;
|
|
}
|
|
} catch (e) {
|
|
// ignore poll errors
|
|
}
|
|
},
|
|
|
|
updateChatFromPoll(messages) {
|
|
const container = document.getElementById('chat-messages');
|
|
if (!container) return;
|
|
const existing = new Set([...container.querySelectorAll('[data-uuid]')].map((el) => el.dataset.uuid));
|
|
messages.forEach((m) => {
|
|
if (!existing.has(m.uuid)) this.appendChatMessage(m);
|
|
});
|
|
},
|
|
|
|
appendChatMessage(message) {
|
|
const container = document.getElementById('chat-messages');
|
|
if (!container) return;
|
|
|
|
const isOwn = message.sender_name === this.displayName;
|
|
const div = document.createElement('div');
|
|
div.className = `meet-chat-bubble${isOwn ? ' meet-chat-bubble--own' : ''}`;
|
|
div.dataset.uuid = message.uuid;
|
|
|
|
const time = message.created_at
|
|
? new Date(message.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
: '';
|
|
|
|
div.innerHTML = `
|
|
<div class="rounded-2xl px-3 py-2 ${isOwn ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-100'}">
|
|
${isOwn ? '' : `<p class="mb-0.5 text-xs font-medium text-sky-300">${this.escape(message.sender_name)}</p>`}
|
|
<p class="text-sm leading-relaxed">${this.escape(message.body)}</p>
|
|
</div>
|
|
<p class="mt-1 text-[10px] text-slate-500 ${isOwn ? 'text-right' : ''}">${this.escape(time)}</p>`;
|
|
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
},
|
|
|
|
showFloatingReaction(kind) {
|
|
const id = Date.now();
|
|
this.floatingReactions.push({ id, kind });
|
|
setTimeout(() => {
|
|
this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id);
|
|
}, 2000);
|
|
},
|
|
|
|
headers() {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': this.config.csrf,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
};
|
|
},
|
|
|
|
escape(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
},
|
|
};
|
|
}
|
|
|
|
window.meetRoom = meetRoom;
|
|
Alpine.data('meetRoom', meetRoom);
|
|
Alpine.start();
|