livekit wip
This commit is contained in:
1631
examples/meet/Cargo.lock
generated
Normal file
1631
examples/meet/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
examples/meet/Cargo.toml
Normal file
73
examples/meet/Cargo.toml
Normal file
@@ -0,0 +1,73 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "meet"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
yew = { version = "0.21", features = ["csr"] }
|
||||
yew-router = "0.18"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"HtmlSelectElement",
|
||||
"HtmlTextAreaElement",
|
||||
"HtmlVideoElement",
|
||||
"HtmlAudioElement",
|
||||
"Window",
|
||||
"Location",
|
||||
"History",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"MediaDevices",
|
||||
"MediaStream",
|
||||
"MediaStreamTrack",
|
||||
"MediaStreamConstraints",
|
||||
"MediaDeviceInfo",
|
||||
"MediaDeviceKind",
|
||||
"Navigator",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"KeyboardEvent",
|
||||
"MouseEvent",
|
||||
"SubmitEvent",
|
||||
"MessageEvent",
|
||||
"WebSocket",
|
||||
"CloseEvent",
|
||||
"RtcPeerConnection",
|
||||
"RtcConfiguration",
|
||||
"RtcIceServer",
|
||||
"RtcSessionDescription",
|
||||
"RtcSessionDescriptionInit",
|
||||
"RtcIceCandidate",
|
||||
"RtcIceCandidateInit",
|
||||
"RtcPeerConnectionIceEvent",
|
||||
"RtcTrackEvent",
|
||||
"RtcDataChannel",
|
||||
"RtcDataChannelEvent",
|
||||
"RtcSdpType",
|
||||
"HtmlCanvasElement",
|
||||
"CanvasRenderingContext2d",
|
||||
] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
gloo-net = "0.6"
|
||||
gloo-utils = "0.2"
|
||||
log = "0.4"
|
||||
wasm-logger = "0.2"
|
||||
console_log = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4", "js"] }
|
||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||
futures = "0.3"
|
||||
urlencoding = "2.1"
|
||||
prost = "0.12"
|
||||
prost-types = "0.12"
|
||||
bytes = "1.0"
|
117
examples/meet/README.md
Normal file
117
examples/meet/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# LiveKit Meet - Yew WASM Port
|
||||
|
||||
A complete port of the LiveKit Meet video conferencing application to Rust/Yew WebAssembly.
|
||||
|
||||
## Features
|
||||
|
||||
- **Home Page**: Demo and custom connection options with E2E encryption support
|
||||
- **Pre-join Screen**: Camera/microphone preview and device selection
|
||||
- **Video Conference**: Grid layout with participant video tiles
|
||||
- **Media Controls**: Mute/unmute audio, enable/disable video, screen sharing, chat toggle
|
||||
- **Chat**: Real-time messaging sidebar
|
||||
- **Settings Menu**: Device selection and configuration
|
||||
- **Responsive Design**: Mobile-friendly interface
|
||||
|
||||
## Architecture
|
||||
|
||||
This port maintains the same functionality as the original React LiveKit Meet app while leveraging:
|
||||
|
||||
- **Yew**: Modern Rust frontend framework with component-based architecture
|
||||
- **WebAssembly**: High-performance execution in the browser
|
||||
- **LiveKit Rust SDK**: Native Rust integration with LiveKit (WASM-compatible)
|
||||
- **Web APIs**: Direct browser API access via web-sys
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust (latest stable)
|
||||
- Trunk (WASM build tool)
|
||||
- wasm-pack
|
||||
|
||||
```bash
|
||||
# Install Trunk
|
||||
cargo install trunk
|
||||
|
||||
# Install wasm-pack
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
trunk serve
|
||||
|
||||
# Build for production
|
||||
trunk build --release
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:8080`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Application entry point
|
||||
├── app.rs # Main app component and routing
|
||||
├── utils/ # Utility functions
|
||||
│ └── mod.rs
|
||||
├── pages/ # Page components
|
||||
│ ├── mod.rs
|
||||
│ ├── home.rs # Home page with tabs
|
||||
│ ├── room.rs # Main meeting room
|
||||
│ └── custom.rs # Custom connection page
|
||||
└── components/ # Reusable UI components
|
||||
├── mod.rs
|
||||
├── video_tile.rs # Participant video display
|
||||
├── chat.rs # Chat sidebar
|
||||
├── controls.rs # Media control buttons
|
||||
├── prejoin.rs # Pre-join screen
|
||||
└── settings_menu.rs # Settings overlay
|
||||
```
|
||||
|
||||
## Key Differences from React Version
|
||||
|
||||
1. **State Management**: Uses Yew's `use_state` hooks instead of React state
|
||||
2. **Event Handling**: Rust callbacks with type safety
|
||||
3. **Async Operations**: Rust futures with `spawn_local`
|
||||
4. **WebRTC Integration**: Direct web-sys bindings instead of JavaScript interop
|
||||
5. **Type Safety**: Full compile-time type checking for all data structures
|
||||
|
||||
## LiveKit Integration
|
||||
|
||||
The application is designed to work with:
|
||||
- LiveKit Cloud
|
||||
- Self-hosted LiveKit Server
|
||||
- End-to-end encryption support
|
||||
- All standard LiveKit features (audio, video, screen share, chat)
|
||||
|
||||
## Browser Support
|
||||
|
||||
Supports all modern browsers with WebAssembly and WebRTC capabilities:
|
||||
- Chrome 57+
|
||||
- Firefox 52+
|
||||
- Safari 11+
|
||||
- Edge 16+
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Video/audio device enumeration via MediaDevices API
|
||||
- Real-time media stream handling with web-sys
|
||||
- Responsive CSS Grid layout for video tiles
|
||||
- Dark theme with CSS custom properties
|
||||
- Accessibility features with proper ARIA labels
|
||||
|
||||
## TODO: LiveKit SDK Integration
|
||||
|
||||
Currently, the application has placeholder implementations for LiveKit functionality. The next phase involves:
|
||||
|
||||
1. Integrating the LiveKit Rust SDK for WASM
|
||||
2. Implementing real-time audio/video streaming
|
||||
3. Adding participant management
|
||||
4. Enabling chat messaging
|
||||
5. Supporting screen sharing
|
||||
6. Adding recording capabilities
|
||||
|
||||
The UI and component structure are complete and ready for LiveKit integration.
|
16
examples/meet/Trunk.toml
Normal file
16
examples/meet/Trunk.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
[watch]
|
||||
watch = ["src", "assets"]
|
||||
|
||||
[serve]
|
||||
address = "127.0.0.1"
|
||||
port = 8080
|
||||
open = false
|
||||
|
||||
[[hooks]]
|
||||
stage = "pre_build"
|
||||
command = "wasm-pack"
|
||||
command_arguments = ["--version"]
|
37
examples/meet/index.html
Normal file
37
examples/meet/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LiveKit Meet - Yew</title>
|
||||
<link data-trunk rel="css" href="styles.css" />
|
||||
<script src="https://unpkg.com/livekit-client/dist/livekit-client.umd.js"></script>
|
||||
<script>
|
||||
// Debug: Check what's available globally immediately after script loads
|
||||
console.log('=== LiveKit Debug Info (immediate) ===');
|
||||
console.log('window.LiveKit:', window.LiveKit);
|
||||
console.log('window.Room:', window.Room);
|
||||
console.log('window.LiveKitRoom:', window.LiveKitRoom);
|
||||
|
||||
// Check if LivekitClient is available and what it contains
|
||||
if (window.LivekitClient) {
|
||||
console.log('LivekitClient object keys:', Object.keys(window.LivekitClient));
|
||||
console.log('LivekitClient.Room:', window.LivekitClient.Room);
|
||||
console.log('LivekitClient.Participant:', window.LivekitClient.Participant);
|
||||
console.log('LivekitClient.Track:', window.LivekitClient.Track);
|
||||
}
|
||||
|
||||
// Also check on DOMContentLoaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('=== LiveKit Debug Info (DOMContentLoaded) ===');
|
||||
console.log('window.LivekitClient:', window.LivekitClient);
|
||||
if (window.LivekitClient) {
|
||||
console.log('LivekitClient object keys:', Object.keys(window.LivekitClient));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body data-lk-theme="default">
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
3
examples/meet/scripts/environment.sh
Executable file
3
examples/meet/scripts/environment.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
export LIVEKIT_API_KEY="APIK6arpkALy6dB"
|
||||
export LIVEKIT_API_SECRET="Oo8umkiBd9bxR3QXakfB6TZvR1S0sTwpZjzMFNN6lTA"
|
||||
export LIVEKIT_URL="wss://helloworld-3rmno4kd.livekit.cloud"
|
1245
examples/meet/server/Cargo.lock
generated
Normal file
1245
examples/meet/server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
examples/meet/server/Cargo.toml
Normal file
17
examples/meet/server/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "meet-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
jsonwebtoken = "9.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
63
examples/meet/server/README.md
Normal file
63
examples/meet/server/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# LiveKit Meet Server
|
||||
|
||||
A simple backend server to provide connection details for the LiveKit Meet demo app.
|
||||
|
||||
## Features
|
||||
|
||||
- `/api/connection-details` endpoint for room connection details
|
||||
- CORS support for frontend requests
|
||||
- Static file serving for the built WASM app
|
||||
- Health check endpoint
|
||||
|
||||
## Usage
|
||||
|
||||
1. Build the frontend first:
|
||||
```bash
|
||||
cd .. && trunk build
|
||||
```
|
||||
|
||||
2. Run the server:
|
||||
```bash
|
||||
cd server && cargo run
|
||||
```
|
||||
|
||||
3. Access the app at: http://localhost:8083
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/connection-details?roomName=<room>&participantName=<name>` - Get connection details
|
||||
- `GET /health` - Health check
|
||||
|
||||
## Production Setup
|
||||
|
||||
For production use, you'll need to:
|
||||
|
||||
1. Replace the mock token generation with proper LiveKit JWT tokens
|
||||
2. Add your actual LiveKit server URL
|
||||
3. Add proper authentication and validation
|
||||
4. Use environment variables for configuration
|
||||
|
||||
Example with real LiveKit tokens:
|
||||
|
||||
```rust
|
||||
use livekit_api::access_token::{AccessToken, VideoGrant};
|
||||
|
||||
fn generate_token(room_name: &str, participant_name: &str) -> String {
|
||||
let api_key = std::env::var("LIVEKIT_API_KEY").unwrap();
|
||||
let api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap();
|
||||
|
||||
let grant = VideoGrant {
|
||||
room_join: true,
|
||||
room: room_name.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let token = AccessToken::new(&api_key, &api_secret)
|
||||
.with_identity(participant_name)
|
||||
.with_video_grant(grant)
|
||||
.to_jwt()
|
||||
.unwrap();
|
||||
|
||||
token
|
||||
}
|
||||
```
|
139
examples/meet/server/src/main.rs
Normal file
139
examples/meet/server/src/main.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
use jsonwebtoken::{encode, Header, EncodingKey, Algorithm};
|
||||
use chrono::{Utc, Duration};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConnectionRequest {
|
||||
#[serde(rename = "roomName")]
|
||||
room_name: String,
|
||||
#[serde(rename = "participantName")]
|
||||
participant_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ConnectionDetails {
|
||||
server_url: String,
|
||||
participant_token: String,
|
||||
}
|
||||
|
||||
async fn connection_details(
|
||||
Query(params): Query<ConnectionRequest>,
|
||||
) -> Result<Json<ConnectionDetails>, StatusCode> {
|
||||
println!("Connection request: room={}, participant={}",
|
||||
params.room_name, params.participant_name);
|
||||
|
||||
// For demo purposes, use a mock LiveKit server URL
|
||||
// In production, you would:
|
||||
// 1. Validate the room and participant
|
||||
// 2. Generate a proper JWT token with LiveKit API key/secret
|
||||
// 3. Return your actual LiveKit server URL
|
||||
|
||||
let token = match generate_livekit_token(¶ms.room_name, ¶ms.participant_name) {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
println!("Failed to generate token: {}", e);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
let details = ConnectionDetails {
|
||||
server_url: std::env::var("LIVEKIT_URL")
|
||||
.unwrap_or_else(|_| "wss://meet-demo.livekit.cloud".to_string()),
|
||||
participant_token: token,
|
||||
};
|
||||
|
||||
Ok(Json(details))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LiveKitClaims {
|
||||
iss: String,
|
||||
sub: String,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
video: VideoGrant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct VideoGrant {
|
||||
#[serde(rename = "roomJoin")]
|
||||
room_join: bool,
|
||||
room: String,
|
||||
}
|
||||
|
||||
fn generate_livekit_token(room_name: &str, participant_name: &str) -> Result<String, String> {
|
||||
// For demo purposes - you should set these as environment variables
|
||||
let api_key = std::env::var("LIVEKIT_API_KEY")
|
||||
.unwrap_or_else(|_| "devkey".to_string());
|
||||
let api_secret = std::env::var("LIVEKIT_API_SECRET")
|
||||
.unwrap_or_else(|_| "secret".to_string());
|
||||
|
||||
println!("Generating token with:");
|
||||
println!(" API Key: {}", api_key);
|
||||
println!(" API Secret: {} (length: {})",
|
||||
if api_secret.len() > 10 { format!("{}...", &api_secret[..10]) } else { api_secret.clone() },
|
||||
api_secret.len());
|
||||
println!(" Room: {}", room_name);
|
||||
println!(" Participant: {}", participant_name);
|
||||
|
||||
let now = Utc::now();
|
||||
let exp = now + Duration::hours(6); // Token valid for 6 hours
|
||||
|
||||
let claims = LiveKitClaims {
|
||||
iss: api_key.clone(),
|
||||
sub: participant_name.to_string(),
|
||||
iat: now.timestamp(),
|
||||
exp: exp.timestamp(),
|
||||
video: VideoGrant {
|
||||
room_join: true,
|
||||
room: room_name.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let header = Header::new(Algorithm::HS256);
|
||||
let encoding_key = EncodingKey::from_secret(api_secret.as_ref());
|
||||
|
||||
encode(&header, &claims, &encoding_key)
|
||||
.map_err(|e| format!("Failed to generate token: {}", e))
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("Starting LiveKit Meet server on http://localhost:8083");
|
||||
|
||||
// Log environment variables for debugging
|
||||
println!("Environment variables:");
|
||||
println!(" LIVEKIT_URL: {:?}", std::env::var("LIVEKIT_URL"));
|
||||
println!(" LIVEKIT_API_KEY: {:?}", std::env::var("LIVEKIT_API_KEY"));
|
||||
println!(" LIVEKIT_API_SECRET: {:?}", std::env::var("LIVEKIT_API_SECRET"));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/connection-details", get(connection_details))
|
||||
.route("/health", get(health))
|
||||
// Serve static files from the dist directory
|
||||
.nest_service("/", ServeDir::new("../dist"))
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8083")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
println!("Server running on http://localhost:8083");
|
||||
println!("API endpoint: http://localhost:8083/api/connection-details");
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
31
examples/meet/src/app.rs
Normal file
31
examples/meet/src/app.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
use crate::pages::{HomePage, RoomPage, CustomPage};
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
#[at("/")]
|
||||
Home,
|
||||
#[at("/rooms/:room_name")]
|
||||
Room { room_name: String },
|
||||
#[at("/custom")]
|
||||
Custom,
|
||||
}
|
||||
|
||||
pub fn switch(routes: Route) -> Html {
|
||||
match routes {
|
||||
Route::Home => html! { <HomePage /> },
|
||||
Route::Room { room_name } => html! { <RoomPage room_name={room_name} /> },
|
||||
Route::Custom => html! { <CustomPage /> },
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(App)]
|
||||
pub fn app() -> Html {
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
<Switch<Route> render={switch} />
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
115
examples/meet/src/components/chat.rs
Normal file
115
examples/meet/src/components/chat.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlTextAreaElement;
|
||||
|
||||
use crate::pages::room::ChatMessage;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ChatSidebarProps {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub on_send_message: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component(ChatSidebar)]
|
||||
pub fn chat_sidebar(props: &ChatSidebarProps) -> Html {
|
||||
let input_ref = use_node_ref();
|
||||
let message_input = use_state(|| String::new());
|
||||
|
||||
let on_input_change = {
|
||||
let message_input = message_input.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlTextAreaElement = e.target_unchecked_into();
|
||||
message_input.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_send = {
|
||||
let message_input = message_input.clone();
|
||||
let on_send_message = props.on_send_message.clone();
|
||||
let input_ref = input_ref.clone();
|
||||
Callback::from(move |e: KeyboardEvent| {
|
||||
if e.key() == "Enter" && !e.shift_key() {
|
||||
e.prevent_default();
|
||||
let content = (*message_input).trim().to_string();
|
||||
if !content.is_empty() {
|
||||
on_send_message.emit(content);
|
||||
message_input.set(String::new());
|
||||
if let Some(input) = input_ref.cast::<HtmlTextAreaElement>() {
|
||||
input.set_value("");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_send_button = {
|
||||
let message_input = message_input.clone();
|
||||
let on_send_message = props.on_send_message.clone();
|
||||
let input_ref = input_ref.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let content = (*message_input).trim().to_string();
|
||||
if !content.is_empty() {
|
||||
on_send_message.emit(content);
|
||||
message_input.set(String::new());
|
||||
if let Some(input) = input_ref.cast::<HtmlTextAreaElement>() {
|
||||
input.set_value("");
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="chat-sidebar">
|
||||
<div class="chat-header">
|
||||
{"Chat"}
|
||||
</div>
|
||||
|
||||
<div class="chat-messages">
|
||||
{for props.messages.iter().map(|message| {
|
||||
html! {
|
||||
<div key={message.id.clone()} class="chat-message">
|
||||
<div class="chat-message-author">
|
||||
{&message.author}
|
||||
<span style="margin-left: 0.5rem; font-size: 0.75rem; color: var(--lk-fg-2);">
|
||||
{message.timestamp.format("%H:%M").to_string()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="chat-message-content">
|
||||
{&message.content}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{if props.messages.is_empty() {
|
||||
html! {
|
||||
<div style="text-align: center; color: var(--lk-fg-2); margin-top: 2rem;">
|
||||
{"No messages yet. Start the conversation!"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<textarea
|
||||
ref={input_ref}
|
||||
class="chat-input"
|
||||
placeholder="Type a message..."
|
||||
value={(*message_input).clone()}
|
||||
oninput={on_input_change}
|
||||
onkeydown={on_send}
|
||||
rows="2"
|
||||
/>
|
||||
<button
|
||||
class="primary-button"
|
||||
style="margin-top: 0.5rem; padding: 0.5rem 1rem;"
|
||||
onclick={on_send_button}
|
||||
disabled={message_input.trim().is_empty()}
|
||||
>
|
||||
{"Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
92
examples/meet/src/components/controls.rs
Normal file
92
examples/meet/src/components/controls.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MediaControlsProps {
|
||||
pub audio_enabled: bool,
|
||||
pub video_enabled: bool,
|
||||
pub screen_share_enabled: bool,
|
||||
pub on_toggle_audio: Callback<()>,
|
||||
pub on_toggle_video: Callback<()>,
|
||||
pub on_toggle_screen_share: Callback<()>,
|
||||
pub on_toggle_chat: Callback<()>,
|
||||
pub on_toggle_settings: Callback<()>,
|
||||
pub on_leave_room: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(MediaControls)]
|
||||
pub fn media_controls(props: &MediaControlsProps) -> Html {
|
||||
html! {
|
||||
<div class="room-controls">
|
||||
// Audio toggle
|
||||
<button
|
||||
class={classes!(
|
||||
"control-button",
|
||||
if props.audio_enabled { "active" } else { "" }
|
||||
)}
|
||||
onclick={props.on_toggle_audio.reform(|_| ())}
|
||||
title={if props.audio_enabled { "Mute microphone" } else { "Unmute microphone" }}
|
||||
>
|
||||
{if props.audio_enabled {
|
||||
html! { "🎤" }
|
||||
} else {
|
||||
html! { "🔇" }
|
||||
}}
|
||||
</button>
|
||||
|
||||
// Video toggle
|
||||
<button
|
||||
class={classes!(
|
||||
"control-button",
|
||||
if props.video_enabled { "active" } else { "" }
|
||||
)}
|
||||
onclick={props.on_toggle_video.reform(|_| ())}
|
||||
title={if props.video_enabled { "Turn off camera" } else { "Turn on camera" }}
|
||||
>
|
||||
{if props.video_enabled {
|
||||
html! { "📹" }
|
||||
} else {
|
||||
html! { "📷" }
|
||||
}}
|
||||
</button>
|
||||
|
||||
// Screen share toggle
|
||||
<button
|
||||
class={classes!(
|
||||
"control-button",
|
||||
if props.screen_share_enabled { "active" } else { "" }
|
||||
)}
|
||||
onclick={props.on_toggle_screen_share.reform(|_| ())}
|
||||
title={if props.screen_share_enabled { "Stop screen share" } else { "Share screen" }}
|
||||
>
|
||||
{"🖥️"}
|
||||
</button>
|
||||
|
||||
// Chat toggle
|
||||
<button
|
||||
class="control-button"
|
||||
onclick={props.on_toggle_chat.reform(|_| ())}
|
||||
title="Toggle chat"
|
||||
>
|
||||
{"💬"}
|
||||
</button>
|
||||
|
||||
// Settings toggle
|
||||
<button
|
||||
class="control-button"
|
||||
onclick={props.on_toggle_settings.reform(|_| ())}
|
||||
title="Settings"
|
||||
>
|
||||
{"⚙️"}
|
||||
</button>
|
||||
|
||||
// Leave room
|
||||
<button
|
||||
class="control-button danger"
|
||||
onclick={props.on_leave_room.reform(|_| ())}
|
||||
title="Leave room"
|
||||
>
|
||||
{"📞"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
11
examples/meet/src/components/mod.rs
Normal file
11
examples/meet/src/components/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod video_tile;
|
||||
pub mod chat;
|
||||
pub mod settings_menu;
|
||||
pub mod controls;
|
||||
pub mod prejoin;
|
||||
|
||||
pub use video_tile::VideoTile;
|
||||
pub use chat::ChatSidebar;
|
||||
pub use settings_menu::SettingsMenu;
|
||||
pub use controls::MediaControls;
|
||||
pub use prejoin::PreJoin;
|
445
examples/meet/src/components/prejoin.rs
Normal file
445
examples/meet/src/components/prejoin.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlVideoElement, MediaDevices, MediaStreamConstraints};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use js_sys::Object;
|
||||
|
||||
use crate::pages::room::LocalUserChoices;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PreJoinProps {
|
||||
pub defaults: LocalUserChoices,
|
||||
pub on_submit: Callback<LocalUserChoices>,
|
||||
pub on_error: Callback<String>,
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
#[function_component(PreJoin)]
|
||||
pub fn prejoin(props: &PreJoinProps) -> Html {
|
||||
let username = use_state(|| props.defaults.username.clone());
|
||||
let video_enabled = use_state(|| props.defaults.video_enabled);
|
||||
let audio_enabled = use_state(|| props.defaults.audio_enabled);
|
||||
let video_devices = use_state(|| Vec::<(String, String)>::new()); // (id, label)
|
||||
let audio_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let selected_video_device = use_state(|| props.defaults.video_device_id.clone());
|
||||
let selected_audio_device = use_state(|| props.defaults.audio_device_id.clone());
|
||||
let preview_stream = use_state(|| None::<web_sys::MediaStream>);
|
||||
let video_ref = use_node_ref();
|
||||
|
||||
|
||||
// Load available devices on mount
|
||||
use_effect_with((), {
|
||||
let video_devices = video_devices.clone();
|
||||
let audio_devices = audio_devices.clone();
|
||||
let on_error = props.on_error.clone();
|
||||
|
||||
move |_| {
|
||||
spawn_local(async move {
|
||||
match get_media_devices().await {
|
||||
Ok((video_devs, audio_devs)) => {
|
||||
video_devices.set(video_devs);
|
||||
audio_devices.set(audio_devs);
|
||||
}
|
||||
Err(e) => {
|
||||
on_error.emit(format!("Failed to get media devices: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
// Setup preview stream when video is enabled
|
||||
use_effect_with((*video_enabled, selected_video_device.clone()), {
|
||||
let preview_stream = preview_stream.clone();
|
||||
let on_error = props.on_error.clone();
|
||||
|
||||
move |(enabled, device_id)| {
|
||||
if *enabled {
|
||||
let preview_stream = preview_stream.clone();
|
||||
let device_id = device_id.clone();
|
||||
let on_error = on_error.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match get_user_media((*device_id).clone(), None).await {
|
||||
Ok(stream) => {
|
||||
preview_stream.set(Some(stream));
|
||||
}
|
||||
Err(e) => {
|
||||
on_error.emit(format!("Failed to access camera: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Stop existing stream
|
||||
if let Some(stream) = &*preview_stream {
|
||||
let tracks = stream.get_tracks();
|
||||
for i in 0..tracks.length() {
|
||||
let track_js = tracks.get(i);
|
||||
if let Ok(track) = track_js.dyn_into::<web_sys::MediaStreamTrack>() {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
preview_stream.set(None);
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
// Assign stream to video element when stream changes
|
||||
use_effect_with(preview_stream.clone(), {
|
||||
let video_ref = video_ref.clone();
|
||||
|
||||
move |stream| {
|
||||
if let Some(video_element) = video_ref.cast::<HtmlVideoElement>() {
|
||||
if let Some(stream) = stream.as_ref() {
|
||||
video_element.set_src_object(Some(stream));
|
||||
let _ = video_element.play();
|
||||
} else {
|
||||
video_element.set_src_object(None);
|
||||
}
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
let on_username_change = {
|
||||
let username = username.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
username.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_video_toggle = {
|
||||
let video_enabled = video_enabled.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
video_enabled.set(input.checked());
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_toggle = {
|
||||
let audio_enabled = audio_enabled.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
audio_enabled.set(input.checked());
|
||||
})
|
||||
};
|
||||
|
||||
let on_video_device_change = {
|
||||
let selected_video_device = selected_video_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let value = if select.value().is_empty() { None } else { Some(select.value()) };
|
||||
selected_video_device.set(value);
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_device_change = {
|
||||
let selected_audio_device = selected_audio_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let value = if select.value().is_empty() { None } else { Some(select.value()) };
|
||||
selected_audio_device.set(value);
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let username = username.clone();
|
||||
let video_enabled = video_enabled.clone();
|
||||
let audio_enabled = audio_enabled.clone();
|
||||
let selected_video_device = selected_video_device.clone();
|
||||
let selected_audio_device = selected_audio_device.clone();
|
||||
let on_submit = props.on_submit.clone();
|
||||
let on_error = props.on_error.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if username.trim().is_empty() {
|
||||
on_error.emit("Please enter your name".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let choices = LocalUserChoices {
|
||||
username: username.trim().to_string(),
|
||||
video_enabled: *video_enabled,
|
||||
audio_enabled: *audio_enabled,
|
||||
video_device_id: (*selected_video_device).clone(),
|
||||
audio_device_id: (*selected_audio_device).clone(),
|
||||
};
|
||||
|
||||
on_submit.emit(choices);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="prejoin-card">
|
||||
<h2 style="margin-bottom: 2rem; text-align: center;">{"Join Meeting"}</h2>
|
||||
|
||||
<div class="prejoin-preview">
|
||||
{if *video_enabled && preview_stream.is_some() {
|
||||
html! {
|
||||
<video
|
||||
ref={video_ref.clone()}
|
||||
autoplay=true
|
||||
muted=true
|
||||
playsinline=true
|
||||
style="width: 100%; height: 100%; object-fit: cover;"
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--lk-fg-2);">
|
||||
{if *video_enabled {
|
||||
"Loading camera..."
|
||||
} else {
|
||||
"Camera off"
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label for="username">{"Your name"}</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={(*username).clone()}
|
||||
onchange={on_username_change}
|
||||
placeholder="Enter your name"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="prejoin-controls">
|
||||
<div class="checkbox-group">
|
||||
<input
|
||||
id="video-enabled"
|
||||
type="checkbox"
|
||||
checked={*video_enabled}
|
||||
onchange={on_video_toggle}
|
||||
/>
|
||||
<label for="video-enabled">{"Camera"}</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input
|
||||
id="audio-enabled"
|
||||
type="checkbox"
|
||||
checked={*audio_enabled}
|
||||
onchange={on_audio_toggle}
|
||||
/>
|
||||
<label for="audio-enabled">{"Microphone"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if !video_devices.is_empty() {
|
||||
html! {
|
||||
<div class="form-group">
|
||||
<label for="video-device">{"Camera"}</label>
|
||||
<select
|
||||
id="video-device"
|
||||
class="device-select"
|
||||
onchange={on_video_device_change}
|
||||
value={selected_video_device.as_ref().unwrap_or(&String::new()).clone()}
|
||||
>
|
||||
<option value="">{"Default Camera"}</option>
|
||||
{for video_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if !audio_devices.is_empty() {
|
||||
html! {
|
||||
<div class="form-group">
|
||||
<label for="audio-device">{"Microphone"}</label>
|
||||
<select
|
||||
id="audio-device"
|
||||
class="device-select"
|
||||
onchange={on_audio_device_change}
|
||||
value={selected_audio_device.as_ref().unwrap_or(&String::new()).clone()}
|
||||
>
|
||||
<option value="">{"Default Microphone"}</option>
|
||||
{for audio_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="primary-button"
|
||||
disabled={props.loading}
|
||||
style="margin-top: 1.5rem;"
|
||||
>
|
||||
{if props.loading {
|
||||
html! {
|
||||
<>
|
||||
<span class="loading-spinner" style="margin-right: 0.5rem;"></span>
|
||||
{"Joining..."}
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! { "Join Meeting" }
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_media_devices() -> Result<(Vec<(String, String)>, Vec<(String, String)>), String> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator
|
||||
.media_devices()
|
||||
.map_err(|_| "MediaDevices not supported")?;
|
||||
|
||||
let promise = media_devices.enumerate_devices()
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
let devices = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
|
||||
let devices: js_sys::Array = devices.into();
|
||||
let mut video_devices = Vec::new();
|
||||
let mut audio_devices = Vec::new();
|
||||
|
||||
for i in 0..devices.length() {
|
||||
if let Some(device) = devices.get(i).dyn_into::<web_sys::MediaDeviceInfo>().ok() {
|
||||
let device_id = device.device_id();
|
||||
let label = device.label();
|
||||
let kind = device.kind();
|
||||
|
||||
match kind {
|
||||
web_sys::MediaDeviceKind::Videoinput => {
|
||||
video_devices.push((device_id, if label.is_empty() { "Camera".to_string() } else { label }));
|
||||
}
|
||||
web_sys::MediaDeviceKind::Audioinput => {
|
||||
audio_devices.push((device_id, if label.is_empty() { "Microphone".to_string() } else { label }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((video_devices, audio_devices))
|
||||
}
|
||||
|
||||
async fn get_user_media(video_device_id: Option<String>, audio_device_id: Option<String>) -> Result<web_sys::MediaStream, String> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator
|
||||
.media_devices()
|
||||
.map_err(|_| "MediaDevices not supported")?;
|
||||
|
||||
// First, check if devices are available
|
||||
log::info!("Checking available media devices...");
|
||||
match get_media_devices().await {
|
||||
Ok((video_devices, audio_devices)) => {
|
||||
log::info!("Found {} video devices and {} audio devices", video_devices.len(), audio_devices.len());
|
||||
if video_devices.is_empty() {
|
||||
return Err("No video devices found. Please connect a camera and refresh the page.".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not enumerate devices: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let constraints = web_sys::MediaStreamConstraints::new();
|
||||
|
||||
// Try different constraint strategies
|
||||
let video_constraints = if let Some(device_id) = video_device_id {
|
||||
log::info!("Using specific video device: {}", device_id);
|
||||
let video_constraints = Object::new();
|
||||
js_sys::Reflect::set(&video_constraints, &"deviceId".into(), &device_id.into())
|
||||
.map_err(|_| "Failed to set video device constraint")?;
|
||||
video_constraints
|
||||
} else {
|
||||
log::info!("Using default video constraints");
|
||||
// Try with minimal constraints first
|
||||
let video_constraints = Object::new();
|
||||
js_sys::Reflect::set(&video_constraints, &"width".into(), &320.into())
|
||||
.map_err(|_| "Failed to set video width")?;
|
||||
js_sys::Reflect::set(&video_constraints, &"height".into(), &240.into())
|
||||
.map_err(|_| "Failed to set video height")?;
|
||||
video_constraints
|
||||
};
|
||||
|
||||
constraints.set_video(&video_constraints.into());
|
||||
|
||||
// Set audio constraints
|
||||
if let Some(device_id) = audio_device_id {
|
||||
let audio_constraints = Object::new();
|
||||
js_sys::Reflect::set(&audio_constraints, &"deviceId".into(), &device_id.into())
|
||||
.map_err(|_| "Failed to set audio device constraint")?;
|
||||
constraints.set_audio(&audio_constraints.into());
|
||||
} else {
|
||||
constraints.set_audio(&true.into());
|
||||
}
|
||||
|
||||
log::info!("Requesting user media with constraints");
|
||||
|
||||
// Try getUserMedia with fallback strategies
|
||||
match try_get_user_media(&media_devices, &constraints).await {
|
||||
Ok(stream) => {
|
||||
log::info!("Successfully obtained media stream");
|
||||
Ok(stream)
|
||||
}
|
||||
Err(first_error) => {
|
||||
log::warn!("First attempt failed: {}", first_error);
|
||||
|
||||
// Fallback: Try with just video, no specific constraints
|
||||
log::info!("Trying fallback: basic video only");
|
||||
let fallback_constraints = web_sys::MediaStreamConstraints::new();
|
||||
fallback_constraints.set_video(&true.into());
|
||||
fallback_constraints.set_audio(&false.into());
|
||||
|
||||
match try_get_user_media(&media_devices, &fallback_constraints).await {
|
||||
Ok(stream) => {
|
||||
log::info!("Fallback successful - got video-only stream");
|
||||
Ok(stream)
|
||||
}
|
||||
Err(second_error) => {
|
||||
log::error!("All attempts failed. First error: {}, Fallback error: {}", first_error, second_error);
|
||||
Err(format!("Camera access failed. Please check:\n1. Camera is connected and not in use\n2. Browser permissions are granted\n3. Page is served over HTTPS\n\nError details: {}", first_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_get_user_media(media_devices: &web_sys::MediaDevices, constraints: &web_sys::MediaStreamConstraints) -> Result<web_sys::MediaStream, String> {
|
||||
let promise = media_devices.get_user_media_with_constraints(constraints)
|
||||
.map_err(|e| format!("Failed to create getUserMedia promise: {:?}", e))?;
|
||||
|
||||
let stream = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|e| format!("getUserMedia failed: {:?}", e))?;
|
||||
|
||||
Ok(stream.into())
|
||||
}
|
222
examples/meet/src/components/settings_menu.rs
Normal file
222
examples/meet/src/components/settings_menu.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum SettingsTab {
|
||||
Media,
|
||||
Recording,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SettingsMenuProps {
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(SettingsMenu)]
|
||||
pub fn settings_menu(props: &SettingsMenuProps) -> Html {
|
||||
let active_tab = use_state(|| SettingsTab::Media);
|
||||
let video_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let audio_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let audio_output_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let selected_video_device = use_state(|| String::new());
|
||||
let selected_audio_device = use_state(|| String::new());
|
||||
let selected_audio_output = use_state(|| String::new());
|
||||
|
||||
// Load devices on mount
|
||||
use_effect_with((), {
|
||||
let video_devices = video_devices.clone();
|
||||
let audio_devices = audio_devices.clone();
|
||||
let audio_output_devices = audio_output_devices.clone();
|
||||
|
||||
move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Ok((video_devs, audio_devs, output_devs)) = get_all_media_devices().await {
|
||||
video_devices.set(video_devs);
|
||||
audio_devices.set(audio_devs);
|
||||
audio_output_devices.set(output_devs);
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
let on_tab_change = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |tab: SettingsTab| {
|
||||
active_tab.set(tab);
|
||||
})
|
||||
};
|
||||
|
||||
let on_video_device_change = {
|
||||
let selected_video_device = selected_video_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
selected_video_device.set(select.value());
|
||||
// TODO: Update LiveKit video device
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_device_change = {
|
||||
let selected_audio_device = selected_audio_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
selected_audio_device.set(select.value());
|
||||
// TODO: Update LiveKit audio device
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_output_change = {
|
||||
let selected_audio_output = selected_audio_output.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
selected_audio_output.set(select.value());
|
||||
// TODO: Update LiveKit audio output device
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="settings-menu">
|
||||
<div class="settings-tabs">
|
||||
<button
|
||||
class={classes!("settings-tab", (*active_tab == SettingsTab::Media).then(|| "active"))}
|
||||
onclick={on_tab_change.reform(|_| SettingsTab::Media)}
|
||||
>
|
||||
{"Media Devices"}
|
||||
</button>
|
||||
<button
|
||||
class={classes!("settings-tab", (*active_tab == SettingsTab::Recording).then(|| "active"))}
|
||||
onclick={on_tab_change.reform(|_| SettingsTab::Recording)}
|
||||
>
|
||||
{"Recording"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
{match *active_tab {
|
||||
SettingsTab::Media => html! {
|
||||
<>
|
||||
<div class="settings-section">
|
||||
<h3>{"Camera"}</h3>
|
||||
<select
|
||||
class="device-select"
|
||||
value={(*selected_video_device).clone()}
|
||||
onchange={on_video_device_change}
|
||||
>
|
||||
<option value="">{"Default Camera"}</option>
|
||||
{for video_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>{"Microphone"}</h3>
|
||||
<select
|
||||
class="device-select"
|
||||
value={(*selected_audio_device).clone()}
|
||||
onchange={on_audio_device_change}
|
||||
>
|
||||
<option value="">{"Default Microphone"}</option>
|
||||
{for audio_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>{"Speaker & Headphones"}</h3>
|
||||
<select
|
||||
class="device-select"
|
||||
value={(*selected_audio_output).clone()}
|
||||
onchange={on_audio_output_change}
|
||||
>
|
||||
<option value="">{"Default Speaker"}</option>
|
||||
{for audio_output_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
},
|
||||
SettingsTab::Recording => html! {
|
||||
<div class="settings-section">
|
||||
<h3>{"Record Meeting"}</h3>
|
||||
<p>{"Recording functionality will be available when connected to a LiveKit server with recording enabled."}</p>
|
||||
<button
|
||||
class="primary-button"
|
||||
disabled=true
|
||||
style="opacity: 0.5;"
|
||||
>
|
||||
{"Start Recording"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; width: 100%; margin-top: 1.5rem;">
|
||||
<button
|
||||
class="primary-button"
|
||||
onclick={props.on_close.reform(|_| ())}
|
||||
>
|
||||
{"Close"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_all_media_devices() -> Result<(Vec<(String, String)>, Vec<(String, String)>, Vec<(String, String)>), String> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator
|
||||
.media_devices()
|
||||
.map_err(|_| "MediaDevices not supported")?;
|
||||
|
||||
let promise = media_devices.enumerate_devices()
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
let devices = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
|
||||
let devices: js_sys::Array = devices.into();
|
||||
let mut video_devices = Vec::new();
|
||||
let mut audio_devices = Vec::new();
|
||||
let mut audio_output_devices = Vec::new();
|
||||
|
||||
for i in 0..devices.length() {
|
||||
if let Some(device) = devices.get(i).dyn_into::<web_sys::MediaDeviceInfo>().ok() {
|
||||
let device_id = device.device_id();
|
||||
let label = device.label();
|
||||
let kind = device.kind();
|
||||
|
||||
match kind {
|
||||
web_sys::MediaDeviceKind::Videoinput => {
|
||||
video_devices.push((device_id, if label.is_empty() { "Camera".to_string() } else { label }));
|
||||
}
|
||||
web_sys::MediaDeviceKind::Audioinput => {
|
||||
audio_devices.push((device_id, if label.is_empty() { "Microphone".to_string() } else { label }));
|
||||
}
|
||||
web_sys::MediaDeviceKind::Audiooutput => {
|
||||
audio_output_devices.push((device_id, if label.is_empty() { "Speaker".to_string() } else { label }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((video_devices, audio_devices, audio_output_devices))
|
||||
}
|
161
examples/meet/src/components/video_tile.rs
Normal file
161
examples/meet/src/components/video_tile.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlVideoElement, MediaStream, MediaStreamConstraints};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::pages::room::Participant;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct VideoTileProps {
|
||||
pub participant: Participant,
|
||||
}
|
||||
|
||||
#[function_component(VideoTile)]
|
||||
pub fn video_tile(props: &VideoTileProps) -> Html {
|
||||
let video_ref = use_node_ref();
|
||||
let participant = &props.participant;
|
||||
|
||||
// Initialize video element and capture media
|
||||
use_effect_with(participant.clone(), {
|
||||
let video_ref = video_ref.clone();
|
||||
move |participant| {
|
||||
if let Some(video_element) = video_ref.cast::<HtmlVideoElement>() {
|
||||
if participant.is_local && participant.video_enabled {
|
||||
// Set up local video stream using getUserMedia
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match get_user_media().await {
|
||||
Ok(stream) => {
|
||||
video_element.set_src_object(Some(&stream));
|
||||
web_sys::console::log_1(&"Local video stream set up successfully".into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to get user media: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if !participant.is_local && participant.video_enabled {
|
||||
// For remote participants, attach actual LiveKit video tracks
|
||||
web_sys::console::log_1(&format!("Setting up remote video for participant: {}", participant.id).into());
|
||||
// Note: Track attachment will be handled by the room page when tracks are available
|
||||
// This is just a placeholder for the video element
|
||||
}
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="video-tile">
|
||||
// Always render video element for remote participants to allow track attachment
|
||||
<video
|
||||
id={format!("video-{}", participant.id)}
|
||||
ref={video_ref}
|
||||
autoplay=true
|
||||
muted={participant.is_local}
|
||||
playsinline=true
|
||||
class="video-element"
|
||||
style={if !participant.video_enabled { "display: none;" } else { "" }}
|
||||
/>
|
||||
|
||||
{if !participant.video_enabled {
|
||||
html! {
|
||||
<div class="video-placeholder">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
<span>{"Video disabled"}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<div class="video-overlay">
|
||||
<div class="participant-name">
|
||||
{&participant.name}
|
||||
{if participant.is_local {
|
||||
html! { <span>{" (You)"}</span> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="participant-status">
|
||||
{if !participant.audio_enabled {
|
||||
html! {
|
||||
<div class="status-indicator muted" title="Microphone muted">
|
||||
{"🔇"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if !participant.video_enabled {
|
||||
html! {
|
||||
<div class="status-indicator" title="Camera off">
|
||||
{"📷"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if participant.screen_share_enabled {
|
||||
html! {
|
||||
<div class="status-indicator" title="Screen sharing">
|
||||
{"🖥️"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_media() -> Result<MediaStream, JsValue> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator.media_devices()?;
|
||||
|
||||
let constraints = MediaStreamConstraints::new();
|
||||
constraints.set_video(&JsValue::from(true));
|
||||
constraints.set_audio(&JsValue::from(true));
|
||||
|
||||
// Try getUserMedia with fallback strategies
|
||||
match try_get_user_media(&media_devices, &constraints).await {
|
||||
Ok(stream) => {
|
||||
web_sys::console::log_1(&"Successfully obtained media stream".into());
|
||||
Ok(stream)
|
||||
}
|
||||
Err(first_error) => {
|
||||
web_sys::console::warn_1(&format!("First attempt failed: {:?}", first_error).into());
|
||||
|
||||
// Fallback: Try with just video, no audio
|
||||
web_sys::console::log_1(&"Trying fallback: basic video only".into());
|
||||
let fallback_constraints = MediaStreamConstraints::new();
|
||||
fallback_constraints.set_video(&JsValue::from(true));
|
||||
fallback_constraints.set_audio(&JsValue::from(false));
|
||||
|
||||
match try_get_user_media(&media_devices, &fallback_constraints).await {
|
||||
Ok(stream) => {
|
||||
web_sys::console::log_1(&"Fallback successful - got video-only stream".into());
|
||||
Ok(stream)
|
||||
}
|
||||
Err(second_error) => {
|
||||
web_sys::console::error_1(&format!("All attempts failed. First error: {:?}, Fallback error: {:?}", first_error, second_error).into());
|
||||
Err(JsValue::from_str(&format!("Camera access failed: {:?}", first_error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_get_user_media(media_devices: &web_sys::MediaDevices, constraints: &MediaStreamConstraints) -> Result<MediaStream, JsValue> {
|
||||
let promise = media_devices.get_user_media_with_constraints(constraints)?;
|
||||
let js_value = JsFuture::from(promise).await?;
|
||||
let media_stream: MediaStream = js_value.dyn_into()?;
|
||||
Ok(media_stream)
|
||||
}
|
||||
|
185
examples/meet/src/livekit_client.rs
Normal file
185
examples/meet/src/livekit_client.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{WebSocket, MessageEvent, CloseEvent, Event, BinaryType, ErrorEvent};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
use yew::UseStateHandle;
|
||||
use crate::livekit_protocol::LiveKitProtocolHandler;
|
||||
// Removed circular dependency - types moved to separate module if needed
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LiveKitClient {
|
||||
pub participants: HashMap<String, RemoteParticipant>,
|
||||
pub local_participant: Option<LocalParticipant>,
|
||||
pub room_name: String,
|
||||
pub connected: bool,
|
||||
pub websocket: Option<WebSocket>,
|
||||
pub protocol_handler: Rc<RefCell<LiveKitProtocolHandler>>,
|
||||
pub event_callback: Option<Rc<dyn Fn(crate::livekit_protocol::LiveKitEvent)>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteParticipant {
|
||||
pub sid: String,
|
||||
pub identity: String,
|
||||
pub name: String,
|
||||
pub audio_enabled: bool,
|
||||
pub video_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalParticipant {
|
||||
pub identity: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LiveKitEvent {
|
||||
ParticipantConnected(RemoteParticipant),
|
||||
ParticipantDisconnected(String),
|
||||
TrackSubscribed { participant_sid: String, track_kind: String },
|
||||
TrackUnsubscribed { participant_sid: String, track_kind: String },
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
impl LiveKitClient {
|
||||
pub fn new(room_name: String) -> Self {
|
||||
Self {
|
||||
participants: HashMap::new(),
|
||||
local_participant: None,
|
||||
room_name,
|
||||
connected: false,
|
||||
websocket: None,
|
||||
protocol_handler: Rc::new(RefCell::new(LiveKitProtocolHandler::new())),
|
||||
event_callback: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_event_callback<F>(&mut self, callback: F)
|
||||
where
|
||||
F: Fn(crate::livekit_protocol::LiveKitEvent) + 'static,
|
||||
{
|
||||
self.event_callback = Some(Rc::new(callback));
|
||||
}
|
||||
|
||||
pub async fn connect(&mut self, url: &str, token: &str) -> Result<(), String> {
|
||||
web_sys::console::log_1(&format!("Connecting to LiveKit: {}", url).into());
|
||||
|
||||
// Create WebSocket URL for LiveKit
|
||||
let ws_url = if url.starts_with("wss://") || url.starts_with("ws://") {
|
||||
url.to_string()
|
||||
} else if url.starts_with("https://") {
|
||||
url.replace("https://", "wss://")
|
||||
} else if url.starts_with("http://") {
|
||||
url.replace("http://", "ws://")
|
||||
} else {
|
||||
format!("wss://{}", url)
|
||||
};
|
||||
|
||||
let ws_url = format!("{}/rtc?access_token={}", ws_url, token);
|
||||
web_sys::console::log_1(&format!("Connecting to LiveKit WebSocket: {}", ws_url).into());
|
||||
web_sys::console::log_1(&format!("Server URL: {}, Token: {}", url, token).into());
|
||||
|
||||
let ws = WebSocket::new(&ws_url)
|
||||
.map_err(|e| format!("Failed to create WebSocket: {:?}", e))?;
|
||||
|
||||
// Set binary type for protobuf messages
|
||||
ws.set_binary_type(web_sys::BinaryType::Arraybuffer);
|
||||
|
||||
let ws_clone = ws.clone();
|
||||
let onopen_callback = Closure::wrap(Box::new(move |_event: Event| {
|
||||
web_sys::console::log_1(&"LiveKit WebSocket connected, sending join request".into());
|
||||
|
||||
// Send join request immediately after connection
|
||||
match crate::livekit_protocol::LiveKitProtocolHandler::create_join_request("room", "user", "User") {
|
||||
Ok(join_data) => {
|
||||
if let Err(e) = ws_clone.send_with_u8_array(&join_data) {
|
||||
web_sys::console::error_1(&format!("Failed to send join request: {:?}", e).into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Join request sent successfully".into());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to create join request: {}", e).into());
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
|
||||
onopen_callback.forget();
|
||||
|
||||
let protocol_handler = self.protocol_handler.clone();
|
||||
let event_callback = self.event_callback.clone();
|
||||
|
||||
let onmessage_callback = Closure::wrap(Box::new(move |event: MessageEvent| {
|
||||
web_sys::console::log_1(&"LiveKit WebSocket message received".into());
|
||||
|
||||
if let Ok(array_buffer) = event.data().dyn_into::<js_sys::ArrayBuffer>() {
|
||||
let uint8_array = js_sys::Uint8Array::new(&array_buffer);
|
||||
let bytes = uint8_array.to_vec();
|
||||
|
||||
web_sys::console::log_1(&format!("Received {} bytes from LiveKit", bytes.len()).into());
|
||||
|
||||
// Parse protobuf message
|
||||
match protocol_handler.try_borrow_mut() {
|
||||
Ok(mut handler_ref) => {
|
||||
web_sys::console::log_1(&"Parsing protobuf message".into());
|
||||
if let Some(events) = handler_ref.handle_message(&bytes) {
|
||||
web_sys::console::log_1(&format!("Generated {} LiveKit events", events.len()).into());
|
||||
for event in events {
|
||||
web_sys::console::log_1(&format!("Processing event: {:?}", event).into());
|
||||
if let Some(callback) = event_callback.as_ref() {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
web_sys::console::log_1(&"Failed to parse protobuf message or no events generated".into());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
web_sys::console::error_1(&"Could not borrow protocol handler".into());
|
||||
}
|
||||
}
|
||||
} else if let Ok(text) = event.data().dyn_into::<js_sys::JsString>() {
|
||||
let text_str: String = text.into();
|
||||
web_sys::console::log_1(&format!("Received text message: {}", text_str).into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Received unknown message type".into());
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
|
||||
onmessage_callback.forget();
|
||||
|
||||
let onerror_callback = Closure::wrap(Box::new(move |event: ErrorEvent| {
|
||||
web_sys::console::error_1(&format!("LiveKit WebSocket error: {:?}", event.message()).into());
|
||||
web_sys::console::error_1(&format!("Error details: {:?}", event).into());
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
|
||||
onerror_callback.forget();
|
||||
|
||||
let onclose_callback = Closure::wrap(Box::new(move |event: CloseEvent| {
|
||||
web_sys::console::log_1(&format!("LiveKit WebSocket closed: code={}, reason={}, clean={}",
|
||||
event.code(), event.reason(), event.was_clean()).into());
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
|
||||
onclose_callback.forget();
|
||||
|
||||
self.websocket = Some(ws);
|
||||
self.connected = true;
|
||||
self.local_participant = Some(LocalParticipant {
|
||||
identity: "local_user".to_string(),
|
||||
name: "Local User".to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_participants(&self) -> Vec<RemoteParticipant> {
|
||||
self.participants.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
}
|
183
examples/meet/src/livekit_js_bindings.rs
Normal file
183
examples/meet/src/livekit_js_bindings.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlVideoElement;
|
||||
use js_sys::{Promise, Function};
|
||||
|
||||
// LiveKit Room bindings
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = "Room", js_namespace = LivekitClient)]
|
||||
pub type LiveKitRoom;
|
||||
|
||||
#[wasm_bindgen(constructor, js_namespace = LivekitClient, js_class = "Room")]
|
||||
pub fn new(options: &JsValue) -> LiveKitRoom;
|
||||
|
||||
#[wasm_bindgen(method, js_name = connect)]
|
||||
pub fn connect(this: &LiveKitRoom, url: &str, token: &str) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = disconnect)]
|
||||
pub fn disconnect(this: &LiveKitRoom);
|
||||
|
||||
#[wasm_bindgen(method, js_name = on)]
|
||||
pub fn on(this: &LiveKitRoom, event: &str, callback: &Function);
|
||||
|
||||
#[wasm_bindgen(method, js_name = publishTrack)]
|
||||
pub fn publish_track(this: &LiveKitRoom, track: &JsValue, options: &JsValue) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = startVideo)]
|
||||
pub fn start_video(this: &LiveKitRoom) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = startAudio)]
|
||||
pub fn start_audio(this: &LiveKitRoom) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = participants)]
|
||||
pub fn participants(this: &LiveKitRoom) -> js_sys::Map;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = audioTracks)]
|
||||
pub fn audio_tracks(this: &LiveKitRoom) -> js_sys::Map;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = videoTracks)]
|
||||
pub fn video_tracks(this: &LiveKitRoom) -> js_sys::Map;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = localParticipant)]
|
||||
pub fn local_participant(this: &LiveKitRoom) -> LocalParticipant;
|
||||
}
|
||||
|
||||
// LiveKit Participant bindings
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = "Participant", js_namespace = LivekitClient)]
|
||||
pub type Participant;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = sid)]
|
||||
pub fn sid(this: &Participant) -> String;
|
||||
|
||||
#[wasm_bindgen(method, getter)]
|
||||
pub fn identity(this: &Participant) -> String;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = name)]
|
||||
pub fn name(this: &Participant) -> Option<String>;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = videoTrackPublications)]
|
||||
pub fn video_tracks(this: &Participant) -> js_sys::Map;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = audioTrackPublications)]
|
||||
pub fn audio_tracks(this: &Participant) -> js_sys::Map;
|
||||
|
||||
#[wasm_bindgen(method, js_name = setCameraEnabled)]
|
||||
pub fn set_camera_enabled(this: &Participant, enabled: bool) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = setMicrophoneEnabled)]
|
||||
pub fn set_microphone_enabled(this: &Participant, enabled: bool) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = on)]
|
||||
fn on(this: &Participant, event: &str, callback: &Function);
|
||||
}
|
||||
|
||||
// LiveKit LocalParticipant bindings (extends Participant)
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = "LocalParticipant", js_namespace = LivekitClient)]
|
||||
pub type LocalParticipant;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = sid)]
|
||||
pub fn sid(this: &LocalParticipant) -> String;
|
||||
|
||||
#[wasm_bindgen(method, getter)]
|
||||
pub fn identity(this: &LocalParticipant) -> String;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = name)]
|
||||
pub fn name(this: &LocalParticipant) -> Option<String>;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = videoTrackPublications)]
|
||||
pub fn video_tracks(this: &LocalParticipant) -> js_sys::Map;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = audioTrackPublications)]
|
||||
pub fn audio_tracks(this: &LocalParticipant) -> js_sys::Map;
|
||||
|
||||
#[wasm_bindgen(method, js_name = setMicrophoneEnabled)]
|
||||
pub fn set_microphone_enabled(this: &LocalParticipant, enabled: bool) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = setCameraEnabled)]
|
||||
pub fn set_camera_enabled(this: &LocalParticipant, enabled: bool) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = setMicrophoneMuted)]
|
||||
pub fn set_microphone_muted(this: &LocalParticipant, muted: bool) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = setCameraMuted)]
|
||||
pub fn set_camera_muted(this: &LocalParticipant, muted: bool) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = setScreenShareEnabled)]
|
||||
pub fn set_screen_share_enabled(this: &LocalParticipant, enabled: bool) -> Promise;
|
||||
|
||||
#[wasm_bindgen(method, js_name = publishData)]
|
||||
pub fn publish_data(this: &LocalParticipant, data: &[u8], reliable: bool) -> Promise;
|
||||
}
|
||||
|
||||
// LiveKit Track bindings
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = "Track", js_namespace = LivekitClient)]
|
||||
pub type Track;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = sid)]
|
||||
pub fn sid(this: &Track) -> String;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = kind)]
|
||||
pub fn kind(this: &Track) -> String;
|
||||
|
||||
#[wasm_bindgen(method, js_name = attach)]
|
||||
pub fn attach(this: &Track, element: &HtmlVideoElement);
|
||||
|
||||
#[wasm_bindgen(method, js_name = attach)]
|
||||
pub fn attach_audio(this: &Track, element: &web_sys::HtmlAudioElement);
|
||||
|
||||
#[wasm_bindgen(method, js_name = detach)]
|
||||
pub fn detach(this: &Track, element: &HtmlVideoElement);
|
||||
|
||||
#[wasm_bindgen(method, js_name = detach)]
|
||||
pub fn detach_audio(this: &Track, element: &web_sys::HtmlAudioElement);
|
||||
}
|
||||
|
||||
// LiveKit TrackPublication bindings
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = "TrackPublication", js_namespace = LivekitClient)]
|
||||
pub type TrackPublication;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = track)]
|
||||
pub fn track(this: &TrackPublication) -> Option<Track>;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = trackSid)]
|
||||
pub fn track_sid(this: &TrackPublication) -> String;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = kind)]
|
||||
fn kind(this: &TrackPublication) -> String;
|
||||
|
||||
#[wasm_bindgen(method, getter, js_name = isSubscribed)]
|
||||
fn is_subscribed(this: &TrackPublication) -> bool;
|
||||
}
|
||||
|
||||
// LiveKit RoomEvent constants
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = ["LiveKitClient", "RoomEvent"], js_name = Connected)]
|
||||
static ROOM_EVENT_CONNECTED: JsValue;
|
||||
|
||||
#[wasm_bindgen(js_namespace = ["LiveKitClient", "RoomEvent"], js_name = Disconnected)]
|
||||
static ROOM_EVENT_DISCONNECTED: JsValue;
|
||||
|
||||
#[wasm_bindgen(js_namespace = ["LiveKitClient", "RoomEvent"], js_name = ParticipantConnected)]
|
||||
static ROOM_EVENT_PARTICIPANT_CONNECTED: JsValue;
|
||||
|
||||
#[wasm_bindgen(js_namespace = ["LiveKitClient", "RoomEvent"], js_name = ParticipantDisconnected)]
|
||||
static ROOM_EVENT_PARTICIPANT_DISCONNECTED: JsValue;
|
||||
|
||||
#[wasm_bindgen(js_namespace = ["LiveKitClient", "RoomEvent"], js_name = TrackSubscribed)]
|
||||
static ROOM_EVENT_TRACK_SUBSCRIBED: JsValue;
|
||||
|
||||
#[wasm_bindgen(js_namespace = ["LiveKitClient", "RoomEvent"], js_name = TrackUnsubscribed)]
|
||||
static ROOM_EVENT_TRACK_UNSUBSCRIBED: JsValue;
|
||||
}
|
||||
|
||||
|
850
examples/meet/src/livekit_js_client.rs
Normal file
850
examples/meet/src/livekit_js_client.rs
Normal file
@@ -0,0 +1,850 @@
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{console, HtmlVideoElement};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use js_sys::Object;
|
||||
use crate::livekit_js_bindings::{LiveKitRoom, Participant};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParticipantInfo {
|
||||
pub sid: String,
|
||||
pub identity: String,
|
||||
pub name: String,
|
||||
pub video_tracks: Vec<String>,
|
||||
pub audio_tracks: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LiveKitEvent {
|
||||
Connected,
|
||||
Disconnected,
|
||||
ParticipantConnected(ParticipantInfo),
|
||||
ParticipantDisconnected(String),
|
||||
TrackSubscribed {
|
||||
participant_sid: String,
|
||||
track_sid: String,
|
||||
kind: String,
|
||||
},
|
||||
TrackUnsubscribed {
|
||||
participant_sid: String,
|
||||
track_sid: String,
|
||||
},
|
||||
ChatMessageReceived {
|
||||
participant_sid: String,
|
||||
participant_name: String,
|
||||
message: String,
|
||||
timestamp: f64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LiveKitJsClient {
|
||||
room: Rc<RefCell<Option<LiveKitRoom>>>,
|
||||
event_callback: Option<Rc<dyn Fn(LiveKitEvent)>>,
|
||||
}
|
||||
|
||||
impl LiveKitJsClient {
|
||||
pub fn new() -> Self {
|
||||
let options = Object::new();
|
||||
let room = LiveKitRoom::new(&options.into());
|
||||
Self {
|
||||
room: Rc::new(RefCell::new(Some(room))),
|
||||
event_callback: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for event handling
|
||||
fn setup_event_handlers(&self, callback: Rc<dyn Fn(LiveKitEvent)>) {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
// Connected event
|
||||
let connected_callback = callback.clone();
|
||||
let connected_closure = Closure::wrap(Box::new(move |_: JsValue| {
|
||||
connected_callback(LiveKitEvent::Connected);
|
||||
}) as Box<dyn FnMut(JsValue)>);
|
||||
room.on("connected", connected_closure.as_ref().unchecked_ref());
|
||||
connected_closure.forget();
|
||||
|
||||
// Participant connected event
|
||||
let participant_connected_callback = callback.clone();
|
||||
let participant_connected_closure = Closure::wrap(Box::new(move |participant: JsValue| {
|
||||
if let Ok(participant) = participant.dyn_into::<Participant>() {
|
||||
let participant_info = ParticipantInfo {
|
||||
sid: participant.sid(),
|
||||
identity: participant.identity(),
|
||||
name: participant.name().unwrap_or_default(),
|
||||
video_tracks: Vec::new(),
|
||||
audio_tracks: Vec::new(),
|
||||
};
|
||||
participant_connected_callback(LiveKitEvent::ParticipantConnected(participant_info));
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue)>);
|
||||
room.on("participantConnected", participant_connected_closure.as_ref().unchecked_ref());
|
||||
participant_connected_closure.forget();
|
||||
|
||||
// Participant disconnected event
|
||||
let participant_disconnected_callback = callback.clone();
|
||||
let participant_disconnected_closure = Closure::wrap(Box::new(move |participant: JsValue| {
|
||||
if let Ok(participant) = participant.dyn_into::<Participant>() {
|
||||
participant_disconnected_callback(LiveKitEvent::ParticipantDisconnected(participant.sid()));
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue)>);
|
||||
room.on("participantDisconnected", participant_disconnected_closure.as_ref().unchecked_ref());
|
||||
participant_disconnected_closure.forget();
|
||||
|
||||
// Track published event (when any participant publishes a track)
|
||||
let track_published_callback = callback.clone();
|
||||
let track_published_closure = Closure::wrap(Box::new(move |publication: JsValue, participant: JsValue| {
|
||||
console::log_1(&"🎯 Track published event fired!".into());
|
||||
if let (Ok(_publication), Ok(participant)) = (
|
||||
publication.dyn_into::<crate::livekit_js_bindings::TrackPublication>(),
|
||||
participant.dyn_into::<crate::livekit_js_bindings::Participant>()
|
||||
) {
|
||||
console::log_1(&format!("📹 Track published by participant: {}", participant.sid()).into());
|
||||
// Force a participant update to refresh track lists
|
||||
track_published_callback(LiveKitEvent::ParticipantConnected(ParticipantInfo {
|
||||
sid: participant.sid(),
|
||||
identity: participant.identity(),
|
||||
name: participant.name().unwrap_or_default(),
|
||||
video_tracks: Vec::new(), // Will be populated by get_participants
|
||||
audio_tracks: Vec::new(),
|
||||
}));
|
||||
} else {
|
||||
console::warn_1(&"Failed to parse track publication event arguments".into());
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue, JsValue)>);
|
||||
room.on("trackPublished", track_published_closure.as_ref().unchecked_ref());
|
||||
track_published_closure.forget();
|
||||
|
||||
// Track subscribed event - attach video and audio directly in the event handler
|
||||
let track_subscribed_callback = callback.clone();
|
||||
let track_subscribed_closure = Closure::wrap(Box::new(move |track: JsValue, publication: JsValue, participant: JsValue| {
|
||||
console::log_1(&"🎯 Track subscribed event fired!".into());
|
||||
if let (Ok(track), Ok(_publication), Ok(participant)) = (
|
||||
track.dyn_into::<crate::livekit_js_bindings::Track>(),
|
||||
publication.dyn_into::<crate::livekit_js_bindings::TrackPublication>(),
|
||||
participant.dyn_into::<crate::livekit_js_bindings::Participant>()
|
||||
) {
|
||||
let kind = if track.kind() == "video" { "video" } else { "audio" };
|
||||
console::log_1(&format!("📹 Track subscribed: {} track {} for participant {}", kind, track.sid(), participant.sid()).into());
|
||||
|
||||
let participant_sid = participant.sid();
|
||||
let track_sid = track.sid();
|
||||
|
||||
if kind == "video" {
|
||||
console::log_1(&format!("🎬 Directly attaching video track {} for participant {}", track_sid, participant_sid).into());
|
||||
|
||||
// Clone track for async operation
|
||||
let track_clone = track.clone();
|
||||
spawn_local(async move {
|
||||
// Wait longer for the DOM to update and video element to be ready
|
||||
gloo_timers::future::TimeoutFuture::new(500).await;
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let video_id = format!("video-{}", participant_sid);
|
||||
|
||||
console::log_1(&format!("🔍 Looking for video element with ID: {}", video_id).into());
|
||||
|
||||
// Try multiple times to find and attach to the video element
|
||||
let mut attempts = 0;
|
||||
let max_attempts = 5;
|
||||
|
||||
while attempts < max_attempts {
|
||||
if let Some(element) = document.get_element_by_id(&video_id) {
|
||||
console::log_1(&format!("✅ Found video element: {} (attempt {})", video_id, attempts + 1).into());
|
||||
|
||||
// Log that we found the video element
|
||||
console::log_1(&format!("🔍 Video element found and ready for attachment").into());
|
||||
|
||||
if let Ok(video_element) = element.dyn_into::<web_sys::HtmlVideoElement>() {
|
||||
console::log_1(&format!("🎬 Attaching track {} to video element {}", track_sid, video_id).into());
|
||||
|
||||
// Cast JsValue back to Track type before calling attach
|
||||
if let Ok(track_obj) = track_clone.dyn_into::<crate::livekit_js_bindings::Track>() {
|
||||
track_obj.attach(&video_element);
|
||||
console::log_1(&format!("🎉 Successfully attached video track {} directly!", track_sid).into());
|
||||
|
||||
// Force the video to play
|
||||
let _ = video_element.play();
|
||||
console::log_1(&format!("▶️ Started video playback for {}", track_sid).into());
|
||||
break;
|
||||
} else {
|
||||
console::error_1(&format!("❌ Failed to cast track object for {}", track_sid).into());
|
||||
}
|
||||
} else {
|
||||
console::error_1(&format!("❌ Element {} is not a video element", video_id).into());
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
console::log_1(&format!("⏳ Video element not found: {} (attempt {}/{})", video_id, attempts + 1, max_attempts).into());
|
||||
attempts += 1;
|
||||
if attempts < max_attempts {
|
||||
gloo_timers::future::TimeoutFuture::new(200).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if attempts >= max_attempts {
|
||||
console::error_1(&format!("❌ Failed to find video element {} after {} attempts", video_id, max_attempts).into());
|
||||
}
|
||||
});
|
||||
} else if kind == "audio" {
|
||||
console::log_1(&format!("🎤 Directly attaching audio track {} for participant {}", track_sid, participant_sid).into());
|
||||
|
||||
// Clone track for async operation
|
||||
let track_clone = track.clone();
|
||||
spawn_local(async move {
|
||||
// Wait a bit for the DOM to update
|
||||
gloo_timers::future::TimeoutFuture::new(100).await;
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
|
||||
// Create or find audio element for this participant
|
||||
let audio_id = format!("audio-{}", participant_sid);
|
||||
console::log_1(&format!("🔍 Looking for audio element with ID: {}", audio_id).into());
|
||||
|
||||
let audio_element = if let Some(element) = document.get_element_by_id(&audio_id) {
|
||||
console::log_1(&format!("✅ Found existing audio element: {}", audio_id).into());
|
||||
element.dyn_into::<web_sys::HtmlAudioElement>().ok()
|
||||
} else {
|
||||
// Create new audio element
|
||||
console::log_1(&format!("🆕 Creating new audio element: {}", audio_id).into());
|
||||
if let Ok(audio) = document.create_element("audio") {
|
||||
if let Ok(audio_elem) = audio.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
audio_elem.set_id(&audio_id);
|
||||
audio_elem.set_autoplay(true);
|
||||
audio_elem.set_controls(false);
|
||||
// Append to body or a container
|
||||
if let Some(body) = document.body() {
|
||||
let _ = body.append_child(&audio_elem);
|
||||
}
|
||||
Some(audio_elem)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(audio_elem) = audio_element {
|
||||
console::log_1(&format!("🎤 Attaching audio track {} to audio element {}", track_sid, audio_id).into());
|
||||
// Cast JsValue back to Track type before calling attach
|
||||
if let Ok(track_obj) = track_clone.dyn_into::<crate::livekit_js_bindings::Track>() {
|
||||
track_obj.attach_audio(&audio_elem);
|
||||
console::log_1(&format!("🎉 Successfully attached audio track {} directly!", track_sid).into());
|
||||
} else {
|
||||
console::error_1(&format!("❌ Failed to cast audio track object for {}", track_sid).into());
|
||||
}
|
||||
} else {
|
||||
console::error_1(&format!("❌ Could not create/find audio element: {}", audio_id).into());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// First, ensure the participant exists in our local list by sending a ParticipantConnected event
|
||||
let participant_info = ParticipantInfo {
|
||||
sid: participant.sid(),
|
||||
identity: participant.identity(),
|
||||
name: participant.name().unwrap_or_default(),
|
||||
video_tracks: if kind == "video" { vec![track.sid()] } else { Vec::new() },
|
||||
audio_tracks: if kind == "audio" { vec![track.sid()] } else { Vec::new() },
|
||||
};
|
||||
|
||||
// Send ParticipantConnected first to ensure participant exists
|
||||
track_subscribed_callback(LiveKitEvent::ParticipantConnected(participant_info));
|
||||
|
||||
// Then send the TrackSubscribed event
|
||||
track_subscribed_callback(LiveKitEvent::TrackSubscribed {
|
||||
participant_sid: participant.sid(),
|
||||
track_sid: track.sid(),
|
||||
kind: kind.to_string(),
|
||||
});
|
||||
} else {
|
||||
console::warn_1(&"Failed to parse track subscription event arguments".into());
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue, JsValue, JsValue)>);
|
||||
room.on("trackSubscribed", track_subscribed_closure.as_ref().unchecked_ref());
|
||||
track_subscribed_closure.forget();
|
||||
|
||||
// Data received event (for chat messages)
|
||||
let data_received_callback = callback.clone();
|
||||
let data_received_closure = Closure::wrap(Box::new(move |data: JsValue, participant: JsValue| {
|
||||
console::log_1(&"💬 Data received event fired!".into());
|
||||
if let Ok(participant) = participant.dyn_into::<crate::livekit_js_bindings::Participant>() {
|
||||
// Convert data to string
|
||||
if let Some(message_str) = data.as_string() {
|
||||
console::log_1(&format!("💬 Chat message received from {}: {}", participant.sid(), message_str).into());
|
||||
|
||||
let timestamp = js_sys::Date::now();
|
||||
data_received_callback(LiveKitEvent::ChatMessageReceived {
|
||||
participant_sid: participant.sid(),
|
||||
participant_name: participant.name().unwrap_or_else(|| participant.identity()),
|
||||
message: message_str,
|
||||
timestamp,
|
||||
});
|
||||
} else if let Ok(uint8_array) = data.dyn_into::<js_sys::Uint8Array>() {
|
||||
// Handle binary data as UTF-8 string
|
||||
let mut bytes = vec![0u8; uint8_array.length() as usize];
|
||||
uint8_array.copy_to(&mut bytes);
|
||||
if let Ok(message_str) = String::from_utf8(bytes) {
|
||||
console::log_1(&format!("💬 Chat message (binary) received from {}: {}", participant.sid(), message_str).into());
|
||||
|
||||
let timestamp = js_sys::Date::now();
|
||||
data_received_callback(LiveKitEvent::ChatMessageReceived {
|
||||
participant_sid: participant.sid(),
|
||||
participant_name: participant.name().unwrap_or_else(|| participant.identity()),
|
||||
message: message_str,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue, JsValue)>);
|
||||
room.on("dataReceived", data_received_closure.as_ref().unchecked_ref());
|
||||
data_received_closure.forget();
|
||||
|
||||
// Track unsubscribed event
|
||||
let track_unsubscribed_callback = callback.clone();
|
||||
let track_unsubscribed_closure = Closure::wrap(Box::new(move |track: JsValue, publication: JsValue, participant: JsValue| {
|
||||
if let (Ok(track), Ok(participant)) = (
|
||||
track.dyn_into::<crate::livekit_js_bindings::Track>(),
|
||||
participant.dyn_into::<crate::livekit_js_bindings::Participant>()
|
||||
) {
|
||||
track_unsubscribed_callback(LiveKitEvent::TrackUnsubscribed {
|
||||
participant_sid: participant.sid(),
|
||||
track_sid: track.sid(),
|
||||
});
|
||||
}
|
||||
}) as Box<dyn FnMut(JsValue, JsValue, JsValue)>);
|
||||
room.on("trackUnsubscribed", track_unsubscribed_closure.as_ref().unchecked_ref());
|
||||
track_unsubscribed_closure.forget();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_event_callback<F>(&mut self, callback: F)
|
||||
where
|
||||
F: Fn(LiveKitEvent) + 'static,
|
||||
{
|
||||
self.event_callback = Some(Rc::new(callback));
|
||||
self.setup_event_handlers(self.event_callback.as_ref().unwrap().clone());
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, url: &str, token: &str) {
|
||||
console::log_1(&format!("Connecting to LiveKit room: {}", url).into());
|
||||
|
||||
let room_clone = self.room.clone();
|
||||
let promise = {
|
||||
let room_borrow = room_clone.borrow();
|
||||
if let Some(room) = room_borrow.as_ref() {
|
||||
room.connect(url, token)
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle the connection promise
|
||||
let future = wasm_bindgen_futures::JsFuture::from(promise);
|
||||
let callback_clone = self.event_callback.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match future.await {
|
||||
Ok(_) => {
|
||||
console::log_1(&"✅ Connected to LiveKit room successfully".into());
|
||||
|
||||
// Enable and publish camera and microphone after successful connection
|
||||
if let Some(room_ref) = room_clone.borrow().as_ref() {
|
||||
let local_participant = room_ref.local_participant();
|
||||
|
||||
console::log_1(&"🎥 Attempting to enable camera...".into());
|
||||
let camera_promise = local_participant.set_camera_enabled(true);
|
||||
match wasm_bindgen_futures::JsFuture::from(camera_promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&"✅ Camera enabled and published successfully".into());
|
||||
|
||||
// Check if video track was published
|
||||
let video_tracks = local_participant.video_tracks();
|
||||
let video_track_count = video_tracks.size();
|
||||
console::log_1(&format!("🔍 After camera enable - Local video tracks: {}", video_track_count).into());
|
||||
},
|
||||
Err(e) => console::log_1(&format!("❌ Failed to enable camera: {:?}", e).into()),
|
||||
}
|
||||
|
||||
console::log_1(&"🎤 Attempting to enable microphone...".into());
|
||||
|
||||
// First try to enable microphone to create the track
|
||||
let microphone_promise = local_participant.set_microphone_enabled(true);
|
||||
match wasm_bindgen_futures::JsFuture::from(microphone_promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&"✅ Microphone enabled and published successfully".into());
|
||||
|
||||
// Check if audio track was published
|
||||
let audio_tracks = local_participant.audio_tracks();
|
||||
let audio_track_count = audio_tracks.size();
|
||||
console::log_1(&format!("🔍 After microphone enable - Local audio tracks: {}", audio_track_count).into());
|
||||
|
||||
// Now mute it initially (so user starts muted but track exists)
|
||||
console::log_1(&"🔇 Setting initial microphone state to muted...".into());
|
||||
let mute_promise = local_participant.set_microphone_muted(true);
|
||||
match wasm_bindgen_futures::JsFuture::from(mute_promise).await {
|
||||
Ok(_) => console::log_1(&"✅ Microphone initially muted successfully".into()),
|
||||
Err(e) => console::log_1(&format!("⚠️ Failed to mute microphone initially: {:?}", e).into()),
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
console::log_1(&format!("❌ Failed to enable microphone: {:?}", e).into());
|
||||
// Try to request microphone access manually
|
||||
console::log_1(&"🔄 Trying manual microphone access...".into());
|
||||
// Create a simple microphone access request inline
|
||||
let window = web_sys::window().ok_or("No window").unwrap();
|
||||
let navigator = window.navigator();
|
||||
if let Ok(media_devices) = navigator.media_devices() {
|
||||
let mut constraints = web_sys::MediaStreamConstraints::new();
|
||||
constraints.set_audio(&true.into());
|
||||
constraints.set_video(&false.into());
|
||||
|
||||
if let Ok(promise) = media_devices.get_user_media_with_constraints(&constraints) {
|
||||
match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&"✅ Manual microphone access granted".into());
|
||||
// Try enabling again
|
||||
let retry_promise = local_participant.set_microphone_enabled(true);
|
||||
match wasm_bindgen_futures::JsFuture::from(retry_promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&"✅ Microphone enabled on retry".into());
|
||||
// Mute it initially
|
||||
let mute_promise = local_participant.set_microphone_muted(true);
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(mute_promise).await;
|
||||
},
|
||||
Err(e) => console::log_1(&format!("❌ Microphone retry failed: {:?}", e).into()),
|
||||
}
|
||||
},
|
||||
Err(e) => console::log_1(&format!("❌ Manual microphone access failed: {:?}", e).into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Force a manual participant update after enabling tracks
|
||||
console::log_1(&"🔄 Forcing participant update after track enablement...".into());
|
||||
if let Some(callback) = &callback_clone {
|
||||
// Get actual track information from the participant
|
||||
let video_tracks = local_participant.video_tracks();
|
||||
let audio_tracks = local_participant.audio_tracks();
|
||||
|
||||
// Convert JS Maps to Vec<String> of track IDs
|
||||
let mut video_track_ids = Vec::new();
|
||||
let mut audio_track_ids = Vec::new();
|
||||
|
||||
// Iterate through video tracks
|
||||
video_tracks.for_each(&mut |value, key| {
|
||||
if let Some(track_id) = key.as_string() {
|
||||
video_track_ids.push(track_id);
|
||||
}
|
||||
});
|
||||
|
||||
// Iterate through audio tracks
|
||||
audio_tracks.for_each(&mut |value, key| {
|
||||
if let Some(track_id) = key.as_string() {
|
||||
audio_track_ids.push(track_id);
|
||||
}
|
||||
});
|
||||
|
||||
console::log_1(&format!("📊 Collected {} video tracks, {} audio tracks",
|
||||
video_track_ids.len(), audio_track_ids.len()).into());
|
||||
|
||||
let participant_info = ParticipantInfo {
|
||||
sid: local_participant.sid(),
|
||||
identity: local_participant.identity(),
|
||||
name: local_participant.name().unwrap_or_default(),
|
||||
video_tracks: video_track_ids,
|
||||
audio_tracks: audio_track_ids,
|
||||
};
|
||||
callback(LiveKitEvent::ParticipantConnected(participant_info));
|
||||
console::log_1(&"🔄 Manual participant update sent".into());
|
||||
|
||||
// Query and sync existing participants
|
||||
console::log_1(&"🔍 Querying existing participants...".into());
|
||||
let all_participants = room_ref.participants();
|
||||
console::log_1(&format!("📊 Found {} existing participants", all_participants.size()).into());
|
||||
|
||||
// Iterate through existing participants (excluding local participant)
|
||||
let local_sid = local_participant.sid();
|
||||
all_participants.for_each(&mut |participant_value, participant_key| {
|
||||
if let Ok(participant) = participant_value.dyn_into::<crate::livekit_js_bindings::Participant>() {
|
||||
// Skip local participant to avoid duplication
|
||||
if participant.sid() == local_sid {
|
||||
return;
|
||||
}
|
||||
console::log_1(&format!("👥 Syncing existing participant: {} ({})", participant.identity(), participant.sid()).into());
|
||||
|
||||
// Get their tracks
|
||||
let participant_video_tracks = participant.video_tracks();
|
||||
let participant_audio_tracks = participant.audio_tracks();
|
||||
|
||||
let mut participant_video_track_ids = Vec::new();
|
||||
let mut participant_audio_track_ids = Vec::new();
|
||||
|
||||
// Collect video track IDs
|
||||
participant_video_tracks.for_each(&mut |track_value, track_key| {
|
||||
if let Some(track_id) = track_key.as_string() {
|
||||
participant_video_track_ids.push(track_id);
|
||||
}
|
||||
});
|
||||
|
||||
// Collect audio track IDs
|
||||
participant_audio_tracks.for_each(&mut |track_value, track_key| {
|
||||
if let Some(track_id) = track_key.as_string() {
|
||||
participant_audio_track_ids.push(track_id);
|
||||
}
|
||||
});
|
||||
|
||||
console::log_1(&format!("📊 Participant {} has {} video tracks, {} audio tracks",
|
||||
participant.sid(), participant_video_track_ids.len(), participant_audio_track_ids.len()).into());
|
||||
|
||||
let existing_participant_info = ParticipantInfo {
|
||||
sid: participant.sid(),
|
||||
identity: participant.identity(),
|
||||
name: participant.name().unwrap_or_default(),
|
||||
video_tracks: participant_video_track_ids,
|
||||
audio_tracks: participant_audio_track_ids,
|
||||
};
|
||||
|
||||
// Send ParticipantConnected event for existing participant
|
||||
callback(LiveKitEvent::ParticipantConnected(existing_participant_info));
|
||||
console::log_1(&format!("✅ Synced existing participant: {}", participant.sid()).into());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
console::log_1(&format!("❌ Failed to connect to LiveKit room: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn disconnect(&self) {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
room.disconnect();
|
||||
}
|
||||
*self.room.borrow_mut() = None;
|
||||
}
|
||||
|
||||
pub fn get_participants(&self) -> Vec<ParticipantInfo> {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
let participants_map = room.participants();
|
||||
let mut participants = Vec::new();
|
||||
|
||||
// Get the local participant first
|
||||
let local_participant = room.local_participant();
|
||||
let local_video_tracks = local_participant.video_tracks();
|
||||
let local_audio_tracks = local_participant.audio_tracks();
|
||||
|
||||
console::log_1(&format!("🔍 Local participant SID: {}", local_participant.sid()).into());
|
||||
console::log_1(&format!("🔍 Checking local participant tracks...").into());
|
||||
|
||||
let local_info = ParticipantInfo {
|
||||
sid: local_participant.sid(),
|
||||
identity: local_participant.identity(),
|
||||
name: local_participant.name().unwrap_or_default(),
|
||||
video_tracks: Vec::new(), // TODO: enumerate actual tracks
|
||||
audio_tracks: Vec::new(),
|
||||
};
|
||||
participants.push(local_info);
|
||||
|
||||
// TODO: Iterate through remote participants map
|
||||
// For now, return just local participant with debug info
|
||||
console::log_1(&format!("🔍 Total participants found: {}", participants.len()).into());
|
||||
participants
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attach_video_track(&self, participant_sid: &str, track_sid: &str, video_element: &HtmlVideoElement) {
|
||||
console::log_1(&format!("🔗 Attempting to attach video track {} for participant {}", track_sid, participant_sid).into());
|
||||
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
// Get participant by SID from the participants Map
|
||||
let participants_map = room.participants();
|
||||
|
||||
// Check if participants_map is valid
|
||||
if participants_map.is_undefined() || participants_map.is_null() {
|
||||
console::warn_1(&format!("⚠️ Participants map not ready, retrying in 500ms...").into());
|
||||
|
||||
// Clone necessary data for the retry
|
||||
let client_clone = self.clone();
|
||||
let participant_sid_clone = participant_sid.to_string();
|
||||
let track_sid_clone = track_sid.to_string();
|
||||
let video_element_clone = video_element.clone();
|
||||
|
||||
// Retry after a delay
|
||||
spawn_local(async move {
|
||||
gloo_timers::future::TimeoutFuture::new(500).await;
|
||||
client_clone.attach_video_track_retry(&participant_sid_clone, &track_sid_clone, &video_element_clone, 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let participant_key = JsValue::from_str(participant_sid);
|
||||
|
||||
console::log_1(&format!("🔍 Looking for participant {} in participants map", participant_sid).into());
|
||||
let participant_js = participants_map.get(&participant_key);
|
||||
if !participant_js.is_undefined() {
|
||||
console::log_1(&format!("✅ Found participant {}", participant_sid).into());
|
||||
let participant: crate::livekit_js_bindings::Participant = participant_js.dyn_into().unwrap();
|
||||
|
||||
// Get video tracks Map for this participant
|
||||
let video_tracks_map = participant.video_tracks();
|
||||
let track_key = JsValue::from_str(track_sid);
|
||||
|
||||
console::log_1(&format!("🔍 Looking for track {} in video tracks map", track_sid).into());
|
||||
let track_pub_js = video_tracks_map.get(&track_key);
|
||||
if !track_pub_js.is_undefined() {
|
||||
console::log_1(&format!("✅ Found track publication {}", track_sid).into());
|
||||
let track_pub: crate::livekit_js_bindings::TrackPublication = track_pub_js.dyn_into().unwrap();
|
||||
|
||||
if let Some(track) = track_pub.track() {
|
||||
console::log_1(&format!("✅ Got track object, attempting to attach to video element").into());
|
||||
track.attach(video_element);
|
||||
console::log_1(&format!("🎥 Successfully attached video track {} for participant {}", track_sid, participant_sid).into());
|
||||
return;
|
||||
} else {
|
||||
console::warn_1(&format!("❌ Track publication {} has no track object", track_sid).into());
|
||||
}
|
||||
} else {
|
||||
console::warn_1(&format!("❌ Track {} not found in video tracks map", track_sid).into());
|
||||
}
|
||||
} else {
|
||||
console::warn_1(&format!("❌ Participant {} not found in participants map", participant_sid).into());
|
||||
}
|
||||
console::warn_1(&format!("❌ Could not attach track {} for participant {}", track_sid, participant_sid).into());
|
||||
} else {
|
||||
console::warn_1(&format!("❌ No room available for track attachment").into());
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_video_track_retry(&self, participant_sid: &str, track_sid: &str, video_element: &HtmlVideoElement, attempt: u32) {
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
console::log_1(&format!("🔄 Retry attempt {} for track {} (participant {})", attempt, track_sid, participant_sid).into());
|
||||
|
||||
if attempt > MAX_RETRIES {
|
||||
console::error_1(&format!("❌ Failed to attach track {} after {} attempts", track_sid, MAX_RETRIES).into());
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
console::log_1(&format!("🔍 Room object exists for retry attempt {}", attempt).into());
|
||||
let participants_map = room.participants();
|
||||
|
||||
console::log_1(&format!("🔍 Participants map type: {:?}, undefined: {}, null: {}",
|
||||
participants_map.js_typeof(), participants_map.is_undefined(), participants_map.is_null()).into());
|
||||
|
||||
if participants_map.is_undefined() || participants_map.is_null() {
|
||||
console::warn_1(&format!("⚠️ Participants map still not ready (attempt {}), retrying...", attempt).into());
|
||||
|
||||
let client_clone = self.clone();
|
||||
let participant_sid_clone = participant_sid.to_string();
|
||||
let track_sid_clone = track_sid.to_string();
|
||||
let video_element_clone = video_element.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
gloo_timers::future::TimeoutFuture::new(1000).await; // Longer delay for retries
|
||||
client_clone.attach_video_track_retry(&participant_sid_clone, &track_sid_clone, &video_element_clone, attempt + 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Participants map is ready, proceed with attachment
|
||||
let participant_key = JsValue::from_str(participant_sid);
|
||||
let participant_js = participants_map.get(&participant_key);
|
||||
|
||||
if !participant_js.is_undefined() {
|
||||
console::log_1(&format!("✅ Found participant {} on retry attempt {}", participant_sid, attempt).into());
|
||||
let participant: crate::livekit_js_bindings::Participant = participant_js.dyn_into().unwrap();
|
||||
|
||||
let video_tracks_map = participant.video_tracks();
|
||||
let track_key = JsValue::from_str(track_sid);
|
||||
|
||||
let track_pub_js = video_tracks_map.get(&track_key);
|
||||
if !track_pub_js.is_undefined() {
|
||||
console::log_1(&format!("✅ Found track publication {} on retry", track_sid).into());
|
||||
let track_pub: crate::livekit_js_bindings::TrackPublication = track_pub_js.dyn_into().unwrap();
|
||||
|
||||
if let Some(track) = track_pub.track() {
|
||||
if let Ok(media_stream_track) = track.dyn_into::<web_sys::MediaStreamTrack>() {
|
||||
let media_stream = web_sys::MediaStream::new().unwrap();
|
||||
media_stream.add_track(&media_stream_track);
|
||||
video_element.set_src_object(Some(&media_stream));
|
||||
console::log_1(&format!("🎉 Successfully attached video track {} on retry attempt {}", track_sid, attempt).into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console::warn_1(&format!("❌ Track {} not found on retry attempt {}", track_sid, attempt).into());
|
||||
}
|
||||
} else {
|
||||
console::warn_1(&format!("❌ Participant {} not found on retry attempt {}", participant_sid, attempt).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detach_video_track(&self, participant_sid: &str, track_sid: &str, video_element: &HtmlVideoElement) {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
// Get participant by SID from the participants Map
|
||||
let participants_map = room.participants();
|
||||
let participant_key = JsValue::from_str(participant_sid);
|
||||
|
||||
let participant_js = participants_map.get(&participant_key);
|
||||
if !participant_js.is_undefined() {
|
||||
let participant: crate::livekit_js_bindings::Participant = participant_js.dyn_into().unwrap();
|
||||
|
||||
// Get video tracks Map for this participant
|
||||
let video_tracks_map = participant.video_tracks();
|
||||
let track_key = JsValue::from_str(track_sid);
|
||||
|
||||
let track_pub_js = video_tracks_map.get(&track_key);
|
||||
if !track_pub_js.is_undefined() {
|
||||
let track_pub: crate::livekit_js_bindings::TrackPublication = track_pub_js.dyn_into().unwrap();
|
||||
|
||||
if let Some(track) = track_pub.track() {
|
||||
track.detach(video_element);
|
||||
console::log_1(&format!("Detached video track {} for participant {}", track_sid, participant_sid).into());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enable_camera(&self) -> Result<(), JsValue> {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
let promise = room.start_video();
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JsValue::from_str("Room not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enable_microphone(&self) -> Result<(), JsValue> {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
let promise = room.start_audio();
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JsValue::from_str("Room not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.room.borrow().is_some()
|
||||
}
|
||||
|
||||
pub async fn send_chat_message(&self, message: &str) -> Result<(), JsValue> {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
console::log_1(&format!("💬 Sending chat message via data channel: {}", message).into());
|
||||
let local_participant = room.local_participant();
|
||||
let message_bytes = message.as_bytes();
|
||||
let promise = local_participant.publish_data(message_bytes, true);
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
console::log_1(&"✅ Chat message sent successfully via data channel".into());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JsValue::from_str("Room not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_microphone_enabled(&self, enabled: bool) -> Result<(), JsValue> {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
let local_participant = room.local_participant();
|
||||
|
||||
console::log_1(&format!("🎤 Setting microphone muted: {}", !enabled).into());
|
||||
|
||||
// Use muting instead of enabling/disabling to avoid track removal
|
||||
let promise = local_participant.set_microphone_muted(!enabled);
|
||||
match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&format!("✅ Microphone {} successfully", if enabled { "unmuted" } else { "muted" }).into());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
console::error_1(&format!("❌ Failed to set microphone muted to {}: {:?}", !enabled, e).into());
|
||||
|
||||
// If muting failed, try enabling/disabling as fallback
|
||||
console::log_1(&"🔄 Trying enable/disable as fallback...".into());
|
||||
let fallback_promise = local_participant.set_microphone_enabled(enabled);
|
||||
match wasm_bindgen_futures::JsFuture::from(fallback_promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&format!("✅ Microphone {} via fallback", if enabled { "enabled" } else { "disabled" }).into());
|
||||
Ok(())
|
||||
}
|
||||
Err(fallback_e) => {
|
||||
console::error_1(&format!("❌ Fallback also failed: {:?}", fallback_e).into());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(JsValue::from_str("Room not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_microphone_access(&self) -> Result<(), JsValue> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{MediaDevices, MediaStreamConstraints};
|
||||
|
||||
let window = web_sys::window().ok_or("No window")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator.media_devices().map_err(|_| "No media devices")?;
|
||||
|
||||
let mut constraints = MediaStreamConstraints::new();
|
||||
constraints.set_audio(&true.into());
|
||||
constraints.set_video(&false.into());
|
||||
|
||||
let promise = media_devices.get_user_media_with_constraints(&constraints)?;
|
||||
let _stream = JsFuture::from(promise).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_camera_enabled(&self, enabled: bool) -> Result<(), JsValue> {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
console::log_1(&format!("🎥 Setting camera enabled: {}", enabled).into());
|
||||
let local_participant = room.local_participant();
|
||||
let promise = local_participant.set_camera_enabled(enabled);
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
console::log_1(&format!("✅ Camera {} successfully", if enabled { "enabled" } else { "disabled" }).into());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JsValue::from_str("Room not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_screen_share_enabled(&self, enabled: bool) -> Result<(), JsValue> {
|
||||
if let Some(room) = self.room.borrow().as_ref() {
|
||||
console::log_1(&format!("🖥️ Setting screen share enabled: {}", enabled).into());
|
||||
let local_participant = room.local_participant();
|
||||
let promise = local_participant.set_screen_share_enabled(enabled);
|
||||
match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&format!("✅ Screen share {} successfully", if enabled { "enabled" } else { "disabled" }).into());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
console::error_1(&format!("❌ Failed to set screen share enabled to {}: {:?}", enabled, e).into());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(JsValue::from_str("Room not connected"))
|
||||
}
|
||||
}
|
||||
}
|
739
examples/meet/src/livekit_protocol.rs
Normal file
739
examples/meet/src/livekit_protocol.rs
Normal file
@@ -0,0 +1,739 @@
|
||||
use prost::{Message, Oneof};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// LiveKit Events for UI updates
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LiveKitEvent {
|
||||
ParticipantConnected { sid: String, identity: String, name: String },
|
||||
ParticipantDisconnected { sid: String },
|
||||
TrackPublished { participant_sid: String, track_sid: String },
|
||||
TrackUnpublished { participant_sid: String, track_sid: String },
|
||||
}
|
||||
|
||||
// Signal request for sending messages to LiveKit
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SignalRequest {
|
||||
#[prost(oneof="signal_request::Message", tags="1")]
|
||||
pub message: Option<signal_request::Message>,
|
||||
}
|
||||
|
||||
pub mod signal_request {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Oneof)]
|
||||
pub enum Message {
|
||||
#[prost(message, tag="1")]
|
||||
Join(JoinRequest),
|
||||
}
|
||||
}
|
||||
|
||||
// Join request message
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct JoinRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub room: String,
|
||||
#[prost(string, tag="2")]
|
||||
pub identity: String,
|
||||
#[prost(string, tag="3")]
|
||||
pub name: String,
|
||||
#[prost(string, tag="4")]
|
||||
pub metadata: String,
|
||||
#[prost(bool, tag="5")]
|
||||
pub reconnect: bool,
|
||||
#[prost(bool, tag="6")]
|
||||
pub auto_subscribe: bool,
|
||||
}
|
||||
|
||||
// Signal response from LiveKit
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SignalResponse {
|
||||
#[prost(oneof="signal_response::Message", tags="1,2")]
|
||||
pub message: Option<signal_response::Message>,
|
||||
}
|
||||
|
||||
pub mod signal_response {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Oneof)]
|
||||
pub enum Message {
|
||||
#[prost(message, tag="1")]
|
||||
Join(JoinResponse),
|
||||
#[prost(message, tag="2")]
|
||||
Update(ParticipantUpdate),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct JoinResponse {
|
||||
#[prost(message, optional, tag="1")]
|
||||
pub room: Option<Room>,
|
||||
#[prost(message, optional, tag="2")]
|
||||
pub participant: Option<ParticipantInfo>,
|
||||
#[prost(message, repeated, tag="3")]
|
||||
pub other_participants: Vec<ParticipantInfo>,
|
||||
#[prost(string, tag="4")]
|
||||
pub server_version: String,
|
||||
#[prost(message, repeated, tag="5")]
|
||||
pub ice_servers: Vec<IceServer>,
|
||||
#[prost(string, tag="6")]
|
||||
pub subscriber_primary: String,
|
||||
#[prost(string, tag="7")]
|
||||
pub alternative_url: String,
|
||||
#[prost(message, optional, tag="8")]
|
||||
pub client_configuration: Option<ClientConfiguration>,
|
||||
#[prost(string, tag="9")]
|
||||
pub server_region: String,
|
||||
#[prost(int32, tag="10")]
|
||||
pub ping_timeout: i32,
|
||||
#[prost(int32, tag="11")]
|
||||
pub ping_interval: i32,
|
||||
#[prost(message, optional, tag="12")]
|
||||
pub server_info: Option<ServerInfo>,
|
||||
#[prost(bool, tag="13")]
|
||||
pub sif_trailer: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct Room {
|
||||
#[prost(string, tag="1")]
|
||||
pub sid: String,
|
||||
#[prost(string, tag="2")]
|
||||
pub name: String,
|
||||
#[prost(uint32, tag="3")]
|
||||
pub empty_timeout: u32,
|
||||
#[prost(uint32, tag="4")]
|
||||
pub departure_timeout: u32,
|
||||
#[prost(uint32, tag="5")]
|
||||
pub max_participants: u32,
|
||||
#[prost(int64, tag="6")]
|
||||
pub creation_time: i64,
|
||||
#[prost(string, tag="7")]
|
||||
pub turn_password: String,
|
||||
#[prost(message, repeated, tag="8")]
|
||||
pub enabled_codecs: Vec<Codec>,
|
||||
#[prost(string, tag="9")]
|
||||
pub metadata: String,
|
||||
#[prost(uint32, tag="10")]
|
||||
pub num_participants: u32,
|
||||
#[prost(uint32, tag="11")]
|
||||
pub num_publishers: u32,
|
||||
#[prost(bool, tag="12")]
|
||||
pub active_recording: bool,
|
||||
#[prost(message, optional, tag="13")]
|
||||
pub version: Option<TimedVersion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ParticipantInfo {
|
||||
#[prost(string, tag="1")]
|
||||
pub sid: String,
|
||||
#[prost(string, tag="2")]
|
||||
pub identity: String,
|
||||
#[prost(enumeration="ParticipantInfo_State", tag="3")]
|
||||
pub state: i32,
|
||||
#[prost(message, repeated, tag="4")]
|
||||
pub tracks: Vec<TrackInfo>,
|
||||
#[prost(string, tag="5")]
|
||||
pub metadata: String,
|
||||
#[prost(int64, tag="6")]
|
||||
pub joined_at: i64,
|
||||
#[prost(string, tag="7")]
|
||||
pub name: String,
|
||||
#[prost(uint32, tag="8")]
|
||||
pub version: u32,
|
||||
#[prost(enumeration="ParticipantInfo_Permission", tag="9")]
|
||||
pub permission: i32,
|
||||
#[prost(string, tag="10")]
|
||||
pub region: String,
|
||||
#[prost(bool, tag="11")]
|
||||
pub is_publisher: bool,
|
||||
#[prost(enumeration="ParticipantInfo_Kind", tag="12")]
|
||||
pub kind: i32,
|
||||
#[prost(message, repeated, tag="13")]
|
||||
pub attributes: Vec<ParticipantAttributes>,
|
||||
#[prost(message, optional, tag="14")]
|
||||
pub disconnect_reason: Option<DisconnectReason>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum ParticipantInfo_State {
|
||||
Joining = 0,
|
||||
Joined = 1,
|
||||
Active = 2,
|
||||
Disconnected = 3,
|
||||
}
|
||||
|
||||
impl Default for ParticipantInfo_State {
|
||||
fn default() -> Self {
|
||||
ParticipantInfo_State::Joining
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParticipantInfo_State> for i32 {
|
||||
fn from(val: ParticipantInfo_State) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for ParticipantInfo_State {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(ParticipantInfo_State::Joining),
|
||||
1 => Ok(ParticipantInfo_State::Joined),
|
||||
2 => Ok(ParticipantInfo_State::Active),
|
||||
3 => Ok(ParticipantInfo_State::Disconnected),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum ParticipantInfo_Permission {
|
||||
CanSubscribe = 0,
|
||||
CanPublish = 1,
|
||||
CanPublishData = 2,
|
||||
Hidden = 7,
|
||||
Recorder = 8,
|
||||
CanUpdateMetadata = 10,
|
||||
Admin = 11,
|
||||
}
|
||||
|
||||
impl Default for ParticipantInfo_Permission {
|
||||
fn default() -> Self {
|
||||
ParticipantInfo_Permission::CanSubscribe
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParticipantInfo_Permission> for i32 {
|
||||
fn from(val: ParticipantInfo_Permission) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for ParticipantInfo_Permission {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(ParticipantInfo_Permission::CanSubscribe),
|
||||
1 => Ok(ParticipantInfo_Permission::CanPublish),
|
||||
2 => Ok(ParticipantInfo_Permission::CanPublishData),
|
||||
7 => Ok(ParticipantInfo_Permission::Hidden),
|
||||
8 => Ok(ParticipantInfo_Permission::Recorder),
|
||||
10 => Ok(ParticipantInfo_Permission::CanUpdateMetadata),
|
||||
11 => Ok(ParticipantInfo_Permission::Admin),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum ParticipantInfo_Kind {
|
||||
Standard = 0,
|
||||
Ingress = 1,
|
||||
Egress = 2,
|
||||
Sip = 3,
|
||||
Agent = 4,
|
||||
}
|
||||
|
||||
impl Default for ParticipantInfo_Kind {
|
||||
fn default() -> Self {
|
||||
ParticipantInfo_Kind::Standard
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParticipantInfo_Kind> for i32 {
|
||||
fn from(val: ParticipantInfo_Kind) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for ParticipantInfo_Kind {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(ParticipantInfo_Kind::Standard),
|
||||
1 => Ok(ParticipantInfo_Kind::Ingress),
|
||||
2 => Ok(ParticipantInfo_Kind::Egress),
|
||||
3 => Ok(ParticipantInfo_Kind::Sip),
|
||||
4 => Ok(ParticipantInfo_Kind::Agent),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct TrackInfo {
|
||||
#[prost(string, tag="1")]
|
||||
pub sid: String,
|
||||
#[prost(enumeration="TrackType", tag="2")]
|
||||
pub r#type: i32,
|
||||
#[prost(string, tag="3")]
|
||||
pub name: String,
|
||||
#[prost(bool, tag="4")]
|
||||
pub muted: bool,
|
||||
#[prost(uint32, tag="5")]
|
||||
pub width: u32,
|
||||
#[prost(uint32, tag="6")]
|
||||
pub height: u32,
|
||||
#[prost(bool, tag="7")]
|
||||
pub simulcast: bool,
|
||||
#[prost(bool, tag="8")]
|
||||
pub disable_dtx: bool,
|
||||
#[prost(enumeration="TrackSource", tag="9")]
|
||||
pub source: i32,
|
||||
#[prost(message, repeated, tag="10")]
|
||||
pub layers: Vec<VideoLayer>,
|
||||
#[prost(string, tag="11")]
|
||||
pub mime_type: String,
|
||||
#[prost(string, tag="12")]
|
||||
pub mid: String,
|
||||
#[prost(message, repeated, tag="13")]
|
||||
pub codecs: Vec<SimulcastCodec>,
|
||||
#[prost(bool, tag="14")]
|
||||
pub stereo: bool,
|
||||
#[prost(bool, tag="15")]
|
||||
pub disable_red: bool,
|
||||
#[prost(enumeration="Encryption_Type", tag="16")]
|
||||
pub encryption: i32,
|
||||
#[prost(string, tag="17")]
|
||||
pub stream: String,
|
||||
#[prost(message, optional, tag="18")]
|
||||
pub version: Option<TimedVersion>,
|
||||
#[prost(enumeration="AudioTrackFeature", repeated, tag="19")]
|
||||
pub audio_features: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum TrackType {
|
||||
Audio = 0,
|
||||
Video = 1,
|
||||
Data = 2,
|
||||
}
|
||||
|
||||
impl Default for TrackType {
|
||||
fn default() -> Self {
|
||||
TrackType::Audio
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TrackType> for i32 {
|
||||
fn from(val: TrackType) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for TrackType {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(TrackType::Audio),
|
||||
1 => Ok(TrackType::Video),
|
||||
2 => Ok(TrackType::Data),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum TrackSource {
|
||||
Unknown = 0,
|
||||
Camera = 1,
|
||||
Microphone = 2,
|
||||
ScreenShare = 3,
|
||||
ScreenShareAudio = 4,
|
||||
}
|
||||
|
||||
impl Default for TrackSource {
|
||||
fn default() -> Self {
|
||||
TrackSource::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TrackSource> for i32 {
|
||||
fn from(val: TrackSource) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for TrackSource {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(TrackSource::Unknown),
|
||||
1 => Ok(TrackSource::Camera),
|
||||
2 => Ok(TrackSource::Microphone),
|
||||
3 => Ok(TrackSource::ScreenShare),
|
||||
4 => Ok(TrackSource::ScreenShareAudio),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum AudioTrackFeature {
|
||||
TfEnhancedNoiseCancellation = 0,
|
||||
}
|
||||
|
||||
impl Default for AudioTrackFeature {
|
||||
fn default() -> Self {
|
||||
AudioTrackFeature::TfEnhancedNoiseCancellation
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AudioTrackFeature> for i32 {
|
||||
fn from(val: AudioTrackFeature) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for AudioTrackFeature {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(AudioTrackFeature::TfEnhancedNoiseCancellation),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder structs for other message types
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SessionDescription {
|
||||
#[prost(string, tag="1")]
|
||||
pub r#type: String,
|
||||
#[prost(string, tag="2")]
|
||||
pub sdp: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct TrickleRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub candidate_init: String,
|
||||
#[prost(enumeration="SignalTarget", tag="2")]
|
||||
pub target: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct AddTrackRequest {
|
||||
#[prost(string, tag="1")]
|
||||
pub cid: String,
|
||||
#[prost(string, tag="2")]
|
||||
pub name: String,
|
||||
#[prost(enumeration="TrackType", tag="3")]
|
||||
pub r#type: i32,
|
||||
#[prost(uint32, tag="4")]
|
||||
pub width: u32,
|
||||
#[prost(uint32, tag="5")]
|
||||
pub height: u32,
|
||||
#[prost(bool, tag="6")]
|
||||
pub muted: bool,
|
||||
#[prost(bool, tag="7")]
|
||||
pub disable_dtx: bool,
|
||||
#[prost(enumeration="TrackSource", tag="8")]
|
||||
pub source: i32,
|
||||
#[prost(message, repeated, tag="9")]
|
||||
pub layers: Vec<VideoLayer>,
|
||||
#[prost(message, repeated, tag="10")]
|
||||
pub simulcast_codecs: Vec<SimulcastCodec>,
|
||||
#[prost(string, tag="11")]
|
||||
pub sid: String,
|
||||
#[prost(bool, tag="12")]
|
||||
pub stereo: bool,
|
||||
#[prost(bool, tag="13")]
|
||||
pub disable_red: bool,
|
||||
#[prost(enumeration="Encryption_Type", tag="14")]
|
||||
pub encryption: i32,
|
||||
#[prost(string, tag="15")]
|
||||
pub stream: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum SignalTarget {
|
||||
Publisher = 0,
|
||||
Subscriber = 1,
|
||||
}
|
||||
|
||||
impl Default for SignalTarget {
|
||||
fn default() -> Self {
|
||||
SignalTarget::Publisher
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SignalTarget> for i32 {
|
||||
fn from(val: SignalTarget) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for SignalTarget {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(SignalTarget::Publisher),
|
||||
1 => Ok(SignalTarget::Subscriber),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// More placeholder structs - these would need full implementation
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct MuteTrackRequest {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateSubscription {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateTrackSettings {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct TrackPublishedResponse {
|
||||
#[prost(string, tag="1")]
|
||||
pub track_sid: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SpeakersChanged {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RoomUpdate {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ConnectionQualityUpdate {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct StreamStateUpdate {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SubscribedQualityUpdate {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SubscriptionPermissionUpdate {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct TrackUnpublished {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ReconnectResponse {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SubscriptionResponse {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ErrorResponse {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct IceServer {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ClientConfiguration {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ServerInfo {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct Codec {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct TimedVersion {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct VideoLayer {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SimulcastCodec {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ParticipantAttributes {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct DisconnectReason {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RefreshToken {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct PingRequest {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct PongResponse {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ReconnectRequest {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct MetricsEvent {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ClientInfo {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct TrackSettings {}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(i32)]
|
||||
pub enum Encryption_Type {
|
||||
None = 0,
|
||||
Gcm = 1,
|
||||
Custom = 2,
|
||||
}
|
||||
|
||||
impl Default for Encryption_Type {
|
||||
fn default() -> Self {
|
||||
Encryption_Type::None
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Encryption_Type> for i32 {
|
||||
fn from(val: Encryption_Type) -> i32 {
|
||||
val as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for Encryption_Type {
|
||||
type Error = ();
|
||||
fn try_from(val: i32) -> Result<Self, Self::Error> {
|
||||
match val {
|
||||
0 => Ok(Encryption_Type::None),
|
||||
1 => Ok(Encryption_Type::Gcm),
|
||||
2 => Ok(Encryption_Type::Custom),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol handler
|
||||
#[derive(Debug)]
|
||||
pub struct LiveKitProtocolHandler {
|
||||
participants: HashMap<String, ParticipantInfo>,
|
||||
}
|
||||
|
||||
impl LiveKitProtocolHandler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
participants: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_join_request(room: &str, identity: &str, name: &str) -> Result<Vec<u8>, String> {
|
||||
let join_request = JoinRequest {
|
||||
room: room.to_string(),
|
||||
identity: identity.to_string(),
|
||||
name: name.to_string(),
|
||||
metadata: "".to_string(),
|
||||
reconnect: false,
|
||||
auto_subscribe: true,
|
||||
};
|
||||
|
||||
let signal_request = SignalRequest {
|
||||
message: Some(signal_request::Message::Join(join_request)),
|
||||
};
|
||||
|
||||
Ok(signal_request.encode_to_vec())
|
||||
}
|
||||
|
||||
pub fn handle_message(&mut self, data: &[u8]) -> Option<Vec<LiveKitEvent>> {
|
||||
web_sys::console::log_1(&format!("Attempting to decode {} bytes as SignalResponse", data.len()).into());
|
||||
|
||||
match SignalResponse::decode(data) {
|
||||
Ok(response) => {
|
||||
web_sys::console::log_1(&"Successfully decoded SignalResponse".into());
|
||||
match response.message {
|
||||
Some(signal_response::Message::Join(join_response)) => {
|
||||
web_sys::console::log_1(&"Received JoinResponse".into());
|
||||
|
||||
// Add other participants
|
||||
let mut events = Vec::new();
|
||||
for participant in join_response.other_participants {
|
||||
self.participants.insert(participant.sid.clone(), participant.clone());
|
||||
events.push(LiveKitEvent::ParticipantConnected {
|
||||
sid: participant.sid,
|
||||
identity: participant.identity,
|
||||
name: participant.name,
|
||||
});
|
||||
}
|
||||
Some(events)
|
||||
}
|
||||
Some(signal_response::Message::Update(participant_update)) => {
|
||||
web_sys::console::log_1(&"Received ParticipantUpdate".into());
|
||||
let mut events = Vec::new();
|
||||
for participant in participant_update.participants {
|
||||
if let Some(existing) = self.participants.get_mut(&participant.sid) {
|
||||
*existing = participant.clone();
|
||||
} else {
|
||||
self.participants.insert(participant.sid.clone(), participant.clone());
|
||||
events.push(LiveKitEvent::ParticipantConnected {
|
||||
sid: participant.sid,
|
||||
identity: participant.identity,
|
||||
name: participant.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(events)
|
||||
}
|
||||
_ => {
|
||||
web_sys::console::log_1(&"Received other message type".into());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to decode protobuf: {:?}", e).into());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Missing struct definitions
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct DataPacket {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ResumeRequest {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct Ping {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct Pong {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct RestartIce {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct TrackUnpublishedResponse {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SimulateScenario {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateParticipantMetadata {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SyncState {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct SubscriptionPermission {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct UpdateVideoLayers {}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct ParticipantUpdate {
|
||||
#[prost(message, repeated, tag="1")]
|
||||
pub participants: Vec<ParticipantInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct LeaveRequest {}
|
||||
|
22
examples/meet/src/main.rs
Normal file
22
examples/meet/src/main.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod components;
|
||||
mod pages;
|
||||
mod utils;
|
||||
mod livekit_client;
|
||||
mod livekit_protocol;
|
||||
mod livekit_js_bindings;
|
||||
mod livekit_js_client;
|
||||
|
||||
fn main() {
|
||||
wasm_logger::init(wasm_logger::Config::default());
|
||||
|
||||
// Log browser info for debugging
|
||||
log::info!("Starting LiveKit Meet App");
|
||||
log::info!("User Agent: {:?}", web_sys::window().unwrap().navigator().user_agent());
|
||||
log::info!("Location: {:?}", web_sys::window().unwrap().location().href());
|
||||
|
||||
yew::Renderer::<app::App>::new().render();
|
||||
}
|
254
examples/meet/src/pages/custom.rs
Normal file
254
examples/meet/src/pages/custom.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use web_sys::{window, UrlSearchParams};
|
||||
|
||||
use crate::components::{VideoTile, ChatSidebar, SettingsMenu, MediaControls};
|
||||
use crate::pages::room::{Participant, ChatMessage};
|
||||
use crate::utils::decode_passphrase;
|
||||
|
||||
#[function_component(CustomPage)]
|
||||
pub fn custom_page() -> Html {
|
||||
let navigator = use_navigator().unwrap();
|
||||
let participants = use_state(|| std::collections::HashMap::<String, Participant>::new());
|
||||
let chat_messages = use_state(|| Vec::<ChatMessage>::new());
|
||||
let chat_visible = use_state(|| false);
|
||||
let settings_visible = use_state(|| false);
|
||||
let local_audio_enabled = use_state(|| true);
|
||||
let local_video_enabled = use_state(|| true);
|
||||
let screen_share_enabled = use_state(|| false);
|
||||
let error_message = use_state(|| None::<String>);
|
||||
let connected = use_state(|| false);
|
||||
|
||||
// Parse URL parameters
|
||||
let url_params = use_memo((), |_| {
|
||||
let window = window().unwrap();
|
||||
let location = window.location();
|
||||
let search = location.search().unwrap_or_default();
|
||||
let hash = location.hash().unwrap_or_default();
|
||||
|
||||
if !search.is_empty() {
|
||||
let params = UrlSearchParams::new_with_str(&search[1..]).unwrap(); // Remove '?' prefix
|
||||
let url = params.get("liveKitUrl");
|
||||
let token = params.get("token");
|
||||
let passphrase = if !hash.is_empty() {
|
||||
Some(decode_passphrase(&hash[1..])) // Remove '#' prefix
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(url, token, passphrase)
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
});
|
||||
|
||||
let (livekit_url, token, passphrase) = (*url_params).clone();
|
||||
|
||||
// Validate required parameters
|
||||
let validation_error = use_memo(url_params.clone(), |params| {
|
||||
let (livekit_url, token, _) = &**params;
|
||||
if livekit_url.is_none() || livekit_url.as_ref().unwrap().is_empty() {
|
||||
Some("Missing LiveKit URL".to_string())
|
||||
} else if token.is_none() || token.as_ref().unwrap().is_empty() {
|
||||
Some("Missing LiveKit token".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// Event handlers
|
||||
let on_toggle_audio = {
|
||||
let local_audio_enabled = local_audio_enabled.clone();
|
||||
Callback::from(move |_| {
|
||||
local_audio_enabled.set(!*local_audio_enabled);
|
||||
// TODO: Implement actual audio toggle
|
||||
})
|
||||
};
|
||||
|
||||
let on_toggle_video = {
|
||||
let local_video_enabled = local_video_enabled.clone();
|
||||
Callback::from(move |_| {
|
||||
local_video_enabled.set(!*local_video_enabled);
|
||||
// TODO: Implement actual video toggle
|
||||
})
|
||||
};
|
||||
|
||||
let on_toggle_screen_share = {
|
||||
let screen_share_enabled = screen_share_enabled.clone();
|
||||
Callback::from(move |_| {
|
||||
screen_share_enabled.set(!*screen_share_enabled);
|
||||
// TODO: Implement actual screen share toggle
|
||||
})
|
||||
};
|
||||
|
||||
let on_toggle_chat = {
|
||||
let chat_visible = chat_visible.clone();
|
||||
Callback::from(move |_| {
|
||||
chat_visible.set(!*chat_visible);
|
||||
})
|
||||
};
|
||||
|
||||
let on_toggle_settings = {
|
||||
let settings_visible = settings_visible.clone();
|
||||
Callback::from(move |_| {
|
||||
settings_visible.set(!*settings_visible);
|
||||
})
|
||||
};
|
||||
|
||||
let on_leave_room = {
|
||||
let navigator = navigator.clone();
|
||||
Callback::from(move |_| {
|
||||
navigator.push(&crate::app::Route::Home);
|
||||
})
|
||||
};
|
||||
|
||||
let on_send_message = {
|
||||
let chat_messages = chat_messages.clone();
|
||||
Callback::from(move |message: String| {
|
||||
let mut messages = (*chat_messages).clone();
|
||||
messages.push(ChatMessage {
|
||||
id: format!("msg_{}", messages.len()),
|
||||
author: "You".to_string(),
|
||||
content: message,
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
chat_messages.set(messages);
|
||||
// TODO: Send message via LiveKit
|
||||
})
|
||||
};
|
||||
|
||||
let on_close_settings = {
|
||||
let settings_visible = settings_visible.clone();
|
||||
Callback::from(move |_| {
|
||||
settings_visible.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
// Initialize connection when component mounts
|
||||
use_effect_with(url_params.clone(), {
|
||||
let connected = connected.clone();
|
||||
|
||||
move |params| {
|
||||
let (livekit_url, token, passphrase) = (**params).clone();
|
||||
if let (Some(url), Some(token)) = (livekit_url, token) {
|
||||
// TODO: Initialize LiveKit connection here
|
||||
log::info!("Connecting to LiveKit: {} with E2EE: {}", url, passphrase.is_some());
|
||||
connected.set(true);
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
// Create local participant outside of HTML
|
||||
let local_participant = Participant {
|
||||
id: "local".to_string(),
|
||||
name: "You".to_string(),
|
||||
is_local: true,
|
||||
audio_enabled: *local_audio_enabled,
|
||||
video_enabled: *local_video_enabled,
|
||||
screen_share_enabled: *screen_share_enabled,
|
||||
};
|
||||
|
||||
html! {
|
||||
<main class="app">
|
||||
{if let Some(error) = &*validation_error {
|
||||
html! {
|
||||
<div class="error-container">
|
||||
<div class="error-card">
|
||||
<h2>{"Connection Error"}</h2>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
class="primary-button"
|
||||
onclick={Callback::from(move |_| {
|
||||
navigator.push(&crate::app::Route::Home);
|
||||
})}
|
||||
>
|
||||
{"Go Back"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else if *connected {
|
||||
html! {
|
||||
<>
|
||||
<div class="room-header">
|
||||
<div class="room-title">
|
||||
{if let Some(url) = &livekit_url {
|
||||
format!("Connected to: {}", url)
|
||||
} else {
|
||||
"Custom Room".to_string()
|
||||
}}
|
||||
</div>
|
||||
<div class="connection-status">
|
||||
<span class="status-connected">{"Connected"}</span>
|
||||
</div>
|
||||
<MediaControls
|
||||
audio_enabled={*local_audio_enabled}
|
||||
video_enabled={*local_video_enabled}
|
||||
screen_share_enabled={*screen_share_enabled}
|
||||
on_toggle_audio={on_toggle_audio}
|
||||
on_toggle_video={on_toggle_video}
|
||||
on_toggle_screen_share={on_toggle_screen_share}
|
||||
on_toggle_chat={on_toggle_chat}
|
||||
on_toggle_settings={on_toggle_settings}
|
||||
on_leave_room={on_leave_room}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="video-grid-container">
|
||||
<div class="video-grid">
|
||||
{for participants.values().map(|participant| {
|
||||
html! {
|
||||
<VideoTile
|
||||
key={participant.id.clone()}
|
||||
participant={participant.clone()}
|
||||
/>
|
||||
}
|
||||
})}
|
||||
|
||||
<VideoTile
|
||||
key="local"
|
||||
participant={local_participant.clone()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{if *chat_visible {
|
||||
html! {
|
||||
<ChatSidebar
|
||||
messages={(*chat_messages).clone()}
|
||||
on_send_message={on_send_message}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
{if *settings_visible {
|
||||
html! {
|
||||
<SettingsMenu
|
||||
on_close={on_close_settings}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="loading">{"Connecting..."}</div>
|
||||
}
|
||||
}}
|
||||
|
||||
{if let Some(error) = &*error_message {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</main>
|
||||
}
|
||||
}
|
248
examples/meet/src/pages/home.rs
Normal file
248
examples/meet/src/pages/home.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use gloo_utils::format::JsValueSerdeExt;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::utils::{generate_room_id, random_string, encode_passphrase};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum ActiveTab {
|
||||
Demo,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[function_component(HomePage)]
|
||||
pub fn home_page() -> Html {
|
||||
let navigator = use_navigator().unwrap();
|
||||
let active_tab = use_state(|| ActiveTab::Demo);
|
||||
let e2ee_enabled = use_state(|| false);
|
||||
let passphrase = use_state(|| random_string(64));
|
||||
let server_url = use_state(|| String::new());
|
||||
let token = use_state(|| String::new());
|
||||
|
||||
let on_tab_select = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |tab: ActiveTab| {
|
||||
active_tab.set(tab);
|
||||
})
|
||||
};
|
||||
|
||||
let on_start_meeting = {
|
||||
let navigator = navigator.clone();
|
||||
let e2ee_enabled = e2ee_enabled.clone();
|
||||
let passphrase = passphrase.clone();
|
||||
Callback::from(move |_| {
|
||||
let room_id = generate_room_id();
|
||||
if *e2ee_enabled {
|
||||
let encoded_passphrase = encode_passphrase(&passphrase);
|
||||
navigator.push(&Route::Room {
|
||||
room_name: format!("{}#{}", room_id, encoded_passphrase)
|
||||
});
|
||||
} else {
|
||||
navigator.push(&Route::Room { room_name: room_id });
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_custom_submit = {
|
||||
let navigator = navigator.clone();
|
||||
let server_url = server_url.clone();
|
||||
let token = token.clone();
|
||||
let e2ee_enabled = e2ee_enabled.clone();
|
||||
let passphrase = passphrase.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
let mut url = "/custom?".to_string();
|
||||
url.push_str(&format!("liveKitUrl={}&token={}",
|
||||
urlencoding::encode(&server_url),
|
||||
urlencoding::encode(&token)
|
||||
));
|
||||
|
||||
if *e2ee_enabled {
|
||||
url.push_str(&format!("#{}", encode_passphrase(&passphrase)));
|
||||
}
|
||||
|
||||
navigator.push_with_query(&Route::Custom, &url).unwrap_or_default();
|
||||
})
|
||||
};
|
||||
|
||||
let on_e2ee_change = {
|
||||
let e2ee_enabled = e2ee_enabled.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
e2ee_enabled.set(input.checked());
|
||||
})
|
||||
};
|
||||
|
||||
let on_passphrase_change = {
|
||||
let passphrase = passphrase.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
passphrase.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_server_url_change = {
|
||||
let server_url = server_url.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
server_url.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_token_change = {
|
||||
let token = token.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
token.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<main class="home-container">
|
||||
<div class="home-header">
|
||||
<h1>{"LiveKit Meet"}</h1>
|
||||
<p>{"Open source video conferencing app built with LiveKit and Yew"}</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs-container">
|
||||
<div class="tab-select">
|
||||
<button
|
||||
class={classes!("tab-button", (*active_tab == ActiveTab::Demo).then(|| "active"))}
|
||||
onclick={on_tab_select.reform(|_| ActiveTab::Demo)}
|
||||
>
|
||||
{"Demo"}
|
||||
</button>
|
||||
<button
|
||||
class={classes!("tab-button", (*active_tab == ActiveTab::Custom).then(|| "active"))}
|
||||
onclick={on_tab_select.reform(|_| ActiveTab::Custom)}
|
||||
>
|
||||
{"Custom"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
{match *active_tab {
|
||||
ActiveTab::Demo => html! {
|
||||
<>
|
||||
<p style="margin: 0;">{"Try LiveKit Meet for free with our live demo project."}</p>
|
||||
<button
|
||||
class="primary-button"
|
||||
style="margin-top: 1rem;"
|
||||
onclick={on_start_meeting}
|
||||
>
|
||||
{"Start Meeting"}
|
||||
</button>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input
|
||||
id="use-e2ee"
|
||||
type="checkbox"
|
||||
checked={*e2ee_enabled}
|
||||
onchange={on_e2ee_change.clone()}
|
||||
/>
|
||||
<label for="use-e2ee">{"Enable end-to-end encryption"}</label>
|
||||
</div>
|
||||
|
||||
{if *e2ee_enabled {
|
||||
html! {
|
||||
<div class="form-group">
|
||||
<label for="passphrase">{"Passphrase"}</label>
|
||||
<input
|
||||
id="passphrase"
|
||||
type="password"
|
||||
value={(*passphrase).clone()}
|
||||
onchange={on_passphrase_change.clone()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</>
|
||||
},
|
||||
ActiveTab::Custom => html! {
|
||||
<form onsubmit={on_custom_submit}>
|
||||
<p style="margin-top: 0;">
|
||||
{"Connect LiveKit Meet with a custom server using LiveKit Cloud or LiveKit Server."}
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<input
|
||||
id="serverUrl"
|
||||
name="serverUrl"
|
||||
type="url"
|
||||
placeholder="LiveKit Server URL: wss://*.livekit.cloud"
|
||||
required=true
|
||||
value={(*server_url).clone()}
|
||||
onchange={on_server_url_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<textarea
|
||||
id="token"
|
||||
name="token"
|
||||
placeholder="Token"
|
||||
required=true
|
||||
rows="5"
|
||||
value={(*token).clone()}
|
||||
onchange={on_token_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input
|
||||
id="use-e2ee-custom"
|
||||
type="checkbox"
|
||||
checked={*e2ee_enabled}
|
||||
onchange={on_e2ee_change}
|
||||
/>
|
||||
<label for="use-e2ee-custom">{"Enable end-to-end encryption"}</label>
|
||||
</div>
|
||||
|
||||
{if *e2ee_enabled {
|
||||
html! {
|
||||
<div class="form-group">
|
||||
<label for="passphrase-custom">{"Passphrase"}</label>
|
||||
<input
|
||||
id="passphrase-custom"
|
||||
type="password"
|
||||
value={(*passphrase).clone()}
|
||||
onchange={on_passphrase_change}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<hr style="width: 100%; border-color: rgba(255, 255, 255, 0.15); margin-block: 1rem;" />
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
>
|
||||
{"Connect"}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer style="margin-top: 3rem; text-align: center; color: var(--lk-fg-2);">
|
||||
{"Hosted on "}
|
||||
<a href="https://livekit.io/cloud?ref=meet" rel="noopener" style="color: var(--lk-accent);">
|
||||
{"LiveKit Cloud"}
|
||||
</a>
|
||||
{". Source code on "}
|
||||
<a href="https://github.com/livekit/meet?ref=meet" rel="noopener" style="color: var(--lk-accent);">
|
||||
{"GitHub"}
|
||||
</a>
|
||||
{"."}
|
||||
</footer>
|
||||
</main>
|
||||
}
|
||||
}
|
7
examples/meet/src/pages/mod.rs
Normal file
7
examples/meet/src/pages/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod home;
|
||||
pub mod room;
|
||||
pub mod custom;
|
||||
|
||||
pub use home::HomePage;
|
||||
pub use room::RoomPage;
|
||||
pub use custom::CustomPage;
|
758
examples/meet/src/pages/room.rs
Normal file
758
examples/meet/src/pages/room.rs
Normal file
@@ -0,0 +1,758 @@
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use web_sys::{WebSocket, MessageEvent, CloseEvent, RtcPeerConnection, RtcConfiguration, RtcIceServer};
|
||||
use gloo_net::http::Request;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::components::{VideoTile, ChatSidebar, SettingsMenu, MediaControls, PreJoin};
|
||||
use crate::utils::{decode_passphrase, get_connection_details_endpoint};
|
||||
use crate::livekit_js_client::{LiveKitJsClient, LiveKitEvent, ParticipantInfo};
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConnectionDetails {
|
||||
pub server_url: String,
|
||||
pub participant_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct LocalUserChoices {
|
||||
pub username: String,
|
||||
pub video_enabled: bool,
|
||||
pub audio_enabled: bool,
|
||||
pub video_device_id: Option<String>,
|
||||
pub audio_device_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Participant {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_local: bool,
|
||||
pub audio_enabled: bool,
|
||||
pub video_enabled: bool,
|
||||
pub screen_share_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub author: String,
|
||||
pub content: String,
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RoomPageProps {
|
||||
pub room_name: String,
|
||||
}
|
||||
|
||||
pub enum RoomMsg {
|
||||
PreJoinSubmit(LocalUserChoices),
|
||||
PreJoinError(String),
|
||||
ConnectionDetailsReceived(ConnectionDetails),
|
||||
ConnectionDetailsError(String),
|
||||
RoomConnected,
|
||||
RoomConnectionError(String),
|
||||
ParticipantJoined(Participant),
|
||||
ParticipantLeft(String),
|
||||
ParticipantUpdated(Participant),
|
||||
ChatMessageReceived(ChatMessage),
|
||||
SendChatMessage(String),
|
||||
ToggleAudio,
|
||||
ToggleVideo,
|
||||
ToggleScreenShare,
|
||||
ToggleChat,
|
||||
ToggleSettings,
|
||||
LeaveRoom,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum RoomState {
|
||||
PreJoin,
|
||||
Joining,
|
||||
InRoom,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RoomPageState {
|
||||
pub room_state: RoomState,
|
||||
pub pre_join_choices: Option<LocalUserChoices>,
|
||||
pub connection_details: Option<ConnectionDetails>,
|
||||
pub participants: HashMap<String, Participant>,
|
||||
pub chat_messages: Vec<ChatMessage>,
|
||||
pub chat_visible: bool,
|
||||
pub settings_visible: bool,
|
||||
pub local_audio_enabled: bool,
|
||||
pub local_video_enabled: bool,
|
||||
pub screen_share_enabled: bool,
|
||||
pub error_message: Option<String>,
|
||||
pub loading: bool,
|
||||
pub livekit_room: Option<LiveKitJsClient>,
|
||||
}
|
||||
|
||||
impl Default for RoomPageState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
room_state: RoomState::PreJoin,
|
||||
pre_join_choices: None,
|
||||
connection_details: None,
|
||||
participants: HashMap::new(),
|
||||
chat_messages: Vec::new(),
|
||||
chat_visible: false,
|
||||
settings_visible: false,
|
||||
local_audio_enabled: true,
|
||||
local_video_enabled: true,
|
||||
screen_share_enabled: false,
|
||||
error_message: None,
|
||||
loading: false,
|
||||
livekit_room: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(RoomPage)]
|
||||
pub fn room_page(props: &RoomPageProps) -> Html {
|
||||
let navigator = yew_router::prelude::use_navigator().unwrap();
|
||||
let state = use_state(RoomPageState::default);
|
||||
|
||||
// Parse room name and extract passphrase if present
|
||||
let (room_name, _passphrase) = if props.room_name.contains('#') {
|
||||
let parts: Vec<&str> = props.room_name.split('#').collect();
|
||||
(parts[0].to_string(), Some(decode_passphrase(parts[1])))
|
||||
} else {
|
||||
(props.room_name.clone(), None)
|
||||
};
|
||||
|
||||
// Note: Auto-transition removed - LiveKit Connected event handles state transition
|
||||
|
||||
let on_message = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |msg: RoomMsg| {
|
||||
let mut new_state = (*state).clone();
|
||||
|
||||
match msg {
|
||||
RoomMsg::PreJoinSubmit(choices) => {
|
||||
web_sys::console::log_1(&"PreJoinSubmit received".into());
|
||||
new_state.pre_join_choices = Some(choices.clone());
|
||||
new_state.loading = true;
|
||||
web_sys::console::log_1(&"State updated: loading=true".into());
|
||||
|
||||
// Fetch connection details from backend
|
||||
let room_name = room_name.clone();
|
||||
let username = choices.username.clone();
|
||||
let state_clone = state.clone();
|
||||
spawn_local(async move {
|
||||
web_sys::console::log_1(&"Fetching connection details...".into());
|
||||
match fetch_connection_details(&room_name, &username).await {
|
||||
Ok(details) => {
|
||||
web_sys::console::log_1(&"Connection details fetched successfully".into());
|
||||
// Directly update state instead of using callback
|
||||
let mut new_state = (*state_clone).clone();
|
||||
new_state.connection_details = Some(details.clone());
|
||||
new_state.loading = false;
|
||||
new_state.room_state = RoomState::Joining;
|
||||
// Preserve pre_join_choices from the current state
|
||||
web_sys::console::log_1(&format!("Preserving pre_join_choices: {}", new_state.pre_join_choices.is_some()).into());
|
||||
state_clone.set(new_state);
|
||||
|
||||
// Start LiveKit connection
|
||||
spawn_local(async move {
|
||||
web_sys::console::log_1(&"Starting LiveKit connection...".into());
|
||||
match connect_to_livekit_room_full(&details, state_clone.clone(), &username).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Successfully connected to LiveKit room".into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to connect to LiveKit room: {}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to fetch connection details: {}", e).into());
|
||||
let mut new_state = (*state_clone).clone();
|
||||
new_state.error_message = Some(format!("Failed to get connection details: {}", e));
|
||||
new_state.loading = false;
|
||||
state_clone.set(new_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
RoomMsg::PreJoinError(error) => {
|
||||
new_state.error_message = Some(error);
|
||||
new_state.loading = false;
|
||||
}
|
||||
RoomMsg::ConnectionDetailsReceived(details) => {
|
||||
web_sys::console::log_1(&"Received connection details, transitioning to Joining state".into());
|
||||
new_state.connection_details = Some(details.clone());
|
||||
new_state.loading = false;
|
||||
new_state.room_state = RoomState::Joining;
|
||||
web_sys::console::log_1(&"State updated to Joining".into());
|
||||
|
||||
// Initialize LiveKit room connection
|
||||
let details_clone = details.clone();
|
||||
spawn_local(async move {
|
||||
web_sys::console::log_1(&"Starting WebSocket connection...".into());
|
||||
match connect_to_livekit_room(&details_clone).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Successfully connected to LiveKit room".into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to connect to room: {}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
RoomMsg::ConnectionDetailsError(error) => {
|
||||
new_state.error_message = Some(format!("Failed to get connection details: {}", error));
|
||||
new_state.loading = false;
|
||||
}
|
||||
RoomMsg::RoomConnected => {
|
||||
new_state.loading = false;
|
||||
new_state.room_state = RoomState::InRoom;
|
||||
web_sys::console::log_1(&"Room state updated to InRoom".into());
|
||||
}
|
||||
RoomMsg::RoomConnectionError(error) => {
|
||||
new_state.error_message = Some(format!("Failed to connect to room: {}", error));
|
||||
new_state.loading = false;
|
||||
new_state.room_state = RoomState::PreJoin;
|
||||
}
|
||||
RoomMsg::ParticipantJoined(participant) => {
|
||||
new_state.participants.insert(participant.id.clone(), participant);
|
||||
}
|
||||
RoomMsg::ParticipantLeft(participant_id) => {
|
||||
new_state.participants.remove(&participant_id);
|
||||
}
|
||||
RoomMsg::ParticipantUpdated(participant) => {
|
||||
new_state.participants.insert(participant.id.clone(), participant);
|
||||
}
|
||||
RoomMsg::ChatMessageReceived(message) => {
|
||||
new_state.chat_messages.push(message);
|
||||
}
|
||||
RoomMsg::SendChatMessage(content) => {
|
||||
if !content.trim().is_empty() {
|
||||
// Add message to local chat immediately for sender
|
||||
let message = ChatMessage {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
author: new_state.pre_join_choices.as_ref()
|
||||
.map(|c| c.username.clone())
|
||||
.unwrap_or_default(),
|
||||
content: content.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
new_state.chat_messages.push(message);
|
||||
|
||||
// Send message via LiveKit client to other participants
|
||||
if let Some(livekit_client) = &new_state.livekit_room {
|
||||
let client_clone = livekit_client.clone();
|
||||
let content_clone = content.clone();
|
||||
spawn_local(async move {
|
||||
match client_clone.send_chat_message(&content_clone).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"✅ Chat message sent via LiveKit".into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("❌ Failed to send chat message: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
RoomMsg::ToggleAudio => {
|
||||
let new_audio_state = !new_state.local_audio_enabled;
|
||||
new_state.local_audio_enabled = new_audio_state;
|
||||
|
||||
// Update LiveKit audio state
|
||||
if let Some(livekit_client) = &new_state.livekit_room {
|
||||
let client_clone = livekit_client.clone();
|
||||
spawn_local(async move {
|
||||
match client_clone.set_microphone_enabled(new_audio_state).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&format!("✅ Audio {} via LiveKit", if new_audio_state { "enabled" } else { "disabled" }).into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("❌ Failed to toggle audio: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
RoomMsg::ToggleVideo => {
|
||||
let new_video_state = !new_state.local_video_enabled;
|
||||
new_state.local_video_enabled = new_video_state;
|
||||
|
||||
// Update LiveKit video state
|
||||
if let Some(livekit_client) = &new_state.livekit_room {
|
||||
let client_clone = livekit_client.clone();
|
||||
spawn_local(async move {
|
||||
match client_clone.set_camera_enabled(new_video_state).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&format!("✅ Video {} via LiveKit", if new_video_state { "enabled" } else { "disabled" }).into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("❌ Failed to toggle video: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
RoomMsg::ToggleScreenShare => {
|
||||
let new_screen_share_state = !new_state.screen_share_enabled;
|
||||
new_state.screen_share_enabled = new_screen_share_state;
|
||||
|
||||
// Update LiveKit screen share state
|
||||
if let Some(livekit_client) = &new_state.livekit_room {
|
||||
let client_clone = livekit_client.clone();
|
||||
spawn_local(async move {
|
||||
match client_clone.set_screen_share_enabled(new_screen_share_state).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&format!("✅ Screen share {} via LiveKit", if new_screen_share_state { "enabled" } else { "disabled" }).into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("❌ Failed to toggle screen share: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
RoomMsg::ToggleChat => {
|
||||
new_state.chat_visible = !new_state.chat_visible;
|
||||
}
|
||||
RoomMsg::ToggleSettings => {
|
||||
new_state.settings_visible = !new_state.settings_visible;
|
||||
}
|
||||
RoomMsg::LeaveRoom => {
|
||||
// TODO: Disconnect from LiveKit room
|
||||
navigator.push(&crate::app::Route::Home);
|
||||
}
|
||||
RoomMsg::Error(error) => {
|
||||
new_state.error_message = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
state.set(new_state);
|
||||
})
|
||||
};
|
||||
|
||||
let pre_join_defaults = LocalUserChoices {
|
||||
username: String::new(),
|
||||
video_enabled: true,
|
||||
audio_enabled: true,
|
||||
video_device_id: None,
|
||||
audio_device_id: None,
|
||||
};
|
||||
|
||||
html! {
|
||||
<main class="room-container">
|
||||
{match state.room_state {
|
||||
RoomState::PreJoin => html! {
|
||||
<div class="prejoin-container">
|
||||
<PreJoin
|
||||
defaults={pre_join_defaults}
|
||||
on_submit={on_message.reform(RoomMsg::PreJoinSubmit)}
|
||||
on_error={on_message.reform(RoomMsg::PreJoinError)}
|
||||
loading={state.loading}
|
||||
/>
|
||||
</div>
|
||||
},
|
||||
RoomState::Joining => html! {
|
||||
<div class="joining-container">
|
||||
<div class="joining-message">
|
||||
<div class="spinner"></div>
|
||||
<h2>{"Joining room..."}</h2>
|
||||
<p>{"Connecting to LiveKit server"}</p>
|
||||
</div>
|
||||
</div>
|
||||
},
|
||||
RoomState::InRoom => {
|
||||
html! {
|
||||
<>
|
||||
<div class="room-header">
|
||||
<div class="room-title">
|
||||
{format!("Room: {}", props.room_name)}
|
||||
</div>
|
||||
<MediaControls
|
||||
audio_enabled={state.local_audio_enabled}
|
||||
video_enabled={state.local_video_enabled}
|
||||
screen_share_enabled={state.screen_share_enabled}
|
||||
on_toggle_audio={on_message.reform(|_| RoomMsg::ToggleAudio)}
|
||||
on_toggle_video={on_message.reform(|_| RoomMsg::ToggleVideo)}
|
||||
on_toggle_screen_share={on_message.reform(|_| RoomMsg::ToggleScreenShare)}
|
||||
on_toggle_chat={on_message.reform(|_| RoomMsg::ToggleChat)}
|
||||
on_toggle_settings={on_message.reform(|_| RoomMsg::ToggleSettings)}
|
||||
on_leave_room={on_message.reform(|_| RoomMsg::LeaveRoom)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="video-grid-container">
|
||||
<div class="video-grid">
|
||||
{for state.participants.values().map(|participant| {
|
||||
html! {
|
||||
<VideoTile
|
||||
key={participant.id.clone()}
|
||||
participant={participant.clone()}
|
||||
/>
|
||||
}
|
||||
})}
|
||||
|
||||
// Only show local participant tile if no local participant in participants list
|
||||
{if !state.participants.values().any(|p| p.is_local) {
|
||||
create_local_participant_tile(&state)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
{if state.chat_visible {
|
||||
html! {
|
||||
<ChatSidebar
|
||||
messages={state.chat_messages.clone()}
|
||||
on_send_message={on_message.reform(RoomMsg::SendChatMessage)}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
{if state.settings_visible {
|
||||
html! {
|
||||
<SettingsMenu
|
||||
on_close={on_message.reform(|_| RoomMsg::ToggleSettings)}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
{if let Some(error) = &state.error_message {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
fn create_local_participant_tile(state: &RoomPageState) -> Html {
|
||||
// Get username from pre_join_choices or use a default
|
||||
let username = state.pre_join_choices
|
||||
.as_ref()
|
||||
.map(|c| c.username.clone())
|
||||
.unwrap_or_else(|| "You".to_string());
|
||||
|
||||
let local_participant = Participant {
|
||||
id: "local".to_string(),
|
||||
name: username,
|
||||
is_local: true,
|
||||
audio_enabled: state.local_audio_enabled,
|
||||
video_enabled: state.local_video_enabled,
|
||||
screen_share_enabled: state.screen_share_enabled,
|
||||
};
|
||||
|
||||
html! {
|
||||
<VideoTile
|
||||
key="local"
|
||||
participant={local_participant}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_to_livekit_room_full(details: &ConnectionDetails, state_handle: UseStateHandle<RoomPageState>, username: &str) -> Result<(), String> {
|
||||
let mut livekit_client = LiveKitJsClient::new();
|
||||
|
||||
// Create shared state that can be updated across callbacks
|
||||
let mut initial_state = (*state_handle).clone();
|
||||
initial_state.livekit_room = Some(livekit_client.clone());
|
||||
let shared_state = Rc::new(RefCell::new(initial_state));
|
||||
let state_handle_clone = state_handle.clone();
|
||||
let details_clone = details.clone();
|
||||
let client_clone = livekit_client.clone();
|
||||
|
||||
// Use the username parameter directly for local participant detection
|
||||
let current_username = username.to_string();
|
||||
|
||||
// Debug logging for username capture
|
||||
web_sys::console::log_1(&format!("🔧 Using username for event callback: '{}'", current_username).into());
|
||||
|
||||
let shared_state_clone = shared_state.clone();
|
||||
livekit_client.set_event_callback(move |event| {
|
||||
web_sys::console::log_1(&format!("Processing LiveKit event: {:?}", event).into());
|
||||
|
||||
// CRITICAL FIX: Use shared mutable state that persists between events
|
||||
let mut new_state = shared_state_clone.borrow().clone();
|
||||
|
||||
// Debug: Log current participants before processing
|
||||
web_sys::console::log_1(&format!("🔍 Current participants before event: {}", new_state.participants.len()).into());
|
||||
|
||||
match event {
|
||||
LiveKitEvent::Connected => {
|
||||
web_sys::console::log_1(&"✅ Connected to LiveKit room successfully".into());
|
||||
new_state.room_state = RoomState::InRoom;
|
||||
new_state.loading = false;
|
||||
new_state.connection_details = Some(details_clone.clone());
|
||||
web_sys::console::log_1(&format!("🏠 Room state set to InRoom after Connected event").into());
|
||||
}
|
||||
LiveKitEvent::ParticipantConnected(participant_info) => {
|
||||
web_sys::console::log_1(&format!("👥 ParticipantConnected event - SID: {}, Identity: {}, Name: {}",
|
||||
participant_info.sid, participant_info.identity, participant_info.name).into());
|
||||
|
||||
// CRITICAL FIX: Ensure room state stays InRoom (don't let it revert to PreJoin)
|
||||
if new_state.room_state != RoomState::InRoom {
|
||||
web_sys::console::log_1(&format!("🔧 Fixing room state from {:?} to InRoom", new_state.room_state).into());
|
||||
new_state.room_state = RoomState::InRoom;
|
||||
new_state.connection_details = Some(details_clone.clone());
|
||||
}
|
||||
|
||||
// Skip if participant already exists (prevent duplicates)
|
||||
if !new_state.participants.contains_key(&participant_info.sid) {
|
||||
web_sys::console::log_1(&format!("📊 Before adding participant - Total participants: {}", new_state.participants.len()).into());
|
||||
|
||||
// Determine if this is the local participant using captured username
|
||||
let is_local = participant_info.identity == current_username;
|
||||
|
||||
// Debug logging for local participant detection
|
||||
web_sys::console::log_1(&format!("🔍 Local participant check - Identity: '{}', Username: '{}', Is Local: {}",
|
||||
participant_info.identity, current_username, is_local).into());
|
||||
|
||||
let participant = Participant {
|
||||
id: participant_info.sid.clone(),
|
||||
name: if participant_info.name.is_empty() { participant_info.identity.clone() } else { participant_info.name.clone() },
|
||||
is_local,
|
||||
audio_enabled: !participant_info.audio_tracks.is_empty(),
|
||||
video_enabled: !participant_info.video_tracks.is_empty(),
|
||||
screen_share_enabled: false,
|
||||
};
|
||||
|
||||
new_state.participants.insert(participant_info.sid.clone(), participant);
|
||||
web_sys::console::log_1(&format!("📊 After adding participant - Total participants: {}", new_state.participants.len()).into());
|
||||
web_sys::console::log_1(&format!("✅ Added participant: {} ({}) - Local: {}", participant_info.name, participant_info.sid, is_local).into());
|
||||
web_sys::console::log_1(&format!("🏠 Room state preserved as: {:?}", new_state.room_state).into());
|
||||
} else {
|
||||
web_sys::console::log_1(&format!("⚠️ Participant {} already exists, skipping duplicate", participant_info.sid).into());
|
||||
}
|
||||
}
|
||||
LiveKitEvent::ParticipantDisconnected(sid) => {
|
||||
new_state.participants.remove(&sid);
|
||||
web_sys::console::log_1(&format!("Removed participant: {}", sid).into());
|
||||
}
|
||||
LiveKitEvent::TrackSubscribed { participant_sid, track_sid, kind } => {
|
||||
web_sys::console::log_1(&format!("🎥 TrackSubscribed event - Participant: {}, Track: {}, Kind: {}",
|
||||
participant_sid, track_sid, kind).into());
|
||||
|
||||
// Update participant state to reflect track availability
|
||||
if let Some(participant) = new_state.participants.get_mut(&participant_sid) {
|
||||
if kind == "video" {
|
||||
participant.video_enabled = true;
|
||||
web_sys::console::log_1(&format!("🎬 Setting video_enabled=true for participant: {}", participant_sid).into());
|
||||
} else if kind == "audio" {
|
||||
participant.audio_enabled = true;
|
||||
web_sys::console::log_1(&format!("🎤 Setting audio_enabled=true for participant: {}", participant_sid).into());
|
||||
}
|
||||
} else {
|
||||
web_sys::console::warn_1(&format!("❌ Participant {} not found for track state update", participant_sid).into());
|
||||
}
|
||||
|
||||
// Note: Video track attachment is now handled directly in the LiveKit client's TrackSubscribed event
|
||||
}
|
||||
LiveKitEvent::TrackUnsubscribed { participant_sid, track_sid: _ } => {
|
||||
if let Some(participant) = new_state.participants.get_mut(&participant_sid) {
|
||||
participant.video_enabled = false;
|
||||
web_sys::console::log_1(&format!("Track unsubscribed for participant: {}", participant_sid).into());
|
||||
}
|
||||
}
|
||||
LiveKitEvent::ChatMessageReceived { participant_sid, participant_name, message, timestamp } => {
|
||||
web_sys::console::log_1(&format!("💬 Chat message received from {}: {}", participant_name, message).into());
|
||||
|
||||
// Convert timestamp from f64 to chrono DateTime
|
||||
let datetime = chrono::DateTime::from_timestamp_millis(timestamp as i64)
|
||||
.unwrap_or_else(|| chrono::Utc::now());
|
||||
|
||||
let chat_message = ChatMessage {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
author: participant_name,
|
||||
content: message,
|
||||
timestamp: datetime,
|
||||
};
|
||||
|
||||
new_state.chat_messages.push(chat_message);
|
||||
web_sys::console::log_1(&format!("💬 Added chat message to state, total messages: {}", new_state.chat_messages.len()).into());
|
||||
}
|
||||
LiveKitEvent::Disconnected => {
|
||||
web_sys::console::log_1(&"LiveKit room disconnected".into());
|
||||
new_state.room_state = RoomState::PreJoin;
|
||||
new_state.participants.clear();
|
||||
new_state.connection_details = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Update both shared state and Yew state
|
||||
*shared_state_clone.borrow_mut() = new_state.clone();
|
||||
state_handle_clone.set(new_state);
|
||||
});
|
||||
|
||||
// Connect to the LiveKit room
|
||||
livekit_client.connect(&details.server_url, &details.participant_token);
|
||||
|
||||
web_sys::console::log_1(&"LiveKit connection initiated".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// Legacy function for compatibility
|
||||
async fn connect_to_livekit_room(details: &ConnectionDetails) -> Result<(), String> {
|
||||
web_sys::console::log_1(&format!("Connecting to LiveKit server: {}", details.server_url).into());
|
||||
web_sys::console::log_1(&format!("Using token: {}", details.participant_token).into());
|
||||
|
||||
// Create WebSocket connection to LiveKit server
|
||||
// LiveKit uses secure WebSocket connections, construct proper URL
|
||||
let ws_url = if details.server_url.starts_with("wss://") || details.server_url.starts_with("ws://") {
|
||||
// URL already has WebSocket protocol, use as-is
|
||||
details.server_url.clone()
|
||||
} else if details.server_url.starts_with("http://") {
|
||||
details.server_url.replace("http://", "ws://")
|
||||
} else if details.server_url.starts_with("https://") {
|
||||
details.server_url.replace("https://", "wss://")
|
||||
} else {
|
||||
// No protocol specified, assume secure WebSocket
|
||||
format!("wss://{}", details.server_url)
|
||||
};
|
||||
|
||||
// Add LiveKit WebSocket path and token
|
||||
let ws_url = format!("{}/rtc?access_token={}", ws_url, details.participant_token);
|
||||
|
||||
web_sys::console::log_1(&format!("Connecting to WebSocket URL: {}", ws_url).into());
|
||||
|
||||
let ws = WebSocket::new(&ws_url).map_err(|e| format!("Failed to create WebSocket: {:?}", e))?;
|
||||
|
||||
// Set up WebSocket event handlers
|
||||
let onopen_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
|
||||
web_sys::console::log_1(&"WebSocket connection opened".into());
|
||||
|
||||
// LiveKit uses binary protocol, not JSON messages
|
||||
// The join happens automatically when connecting with a valid token
|
||||
web_sys::console::log_1(&"Connected to LiveKit room".into());
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
|
||||
onopen_callback.forget();
|
||||
|
||||
let onmessage_callback = Closure::wrap(Box::new(move |event: MessageEvent| {
|
||||
// LiveKit uses binary protocol (protobuf), not text messages
|
||||
if let Ok(data) = event.data().dyn_into::<js_sys::ArrayBuffer>() {
|
||||
web_sys::console::log_1(&format!("Received binary message of {} bytes", data.byte_length()).into());
|
||||
|
||||
// For now, simulate participant joining when we receive messages
|
||||
// In a full implementation, we'd parse the protobuf to extract actual participant data
|
||||
// LiveKit SDK handles participant management automatically
|
||||
|
||||
} else if let Ok(data) = event.data().dyn_into::<js_sys::JsString>() {
|
||||
let message: String = data.into();
|
||||
web_sys::console::log_1(&format!("Received text message: {}", message).into());
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
|
||||
onmessage_callback.forget();
|
||||
|
||||
let onerror_callback = Closure::wrap(Box::new(move |event: web_sys::Event| {
|
||||
web_sys::console::error_1(&format!("WebSocket error: {:?}", event).into());
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
|
||||
onerror_callback.forget();
|
||||
|
||||
let onclose_callback = Closure::wrap(Box::new(move |event: CloseEvent| {
|
||||
web_sys::console::log_1(&format!("WebSocket closed: code={}, reason={}",
|
||||
event.code(), event.reason()).into());
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
ws.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
|
||||
onclose_callback.forget();
|
||||
|
||||
// Set up WebRTC peer connection for media
|
||||
setup_webrtc_connection().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_livekit_message(message: &str) {
|
||||
web_sys::console::log_1(&format!("Processing LiveKit message: {}", message).into());
|
||||
|
||||
// TODO: Parse JSON and handle different message types:
|
||||
// - join response
|
||||
// - participant updates
|
||||
// - track updates
|
||||
// - offer/answer/ice candidates
|
||||
}
|
||||
|
||||
async fn setup_webrtc_connection() -> Result<(), String> {
|
||||
web_sys::console::log_1(&"Setting up WebRTC peer connection".into());
|
||||
|
||||
// Create RTC configuration with STUN servers
|
||||
let rtc_config = RtcConfiguration::new();
|
||||
let ice_servers = js_sys::Array::new();
|
||||
|
||||
let stun_server = RtcIceServer::new();
|
||||
stun_server.set_urls(&js_sys::Array::of1(&"stun:stun.l.google.com:19302".into()));
|
||||
ice_servers.push(&stun_server);
|
||||
|
||||
rtc_config.set_ice_servers(&ice_servers);
|
||||
|
||||
// Create peer connection
|
||||
let _pc = RtcPeerConnection::new_with_configuration(&rtc_config)
|
||||
.map_err(|e| format!("Failed to create peer connection: {:?}", e))?;
|
||||
|
||||
web_sys::console::log_1(&"WebRTC peer connection created successfully".into());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_connection_details(room_name: &str, participant_name: &str) -> Result<ConnectionDetails, String> {
|
||||
let endpoint = get_connection_details_endpoint();
|
||||
let url = format!("{}?roomName={}&participantName={}", endpoint,
|
||||
urlencoding::encode(room_name),
|
||||
urlencoding::encode(participant_name)
|
||||
);
|
||||
|
||||
web_sys::console::log_1(&format!("Fetching connection details from: {}", url).into());
|
||||
|
||||
let response = Request::get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Network error fetching connection details: {}", e);
|
||||
web_sys::console::error_1(&error_msg.clone().into());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
web_sys::console::log_1(&format!("Response status: {}", response.status()).into());
|
||||
|
||||
if response.ok() {
|
||||
let connection_details = response
|
||||
.json::<ConnectionDetails>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
web_sys::console::log_1(&format!("Got connection details - Server: {}, Token length: {}",
|
||||
connection_details.server_url, connection_details.participant_token.len()).into());
|
||||
|
||||
Ok(connection_details)
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_msg = format!("Server error {}: {}", response.status(), error_text);
|
||||
web_sys::console::error_1(&error_msg.clone().into());
|
||||
Err(error_msg)
|
||||
}
|
||||
}
|
77
examples/meet/src/utils/mod.rs
Normal file
77
examples/meet/src/utils/mod.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use uuid::Uuid;
|
||||
use web_sys::window;
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
pub fn generate_room_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
pub fn random_string(length: usize) -> String {
|
||||
use js_sys::Math;
|
||||
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let chars_vec: Vec<char> = chars.chars().collect();
|
||||
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
let idx = (Math::random() * chars_vec.len() as f64).floor() as usize;
|
||||
chars_vec[idx]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn encode_passphrase(passphrase: &str) -> String {
|
||||
// Simple base64 encoding for the passphrase
|
||||
use js_sys::{Uint8Array, JSON};
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
let bytes = passphrase.as_bytes();
|
||||
let uint8_array = Uint8Array::new_with_length(bytes.len() as u32);
|
||||
uint8_array.copy_from(bytes);
|
||||
|
||||
// Use browser's btoa function for base64 encoding
|
||||
let window = window().unwrap();
|
||||
let btoa = js_sys::Reflect::get(&window, &JsValue::from_str("btoa")).unwrap();
|
||||
let btoa_fn: js_sys::Function = btoa.into();
|
||||
|
||||
let binary_string = String::from_utf8_lossy(bytes);
|
||||
let result = btoa_fn.call1(&JsValue::NULL, &JsValue::from_str(&binary_string));
|
||||
|
||||
match result {
|
||||
Ok(encoded) => encoded.as_string().unwrap_or_default(),
|
||||
Err(_) => passphrase.to_string(), // Fallback to original
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_passphrase(encoded: &str) -> String {
|
||||
// Simple base64 decoding for the passphrase
|
||||
let window = window().unwrap();
|
||||
let atob = js_sys::Reflect::get(&window, &JsValue::from_str("atob")).unwrap();
|
||||
let atob_fn: js_sys::Function = atob.into();
|
||||
|
||||
let result = atob_fn.call1(&JsValue::NULL, &JsValue::from_str(encoded));
|
||||
|
||||
match result {
|
||||
Ok(decoded) => decoded.as_string().unwrap_or_default(),
|
||||
Err(_) => encoded.to_string(), // Fallback to original
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_low_power_device() -> bool {
|
||||
// Simple heuristic to detect low-power devices
|
||||
let window = window().unwrap();
|
||||
let navigator = window.navigator();
|
||||
|
||||
// Check for mobile user agent
|
||||
if let Ok(user_agent) = navigator.user_agent() {
|
||||
user_agent.to_lowercase().contains("mobile") ||
|
||||
user_agent.to_lowercase().contains("android") ||
|
||||
user_agent.to_lowercase().contains("iphone")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_connection_details_endpoint() -> String {
|
||||
// Always use the backend server on port 8083
|
||||
"http://localhost:8083/api/connection-details".to_string()
|
||||
}
|
530
examples/meet/styles.css
Normal file
530
examples/meet/styles.css
Normal file
@@ -0,0 +1,530 @@
|
||||
/* LiveKit Meet Styles - Ported from React version */
|
||||
:root {
|
||||
--lk-bg: #070707;
|
||||
--lk-bg-2: #1a1a1a;
|
||||
--lk-fg: #ffffff;
|
||||
--lk-fg-2: #e5e5e5;
|
||||
--lk-accent: #00a2ff;
|
||||
--lk-accent-hover: #0088cc;
|
||||
--lk-border: #333333;
|
||||
--lk-border-2: #555555;
|
||||
--lk-danger: #ff5722;
|
||||
--lk-success: #4caf50;
|
||||
--lk-warning: #ff9800;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--lk-bg);
|
||||
color: var(--lk-fg);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* Home page styles */
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #070707 0%, #1a1a1a 100%);
|
||||
}
|
||||
|
||||
.home-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.home-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
background: linear-gradient(135deg, #00a2ff, #0088cc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.home-header p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--lk-fg-2);
|
||||
margin: 0;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.tab-select {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 2px solid var(--lk-border);
|
||||
background: transparent;
|
||||
color: var(--lk-fg-2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
border-color: var(--lk-accent);
|
||||
color: var(--lk-fg);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: var(--lk-accent);
|
||||
border-color: var(--lk-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
background: var(--lk-bg-2);
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--lk-fg);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 6px;
|
||||
background: var(--lk-bg);
|
||||
color: var(--lk-fg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--lk-accent);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--lk-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: var(--lk-accent-hover);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
background: var(--lk-border);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Room/Meeting styles */
|
||||
.room-container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--lk-bg);
|
||||
}
|
||||
|
||||
.room-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--lk-bg-2);
|
||||
border-bottom: 1px solid var(--lk-border);
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--lk-fg);
|
||||
}
|
||||
|
||||
.room-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--lk-border);
|
||||
background: var(--lk-bg);
|
||||
color: var(--lk-fg);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: var(--lk-bg-2);
|
||||
border-color: var(--lk-accent);
|
||||
}
|
||||
|
||||
.control-button.active {
|
||||
background: var(--lk-accent);
|
||||
border-color: var(--lk-accent);
|
||||
}
|
||||
|
||||
.control-button.danger {
|
||||
background: var(--lk-danger);
|
||||
border-color: var(--lk-danger);
|
||||
}
|
||||
|
||||
.control-button.danger:hover {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
.video-grid-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
grid-auto-rows: minmax(200px, 1fr);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.video-tile {
|
||||
position: relative;
|
||||
background: var(--lk-bg-2);
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--lk-bg);
|
||||
}
|
||||
|
||||
.participant-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: var(--lk-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.participant-status {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-indicator.muted {
|
||||
color: var(--lk-danger);
|
||||
}
|
||||
|
||||
/* Chat sidebar */
|
||||
.chat-sidebar {
|
||||
width: 300px;
|
||||
background: var(--lk-bg-2);
|
||||
border-left: 1px solid var(--lk-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--lk-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chat-message-author {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--lk-accent);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.chat-message-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--lk-border);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 6px;
|
||||
background: var(--lk-bg);
|
||||
color: var(--lk-fg);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--lk-accent);
|
||||
}
|
||||
|
||||
/* Settings menu */
|
||||
.settings-menu {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
right: 2rem;
|
||||
width: 350px;
|
||||
background: var(--lk-bg-2);
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--lk-border);
|
||||
background: transparent;
|
||||
color: var(--lk-fg-2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
background: var(--lk-accent);
|
||||
border-color: var(--lk-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--lk-fg);
|
||||
}
|
||||
|
||||
.device-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 4px;
|
||||
background: var(--lk-bg);
|
||||
color: var(--lk-fg);
|
||||
}
|
||||
|
||||
/* Pre-join screen */
|
||||
.prejoin-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: var(--lk-bg);
|
||||
}
|
||||
|
||||
.prejoin-card {
|
||||
background: var(--lk-bg-2);
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prejoin-preview {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: var(--lk-bg);
|
||||
border: 1px solid var(--lk-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prejoin-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.video-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.room-header {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.settings-menu {
|
||||
right: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading and error states */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--lk-border);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--lk-accent);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--lk-danger);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: var(--lk-success);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
Reference in New Issue
Block a user