CallOverlay.svelte 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
  1. <script lang="ts">
  2. import { config, settings, showCallOverlay } from '$lib/stores';
  3. import { onMount, tick, getContext } from 'svelte';
  4. import {
  5. blobToFile,
  6. calculateSHA256,
  7. extractSentencesForAudio,
  8. findWordIndices
  9. } from '$lib/utils';
  10. import { generateEmoji } from '$lib/apis';
  11. import { synthesizeOpenAISpeech, transcribeAudio } from '$lib/apis/audio';
  12. import { toast } from 'svelte-sonner';
  13. import Tooltip from '$lib/components/common/Tooltip.svelte';
  14. import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
  15. const i18n = getContext('i18n');
  16. export let eventTarget: EventTarget;
  17. export let submitPrompt: Function;
  18. export let stopResponse: Function;
  19. export let files;
  20. export let chatId;
  21. export let modelId;
  22. let loading = false;
  23. let confirmed = false;
  24. let interrupted = false;
  25. let emoji = null;
  26. let camera = false;
  27. let cameraStream = null;
  28. let chatStreaming = false;
  29. let rmsLevel = 0;
  30. let hasStartedSpeaking = false;
  31. let mediaRecorder;
  32. let audioChunks = [];
  33. let videoInputDevices = [];
  34. let selectedVideoInputDeviceId = null;
  35. const getVideoInputDevices = async () => {
  36. const devices = await navigator.mediaDevices.enumerateDevices();
  37. videoInputDevices = devices.filter((device) => device.kind === 'videoinput');
  38. if (!!navigator.mediaDevices.getDisplayMedia) {
  39. videoInputDevices = [
  40. ...videoInputDevices,
  41. {
  42. deviceId: 'screen',
  43. label: 'Screen Share'
  44. }
  45. ];
  46. }
  47. console.log(videoInputDevices);
  48. if (selectedVideoInputDeviceId === null && videoInputDevices.length > 0) {
  49. selectedVideoInputDeviceId = videoInputDevices[0].deviceId;
  50. }
  51. };
  52. const startCamera = async () => {
  53. await getVideoInputDevices();
  54. if (cameraStream === null) {
  55. camera = true;
  56. await tick();
  57. try {
  58. await startVideoStream();
  59. } catch (err) {
  60. console.error('Error accessing webcam: ', err);
  61. }
  62. }
  63. };
  64. const startVideoStream = async () => {
  65. const video = document.getElementById('camera-feed');
  66. if (video) {
  67. if (selectedVideoInputDeviceId === 'screen') {
  68. cameraStream = await navigator.mediaDevices.getDisplayMedia({
  69. video: {
  70. cursor: 'always'
  71. },
  72. audio: false
  73. });
  74. } else {
  75. cameraStream = await navigator.mediaDevices.getUserMedia({
  76. video: {
  77. deviceId: selectedVideoInputDeviceId ? { exact: selectedVideoInputDeviceId } : undefined
  78. }
  79. });
  80. }
  81. if (cameraStream) {
  82. await getVideoInputDevices();
  83. video.srcObject = cameraStream;
  84. await video.play();
  85. }
  86. }
  87. };
  88. const stopVideoStream = async () => {
  89. if (cameraStream) {
  90. const tracks = cameraStream.getTracks();
  91. tracks.forEach((track) => track.stop());
  92. }
  93. cameraStream = null;
  94. };
  95. const takeScreenshot = () => {
  96. const video = document.getElementById('camera-feed');
  97. const canvas = document.getElementById('camera-canvas');
  98. if (!canvas) {
  99. return;
  100. }
  101. const context = canvas.getContext('2d');
  102. // Make the canvas match the video dimensions
  103. canvas.width = video.videoWidth;
  104. canvas.height = video.videoHeight;
  105. // Draw the image from the video onto the canvas
  106. context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
  107. // Convert the canvas to a data base64 URL and console log it
  108. const dataURL = canvas.toDataURL('image/png');
  109. console.log(dataURL);
  110. return dataURL;
  111. };
  112. const stopCamera = async () => {
  113. await stopVideoStream();
  114. camera = false;
  115. };
  116. const MIN_DECIBELS = -55;
  117. const VISUALIZER_BUFFER_LENGTH = 300;
  118. const transcribeHandler = async (audioBlob) => {
  119. // Create a blob from the audio chunks
  120. await tick();
  121. const file = blobToFile(audioBlob, 'recording.wav');
  122. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  123. toast.error(error);
  124. return null;
  125. });
  126. if (res) {
  127. console.log(res.text);
  128. if (res.text !== '') {
  129. const _responses = await submitPrompt(res.text, { _raw: true });
  130. console.log(_responses);
  131. }
  132. }
  133. };
  134. const stopRecordingCallback = async (_continue = true) => {
  135. if ($showCallOverlay) {
  136. console.log('%c%s', 'color: red; font-size: 20px;', '🚨 stopRecordingCallback 🚨');
  137. // deep copy the audioChunks array
  138. const _audioChunks = audioChunks.slice(0);
  139. audioChunks = [];
  140. mediaRecorder = false;
  141. if (_continue) {
  142. startRecording();
  143. }
  144. if (confirmed) {
  145. loading = true;
  146. emoji = null;
  147. if (cameraStream) {
  148. const imageUrl = takeScreenshot();
  149. files = [
  150. {
  151. type: 'image',
  152. url: imageUrl
  153. }
  154. ];
  155. }
  156. const audioBlob = new Blob(_audioChunks, { type: 'audio/wav' });
  157. await transcribeHandler(audioBlob);
  158. confirmed = false;
  159. loading = false;
  160. }
  161. } else {
  162. audioChunks = [];
  163. mediaRecorder = false;
  164. }
  165. };
  166. const startRecording = async () => {
  167. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  168. mediaRecorder = new MediaRecorder(stream);
  169. mediaRecorder.onstart = () => {
  170. console.log('Recording started');
  171. audioChunks = [];
  172. analyseAudio(stream);
  173. };
  174. mediaRecorder.ondataavailable = (event) => {
  175. if (hasStartedSpeaking) {
  176. audioChunks.push(event.data);
  177. }
  178. };
  179. mediaRecorder.onstop = (e) => {
  180. console.log('Recording stopped', e);
  181. stopRecordingCallback();
  182. };
  183. mediaRecorder.start();
  184. };
  185. // Function to calculate the RMS level from time domain data
  186. const calculateRMS = (data: Uint8Array) => {
  187. let sumSquares = 0;
  188. for (let i = 0; i < data.length; i++) {
  189. const normalizedValue = (data[i] - 128) / 128; // Normalize the data
  190. sumSquares += normalizedValue * normalizedValue;
  191. }
  192. return Math.sqrt(sumSquares / data.length);
  193. };
  194. const analyseAudio = (stream) => {
  195. const audioContext = new AudioContext();
  196. const audioStreamSource = audioContext.createMediaStreamSource(stream);
  197. const analyser = audioContext.createAnalyser();
  198. analyser.minDecibels = MIN_DECIBELS;
  199. audioStreamSource.connect(analyser);
  200. const bufferLength = analyser.frequencyBinCount;
  201. const domainData = new Uint8Array(bufferLength);
  202. const timeDomainData = new Uint8Array(analyser.fftSize);
  203. let lastSoundTime = Date.now();
  204. hasStartedSpeaking = false;
  205. console.log('🔊 Sound detection started', lastSoundTime, hasStartedSpeaking);
  206. const detectSound = () => {
  207. const processFrame = () => {
  208. if (!mediaRecorder || !$showCallOverlay) {
  209. return;
  210. }
  211. analyser.getByteTimeDomainData(timeDomainData);
  212. analyser.getByteFrequencyData(domainData);
  213. // Calculate RMS level from time domain data
  214. rmsLevel = calculateRMS(timeDomainData);
  215. // Check if initial speech/noise has started
  216. const hasSound = domainData.some((value) => value > 0);
  217. if (hasSound) {
  218. // BIG RED TEXT
  219. console.log('%c%s', 'color: red; font-size: 20px;', '🔊 Sound detected');
  220. if (!hasStartedSpeaking) {
  221. hasStartedSpeaking = true;
  222. stopAllAudio();
  223. }
  224. lastSoundTime = Date.now();
  225. }
  226. // Start silence detection only after initial speech/noise has been detected
  227. if (hasStartedSpeaking) {
  228. if (Date.now() - lastSoundTime > 2000) {
  229. confirmed = true;
  230. if (mediaRecorder) {
  231. console.log('%c%s', 'color: red; font-size: 20px;', '🔇 Silence detected');
  232. mediaRecorder.stop();
  233. return;
  234. }
  235. }
  236. }
  237. window.requestAnimationFrame(processFrame);
  238. };
  239. window.requestAnimationFrame(processFrame);
  240. };
  241. detectSound();
  242. };
  243. let finishedMessages = {};
  244. let currentMessageId = null;
  245. let currentUtterance = null;
  246. const speakSpeechSynthesisHandler = (content) => {
  247. if ($showCallOverlay) {
  248. return new Promise((resolve) => {
  249. let voices = [];
  250. const getVoicesLoop = setInterval(async () => {
  251. voices = await speechSynthesis.getVoices();
  252. if (voices.length > 0) {
  253. clearInterval(getVoicesLoop);
  254. const voice =
  255. voices
  256. ?.filter(
  257. (v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
  258. )
  259. ?.at(0) ?? undefined;
  260. currentUtterance = new SpeechSynthesisUtterance(content);
  261. if (voice) {
  262. currentUtterance.voice = voice;
  263. }
  264. speechSynthesis.speak(currentUtterance);
  265. currentUtterance.onend = async (e) => {
  266. await new Promise((r) => setTimeout(r, 200));
  267. resolve(e);
  268. };
  269. }
  270. }, 100);
  271. });
  272. } else {
  273. return Promise.resolve();
  274. }
  275. };
  276. const playAudio = (audio) => {
  277. if ($showCallOverlay) {
  278. return new Promise((resolve) => {
  279. const audioElement = document.getElementById('audioElement');
  280. if (audioElement) {
  281. audioElement.src = audio.src;
  282. audioElement.muted = true;
  283. audioElement
  284. .play()
  285. .then(() => {
  286. audioElement.muted = false;
  287. })
  288. .catch((error) => {
  289. console.error(error);
  290. });
  291. audioElement.onended = async (e) => {
  292. await new Promise((r) => setTimeout(r, 100));
  293. resolve(e);
  294. };
  295. }
  296. });
  297. } else {
  298. return Promise.resolve();
  299. }
  300. };
  301. const stopAllAudio = async () => {
  302. interrupted = true;
  303. if (chatStreaming) {
  304. stopResponse();
  305. }
  306. if (currentUtterance) {
  307. speechSynthesis.cancel();
  308. currentUtterance = null;
  309. }
  310. const audioElement = document.getElementById('audioElement');
  311. if (audioElement) {
  312. audioElement.muted = true;
  313. audioElement.pause();
  314. audioElement.currentTime = 0;
  315. }
  316. };
  317. let audioAbortController = new AbortController();
  318. // Audio cache map where key is the content and value is the Audio object.
  319. const audioCache = new Map();
  320. const emojiCache = new Map();
  321. const fetchAudio = async (content) => {
  322. if (!audioCache.has(content)) {
  323. try {
  324. // Set the emoji for the content if needed
  325. if ($settings?.showEmojiInCall ?? false) {
  326. const emoji = await generateEmoji(localStorage.token, modelId, content, chatId);
  327. if (emoji) {
  328. emojiCache.set(content, emoji);
  329. }
  330. }
  331. if ($config.audio.tts.engine !== '') {
  332. const res = await synthesizeOpenAISpeech(
  333. localStorage.token,
  334. $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice,
  335. content
  336. ).catch((error) => {
  337. console.error(error);
  338. return null;
  339. });
  340. if (res) {
  341. const blob = await res.blob();
  342. const blobUrl = URL.createObjectURL(blob);
  343. audioCache.set(content, new Audio(blobUrl));
  344. }
  345. } else {
  346. audioCache.set(content, true);
  347. }
  348. } catch (error) {
  349. console.error('Error synthesizing speech:', error);
  350. }
  351. }
  352. return audioCache.get(content);
  353. };
  354. let messages = {};
  355. const monitorAndPlayAudio = async (id, signal) => {
  356. while (!signal.aborted) {
  357. if (messages[id] && messages[id].length > 0) {
  358. // Retrieve the next content string from the queue
  359. const content = messages[id].shift(); // Dequeues the content for playing
  360. if (audioCache.has(content)) {
  361. // If content is available in the cache, play it
  362. // Set the emoji for the content if available
  363. if (($settings?.showEmojiInCall ?? false) && emojiCache.has(content)) {
  364. emoji = emojiCache.get(content);
  365. } else {
  366. emoji = null;
  367. }
  368. if ($config.audio.tts.engine !== '') {
  369. try {
  370. console.log(
  371. '%c%s',
  372. 'color: red; font-size: 20px;',
  373. `Playing audio for content: ${content}`
  374. );
  375. const audio = audioCache.get(content);
  376. await playAudio(audio); // Here ensure that playAudio is indeed correct method to execute
  377. console.log(`Played audio for content: ${content}`);
  378. await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
  379. } catch (error) {
  380. console.error('Error playing audio:', error);
  381. }
  382. } else {
  383. await speakSpeechSynthesisHandler(content);
  384. }
  385. } else {
  386. // If not available in the cache, push it back to the queue and delay
  387. messages[id].unshift(content); // Re-queue the content at the start
  388. console.log(`Audio for "${content}" not yet available in the cache, re-queued...`);
  389. await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
  390. }
  391. } else if (finishedMessages[id] && messages[id] && messages[id].length === 0) {
  392. // If the message is finished and there are no more messages to process, break the loop
  393. break;
  394. } else {
  395. // No messages to process, sleep for a bit
  396. await new Promise((resolve) => setTimeout(resolve, 200));
  397. }
  398. }
  399. console.log(`Audio monitoring and playing stopped for message ID ${id}`);
  400. };
  401. onMount(async () => {
  402. startRecording();
  403. const chatStartHandler = async (e) => {
  404. const { id } = e.detail;
  405. chatStreaming = true;
  406. if (currentMessageId !== id) {
  407. console.log(`Received chat start event for message ID ${id}`);
  408. currentMessageId = id;
  409. if (audioAbortController) {
  410. audioAbortController.abort();
  411. }
  412. audioAbortController = new AbortController();
  413. // Start monitoring and playing audio for the message ID
  414. monitorAndPlayAudio(id, audioAbortController.signal);
  415. }
  416. };
  417. const chatEventHandler = async (e) => {
  418. const { id, content } = e.detail;
  419. // "id" here is message id
  420. // if "id" is not the same as "currentMessageId" then do not process
  421. // "content" here is a sentence from the assistant,
  422. // there will be many sentences for the same "id"
  423. if (currentMessageId === id) {
  424. console.log(`Received chat event for message ID ${id}: ${content}`);
  425. try {
  426. if (messages[id] === undefined) {
  427. messages[id] = [content];
  428. } else {
  429. messages[id].push(content);
  430. }
  431. console.log(content);
  432. fetchAudio(content);
  433. } catch (error) {
  434. console.error('Failed to fetch or play audio:', error);
  435. }
  436. }
  437. };
  438. const chatFinishHandler = async (e) => {
  439. const { id, content } = e.detail;
  440. // "content" here is the entire message from the assistant
  441. chatStreaming = false;
  442. finishedMessages[id] = true;
  443. };
  444. eventTarget.addEventListener('chat:start', chatStartHandler);
  445. eventTarget.addEventListener('chat', chatEventHandler);
  446. eventTarget.addEventListener('chat:finish', chatFinishHandler);
  447. return async () => {
  448. eventTarget.removeEventListener('chat:start', chatStartHandler);
  449. eventTarget.removeEventListener('chat', chatEventHandler);
  450. eventTarget.removeEventListener('chat:finish', chatFinishHandler);
  451. audioAbortController.abort();
  452. await tick();
  453. await stopAllAudio();
  454. await stopRecordingCallback(false);
  455. await stopCamera();
  456. };
  457. });
  458. </script>
  459. {#if $showCallOverlay}
  460. <div class=" absolute w-full h-screen max-h-[100dvh] flex z-[999] overflow-hidden">
  461. <div
  462. class="absolute w-full h-screen max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
  463. >
  464. <div class="max-w-lg w-full h-screen max-h-[100dvh] flex flex-col justify-between p-3 md:p-6">
  465. {#if camera}
  466. <div class="flex justify-center items-center w-full h-20 min-h-20">
  467. {#if emoji}
  468. <div
  469. class=" transition-all rounded-full"
  470. style="font-size:{rmsLevel * 100 > 4
  471. ? '4.5'
  472. : rmsLevel * 100 > 2
  473. ? '4.25'
  474. : rmsLevel * 100 > 1
  475. ? '3.75'
  476. : '3.5'}rem;width: 100%; text-align:center;"
  477. >
  478. {emoji}
  479. </div>
  480. {:else if loading}
  481. <svg
  482. class="size-12 text-gray-900 dark:text-gray-400"
  483. viewBox="0 0 24 24"
  484. fill="currentColor"
  485. xmlns="http://www.w3.org/2000/svg"
  486. ><style>
  487. .spinner_qM83 {
  488. animation: spinner_8HQG 1.05s infinite;
  489. }
  490. .spinner_oXPr {
  491. animation-delay: 0.1s;
  492. }
  493. .spinner_ZTLf {
  494. animation-delay: 0.2s;
  495. }
  496. @keyframes spinner_8HQG {
  497. 0%,
  498. 57.14% {
  499. animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
  500. transform: translate(0);
  501. }
  502. 28.57% {
  503. animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
  504. transform: translateY(-6px);
  505. }
  506. 100% {
  507. transform: translate(0);
  508. }
  509. }
  510. </style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
  511. class="spinner_qM83 spinner_oXPr"
  512. cx="12"
  513. cy="12"
  514. r="3"
  515. /><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg
  516. >
  517. {:else}
  518. <div
  519. class=" {rmsLevel * 100 > 4
  520. ? ' size-[4.5rem]'
  521. : rmsLevel * 100 > 2
  522. ? ' size-16'
  523. : rmsLevel * 100 > 1
  524. ? 'size-14'
  525. : 'size-12'} transition-all bg-black dark:bg-white rounded-full"
  526. />
  527. {/if}
  528. <!-- navbar -->
  529. </div>
  530. {/if}
  531. <div class="flex justify-center items-center flex-1 h-full w-full max-h-full">
  532. {#if !camera}
  533. {#if emoji}
  534. <div
  535. class=" transition-all rounded-full"
  536. style="font-size:{rmsLevel * 100 > 4
  537. ? '13'
  538. : rmsLevel * 100 > 2
  539. ? '12'
  540. : rmsLevel * 100 > 1
  541. ? '11.5'
  542. : '11'}rem;width:100%;text-align:center;"
  543. >
  544. {emoji}
  545. </div>
  546. {:else if loading}
  547. <svg
  548. class="size-44 text-gray-900 dark:text-gray-400"
  549. viewBox="0 0 24 24"
  550. fill="currentColor"
  551. xmlns="http://www.w3.org/2000/svg"
  552. ><style>
  553. .spinner_qM83 {
  554. animation: spinner_8HQG 1.05s infinite;
  555. }
  556. .spinner_oXPr {
  557. animation-delay: 0.1s;
  558. }
  559. .spinner_ZTLf {
  560. animation-delay: 0.2s;
  561. }
  562. @keyframes spinner_8HQG {
  563. 0%,
  564. 57.14% {
  565. animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
  566. transform: translate(0);
  567. }
  568. 28.57% {
  569. animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
  570. transform: translateY(-6px);
  571. }
  572. 100% {
  573. transform: translate(0);
  574. }
  575. }
  576. </style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
  577. class="spinner_qM83 spinner_oXPr"
  578. cx="12"
  579. cy="12"
  580. r="3"
  581. /><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg
  582. >
  583. {:else}
  584. <div
  585. class=" {rmsLevel * 100 > 4
  586. ? ' size-52'
  587. : rmsLevel * 100 > 2
  588. ? 'size-48'
  589. : rmsLevel * 100 > 1
  590. ? 'size-[11.5rem]'
  591. : 'size-44'} transition-all bg-black dark:bg-white rounded-full"
  592. />
  593. {/if}
  594. {:else}
  595. <div
  596. class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full"
  597. >
  598. <video
  599. id="camera-feed"
  600. autoplay
  601. class="rounded-2xl h-full min-w-full object-cover object-center"
  602. playsinline
  603. />
  604. <canvas id="camera-canvas" style="display:none;" />
  605. <div class=" absolute top-4 md:top-8 left-4">
  606. <button
  607. type="button"
  608. class="p-1.5 text-white cursor-pointer backdrop-blur-xl bg-black/10 rounded-full"
  609. on:click={() => {
  610. stopCamera();
  611. }}
  612. >
  613. <svg
  614. xmlns="http://www.w3.org/2000/svg"
  615. viewBox="0 0 16 16"
  616. fill="currentColor"
  617. class="size-6"
  618. >
  619. <path
  620. d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"
  621. />
  622. </svg>
  623. </button>
  624. </div>
  625. </div>
  626. {/if}
  627. </div>
  628. <div class="flex justify-between items-center pb-2 w-full">
  629. <div>
  630. {#if camera}
  631. <VideoInputMenu
  632. devices={videoInputDevices}
  633. on:change={async (e) => {
  634. console.log(e.detail);
  635. selectedVideoInputDeviceId = e.detail;
  636. await stopVideoStream();
  637. await startVideoStream();
  638. }}
  639. >
  640. <button class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900" type="button">
  641. <svg
  642. xmlns="http://www.w3.org/2000/svg"
  643. viewBox="0 0 20 20"
  644. fill="currentColor"
  645. class="size-5"
  646. >
  647. <path
  648. fill-rule="evenodd"
  649. d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
  650. clip-rule="evenodd"
  651. />
  652. </svg>
  653. </button>
  654. </VideoInputMenu>
  655. {:else}
  656. <Tooltip content={$i18n.t('Camera')}>
  657. <button
  658. class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
  659. type="button"
  660. on:click={async () => {
  661. await navigator.mediaDevices.getUserMedia({ video: true });
  662. startCamera();
  663. }}
  664. >
  665. <svg
  666. xmlns="http://www.w3.org/2000/svg"
  667. fill="none"
  668. viewBox="0 0 24 24"
  669. stroke-width="1.5"
  670. stroke="currentColor"
  671. class="size-5"
  672. >
  673. <path
  674. stroke-linecap="round"
  675. stroke-linejoin="round"
  676. d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
  677. />
  678. <path
  679. stroke-linecap="round"
  680. stroke-linejoin="round"
  681. d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
  682. />
  683. </svg>
  684. </button>
  685. </Tooltip>
  686. {/if}
  687. </div>
  688. <div>
  689. <button type="button">
  690. <div class=" line-clamp-1 text-sm font-medium">
  691. {#if loading}
  692. {$i18n.t('Thinking...')}
  693. {:else}
  694. {$i18n.t('Listening...')}
  695. {/if}
  696. </div>
  697. </button>
  698. </div>
  699. <div>
  700. <button
  701. class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
  702. on:click={async () => {
  703. showCallOverlay.set(false);
  704. }}
  705. type="button"
  706. >
  707. <svg
  708. xmlns="http://www.w3.org/2000/svg"
  709. viewBox="0 0 20 20"
  710. fill="currentColor"
  711. class="size-5"
  712. >
  713. <path
  714. d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
  715. />
  716. </svg>
  717. </button>
  718. </div>
  719. </div>
  720. </div>
  721. </div>
  722. </div>
  723. {/if}