Files
ladill-meet/resources/js/meet-room.js
T
isaaccladandCursor 965fb992e9
Deploy Ladill Meet / deploy (push) Failing after 7s
Initial Ladill Meet release.
Phases 0–18: core meetings, webinar, breakouts, team chat, live streaming, town hall, billing, and Ladill Mail calendar wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 23:35:29 +00:00

355 lines
13 KiB
JavaScript

import Alpine from 'alpinejs';
import { Room, RoomEvent, Track } from 'livekit-client';
function meetRoom() {
return {
room: null,
isMuted: false,
isVideoOff: false,
isScreenSharing: false,
isRecording: false,
isLocked: false,
isHost: false,
watermark: false,
liveCaptions: false,
captionText: '',
displayName: '',
canPublish: true,
isWebinar: false,
qaItems: [],
activePolls: [],
breakoutBroadcast: '',
featuresOpen: false,
whiteboardOpen: false,
qaInput: '',
chatOpen: false,
chatInput: '',
connectionStatus: 'Connecting…',
participants: [],
floatingReactions: [],
pollTimer: null,
config: {},
init() {
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,
};
this.isHost = el.dataset.isHost === '1';
this.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '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 || '';
if (this.config.configured && this.config.token) {
this.connectLiveKit();
} else {
this.connectionStatus = 'Video unavailable — configure LiveKit';
}
this.pollTimer = setInterval(() => this.poll(), 3000);
},
async connectLiveKit() {
try {
this.room = new Room({ adaptiveStream: true, dynacast: true });
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
this.attachTrack(track, participant);
});
this.room.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach();
});
this.room.on(RoomEvent.ParticipantConnected, () => this.syncParticipants());
this.room.on(RoomEvent.ParticipantDisconnected, () => this.syncParticipants());
this.room.on(RoomEvent.Disconnected, () => {
this.connectionStatus = 'Disconnected';
});
await this.room.connect(this.config.url, this.config.token);
if (this.canPublish) {
await this.room.localParticipant.enableCameraAndMicrophone();
this.isMuted = false;
this.isVideoOff = false;
} else {
this.connectionStatus = 'View-only audience mode';
this.isMuted = true;
this.isVideoOff = true;
}
this.connectionStatus = 'Connected';
this.syncParticipants();
this.setupCaptionPipeline();
this.room.localParticipant.videoTrackPublications.forEach((pub) => {
if (pub.track) this.attachTrack(pub.track, this.room.localParticipant);
});
} catch (e) {
console.error(e);
this.connectionStatus = 'Connection failed';
}
},
setupCaptionPipeline() {
if (!this.liveCaptions || !this.config.captionUrl) 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;
const count = parseInt(prompt('Number of breakout rooms?', '4') || '0', 10);
if (!count) return;
await fetch(this.config.breakoutsCreateUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ count }),
});
},
async closeBreakouts() {
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
},
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, participant) {
const grid = document.getElementById('video-grid');
if (!grid || track.kind !== Track.Kind.Video) return;
const isScreen = track.source === Track.Source.ScreenShare;
const container = isScreen ? document.getElementById('screen-share') : grid;
if (isScreen) {
container.classList.remove('hidden');
container.innerHTML = '';
}
const el = track.attach();
el.className = isScreen
? 'h-full w-full rounded-xl object-contain'
: 'aspect-video w-full rounded-xl bg-slate-800 object-cover';
el.dataset.participant = participant.identity;
if (!isScreen) {
const wrapper = document.createElement('div');
wrapper.className = 'relative overflow-hidden rounded-xl bg-slate-800';
const label = document.createElement('span');
label.className = 'absolute bottom-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs';
label.textContent = participant.name || participant.identity;
wrapper.appendChild(el);
wrapper.appendChild(label);
container.appendChild(wrapper);
} else {
container.appendChild(el);
}
},
syncParticipants() {
if (!this.room) return;
const all = [this.room.localParticipant, ...this.room.remoteParticipants.values()];
this.participants = all.map((p) => ({ identity: p.identity, name: p.name }));
},
async toggleMute() {
if (!this.room) return;
this.isMuted = !this.isMuted;
await this.room.localParticipant.setMicrophoneEnabled(!this.isMuted);
},
async toggleVideo() {
if (!this.room) return;
this.isVideoOff = !this.isVideoOff;
await this.room.localParticipant.setCameraEnabled(!this.isVideoOff);
},
async toggleScreenShare() {
if (!this.room) return;
this.isScreenSharing = !this.isScreenSharing;
await this.room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!this.isScreenSharing) {
const screen = document.getElementById('screen-share');
if (screen) {
screen.classList.add('hidden');
screen.innerHTML = '';
}
}
},
async raiseHand() {
await fetch(this.config.raiseHandUrl, {
method: 'POST',
headers: this.headers(),
});
},
async 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(emoji) {
await fetch(this.config.reactionUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ emoji }),
});
this.showFloatingReaction(emoji);
},
async poll() {
try {
const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } });
const data = await res.json();
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 div = document.createElement('div');
div.className = 'text-sm';
div.dataset.uuid = message.uuid;
div.innerHTML = `<span class="font-medium text-sky-400">${this.escape(message.sender_name)}</span>
<p class="mt-0.5 text-slate-200">${this.escape(message.body)}</p>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
},
showFloatingReaction(emoji) {
const id = Date.now();
this.floatingReactions.push({ id, emoji });
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();