Deploy Ladill Meet / deploy (push) Failing after 7s
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>
355 lines
13 KiB
JavaScript
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();
|