logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
75
crates/livekit_client/Cargo.toml
Normal file
75
crates/livekit_client/Cargo.toml
Normal file
@@ -0,0 +1,75 @@
|
||||
[package]
|
||||
name = "livekit_client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Logic for using LiveKit with GPUI"
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[[example]]
|
||||
name = "test_app"
|
||||
|
||||
[features]
|
||||
test-support = ["collections/test-support", "gpui/test-support"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
audio.workspace = true
|
||||
collections.workspace = true
|
||||
cpal.workspace = true
|
||||
futures.workspace = true
|
||||
gpui = { workspace = true, features = ["screen-capture", "x11", "wayland"] }
|
||||
gpui_tokio.workspace = true
|
||||
http_client_tls.workspace = true
|
||||
image.workspace = true
|
||||
livekit_api.workspace = true
|
||||
log.workspace = true
|
||||
nanoid.workspace = true
|
||||
parking_lot.workspace = true
|
||||
postage.workspace = true
|
||||
rodio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_urlencoded.workspace = true
|
||||
settings.workspace = true
|
||||
smallvec.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
[target.'cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))'.dependencies]
|
||||
libwebrtc.workspace = true
|
||||
livekit.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
webrtc-sys.workspace = true
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))'.dependencies]
|
||||
scap.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa.workspace = true
|
||||
core-foundation.workspace = true
|
||||
core-video.workspace = true
|
||||
coreaudio-rs = "0.12.1"
|
||||
objc.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
collections = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
gpui_platform.workspace = true
|
||||
simplelog.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["serde_json"]
|
||||
1
crates/livekit_client/LICENSE-GPL
Symbolic link
1
crates/livekit_client/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
392
crates/livekit_client/examples/test_app.rs
Normal file
392
crates/livekit_client/examples/test_app.rs
Normal file
@@ -0,0 +1,392 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
AppContext as _, AsyncApp, Bounds, Context, Entity, InteractiveElement, KeyBinding, Menu,
|
||||
MenuItem, ParentElement, Pixels, Render, ScreenCaptureStream, SharedString,
|
||||
StatefulInteractiveElement as _, Styled, Task, Window, WindowBounds, WindowHandle,
|
||||
WindowOptions, actions, bounds, div, point,
|
||||
prelude::{FluentBuilder as _, IntoElement},
|
||||
px, rgb, size,
|
||||
};
|
||||
use livekit_client::{
|
||||
AudioStream, LocalTrackPublication, Participant, ParticipantIdentity, RemoteParticipant,
|
||||
RemoteTrackPublication, RemoteVideoTrack, RemoteVideoTrackView, Room, RoomEvent,
|
||||
};
|
||||
|
||||
use livekit_api::token::{self, VideoGrant};
|
||||
use log::LevelFilter;
|
||||
use simplelog::SimpleLogger;
|
||||
|
||||
actions!(livekit_client, [Quit]);
|
||||
|
||||
fn main() {
|
||||
SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger");
|
||||
|
||||
gpui_platform::application().run(|cx| {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
println!("USING TEST LIVEKIT");
|
||||
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
println!("USING REAL LIVEKIT");
|
||||
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
cx.activate(true);
|
||||
cx.on_action(quit);
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
cx.set_menus([Menu::new("Zed").items([MenuItem::action("Quit", Quit)])]);
|
||||
|
||||
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or("http://localhost:7880".into());
|
||||
let livekit_key = std::env::var("LIVEKIT_KEY").unwrap_or("devkey".into());
|
||||
let livekit_secret = std::env::var("LIVEKIT_SECRET").unwrap_or("secret".into());
|
||||
let height = px(800.);
|
||||
let width = px(800.);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let mut windows = Vec::new();
|
||||
for i in 0..2 {
|
||||
let token = token::create(
|
||||
&livekit_key,
|
||||
&livekit_secret,
|
||||
Some(&format!("test-participant-{i}")),
|
||||
VideoGrant::to_join("wtej-trty"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let bounds = bounds(point(width * i, px(0.0)), size(width, height));
|
||||
let window = LivekitWindow::new(livekit_url.clone(), token, bounds, cx).await;
|
||||
windows.push(window);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut gpui::App) {
|
||||
cx.quit();
|
||||
}
|
||||
|
||||
struct LivekitWindow {
|
||||
room: Arc<livekit_client::Room>,
|
||||
microphone_track: Option<LocalTrackPublication>,
|
||||
screen_share_track: Option<LocalTrackPublication>,
|
||||
microphone_stream: Option<livekit_client::AudioStream>,
|
||||
screen_share_stream: Option<Box<dyn ScreenCaptureStream>>,
|
||||
remote_participants: Vec<(ParticipantIdentity, ParticipantState)>,
|
||||
_events_task: Task<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParticipantState {
|
||||
audio_output_stream: Option<(RemoteTrackPublication, AudioStream)>,
|
||||
muted: bool,
|
||||
screen_share_output_view: Option<(RemoteVideoTrack, Entity<RemoteVideoTrackView>)>,
|
||||
speaking: bool,
|
||||
}
|
||||
|
||||
impl LivekitWindow {
|
||||
async fn new(
|
||||
url: String,
|
||||
token: String,
|
||||
bounds: Bounds<Pixels>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> WindowHandle<Self> {
|
||||
let (room, mut events) =
|
||||
Room::connect(url.clone(), token, cx)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!(
|
||||
"Failed to connect to {url}: {err}.\nTry `foreman start` to run the livekit server"
|
||||
);
|
||||
|
||||
std::process::exit(1)
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
cx.new(|cx| {
|
||||
let _events_task = cx.spawn_in(window, async move |this, cx| {
|
||||
while let Some(event) = events.next().await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this: &mut LivekitWindow, cx| {
|
||||
this.handle_room_event(event, window, cx)
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
room: Arc::new(room),
|
||||
microphone_track: None,
|
||||
microphone_stream: None,
|
||||
screen_share_track: None,
|
||||
screen_share_stream: None,
|
||||
remote_participants: Vec::new(),
|
||||
_events_task,
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_room_event(&mut self, event: RoomEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
eprintln!("event: {event:?}");
|
||||
|
||||
match event {
|
||||
RoomEvent::TrackUnpublished {
|
||||
publication,
|
||||
participant,
|
||||
} => {
|
||||
let output = self.remote_participant(participant);
|
||||
let unpublish_sid = publication.sid();
|
||||
if output
|
||||
.audio_output_stream
|
||||
.as_ref()
|
||||
.is_some_and(|(track, _)| track.sid() == unpublish_sid)
|
||||
{
|
||||
output.audio_output_stream.take();
|
||||
}
|
||||
if output
|
||||
.screen_share_output_view
|
||||
.as_ref()
|
||||
.is_some_and(|(track, _)| track.sid() == unpublish_sid)
|
||||
{
|
||||
output.screen_share_output_view.take();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
RoomEvent::TrackSubscribed {
|
||||
publication,
|
||||
participant,
|
||||
track,
|
||||
} => {
|
||||
let room = self.room.clone();
|
||||
let output = self.remote_participant(participant);
|
||||
match track {
|
||||
livekit_client::RemoteTrack::Audio(track) => {
|
||||
output.audio_output_stream = Some((
|
||||
publication,
|
||||
room.play_remote_audio_track(&track, cx).unwrap(),
|
||||
));
|
||||
}
|
||||
livekit_client::RemoteTrack::Video(track) => {
|
||||
output.screen_share_output_view = Some((
|
||||
track.clone(),
|
||||
cx.new(|cx| RemoteVideoTrackView::new(track, window, cx)),
|
||||
));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
RoomEvent::TrackMuted { participant, .. } => {
|
||||
if let Participant::Remote(participant) = participant {
|
||||
self.remote_participant(participant).muted = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
RoomEvent::TrackUnmuted { participant, .. } => {
|
||||
if let Participant::Remote(participant) = participant {
|
||||
self.remote_participant(participant).muted = false;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
RoomEvent::ActiveSpeakersChanged { speakers } => {
|
||||
for (identity, output) in &mut self.remote_participants {
|
||||
output.speaking = speakers.iter().any(|speaker| {
|
||||
if let Participant::Remote(speaker) = speaker {
|
||||
speaker.identity() == *identity
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn remote_participant(&mut self, participant: RemoteParticipant) -> &mut ParticipantState {
|
||||
match self
|
||||
.remote_participants
|
||||
.binary_search_by_key(&&participant.identity(), |row| &row.0)
|
||||
{
|
||||
Ok(ix) => &mut self.remote_participants[ix].1,
|
||||
Err(ix) => {
|
||||
self.remote_participants
|
||||
.insert(ix, (participant.identity(), ParticipantState::default()));
|
||||
&mut self.remote_participants[ix].1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_mute(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(track) = &self.microphone_track {
|
||||
if track.is_muted() {
|
||||
track.unmute(cx);
|
||||
} else {
|
||||
track.mute(cx);
|
||||
}
|
||||
cx.notify();
|
||||
} else {
|
||||
let room = self.room.clone();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let (publication, stream, _input_lag_us) = room
|
||||
.publish_local_microphone_track("test_user".to_string(), false, cx)
|
||||
.await
|
||||
.unwrap();
|
||||
this.update(cx, |this, cx| {
|
||||
this.microphone_track = Some(publication);
|
||||
this.microphone_stream = Some(stream);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_screen_share(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(track) = self.screen_share_track.take() {
|
||||
self.screen_share_stream.take();
|
||||
let participant = self.room.local_participant();
|
||||
cx.spawn(async move |_, cx| {
|
||||
participant.unpublish_track(track.sid(), cx).await.unwrap();
|
||||
})
|
||||
.detach();
|
||||
cx.notify();
|
||||
} else {
|
||||
let participant = self.room.local_participant();
|
||||
let sources = cx.screen_capture_sources();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let sources = sources.await.unwrap()?;
|
||||
let source = sources.into_iter().next().unwrap();
|
||||
|
||||
let (publication, stream) = participant
|
||||
.publish_screenshare_track(&*source, cx)
|
||||
.await
|
||||
.unwrap();
|
||||
this.update(cx, |this, cx| {
|
||||
this.screen_share_track = Some(publication);
|
||||
this.screen_share_stream = Some(stream);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_remote_audio_for_participant(
|
||||
&mut self,
|
||||
identity: &ParticipantIdentity,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<()> {
|
||||
let participant = self.remote_participants.iter().find_map(|(id, state)| {
|
||||
if id == identity { Some(state) } else { None }
|
||||
})?;
|
||||
let publication = &participant.audio_output_stream.as_ref()?.0;
|
||||
publication.set_enabled(!publication.is_enabled(), cx);
|
||||
cx.notify();
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for LivekitWindow {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn button() -> gpui::Div {
|
||||
div()
|
||||
.w(px(180.0))
|
||||
.h(px(30.0))
|
||||
.px_2()
|
||||
.m_2()
|
||||
.bg(rgb(0x8888ff))
|
||||
}
|
||||
|
||||
div()
|
||||
.bg(rgb(0xffffff))
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div().bg(rgb(0xffd4a8)).flex().flex_row().children([
|
||||
button()
|
||||
.id("toggle-mute")
|
||||
.child(if let Some(track) = &self.microphone_track {
|
||||
if track.is_muted() { "Unmute" } else { "Mute" }
|
||||
} else {
|
||||
"Publish mic"
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, cx| this.toggle_mute(window, cx))),
|
||||
button()
|
||||
.id("toggle-screen-share")
|
||||
.child(if self.screen_share_track.is_none() {
|
||||
"Share screen"
|
||||
} else {
|
||||
"Unshare screen"
|
||||
})
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.toggle_screen_share(window, cx)),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("remote-participants")
|
||||
.overflow_y_scroll()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_grow()
|
||||
.children(self.remote_participants.iter().map(|(identity, state)| {
|
||||
div()
|
||||
.h(px(1080.0))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.m_2()
|
||||
.px_2()
|
||||
.bg(rgb(0x8888ff))
|
||||
.child(SharedString::from(if state.speaking {
|
||||
format!("{} (speaking)", &identity.0)
|
||||
} else if state.muted {
|
||||
format!("{} (muted)", &identity.0)
|
||||
} else {
|
||||
identity.0.clone()
|
||||
}))
|
||||
.when_some(state.audio_output_stream.as_ref(), |el, state| {
|
||||
el.child(
|
||||
button()
|
||||
.id(identity.0.clone())
|
||||
.child(if state.0.is_enabled() {
|
||||
"Deafen"
|
||||
} else {
|
||||
"Undeafen"
|
||||
})
|
||||
.on_click(cx.listener({
|
||||
let identity = identity.clone();
|
||||
move |this, _, _, cx| {
|
||||
this.toggle_remote_audio_for_participant(
|
||||
&identity, cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
})
|
||||
.children(state.screen_share_output_view.as_ref().map(|e| e.1.clone()))
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
253
crates/livekit_client/src/lib.rs
Normal file
253
crates/livekit_client/src/lib.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use anyhow::Context as _;
|
||||
use collections::HashMap;
|
||||
use cpal::DeviceId;
|
||||
|
||||
mod remote_video_track_view;
|
||||
pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent};
|
||||
use rodio::DeviceTrait as _;
|
||||
|
||||
mod record;
|
||||
pub use record::CaptureInput;
|
||||
|
||||
#[cfg(any(
|
||||
rust_analyzer,
|
||||
not(any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
))
|
||||
))]
|
||||
mod livekit_client;
|
||||
#[cfg(any(
|
||||
rust_analyzer,
|
||||
not(any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
))
|
||||
))]
|
||||
pub use livekit_client::*;
|
||||
|
||||
#[cfg(all(
|
||||
not(rust_analyzer),
|
||||
any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
)
|
||||
))]
|
||||
mod mock_client;
|
||||
#[cfg(all(
|
||||
not(rust_analyzer),
|
||||
any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
)
|
||||
))]
|
||||
pub mod test;
|
||||
#[cfg(all(
|
||||
not(rust_analyzer),
|
||||
any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
)
|
||||
))]
|
||||
pub use mock_client::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Participant {
|
||||
Local(LocalParticipant),
|
||||
Remote(RemoteParticipant),
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
|
||||
pub enum ConnectionQuality {
|
||||
Excellent,
|
||||
Good,
|
||||
Poor,
|
||||
Lost,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrackPublication {
|
||||
Local(LocalTrackPublication),
|
||||
Remote(RemoteTrackPublication),
|
||||
}
|
||||
|
||||
impl TrackPublication {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
match self {
|
||||
TrackPublication::Local(local) => local.sid(),
|
||||
TrackPublication::Remote(remote) => remote.sid(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_muted(&self) -> bool {
|
||||
match self {
|
||||
TrackPublication::Local(local) => local.is_muted(),
|
||||
TrackPublication::Remote(remote) => remote.is_muted(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RemoteTrack {
|
||||
Audio(RemoteAudioTrack),
|
||||
Video(RemoteVideoTrack),
|
||||
}
|
||||
|
||||
impl RemoteTrack {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
match self {
|
||||
RemoteTrack::Audio(remote_audio_track) => remote_audio_track.sid(),
|
||||
RemoteTrack::Video(remote_video_track) => remote_video_track.sid(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum LocalTrack {
|
||||
Audio(LocalAudioTrack),
|
||||
Video(LocalVideoTrack),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum RoomEvent {
|
||||
ParticipantConnected(RemoteParticipant),
|
||||
ParticipantDisconnected(RemoteParticipant),
|
||||
LocalTrackPublished {
|
||||
publication: LocalTrackPublication,
|
||||
track: LocalTrack,
|
||||
participant: LocalParticipant,
|
||||
},
|
||||
LocalTrackUnpublished {
|
||||
publication: LocalTrackPublication,
|
||||
participant: LocalParticipant,
|
||||
},
|
||||
LocalTrackSubscribed {
|
||||
track: LocalTrack,
|
||||
},
|
||||
TrackSubscribed {
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
},
|
||||
TrackUnsubscribed {
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
},
|
||||
TrackSubscriptionFailed {
|
||||
participant: RemoteParticipant,
|
||||
// error: livekit::track::TrackError,
|
||||
track_sid: TrackSid,
|
||||
},
|
||||
TrackPublished {
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
},
|
||||
TrackUnpublished {
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
},
|
||||
TrackMuted {
|
||||
participant: Participant,
|
||||
publication: TrackPublication,
|
||||
},
|
||||
TrackUnmuted {
|
||||
participant: Participant,
|
||||
publication: TrackPublication,
|
||||
},
|
||||
RoomMetadataChanged {
|
||||
old_metadata: String,
|
||||
metadata: String,
|
||||
},
|
||||
ParticipantMetadataChanged {
|
||||
participant: Participant,
|
||||
old_metadata: String,
|
||||
metadata: String,
|
||||
},
|
||||
ParticipantNameChanged {
|
||||
participant: Participant,
|
||||
old_name: String,
|
||||
name: String,
|
||||
},
|
||||
ParticipantAttributesChanged {
|
||||
participant: Participant,
|
||||
changed_attributes: HashMap<String, String>,
|
||||
},
|
||||
ActiveSpeakersChanged {
|
||||
speakers: Vec<Participant>,
|
||||
},
|
||||
ConnectionQualityChanged {
|
||||
participant: Participant,
|
||||
quality: ConnectionQuality,
|
||||
},
|
||||
ConnectionStateChanged(ConnectionState),
|
||||
Connected {
|
||||
participants_with_tracks: Vec<(RemoteParticipant, Vec<RemoteTrackPublication>)>,
|
||||
},
|
||||
Disconnected {
|
||||
reason: &'static str,
|
||||
},
|
||||
Reconnecting,
|
||||
Reconnected,
|
||||
}
|
||||
|
||||
pub(crate) fn default_device(
|
||||
input: bool,
|
||||
device_id: Option<&DeviceId>,
|
||||
) -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> {
|
||||
let device = audio::resolve_device(device_id, input)?;
|
||||
let config = if input {
|
||||
device
|
||||
.default_input_config()
|
||||
.context("failed to get default input config")?
|
||||
} else {
|
||||
device
|
||||
.default_output_config()
|
||||
.context("failed to get default output config")?
|
||||
};
|
||||
Ok((device, config))
|
||||
}
|
||||
|
||||
pub(crate) fn get_sample_data(
|
||||
sample_format: cpal::SampleFormat,
|
||||
data: &cpal::Data,
|
||||
) -> anyhow::Result<Vec<i16>> {
|
||||
match sample_format {
|
||||
cpal::SampleFormat::I8 => Ok(convert_sample_data::<i8, i16>(data)),
|
||||
cpal::SampleFormat::I16 => Ok(data.as_slice::<i16>().unwrap().to_vec()),
|
||||
cpal::SampleFormat::I24 => Ok(convert_sample_data::<cpal::I24, i16>(data)),
|
||||
cpal::SampleFormat::I32 => Ok(convert_sample_data::<i32, i16>(data)),
|
||||
cpal::SampleFormat::I64 => Ok(convert_sample_data::<i64, i16>(data)),
|
||||
cpal::SampleFormat::U8 => Ok(convert_sample_data::<u8, i16>(data)),
|
||||
cpal::SampleFormat::U16 => Ok(convert_sample_data::<u16, i16>(data)),
|
||||
cpal::SampleFormat::U32 => Ok(convert_sample_data::<u32, i16>(data)),
|
||||
cpal::SampleFormat::U64 => Ok(convert_sample_data::<u64, i16>(data)),
|
||||
cpal::SampleFormat::F32 => Ok(convert_sample_data::<f32, i16>(data)),
|
||||
cpal::SampleFormat::F64 => Ok(convert_sample_data::<f64, i16>(data)),
|
||||
_ => anyhow::bail!("Unsupported sample format"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn convert_sample_data<
|
||||
TSource: cpal::SizedSample,
|
||||
TDest: cpal::SizedSample + cpal::FromSample<TSource>,
|
||||
>(
|
||||
data: &cpal::Data,
|
||||
) -> Vec<TDest> {
|
||||
data.as_slice::<TSource>()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e.to_sample::<TDest>())
|
||||
.collect()
|
||||
}
|
||||
562
crates/livekit_client/src/livekit_client.rs
Normal file
562
crates/livekit_client/src/livekit_client.rs
Normal file
@@ -0,0 +1,562 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use audio::AudioSettings;
|
||||
use collections::HashMap;
|
||||
use futures::{SinkExt, channel::mpsc};
|
||||
use gpui::{App, AsyncApp, ScreenCaptureSource, ScreenCaptureStream, Task};
|
||||
use gpui_tokio::Tokio;
|
||||
|
||||
use playback::capture_local_video_track;
|
||||
use settings::Settings;
|
||||
use std::sync::{Arc, atomic::AtomicU64};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
mod playback;
|
||||
|
||||
use crate::{ConnectionQuality, LocalTrack, Participant, RemoteTrack, RoomEvent, TrackPublication};
|
||||
pub use livekit::SessionStats;
|
||||
pub use livekit::webrtc::stats::RtcStats;
|
||||
pub use playback::AudioStream;
|
||||
pub(crate) use playback::{RemoteVideoFrame, play_remote_video_track};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteVideoTrack(livekit::track::RemoteVideoTrack);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteAudioTrack(livekit::track::RemoteAudioTrack);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteTrackPublication(livekit::publication::RemoteTrackPublication);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteParticipant(livekit::participant::RemoteParticipant);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalVideoTrack(livekit::track::LocalVideoTrack);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalAudioTrack(livekit::track::LocalAudioTrack);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalTrackPublication(livekit::publication::LocalTrackPublication);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalParticipant(livekit::participant::LocalParticipant);
|
||||
|
||||
pub struct Room {
|
||||
room: livekit::Room,
|
||||
_task: Task<()>,
|
||||
playback: playback::AudioStack,
|
||||
}
|
||||
|
||||
pub type TrackSid = livekit::id::TrackSid;
|
||||
pub type ConnectionState = livekit::ConnectionState;
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
|
||||
pub struct ParticipantIdentity(pub String);
|
||||
|
||||
impl Room {
|
||||
pub async fn connect(
|
||||
url: String,
|
||||
token: String,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(Self, mpsc::UnboundedReceiver<RoomEvent>)> {
|
||||
let mut config = livekit::RoomOptions::default();
|
||||
config.tls_config = livekit::TlsConfig(Some(http_client_tls::tls_config()));
|
||||
let (room, mut events) = Tokio::spawn(cx, async move {
|
||||
livekit::Room::connect(&url, &token, config).await
|
||||
})
|
||||
.await??;
|
||||
|
||||
let (mut tx, rx) = mpsc::unbounded();
|
||||
let task = cx.background_executor().spawn(async move {
|
||||
while let Some(event) = events.recv().await {
|
||||
if let Some(event) = room_event_from_livekit(event) {
|
||||
tx.send(event).await.ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
room,
|
||||
_task: task,
|
||||
playback: playback::AudioStack::new(cx.background_executor().clone()),
|
||||
},
|
||||
rx,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn local_participant(&self) -> LocalParticipant {
|
||||
LocalParticipant(self.room.local_participant())
|
||||
}
|
||||
|
||||
pub fn remote_participants(&self) -> HashMap<ParticipantIdentity, RemoteParticipant> {
|
||||
self.room
|
||||
.remote_participants()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (ParticipantIdentity(k.0), RemoteParticipant(v)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn connection_state(&self) -> ConnectionState {
|
||||
self.room.connection_state()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
self.room.name()
|
||||
}
|
||||
|
||||
pub async fn sid(&self) -> String {
|
||||
self.room.sid().await.to_string()
|
||||
}
|
||||
|
||||
pub async fn publish_local_microphone_track(
|
||||
&self,
|
||||
user_name: String,
|
||||
is_staff: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(LocalTrackPublication, playback::AudioStream, Arc<AtomicU64>)> {
|
||||
let (track, stream, input_lag_us) = self
|
||||
.playback
|
||||
.capture_local_microphone_track(user_name, is_staff, &cx)?;
|
||||
let publication = self
|
||||
.local_participant()
|
||||
.publish_track(
|
||||
livekit::track::LocalTrack::Audio(track.0),
|
||||
livekit::options::TrackPublishOptions {
|
||||
source: livekit::track::TrackSource::Microphone,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((publication, stream, input_lag_us))
|
||||
}
|
||||
|
||||
pub async fn unpublish_local_track(
|
||||
&self,
|
||||
sid: TrackSid,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<LocalTrackPublication> {
|
||||
self.local_participant().unpublish_track(sid, cx).await
|
||||
}
|
||||
|
||||
pub fn play_remote_audio_track(
|
||||
&self,
|
||||
track: &RemoteAudioTrack,
|
||||
cx: &mut App,
|
||||
) -> Result<playback::AudioStream> {
|
||||
let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
|
||||
Ok(self
|
||||
.playback
|
||||
.play_remote_audio_track(&track.0, output_audio_device))
|
||||
}
|
||||
|
||||
pub async fn get_stats(&self) -> Result<livekit::SessionStats> {
|
||||
self.room.get_stats().await.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
/// Returns a `Task` that fetches room stats on the Tokio runtime.
|
||||
///
|
||||
/// LiveKit's SDK is Tokio-based, so the stats fetch must run within
|
||||
/// a Tokio context rather than on GPUI's smol-based background executor.
|
||||
pub fn stats_task(&self, cx: &impl gpui::AppContext) -> Task<Result<livekit::SessionStats>> {
|
||||
let inner = self.room.clone();
|
||||
Tokio::spawn_result(cx, async move {
|
||||
inner.get_stats().await.map_err(anyhow::Error::from)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalParticipant {
|
||||
pub fn connection_quality(&self) -> ConnectionQuality {
|
||||
connection_quality_from_livekit(self.0.connection_quality())
|
||||
}
|
||||
|
||||
pub fn audio_level(&self) -> f32 {
|
||||
self.0.audio_level()
|
||||
}
|
||||
|
||||
pub async fn publish_screenshare_track(
|
||||
&self,
|
||||
source: &dyn ScreenCaptureSource,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(LocalTrackPublication, Box<dyn ScreenCaptureStream>)> {
|
||||
let (track, stream) = capture_local_video_track(source, cx).await?;
|
||||
let options = livekit::options::TrackPublishOptions {
|
||||
source: livekit::track::TrackSource::Screenshare,
|
||||
video_codec: livekit::options::VideoCodec::VP8,
|
||||
..Default::default()
|
||||
};
|
||||
let publication = self
|
||||
.publish_track(livekit::track::LocalTrack::Video(track.0), options, cx)
|
||||
.await?;
|
||||
|
||||
Ok((publication, stream))
|
||||
}
|
||||
|
||||
async fn publish_track(
|
||||
&self,
|
||||
track: livekit::track::LocalTrack,
|
||||
options: livekit::options::TrackPublishOptions,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<LocalTrackPublication> {
|
||||
let participant = self.0.clone();
|
||||
Tokio::spawn(cx, async move {
|
||||
participant.publish_track(track, options).await
|
||||
})
|
||||
.await?
|
||||
.map(LocalTrackPublication)
|
||||
.context("publishing a track")
|
||||
}
|
||||
|
||||
pub async fn unpublish_track(
|
||||
&self,
|
||||
sid: TrackSid,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<LocalTrackPublication> {
|
||||
let participant = self.0.clone();
|
||||
Tokio::spawn(cx, async move { participant.unpublish_track(&sid).await })
|
||||
.await?
|
||||
.map(LocalTrackPublication)
|
||||
.context("unpublishing a track")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn publish_screenshare_track_wayland(
|
||||
&self,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(
|
||||
LocalTrackPublication,
|
||||
Box<dyn ScreenCaptureStream>,
|
||||
futures::channel::oneshot::Receiver<()>,
|
||||
)> {
|
||||
let (track, stop_flag, feed_task, failure_rx) =
|
||||
linux::start_wayland_desktop_capture(cx).await?;
|
||||
let options = livekit::options::TrackPublishOptions {
|
||||
source: livekit::track::TrackSource::Screenshare,
|
||||
video_codec: livekit::options::VideoCodec::VP8,
|
||||
..Default::default()
|
||||
};
|
||||
let publication = self
|
||||
.publish_track(livekit::track::LocalTrack::Video(track.0), options, cx)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
publication,
|
||||
Box::new(linux::WaylandScreenCaptureStream::new(stop_flag, feed_task)),
|
||||
failure_rx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalTrackPublication {
|
||||
pub fn mute(&self, cx: &App) {
|
||||
let track = self.0.clone();
|
||||
Tokio::spawn(cx, async move {
|
||||
track.mute();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn unmute(&self, cx: &App) {
|
||||
let track = self.0.clone();
|
||||
Tokio::spawn(cx, async move {
|
||||
track.unmute();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.0.sid()
|
||||
}
|
||||
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.0.is_muted()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteParticipant {
|
||||
pub fn connection_quality(&self) -> ConnectionQuality {
|
||||
connection_quality_from_livekit(self.0.connection_quality())
|
||||
}
|
||||
|
||||
pub fn audio_level(&self) -> f32 {
|
||||
self.0.audio_level()
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> ParticipantIdentity {
|
||||
ParticipantIdentity(self.0.identity().0)
|
||||
}
|
||||
|
||||
pub fn track_publications(&self) -> HashMap<TrackSid, RemoteTrackPublication> {
|
||||
self.0
|
||||
.track_publications()
|
||||
.into_iter()
|
||||
.map(|(sid, publication)| (sid, RemoteTrackPublication(publication)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteAudioTrack {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.0.sid()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteVideoTrack {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.0.sid()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteTrackPublication {
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.0.is_muted()
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.0.is_enabled()
|
||||
}
|
||||
|
||||
pub fn track(&self) -> Option<RemoteTrack> {
|
||||
self.0.track().map(remote_track_from_livekit)
|
||||
}
|
||||
|
||||
pub fn is_audio(&self) -> bool {
|
||||
self.0.kind() == livekit::track::TrackKind::Audio
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool, cx: &App) {
|
||||
let track = self.0.clone();
|
||||
Tokio::spawn(cx, async move { track.set_enabled(enabled) }).detach();
|
||||
}
|
||||
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.0.sid()
|
||||
}
|
||||
}
|
||||
|
||||
impl Participant {
|
||||
pub fn identity(&self) -> ParticipantIdentity {
|
||||
match self {
|
||||
Participant::Local(local_participant) => {
|
||||
ParticipantIdentity(local_participant.0.identity().0)
|
||||
}
|
||||
Participant::Remote(remote_participant) => {
|
||||
ParticipantIdentity(remote_participant.0.identity().0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connection_quality(&self) -> ConnectionQuality {
|
||||
match self {
|
||||
Participant::Local(local_participant) => local_participant.connection_quality(),
|
||||
Participant::Remote(remote_participant) => remote_participant.connection_quality(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio_level(&self) -> f32 {
|
||||
match self {
|
||||
Participant::Local(local_participant) => local_participant.audio_level(),
|
||||
Participant::Remote(remote_participant) => remote_participant.audio_level(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_quality_from_livekit(
|
||||
quality: livekit::prelude::ConnectionQuality,
|
||||
) -> ConnectionQuality {
|
||||
match quality {
|
||||
livekit::prelude::ConnectionQuality::Excellent => ConnectionQuality::Excellent,
|
||||
livekit::prelude::ConnectionQuality::Good => ConnectionQuality::Good,
|
||||
livekit::prelude::ConnectionQuality::Poor => ConnectionQuality::Poor,
|
||||
livekit::prelude::ConnectionQuality::Lost => ConnectionQuality::Lost,
|
||||
}
|
||||
}
|
||||
|
||||
fn participant_from_livekit(participant: livekit::participant::Participant) -> Participant {
|
||||
match participant {
|
||||
livekit::participant::Participant::Local(local) => {
|
||||
Participant::Local(LocalParticipant(local))
|
||||
}
|
||||
livekit::participant::Participant::Remote(remote) => {
|
||||
Participant::Remote(RemoteParticipant(remote))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publication_from_livekit(
|
||||
publication: livekit::publication::TrackPublication,
|
||||
) -> TrackPublication {
|
||||
match publication {
|
||||
livekit::publication::TrackPublication::Local(local) => {
|
||||
TrackPublication::Local(LocalTrackPublication(local))
|
||||
}
|
||||
livekit::publication::TrackPublication::Remote(remote) => {
|
||||
TrackPublication::Remote(RemoteTrackPublication(remote))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_track_from_livekit(track: livekit::track::RemoteTrack) -> RemoteTrack {
|
||||
match track {
|
||||
livekit::track::RemoteTrack::Audio(audio) => RemoteTrack::Audio(RemoteAudioTrack(audio)),
|
||||
livekit::track::RemoteTrack::Video(video) => RemoteTrack::Video(RemoteVideoTrack(video)),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_track_from_livekit(track: livekit::track::LocalTrack) -> LocalTrack {
|
||||
match track {
|
||||
livekit::track::LocalTrack::Audio(audio) => LocalTrack::Audio(LocalAudioTrack(audio)),
|
||||
livekit::track::LocalTrack::Video(video) => LocalTrack::Video(LocalVideoTrack(video)),
|
||||
}
|
||||
}
|
||||
fn room_event_from_livekit(event: livekit::RoomEvent) -> Option<RoomEvent> {
|
||||
let event = match event {
|
||||
livekit::RoomEvent::ParticipantConnected(remote_participant) => {
|
||||
RoomEvent::ParticipantConnected(RemoteParticipant(remote_participant))
|
||||
}
|
||||
livekit::RoomEvent::ParticipantDisconnected(remote_participant) => {
|
||||
RoomEvent::ParticipantDisconnected(RemoteParticipant(remote_participant))
|
||||
}
|
||||
livekit::RoomEvent::LocalTrackPublished {
|
||||
publication,
|
||||
track,
|
||||
participant,
|
||||
} => RoomEvent::LocalTrackPublished {
|
||||
publication: LocalTrackPublication(publication),
|
||||
track: local_track_from_livekit(track),
|
||||
participant: LocalParticipant(participant),
|
||||
},
|
||||
livekit::RoomEvent::LocalTrackUnpublished {
|
||||
publication,
|
||||
participant,
|
||||
} => RoomEvent::LocalTrackUnpublished {
|
||||
publication: LocalTrackPublication(publication),
|
||||
participant: LocalParticipant(participant),
|
||||
},
|
||||
livekit::RoomEvent::LocalTrackSubscribed { track } => RoomEvent::LocalTrackSubscribed {
|
||||
track: local_track_from_livekit(track),
|
||||
},
|
||||
livekit::RoomEvent::TrackSubscribed {
|
||||
track,
|
||||
publication,
|
||||
participant,
|
||||
} => RoomEvent::TrackSubscribed {
|
||||
track: remote_track_from_livekit(track),
|
||||
publication: RemoteTrackPublication(publication),
|
||||
participant: RemoteParticipant(participant),
|
||||
},
|
||||
livekit::RoomEvent::TrackUnsubscribed {
|
||||
track,
|
||||
publication,
|
||||
participant,
|
||||
} => RoomEvent::TrackUnsubscribed {
|
||||
track: remote_track_from_livekit(track),
|
||||
publication: RemoteTrackPublication(publication),
|
||||
participant: RemoteParticipant(participant),
|
||||
},
|
||||
livekit::RoomEvent::TrackSubscriptionFailed {
|
||||
participant,
|
||||
error: _,
|
||||
track_sid,
|
||||
} => RoomEvent::TrackSubscriptionFailed {
|
||||
participant: RemoteParticipant(participant),
|
||||
track_sid,
|
||||
},
|
||||
livekit::RoomEvent::TrackPublished {
|
||||
publication,
|
||||
participant,
|
||||
} => RoomEvent::TrackPublished {
|
||||
publication: RemoteTrackPublication(publication),
|
||||
participant: RemoteParticipant(participant),
|
||||
},
|
||||
livekit::RoomEvent::TrackUnpublished {
|
||||
publication,
|
||||
participant,
|
||||
} => RoomEvent::TrackUnpublished {
|
||||
publication: RemoteTrackPublication(publication),
|
||||
participant: RemoteParticipant(participant),
|
||||
},
|
||||
livekit::RoomEvent::TrackMuted {
|
||||
participant,
|
||||
publication,
|
||||
} => RoomEvent::TrackMuted {
|
||||
publication: publication_from_livekit(publication),
|
||||
participant: participant_from_livekit(participant),
|
||||
},
|
||||
livekit::RoomEvent::TrackUnmuted {
|
||||
participant,
|
||||
publication,
|
||||
} => RoomEvent::TrackUnmuted {
|
||||
publication: publication_from_livekit(publication),
|
||||
participant: participant_from_livekit(participant),
|
||||
},
|
||||
livekit::RoomEvent::RoomMetadataChanged {
|
||||
old_metadata,
|
||||
metadata,
|
||||
} => RoomEvent::RoomMetadataChanged {
|
||||
old_metadata,
|
||||
metadata,
|
||||
},
|
||||
livekit::RoomEvent::ParticipantMetadataChanged {
|
||||
participant,
|
||||
old_metadata,
|
||||
metadata,
|
||||
} => RoomEvent::ParticipantMetadataChanged {
|
||||
participant: participant_from_livekit(participant),
|
||||
old_metadata,
|
||||
metadata,
|
||||
},
|
||||
livekit::RoomEvent::ParticipantNameChanged {
|
||||
participant,
|
||||
old_name,
|
||||
name,
|
||||
} => RoomEvent::ParticipantNameChanged {
|
||||
participant: participant_from_livekit(participant),
|
||||
old_name,
|
||||
name,
|
||||
},
|
||||
livekit::RoomEvent::ParticipantAttributesChanged {
|
||||
participant,
|
||||
changed_attributes,
|
||||
} => RoomEvent::ParticipantAttributesChanged {
|
||||
participant: participant_from_livekit(participant),
|
||||
changed_attributes: changed_attributes.into_iter().collect(),
|
||||
},
|
||||
livekit::RoomEvent::ActiveSpeakersChanged { speakers } => {
|
||||
RoomEvent::ActiveSpeakersChanged {
|
||||
speakers: speakers.into_iter().map(participant_from_livekit).collect(),
|
||||
}
|
||||
}
|
||||
livekit::RoomEvent::Connected {
|
||||
participants_with_tracks,
|
||||
} => RoomEvent::Connected {
|
||||
participants_with_tracks: participants_with_tracks
|
||||
.into_iter()
|
||||
.map({
|
||||
|(p, t)| {
|
||||
(
|
||||
RemoteParticipant(p),
|
||||
t.into_iter().map(RemoteTrackPublication).collect(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
livekit::RoomEvent::Disconnected { reason } => RoomEvent::Disconnected {
|
||||
reason: reason.as_str_name(),
|
||||
},
|
||||
livekit::RoomEvent::Reconnecting => RoomEvent::Reconnecting,
|
||||
livekit::RoomEvent::Reconnected => RoomEvent::Reconnected,
|
||||
livekit::RoomEvent::ConnectionQualityChanged {
|
||||
quality,
|
||||
participant,
|
||||
} => RoomEvent::ConnectionQualityChanged {
|
||||
participant: participant_from_livekit(participant),
|
||||
quality: connection_quality_from_livekit(quality),
|
||||
},
|
||||
_ => {
|
||||
log::trace!("dropping livekit event: {:?}", event);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(event)
|
||||
}
|
||||
203
crates/livekit_client/src/livekit_client/linux.rs
Normal file
203
crates/livekit_client/src/livekit_client/linux.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use anyhow::Result;
|
||||
use futures::StreamExt as _;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{AsyncApp, ScreenCaptureStream};
|
||||
use livekit::track;
|
||||
use livekit::webrtc::{
|
||||
prelude::NV12Buffer,
|
||||
video_frame::{VideoFrame, VideoRotation},
|
||||
video_source::{RtcVideoSource, VideoResolution, native::NativeVideoSource},
|
||||
};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT_WAYLAND_SHARE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
const PIPEWIRE_TIMEOUT_S: u64 = 30;
|
||||
|
||||
pub struct WaylandScreenCaptureStream {
|
||||
id: u64,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
_capture_task: gpui::Task<()>,
|
||||
}
|
||||
|
||||
impl WaylandScreenCaptureStream {
|
||||
pub fn new(stop_flag: Arc<AtomicBool>, capture_task: gpui::Task<()>) -> Self {
|
||||
Self {
|
||||
id: NEXT_WAYLAND_SHARE_ID.fetch_add(1, Ordering::Relaxed),
|
||||
stop_flag,
|
||||
_capture_task: capture_task,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenCaptureStream for WaylandScreenCaptureStream {
|
||||
fn metadata(&self) -> Result<gpui::SourceMetadata> {
|
||||
Ok(gpui::SourceMetadata {
|
||||
id: self.id,
|
||||
label: None,
|
||||
is_main: None,
|
||||
resolution: gpui::size(gpui::DevicePixels(1), gpui::DevicePixels(1)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WaylandScreenCaptureStream {
|
||||
fn drop(&mut self) {
|
||||
self.stop_flag.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_wayland_desktop_capture(
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(
|
||||
crate::LocalVideoTrack,
|
||||
Arc<AtomicBool>,
|
||||
gpui::Task<()>,
|
||||
oneshot::Receiver<()>,
|
||||
)> {
|
||||
use futures::channel::mpsc;
|
||||
use gpui::FutureExt as _;
|
||||
use libwebrtc::desktop_capturer::{
|
||||
CaptureError, DesktopCaptureSourceType, DesktopCapturer, DesktopCapturerOptions,
|
||||
DesktopFrame,
|
||||
};
|
||||
use libwebrtc::native::yuv_helper::argb_to_nv12;
|
||||
use std::time::Duration;
|
||||
use webrtc_sys::webrtc::ffi as webrtc_ffi;
|
||||
|
||||
fn webrtc_log_callback(message: String, severity: webrtc_ffi::LoggingSeverity) {
|
||||
match severity {
|
||||
webrtc_ffi::LoggingSeverity::Error => log::error!("[webrtc] {}", message.trim()),
|
||||
_ => log::debug!("[webrtc] {}", message.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
let _webrtc_log_sink = webrtc_ffi::new_log_sink(webrtc_log_callback);
|
||||
log::debug!("Wayland desktop capture: WebRTC internal logging enabled");
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let (mut video_source_tx, mut video_source_rx) = mpsc::channel::<NativeVideoSource>(1);
|
||||
let (failure_tx, failure_rx) = oneshot::channel::<()>();
|
||||
|
||||
let mut options = DesktopCapturerOptions::new(DesktopCaptureSourceType::Generic);
|
||||
options.set_include_cursor(true);
|
||||
let mut capturer = DesktopCapturer::new(options).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create desktop capturer. \
|
||||
Check that xdg-desktop-portal is installed and running."
|
||||
)
|
||||
})?;
|
||||
|
||||
let permanent_error = Arc::new(AtomicBool::new(false));
|
||||
let stop_cb = stop_flag.clone();
|
||||
let permanent_error_cb = permanent_error.clone();
|
||||
capturer.start_capture(None, {
|
||||
let mut video_source: Option<NativeVideoSource> = None;
|
||||
let mut current_width: u32 = 0;
|
||||
let mut current_height: u32 = 0;
|
||||
let mut video_frame = VideoFrame {
|
||||
rotation: VideoRotation::VideoRotation0,
|
||||
buffer: NV12Buffer::new(1, 1),
|
||||
timestamp_us: 0,
|
||||
};
|
||||
|
||||
move |result: Result<DesktopFrame, CaptureError>| {
|
||||
let frame = match result {
|
||||
Ok(frame) => frame,
|
||||
Err(CaptureError::Temporary) => return,
|
||||
Err(CaptureError::Permanent) => {
|
||||
log::error!("Wayland desktop capture encountered a permanent error");
|
||||
permanent_error_cb.store(true, Ordering::Release);
|
||||
stop_cb.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let width = frame.width() as u32;
|
||||
let height = frame.height() as u32;
|
||||
if width != current_width || height != current_height {
|
||||
current_width = width;
|
||||
current_height = height;
|
||||
video_frame.buffer = NV12Buffer::new(width, height);
|
||||
}
|
||||
|
||||
let (stride_y, stride_uv) = video_frame.buffer.strides();
|
||||
let (data_y, data_uv) = video_frame.buffer.data_mut();
|
||||
argb_to_nv12(
|
||||
frame.data(),
|
||||
frame.stride(),
|
||||
data_y,
|
||||
stride_y,
|
||||
data_uv,
|
||||
stride_uv,
|
||||
width as i32,
|
||||
height as i32,
|
||||
);
|
||||
|
||||
if let Some(source) = &video_source {
|
||||
source.capture_frame(&video_frame);
|
||||
} else {
|
||||
let source = NativeVideoSource::new(VideoResolution { width, height }, true);
|
||||
source.capture_frame(&video_frame);
|
||||
video_source_tx.try_send(source.clone()).ok();
|
||||
video_source = Some(source);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log::info!("Wayland desktop capture: starting capture loop");
|
||||
|
||||
let stop = stop_flag.clone();
|
||||
let tokio_task = gpui_tokio::Tokio::spawn(cx, async move {
|
||||
loop {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
capturer.capture_frame();
|
||||
tokio::time::sleep(Duration::from_millis(33)).await;
|
||||
}
|
||||
drop(capturer);
|
||||
|
||||
if permanent_error.load(Ordering::Acquire) {
|
||||
log::error!("Wayland screen capture ended due to a permanent capture error");
|
||||
let _ = failure_tx.send(());
|
||||
}
|
||||
});
|
||||
|
||||
let capture_task = cx.background_executor().spawn(async move {
|
||||
if let Err(error) = tokio_task.await {
|
||||
log::error!("Wayland capture task failed: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
let executor = cx.background_executor().clone();
|
||||
let video_source = video_source_rx
|
||||
.next()
|
||||
.with_timeout(Duration::from_secs(PIPEWIRE_TIMEOUT_S), &executor)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
log::error!("Wayland desktop capture timed out.");
|
||||
anyhow::anyhow!(
|
||||
"Screen sharing timed out waiting for the first frame. \
|
||||
Check that xdg-desktop-portal and PipeWire are running, \
|
||||
and that your portal backend matches your compositor."
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
anyhow::anyhow!(
|
||||
"Screen sharing was canceled or the portal denied permission. \
|
||||
You can try again from the screen share button."
|
||||
)
|
||||
})?;
|
||||
|
||||
let track = super::LocalVideoTrack(track::LocalVideoTrack::create_video_track(
|
||||
"screen share",
|
||||
RtcVideoSource::Native(video_source),
|
||||
));
|
||||
|
||||
Ok((track, stop_flag, capture_task, failure_rx))
|
||||
}
|
||||
1107
crates/livekit_client/src/livekit_client/playback.rs
Normal file
1107
crates/livekit_client/src/livekit_client/playback.rs
Normal file
File diff suppressed because it is too large
Load Diff
39
crates/livekit_client/src/mock_client.rs
Normal file
39
crates/livekit_client/src/mock_client.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use crate::test;
|
||||
|
||||
pub(crate) mod participant;
|
||||
pub(crate) mod publication;
|
||||
pub(crate) mod track;
|
||||
|
||||
pub type RemoteVideoTrack = track::RemoteVideoTrack;
|
||||
pub type RemoteAudioTrack = track::RemoteAudioTrack;
|
||||
pub type RemoteTrackPublication = publication::RemoteTrackPublication;
|
||||
pub type RemoteParticipant = participant::RemoteParticipant;
|
||||
|
||||
pub type LocalVideoTrack = track::LocalVideoTrack;
|
||||
pub type LocalAudioTrack = track::LocalAudioTrack;
|
||||
pub type LocalTrackPublication = publication::LocalTrackPublication;
|
||||
pub type LocalParticipant = participant::LocalParticipant;
|
||||
|
||||
pub type Room = test::Room;
|
||||
pub use test::{ConnectionState, ParticipantIdentity, RtcStats, SessionStats, TrackSid};
|
||||
|
||||
pub struct AudioStream {}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub type RemoteVideoFrame = std::sync::Arc<gpui::RenderImage>;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RemoteVideoFrame {}
|
||||
#[cfg(target_os = "macos")]
|
||||
impl Into<gpui::SurfaceSource> for RemoteVideoFrame {
|
||||
fn into(self) -> gpui::SurfaceSource {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
pub(crate) fn play_remote_video_track(
|
||||
_track: &crate::RemoteVideoTrack,
|
||||
_: &gpui::BackgroundExecutor,
|
||||
) -> impl futures::Stream<Item = RemoteVideoFrame> + use<> {
|
||||
futures::stream::pending()
|
||||
}
|
||||
222
crates/livekit_client/src/mock_client/participant.rs
Normal file
222
crates/livekit_client/src/mock_client/participant.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use crate::{
|
||||
AudioStream, ConnectionQuality, LocalAudioTrack, LocalTrackPublication, LocalVideoTrack,
|
||||
Participant, ParticipantIdentity, RemoteTrack, RemoteTrackPublication, TrackSid,
|
||||
test::{Room, WeakRoom},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use gpui::{
|
||||
AsyncApp, DevicePixels, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, size,
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicU64};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalParticipant {
|
||||
pub(crate) identity: ParticipantIdentity,
|
||||
pub(crate) room: Room,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteParticipant {
|
||||
pub(crate) identity: ParticipantIdentity,
|
||||
pub(crate) room: WeakRoom,
|
||||
}
|
||||
|
||||
impl Participant {
|
||||
pub fn identity(&self) -> ParticipantIdentity {
|
||||
match self {
|
||||
Participant::Local(participant) => participant.identity.clone(),
|
||||
Participant::Remote(participant) => participant.identity.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connection_quality(&self) -> ConnectionQuality {
|
||||
match self {
|
||||
Participant::Local(p) => p.connection_quality(),
|
||||
Participant::Remote(p) => p.connection_quality(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio_level(&self) -> f32 {
|
||||
match self {
|
||||
Participant::Local(p) => p.audio_level(),
|
||||
Participant::Remote(p) => p.audio_level(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalParticipant {
|
||||
pub fn connection_quality(&self) -> ConnectionQuality {
|
||||
ConnectionQuality::Excellent
|
||||
}
|
||||
|
||||
pub fn audio_level(&self) -> f32 {
|
||||
0.0
|
||||
}
|
||||
|
||||
pub async fn unpublish_track(&self, track: TrackSid, _cx: &AsyncApp) -> Result<()> {
|
||||
self.room
|
||||
.test_server()
|
||||
.unpublish_track(self.room.token(), &track)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn publish_microphone_track(
|
||||
&self,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<(LocalTrackPublication, AudioStream, Arc<AtomicU64>)> {
|
||||
let this = self.clone();
|
||||
let server = this.room.test_server();
|
||||
let sid = server
|
||||
.publish_audio_track(this.room.token(), &LocalAudioTrack {})
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
LocalTrackPublication {
|
||||
room: self.room.downgrade(),
|
||||
sid,
|
||||
},
|
||||
AudioStream {},
|
||||
Arc::new(AtomicU64::new(0)),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn publish_screenshare_track(
|
||||
&self,
|
||||
_source: &dyn ScreenCaptureSource,
|
||||
_cx: &mut AsyncApp,
|
||||
) -> Result<(LocalTrackPublication, Box<dyn ScreenCaptureStream>)> {
|
||||
let this = self.clone();
|
||||
let server = this.room.test_server();
|
||||
let sid = server
|
||||
.publish_video_track(this.room.token(), LocalVideoTrack {})
|
||||
.await?;
|
||||
Ok((
|
||||
LocalTrackPublication {
|
||||
room: self.room.downgrade(),
|
||||
sid,
|
||||
},
|
||||
Box::new(TestScreenCaptureStream {}),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn publish_screenshare_track_wayland(
|
||||
&self,
|
||||
_cx: &mut AsyncApp,
|
||||
) -> Result<(
|
||||
LocalTrackPublication,
|
||||
Box<dyn ScreenCaptureStream>,
|
||||
futures::channel::oneshot::Receiver<()>,
|
||||
)> {
|
||||
let (_failure_tx, failure_rx) = futures::channel::oneshot::channel();
|
||||
let this = self.clone();
|
||||
let server = this.room.test_server();
|
||||
let sid = server
|
||||
.publish_video_track(this.room.token(), LocalVideoTrack {})
|
||||
.await?;
|
||||
Ok((
|
||||
LocalTrackPublication {
|
||||
room: self.room.downgrade(),
|
||||
sid,
|
||||
},
|
||||
Box::new(TestWaylandScreenCaptureStream::new()),
|
||||
failure_rx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteParticipant {
|
||||
pub fn connection_quality(&self) -> ConnectionQuality {
|
||||
ConnectionQuality::Excellent
|
||||
}
|
||||
|
||||
pub fn audio_level(&self) -> f32 {
|
||||
0.0
|
||||
}
|
||||
|
||||
pub fn track_publications(&self) -> HashMap<TrackSid, RemoteTrackPublication> {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
let server = room.test_server();
|
||||
let audio = server
|
||||
.audio_tracks(room.token())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|track| track.publisher_id() == self.identity)
|
||||
.map(|track| {
|
||||
(
|
||||
track.sid(),
|
||||
RemoteTrackPublication {
|
||||
sid: track.sid(),
|
||||
room: self.room.clone(),
|
||||
track: RemoteTrack::Audio(track),
|
||||
},
|
||||
)
|
||||
});
|
||||
let video = server
|
||||
.video_tracks(room.token())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|track| track.publisher_id() == self.identity)
|
||||
.map(|track| {
|
||||
(
|
||||
track.sid(),
|
||||
RemoteTrackPublication {
|
||||
sid: track.sid(),
|
||||
room: self.room.clone(),
|
||||
track: RemoteTrack::Video(track),
|
||||
},
|
||||
)
|
||||
});
|
||||
audio.chain(video).collect()
|
||||
} else {
|
||||
HashMap::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> ParticipantIdentity {
|
||||
self.identity.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct TestScreenCaptureStream;
|
||||
|
||||
impl ScreenCaptureStream for TestScreenCaptureStream {
|
||||
fn metadata(&self) -> Result<SourceMetadata> {
|
||||
Ok(SourceMetadata {
|
||||
id: 0,
|
||||
is_main: None,
|
||||
label: None,
|
||||
resolution: size(DevicePixels(1), DevicePixels(1)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
static NEXT_TEST_WAYLAND_SHARE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct TestWaylandScreenCaptureStream {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl TestWaylandScreenCaptureStream {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
id: NEXT_TEST_WAYLAND_SHARE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl ScreenCaptureStream for TestWaylandScreenCaptureStream {
|
||||
fn metadata(&self) -> Result<SourceMetadata> {
|
||||
Ok(SourceMetadata {
|
||||
id: self.id,
|
||||
is_main: None,
|
||||
label: None,
|
||||
resolution: size(DevicePixels(1), DevicePixels(1)),
|
||||
})
|
||||
}
|
||||
}
|
||||
91
crates/livekit_client/src/mock_client/publication.rs
Normal file
91
crates/livekit_client/src/mock_client/publication.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use gpui::App;
|
||||
|
||||
use crate::{RemoteTrack, TrackSid, test::WeakRoom};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalTrackPublication {
|
||||
pub(crate) sid: TrackSid,
|
||||
pub(crate) room: WeakRoom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteTrackPublication {
|
||||
pub(crate) sid: TrackSid,
|
||||
pub(crate) room: WeakRoom,
|
||||
pub(crate) track: RemoteTrack,
|
||||
}
|
||||
|
||||
impl LocalTrackPublication {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.sid.clone()
|
||||
}
|
||||
|
||||
pub fn mute(&self, _cx: &App) {
|
||||
self.set_mute(true)
|
||||
}
|
||||
|
||||
pub fn unmute(&self, _cx: &App) {
|
||||
self.set_mute(false)
|
||||
}
|
||||
|
||||
fn set_mute(&self, mute: bool) {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
room.test_server()
|
||||
.set_track_muted(&room.token(), &self.sid, mute)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_muted(&self) -> bool {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
room.test_server()
|
||||
.is_track_muted(&room.token(), &self.sid)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteTrackPublication {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.sid.clone()
|
||||
}
|
||||
|
||||
pub fn track(&self) -> Option<RemoteTrack> {
|
||||
Some(self.track.clone())
|
||||
}
|
||||
|
||||
pub fn is_audio(&self) -> bool {
|
||||
matches!(self.track, RemoteTrack::Audio(_))
|
||||
}
|
||||
|
||||
pub fn is_muted(&self) -> bool {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
room.test_server()
|
||||
.is_track_muted(&room.token(), &self.sid)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
!room.0.lock().paused_audio_tracks.contains(&self.sid)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool, _cx: &App) {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
let paused_audio_tracks = &mut room.0.lock().paused_audio_tracks;
|
||||
if enabled {
|
||||
paused_audio_tracks.remove(&self.sid);
|
||||
} else {
|
||||
paused_audio_tracks.insert(self.sid.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
crates/livekit_client/src/mock_client/track.rs
Normal file
56
crates/livekit_client/src/mock_client/track.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
ParticipantIdentity, TrackSid,
|
||||
test::{TestServerAudioTrack, TestServerVideoTrack, WeakRoom},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalVideoTrack {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalAudioTrack {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteVideoTrack {
|
||||
pub(crate) server_track: Arc<TestServerVideoTrack>,
|
||||
pub(crate) _room: WeakRoom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteAudioTrack {
|
||||
pub(crate) server_track: Arc<TestServerAudioTrack>,
|
||||
pub(crate) room: WeakRoom,
|
||||
}
|
||||
|
||||
impl RemoteAudioTrack {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.server_track.sid.clone()
|
||||
}
|
||||
|
||||
pub fn publisher_id(&self) -> ParticipantIdentity {
|
||||
self.server_track.publisher_id.clone()
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
if let Some(room) = self.room.upgrade() {
|
||||
!room
|
||||
.0
|
||||
.lock()
|
||||
.paused_audio_tracks
|
||||
.contains(&self.server_track.sid)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteVideoTrack {
|
||||
pub fn sid(&self) -> TrackSid {
|
||||
self.server_track.sid.clone()
|
||||
}
|
||||
|
||||
pub fn publisher_id(&self) -> ParticipantIdentity {
|
||||
self.server_track.publisher_id.clone()
|
||||
}
|
||||
}
|
||||
102
crates/livekit_client/src/record.rs
Normal file
102
crates/livekit_client/src/record.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use std::{
|
||||
env,
|
||||
num::NonZero,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use cpal::DeviceId;
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
use rodio::{buffer::SamplesBuffer, conversions::SampleTypeConverter};
|
||||
use util::ResultExt;
|
||||
|
||||
pub struct CaptureInput {
|
||||
pub name: String,
|
||||
pub input_device: Option<DeviceId>,
|
||||
config: cpal::SupportedStreamConfig,
|
||||
samples: Arc<Mutex<Vec<i16>>>,
|
||||
_stream: cpal::Stream,
|
||||
}
|
||||
|
||||
impl CaptureInput {
|
||||
pub fn start(input_device: Option<DeviceId>) -> anyhow::Result<Self> {
|
||||
let (device, config) = crate::default_device(true, input_device.as_ref())?;
|
||||
let name = device
|
||||
.description()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.unwrap_or("<unknown>".to_string());
|
||||
log::info!("Using microphone: {}", name);
|
||||
|
||||
let samples = Arc::new(Mutex::new(Vec::new()));
|
||||
let stream = start_capture(device, config.clone(), samples.clone())?;
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
input_device,
|
||||
_stream: stream,
|
||||
config,
|
||||
samples,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<PathBuf> {
|
||||
let name = self.name;
|
||||
let mut path = env::current_dir().context("Could not get current dir")?;
|
||||
path.push(&format!("test_recording_{name}.wav"));
|
||||
log::info!("Test recording written to: {}", path.display());
|
||||
write_out(self.samples, self.config, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn start_capture(
|
||||
device: cpal::Device,
|
||||
config: cpal::SupportedStreamConfig,
|
||||
samples: Arc<Mutex<Vec<i16>>>,
|
||||
) -> Result<cpal::Stream> {
|
||||
let stream = device
|
||||
.build_input_stream_raw(
|
||||
&config.config(),
|
||||
config.sample_format(),
|
||||
move |data, _: &_| {
|
||||
let data = crate::get_sample_data(config.sample_format(), data).log_err();
|
||||
let Some(data) = data else {
|
||||
return;
|
||||
};
|
||||
samples
|
||||
.try_lock()
|
||||
.expect("Only locked after stream ends")
|
||||
.extend_from_slice(&data);
|
||||
},
|
||||
|err| log::error!("error capturing audio track: {:?}", err),
|
||||
Some(Duration::from_millis(100)),
|
||||
)
|
||||
.context("failed to build input stream")?;
|
||||
|
||||
stream.play()?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn write_out(
|
||||
samples: Arc<Mutex<Vec<i16>>>,
|
||||
config: cpal::SupportedStreamConfig,
|
||||
path: &Path,
|
||||
) -> Result<()> {
|
||||
let samples = std::mem::take(
|
||||
&mut *samples
|
||||
.try_lock()
|
||||
.expect("Stream has ended, callback cant hold the lock"),
|
||||
);
|
||||
let samples: Vec<f32> = SampleTypeConverter::<_, f32>::new(samples.into_iter()).collect();
|
||||
let mut samples = SamplesBuffer::new(
|
||||
NonZero::new(config.channels()).expect("config channel is never zero"),
|
||||
NonZero::new(config.sample_rate()).expect("config sample_rate is never zero"),
|
||||
samples,
|
||||
);
|
||||
match rodio::wav_to_file(&mut samples, path) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(anyhow::anyhow!("Failed to write wav file: {}", e)),
|
||||
}
|
||||
}
|
||||
109
crates/livekit_client/src/remote_video_track_view.rs
Normal file
109
crates/livekit_client/src/remote_video_track_view.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use super::RemoteVideoTrack;
|
||||
use futures::StreamExt as _;
|
||||
use gpui::{
|
||||
AppContext as _, Context, Empty, Entity, EventEmitter, IntoElement, Render, Task, Window,
|
||||
};
|
||||
|
||||
pub struct RemoteVideoTrackView {
|
||||
track: RemoteVideoTrack,
|
||||
latest_frame: Option<crate::RemoteVideoFrame>,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
current_rendered_frame: Option<crate::RemoteVideoFrame>,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
previous_rendered_frame: Option<crate::RemoteVideoFrame>,
|
||||
_maintain_frame: Task<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteVideoTrackViewEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
impl RemoteVideoTrackView {
|
||||
pub fn new(track: RemoteVideoTrack, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
cx.focus_handle();
|
||||
let frames = crate::play_remote_video_track(&track, cx.background_executor());
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
use util::ResultExt;
|
||||
|
||||
let window_handle = window.window_handle();
|
||||
cx.on_release(move |this, cx| {
|
||||
if let Some(frame) = this.previous_rendered_frame.take() {
|
||||
window_handle
|
||||
.update(cx, |_, window, _cx| window.drop_image(frame).log_err())
|
||||
.ok();
|
||||
}
|
||||
if let Some(frame) = this.current_rendered_frame.take() {
|
||||
window_handle
|
||||
.update(cx, |_, window, _cx| window.drop_image(frame).log_err())
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
Self {
|
||||
track,
|
||||
latest_frame: None,
|
||||
_maintain_frame: cx.spawn_in(window, async move |this, cx| {
|
||||
futures::pin_mut!(frames);
|
||||
while let Some(frame) = frames.next().await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.latest_frame = Some(frame);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
this.update(cx, |_this, cx| cx.emit(RemoteVideoTrackViewEvent::Close))
|
||||
.ok();
|
||||
}),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
current_rendered_frame: None,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
previous_rendered_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clone(&self, window: &mut Window, cx: &mut Context<Self>) -> Entity<Self> {
|
||||
cx.new(|cx| Self::new(self.track.clone(), window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<RemoteVideoTrackViewEvent> for RemoteVideoTrackView {}
|
||||
|
||||
impl Render for RemoteVideoTrackView {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(latest_frame) = &self.latest_frame {
|
||||
use gpui::Styled as _;
|
||||
return gpui::surface(latest_frame.clone())
|
||||
.size_full()
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Some(latest_frame) = &self.latest_frame {
|
||||
use gpui::Styled as _;
|
||||
if let Some(current_rendered_frame) = self.current_rendered_frame.take() {
|
||||
if let Some(frame) = self.previous_rendered_frame.take() {
|
||||
// Only drop the frame if it's not also the current frame.
|
||||
if frame.id != current_rendered_frame.id {
|
||||
use util::ResultExt as _;
|
||||
_window.drop_image(frame).log_err();
|
||||
}
|
||||
}
|
||||
self.previous_rendered_frame = Some(current_rendered_frame)
|
||||
}
|
||||
self.current_rendered_frame = Some(latest_frame.clone());
|
||||
use gpui::ParentElement;
|
||||
return ui::h_flex()
|
||||
.size_full()
|
||||
.child(gpui::img(latest_frame.clone()).size_full())
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
Empty.into_any_element()
|
||||
}
|
||||
}
|
||||
855
crates/livekit_client/src/test.rs
Normal file
855
crates/livekit_client/src/test.rs
Normal file
@@ -0,0 +1,855 @@
|
||||
use crate::{AudioStream, Participant, RemoteTrack, RoomEvent, TrackPublication};
|
||||
|
||||
use crate::mock_client::{participant::*, publication::*, track::*};
|
||||
use anyhow::{Context as _, Result};
|
||||
use async_trait::async_trait;
|
||||
use collections::{BTreeMap, HashMap, HashSet, btree_map::Entry as BTreeEntry, hash_map::Entry};
|
||||
use gpui::{App, AsyncApp, BackgroundExecutor};
|
||||
use livekit_api::{proto, token};
|
||||
use parking_lot::Mutex;
|
||||
use postage::{mpsc, sink::Sink};
|
||||
use std::sync::{
|
||||
Arc, Weak,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering::SeqCst},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
|
||||
pub struct ParticipantIdentity(pub String);
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
|
||||
pub struct TrackSid(pub(crate) String);
|
||||
|
||||
impl std::fmt::Display for TrackSid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for TrackSid {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Ok(TrackSid(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
|
||||
#[non_exhaustive]
|
||||
pub enum ConnectionState {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SessionStats {
|
||||
pub publisher_stats: Vec<RtcStats>,
|
||||
pub subscriber_stats: Vec<RtcStats>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RtcStats {}
|
||||
|
||||
static SERVERS: Mutex<BTreeMap<String, Arc<TestServer>>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
pub struct TestServer {
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
pub secret_key: String,
|
||||
rooms: Mutex<HashMap<String, TestServerRoom>>,
|
||||
executor: BackgroundExecutor,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
pub fn create(
|
||||
url: String,
|
||||
api_key: String,
|
||||
secret_key: String,
|
||||
executor: BackgroundExecutor,
|
||||
) -> Result<Arc<TestServer>> {
|
||||
let mut servers = SERVERS.lock();
|
||||
if let BTreeEntry::Vacant(e) = servers.entry(url.clone()) {
|
||||
let server = Arc::new(TestServer {
|
||||
url,
|
||||
api_key,
|
||||
secret_key,
|
||||
rooms: Default::default(),
|
||||
executor,
|
||||
});
|
||||
e.insert(server.clone());
|
||||
Ok(server)
|
||||
} else {
|
||||
anyhow::bail!("a server with url {url:?} already exists");
|
||||
}
|
||||
}
|
||||
|
||||
fn get(url: &str) -> Result<Arc<TestServer>> {
|
||||
Ok(SERVERS
|
||||
.lock()
|
||||
.get(url)
|
||||
.context("no server found for url")?
|
||||
.clone())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<()> {
|
||||
SERVERS
|
||||
.lock()
|
||||
.remove(&self.url)
|
||||
.with_context(|| format!("server with url {:?} does not exist", self.url))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_api_client(&self) -> TestApiClient {
|
||||
TestApiClient {
|
||||
url: self.url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_room(&self, room: String) -> Result<()> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
if let Entry::Vacant(e) = server_rooms.entry(room.clone()) {
|
||||
e.insert(Default::default());
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("{room:?} already exists");
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_room(&self, room: String) -> Result<()> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
server_rooms
|
||||
.remove(&room)
|
||||
.with_context(|| format!("room {room:?} does not exist"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn join_room(&self, token: String, client_room: Room) -> Result<ParticipantIdentity> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
let room_name = claims.video.room.unwrap();
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = (*server_rooms).entry(room_name.to_string()).or_default();
|
||||
|
||||
if let Entry::Vacant(e) = room.client_rooms.entry(identity.clone()) {
|
||||
for server_track in &room.video_tracks {
|
||||
let track = RemoteTrack::Video(RemoteVideoTrack {
|
||||
server_track: server_track.clone(),
|
||||
_room: client_room.downgrade(),
|
||||
});
|
||||
client_room
|
||||
.0
|
||||
.lock()
|
||||
.updates_tx
|
||||
.blocking_send(RoomEvent::TrackSubscribed {
|
||||
track: track.clone(),
|
||||
publication: RemoteTrackPublication {
|
||||
sid: server_track.sid.clone(),
|
||||
room: client_room.downgrade(),
|
||||
track,
|
||||
},
|
||||
participant: RemoteParticipant {
|
||||
room: client_room.downgrade(),
|
||||
identity: server_track.publisher_id.clone(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
for server_track in &room.audio_tracks {
|
||||
let track = RemoteTrack::Audio(RemoteAudioTrack {
|
||||
server_track: server_track.clone(),
|
||||
room: client_room.downgrade(),
|
||||
});
|
||||
client_room
|
||||
.0
|
||||
.lock()
|
||||
.updates_tx
|
||||
.blocking_send(RoomEvent::TrackSubscribed {
|
||||
track: track.clone(),
|
||||
publication: RemoteTrackPublication {
|
||||
sid: server_track.sid.clone(),
|
||||
room: client_room.downgrade(),
|
||||
track,
|
||||
},
|
||||
participant: RemoteParticipant {
|
||||
room: client_room.downgrade(),
|
||||
identity: server_track.publisher_id.clone(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
e.insert(client_room);
|
||||
Ok(identity)
|
||||
} else {
|
||||
anyhow::bail!("{identity:?} attempted to join room {room_name:?} twice");
|
||||
}
|
||||
}
|
||||
|
||||
async fn leave_room(&self, token: String) -> Result<()> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
let room_name = claims.video.room.unwrap();
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&*room_name)
|
||||
.with_context(|| format!("room {room_name:?} does not exist"))?;
|
||||
room.client_rooms.remove(&identity).with_context(|| {
|
||||
format!("{identity:?} attempted to leave room {room_name:?} before joining it")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_participants(
|
||||
&self,
|
||||
token: String,
|
||||
) -> Result<HashMap<ParticipantIdentity, RemoteParticipant>> {
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let local_identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
let room_name = claims.video.room.unwrap().to_string();
|
||||
|
||||
if let Some(server_room) = self.rooms.lock().get(&room_name) {
|
||||
let room = server_room
|
||||
.client_rooms
|
||||
.get(&local_identity)
|
||||
.unwrap()
|
||||
.downgrade();
|
||||
Ok(server_room
|
||||
.client_rooms
|
||||
.iter()
|
||||
.filter(|(identity, _)| *identity != &local_identity)
|
||||
.map(|(identity, _)| {
|
||||
(
|
||||
identity.clone(),
|
||||
RemoteParticipant {
|
||||
room: room.clone(),
|
||||
identity: identity.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_participant(
|
||||
&self,
|
||||
room_name: String,
|
||||
identity: ParticipantIdentity,
|
||||
) -> Result<()> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
room.client_rooms
|
||||
.remove(&identity)
|
||||
.with_context(|| format!("participant {identity:?} did not join room {room_name:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_participant(
|
||||
&self,
|
||||
room_name: String,
|
||||
identity: String,
|
||||
permission: proto::ParticipantPermission,
|
||||
) -> Result<()> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
room.participant_permissions
|
||||
.insert(ParticipantIdentity(identity), permission);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disconnect_client(&self, client_identity: String) {
|
||||
let client_identity = ParticipantIdentity(client_identity);
|
||||
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
for room in server_rooms.values_mut() {
|
||||
if let Some(room) = room.client_rooms.remove(&client_identity) {
|
||||
let mut room = room.0.lock();
|
||||
room.connection_state = ConnectionState::Disconnected;
|
||||
room.updates_tx
|
||||
.blocking_send(RoomEvent::Disconnected {
|
||||
reason: "SIGNAL_CLOSED",
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn publish_video_track(
|
||||
&self,
|
||||
token: String,
|
||||
_local_track: LocalVideoTrack,
|
||||
) -> Result<TrackSid> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
let room_name = claims.video.room.unwrap();
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&*room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
|
||||
let can_publish = room
|
||||
.participant_permissions
|
||||
.get(&identity)
|
||||
.map(|permission| permission.can_publish)
|
||||
.or(claims.video.can_publish)
|
||||
.unwrap_or(true);
|
||||
|
||||
anyhow::ensure!(can_publish, "user is not allowed to publish");
|
||||
|
||||
let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap();
|
||||
let server_track = Arc::new(TestServerVideoTrack {
|
||||
sid: sid.clone(),
|
||||
publisher_id: identity.clone(),
|
||||
});
|
||||
|
||||
room.video_tracks.push(server_track.clone());
|
||||
|
||||
for (room_identity, client_room) in &room.client_rooms {
|
||||
if *room_identity != identity {
|
||||
let track = RemoteTrack::Video(RemoteVideoTrack {
|
||||
server_track: server_track.clone(),
|
||||
_room: client_room.downgrade(),
|
||||
});
|
||||
let publication = RemoteTrackPublication {
|
||||
sid: sid.clone(),
|
||||
room: client_room.downgrade(),
|
||||
track: track.clone(),
|
||||
};
|
||||
let participant = RemoteParticipant {
|
||||
identity: identity.clone(),
|
||||
room: client_room.downgrade(),
|
||||
};
|
||||
client_room
|
||||
.0
|
||||
.lock()
|
||||
.updates_tx
|
||||
.blocking_send(RoomEvent::TrackSubscribed {
|
||||
track,
|
||||
publication,
|
||||
participant,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sid)
|
||||
}
|
||||
|
||||
pub(crate) async fn publish_audio_track(
|
||||
&self,
|
||||
token: String,
|
||||
_local_track: &LocalAudioTrack,
|
||||
) -> Result<TrackSid> {
|
||||
self.simulate_random_delay().await;
|
||||
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
let room_name = claims.video.room.unwrap();
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&*room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
|
||||
let can_publish = room
|
||||
.participant_permissions
|
||||
.get(&identity)
|
||||
.map(|permission| permission.can_publish)
|
||||
.or(claims.video.can_publish)
|
||||
.unwrap_or(true);
|
||||
|
||||
anyhow::ensure!(can_publish, "user is not allowed to publish");
|
||||
|
||||
let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap();
|
||||
let server_track = Arc::new(TestServerAudioTrack {
|
||||
sid: sid.clone(),
|
||||
publisher_id: identity.clone(),
|
||||
muted: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
room.audio_tracks.push(server_track.clone());
|
||||
|
||||
for (room_identity, client_room) in &room.client_rooms {
|
||||
if *room_identity != identity {
|
||||
let track = RemoteTrack::Audio(RemoteAudioTrack {
|
||||
server_track: server_track.clone(),
|
||||
room: client_room.downgrade(),
|
||||
});
|
||||
let publication = RemoteTrackPublication {
|
||||
sid: sid.clone(),
|
||||
room: client_room.downgrade(),
|
||||
track: track.clone(),
|
||||
};
|
||||
let participant = RemoteParticipant {
|
||||
identity: identity.clone(),
|
||||
room: client_room.downgrade(),
|
||||
};
|
||||
client_room
|
||||
.0
|
||||
.lock()
|
||||
.updates_tx
|
||||
.blocking_send(RoomEvent::TrackSubscribed {
|
||||
track,
|
||||
publication,
|
||||
participant,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sid)
|
||||
}
|
||||
|
||||
pub(crate) async fn unpublish_track(&self, token: String, track_sid: &TrackSid) -> Result<()> {
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
let room_name = claims.video.room.unwrap();
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&*room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
|
||||
if let Some(video_to_unpublish) = room.video_tracks.iter().position(|t| t.sid == *track_sid)
|
||||
{
|
||||
let video_to_unpublish = room.video_tracks.remove(video_to_unpublish);
|
||||
for client_room in room
|
||||
.client_rooms
|
||||
.iter()
|
||||
.filter(|(id, _)| **id != identity)
|
||||
.map(|(_, room)| room)
|
||||
{
|
||||
let track = RemoteTrack::Video(RemoteVideoTrack {
|
||||
server_track: video_to_unpublish.clone(),
|
||||
_room: client_room.downgrade(),
|
||||
});
|
||||
let publication = RemoteTrackPublication {
|
||||
sid: track_sid.clone(),
|
||||
room: client_room.downgrade(),
|
||||
track: track.clone(),
|
||||
};
|
||||
let participant = RemoteParticipant {
|
||||
identity: identity.clone(),
|
||||
room: client_room.downgrade(),
|
||||
};
|
||||
let event = RoomEvent::TrackUnsubscribed {
|
||||
track,
|
||||
publication,
|
||||
participant,
|
||||
};
|
||||
|
||||
client_room.0.lock().updates_tx.blocking_send(event).ok();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(audio_to_unpublish) = room.audio_tracks.iter().position(|t| t.sid == *track_sid)
|
||||
{
|
||||
let audio_to_unpublish = room.audio_tracks.remove(audio_to_unpublish);
|
||||
for client_room in room
|
||||
.client_rooms
|
||||
.iter()
|
||||
.filter(|(id, _)| **id != identity)
|
||||
.map(|(_, room)| room)
|
||||
{
|
||||
let track = RemoteTrack::Audio(RemoteAudioTrack {
|
||||
server_track: audio_to_unpublish.clone(),
|
||||
room: client_room.downgrade(),
|
||||
});
|
||||
let publication = RemoteTrackPublication {
|
||||
sid: track_sid.clone(),
|
||||
room: client_room.downgrade(),
|
||||
track: track.clone(),
|
||||
};
|
||||
let participant = RemoteParticipant {
|
||||
identity: identity.clone(),
|
||||
room: client_room.downgrade(),
|
||||
};
|
||||
let event = RoomEvent::TrackUnsubscribed {
|
||||
track,
|
||||
publication,
|
||||
participant,
|
||||
};
|
||||
|
||||
client_room.0.lock().updates_tx.blocking_send(event).ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_track_muted(
|
||||
&self,
|
||||
token: &str,
|
||||
track_sid: &TrackSid,
|
||||
muted: bool,
|
||||
) -> Result<()> {
|
||||
let claims = livekit_api::token::validate(token, &self.secret_key)?;
|
||||
let room_name = claims.video.room.unwrap();
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&*room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
if let Some(track) = room
|
||||
.audio_tracks
|
||||
.iter_mut()
|
||||
.find(|track| track.sid == *track_sid)
|
||||
{
|
||||
track.muted.store(muted, SeqCst);
|
||||
for (id, client_room) in room.client_rooms.iter() {
|
||||
if *id != identity {
|
||||
let participant = Participant::Remote(RemoteParticipant {
|
||||
identity: identity.clone(),
|
||||
room: client_room.downgrade(),
|
||||
});
|
||||
let track = RemoteTrack::Audio(RemoteAudioTrack {
|
||||
server_track: track.clone(),
|
||||
room: client_room.downgrade(),
|
||||
});
|
||||
let publication = TrackPublication::Remote(RemoteTrackPublication {
|
||||
sid: track_sid.clone(),
|
||||
room: client_room.downgrade(),
|
||||
track,
|
||||
});
|
||||
|
||||
let event = if muted {
|
||||
RoomEvent::TrackMuted {
|
||||
participant,
|
||||
publication,
|
||||
}
|
||||
} else {
|
||||
RoomEvent::TrackUnmuted {
|
||||
participant,
|
||||
publication,
|
||||
}
|
||||
};
|
||||
|
||||
client_room
|
||||
.0
|
||||
.lock()
|
||||
.updates_tx
|
||||
.blocking_send(event)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_track_muted(&self, token: &str, track_sid: &TrackSid) -> Option<bool> {
|
||||
let claims = livekit_api::token::validate(token, &self.secret_key).ok()?;
|
||||
let room_name = claims.video.room.unwrap();
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms.get_mut(&*room_name)?;
|
||||
room.audio_tracks.iter().find_map(|track| {
|
||||
if track.sid == *track_sid {
|
||||
Some(track.muted.load(SeqCst))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn video_tracks(&self, token: String) -> Result<Vec<RemoteVideoTrack>> {
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let room_name = claims.video.room.unwrap();
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&*room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
let client_room = room
|
||||
.client_rooms
|
||||
.get(&identity)
|
||||
.context("not a participant in room")?;
|
||||
Ok(room
|
||||
.video_tracks
|
||||
.iter()
|
||||
.map(|track| RemoteVideoTrack {
|
||||
server_track: track.clone(),
|
||||
_room: client_room.downgrade(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn audio_tracks(&self, token: String) -> Result<Vec<RemoteAudioTrack>> {
|
||||
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
|
||||
let room_name = claims.video.room.unwrap();
|
||||
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
|
||||
|
||||
let mut server_rooms = self.rooms.lock();
|
||||
let room = server_rooms
|
||||
.get_mut(&*room_name)
|
||||
.with_context(|| format!("room {room_name} does not exist"))?;
|
||||
let client_room = room
|
||||
.client_rooms
|
||||
.get(&identity)
|
||||
.context("not a participant in room")?;
|
||||
Ok(room
|
||||
.audio_tracks
|
||||
.iter()
|
||||
.map(|track| RemoteAudioTrack {
|
||||
server_track: track.clone(),
|
||||
room: client_room.downgrade(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn simulate_random_delay(&self) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
self.executor.simulate_random_delay().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct TestServerRoom {
|
||||
client_rooms: HashMap<ParticipantIdentity, Room>,
|
||||
video_tracks: Vec<Arc<TestServerVideoTrack>>,
|
||||
audio_tracks: Vec<Arc<TestServerAudioTrack>>,
|
||||
participant_permissions: HashMap<ParticipantIdentity, proto::ParticipantPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TestServerVideoTrack {
|
||||
pub(crate) sid: TrackSid,
|
||||
pub(crate) publisher_id: ParticipantIdentity,
|
||||
// frames_rx: async_broadcast::Receiver<Frame>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TestServerAudioTrack {
|
||||
pub(crate) sid: TrackSid,
|
||||
pub(crate) publisher_id: ParticipantIdentity,
|
||||
pub(crate) muted: AtomicBool,
|
||||
}
|
||||
|
||||
pub struct TestApiClient {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl livekit_api::Client for TestApiClient {
|
||||
fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
async fn create_room(&self, name: String) -> Result<()> {
|
||||
let server = TestServer::get(&self.url)?;
|
||||
server.create_room(name).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_room(&self, name: String) -> Result<()> {
|
||||
let server = TestServer::get(&self.url)?;
|
||||
server.delete_room(name).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_participant(&self, room: String, identity: String) -> Result<()> {
|
||||
let server = TestServer::get(&self.url)?;
|
||||
server
|
||||
.remove_participant(room, ParticipantIdentity(identity))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_participant(
|
||||
&self,
|
||||
room: String,
|
||||
identity: String,
|
||||
permission: livekit_api::proto::ParticipantPermission,
|
||||
) -> Result<()> {
|
||||
let server = TestServer::get(&self.url)?;
|
||||
server
|
||||
.update_participant(room, identity, permission)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn room_token(&self, room: &str, identity: &str) -> Result<String> {
|
||||
let server = TestServer::get(&self.url)?;
|
||||
token::create(
|
||||
&server.api_key,
|
||||
&server.secret_key,
|
||||
Some(identity),
|
||||
token::VideoGrant::to_join(room),
|
||||
)
|
||||
}
|
||||
|
||||
fn guest_token(&self, room: &str, identity: &str) -> Result<String> {
|
||||
let server = TestServer::get(&self.url)?;
|
||||
token::create(
|
||||
&server.api_key,
|
||||
&server.secret_key,
|
||||
Some(identity),
|
||||
token::VideoGrant::for_guest(room),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RoomState {
|
||||
pub(crate) url: String,
|
||||
pub(crate) token: String,
|
||||
pub(crate) local_identity: ParticipantIdentity,
|
||||
pub(crate) connection_state: ConnectionState,
|
||||
pub(crate) paused_audio_tracks: HashSet<TrackSid>,
|
||||
pub(crate) updates_tx: mpsc::Sender<RoomEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Room(pub(crate) Arc<Mutex<RoomState>>);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct WeakRoom(Weak<Mutex<RoomState>>);
|
||||
|
||||
impl std::fmt::Debug for RoomState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Room")
|
||||
.field("url", &self.url)
|
||||
.field("token", &self.token)
|
||||
.field("local_identity", &self.local_identity)
|
||||
.field("connection_state", &self.connection_state)
|
||||
.field("paused_audio_tracks", &self.paused_audio_tracks)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub(crate) fn downgrade(&self) -> WeakRoom {
|
||||
WeakRoom(Arc::downgrade(&self.0))
|
||||
}
|
||||
|
||||
pub fn connection_state(&self) -> ConnectionState {
|
||||
self.0.lock().connection_state
|
||||
}
|
||||
|
||||
pub fn local_participant(&self) -> LocalParticipant {
|
||||
let identity = self.0.lock().local_identity.clone();
|
||||
LocalParticipant {
|
||||
identity,
|
||||
room: self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
url: String,
|
||||
token: String,
|
||||
_cx: &mut AsyncApp,
|
||||
) -> Result<(Self, mpsc::Receiver<RoomEvent>)> {
|
||||
let server = TestServer::get(&url)?;
|
||||
let (updates_tx, updates_rx) = mpsc::channel(1024);
|
||||
let this = Self(Arc::new(Mutex::new(RoomState {
|
||||
local_identity: ParticipantIdentity(String::new()),
|
||||
url: url.to_string(),
|
||||
token: token.to_string(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
paused_audio_tracks: Default::default(),
|
||||
updates_tx,
|
||||
})));
|
||||
|
||||
let identity = server
|
||||
.join_room(token.to_string(), this.clone())
|
||||
.await
|
||||
.context("room join")?;
|
||||
{
|
||||
let mut state = this.0.lock();
|
||||
state.local_identity = identity;
|
||||
state.connection_state = ConnectionState::Connected;
|
||||
}
|
||||
|
||||
Ok((this, updates_rx))
|
||||
}
|
||||
|
||||
pub fn remote_participants(&self) -> HashMap<ParticipantIdentity, RemoteParticipant> {
|
||||
self.test_server()
|
||||
.remote_participants(self.0.lock().token.clone())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn test_server(&self) -> Arc<TestServer> {
|
||||
TestServer::get(&self.0.lock().url).unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn token(&self) -> String {
|
||||
self.0.lock().token.clone()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
"test_room".to_string()
|
||||
}
|
||||
|
||||
pub async fn sid(&self) -> String {
|
||||
"RM_test_session".to_string()
|
||||
}
|
||||
|
||||
pub fn play_remote_audio_track(
|
||||
&self,
|
||||
_track: &RemoteAudioTrack,
|
||||
_cx: &App,
|
||||
) -> anyhow::Result<AudioStream> {
|
||||
Ok(AudioStream {})
|
||||
}
|
||||
|
||||
pub async fn unpublish_local_track(&self, sid: TrackSid, cx: &mut AsyncApp) -> Result<()> {
|
||||
self.local_participant().unpublish_track(sid, cx).await
|
||||
}
|
||||
|
||||
pub async fn publish_local_microphone_track(
|
||||
&self,
|
||||
_track_name: String,
|
||||
_is_staff: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(LocalTrackPublication, AudioStream, Arc<AtomicU64>)> {
|
||||
self.local_participant().publish_microphone_track(cx).await
|
||||
}
|
||||
|
||||
pub async fn get_stats(&self) -> Result<SessionStats> {
|
||||
Ok(SessionStats::default())
|
||||
}
|
||||
|
||||
pub fn stats_task(&self, _cx: &impl gpui::AppContext) -> gpui::Task<Result<SessionStats>> {
|
||||
gpui::Task::ready(Ok(SessionStats::default()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RoomState {
|
||||
fn drop(&mut self) {
|
||||
if self.connection_state == ConnectionState::Connected
|
||||
&& let Ok(server) = TestServer::get(&self.url)
|
||||
{
|
||||
let executor = server.executor.clone();
|
||||
let token = self.token.clone();
|
||||
executor
|
||||
.spawn(async move { server.leave_room(token).await.ok() })
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WeakRoom {
|
||||
pub(crate) fn upgrade(&self) -> Option<Room> {
|
||||
self.0.upgrade().map(Room)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user