0.1.1
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tauri-video03"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "A Tauri Video App"
|
||||
authors = ["sinus@sasedev.net"]
|
||||
edition = "2024"
|
||||
@@ -28,10 +28,11 @@ serde = { version = "^1.0", features = ["derive"] }
|
||||
serde_json = { version = "^1.0", features = [] }
|
||||
tauri = { version = "^2.10", features = ["default"] }
|
||||
tauri-plugin-opener = { version = "^2.5", features = [] }
|
||||
tauri-plugin-websocket = "2"
|
||||
tokio = { version = "^1.50", features = ["full"] }
|
||||
tokio-tungstenite = { version = "^0.29", features = ["rustls", "tokio-rustls", "url"] }
|
||||
tracing = { version = "^0.1", features = ["async-await", "log"] }
|
||||
tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] }
|
||||
ts-rs = { version = "^12.0", features = [] }
|
||||
uuid = { version = "^1.23", features = [] }
|
||||
uuid = { version = "^1.23", features = ["v4"] }
|
||||
webrtc = { version = "^0.17", features = [] }
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"websocket:default"
|
||||
]
|
||||
}
|
||||
198
frontend/chat.ts
Normal file
198
frontend/chat.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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;
|
||||
}
|
||||
@@ -6,12 +6,43 @@
|
||||
<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';" />
|
||||
<title>Tauri GST Record + Preview</title>
|
||||
<title>Tauri GST Signalling + Chat</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="container">
|
||||
<h1>POC 2 - Recording + Preview</h1>
|
||||
<h1>POC 3 - Chat</h1>
|
||||
|
||||
<section class="card">
|
||||
<h2>Signalling + Chat</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>
|
||||
</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>
|
||||
|
||||
107
frontend/main.ts
107
frontend/main.ts
@@ -2,6 +2,8 @@
|
||||
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';
|
||||
|
||||
type Mode = "idle" | "audio" | "video" | "av";
|
||||
|
||||
@@ -19,6 +21,15 @@ 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");
|
||||
|
||||
if (
|
||||
startAudioBtn === null ||
|
||||
stopAudioBtn === null ||
|
||||
@@ -29,7 +40,15 @@ if (
|
||||
startAvBtn === null ||
|
||||
stopAvBtn === null ||
|
||||
avStatus === null ||
|
||||
previewElement === 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");
|
||||
}
|
||||
@@ -320,3 +339,89 @@ stopAvBtn.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -71,3 +71,19 @@ pre {
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
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;
|
||||
}
|
||||
|
||||
3
frontend/ts/bindings/ClientWsMessage.ts
Normal file
3
frontend/ts/bindings/ClientWsMessage.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ClientWsMessage = { "type": "hello", display_name: string, } | { "type": "chat_send", text: string, } | { "type": "offer", target_peer_id: string, sdp: string, } | { "type": "answer", target_peer_id: string, sdp: string, } | { "type": "ice_candidate", target_peer_id: string, candidate: string, sdp_mid: string | null, sdp_mline_index: number | null, } | { "type": "ping" };
|
||||
3
frontend/ts/bindings/PeerInfo.ts
Normal file
3
frontend/ts/bindings/PeerInfo.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PeerInfo = { peer_id: string, display_name: string, };
|
||||
4
frontend/ts/bindings/ServerWsMessage.ts
Normal file
4
frontend/ts/bindings/ServerWsMessage.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 ServerWsMessage = { "type": "welcome", peer_id: string, } | { "type": "peer_list", peers: Array<PeerInfo>, } | { "type": "peer_joined", peer: PeerInfo, } | { "type": "peer_left", peer_id: string, } | { "type": "chat_receive", from_peer_id: string, from_display_name: string, text: string, } | { "type": "offer", from_peer_id: string, sdp: string, } | { "type": "answer", from_peer_id: string, sdp: string, } | { "type": "ice_candidate", from_peer_id: string, candidate: string, sdp_mid: string | null, sdp_mline_index: number | null, } | { "type": "pong" } | { "type": "error", message: string, };
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-video03",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10",
|
||||
"@tauri-apps/plugin-opener": "^2.5"
|
||||
"@tauri-apps/plugin-opener": "^2.5",
|
||||
"@tauri-apps/plugin-websocket": "^2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.10",
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn run() {
|
||||
init_gstreamer();
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(app_state::AppState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "tauri-video03",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"identifier": "com.sinus.tauri-video03",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -7,6 +7,7 @@ const host = process.env.TAURI_DEV_HOST;
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
|
||||
envPrefix: ['VITE_', 'TAURI_ENV_*'],
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
@@ -61,7 +62,7 @@ export default defineConfig(async () => ({
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
strictPort: false,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user