This commit is contained in:
2026-04-02 16:51:28 +02:00
parent 945115527f
commit 9e14e41b36
15 changed files with 1740 additions and 492 deletions

View File

@@ -1,9 +1,9 @@
// file: frontend/main.ts
import { invoke } from "@tauri-apps/api/core";
import { AvStartResponse } from './ts/bindings/AvStartResponse';
import { AvStopResponse } from './ts/bindings/AvStopResponse';
import { LocalSignallingClient } from "./chat";
import { PeerInfo } from './ts/bindings/PeerInfo';
import { RtcSnapshot } from './ts/bindings/RtcSnapshot';
type Mode = "idle" | "audio" | "video" | "av";
@@ -21,77 +21,83 @@ const avStatus = document.querySelector<HTMLElement>("#av-status");
const previewElement = document.querySelector<HTMLImageElement>("#video-preview");
const chatDisplayNameInput = document.querySelector<HTMLInputElement>("#chat-display-name");
const chatConnectBtn = document.querySelector<HTMLButtonElement>("#chat-connect-btn");
const chatDisconnectBtn = document.querySelector<HTMLButtonElement>("#chat-disconnect-btn");
const chatInput = document.querySelector<HTMLInputElement>("#chat-input");
const chatSendBtn = document.querySelector<HTMLButtonElement>("#chat-send-btn");
const chatPeers = document.querySelector<HTMLElement>("#chat-peers");
const chatLogs = document.querySelector<HTMLElement>("#chat-logs");
const chatMessages = document.querySelector<HTMLElement>("#chat-messages");
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");
if (
startAudioBtn === null ||
stopAudioBtn === null ||
audioStatus === null ||
startVideoBtn === null ||
stopVideoBtn === null ||
videoStatus === null ||
startAvBtn === null ||
stopAvBtn === null ||
avStatus === null ||
previewElement === null ||
chatDisplayNameInput === null ||
chatConnectBtn === null ||
chatDisconnectBtn === null ||
chatInput === null ||
chatSendBtn === null ||
chatPeers === null ||
chatLogs === null ||
chatMessages === null
) {
throw new Error("missing UI elements");
}
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)
if (audioStatus) {
audioStatus.textContent = message;
}
}
function setVideoStatus(message: string): void {
if (videoStatus)
if (videoStatus) {
videoStatus.textContent = message;
}
}
function setAvStatus(message: string): void {
if (avStatus)
if (avStatus) {
avStatus.textContent = message;
}
}
function setAudioButtons(isRecording: boolean): void {
if (startAudioBtn)
if (startAudioBtn) {
startAudioBtn.disabled = isRecording;
if (stopAudioBtn)
}
if (stopAudioBtn) {
stopAudioBtn.disabled = !isRecording;
}
}
function setVideoButtons(isRecording: boolean): void {
if (startVideoBtn)
if (startVideoBtn) {
startVideoBtn.disabled = isRecording;
if (stopVideoBtn)
}
if (stopVideoBtn) {
stopVideoBtn.disabled = !isRecording;
}
}
function setAvButtons(isRecording: boolean): void {
if (startAvBtn)
if (startAvBtn) {
startAvBtn.disabled = isRecording;
if (stopAvBtn)
}
if (stopAvBtn) {
stopAvBtn.disabled = !isRecording;
}
}
function setAllButtonsForMode(mode: Mode): void {
@@ -107,24 +113,30 @@ function setAllButtonsForMode(mode: Mode): void {
setAvButtons(mode === "av");
if (mode !== "audio") {
if (stopAudioBtn)
if (stopAudioBtn) {
stopAudioBtn.disabled = true;
if (startAudioBtn)
}
if (startAudioBtn) {
startAudioBtn.disabled = true;
}
}
if (mode !== "video") {
if (stopVideoBtn)
if (stopVideoBtn) {
stopVideoBtn.disabled = true;
if (startVideoBtn)
}
if (startVideoBtn) {
startVideoBtn.disabled = true;
}
}
if (mode !== "av") {
if (stopAvBtn)
if (stopAvBtn) {
stopAvBtn.disabled = true;
if (startAvBtn)
}
if (startAvBtn) {
startAvBtn.disabled = true;
}
}
}
@@ -133,6 +145,40 @@ function setCurrentMode(mode: Mode): void {
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);
@@ -147,9 +193,12 @@ function base64ToUint8Array(base64: string): Uint8Array {
}
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" });
@@ -160,8 +209,7 @@ function updatePreviewImageFromBase64(encoded: string): void {
}
previewObjectUrl = objectUrl;
if (previewElement)
previewElement.src = objectUrl;
previewElement.src = objectUrl;
}
async function refreshPreviewFrame(): Promise<void> {
@@ -215,213 +263,376 @@ function stopPreviewPolling(): void {
previewObjectUrl = null;
}
if (previewElement)
if (previewElement) {
previewElement.removeAttribute("src");
}
}
startAudioBtn.addEventListener("click", async () => {
if (currentMode !== "idle") {
setAudioStatus("Another mode is already running.");
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;
}
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");
}
});
stopAudioBtn.addEventListener("click", async () => {
if (currentMode !== "audio") {
setAudioStatus("Audio mode is not running.");
if (rtcPollingPaused) {
return;
}
stopAudioBtn.disabled = true;
rtcSnapshotRequestInFlight = true;
try {
const path = await invoke<string>("stop_audio_recording");
setAudioStatus(`Audio recording stopped.\nSaved file: ${path}`);
setCurrentMode("idle");
const snapshot = await invoke<RtcSnapshot>("rtc_get_snapshot");
renderRtcSnapshot(snapshot);
} catch (error) {
setAudioStatus(`Stop audio failed.\n${String(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");
}
});
startVideoBtn.addEventListener("click", async () => {
if (currentMode !== "idle") {
setVideoStatus("Another mode is already running.");
return;
}
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");
}
});
}
setCurrentMode("video");
if (stopAudioBtn) {
stopAudioBtn.addEventListener("click", async () => {
if (currentMode !== "audio") {
setAudioStatus("Audio mode is not running.");
return;
}
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");
}
});
stopAudioBtn.disabled = true;
stopVideoBtn.addEventListener("click", async () => {
if (currentMode !== "video") {
setVideoStatus("Video mode is not running.");
return;
}
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");
}
});
}
stopVideoBtn.disabled = true;
if (startVideoBtn) {
startVideoBtn.addEventListener("click", async () => {
if (currentMode !== "idle") {
setVideoStatus("Another mode is already running.");
return;
}
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");
}
});
startAvBtn.addEventListener("click", async () => {
if (currentMode !== "idle") {
setAvStatus("Another mode is already running.");
return;
}
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");
}
});
}
setCurrentMode("av");
if (stopVideoBtn) {
stopVideoBtn.addEventListener("click", async () => {
if (currentMode !== "video") {
setVideoStatus("Video mode is not running.");
return;
}
try {
const result = await invoke<AvStartResponse>("start_av_recording");
stopVideoBtn.disabled = true;
setAvStatus(
`AV recording started.\nAudio: ${result.audio_path}\nVideo: ${result.video_path}`
);
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");
}
});
}
startPreviewPolling();
} catch (error) {
setAvStatus(`Start AV failed.\n${String(error)}`);
stopPreviewPolling();
setCurrentMode("idle");
}
});
if (startAvBtn) {
startAvBtn.addEventListener("click", async () => {
if (currentMode !== "idle") {
setAvStatus("Another mode is already running.");
return;
}
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");
}
});
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");
let signallingClient: LocalSignallingClient | null = null;
function appendChatLog(line: string): void {
if (chatLogs) {
const current = chatLogs.textContent ?? "";
chatLogs.textContent = `${current}\n${line}`.trim();
}
}
function appendChatMessage(line: string): void {
if (chatMessages) {
const current = chatMessages.textContent ?? "";
chatMessages.textContent = `${current}\n${line}`.trim();
}
}
function updatePeerList(peers: PeerInfo[]): void {
if (chatPeers)
chatPeers.textContent = JSON.stringify(peers, null, 2);
}
function setChatConnected(connected: boolean): void {
if (chatConnectBtn)
chatConnectBtn.disabled = connected;
if (chatDisconnectBtn)
chatDisconnectBtn.disabled = !connected;
if (chatSendBtn)
chatSendBtn.disabled = !connected;
}
chatConnectBtn.addEventListener("click", async () => {
if (signallingClient !== null) {
appendChatLog("signalling client already exists");
return;
}
const displayName = (chatDisplayNameInput.value || "").trim() || "anonymous";
const client = new LocalSignallingClient(
displayName,
(line) => appendChatLog(line),
(peers) => updatePeerList(peers),
(line) => appendChatMessage(line),
);
try {
await client.connect();
signallingClient = client;
setChatConnected(true);
} catch (error) {
appendChatLog(`connect failed: ${String(error)}`);
signallingClient = null;
setChatConnected(false);
}
});
chatDisconnectBtn.addEventListener("click", () => {
if (signallingClient === null) {
return;
}
signallingClient.disconnect();
signallingClient = null;
setChatConnected(false);
updatePeerList([]);
});
chatSendBtn.addEventListener("click", () => {
if (signallingClient === null) {
appendChatLog("not connected");
return;
}
const text = chatInput.value.trim();
if (text.length === 0) {
appendChatLog("chat message is empty");
return;
}
signallingClient.sendChat(text);
chatInput.value = "";
});
setChatConnected(false);
setRtcConnected(false, false, null);
setRtcDataChannelOpen(false);
startRtcSnapshotPolling();