use std::time::Duration; use clap::Parser; use eframe::egui::{self, Color32, Id, Vec2}; use iroh::{Endpoint, EndpointId}; use iroh_live::{ Live, media::{ audio::AudioBackend, ffmpeg::{FfmpegDecoders, FfmpegVideoDecoder, ffmpeg_log_init}, subscribe::{AudioTrack, SubscribeBroadcast, WatchTrack}, }, moq::MoqSession, ticket::LiveTicket, util::StatsSmoother, }; use n0_error::{Result, anyerr}; use tracing::info; #[derive(Debug, Parser)] struct Cli { #[clap(long, conflicts_with = "endpoint-id")] ticket: Option, #[clap(long, conflicts_with = "ticket", requires = "name")] endpoint_id: Option, #[clap(long, conflicts_with = "ticket", requires = "endpoint-id")] name: Option, } fn main() -> Result<()> { let cli = Cli::parse(); let ticket = match (cli.ticket, cli.endpoint_id, cli.name) { (Some(ticket), None, None) => ticket, (None, Some(endpoint_id), Some(name)) => LiveTicket::new(endpoint_id, name), _ => { eprintln!("Invalid arguments: Use either --ticket, or --endpoint and --name"); std::process::exit(1); } }; tracing_subscriber::fmt::init(); ffmpeg_log_init(); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap(); let audio_ctx = AudioBackend::new(); println!("connecting to {ticket} ..."); let (endpoint, session, track) = rt.block_on({ let audio_ctx = audio_ctx.clone(); async move { let endpoint = Endpoint::bind().await?; let live = Live::new(endpoint.clone()); let audio_out = audio_ctx.default_output().await?; let (session, track) = live .watch_and_listen::( ticket.endpoint, &ticket.broadcast_name, audio_out, Default::default(), ) .await?; println!("connected!"); n0_error::Ok((endpoint, session, track)) } })?; let _guard = rt.enter(); eframe::run_native( "IrohLive", eframe::NativeOptions::default(), Box::new(|cc| { let egui_ctx = cc.egui_ctx.clone(); rt.spawn(async move { let _ = tokio::signal::ctrl_c().await; egui_ctx.send_viewport_cmd(egui::ViewportCommand::Close); // TODO: When the app is not visible, this will not trigger `update` immediately. // See https://github.com/emilk/egui/issues/5112 egui_ctx.request_repaint(); }); let app = App { video: track.video.map(|video| VideoView::new(&cc.egui_ctx, video)), _audio_ctx: audio_ctx, _audio: track.audio, broadcast: track.broadcast, session: session, stats: StatsSmoother::new(), endpoint, rt, }; Ok(Box::new(app)) }), ) .map_err(|err| anyerr!("eframe failed: {err:#}")) } struct App { video: Option, _audio: Option, _audio_ctx: AudioBackend, endpoint: Endpoint, session: MoqSession, broadcast: SubscribeBroadcast, stats: StatsSmoother, rt: tokio::runtime::Runtime, } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.request_repaint_after(Duration::from_millis(30)); // min 30 fps egui::CentralPanel::default() .frame(egui::Frame::new().inner_margin(0.0).outer_margin(0.0)) .show(ctx, |ui| { ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); let avail = ui.available_size(); if let Some(video) = self.video.as_mut() { ui.add_sized(avail, video.render(ctx, avail)); } egui::Area::new(Id::new("overlay")) .anchor(egui::Align2::LEFT_BOTTOM, [8.0, -8.0]) .show(ctx, |ui| { egui::Frame::new() .fill(egui::Color32::from_rgba_unmultiplied(0, 0, 0, 128)) .corner_radius(3.0) .show(ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); ui.set_min_width(100.); self.render_overlay(ctx, ui); }) }) }); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { info!("exit"); self.broadcast.shutdown(); self.session.close(0, b"bye"); let endpoint = self.endpoint.clone(); self.rt.block_on(async move { endpoint.close().await; info!("endpoint closed"); }); } } impl App { fn render_overlay(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { ui.vertical(|ui| { let selected = self .video .as_ref() .map(|video| video.track.rendition().to_owned()); egui::ComboBox::from_label("") .selected_text(selected.clone().unwrap_or_default()) .show_ui(ui, |ui| { for name in self.broadcast.catalog().video_renditions() { if ui .selectable_label(selected.as_deref() == Some(name), name) .clicked() { if let Ok(track) = self .broadcast .watch_rendition::(&Default::default(), name) { self.video = Some(VideoView::new(ctx, track)); } } } }); let stats = self.stats.smoothed(|| self.session.conn().stats()); ui.label(format!( "peer: {}", self.session.conn().remote_id().fmt_short() )); ui.label(format!("BW up: {}", stats.up.rate_str)); ui.label(format!("BW down: {}", stats.down.rate_str)); ui.label(format!("RTT: {}ms", stats.rtt.as_millis())); }); } } struct VideoView { track: WatchTrack, texture: egui::TextureHandle, size: egui::Vec2, } impl VideoView { fn new(ctx: &egui::Context, track: WatchTrack) -> Self { let size = egui::vec2(100., 100.); let color_image = egui::ColorImage::filled([size.x as usize, size.y as usize], Color32::BLACK); let texture = ctx.load_texture("video", color_image, egui::TextureOptions::default()); Self { size, texture, track, } } fn render(&mut self, ctx: &egui::Context, available_size: Vec2) -> egui::Image<'_> { let available_size = available_size.into(); if available_size != self.size { self.size = available_size; let ppp = ctx.pixels_per_point(); let w = (available_size.x * ppp) as u32; let h = (available_size.y * ppp) as u32; self.track.set_viewport(w, h); } if let Some(frame) = self.track.current_frame() { let (w, h) = frame.img().dimensions(); let image = egui::ColorImage::from_rgba_unmultiplied( [w as usize, h as usize], frame.img().as_raw(), ); self.texture.set(image, Default::default()); } egui::Image::from_texture(&self.texture).shrink_to_fit() } }