feat: ffmpeg image loader

This commit is contained in:
kieran 2024-10-22 23:03:49 +01:00
parent d21a45c941
commit c62fbfe510
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
30 changed files with 331 additions and 925 deletions

763
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,6 @@ egui = { version = "0.29.1" }
eframe = { version = "0.29.1", default-features = false, features = ["glow", "wgpu", "wayland", "x11", "android-native-activity"] } eframe = { version = "0.29.1", default-features = false, features = ["glow", "wgpu", "wayland", "x11", "android-native-activity"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", version = "0.3.4" } nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", version = "0.3.4" }
nostr-sdk = { version = "0.35.0", features = ["all-nips"] } nostr-sdk = { version = "0.35.0", features = ["all-nips"] }
egui_extras = { version = "0.29.1", features = ["all_loaders"] }
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
log = "0.4.22" log = "0.4.22"
pretty_env_logger = "0.5.0" pretty_env_logger = "0.5.0"
egui_inbox = "0.6.0" egui_inbox = "0.6.0"
@ -26,11 +24,12 @@ async-trait = "0.1.83"
sha2 = "0.10.8" sha2 = "0.10.8"
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls-native-roots"] } reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls-native-roots"] }
itertools = "0.13.0" itertools = "0.13.0"
lru = "0.12.5"
egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "396d0041b437d2354f7a5d4e61c8ce33a69eb3b2" } egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "eb2675dd5206d064afdd82ea72c0fac083596d86" }
#egui-video = { path = "../egui-video" } #egui-video = { path = "../egui-video" }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14.1" android_logger = "0.14.1"
android-activity = { version = "0.6.0", features = ["native-activity"] } android-activity = { version = "0.6.0", features = ["native-activity"] }
winit = { version = "0.30.5", features = ["android-native-activity"] } winit = { version = "0.30.5", features = ["android-native-activity"] }

View File

@ -33,7 +33,6 @@ impl ZapStreamApp {
.expect("Failed to add relay"); .expect("Failed to add relay");
client_clone.connect().await; client_clone.connect().await;
}); });
egui_extras::install_image_loaders(&cc.egui_ctx);
let ndb_path = data_path.join("ndb"); let ndb_path = data_path.join("ndb");
std::fs::create_dir_all(&ndb_path).expect("Failed to create ndb directory"); std::fs::create_dir_all(&ndb_path).expect("Failed to create ndb directory");

View File

@ -1,16 +1,15 @@
pub mod app; pub mod app;
mod link; mod link;
mod note_store;
mod note_util; mod note_util;
mod route; mod route;
mod services; mod services;
mod stream_info; mod stream_info;
mod widgets;
mod theme; mod theme;
mod note_store; mod widgets;
use eframe::Renderer;
use crate::app::ZapStreamApp; use crate::app::ZapStreamApp;
use eframe::Renderer;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use winit::platform::android::activity::AndroidApp; use winit::platform::android::activity::AndroidApp;
@ -22,7 +21,9 @@ use winit::platform::android::EventLoopBuilderExtAndroid;
#[tokio::main] #[tokio::main]
pub async fn android_main(app: AndroidApp) { pub async fn android_main(app: AndroidApp) {
std::env::set_var("RUST_BACKTRACE", "full"); std::env::set_var("RUST_BACKTRACE", "full");
android_logger::init_once(android_logger::Config::default().with_max_level(log::LevelFilter::Info)); android_logger::init_once(
android_logger::Config::default().with_max_level(log::LevelFilter::Info),
);
let mut options = eframe::NativeOptions::default(); let mut options = eframe::NativeOptions::default();
options.renderer = Renderer::Glow; options.renderer = Renderer::Glow;
@ -42,4 +43,4 @@ pub async fn android_main(app: AndroidApp) {
options, options,
Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path)))), Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path)))),
); );
} }

View File

@ -59,7 +59,13 @@ impl NostrLink {
} }
pub fn from_note(note: &Note<'_>) -> Self { pub fn from_note(note: &Note<'_>) -> Self {
if note.kind() >= 30_000 && note.kind() < 40_000 && note.get_tag_value("d").and_then(|v| v.variant().str()).is_some() { if note.kind() >= 30_000
&& note.kind() < 40_000
&& note
.get_tag_value("d")
.and_then(|v| v.variant().str())
.is_some()
{
Self { Self {
hrp: NostrLinkType::Coordinate, hrp: NostrLinkType::Coordinate,
id: IdOrStr::Str( id: IdOrStr::Str(

View File

@ -9,7 +9,7 @@ pub struct NoteStore<'a> {
impl<'a> NoteStore<'a> { impl<'a> NoteStore<'a> {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
events: HashMap::new() events: HashMap::new(),
} }
} }
@ -36,12 +36,10 @@ impl<'a> NoteStore<'a> {
} }
pub fn key(note: &Note<'a>) -> String { pub fn key(note: &Note<'a>) -> String {
NostrLink::from_note(note) NostrLink::from_note(note).to_tag_value()
.to_tag_value()
} }
pub fn iter(&self) -> impl Iterator<Item=&Note<'a>> { pub fn iter(&self) -> impl Iterator<Item = &Note<'a>> {
self.events.values() self.events.values()
} }
} }

View File

@ -42,8 +42,7 @@ impl NostrWidget for HomePage {
let events = NoteStore::from_vec(events); let events = NoteStore::from_vec(events);
ScrollArea::vertical() ScrollArea::vertical()
.show(ui, |ui| { .show(ui, |ui| widgets::StreamList::new(&events, services).ui(ui))
widgets::StreamList::new(&events, services).ui(ui) .inner
}).inner
} }
} }

View File

@ -25,10 +25,7 @@ impl NostrWidget for LoginPage {
ui.label(RichText::new("Login").size(32.)); ui.label(RichText::new("Login").size(32.));
ui.label("Pubkey"); ui.label("Pubkey");
ui.text_edit_singleline(&mut self.key); ui.text_edit_singleline(&mut self.key);
if Button::new() if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
.show(ui, |ui| {
ui.label("Login")
}).clicked() {
if let Ok(pk) = hex::decode(&self.key) { if let Ok(pk) = hex::decode(&self.key) {
if let Ok(pk) = pk.as_slice().try_into() { if let Ok(pk) = pk.as_slice().try_into() {
services.action(RouteAction::LoginPubkey(pk)); services.action(RouteAction::LoginPubkey(pk));
@ -41,6 +38,7 @@ impl NostrWidget for LoginPage {
if let Some(e) = &self.error { if let Some(e) = &self.error {
ui.label(RichText::new(e).color(Color32::RED)); ui.label(RichText::new(e).color(Color32::RED));
} }
}).response })
.response
} }
} }

View File

@ -14,8 +14,8 @@ use nostrdb::{Ndb, Transaction};
use std::path::PathBuf; use std::path::PathBuf;
mod home; mod home;
mod stream;
mod login; mod login;
mod stream;
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum Routes { pub enum Routes {
@ -123,7 +123,8 @@ impl Router {
} else { } else {
ui.label("No widget") ui.label("No widget")
} }
}).response })
.response
} }
} }

View File

@ -25,8 +25,7 @@ impl StreamPage {
Self { Self {
link, link,
sub, sub,
event: events event: events.first().map(|n| OwnedNote(n.note_key.as_u64())),
.first().map(|n| OwnedNote(n.note_key.as_u64())),
chat: None, chat: None,
player: None, player: None,
new_msg: WriteChat::new(), new_msg: WriteChat::new(),
@ -44,7 +43,8 @@ impl NostrWidget for StreamPage {
let event = if let Some(k) = &self.event { let event = if let Some(k) = &self.event {
services services
.ndb .ndb
.get_note_by_key(services.tx, NoteKey::new(k.0)).ok() .get_note_by_key(services.tx, NoteKey::new(k.0))
.ok()
} else { } else {
None None
}; };
@ -79,9 +79,8 @@ impl NostrWidget for StreamPage {
// consume rest of space // consume rest of space
ui.add_space(ui.available_height()); ui.add_space(ui.available_height());
}); });
ui.allocate_ui(Vec2::new(w, chat_h), |ui| { ui.allocate_ui(Vec2::new(w, chat_h), |ui| self.new_msg.render(ui, services))
self.new_msg.render(ui, services) .response
}).response
} else { } else {
ui.label("Loading..") ui.label("Loading..")
} }

View File

@ -0,0 +1,54 @@
use anyhow::Error;
use egui::ColorImage;
use std::path::PathBuf;
pub struct FfmpegLoader {}
impl FfmpegLoader {
pub fn new() -> Self {
Self {}
}
pub fn load_image(&self, path: PathBuf) -> Result<ColorImage, Error> {
unsafe {
let mut demux = egui_video::ffmpeg::demux::Demuxer::new(path.to_str().unwrap());
let info = demux.probe_input()?;
let bv = info.best_video();
if bv.is_none() {
anyhow::bail!("Not a video/image");
}
let bv = bv.unwrap();
let mut decode = egui_video::ffmpeg::decode::Decoder::new();
let rgb = egui_video::ffmpeg_sys_the_third::AVPixelFormat::AV_PIX_FMT_RGB24;
let mut scaler = egui_video::ffmpeg::scale::Scaler::new(rgb);
let mut n_pkt = 0;
loop {
let (mut pkt, stream) = demux.get_packet()?;
if (*stream).index as usize == bv.index {
let frames = decode.decode_pkt(pkt, stream)?;
if let Some((frame, _)) = frames.first() {
let mut frame = *frame;
let mut frame_rgb = scaler.process_frame(
frame,
(*frame).width as u16,
(*frame).height as u16,
)?;
egui_video::ffmpeg_sys_the_third::av_frame_free(&mut frame);
let image = egui_video::ffmpeg::video_frame_to_image(frame_rgb);
egui_video::ffmpeg_sys_the_third::av_frame_free(&mut frame_rgb);
return Ok(image);
}
}
egui_video::ffmpeg_sys_the_third::av_packet_free(&mut pkt);
n_pkt += 1;
if n_pkt > 10 {
anyhow::bail!("No image found");
}
}
}
}
}

View File

@ -1,27 +1,47 @@
use egui::Image; use crate::services::ffmpeg_loader::FfmpegLoader;
use crate::theme::NEUTRAL_800;
use anyhow::Error;
use eframe::epaint::Color32;
use egui::load::SizedTexture;
use egui::{ColorImage, Context, Image, ImageData, TextureHandle, TextureOptions};
use itertools::Itertools;
use log::{error, info}; use log::{error, info};
use lru::LruCache;
use nostr_sdk::util::hex; use nostr_sdk::util::hex;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
use std::num::NonZeroUsize;
use std::ops::Deref;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::{Arc, Mutex};
use tokio::sync::Mutex;
type ImageCacheStore = Arc<Mutex<LruCache<String, TextureHandle>>>;
pub struct ImageCache { pub struct ImageCache {
ctx: egui::Context, ctx: Context,
dir: PathBuf, dir: PathBuf,
fetch_lock: Arc<Mutex<HashSet<String>>>, placeholder: TextureHandle,
cache: ImageCacheStore,
fetch_cache: Arc<Mutex<HashSet<String>>>,
} }
impl ImageCache { impl ImageCache {
pub fn new(data_path: PathBuf, ctx: egui::Context) -> Self { pub fn new(data_path: PathBuf, ctx: Context) -> Self {
let out = data_path.join("cache/images"); let out = data_path.join("cache/images");
fs::create_dir_all(&out).unwrap(); fs::create_dir_all(&out).unwrap();
let placeholder = ctx.load_texture(
"placeholder",
ImageData::from(ColorImage::new([1, 1], NEUTRAL_800)),
TextureOptions::default(),
);
Self { Self {
ctx, ctx,
dir: out, dir: out,
fetch_lock: Arc::new(Mutex::new(HashSet::new())), placeholder,
cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(100).unwrap()))),
fetch_cache: Arc::new(Mutex::new(HashSet::new())),
} }
} }
@ -42,29 +62,61 @@ impl ImageCache {
U: Into<String>, U: Into<String>,
{ {
let u = url.into(); let u = url.into();
if let Ok(mut c) = self.cache.lock() {
if let Some(i) = c.get(&u) {
return Image::from_texture(i);
}
}
let path = self.find(&u); let path = self.find(&u);
if !path.exists() && !u.is_empty() { if !path.exists() && !u.is_empty() {
let path = path.clone(); let path = path.clone();
let fl = self.fetch_lock.clone(); let cache = self.cache.clone();
let ctx = self.ctx.clone(); let ctx = self.ctx.clone();
let fetch_cache = self.fetch_cache.clone();
let placeholder = self.placeholder.clone();
tokio::spawn(async move { tokio::spawn(async move {
if fl.lock().await.insert(u.clone()) { if fetch_cache.lock().unwrap().insert(u.clone()) {
info!("Fetching image: {}", &u); info!("Fetching image: {}", &u);
if let Ok(data) = reqwest::get(&u) if let Ok(data) = reqwest::get(&u).await {
.await { tokio::fs::create_dir_all(path.parent().unwrap())
tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap(); .await
if let Err(e) = tokio::fs::write(path, data.bytes().await.unwrap()).await { .unwrap();
let img_data = data.bytes().await.unwrap();
if let Err(e) = tokio::fs::write(path.clone(), img_data).await {
error!("Failed to write file: {}", e); error!("Failed to write file: {}", e);
} }
// forget cached url let t = Self::load_image(&ctx, path, &u)
for t in ctx.loaders().texture.lock().iter() { .await
t.forget(&u); .unwrap_or(placeholder);
} cache.lock().unwrap().put(u.clone(), t);
ctx.request_repaint(); ctx.request_repaint();
} }
} }
}); });
} else if path.exists() {
let path = path.clone();
let ctx = self.ctx.clone();
let cache = self.cache.clone();
let placeholder = self.placeholder.clone();
tokio::spawn(async move {
let t = Self::load_image(&ctx, path, &u)
.await
.unwrap_or(placeholder);
cache.lock().unwrap().put(u.clone(), t);
ctx.request_repaint();
});
} }
Image::from_uri(format!("file://{}", path.to_str().unwrap())) Image::from_texture(&self.placeholder)
} }
}
async fn load_image(ctx: &Context, path: PathBuf, key: &str) -> Option<TextureHandle> {
let mut loader = FfmpegLoader::new();
match loader.load_image(path) {
Ok(i) => Some(ctx.load_texture(key, ImageData::from(i), TextureOptions::default())),
Err(e) => {
println!("Failed to load image: {}", e);
None
}
}
}
}

View File

@ -1,3 +1,5 @@
pub mod image_cache;
pub mod ndb_wrapper; pub mod ndb_wrapper;
pub mod query; pub mod query;
pub mod image_cache;
mod ffmpeg_loader;

View File

@ -144,11 +144,12 @@ impl NDBWrapper {
// TODO: fix this shit // TODO: fix this shit
if p.is_none() && self.profiles.lock().unwrap().insert(*pubkey) { if p.is_none() && self.profiles.lock().unwrap().insert(*pubkey) {
self.query_manager.queue_query("profile", &[ self.query_manager.queue_query(
nostr::Filter::new() "profile",
&[nostr::Filter::new()
.kinds([Kind::Metadata]) .kinds([Kind::Metadata])
.authors([PublicKey::from_slice(pubkey).unwrap()]) .authors([PublicKey::from_slice(pubkey).unwrap()])],
]) )
} }
let sub = None; let sub = None;
(p, sub) (p, sub)

View File

@ -65,21 +65,24 @@ impl Query {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
// remove filters already sent // remove filters already sent
next.retain(|f| self.traces.is_empty() || !self.traces.iter().all(|y| y.filters.iter().any(|z| z == f))); next.retain(|f| {
self.traces.is_empty() || !self.traces.iter().all(|y| y.filters.iter().any(|z| z == f))
});
// force profile queries into single filter // force profile queries into single filter
if next.iter().all(|f| if let Some(k) = &f.kinds { if next.iter().all(|f| {
k.len() == 1 && k.first().unwrap().as_u16() == 0 if let Some(k) = &f.kinds {
} else { k.len() == 1 && k.first().unwrap().as_u16() == 0
false } else {
false
}
}) { }) {
next = vec![Filter::new() next = vec![Filter::new().kinds([Metadata]).authors(
.kinds([Metadata]) next.iter()
.authors(next.iter().flat_map(|f| f.authors.as_ref().unwrap().clone())) .flat_map(|f| f.authors.as_ref().unwrap().clone()),
] )]
} }
if next.is_empty() { if next.is_empty() {
return None; return None;
} }
@ -166,18 +169,24 @@ where
where where
F: Into<Vec<QueryFilter>>, F: Into<Vec<QueryFilter>>,
{ {
self.queue_into_queries.send(QueueDefer { self.queue_into_queries
id: id.to_string(), .send(QueueDefer {
filters: filters.into(), id: id.to_string(),
}).unwrap() filters: filters.into(),
})
.unwrap()
} }
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl QueryClient for Client { impl QueryClient for Client {
async fn subscribe(&self, id: &str, filters: &[QueryFilter]) -> Result<(), Error> { async fn subscribe(&self, id: &str, filters: &[QueryFilter]) -> Result<(), Error> {
self.subscribe_with_id(SubscriptionId::new(id), filters.into(), Some(SubscribeAutoCloseOptions::default())) self.subscribe_with_id(
.await?; SubscriptionId::new(id),
filters.into(),
Some(SubscribeAutoCloseOptions::default()),
)
.await?;
Ok(()) Ok(())
} }
} }

View File

@ -74,11 +74,11 @@ impl<'a> StreamInfo for Note<'a> {
} }
} }
fn starts(&self) -> u64 { fn starts(&self) -> u64 {
if let Some(s) = self.get_tag_value("starts") { if let Some(s) = self.get_tag_value("starts") {
s.variant().str() s.variant().str().map_or(self.created_at(), |v| {
.map_or(self.created_at(), |v| v.parse::<u64>().unwrap_or(self.created_at())) v.parse::<u64>().unwrap_or(self.created_at())
})
} else { } else {
self.created_at() self.created_at()
} }

View File

@ -4,4 +4,4 @@ pub const FONT_SIZE: f32 = 13.0;
pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217); pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217);
pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115); pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115);
pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38); pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38);
pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23); pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23);

View File

@ -28,8 +28,7 @@ impl<'a> Avatar<'a> {
} }
pub fn from_profile(p: &'a Option<NdbProfile<'a>>, svc: &'a ImageCache) -> Self { pub fn from_profile(p: &'a Option<NdbProfile<'a>>, svc: &'a ImageCache) -> Self {
let img = p let img = p.map_or(None, |f| f.picture().map(|f| svc.load(f)));
.map_or(None, |f| f.picture().map(|f| svc.load(f)));
Self { Self {
image: img, image: img,
sub: None, sub: None,
@ -57,10 +56,17 @@ impl<'a> Widget for Avatar<'a> {
let size_v = self.size.unwrap_or(40.); let size_v = self.size.unwrap_or(40.);
let size = Vec2::new(size_v, size_v); let size = Vec2::new(size_v, size_v);
match self.image { match self.image {
Some(img) => img.fit_to_exact_size(size).rounding(Rounding::same(size_v)).ui(ui), Some(img) => img
.fit_to_exact_size(size)
.rounding(Rounding::same(size_v))
.ui(ui),
None => { None => {
let (response, painter) = ui.allocate_painter(size, Sense::click()); let (response, painter) = ui.allocate_painter(size, Sense::click());
painter.circle_filled(Pos2::new(size_v / 2., size_v / 2.), size_v / 2., Color32::from_rgb(200, 200, 200)); painter.circle_filled(
Pos2::new(size_v / 2., size_v / 2.),
size_v / 2.,
Color32::from_rgb(200, 200, 200),
);
response response
} }
} }

View File

@ -7,9 +7,7 @@ pub struct Button {
impl Button { impl Button {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self { color: NEUTRAL_800 }
color: NEUTRAL_800
}
} }
pub fn show<F>(self, ui: &mut Ui, add_contents: F) -> Response pub fn show<F>(self, ui: &mut Ui, add_contents: F) -> Response
@ -24,9 +22,11 @@ impl Button {
let id = r.response.id; let id = r.response.id;
ui.interact( ui.interact(
r.response.on_hover_and_drag_cursor(CursorIcon::PointingHand).rect, r.response
.on_hover_and_drag_cursor(CursorIcon::PointingHand)
.rect,
id, id,
Sense::click(), Sense::click(),
) )
} }
} }

View File

@ -50,11 +50,13 @@ impl NostrWidget for Chat {
.map_while(|n| { .map_while(|n| {
services services
.ndb .ndb
.get_note_by_key(services.tx, NoteKey::new(n.0)).ok() .get_note_by_key(services.tx, NoteKey::new(n.0))
.ok()
}) })
.collect(); .collect();
let stream = services.ndb let stream = services
.ndb
.get_note_by_key(services.tx, NoteKey::new(self.stream.0)) .get_note_by_key(services.tx, NoteKey::new(self.stream.0))
.unwrap(); .unwrap();
@ -66,13 +68,17 @@ impl NostrWidget for Chat {
.show(ui, |ui| { .show(ui, |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 8.0; ui.spacing_mut().item_spacing.y = 8.0;
for ev in events.iter().sorted_by(|a, b| { for ev in events
a.created_at().cmp(&b.created_at()) .iter()
}).tail(20) { .sorted_by(|a, b| a.created_at().cmp(&b.created_at()))
.tail(20)
{
ChatMessage::new(&stream, ev, services).ui(ui); ChatMessage::new(&stream, ev, services).ui(ui);
} }
}) })
}).response })
}).inner .response
})
.inner
} }
} }

View File

@ -16,8 +16,17 @@ pub struct ChatMessage<'a> {
} }
impl<'a> ChatMessage<'a> { impl<'a> ChatMessage<'a> {
pub fn new(stream: &'a Note<'a>, ev: &'a Note<'a>, services: &'a RouteServices<'a>) -> ChatMessage<'a> { pub fn new(
ChatMessage { stream, ev, services, profile: services.ndb.fetch_profile(services.tx, ev.pubkey()) } stream: &'a Note<'a>,
ev: &'a Note<'a>,
services: &'a RouteServices<'a>,
) -> ChatMessage<'a> {
ChatMessage {
stream,
ev,
services,
profile: services.ndb.fetch_profile(services.tx, ev.pubkey()),
}
} }
} }
@ -29,17 +38,15 @@ impl<'a> Widget for ChatMessage<'a> {
job.wrap.break_anywhere = true; job.wrap.break_anywhere = true;
let is_host = self.stream.host().eq(self.ev.pubkey()); let is_host = self.stream.host().eq(self.ev.pubkey());
let profile = self.services.ndb.get_profile_by_pubkey(self.services.tx, self.ev.pubkey()) let profile = self
.services
.ndb
.get_profile_by_pubkey(self.services.tx, self.ev.pubkey())
.map_or(None, |p| p.record().profile()); .map_or(None, |p| p.record().profile());
let name = profile let name = profile.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
let name_color = if is_host { let name_color = if is_host { PRIMARY } else { NEUTRAL_500 };
PRIMARY
} else {
NEUTRAL_500
};
let mut format = TextFormat::default(); let mut format = TextFormat::default();
format.line_height = Some(24.0); format.line_height = Some(24.0);
@ -50,10 +57,9 @@ impl<'a> Widget for ChatMessage<'a> {
format.color = Color32::WHITE; format.color = Color32::WHITE;
job.append(self.ev.content(), 5.0, format.clone()); job.append(self.ev.content(), 5.0, format.clone());
ui.add(Avatar::from_profile(&profile ,self.services.img_cache).size(24.)); ui.add(Avatar::from_profile(&profile, self.services.img_cache).size(24.));
ui.add(Label::new(job) ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
.wrap_mode(TextWrapMode::Wrap) })
); .response
}).response
} }
} }

View File

@ -37,10 +37,7 @@ impl NostrWidget for Header {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if let Some(pk) = services.login { if let Some(pk) = services.login {
ui.add(Avatar::pubkey(pk, services)); ui.add(Avatar::pubkey(pk, services));
} else if Button::new() } else if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
.show(ui, |ui| {
ui.label("Login")
}).clicked() {
services.navigate(Routes::LoginPage); services.navigate(Routes::LoginPage);
} }
}); });

View File

@ -1,16 +1,16 @@
mod avatar; mod avatar;
mod button;
mod chat; mod chat;
mod chat_message; mod chat_message;
mod header; mod header;
mod profile; mod profile;
mod stream_tile;
mod stream_list; mod stream_list;
mod stream_player; mod stream_player;
mod video_placeholder; mod stream_tile;
mod stream_title; mod stream_title;
mod write_chat;
mod username; mod username;
mod button; mod video_placeholder;
mod write_chat;
use crate::route::RouteServices; use crate::route::RouteServices;
use egui::{Response, Ui}; use egui::{Response, Ui};
@ -20,13 +20,13 @@ pub trait NostrWidget {
} }
pub use self::avatar::Avatar; pub use self::avatar::Avatar;
pub use self::button::Button;
pub use self::chat::Chat; pub use self::chat::Chat;
pub use self::header::Header; pub use self::header::Header;
pub use self::profile::Profile; pub use self::profile::Profile;
pub use self::stream_list::StreamList; pub use self::stream_list::StreamList;
pub use self::stream_player::StreamPlayer; pub use self::stream_player::StreamPlayer;
pub use self::video_placeholder::VideoPlaceholder;
pub use self::stream_title::StreamTitle; pub use self::stream_title::StreamTitle;
pub use self::write_chat::WriteChat;
pub use self::username::Username; pub use self::username::Username;
pub use self::button::Button; pub use self::video_placeholder::VideoPlaceholder;
pub use self::write_chat::WriteChat;

View File

@ -1,10 +1,10 @@
use crate::route::RouteServices; use crate::route::RouteServices;
use crate::services::image_cache::ImageCache; use crate::services::image_cache::ImageCache;
use crate::services::ndb_wrapper::SubWrapper; use crate::services::ndb_wrapper::SubWrapper;
use crate::theme::FONT_SIZE;
use crate::widgets::{Avatar, Username}; use crate::widgets::{Avatar, Username};
use egui::{Response, Ui, Widget}; use egui::{Response, Ui, Widget};
use nostrdb::NdbProfile; use nostrdb::NdbProfile;
use crate::theme::FONT_SIZE;
pub struct Profile<'a> { pub struct Profile<'a> {
size: f32, size: f32,
@ -39,6 +39,7 @@ impl<'a> Widget for Profile<'a> {
ui.add(Avatar::from_profile(&self.profile, self.img_cache).size(self.size)); ui.add(Avatar::from_profile(&self.profile, self.img_cache).size(self.size));
ui.add(Username::new(&self.profile, FONT_SIZE)) ui.add(Username::new(&self.profile, FONT_SIZE))
}).response })
.response
} }
} }

View File

@ -23,11 +23,11 @@ impl Widget for StreamList<'_> {
.show(ui, |ui| { .show(ui, |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.style_mut().spacing.item_spacing = egui::vec2(0., 20.0); ui.style_mut().spacing.item_spacing = egui::vec2(0., 20.0);
for event in self.streams.iter() for event in self.streams.iter().sorted_by(|a, b| {
.sorted_by(|a, b| { a.status()
a.status().cmp(&b.status()) .cmp(&b.status())
.then(a.starts().cmp(&b.starts()).reverse()) .then(a.starts().cmp(&b.starts()).reverse())
}) { }) {
ui.add(StreamEvent::new(event, self.services)); ui.add(StreamEvent::new(event, self.services));
} }
}) })

View File

@ -11,9 +11,7 @@ impl StreamPlayer {
let mut p = Player::new(ctx, url); let mut p = Player::new(ctx, url);
p.set_debug(true); p.set_debug(true);
p.start(); p.start();
Self { Self { player: Some(p) }
player: Some(p)
}
} }
} }

View File

@ -1,13 +1,15 @@
use crate::link::NostrLink; use crate::link::NostrLink;
use crate::route::{RouteServices, Routes}; use crate::route::{RouteServices, Routes};
use crate::stream_info::{StreamInfo, StreamStatus}; use crate::stream_info::{StreamInfo, StreamStatus};
use crate::theme::{NEUTRAL_500, NEUTRAL_900, PRIMARY}; use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY};
use crate::widgets::avatar::Avatar; use crate::widgets::avatar::Avatar;
use eframe::epaint::{Rounding, Vec2}; use eframe::epaint::{Rounding, Vec2};
use egui::epaint::RectShape; use egui::epaint::RectShape;
use egui::load::TexturePoll; use egui::load::TexturePoll;
use egui::{vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode, Ui, Widget}; use egui::{
use image::Pixel; vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode,
Ui, Widget,
};
use nostrdb::Note; use nostrdb::Note;
pub struct StreamEvent<'a> { pub struct StreamEvent<'a> {
@ -17,10 +19,7 @@ pub struct StreamEvent<'a> {
impl<'a> StreamEvent<'a> { impl<'a> StreamEvent<'a> {
pub fn new(event: &'a Note<'a>, services: &'a RouteServices) -> Self { pub fn new(event: &'a Note<'a>, services: &'a RouteServices) -> Self {
Self { Self { event, services }
event,
services,
}
} }
} }
impl Widget for StreamEvent<'_> { impl Widget for StreamEvent<'_> {
@ -33,14 +32,14 @@ impl Widget for StreamEvent<'_> {
let w = ui.available_width(); let w = ui.available_width();
let h = (w / 16.0) * 9.0; let h = (w / 16.0) * 9.0;
let cover = self.event.image() let cover = self.event.image().map(|p| self.services.img_cache.load(p));
.map(|p| self.services.img_cache.load(p));
let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click()); let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click());
if let Some(cover) = cover.map(|c| if let Some(cover) = cover.map(|c| {
c.rounding(Rounding::same(12.)) c.rounding(Rounding::same(12.))
.load_for_size(painter.ctx(), Vec2::new(w, h))) { .load_for_size(painter.ctx(), Vec2::new(w, h))
}) {
match cover { match cover {
Ok(TexturePoll::Ready { texture }) => { Ok(TexturePoll::Ready { texture }) => {
painter.add(RectShape { painter.add(RectShape {
@ -54,11 +53,11 @@ impl Widget for StreamEvent<'_> {
}); });
} }
_ => { _ => {
painter.rect_filled(response.rect, 12., NEUTRAL_500); painter.rect_filled(response.rect, 12., NEUTRAL_800);
} }
} }
} else { } else {
painter.rect_filled(response.rect, 12., NEUTRAL_500); painter.rect_filled(response.rect, 12., NEUTRAL_800);
} }
let overlay_label_pad = Vec2::new(5., 5.); let overlay_label_pad = Vec2::new(5., 5.);
@ -68,20 +67,41 @@ impl Widget for StreamEvent<'_> {
} else { } else {
NEUTRAL_900 NEUTRAL_900
}; };
let live_label = painter.layout_no_wrap(live_label_text, FontId::default(), Color32::WHITE); let live_label =
painter.layout_no_wrap(live_label_text, FontId::default(), Color32::WHITE);
let overlay_react = response.rect.shrink(8.0); let overlay_react = response.rect.shrink(8.0);
let live_label_pos = overlay_react.min + vec2(overlay_react.width() - live_label.rect.width() - (overlay_label_pad.x * 2.), 0.0); let live_label_pos = overlay_react.min
let live_label_background = Rect::from_two_pos(live_label_pos, live_label_pos + live_label.size() + (overlay_label_pad * 2.)); + vec2(
overlay_react.width() - live_label.rect.width() - (overlay_label_pad.x * 2.),
0.0,
);
let live_label_background = Rect::from_two_pos(
live_label_pos,
live_label_pos + live_label.size() + (overlay_label_pad * 2.),
);
painter.rect_filled(live_label_background, 8., live_label_color); painter.rect_filled(live_label_background, 8., live_label_color);
painter.galley(live_label_pos + overlay_label_pad, live_label, Color32::PLACEHOLDER); painter.galley(
live_label_pos + overlay_label_pad,
live_label,
Color32::PLACEHOLDER,
);
if let Some(viewers) = self.event.viewers() { if let Some(viewers) = self.event.viewers() {
let viewers_label = painter.layout_no_wrap(format!("{} viewers", viewers), FontId::default(), Color32::WHITE); let viewers_label = painter.layout_no_wrap(
let rect_start = overlay_react.max - viewers_label.size() - (overlay_label_pad * 2.0); format!("{} viewers", viewers),
FontId::default(),
Color32::WHITE,
);
let rect_start =
overlay_react.max - viewers_label.size() - (overlay_label_pad * 2.0);
let pos = Rect::from_two_pos(rect_start, overlay_react.max); let pos = Rect::from_two_pos(rect_start, overlay_react.max);
painter.rect_filled(pos, 8., NEUTRAL_900); painter.rect_filled(pos, 8., NEUTRAL_900);
painter.galley(rect_start + overlay_label_pad, viewers_label, Color32::PLACEHOLDER); painter.galley(
rect_start + overlay_label_pad,
viewers_label,
Color32::PLACEHOLDER,
);
} }
let response = response.on_hover_and_drag_cursor(CursorIcon::PointingHand); let response = response.on_hover_and_drag_cursor(CursorIcon::PointingHand);
if response.clicked() { if response.clicked() {
@ -98,6 +118,6 @@ impl Widget for StreamEvent<'_> {
ui.add(Label::new(title).wrap_mode(TextWrapMode::Truncate)); ui.add(Label::new(title).wrap_mode(TextWrapMode::Truncate));
}) })
}) })
.response .response
} }
} }

View File

@ -11,9 +11,7 @@ pub struct StreamTitle<'a> {
impl<'a> StreamTitle<'a> { impl<'a> StreamTitle<'a> {
pub fn new(event: &'a Note<'a>) -> StreamTitle { pub fn new(event: &'a Note<'a>) -> StreamTitle {
StreamTitle { StreamTitle { event }
event
}
} }
} }
@ -28,15 +26,17 @@ impl<'a> NostrWidget for StreamTitle<'a> {
.color(Color32::WHITE); .color(Color32::WHITE);
ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Truncate)); ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Truncate));
Profile::new(self.event.host(), services) Profile::new(self.event.host(), services).size(32.).ui(ui);
.size(32.)
.ui(ui);
if let Some(summary) = self.event.get_tag_value("summary").and_then(|r| r.variant().str()) { if let Some(summary) = self
let summary = RichText::new(summary) .event
.color(Color32::WHITE); .get_tag_value("summary")
.and_then(|r| r.variant().str())
{
let summary = RichText::new(summary).color(Color32::WHITE);
ui.add(Label::new(summary).wrap_mode(TextWrapMode::Truncate)); ui.add(Label::new(summary).wrap_mode(TextWrapMode::Truncate));
} }
}).response })
.response
} }
} }

View File

@ -20,4 +20,4 @@ impl<'a> Widget for Username<'a> {
let name = RichText::new(name).size(self.size).color(Color32::WHITE); let name = RichText::new(name).size(self.size).color(Color32::WHITE);
ui.add(Label::new(name).wrap_mode(TextWrapMode::Truncate)) ui.add(Label::new(name).wrap_mode(TextWrapMode::Truncate))
} }
} }

View File

@ -10,9 +10,7 @@ pub struct WriteChat {
impl WriteChat { impl WriteChat {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self { msg: String::new() }
msg: String::new(),
}
} }
} }
@ -30,18 +28,19 @@ impl NostrWidget for WriteChat {
.inner_margin(Margin::symmetric(12., 12.)) .inner_margin(Margin::symmetric(12., 12.))
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
let editor = TextEdit::singleline(&mut self.msg) let editor = TextEdit::singleline(&mut self.msg).frame(false);
.frame(false);
ui.add(editor); ui.add(editor);
if Image::from_bytes("send-03.svg", logo_bytes) if Image::from_bytes("send-03.svg", logo_bytes)
.sense(Sense::click()) .sense(Sense::click())
.ui(ui) .ui(ui)
.clicked() { .clicked()
{
info!("Sending: {}", self.msg); info!("Sending: {}", self.msg);
self.msg.clear(); self.msg.clear();
} }
}); });
}) })
}).response })
.response
} }
} }