631 lines
23 KiB
Vue
631 lines
23 KiB
Vue
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|
import {
|
|
Room,
|
|
RoomEvent,
|
|
Track,
|
|
ParticipantKind,
|
|
LocalAudioTrack,
|
|
LocalVideoTrack,
|
|
} from 'livekit-client';
|
|
import { getConfig, getLang } from '../utils/config';
|
|
import { fetchMeetingToken } from '../api/meetings';
|
|
|
|
const config = getConfig();
|
|
const meetingId = config.meetingId;
|
|
const meetingsUrl = config.routes?.meetings?.index || '/communication/meetings';
|
|
const translationLanguages = config.translationLanguages || { fa: 'فارسی', en: 'English' };
|
|
const defaultLanguage = config.defaultMeetingLanguage || 'fa';
|
|
const translationFeatureEnabled = config.translationEnabled !== false;
|
|
|
|
const room = new Room({ adaptiveStream: true, dynacast: true });
|
|
const tiles = new Map();
|
|
const attachedAudioEls = new Map();
|
|
const TRANSLATION_TRACK_PREFIX = 'tx:';
|
|
|
|
const showPrejoin = ref(true);
|
|
const showOverlay = ref(false);
|
|
const overlayMessage = ref('');
|
|
const overlayError = ref(false);
|
|
const overlayShowBack = ref(false);
|
|
|
|
const prejoinMicOn = ref(true);
|
|
const prejoinCamOn = ref(true);
|
|
const micEnabled = ref(false);
|
|
const camEnabled = ref(false);
|
|
const controlsEnabled = ref(false);
|
|
const prejoinHint = ref('');
|
|
const noVideoText = ref('');
|
|
const showNoVideo = ref(false);
|
|
|
|
const myLanguage = ref(defaultLanguage);
|
|
const translationEnabled = ref(translationFeatureEnabled);
|
|
const showCaptions = ref(true);
|
|
const captions = ref({});
|
|
|
|
const cameras = ref([]);
|
|
const microphones = ref([]);
|
|
const selectedCamera = ref('');
|
|
const selectedMic = ref('');
|
|
const videoGrid = ref(null);
|
|
const prejoinPreview = ref(null);
|
|
|
|
const languageOptions = computed(() =>
|
|
Object.entries(translationLanguages).map(([code, label]) => ({ code, label }))
|
|
);
|
|
|
|
let prejoinStream = null;
|
|
let isLeaving = false;
|
|
|
|
function stopStream(stream) {
|
|
stream?.getTracks().forEach((t) => t.stop());
|
|
}
|
|
|
|
function mediaErrorMessage(err) {
|
|
const name = err?.name || '';
|
|
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
|
|
return getLang('permission_denied');
|
|
}
|
|
if (name === 'NotReadableError' || name === 'AbortError') {
|
|
return getLang('camera_in_use');
|
|
}
|
|
return err?.message || getLang('no_camera_preview');
|
|
}
|
|
|
|
function getSpeakerLang(participant) {
|
|
return participant?.attributes?.lang || participant?.attributes?.language || defaultLanguage;
|
|
}
|
|
|
|
function isAgentParticipant(participant) {
|
|
return participant?.kind === ParticipantKind.AGENT || participant?.identity === 'agent';
|
|
}
|
|
|
|
function parseTranslationTrackName(name) {
|
|
if (!name?.startsWith(TRANSLATION_TRACK_PREFIX)) return null;
|
|
const parts = name.slice(TRANSLATION_TRACK_PREFIX.length).split(':');
|
|
if (parts.length < 2) return null;
|
|
const targetLang = parts.pop();
|
|
const sourceIdentity = parts.join(':');
|
|
if (!sourceIdentity || !targetLang) return null;
|
|
return { sourceIdentity, targetLang };
|
|
}
|
|
|
|
function findParticipantByIdentity(identity) {
|
|
if (room.localParticipant.identity === identity) return room.localParticipant;
|
|
return Array.from(room.remoteParticipants.values()).find((p) => p.identity === identity) || null;
|
|
}
|
|
|
|
function setRoomStatus(text, state = '') {
|
|
const el = document.getElementById('room-status');
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = `room-status${state ? ` ${state}` : ''}`;
|
|
}
|
|
|
|
function setCaption(participantKey, text) {
|
|
if (!text?.trim()) return;
|
|
captions.value = { ...captions.value, [participantKey]: text };
|
|
}
|
|
|
|
async function loadDevices() {
|
|
if (!navigator.mediaDevices?.enumerateDevices) return;
|
|
try {
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
cameras.value = devices.filter((d) => d.kind === 'videoinput');
|
|
microphones.value = devices.filter((d) => d.kind === 'audioinput');
|
|
if (!selectedCamera.value && cameras.value[0]) selectedCamera.value = cameras.value[0].deviceId;
|
|
if (!selectedMic.value && microphones.value[0]) selectedMic.value = microphones.value[0].deviceId;
|
|
} catch (e) {
|
|
console.warn('enumerateDevices:', e);
|
|
}
|
|
}
|
|
|
|
function buildPrejoinConstraints() {
|
|
const constraints = { audio: false, video: false };
|
|
if (prejoinMicOn.value) {
|
|
constraints.audio = selectedMic.value ? { deviceId: { exact: selectedMic.value } } : true;
|
|
}
|
|
if (prejoinCamOn.value) {
|
|
constraints.video = selectedCamera.value
|
|
? { deviceId: { exact: selectedCamera.value }, width: { ideal: 640 }, height: { ideal: 480 } }
|
|
: { width: { ideal: 640 }, height: { ideal: 480 } };
|
|
}
|
|
return constraints;
|
|
}
|
|
|
|
async function startPrejoinPreview() {
|
|
stopStream(prejoinStream);
|
|
prejoinStream = null;
|
|
prejoinHint.value = '';
|
|
showNoVideo.value = false;
|
|
|
|
if (!prejoinMicOn.value && !prejoinCamOn.value) {
|
|
showNoVideo.value = true;
|
|
noVideoText.value = getLang('media_permission_denied');
|
|
return;
|
|
}
|
|
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
prejoinHint.value = getLang('secure_context_required');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
prejoinStream = await navigator.mediaDevices.getUserMedia(buildPrejoinConstraints());
|
|
if (prejoinCamOn.value && prejoinStream.getVideoTracks().length && prejoinPreview.value) {
|
|
prejoinPreview.value.srcObject = prejoinStream;
|
|
} else {
|
|
showNoVideo.value = true;
|
|
noVideoText.value = getLang('no_camera_preview');
|
|
}
|
|
await loadDevices();
|
|
} catch (e) {
|
|
prejoinHint.value = mediaErrorMessage(e);
|
|
showNoVideo.value = true;
|
|
noVideoText.value = mediaErrorMessage(e);
|
|
}
|
|
}
|
|
|
|
function getOrCreateTile(participant, label, isLocal) {
|
|
const id = `${participant.sid}-main`;
|
|
if (tiles.has(id)) return tiles.get(id);
|
|
const tile = document.createElement('div');
|
|
tile.className = `video-tile${isLocal ? ' local' : ''}`;
|
|
tile.dataset.participantKey = participant.identity;
|
|
tile.innerHTML = `
|
|
<div class="tile-caption"></div>
|
|
<div class="tile-label">${label}</div>
|
|
`;
|
|
videoGrid.value?.appendChild(tile);
|
|
tiles.set(id, tile);
|
|
return tile;
|
|
}
|
|
|
|
function updateTileCaption(participantKey, text) {
|
|
const tile = Array.from(tiles.values()).find((el) => el.dataset.participantKey === participantKey);
|
|
if (!tile) return;
|
|
const captionEl = tile.querySelector('.tile-caption');
|
|
if (!captionEl) return;
|
|
if (showCaptions.value && text) {
|
|
captionEl.textContent = text;
|
|
captionEl.classList.add('visible');
|
|
} else {
|
|
captionEl.classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
function detachAudioForParticipant(participantKey) {
|
|
const existing = attachedAudioEls.get(participantKey);
|
|
if (existing) {
|
|
existing.remove();
|
|
attachedAudioEls.delete(participantKey);
|
|
}
|
|
}
|
|
|
|
function attachAudio(track, participant, isTranslated = false) {
|
|
const participantKey = participant.identity;
|
|
const label = participant === room.localParticipant
|
|
? getLang('you')
|
|
: (participant.name || participant.identity);
|
|
getOrCreateTile(participant, label, participant === room.localParticipant);
|
|
|
|
detachAudioForParticipant(participantKey);
|
|
const media = track.attach();
|
|
media.dataset.translated = isTranslated ? 'true' : 'false';
|
|
document.body.appendChild(media);
|
|
attachedAudioEls.set(participantKey, media);
|
|
}
|
|
|
|
function attachVideo(track, participant, isLocal) {
|
|
const label = isLocal ? getLang('you') : (participant.name || participant.identity);
|
|
const tile = getOrCreateTile(participant, label, isLocal);
|
|
tile.querySelector('video')?.remove();
|
|
const media = track.attach();
|
|
tile.insertBefore(media, tile.firstChild);
|
|
}
|
|
|
|
function applyHumanSubscriptions(participant) {
|
|
const theirLang = getSpeakerLang(participant);
|
|
const hearNative = !translationEnabled.value || theirLang === myLanguage.value;
|
|
|
|
participant.audioTrackPublications.forEach((pub) => {
|
|
if (pub.source !== Track.Source.Microphone) return;
|
|
if (pub.isSubscribed !== hearNative) {
|
|
pub.setSubscribed(hearNative);
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyAgentSubscriptions(agent, peerLangs) {
|
|
agent.audioTrackPublications.forEach((pub) => {
|
|
const parsed = parseTranslationTrackName(pub.trackName);
|
|
if (!parsed) return;
|
|
|
|
const matchesMe = parsed.targetLang === myLanguage.value;
|
|
const speakerLang = peerLangs.get(parsed.sourceIdentity);
|
|
const speakerNotMyLang = speakerLang && speakerLang !== myLanguage.value;
|
|
const desired = translationEnabled.value && matchesMe && speakerNotMyLang;
|
|
|
|
if (pub.isSubscribed !== desired) {
|
|
pub.setSubscribed(desired);
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyTranslationRouting() {
|
|
if (!translationEnabled.value) return;
|
|
|
|
const remotes = Array.from(room.remoteParticipants.values());
|
|
const peerLangs = new Map();
|
|
|
|
remotes.forEach((p) => {
|
|
if (!isAgentParticipant(p)) {
|
|
peerLangs.set(p.identity, getSpeakerLang(p));
|
|
}
|
|
});
|
|
|
|
remotes.forEach((p) => {
|
|
if (isAgentParticipant(p)) {
|
|
applyAgentSubscriptions(p, peerLangs);
|
|
} else {
|
|
applyHumanSubscriptions(p);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleAudioTrack(track, publication, participant) {
|
|
if (track.kind !== Track.Kind.Audio) return;
|
|
|
|
if (isAgentParticipant(participant)) {
|
|
const parsed = parseTranslationTrackName(publication.trackName);
|
|
if (!parsed || parsed.targetLang !== myLanguage.value) return;
|
|
const speaker = findParticipantByIdentity(parsed.sourceIdentity);
|
|
if (!speaker) return;
|
|
attachAudio(track, speaker, true);
|
|
return;
|
|
}
|
|
|
|
if (publication.source !== Track.Source.Microphone) return;
|
|
|
|
const theirLang = getSpeakerLang(participant);
|
|
if (translationEnabled.value && theirLang !== myLanguage.value) {
|
|
detachAudioForParticipant(participant.identity);
|
|
return;
|
|
}
|
|
|
|
attachAudio(track, participant, false);
|
|
}
|
|
|
|
function setupRoomEvents() {
|
|
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
|
if (track.kind === Track.Kind.Video) {
|
|
attachVideo(track, participant, participant === room.localParticipant);
|
|
return;
|
|
}
|
|
if (track.kind === Track.Kind.Audio) {
|
|
handleAudioTrack(track, publication, participant);
|
|
}
|
|
});
|
|
|
|
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
|
track.detach().forEach((el) => el.remove());
|
|
if (track.kind !== Track.Kind.Audio) return;
|
|
|
|
if (isAgentParticipant(participant)) {
|
|
const parsed = parseTranslationTrackName(publication.trackName);
|
|
if (parsed) detachAudioForParticipant(parsed.sourceIdentity);
|
|
return;
|
|
}
|
|
|
|
if (publication.source === Track.Source.Microphone) {
|
|
detachAudioForParticipant(participant.identity);
|
|
}
|
|
});
|
|
|
|
room.on(RoomEvent.LocalTrackPublished, (publication) => {
|
|
if (!publication.track) return;
|
|
if (publication.track.kind === Track.Kind.Video) {
|
|
attachVideo(publication.track, room.localParticipant, true);
|
|
}
|
|
});
|
|
|
|
room.on(RoomEvent.ParticipantConnected, (participant) => {
|
|
if (!isAgentParticipant(participant)) {
|
|
participant.trackPublications.forEach((publication) => {
|
|
if (publication.track && publication.isSubscribed) {
|
|
if (publication.kind === Track.Kind.Video) {
|
|
attachVideo(publication.track, participant, false);
|
|
} else if (publication.kind === Track.Kind.Audio) {
|
|
handleAudioTrack(publication.track, publication, participant);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
applyTranslationRouting();
|
|
});
|
|
|
|
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
detachAudioForParticipant(participant.identity);
|
|
const nextCaptions = { ...captions.value };
|
|
delete nextCaptions[participant.identity];
|
|
captions.value = nextCaptions;
|
|
tiles.forEach((tile, id) => {
|
|
if (id.startsWith(participant.sid)) {
|
|
tile.remove();
|
|
tiles.delete(id);
|
|
}
|
|
});
|
|
applyTranslationRouting();
|
|
});
|
|
|
|
const routingEvents = [
|
|
RoomEvent.ParticipantAttributesChanged,
|
|
RoomEvent.TrackPublished,
|
|
RoomEvent.TrackUnpublished,
|
|
RoomEvent.TrackMuted,
|
|
RoomEvent.TrackUnmuted,
|
|
];
|
|
routingEvents.forEach((event) => {
|
|
room.on(event, () => applyTranslationRouting());
|
|
});
|
|
|
|
room.on(RoomEvent.ParticipantAttributesChanged, () => {
|
|
room.remoteParticipants.forEach((participant) => {
|
|
if (isAgentParticipant(participant)) return;
|
|
participant.trackPublications.forEach((publication) => {
|
|
if (publication.track && publication.kind === Track.Kind.Audio) {
|
|
handleAudioTrack(publication.track, publication, participant);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
room.registerTextStreamHandler('lk.translation', async (reader) => {
|
|
try {
|
|
const attrs = reader.info?.attributes || {};
|
|
const targetLang = attrs.target_lang;
|
|
const sourceIdentity = attrs.source_identity;
|
|
if (!translationEnabled.value || targetLang !== myLanguage.value || !sourceIdentity) return;
|
|
|
|
let text = '';
|
|
for await (const chunk of reader) {
|
|
text += chunk;
|
|
}
|
|
if (!text.trim()) return;
|
|
|
|
const prev = captions.value[sourceIdentity] || '';
|
|
const next = attrs.final === 'true' ? text.trim() : (prev + text);
|
|
setCaption(sourceIdentity, next);
|
|
updateTileCaption(sourceIdentity, next);
|
|
} catch (e) {
|
|
console.warn('translation caption stream:', e);
|
|
}
|
|
});
|
|
|
|
room.on(RoomEvent.Disconnected, () => {
|
|
if (isLeaving) {
|
|
window.location.href = meetingsUrl;
|
|
return;
|
|
}
|
|
showOverlay.value = true;
|
|
overlayMessage.value = getLang('left_meeting');
|
|
overlayShowBack.value = true;
|
|
controlsEnabled.value = false;
|
|
});
|
|
}
|
|
|
|
async function publishStreamTrack(kind, enabled) {
|
|
if (!prejoinStream || !enabled) return false;
|
|
const tracks = kind === 'video' ? prejoinStream.getVideoTracks() : prejoinStream.getAudioTracks();
|
|
const mediaTrack = tracks[0];
|
|
if (!mediaTrack) return false;
|
|
const localTrack = kind === 'video' ? new LocalVideoTrack(mediaTrack) : new LocalAudioTrack(mediaTrack);
|
|
const source = kind === 'video' ? Track.Source.Camera : Track.Source.Microphone;
|
|
await room.localParticipant.publishTrack(localTrack, { source });
|
|
return true;
|
|
}
|
|
|
|
async function joinMeeting() {
|
|
showPrejoin.value = false;
|
|
showOverlay.value = true;
|
|
overlayError.value = false;
|
|
overlayShowBack.value = false;
|
|
overlayMessage.value = getLang('connecting_to_meeting');
|
|
setupRoomEvents();
|
|
|
|
try {
|
|
const res = await fetchMeetingToken(meetingId, {
|
|
language: myLanguage.value,
|
|
translation_enabled: translationEnabled.value,
|
|
});
|
|
if (!res.success) {
|
|
overlayError.value = true;
|
|
overlayMessage.value = res.msg || getLang('connection_failed');
|
|
overlayShowBack.value = true;
|
|
return;
|
|
}
|
|
|
|
await room.connect(res.url, res.token, { autoSubscribe: true });
|
|
await room.localParticipant.setAttributes({
|
|
lang: myLanguage.value,
|
|
language: myLanguage.value,
|
|
translation: translationEnabled.value ? 'true' : 'false',
|
|
});
|
|
|
|
micEnabled.value = await publishStreamTrack('audio', prejoinMicOn.value);
|
|
camEnabled.value = await publishStreamTrack('video', prejoinCamOn.value);
|
|
|
|
stopStream(prejoinStream);
|
|
prejoinStream = null;
|
|
|
|
room.remoteParticipants.forEach((participant) => {
|
|
if (isAgentParticipant(participant)) return;
|
|
participant.trackPublications.forEach((publication) => {
|
|
if (!publication.track || !publication.isSubscribed) return;
|
|
if (publication.kind === Track.Kind.Video) {
|
|
attachVideo(publication.track, participant, false);
|
|
} else if (publication.kind === Track.Kind.Audio) {
|
|
handleAudioTrack(publication.track, publication, participant);
|
|
}
|
|
});
|
|
});
|
|
|
|
applyTranslationRouting();
|
|
|
|
showOverlay.value = false;
|
|
controlsEnabled.value = true;
|
|
const statusText = translationEnabled.value
|
|
? getLang('meeting_connected_translation')
|
|
: getLang('meeting_connected');
|
|
setRoomStatus(statusText, 'connected');
|
|
} catch (err) {
|
|
overlayError.value = true;
|
|
overlayMessage.value = getLang('connection_failed') + (err.message ? ` (${err.message})` : '');
|
|
overlayShowBack.value = true;
|
|
}
|
|
}
|
|
|
|
async function toggleMic() {
|
|
if (micEnabled.value) {
|
|
await room.localParticipant.setMicrophoneEnabled(false);
|
|
micEnabled.value = false;
|
|
} else {
|
|
await room.localParticipant.setMicrophoneEnabled(true);
|
|
micEnabled.value = true;
|
|
}
|
|
}
|
|
|
|
async function toggleCam() {
|
|
if (camEnabled.value) {
|
|
await room.localParticipant.setCameraEnabled(false);
|
|
camEnabled.value = false;
|
|
} else {
|
|
await room.localParticipant.setCameraEnabled(true);
|
|
camEnabled.value = true;
|
|
}
|
|
}
|
|
|
|
function toggleCaptions() {
|
|
showCaptions.value = !showCaptions.value;
|
|
Object.entries(captions.value).forEach(([key, text]) => updateTileCaption(key, text));
|
|
}
|
|
|
|
async function leaveMeeting() {
|
|
if (isLeaving) return;
|
|
isLeaving = true;
|
|
stopStream(prejoinStream);
|
|
try {
|
|
await room.disconnect(true);
|
|
} catch (e) {
|
|
console.warn(e);
|
|
}
|
|
window.location.href = meetingsUrl;
|
|
}
|
|
|
|
function goBack() {
|
|
window.location.href = meetingsUrl;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!window.isSecureContext) {
|
|
prejoinHint.value = getLang('secure_context_required');
|
|
}
|
|
await loadDevices();
|
|
await startPrejoinPreview();
|
|
window.addEventListener('beforeunload', () => {
|
|
stopStream(prejoinStream);
|
|
room.disconnect();
|
|
});
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopStream(prejoinStream);
|
|
room.disconnect();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<main class="room-main">
|
|
<div id="video-grid" ref="videoGrid"></div>
|
|
|
|
<div v-if="showPrejoin" class="room-overlay">
|
|
<div class="prejoin-panel">
|
|
<h2>{{ getLang('prejoin_title') }}</h2>
|
|
<p>{{ getLang('prejoin_subtitle') }}</p>
|
|
<div class="prejoin-preview-wrap">
|
|
<video v-show="!showNoVideo" ref="prejoinPreview" autoplay muted playsinline></video>
|
|
<div v-show="showNoVideo" class="prejoin-no-video">{{ noVideoText }}</div>
|
|
</div>
|
|
<div class="prejoin-devices">
|
|
<div>
|
|
<label>{{ getLang('select_camera') }}</label>
|
|
<select v-model="selectedCamera" @change="startPrejoinPreview">
|
|
<option v-for="(d, i) in cameras" :key="d.deviceId" :value="d.deviceId">{{ d.label || `Camera ${i + 1}` }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>{{ getLang('select_microphone') }}</label>
|
|
<select v-model="selectedMic" @change="startPrejoinPreview">
|
|
<option v-for="(d, i) in microphones" :key="d.deviceId" :value="d.deviceId">{{ d.label || `Mic ${i + 1}` }}</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="translationFeatureEnabled">
|
|
<label>{{ getLang('my_language') }}</label>
|
|
<select v-model="myLanguage">
|
|
<option v-for="opt in languageOptions" :key="opt.code" :value="opt.code">{{ opt.label }}</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="translationFeatureEnabled" class="prejoin-toggle-row">
|
|
<label class="toggle-label">
|
|
<input v-model="translationEnabled" type="checkbox" />
|
|
{{ getLang('enable_live_translation') }}
|
|
</label>
|
|
<span class="toggle-hint">{{ getLang('translation_hint') }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="prejoin-actions">
|
|
<button type="button" class="toolbar-btn mic" :class="{ off: !prejoinMicOn }" @click="prejoinMicOn = !prejoinMicOn; startPrejoinPreview()">
|
|
<i :class="prejoinMicOn ? 'fa fa-microphone' : 'fa fa-microphone-slash'"></i>
|
|
</button>
|
|
<button type="button" class="toolbar-btn cam" :class="{ off: !prejoinCamOn }" @click="prejoinCamOn = !prejoinCamOn; startPrejoinPreview()">
|
|
<i :class="prejoinCamOn ? 'fa fa-video-camera' : 'fa fa-ban'"></i>
|
|
</button>
|
|
<button type="button" class="btn-join" @click="joinMeeting">{{ getLang('join_meeting_room') }}</button>
|
|
</div>
|
|
<p class="prejoin-hint">{{ prejoinHint }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showOverlay" class="room-overlay" :class="{ error: overlayError }">
|
|
<i :class="overlayError ? 'fa fa-exclamation-circle' : 'fa fa-spinner fa-spin'" class="main-icon"></i>
|
|
<p>{{ overlayMessage }}</p>
|
|
<button v-if="overlayShowBack" type="button" class="overlay-back" @click="goBack">
|
|
{{ getLang('back_to_meetings') }}
|
|
</button>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="room-toolbar">
|
|
<button type="button" class="toolbar-btn mic" :class="{ off: !micEnabled }" :disabled="!controlsEnabled" @click="toggleMic">
|
|
<i :class="micEnabled ? 'fa fa-microphone' : 'fa fa-microphone-slash'"></i>
|
|
</button>
|
|
<button type="button" class="toolbar-btn cam" :class="{ off: !camEnabled }" :disabled="!controlsEnabled" @click="toggleCam">
|
|
<i :class="camEnabled ? 'fa fa-video-camera' : 'fa fa-ban'"></i>
|
|
</button>
|
|
<button
|
|
v-if="translationFeatureEnabled && translationEnabled"
|
|
type="button"
|
|
class="toolbar-btn captions"
|
|
:class="{ off: !showCaptions }"
|
|
:disabled="!controlsEnabled"
|
|
:title="getLang('toggle_captions')"
|
|
@click="toggleCaptions"
|
|
>
|
|
<i class="fa fa-font"></i>
|
|
</button>
|
|
<button type="button" class="toolbar-btn leave" :disabled="!controlsEnabled" @click="leaveMeeting">
|
|
<i class="fa fa-phone"></i> {{ getLang('leave_meeting') }}
|
|
</button>
|
|
</footer>
|
|
</template>
|