livekit wip
This commit is contained in:
115
examples/meet/src/components/chat.rs
Normal file
115
examples/meet/src/components/chat.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlTextAreaElement;
|
||||
|
||||
use crate::pages::room::ChatMessage;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ChatSidebarProps {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub on_send_message: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component(ChatSidebar)]
|
||||
pub fn chat_sidebar(props: &ChatSidebarProps) -> Html {
|
||||
let input_ref = use_node_ref();
|
||||
let message_input = use_state(|| String::new());
|
||||
|
||||
let on_input_change = {
|
||||
let message_input = message_input.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlTextAreaElement = e.target_unchecked_into();
|
||||
message_input.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_send = {
|
||||
let message_input = message_input.clone();
|
||||
let on_send_message = props.on_send_message.clone();
|
||||
let input_ref = input_ref.clone();
|
||||
Callback::from(move |e: KeyboardEvent| {
|
||||
if e.key() == "Enter" && !e.shift_key() {
|
||||
e.prevent_default();
|
||||
let content = (*message_input).trim().to_string();
|
||||
if !content.is_empty() {
|
||||
on_send_message.emit(content);
|
||||
message_input.set(String::new());
|
||||
if let Some(input) = input_ref.cast::<HtmlTextAreaElement>() {
|
||||
input.set_value("");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_send_button = {
|
||||
let message_input = message_input.clone();
|
||||
let on_send_message = props.on_send_message.clone();
|
||||
let input_ref = input_ref.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let content = (*message_input).trim().to_string();
|
||||
if !content.is_empty() {
|
||||
on_send_message.emit(content);
|
||||
message_input.set(String::new());
|
||||
if let Some(input) = input_ref.cast::<HtmlTextAreaElement>() {
|
||||
input.set_value("");
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="chat-sidebar">
|
||||
<div class="chat-header">
|
||||
{"Chat"}
|
||||
</div>
|
||||
|
||||
<div class="chat-messages">
|
||||
{for props.messages.iter().map(|message| {
|
||||
html! {
|
||||
<div key={message.id.clone()} class="chat-message">
|
||||
<div class="chat-message-author">
|
||||
{&message.author}
|
||||
<span style="margin-left: 0.5rem; font-size: 0.75rem; color: var(--lk-fg-2);">
|
||||
{message.timestamp.format("%H:%M").to_string()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="chat-message-content">
|
||||
{&message.content}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{if props.messages.is_empty() {
|
||||
html! {
|
||||
<div style="text-align: center; color: var(--lk-fg-2); margin-top: 2rem;">
|
||||
{"No messages yet. Start the conversation!"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<textarea
|
||||
ref={input_ref}
|
||||
class="chat-input"
|
||||
placeholder="Type a message..."
|
||||
value={(*message_input).clone()}
|
||||
oninput={on_input_change}
|
||||
onkeydown={on_send}
|
||||
rows="2"
|
||||
/>
|
||||
<button
|
||||
class="primary-button"
|
||||
style="margin-top: 0.5rem; padding: 0.5rem 1rem;"
|
||||
onclick={on_send_button}
|
||||
disabled={message_input.trim().is_empty()}
|
||||
>
|
||||
{"Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
92
examples/meet/src/components/controls.rs
Normal file
92
examples/meet/src/components/controls.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MediaControlsProps {
|
||||
pub audio_enabled: bool,
|
||||
pub video_enabled: bool,
|
||||
pub screen_share_enabled: bool,
|
||||
pub on_toggle_audio: Callback<()>,
|
||||
pub on_toggle_video: Callback<()>,
|
||||
pub on_toggle_screen_share: Callback<()>,
|
||||
pub on_toggle_chat: Callback<()>,
|
||||
pub on_toggle_settings: Callback<()>,
|
||||
pub on_leave_room: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(MediaControls)]
|
||||
pub fn media_controls(props: &MediaControlsProps) -> Html {
|
||||
html! {
|
||||
<div class="room-controls">
|
||||
// Audio toggle
|
||||
<button
|
||||
class={classes!(
|
||||
"control-button",
|
||||
if props.audio_enabled { "active" } else { "" }
|
||||
)}
|
||||
onclick={props.on_toggle_audio.reform(|_| ())}
|
||||
title={if props.audio_enabled { "Mute microphone" } else { "Unmute microphone" }}
|
||||
>
|
||||
{if props.audio_enabled {
|
||||
html! { "🎤" }
|
||||
} else {
|
||||
html! { "🔇" }
|
||||
}}
|
||||
</button>
|
||||
|
||||
// Video toggle
|
||||
<button
|
||||
class={classes!(
|
||||
"control-button",
|
||||
if props.video_enabled { "active" } else { "" }
|
||||
)}
|
||||
onclick={props.on_toggle_video.reform(|_| ())}
|
||||
title={if props.video_enabled { "Turn off camera" } else { "Turn on camera" }}
|
||||
>
|
||||
{if props.video_enabled {
|
||||
html! { "📹" }
|
||||
} else {
|
||||
html! { "📷" }
|
||||
}}
|
||||
</button>
|
||||
|
||||
// Screen share toggle
|
||||
<button
|
||||
class={classes!(
|
||||
"control-button",
|
||||
if props.screen_share_enabled { "active" } else { "" }
|
||||
)}
|
||||
onclick={props.on_toggle_screen_share.reform(|_| ())}
|
||||
title={if props.screen_share_enabled { "Stop screen share" } else { "Share screen" }}
|
||||
>
|
||||
{"🖥️"}
|
||||
</button>
|
||||
|
||||
// Chat toggle
|
||||
<button
|
||||
class="control-button"
|
||||
onclick={props.on_toggle_chat.reform(|_| ())}
|
||||
title="Toggle chat"
|
||||
>
|
||||
{"💬"}
|
||||
</button>
|
||||
|
||||
// Settings toggle
|
||||
<button
|
||||
class="control-button"
|
||||
onclick={props.on_toggle_settings.reform(|_| ())}
|
||||
title="Settings"
|
||||
>
|
||||
{"⚙️"}
|
||||
</button>
|
||||
|
||||
// Leave room
|
||||
<button
|
||||
class="control-button danger"
|
||||
onclick={props.on_leave_room.reform(|_| ())}
|
||||
title="Leave room"
|
||||
>
|
||||
{"📞"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
11
examples/meet/src/components/mod.rs
Normal file
11
examples/meet/src/components/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod video_tile;
|
||||
pub mod chat;
|
||||
pub mod settings_menu;
|
||||
pub mod controls;
|
||||
pub mod prejoin;
|
||||
|
||||
pub use video_tile::VideoTile;
|
||||
pub use chat::ChatSidebar;
|
||||
pub use settings_menu::SettingsMenu;
|
||||
pub use controls::MediaControls;
|
||||
pub use prejoin::PreJoin;
|
445
examples/meet/src/components/prejoin.rs
Normal file
445
examples/meet/src/components/prejoin.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlVideoElement, MediaDevices, MediaStreamConstraints};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use js_sys::Object;
|
||||
|
||||
use crate::pages::room::LocalUserChoices;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PreJoinProps {
|
||||
pub defaults: LocalUserChoices,
|
||||
pub on_submit: Callback<LocalUserChoices>,
|
||||
pub on_error: Callback<String>,
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
#[function_component(PreJoin)]
|
||||
pub fn prejoin(props: &PreJoinProps) -> Html {
|
||||
let username = use_state(|| props.defaults.username.clone());
|
||||
let video_enabled = use_state(|| props.defaults.video_enabled);
|
||||
let audio_enabled = use_state(|| props.defaults.audio_enabled);
|
||||
let video_devices = use_state(|| Vec::<(String, String)>::new()); // (id, label)
|
||||
let audio_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let selected_video_device = use_state(|| props.defaults.video_device_id.clone());
|
||||
let selected_audio_device = use_state(|| props.defaults.audio_device_id.clone());
|
||||
let preview_stream = use_state(|| None::<web_sys::MediaStream>);
|
||||
let video_ref = use_node_ref();
|
||||
|
||||
|
||||
// Load available devices on mount
|
||||
use_effect_with((), {
|
||||
let video_devices = video_devices.clone();
|
||||
let audio_devices = audio_devices.clone();
|
||||
let on_error = props.on_error.clone();
|
||||
|
||||
move |_| {
|
||||
spawn_local(async move {
|
||||
match get_media_devices().await {
|
||||
Ok((video_devs, audio_devs)) => {
|
||||
video_devices.set(video_devs);
|
||||
audio_devices.set(audio_devs);
|
||||
}
|
||||
Err(e) => {
|
||||
on_error.emit(format!("Failed to get media devices: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
// Setup preview stream when video is enabled
|
||||
use_effect_with((*video_enabled, selected_video_device.clone()), {
|
||||
let preview_stream = preview_stream.clone();
|
||||
let on_error = props.on_error.clone();
|
||||
|
||||
move |(enabled, device_id)| {
|
||||
if *enabled {
|
||||
let preview_stream = preview_stream.clone();
|
||||
let device_id = device_id.clone();
|
||||
let on_error = on_error.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match get_user_media((*device_id).clone(), None).await {
|
||||
Ok(stream) => {
|
||||
preview_stream.set(Some(stream));
|
||||
}
|
||||
Err(e) => {
|
||||
on_error.emit(format!("Failed to access camera: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Stop existing stream
|
||||
if let Some(stream) = &*preview_stream {
|
||||
let tracks = stream.get_tracks();
|
||||
for i in 0..tracks.length() {
|
||||
let track_js = tracks.get(i);
|
||||
if let Ok(track) = track_js.dyn_into::<web_sys::MediaStreamTrack>() {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
preview_stream.set(None);
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
// Assign stream to video element when stream changes
|
||||
use_effect_with(preview_stream.clone(), {
|
||||
let video_ref = video_ref.clone();
|
||||
|
||||
move |stream| {
|
||||
if let Some(video_element) = video_ref.cast::<HtmlVideoElement>() {
|
||||
if let Some(stream) = stream.as_ref() {
|
||||
video_element.set_src_object(Some(stream));
|
||||
let _ = video_element.play();
|
||||
} else {
|
||||
video_element.set_src_object(None);
|
||||
}
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
let on_username_change = {
|
||||
let username = username.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
username.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_video_toggle = {
|
||||
let video_enabled = video_enabled.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
video_enabled.set(input.checked());
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_toggle = {
|
||||
let audio_enabled = audio_enabled.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
audio_enabled.set(input.checked());
|
||||
})
|
||||
};
|
||||
|
||||
let on_video_device_change = {
|
||||
let selected_video_device = selected_video_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let value = if select.value().is_empty() { None } else { Some(select.value()) };
|
||||
selected_video_device.set(value);
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_device_change = {
|
||||
let selected_audio_device = selected_audio_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let value = if select.value().is_empty() { None } else { Some(select.value()) };
|
||||
selected_audio_device.set(value);
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let username = username.clone();
|
||||
let video_enabled = video_enabled.clone();
|
||||
let audio_enabled = audio_enabled.clone();
|
||||
let selected_video_device = selected_video_device.clone();
|
||||
let selected_audio_device = selected_audio_device.clone();
|
||||
let on_submit = props.on_submit.clone();
|
||||
let on_error = props.on_error.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if username.trim().is_empty() {
|
||||
on_error.emit("Please enter your name".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let choices = LocalUserChoices {
|
||||
username: username.trim().to_string(),
|
||||
video_enabled: *video_enabled,
|
||||
audio_enabled: *audio_enabled,
|
||||
video_device_id: (*selected_video_device).clone(),
|
||||
audio_device_id: (*selected_audio_device).clone(),
|
||||
};
|
||||
|
||||
on_submit.emit(choices);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="prejoin-card">
|
||||
<h2 style="margin-bottom: 2rem; text-align: center;">{"Join Meeting"}</h2>
|
||||
|
||||
<div class="prejoin-preview">
|
||||
{if *video_enabled && preview_stream.is_some() {
|
||||
html! {
|
||||
<video
|
||||
ref={video_ref.clone()}
|
||||
autoplay=true
|
||||
muted=true
|
||||
playsinline=true
|
||||
style="width: 100%; height: 100%; object-fit: cover;"
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--lk-fg-2);">
|
||||
{if *video_enabled {
|
||||
"Loading camera..."
|
||||
} else {
|
||||
"Camera off"
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label for="username">{"Your name"}</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={(*username).clone()}
|
||||
onchange={on_username_change}
|
||||
placeholder="Enter your name"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="prejoin-controls">
|
||||
<div class="checkbox-group">
|
||||
<input
|
||||
id="video-enabled"
|
||||
type="checkbox"
|
||||
checked={*video_enabled}
|
||||
onchange={on_video_toggle}
|
||||
/>
|
||||
<label for="video-enabled">{"Camera"}</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input
|
||||
id="audio-enabled"
|
||||
type="checkbox"
|
||||
checked={*audio_enabled}
|
||||
onchange={on_audio_toggle}
|
||||
/>
|
||||
<label for="audio-enabled">{"Microphone"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if !video_devices.is_empty() {
|
||||
html! {
|
||||
<div class="form-group">
|
||||
<label for="video-device">{"Camera"}</label>
|
||||
<select
|
||||
id="video-device"
|
||||
class="device-select"
|
||||
onchange={on_video_device_change}
|
||||
value={selected_video_device.as_ref().unwrap_or(&String::new()).clone()}
|
||||
>
|
||||
<option value="">{"Default Camera"}</option>
|
||||
{for video_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if !audio_devices.is_empty() {
|
||||
html! {
|
||||
<div class="form-group">
|
||||
<label for="audio-device">{"Microphone"}</label>
|
||||
<select
|
||||
id="audio-device"
|
||||
class="device-select"
|
||||
onchange={on_audio_device_change}
|
||||
value={selected_audio_device.as_ref().unwrap_or(&String::new()).clone()}
|
||||
>
|
||||
<option value="">{"Default Microphone"}</option>
|
||||
{for audio_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="primary-button"
|
||||
disabled={props.loading}
|
||||
style="margin-top: 1.5rem;"
|
||||
>
|
||||
{if props.loading {
|
||||
html! {
|
||||
<>
|
||||
<span class="loading-spinner" style="margin-right: 0.5rem;"></span>
|
||||
{"Joining..."}
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! { "Join Meeting" }
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_media_devices() -> Result<(Vec<(String, String)>, Vec<(String, String)>), String> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator
|
||||
.media_devices()
|
||||
.map_err(|_| "MediaDevices not supported")?;
|
||||
|
||||
let promise = media_devices.enumerate_devices()
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
let devices = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
|
||||
let devices: js_sys::Array = devices.into();
|
||||
let mut video_devices = Vec::new();
|
||||
let mut audio_devices = Vec::new();
|
||||
|
||||
for i in 0..devices.length() {
|
||||
if let Some(device) = devices.get(i).dyn_into::<web_sys::MediaDeviceInfo>().ok() {
|
||||
let device_id = device.device_id();
|
||||
let label = device.label();
|
||||
let kind = device.kind();
|
||||
|
||||
match kind {
|
||||
web_sys::MediaDeviceKind::Videoinput => {
|
||||
video_devices.push((device_id, if label.is_empty() { "Camera".to_string() } else { label }));
|
||||
}
|
||||
web_sys::MediaDeviceKind::Audioinput => {
|
||||
audio_devices.push((device_id, if label.is_empty() { "Microphone".to_string() } else { label }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((video_devices, audio_devices))
|
||||
}
|
||||
|
||||
async fn get_user_media(video_device_id: Option<String>, audio_device_id: Option<String>) -> Result<web_sys::MediaStream, String> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator
|
||||
.media_devices()
|
||||
.map_err(|_| "MediaDevices not supported")?;
|
||||
|
||||
// First, check if devices are available
|
||||
log::info!("Checking available media devices...");
|
||||
match get_media_devices().await {
|
||||
Ok((video_devices, audio_devices)) => {
|
||||
log::info!("Found {} video devices and {} audio devices", video_devices.len(), audio_devices.len());
|
||||
if video_devices.is_empty() {
|
||||
return Err("No video devices found. Please connect a camera and refresh the page.".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not enumerate devices: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let constraints = web_sys::MediaStreamConstraints::new();
|
||||
|
||||
// Try different constraint strategies
|
||||
let video_constraints = if let Some(device_id) = video_device_id {
|
||||
log::info!("Using specific video device: {}", device_id);
|
||||
let video_constraints = Object::new();
|
||||
js_sys::Reflect::set(&video_constraints, &"deviceId".into(), &device_id.into())
|
||||
.map_err(|_| "Failed to set video device constraint")?;
|
||||
video_constraints
|
||||
} else {
|
||||
log::info!("Using default video constraints");
|
||||
// Try with minimal constraints first
|
||||
let video_constraints = Object::new();
|
||||
js_sys::Reflect::set(&video_constraints, &"width".into(), &320.into())
|
||||
.map_err(|_| "Failed to set video width")?;
|
||||
js_sys::Reflect::set(&video_constraints, &"height".into(), &240.into())
|
||||
.map_err(|_| "Failed to set video height")?;
|
||||
video_constraints
|
||||
};
|
||||
|
||||
constraints.set_video(&video_constraints.into());
|
||||
|
||||
// Set audio constraints
|
||||
if let Some(device_id) = audio_device_id {
|
||||
let audio_constraints = Object::new();
|
||||
js_sys::Reflect::set(&audio_constraints, &"deviceId".into(), &device_id.into())
|
||||
.map_err(|_| "Failed to set audio device constraint")?;
|
||||
constraints.set_audio(&audio_constraints.into());
|
||||
} else {
|
||||
constraints.set_audio(&true.into());
|
||||
}
|
||||
|
||||
log::info!("Requesting user media with constraints");
|
||||
|
||||
// Try getUserMedia with fallback strategies
|
||||
match try_get_user_media(&media_devices, &constraints).await {
|
||||
Ok(stream) => {
|
||||
log::info!("Successfully obtained media stream");
|
||||
Ok(stream)
|
||||
}
|
||||
Err(first_error) => {
|
||||
log::warn!("First attempt failed: {}", first_error);
|
||||
|
||||
// Fallback: Try with just video, no specific constraints
|
||||
log::info!("Trying fallback: basic video only");
|
||||
let fallback_constraints = web_sys::MediaStreamConstraints::new();
|
||||
fallback_constraints.set_video(&true.into());
|
||||
fallback_constraints.set_audio(&false.into());
|
||||
|
||||
match try_get_user_media(&media_devices, &fallback_constraints).await {
|
||||
Ok(stream) => {
|
||||
log::info!("Fallback successful - got video-only stream");
|
||||
Ok(stream)
|
||||
}
|
||||
Err(second_error) => {
|
||||
log::error!("All attempts failed. First error: {}, Fallback error: {}", first_error, second_error);
|
||||
Err(format!("Camera access failed. Please check:\n1. Camera is connected and not in use\n2. Browser permissions are granted\n3. Page is served over HTTPS\n\nError details: {}", first_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_get_user_media(media_devices: &web_sys::MediaDevices, constraints: &web_sys::MediaStreamConstraints) -> Result<web_sys::MediaStream, String> {
|
||||
let promise = media_devices.get_user_media_with_constraints(constraints)
|
||||
.map_err(|e| format!("Failed to create getUserMedia promise: {:?}", e))?;
|
||||
|
||||
let stream = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|e| format!("getUserMedia failed: {:?}", e))?;
|
||||
|
||||
Ok(stream.into())
|
||||
}
|
222
examples/meet/src/components/settings_menu.rs
Normal file
222
examples/meet/src/components/settings_menu.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum SettingsTab {
|
||||
Media,
|
||||
Recording,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SettingsMenuProps {
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(SettingsMenu)]
|
||||
pub fn settings_menu(props: &SettingsMenuProps) -> Html {
|
||||
let active_tab = use_state(|| SettingsTab::Media);
|
||||
let video_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let audio_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let audio_output_devices = use_state(|| Vec::<(String, String)>::new());
|
||||
let selected_video_device = use_state(|| String::new());
|
||||
let selected_audio_device = use_state(|| String::new());
|
||||
let selected_audio_output = use_state(|| String::new());
|
||||
|
||||
// Load devices on mount
|
||||
use_effect_with((), {
|
||||
let video_devices = video_devices.clone();
|
||||
let audio_devices = audio_devices.clone();
|
||||
let audio_output_devices = audio_output_devices.clone();
|
||||
|
||||
move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Ok((video_devs, audio_devs, output_devs)) = get_all_media_devices().await {
|
||||
video_devices.set(video_devs);
|
||||
audio_devices.set(audio_devs);
|
||||
audio_output_devices.set(output_devs);
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
let on_tab_change = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |tab: SettingsTab| {
|
||||
active_tab.set(tab);
|
||||
})
|
||||
};
|
||||
|
||||
let on_video_device_change = {
|
||||
let selected_video_device = selected_video_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
selected_video_device.set(select.value());
|
||||
// TODO: Update LiveKit video device
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_device_change = {
|
||||
let selected_audio_device = selected_audio_device.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
selected_audio_device.set(select.value());
|
||||
// TODO: Update LiveKit audio device
|
||||
})
|
||||
};
|
||||
|
||||
let on_audio_output_change = {
|
||||
let selected_audio_output = selected_audio_output.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
selected_audio_output.set(select.value());
|
||||
// TODO: Update LiveKit audio output device
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="settings-menu">
|
||||
<div class="settings-tabs">
|
||||
<button
|
||||
class={classes!("settings-tab", (*active_tab == SettingsTab::Media).then(|| "active"))}
|
||||
onclick={on_tab_change.reform(|_| SettingsTab::Media)}
|
||||
>
|
||||
{"Media Devices"}
|
||||
</button>
|
||||
<button
|
||||
class={classes!("settings-tab", (*active_tab == SettingsTab::Recording).then(|| "active"))}
|
||||
onclick={on_tab_change.reform(|_| SettingsTab::Recording)}
|
||||
>
|
||||
{"Recording"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
{match *active_tab {
|
||||
SettingsTab::Media => html! {
|
||||
<>
|
||||
<div class="settings-section">
|
||||
<h3>{"Camera"}</h3>
|
||||
<select
|
||||
class="device-select"
|
||||
value={(*selected_video_device).clone()}
|
||||
onchange={on_video_device_change}
|
||||
>
|
||||
<option value="">{"Default Camera"}</option>
|
||||
{for video_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>{"Microphone"}</h3>
|
||||
<select
|
||||
class="device-select"
|
||||
value={(*selected_audio_device).clone()}
|
||||
onchange={on_audio_device_change}
|
||||
>
|
||||
<option value="">{"Default Microphone"}</option>
|
||||
{for audio_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>{"Speaker & Headphones"}</h3>
|
||||
<select
|
||||
class="device-select"
|
||||
value={(*selected_audio_output).clone()}
|
||||
onchange={on_audio_output_change}
|
||||
>
|
||||
<option value="">{"Default Speaker"}</option>
|
||||
{for audio_output_devices.iter().map(|(id, label)| {
|
||||
html! {
|
||||
<option key={id.clone()} value={id.clone()}>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
},
|
||||
SettingsTab::Recording => html! {
|
||||
<div class="settings-section">
|
||||
<h3>{"Record Meeting"}</h3>
|
||||
<p>{"Recording functionality will be available when connected to a LiveKit server with recording enabled."}</p>
|
||||
<button
|
||||
class="primary-button"
|
||||
disabled=true
|
||||
style="opacity: 0.5;"
|
||||
>
|
||||
{"Start Recording"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; width: 100%; margin-top: 1.5rem;">
|
||||
<button
|
||||
class="primary-button"
|
||||
onclick={props.on_close.reform(|_| ())}
|
||||
>
|
||||
{"Close"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_all_media_devices() -> Result<(Vec<(String, String)>, Vec<(String, String)>, Vec<(String, String)>), String> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator
|
||||
.media_devices()
|
||||
.map_err(|_| "MediaDevices not supported")?;
|
||||
|
||||
let promise = media_devices.enumerate_devices()
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
let devices = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|_| "Failed to enumerate devices")?;
|
||||
|
||||
let devices: js_sys::Array = devices.into();
|
||||
let mut video_devices = Vec::new();
|
||||
let mut audio_devices = Vec::new();
|
||||
let mut audio_output_devices = Vec::new();
|
||||
|
||||
for i in 0..devices.length() {
|
||||
if let Some(device) = devices.get(i).dyn_into::<web_sys::MediaDeviceInfo>().ok() {
|
||||
let device_id = device.device_id();
|
||||
let label = device.label();
|
||||
let kind = device.kind();
|
||||
|
||||
match kind {
|
||||
web_sys::MediaDeviceKind::Videoinput => {
|
||||
video_devices.push((device_id, if label.is_empty() { "Camera".to_string() } else { label }));
|
||||
}
|
||||
web_sys::MediaDeviceKind::Audioinput => {
|
||||
audio_devices.push((device_id, if label.is_empty() { "Microphone".to_string() } else { label }));
|
||||
}
|
||||
web_sys::MediaDeviceKind::Audiooutput => {
|
||||
audio_output_devices.push((device_id, if label.is_empty() { "Speaker".to_string() } else { label }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((video_devices, audio_devices, audio_output_devices))
|
||||
}
|
161
examples/meet/src/components/video_tile.rs
Normal file
161
examples/meet/src/components/video_tile.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlVideoElement, MediaStream, MediaStreamConstraints};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::pages::room::Participant;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct VideoTileProps {
|
||||
pub participant: Participant,
|
||||
}
|
||||
|
||||
#[function_component(VideoTile)]
|
||||
pub fn video_tile(props: &VideoTileProps) -> Html {
|
||||
let video_ref = use_node_ref();
|
||||
let participant = &props.participant;
|
||||
|
||||
// Initialize video element and capture media
|
||||
use_effect_with(participant.clone(), {
|
||||
let video_ref = video_ref.clone();
|
||||
move |participant| {
|
||||
if let Some(video_element) = video_ref.cast::<HtmlVideoElement>() {
|
||||
if participant.is_local && participant.video_enabled {
|
||||
// Set up local video stream using getUserMedia
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match get_user_media().await {
|
||||
Ok(stream) => {
|
||||
video_element.set_src_object(Some(&stream));
|
||||
web_sys::console::log_1(&"Local video stream set up successfully".into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to get user media: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if !participant.is_local && participant.video_enabled {
|
||||
// For remote participants, attach actual LiveKit video tracks
|
||||
web_sys::console::log_1(&format!("Setting up remote video for participant: {}", participant.id).into());
|
||||
// Note: Track attachment will be handled by the room page when tracks are available
|
||||
// This is just a placeholder for the video element
|
||||
}
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="video-tile">
|
||||
// Always render video element for remote participants to allow track attachment
|
||||
<video
|
||||
id={format!("video-{}", participant.id)}
|
||||
ref={video_ref}
|
||||
autoplay=true
|
||||
muted={participant.is_local}
|
||||
playsinline=true
|
||||
class="video-element"
|
||||
style={if !participant.video_enabled { "display: none;" } else { "" }}
|
||||
/>
|
||||
|
||||
{if !participant.video_enabled {
|
||||
html! {
|
||||
<div class="video-placeholder">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
<span>{"Video disabled"}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<div class="video-overlay">
|
||||
<div class="participant-name">
|
||||
{&participant.name}
|
||||
{if participant.is_local {
|
||||
html! { <span>{" (You)"}</span> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="participant-status">
|
||||
{if !participant.audio_enabled {
|
||||
html! {
|
||||
<div class="status-indicator muted" title="Microphone muted">
|
||||
{"🔇"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if !participant.video_enabled {
|
||||
html! {
|
||||
<div class="status-indicator" title="Camera off">
|
||||
{"📷"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if participant.screen_share_enabled {
|
||||
html! {
|
||||
<div class="status-indicator" title="Screen sharing">
|
||||
{"🖥️"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_media() -> Result<MediaStream, JsValue> {
|
||||
let window = web_sys::window().ok_or("No window object")?;
|
||||
let navigator = window.navigator();
|
||||
let media_devices = navigator.media_devices()?;
|
||||
|
||||
let constraints = MediaStreamConstraints::new();
|
||||
constraints.set_video(&JsValue::from(true));
|
||||
constraints.set_audio(&JsValue::from(true));
|
||||
|
||||
// Try getUserMedia with fallback strategies
|
||||
match try_get_user_media(&media_devices, &constraints).await {
|
||||
Ok(stream) => {
|
||||
web_sys::console::log_1(&"Successfully obtained media stream".into());
|
||||
Ok(stream)
|
||||
}
|
||||
Err(first_error) => {
|
||||
web_sys::console::warn_1(&format!("First attempt failed: {:?}", first_error).into());
|
||||
|
||||
// Fallback: Try with just video, no audio
|
||||
web_sys::console::log_1(&"Trying fallback: basic video only".into());
|
||||
let fallback_constraints = MediaStreamConstraints::new();
|
||||
fallback_constraints.set_video(&JsValue::from(true));
|
||||
fallback_constraints.set_audio(&JsValue::from(false));
|
||||
|
||||
match try_get_user_media(&media_devices, &fallback_constraints).await {
|
||||
Ok(stream) => {
|
||||
web_sys::console::log_1(&"Fallback successful - got video-only stream".into());
|
||||
Ok(stream)
|
||||
}
|
||||
Err(second_error) => {
|
||||
web_sys::console::error_1(&format!("All attempts failed. First error: {:?}, Fallback error: {:?}", first_error, second_error).into());
|
||||
Err(JsValue::from_str(&format!("Camera access failed: {:?}", first_error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_get_user_media(media_devices: &web_sys::MediaDevices, constraints: &MediaStreamConstraints) -> Result<MediaStream, JsValue> {
|
||||
let promise = media_devices.get_user_media_with_constraints(constraints)?;
|
||||
let js_value = JsFuture::from(promise).await?;
|
||||
let media_stream: MediaStream = js_value.dyn_into()?;
|
||||
Ok(media_stream)
|
||||
}
|
||||
|
Reference in New Issue
Block a user