225 lines
7.7 KiB
Rust
225 lines
7.7 KiB
Rust
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<LiveTicket>,
|
|
#[clap(long, conflicts_with = "ticket", requires = "name")]
|
|
endpoint_id: Option<EndpointId>,
|
|
#[clap(long, conflicts_with = "ticket", requires = "endpoint-id")]
|
|
name: Option<String>,
|
|
}
|
|
|
|
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::<FfmpegDecoders>(
|
|
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<VideoView>,
|
|
_audio: Option<AudioTrack>,
|
|
_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::<FfmpegVideoDecoder>(&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()
|
|
}
|
|
}
|