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"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", version = "0.3.4" }
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"
pretty_env_logger = "0.5.0"
egui_inbox = "0.6.0"
@ -26,11 +24,12 @@ async-trait = "0.1.83"
sha2 = "0.10.8"
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls-native-roots"] }
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" }
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14.1"
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");
client_clone.connect().await;
});
egui_extras::install_image_loaders(&cc.egui_ctx);
let ndb_path = data_path.join("ndb");
std::fs::create_dir_all(&ndb_path).expect("Failed to create ndb directory");

View File

@ -1,16 +1,15 @@
pub mod app;
mod link;
mod note_store;
mod note_util;
mod route;
mod services;
mod stream_info;
mod widgets;
mod theme;
mod note_store;
mod widgets;
use eframe::Renderer;
use crate::app::ZapStreamApp;
use eframe::Renderer;
#[cfg(target_os = "android")]
use winit::platform::android::activity::AndroidApp;
@ -22,7 +21,9 @@ use winit::platform::android::EventLoopBuilderExtAndroid;
#[tokio::main]
pub async fn android_main(app: AndroidApp) {
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();
options.renderer = Renderer::Glow;
@ -42,4 +43,4 @@ pub async fn android_main(app: AndroidApp) {
options,
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 {
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 {
hrp: NostrLinkType::Coordinate,
id: IdOrStr::Str(

View File

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

View File

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

View File

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

View File

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

View File

@ -25,8 +25,7 @@ impl StreamPage {
Self {
link,
sub,
event: events
.first().map(|n| OwnedNote(n.note_key.as_u64())),
event: events.first().map(|n| OwnedNote(n.note_key.as_u64())),
chat: None,
player: None,
new_msg: WriteChat::new(),
@ -44,7 +43,8 @@ impl NostrWidget for StreamPage {
let event = if let Some(k) = &self.event {
services
.ndb
.get_note_by_key(services.tx, NoteKey::new(k.0)).ok()
.get_note_by_key(services.tx, NoteKey::new(k.0))
.ok()
} else {
None
};
@ -79,9 +79,8 @@ impl NostrWidget for StreamPage {
// consume rest of space
ui.add_space(ui.available_height());
});
ui.allocate_ui(Vec2::new(w, chat_h), |ui| {
self.new_msg.render(ui, services)
}).response
ui.allocate_ui(Vec2::new(w, chat_h), |ui| self.new_msg.render(ui, services))
.response
} else {
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 lru::LruCache;
use nostr_sdk::util::hex;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::num::NonZeroUsize;
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use std::sync::{Arc, Mutex};
type ImageCacheStore = Arc<Mutex<LruCache<String, TextureHandle>>>;
pub struct ImageCache {
ctx: egui::Context,
ctx: Context,
dir: PathBuf,
fetch_lock: Arc<Mutex<HashSet<String>>>,
placeholder: TextureHandle,
cache: ImageCacheStore,
fetch_cache: Arc<Mutex<HashSet<String>>>,
}
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");
fs::create_dir_all(&out).unwrap();
let placeholder = ctx.load_texture(
"placeholder",
ImageData::from(ColorImage::new([1, 1], NEUTRAL_800)),
TextureOptions::default(),
);
Self {
ctx,
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>,
{
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);
if !path.exists() && !u.is_empty() {
let path = path.clone();
let fl = self.fetch_lock.clone();
let cache = self.cache.clone();
let ctx = self.ctx.clone();
let fetch_cache = self.fetch_cache.clone();
let placeholder = self.placeholder.clone();
tokio::spawn(async move {
if fl.lock().await.insert(u.clone()) {
if fetch_cache.lock().unwrap().insert(u.clone()) {
info!("Fetching image: {}", &u);
if let Ok(data) = reqwest::get(&u)
.await {
tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap();
if let Err(e) = tokio::fs::write(path, data.bytes().await.unwrap()).await {
if let Ok(data) = reqwest::get(&u).await {
tokio::fs::create_dir_all(path.parent().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);
}
// forget cached url
for t in ctx.loaders().texture.lock().iter() {
t.forget(&u);
}
let t = Self::load_image(&ctx, path, &u)
.await
.unwrap_or(placeholder);
cache.lock().unwrap().put(u.clone(), t);
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 query;
pub mod image_cache;
mod ffmpeg_loader;

View File

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

View File

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

View File

@ -74,11 +74,11 @@ impl<'a> StreamInfo for Note<'a> {
}
}
fn starts(&self) -> u64 {
if let Some(s) = self.get_tag_value("starts") {
s.variant().str()
.map_or(self.created_at(), |v| v.parse::<u64>().unwrap_or(self.created_at()))
s.variant().str().map_or(self.created_at(), |v| {
v.parse::<u64>().unwrap_or(self.created_at())
})
} else {
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 NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115);
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 {
let img = p
.map_or(None, |f| f.picture().map(|f| svc.load(f)));
let img = p.map_or(None, |f| f.picture().map(|f| svc.load(f)));
Self {
image: img,
sub: None,
@ -57,10 +56,17 @@ impl<'a> Widget for Avatar<'a> {
let size_v = self.size.unwrap_or(40.);
let size = Vec2::new(size_v, size_v);
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 => {
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
}
}

View File

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

View File

@ -50,11 +50,13 @@ impl NostrWidget for Chat {
.map_while(|n| {
services
.ndb
.get_note_by_key(services.tx, NoteKey::new(n.0)).ok()
.get_note_by_key(services.tx, NoteKey::new(n.0))
.ok()
})
.collect();
let stream = services.ndb
let stream = services
.ndb
.get_note_by_key(services.tx, NoteKey::new(self.stream.0))
.unwrap();
@ -66,13 +68,17 @@ impl NostrWidget for Chat {
.show(ui, |ui| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 8.0;
for ev in events.iter().sorted_by(|a, b| {
a.created_at().cmp(&b.created_at())
}).tail(20) {
for ev in events
.iter()
.sorted_by(|a, b| a.created_at().cmp(&b.created_at()))
.tail(20)
{
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> {
pub fn new(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()) }
pub fn new(
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;
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());
let name = profile
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
let name = profile.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
let name_color = if is_host {
PRIMARY
} else {
NEUTRAL_500
};
let name_color = if is_host { PRIMARY } else { NEUTRAL_500 };
let mut format = TextFormat::default();
format.line_height = Some(24.0);
@ -50,10 +57,9 @@ impl<'a> Widget for ChatMessage<'a> {
format.color = Color32::WHITE;
job.append(self.ev.content(), 5.0, format.clone());
ui.add(Avatar::from_profile(&profile ,self.services.img_cache).size(24.));
ui.add(Label::new(job)
.wrap_mode(TextWrapMode::Wrap)
);
}).response
ui.add(Avatar::from_profile(&profile, self.services.img_cache).size(24.));
ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
})
.response
}
}

View File

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

View File

@ -1,16 +1,16 @@
mod avatar;
mod button;
mod chat;
mod chat_message;
mod header;
mod profile;
mod stream_tile;
mod stream_list;
mod stream_player;
mod video_placeholder;
mod stream_tile;
mod stream_title;
mod write_chat;
mod username;
mod button;
mod video_placeholder;
mod write_chat;
use crate::route::RouteServices;
use egui::{Response, Ui};
@ -20,13 +20,13 @@ pub trait NostrWidget {
}
pub use self::avatar::Avatar;
pub use self::button::Button;
pub use self::chat::Chat;
pub use self::header::Header;
pub use self::profile::Profile;
pub use self::stream_list::StreamList;
pub use self::stream_player::StreamPlayer;
pub use self::video_placeholder::VideoPlaceholder;
pub use self::stream_title::StreamTitle;
pub use self::write_chat::WriteChat;
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::services::image_cache::ImageCache;
use crate::services::ndb_wrapper::SubWrapper;
use crate::theme::FONT_SIZE;
use crate::widgets::{Avatar, Username};
use egui::{Response, Ui, Widget};
use nostrdb::NdbProfile;
use crate::theme::FONT_SIZE;
pub struct Profile<'a> {
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(Username::new(&self.profile, FONT_SIZE))
}).response
})
.response
}
}

View File

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

View File

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

View File

@ -1,13 +1,15 @@
use crate::link::NostrLink;
use crate::route::{RouteServices, Routes};
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 eframe::epaint::{Rounding, Vec2};
use egui::epaint::RectShape;
use egui::load::TexturePoll;
use egui::{vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode, Ui, Widget};
use image::Pixel;
use egui::{
vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode,
Ui, Widget,
};
use nostrdb::Note;
pub struct StreamEvent<'a> {
@ -17,10 +19,7 @@ pub struct StreamEvent<'a> {
impl<'a> StreamEvent<'a> {
pub fn new(event: &'a Note<'a>, services: &'a RouteServices) -> Self {
Self {
event,
services,
}
Self { event, services }
}
}
impl Widget for StreamEvent<'_> {
@ -33,14 +32,14 @@ impl Widget for StreamEvent<'_> {
let w = ui.available_width();
let h = (w / 16.0) * 9.0;
let cover = self.event.image()
.map(|p| self.services.img_cache.load(p));
let cover = self.event.image().map(|p| self.services.img_cache.load(p));
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.))
.load_for_size(painter.ctx(), Vec2::new(w, h))) {
.load_for_size(painter.ctx(), Vec2::new(w, h))
}) {
match cover {
Ok(TexturePoll::Ready { texture }) => {
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 {
painter.rect_filled(response.rect, 12., NEUTRAL_500);
painter.rect_filled(response.rect, 12., NEUTRAL_800);
}
let overlay_label_pad = Vec2::new(5., 5.);
@ -68,20 +67,41 @@ impl Widget for StreamEvent<'_> {
} else {
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 live_label_pos = overlay_react.min + 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.));
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_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.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() {
let viewers_label = painter.layout_no_wrap(format!("{} viewers", viewers), FontId::default(), Color32::WHITE);
let rect_start = overlay_react.max - viewers_label.size() - (overlay_label_pad * 2.0);
let viewers_label = painter.layout_no_wrap(
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);
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);
if response.clicked() {
@ -98,6 +118,6 @@ impl Widget for StreamEvent<'_> {
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> {
pub fn new(event: &'a Note<'a>) -> StreamTitle {
StreamTitle {
event
}
StreamTitle { event }
}
}
@ -28,15 +26,17 @@ impl<'a> NostrWidget for StreamTitle<'a> {
.color(Color32::WHITE);
ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Truncate));
Profile::new(self.event.host(), services)
.size(32.)
.ui(ui);
Profile::new(self.event.host(), services).size(32.).ui(ui);
if let Some(summary) = self.event.get_tag_value("summary").and_then(|r| r.variant().str()) {
let summary = RichText::new(summary)
.color(Color32::WHITE);
if let Some(summary) = self
.event
.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));
}
}).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);
ui.add(Label::new(name).wrap_mode(TextWrapMode::Truncate))
}
}
}

View File

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