feat: ffmpeg image loader
This commit is contained in:
parent
d21a45c941
commit
c62fbfe510
763
Cargo.lock
generated
763
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"] }
|
||||
|
@ -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");
|
||||
|
13
src/lib.rs
13
src/lib.rs
@ -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)))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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..")
|
||||
}
|
||||
|
54
src/services/ffmpeg_loader.rs
Normal file
54
src/services/ffmpeg_loader.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
pub mod image_cache;
|
||||
pub mod ndb_wrapper;
|
||||
pub mod query;
|
||||
pub mod image_cache;
|
||||
|
||||
mod ffmpeg_loader;
|
||||
|
@ -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)
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
})
|
||||
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user