ultimatepos/Modules/Communication/Resources/js/components/MeetingRoom.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>