639 lines
18 KiB
TypeScript
639 lines
18 KiB
TypeScript
// 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<HTMLButtonElement>("#start-audio-btn");
|
|
const stopAudioBtn = document.querySelector<HTMLButtonElement>("#stop-audio-btn");
|
|
const audioStatus = document.querySelector<HTMLElement>("#audio-status");
|
|
|
|
const startVideoBtn = document.querySelector<HTMLButtonElement>("#start-video-btn");
|
|
const stopVideoBtn = document.querySelector<HTMLButtonElement>("#stop-video-btn");
|
|
const videoStatus = document.querySelector<HTMLElement>("#video-status");
|
|
|
|
const startAvBtn = document.querySelector<HTMLButtonElement>("#start-av-btn");
|
|
const stopAvBtn = document.querySelector<HTMLButtonElement>("#stop-av-btn");
|
|
const avStatus = document.querySelector<HTMLElement>("#av-status");
|
|
|
|
const previewElement = document.querySelector<HTMLImageElement>("#video-preview");
|
|
|
|
const rtcServerUrlInput = document.querySelector<HTMLInputElement>("#rtc-server-url");
|
|
const rtcDisplayNameInput = document.querySelector<HTMLInputElement>("#rtc-display-name");
|
|
const rtcConnectBtn = document.querySelector<HTMLButtonElement>("#rtc-connect-btn");
|
|
const rtcDisconnectBtn = document.querySelector<HTMLButtonElement>("#rtc-disconnect-btn");
|
|
|
|
const rtcPeers = document.querySelector<HTMLElement>("#rtc-peers");
|
|
const rtcLogs = document.querySelector<HTMLElement>("#rtc-logs");
|
|
|
|
const rtcChatInput = document.querySelector<HTMLInputElement>("#rtc-chat-input");
|
|
const rtcChatSendBtn = document.querySelector<HTMLButtonElement>("#rtc-chat-send-btn");
|
|
const rtcChatMessages = document.querySelector<HTMLElement>("#rtc-chat-messages");
|
|
|
|
const rtcTargetPeerIdInput = document.querySelector<HTMLInputElement>("#rtc-target-peer-id");
|
|
const rtcStartOfferBtn = document.querySelector<HTMLButtonElement>("#rtc-start-offer-btn");
|
|
const rtcClosePeerBtn = document.querySelector<HTMLButtonElement>("#rtc-close-peer-btn");
|
|
const rtcStatus = document.querySelector<HTMLElement>("#rtc-status");
|
|
|
|
const rtcDirectMessageInput = document.querySelector<HTMLInputElement>("#rtc-direct-message-input");
|
|
const rtcDirectSendBtn = document.querySelector<HTMLButtonElement>("#rtc-direct-send-btn");
|
|
const rtcDirectMessages = document.querySelector<HTMLElement>("#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<void> {
|
|
if (previewRequestInFlight) {
|
|
return;
|
|
}
|
|
|
|
if (currentMode !== "video" && currentMode !== "av") {
|
|
return;
|
|
}
|
|
|
|
previewRequestInFlight = true;
|
|
|
|
try {
|
|
const encoded = await invoke<string | null>("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<void> {
|
|
if (rtcSnapshotRequestInFlight) {
|
|
return;
|
|
}
|
|
|
|
if (rtcPollingPaused) {
|
|
return;
|
|
}
|
|
|
|
rtcSnapshotRequestInFlight = true;
|
|
|
|
try {
|
|
const snapshot = await invoke<RtcSnapshot>("rtc_get_snapshot");
|
|
renderRtcSnapshot(snapshot);
|
|
} catch (error) {
|
|
console.error("rtc snapshot refresh failed", error);
|
|
} finally {
|
|
rtcSnapshotRequestInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function refreshRtcSnapshotForced(): Promise<void> {
|
|
if (rtcSnapshotRequestInFlight) {
|
|
return;
|
|
}
|
|
|
|
rtcSnapshotRequestInFlight = true;
|
|
|
|
try {
|
|
const snapshot = await invoke<RtcSnapshot>("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<string>("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<string>("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<string>("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<string>("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<AvStartResponse>("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<AvStopResponse>("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();
|
|
|