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

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