0.2.1
This commit is contained in:
198
frontend/chat.ts
198
frontend/chat.ts
@@ -1,198 +0,0 @@
|
||||
import WebSocket from "@tauri-apps/plugin-websocket";
|
||||
import { PeerInfo } from './ts/bindings/PeerInfo';
|
||||
import { ClientWsMessage } from './ts/bindings/ClientWsMessage';
|
||||
import { ServerWsMessage } from './ts/bindings/ServerWsMessage';
|
||||
|
||||
type RemoveListener = (() => void) | null;
|
||||
|
||||
export class LocalSignallingClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private removeListener: RemoveListener = null;
|
||||
private myPeerId: string | null = null;
|
||||
private peers: PeerInfo[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly displayName: string,
|
||||
private readonly onLog: (message: string) => void,
|
||||
private readonly onPeers: (peers: PeerInfo[]) => void,
|
||||
private readonly onChat: (line: string) => void,
|
||||
) {}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.socket !== null) {
|
||||
this.onLog("signalling already connected");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = "ws://127.0.0.1:3012";
|
||||
|
||||
this.onLog(`connecting to ${url}`);
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = await WebSocket.connect(url);
|
||||
} catch (error) {
|
||||
throw new Error(`websocket connection failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
this.socket = socket;
|
||||
|
||||
const removeListener = socket.addListener((message) => {
|
||||
if (message.type === "Text") {
|
||||
this.handleServerMessage(message.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "Close") {
|
||||
this.onLog("signalling disconnected");
|
||||
this.cleanupDisconnectedState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "Binary") {
|
||||
this.onLog("unexpected binary websocket message");
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.removeListener = removeListener;
|
||||
|
||||
this.onLog("signalling connected");
|
||||
|
||||
await this.send({
|
||||
type: "hello",
|
||||
display_name: this.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.socket === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
|
||||
if (this.removeListener !== null) {
|
||||
this.removeListener();
|
||||
this.removeListener = null;
|
||||
}
|
||||
|
||||
this.socket = null;
|
||||
|
||||
try {
|
||||
await socket.disconnect();
|
||||
} catch (error) {
|
||||
this.onLog(`disconnect error: ${String(error)}`);
|
||||
}
|
||||
|
||||
this.cleanupDisconnectedState();
|
||||
}
|
||||
|
||||
public async sendChat(text: string): Promise<void> {
|
||||
await this.send({
|
||||
type: "chat_send",
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
public getMyPeerId(): string | null {
|
||||
return this.myPeerId;
|
||||
}
|
||||
|
||||
public getPeers(): PeerInfo[] {
|
||||
return [...this.peers];
|
||||
}
|
||||
|
||||
private async send(message: ClientWsMessage): Promise<void> {
|
||||
if (this.socket === null) {
|
||||
this.onLog("cannot send: signalling not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
try {
|
||||
await this.socket.send(payload);
|
||||
} catch (error) {
|
||||
this.onLog(`send failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupDisconnectedState(): void {
|
||||
this.socket = null;
|
||||
this.myPeerId = null;
|
||||
this.peers = [];
|
||||
this.onPeers([]);
|
||||
}
|
||||
|
||||
private handleServerMessage(raw: string): void {
|
||||
let message: ServerWsMessage;
|
||||
|
||||
try {
|
||||
message = JSON.parse(raw) as ServerWsMessage;
|
||||
} catch (error) {
|
||||
this.onLog(`invalid server message: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "welcome":
|
||||
this.myPeerId = message.peer_id;
|
||||
this.onLog(`welcome peer_id=${message.peer_id}`);
|
||||
break;
|
||||
|
||||
case "peer_list":
|
||||
this.peers = message.peers;
|
||||
this.onPeers([...this.peers]);
|
||||
this.onLog(`peer_list received (${message.peers.length})`);
|
||||
break;
|
||||
|
||||
case "peer_joined":
|
||||
this.peers = mergePeer(this.peers, message.peer);
|
||||
this.onPeers([...this.peers]);
|
||||
this.onLog(`peer joined: ${message.peer.display_name}`);
|
||||
break;
|
||||
|
||||
case "peer_left":
|
||||
this.peers = this.peers.filter((peer) => peer.peer_id !== message.peer_id);
|
||||
this.onPeers([...this.peers]);
|
||||
this.onLog(`peer left: ${message.peer_id}`);
|
||||
break;
|
||||
|
||||
case "chat_receive":
|
||||
this.onChat(`${message.from_display_name}: ${message.text}`);
|
||||
break;
|
||||
|
||||
case "offer":
|
||||
this.onLog(`offer received from ${message.from_peer_id}`);
|
||||
break;
|
||||
|
||||
case "answer":
|
||||
this.onLog(`answer received from ${message.from_peer_id}`);
|
||||
break;
|
||||
|
||||
case "ice_candidate":
|
||||
this.onLog(`ice candidate received from ${message.from_peer_id}`);
|
||||
break;
|
||||
|
||||
case "pong":
|
||||
this.onLog("pong");
|
||||
break;
|
||||
|
||||
case "error":
|
||||
this.onLog(`server error: ${message.message}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.onLog(`unhandled server message: ${raw}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergePeer(peers: PeerInfo[], incoming: PeerInfo): PeerInfo[] {
|
||||
const next = peers.filter((peer) => peer.peer_id !== incoming.peer_id);
|
||||
next.push(incoming);
|
||||
next.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
return next;
|
||||
}
|
||||
@@ -5,62 +5,28 @@
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self';" />
|
||||
content="default-src 'self';
|
||||
script-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob:;
|
||||
connect-src * ipc: http://ipc.localhost ipc://localhost;" />
|
||||
<title>Tauri GST Signalling + Chat</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="container">
|
||||
<h1>POC 3 - Chat</h1>
|
||||
<h1>POC 3 - Rust signalling + Rust WebRTC</h1>
|
||||
|
||||
<section class="card">
|
||||
<h2>Signalling + Chat</h2>
|
||||
<h2>Audio</h2>
|
||||
|
||||
<div class="actions">
|
||||
<input id="chat-display-name" type="text" placeholder="Display name"
|
||||
value="sinus" />
|
||||
<button id="chat-connect-btn" type="button">Connect
|
||||
signalling</button>
|
||||
<button id="chat-disconnect-btn" type="button" disabled>Disconnect</button>
|
||||
<button id="start-audio-btn" type="button">Start audio</button>
|
||||
<button id="stop-audio-btn" type="button" disabled>Stop
|
||||
audio</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-grid">
|
||||
<div>
|
||||
<h3>Peers</h3>
|
||||
<pre id="chat-peers">[]</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Logs</h3>
|
||||
<pre id="chat-logs">Ready.</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input id="chat-input" type="text" placeholder="Message" />
|
||||
<button id="chat-send-btn" type="button" disabled>Send chat</button>
|
||||
</div>
|
||||
|
||||
<pre id="chat-messages">No messages yet.</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Video Preview</h2>
|
||||
|
||||
<div class="preview-wrap">
|
||||
<img id="video-preview" alt="Video preview" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Audio + Video</h2>
|
||||
|
||||
<div class="actions">
|
||||
<button id="start-av-btn" type="button">Start AV</button>
|
||||
<button id="stop-av-btn" type="button" disabled>Stop AV</button>
|
||||
</div>
|
||||
|
||||
<pre id="av-status">Ready.</pre>
|
||||
<pre id="audio-status">Ready.</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -76,15 +42,81 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Audio</h2>
|
||||
<h2>Audio + Video</h2>
|
||||
|
||||
<div class="actions">
|
||||
<button id="start-audio-btn" type="button">Start audio</button>
|
||||
<button id="stop-audio-btn" type="button" disabled>Stop
|
||||
audio</button>
|
||||
<button id="start-av-btn" type="button">Start AV</button>
|
||||
<button id="stop-av-btn" type="button" disabled>Stop AV</button>
|
||||
</div>
|
||||
|
||||
<pre id="audio-status">Ready.</pre>
|
||||
<pre id="av-status">Ready.</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Video Preview</h2>
|
||||
|
||||
<div class="preview-wrap">
|
||||
<img id="video-preview" alt="Video preview" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Signalling + Chat</h2>
|
||||
|
||||
<div class="actions">
|
||||
<input id="rtc-server-url" type="text" value="ws://127.0.0.1:3012"
|
||||
placeholder="Signalling URL" /> <input
|
||||
id="rtc-display-name" type="text" value="sinus"
|
||||
placeholder="Display name" />
|
||||
<button id="rtc-connect-btn" type="button">Connect
|
||||
signalling</button>
|
||||
<button id="rtc-disconnect-btn" type="button" disabled>Disconnect</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-grid">
|
||||
<div>
|
||||
<h3>Peers</h3>
|
||||
<pre id="rtc-peers">[]</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Logs</h3>
|
||||
<pre id="rtc-logs">Ready.</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input id="rtc-chat-input" type="text"
|
||||
placeholder="Signalling chat message" />
|
||||
<button id="rtc-chat-send-btn" type="button" disabled>Send
|
||||
chat</button>
|
||||
</div>
|
||||
|
||||
<pre id="rtc-chat-messages">No messages yet.</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Rust WebRTC DataChannel</h2>
|
||||
|
||||
<div class="actions">
|
||||
<input id="rtc-target-peer-id" type="text"
|
||||
placeholder="Target peer id" />
|
||||
<button id="rtc-start-offer-btn" type="button" disabled>Start
|
||||
RTC</button>
|
||||
<button id="rtc-close-peer-btn" type="button" disabled>Close
|
||||
RTC</button>
|
||||
</div>
|
||||
|
||||
<pre id="rtc-status">Idle.</pre>
|
||||
|
||||
<div class="actions">
|
||||
<input id="rtc-direct-message-input" type="text"
|
||||
placeholder="RTC direct message" />
|
||||
<button id="rtc-direct-send-btn" type="button" disabled>Send
|
||||
RTC</button>
|
||||
</div>
|
||||
|
||||
<pre id="rtc-direct-messages">No RTC messages yet.</pre>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="main.ts" defer></script>
|
||||
|
||||
671
frontend/main.ts
671
frontend/main.ts
@@ -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();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ body {
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 980px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -43,6 +43,15 @@ button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #0b1220;
|
||||
color: #f9fafb;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
@@ -51,7 +60,6 @@ button:disabled {
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -78,12 +86,3 @@ pre {
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #0b1220;
|
||||
color: #f9fafb;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
4
frontend/ts/bindings/RtcSnapshot.ts
Normal file
4
frontend/ts/bindings/RtcSnapshot.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PeerInfo } from "./PeerInfo";
|
||||
|
||||
export type RtcSnapshot = { signalling_connected: boolean, server_url: string, display_name: string, my_peer_id: string | null, peers: Array<PeerInfo>, logs: Array<string>, chat_messages: Array<string>, rtc_messages: Array<string>, rtc_status: string, data_channel_open: boolean, active_remote_peer_id: string | null, };
|
||||
Reference in New Issue
Block a user