livekit wip

This commit is contained in:
Timur Gordon
2025-08-29 15:29:24 +02:00
parent ba43a82db0
commit 7ca492346d
28 changed files with 8271 additions and 0 deletions

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
View 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
View 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
View 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
View 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>

View 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

File diff suppressed because it is too large Load Diff

View 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"] }

View 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
}
```

View 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(&params.room_name, &params.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
View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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;

View 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())
}

View 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))
}

View 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)
}

View 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
}
}

View 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;
}

View 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"))
}
}
}

View 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
View 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();
}

View 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>
}
}

View 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>
}
}

View 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;

View 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)
}
}

View 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
View 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;
}