// file: frontend/main.ts import { invoke } from "@tauri-apps/api/core"; import { AvStartResponse } from './ts/bindings/AvStartResponse'; import { AvStopResponse } from './ts/bindings/AvStopResponse'; import { RtcSnapshot } from './ts/bindings/RtcSnapshot'; type Mode = "idle" | "audio" | "video" | "av"; const startAudioBtn = document.querySelector("#start-audio-btn"); const stopAudioBtn = document.querySelector("#stop-audio-btn"); const audioStatus = document.querySelector("#audio-status"); const startVideoBtn = document.querySelector("#start-video-btn"); const stopVideoBtn = document.querySelector("#stop-video-btn"); const videoStatus = document.querySelector("#video-status"); const startAvBtn = document.querySelector("#start-av-btn"); const stopAvBtn = document.querySelector("#stop-av-btn"); const avStatus = document.querySelector("#av-status"); const previewElement = document.querySelector("#video-preview"); const rtcServerUrlInput = document.querySelector("#rtc-server-url"); const rtcDisplayNameInput = document.querySelector("#rtc-display-name"); const rtcConnectBtn = document.querySelector("#rtc-connect-btn"); const rtcDisconnectBtn = document.querySelector("#rtc-disconnect-btn"); const rtcPeers = document.querySelector("#rtc-peers"); const rtcLogs = document.querySelector("#rtc-logs"); const rtcChatInput = document.querySelector("#rtc-chat-input"); const rtcChatSendBtn = document.querySelector("#rtc-chat-send-btn"); const rtcChatMessages = document.querySelector("#rtc-chat-messages"); const rtcTargetPeerIdInput = document.querySelector("#rtc-target-peer-id"); const rtcStartOfferBtn = document.querySelector("#rtc-start-offer-btn"); const rtcClosePeerBtn = document.querySelector("#rtc-close-peer-btn"); const rtcStatus = document.querySelector("#rtc-status"); const rtcDirectMessageInput = document.querySelector("#rtc-direct-message-input"); const rtcDirectSendBtn = document.querySelector("#rtc-direct-send-btn"); const rtcDirectMessages = document.querySelector("#rtc-direct-messages"); let currentMode: Mode = "idle"; let previewTimer: number | null = null; let previewRequestInFlight = false; let previewObjectUrl: string | null = null; let rtcSnapshotTimer: number | null = null; let rtcSnapshotRequestInFlight = false; let rtcPollingPaused = false; function setAudioStatus(message: string): void { if (audioStatus) { audioStatus.textContent = message; } } function setVideoStatus(message: string): void { if (videoStatus) { videoStatus.textContent = message; } } function setAvStatus(message: string): void { if (avStatus) { avStatus.textContent = message; } } function setAudioButtons(isRecording: boolean): void { if (startAudioBtn) { startAudioBtn.disabled = isRecording; } if (stopAudioBtn) { stopAudioBtn.disabled = !isRecording; } } function setVideoButtons(isRecording: boolean): void { if (startVideoBtn) { startVideoBtn.disabled = isRecording; } if (stopVideoBtn) { stopVideoBtn.disabled = !isRecording; } } function setAvButtons(isRecording: boolean): void { if (startAvBtn) { startAvBtn.disabled = isRecording; } if (stopAvBtn) { stopAvBtn.disabled = !isRecording; } } function setAllButtonsForMode(mode: Mode): void { if (mode === "idle") { setAudioButtons(false); setVideoButtons(false); setAvButtons(false); return; } setAudioButtons(mode === "audio"); setVideoButtons(mode === "video"); setAvButtons(mode === "av"); if (mode !== "audio") { if (stopAudioBtn) { stopAudioBtn.disabled = true; } if (startAudioBtn) { startAudioBtn.disabled = true; } } if (mode !== "video") { if (stopVideoBtn) { stopVideoBtn.disabled = true; } if (startVideoBtn) { startVideoBtn.disabled = true; } } if (mode !== "av") { if (stopAvBtn) { stopAvBtn.disabled = true; } if (startAvBtn) { startAvBtn.disabled = true; } } } function setCurrentMode(mode: Mode): void { currentMode = mode; setAllButtonsForMode(mode); } function setRtcConnected( signallingConnected: boolean, dataChannelOpen: boolean, activeRemotePeerId: string | null, ): void { const rtcBusy = dataChannelOpen || activeRemotePeerId !== null; if (rtcConnectBtn) { rtcConnectBtn.disabled = signallingConnected; } if (rtcDisconnectBtn) { rtcDisconnectBtn.disabled = !signallingConnected; } if (rtcChatSendBtn) { rtcChatSendBtn.disabled = !signallingConnected; } if (rtcStartOfferBtn) { rtcStartOfferBtn.disabled = !signallingConnected || rtcBusy; } } function setRtcDataChannelOpen(open: boolean): void { if (rtcDirectSendBtn) { rtcDirectSendBtn.disabled = !open; } if (rtcClosePeerBtn) { rtcClosePeerBtn.disabled = !open; } } function base64ToUint8Array(base64: string): Uint8Array { const binary = window.atob(base64); const bytes = new Uint8Array(binary.length); let index = 0; while (index < binary.length) { bytes[index] = binary.charCodeAt(index); index += 1; } return bytes; } function updatePreviewImageFromBase64(encoded: string): void { if (!previewElement) { return; } const bytes = base64ToUint8Array(encoded); const copied = new Uint8Array(bytes.byteLength); copied.set(bytes); const blob = new Blob([copied.buffer], { type: "image/jpeg" }); const objectUrl = URL.createObjectURL(blob); if (previewObjectUrl !== null) { URL.revokeObjectURL(previewObjectUrl); } previewObjectUrl = objectUrl; previewElement.src = objectUrl; } async function refreshPreviewFrame(): Promise { if (previewRequestInFlight) { return; } if (currentMode !== "video" && currentMode !== "av") { return; } previewRequestInFlight = true; try { const encoded = await invoke("get_video_preview_frame_base64"); if (encoded !== null && encoded.length > 0) { updatePreviewImageFromBase64(encoded); } } catch (error) { console.error("preview refresh failed", error); } finally { previewRequestInFlight = false; } } function startPreviewPolling(): void { if (previewTimer !== null) { return; } window.setTimeout(() => { void refreshPreviewFrame(); }, 120); previewTimer = window.setInterval(() => { void refreshPreviewFrame(); }, 200); } function stopPreviewPolling(): void { if (previewTimer !== null) { window.clearInterval(previewTimer); previewTimer = null; } previewRequestInFlight = false; if (previewObjectUrl !== null) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = null; } if (previewElement) { previewElement.removeAttribute("src"); } } function renderRtcSnapshot(snapshot: RtcSnapshot): void { const peersText = JSON.stringify(snapshot.peers, null, 2); if (rtcPeers && rtcPeers.textContent !== peersText) { rtcPeers.textContent = peersText; } const logsText = snapshot.logs.join("\n"); if (rtcLogs && rtcLogs.textContent !== logsText) { rtcLogs.textContent = logsText; } const chatMessagesText = snapshot.chat_messages.join("\n"); if (rtcChatMessages && rtcChatMessages.textContent !== chatMessagesText) { rtcChatMessages.textContent = chatMessagesText; } const directMessagesText = snapshot.rtc_messages.join("\n"); if (rtcDirectMessages && rtcDirectMessages.textContent !== directMessagesText) { rtcDirectMessages.textContent = directMessagesText; } if (rtcStatus && rtcStatus.textContent !== snapshot.rtc_status) { rtcStatus.textContent = snapshot.rtc_status; } if ( rtcTargetPeerIdInput && snapshot.active_remote_peer_id !== null && document.activeElement !== rtcTargetPeerIdInput && rtcTargetPeerIdInput.value !== snapshot.active_remote_peer_id ) { rtcTargetPeerIdInput.value = snapshot.active_remote_peer_id; } if ( rtcServerUrlInput && snapshot.server_url.length > 0 && document.activeElement !== rtcServerUrlInput && rtcServerUrlInput.value !== snapshot.server_url ) { rtcServerUrlInput.value = snapshot.server_url; } if ( rtcDisplayNameInput && snapshot.display_name.length > 0 && document.activeElement !== rtcDisplayNameInput && rtcDisplayNameInput.value !== snapshot.display_name ) { rtcDisplayNameInput.value = snapshot.display_name; } setRtcConnected( snapshot.signalling_connected, snapshot.data_channel_open, snapshot.active_remote_peer_id, ); } async function refreshRtcSnapshot(): Promise { if (rtcSnapshotRequestInFlight) { return; } if (rtcPollingPaused) { return; } rtcSnapshotRequestInFlight = true; try { const snapshot = await invoke("rtc_get_snapshot"); renderRtcSnapshot(snapshot); } catch (error) { console.error("rtc snapshot refresh failed", error); } finally { rtcSnapshotRequestInFlight = false; } } async function refreshRtcSnapshotForced(): Promise { if (rtcSnapshotRequestInFlight) { return; } rtcSnapshotRequestInFlight = true; try { const snapshot = await invoke("rtc_get_snapshot"); renderRtcSnapshot(snapshot); } catch (error) { console.error("rtc snapshot forced refresh failed", error); } finally { rtcSnapshotRequestInFlight = false; } } function startRtcSnapshotPolling(): void { if (rtcSnapshotTimer !== null) { return; } window.setTimeout(() => { void refreshRtcSnapshot(); }, 100); rtcSnapshotTimer = window.setInterval(() => { void refreshRtcSnapshot(); }, 1000); } if (startAudioBtn) { startAudioBtn.addEventListener("click", async () => { if (currentMode !== "idle") { setAudioStatus("Another mode is already running."); return; } setCurrentMode("audio"); try { const path = await invoke("start_audio_recording"); setAudioStatus(`Audio recording started.\nOutput: ${path}`); } catch (error) { setAudioStatus(`Start audio failed.\n${String(error)}`); setCurrentMode("idle"); } }); } if (stopAudioBtn) { stopAudioBtn.addEventListener("click", async () => { if (currentMode !== "audio") { setAudioStatus("Audio mode is not running."); return; } stopAudioBtn.disabled = true; try { const path = await invoke("stop_audio_recording"); setAudioStatus(`Audio recording stopped.\nSaved file: ${path}`); setCurrentMode("idle"); } catch (error) { setAudioStatus(`Stop audio failed.\n${String(error)}`); setCurrentMode("audio"); } }); } if (startVideoBtn) { startVideoBtn.addEventListener("click", async () => { if (currentMode !== "idle") { setVideoStatus("Another mode is already running."); return; } setCurrentMode("video"); try { const path = await invoke("start_video_recording"); setVideoStatus(`Video recording started.\nOutput: ${path}`); startPreviewPolling(); } catch (error) { setVideoStatus(`Start video failed.\n${String(error)}`); stopPreviewPolling(); setCurrentMode("idle"); } }); } if (stopVideoBtn) { stopVideoBtn.addEventListener("click", async () => { if (currentMode !== "video") { setVideoStatus("Video mode is not running."); return; } stopVideoBtn.disabled = true; try { const path = await invoke("stop_video_recording"); setVideoStatus(`Video recording stopped.\nSaved file: ${path}`); stopPreviewPolling(); setCurrentMode("idle"); } catch (error) { setVideoStatus(`Stop video failed.\n${String(error)}`); setCurrentMode("video"); } }); } if (startAvBtn) { startAvBtn.addEventListener("click", async () => { if (currentMode !== "idle") { setAvStatus("Another mode is already running."); return; } setCurrentMode("av"); try { const result = await invoke("start_av_recording"); setAvStatus( `AV recording started.\nAudio: ${result.audio_path}\nVideo: ${result.video_path}` ); startPreviewPolling(); } catch (error) { setAvStatus(`Start AV failed.\n${String(error)}`); stopPreviewPolling(); setCurrentMode("idle"); } }); } if (stopAvBtn) { stopAvBtn.addEventListener("click", async () => { if (currentMode !== "av") { setAvStatus("AV mode is not running."); return; } stopAvBtn.disabled = true; try { const result = await invoke("stop_av_recording"); setAvStatus( `AV recording stopped.\nAudio: ${result.audio_path}\nVideo: ${result.video_path}` ); stopPreviewPolling(); setCurrentMode("idle"); } catch (error) { setAvStatus(`Stop AV failed.\n${String(error)}`); setCurrentMode("av"); } }); } if (rtcConnectBtn) { rtcConnectBtn.addEventListener("click", async () => { const serverUrl = (rtcServerUrlInput?.value || "").trim() || "ws://127.0.0.1:3012"; const displayName = (rtcDisplayNameInput?.value || "").trim() || "anonymous"; rtcPollingPaused = true; try { await invoke("rtc_connect_signalling", { serverUrl, displayName, }); await refreshRtcSnapshotForced(); } catch (error) { console.error("rtc_connect_signalling failed", error); } finally { rtcPollingPaused = false; } }); } if (rtcDisconnectBtn) { rtcDisconnectBtn.addEventListener("click", async () => { rtcPollingPaused = true; try { await invoke("rtc_disconnect_signalling"); await refreshRtcSnapshotForced(); } catch (error) { console.error("rtc_disconnect_signalling failed", error); } finally { rtcPollingPaused = false; } }); } if (rtcChatSendBtn) { rtcChatSendBtn.addEventListener("click", async () => { const text = (rtcChatInput?.value || "").trim(); if (text.length === 0) { return; } rtcPollingPaused = true; try { await invoke("rtc_send_chat_message", { text }); if (rtcChatInput) { rtcChatInput.value = ""; } await refreshRtcSnapshotForced(); } catch (error) { console.error("rtc_send_chat_message failed", error); } finally { rtcPollingPaused = false; } }); } if (rtcStartOfferBtn) { rtcStartOfferBtn.addEventListener("click", async () => { const targetPeerId = (rtcTargetPeerIdInput?.value || "").trim(); if (targetPeerId.length === 0) { return; } rtcPollingPaused = true; if (rtcStartOfferBtn) { rtcStartOfferBtn.disabled = true; } try { await invoke("rtc_start_offer", { targetPeerId }); await refreshRtcSnapshotForced(); } catch (error) { console.error("rtc_start_offer failed", error); } finally { rtcPollingPaused = false; } }); } if (rtcClosePeerBtn) { rtcClosePeerBtn.addEventListener("click", async () => { rtcPollingPaused = true; try { await invoke("rtc_close_peer"); await refreshRtcSnapshotForced(); } catch (error) { console.error("rtc_close_peer failed", error); } finally { rtcPollingPaused = false; } }); } if (rtcDirectSendBtn) { rtcDirectSendBtn.addEventListener("click", async () => { const text = (rtcDirectMessageInput?.value || "").trim(); if (text.length === 0) { return; } rtcPollingPaused = true; try { await invoke("rtc_send_data_message", { text }); if (rtcDirectMessageInput) { rtcDirectMessageInput.value = ""; } await refreshRtcSnapshotForced(); } catch (error) { console.error("rtc_send_data_message failed", error); } finally { rtcPollingPaused = false; } }); } setCurrentMode("idle"); setRtcConnected(false, false, null); setRtcDataChannelOpen(false); startRtcSnapshotPolling();