diff --git a/Cargo.lock b/Cargo.lock index 1ad9b35..9b5f1a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2831,7 +2831,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.3.4" -source = "git+https://github.com/damus-io/nostrdb-rs#9bbafd8a2e904b77a51e7cfca71eb5bb5650e829" +source = "git+https://github.com/damus-io/nostrdb-rs?branch=master#9bbafd8a2e904b77a51e7cfca71eb5bb5650e829" dependencies = [ "bindgen 0.69.5", "cc", diff --git a/Cargo.toml b/Cargo.toml index ec93755..671e1fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ crate-type = ["lib", "cdylib"] [dependencies] tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] } egui = { version = "0.29.1" } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", version = "0.3.4" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", branch = "master" } nostr-sdk = { version = "0.35.0", features = ["all-nips"] } log = "0.4.22" pretty_env_logger = "0.5.0" @@ -25,10 +25,10 @@ reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tl itertools = "0.13.0" lru = "0.12.5" resvg = { version = "0.44.0", default-features = false } - -egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "ced65d0bb4d2d144b87c70518a04b767ba37c0c1" } serde = { version = "1.0.214", features = ["derive"] } serde_with = { version = "3.11.0", features = ["hex"] } + +egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "ced65d0bb4d2d144b87c70518a04b767ba37c0c1" } #egui-video = { path = "../egui-video" } [target.'cfg(not(target_os = "android"))'.dependencies] diff --git a/src/android.rs b/src/android.rs index b6bc65c..70ef139 100644 --- a/src/android.rs +++ b/src/android.rs @@ -1,7 +1,9 @@ -use crate::app::{NativeLayer, NativeSecureStorage, ZapStreamApp}; +use crate::app::{NativeLayerOps, ZapStreamApp}; use crate::av_log_redirect; use eframe::Renderer; use egui::{Margin, ViewportBuilder}; +use serde::de::DeserializeOwned; +use serde::Serialize; use std::ops::Div; use winit::platform::android::activity::AndroidApp; use winit::platform::android::EventLoopBuilderExtAndroid; @@ -36,13 +38,13 @@ pub fn start_android(app: AndroidApp) { if let Err(e) = eframe::run_native( "zap.stream", options, - Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path, Box::new(app))))), + Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc, data_path, app)))), ) { eprintln!("{}", e); } } -impl NativeLayer for AndroidApp { +impl NativeLayerOps for AndroidApp { fn frame_margin(&self) -> Margin { if let Some(wd) = self.native_window() { let (w, h) = (wd.width(), wd.height()); @@ -70,12 +72,6 @@ impl NativeLayer for AndroidApp { self.hide_soft_input(true); } - fn secure_storage(&self) -> Box { - Box::new(self.clone()) - } -} - -impl NativeSecureStorage for AndroidApp { fn get(&self, k: &str) -> Option { None } @@ -87,4 +83,12 @@ impl NativeSecureStorage for AndroidApp { fn remove(&mut self, k: &str) -> bool { false } + + fn get_obj(&self, k: &str) -> Option { + None + } + + fn set_obj(&mut self, k: &str, v: &T) -> bool { + false + } } diff --git a/src/bin/zap_stream_app.rs b/src/bin/zap_stream_app.rs index e5075ec..bd01400 100644 --- a/src/bin/zap_stream_app.rs +++ b/src/bin/zap_stream_app.rs @@ -1,5 +1,4 @@ -use eframe::Renderer; -use egui::{Margin, Vec2}; +use egui::{Margin, Vec2, ViewportBuilder}; use nostr_sdk::serde_json; use serde::de::DeserializeOwned; use serde::Serialize; @@ -19,8 +18,7 @@ async fn main() { egui_video::ffmpeg_sys_the_third::av_log_set_callback(Some(av_log_redirect)); } let mut options = eframe::NativeOptions::default(); - options.renderer = Renderer::Glow; - options.viewport = options.viewport.with_inner_size(Vec2::new(360., 720.)); + options.viewport = ViewportBuilder::default().with_inner_size(Vec2::new(1280., 720.)); let data_path = PathBuf::from("./.data"); let config = DesktopApp::new(data_path.clone()); diff --git a/src/lib.rs b/src/lib.rs index 2ab9fa4..b640dd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,7 +32,7 @@ pub unsafe extern "C" fn av_log_redirect( use egui_video::ffmpeg_sys_the_third::*; let log_level = match level { AV_LOG_DEBUG => log::Level::Debug, - AV_LOG_WARNING => log::Level::Warn, + AV_LOG_WARNING => log::Level::Debug, // downgrade to debug (spammy) AV_LOG_INFO => log::Level::Info, AV_LOG_ERROR => log::Level::Error, AV_LOG_PANIC => log::Level::Error, diff --git a/src/note_store.rs b/src/note_store.rs index 3eb883d..351b685 100644 --- a/src/note_store.rs +++ b/src/note_store.rs @@ -3,7 +3,7 @@ use nostrdb::Note; use std::collections::HashMap; pub struct NoteStore<'a> { - events: HashMap>, + events: HashMap>, } impl<'a> NoteStore<'a> { @@ -13,7 +13,11 @@ impl<'a> NoteStore<'a> { } } - pub fn from_vec(events: Vec>) -> Self { + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn from_vec(events: Vec<&'a Note<'a>>) -> Self { let mut store = Self::new(); for note in events { store.add(note); @@ -21,7 +25,7 @@ impl<'a> NoteStore<'a> { store } - pub fn add(&mut self, note: Note<'a>) -> Option> { + pub fn add(&mut self, note: &'a Note<'a>) -> Option<&'a Note<'a>> { let k = Self::key(¬e); if let Some(v) = self.events.get(&k) { if v.created_at() < note.created_at() { @@ -31,7 +35,7 @@ impl<'a> NoteStore<'a> { self.events.insert(k, note) } - pub fn remove(&mut self, note: &Note<'a>) -> Option> { + pub fn remove(&mut self, note: &Note<'a>) -> Option<&'a Note<'a>> { self.events.remove(&Self::key(note)) } @@ -39,7 +43,7 @@ impl<'a> NoteStore<'a> { NostrLink::from_note(note).to_tag_value() } - pub fn iter(&self) -> impl Iterator> { + pub fn iter(&self) -> impl Iterator> { self.events.values() } } diff --git a/src/route/home.rs b/src/route/home.rs index 0b9f42d..2a6d0e8 100644 --- a/src/route/home.rs +++ b/src/route/home.rs @@ -2,9 +2,10 @@ use crate::note_store::NoteStore; use crate::note_util::OwnedNote; use crate::route::RouteServices; use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper}; +use crate::stream_info::{StreamInfo, StreamStatus}; use crate::widgets; use crate::widgets::NostrWidget; -use egui::{Response, ScrollArea, Ui, Widget}; +use egui::{Id, Response, RichText, ScrollArea, Ui, Widget}; use nostrdb::{Filter, Note, NoteKey, Transaction}; pub struct HomePage { @@ -14,8 +15,8 @@ pub struct HomePage { impl HomePage { pub fn new(ndb: &NDBWrapper, tx: &Transaction) -> Self { - let filter = [Filter::new().kinds([30_311]).limit(10).build()]; - let (sub, events) = ndb.subscribe_with_results("home-page", &filter, tx, 100); + let filter = [Filter::new().kinds([30_311]).limit(100).build()]; + let (sub, events) = ndb.subscribe_with_results("home-page", &filter, tx, 1000); Self { sub, events: events @@ -40,9 +41,55 @@ impl NostrWidget for HomePage { .map_while(|f| f.ok()) .collect(); - let events = NoteStore::from_vec(events); ScrollArea::vertical() - .show(ui, |ui| widgets::StreamList::new(&events, services).ui(ui)) + .show(ui, |ui| { + let events_live = NoteStore::from_vec( + events + .iter() + .filter(|r| matches!(r.status(), StreamStatus::Live)) + .collect(), + ); + if events_live.len() > 0 { + widgets::StreamList::new( + Id::new("live-streams"), + &events_live, + services, + Some(RichText::new("Live").size(32.0)), + ) + .ui(ui); + } + let events_planned = NoteStore::from_vec( + events + .iter() + .filter(|r| matches!(r.status(), StreamStatus::Planned)) + .collect(), + ); + if events_planned.len() > 0 { + widgets::StreamList::new( + Id::new("planned-streams"), + &events_planned, + services, + Some(RichText::new("Planned").size(32.0)), + ) + .ui(ui); + } + let events_ended = NoteStore::from_vec( + events + .iter() + .filter(|r| matches!(r.status(), StreamStatus::Ended)) + .collect(), + ); + if events_ended.len() > 0 { + widgets::StreamList::new( + Id::new("ended-streams"), + &events_ended, + services, + Some(RichText::new("Ended").size(32.0)), + ) + .ui(ui); + } + ui.response() + }) .inner } } diff --git a/src/route/stream.rs b/src/route/stream.rs index 16e7c9d..dd960f2 100644 --- a/src/route/stream.rs +++ b/src/route/stream.rs @@ -4,8 +4,8 @@ use crate::route::RouteServices; use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper}; use crate::stream_info::StreamInfo; use crate::widgets::{Chat, NostrWidget, StreamPlayer, StreamTitle, WriteChat}; -use egui::{Response, Ui, Vec2, Widget}; -use nostrdb::{Filter, NoteKey, Transaction}; +use egui::{vec2, Response, Ui, Vec2, Widget}; +use nostrdb::{Filter, Note, NoteKey, Transaction}; use std::borrow::Borrow; pub struct StreamPage { @@ -31,6 +31,81 @@ impl StreamPage { new_msg: WriteChat::new(link), } } + fn render_mobile( + &mut self, + event: &Note<'_>, + ui: &mut Ui, + services: &mut RouteServices<'_>, + ) -> Response { + if let Some(player) = &mut self.player { + player.ui(ui); + } + StreamTitle::new(&event).render(ui, services); + + let chat_h = 60.0; + let w = ui.available_width(); + let h = ui + .available_height() + .max(ui.available_rect_before_wrap().height()) + .max(chat_h); + ui.allocate_ui(Vec2::new(w, h - chat_h), |ui| { + if let Some(c) = self.chat.as_mut() { + c.render(ui, services); + } else { + ui.label("Loading.."); + } + // consume rest of space + if ui.available_height().is_finite() { + ui.add_space(ui.available_height()); + } + }); + ui.allocate_ui(vec2(w, chat_h), |ui| { + self.new_msg.render(ui, services); + }); + ui.response() + } + + fn render_desktop( + &mut self, + event: &Note<'_>, + ui: &mut Ui, + services: &mut RouteServices<'_>, + ) -> Response { + let max_h = ui.available_height(); + let chat_w = 450.0; + let video_width = ui.available_width() - chat_w; + let video_height = max_h.min((video_width / 16.0) * 9.0); + + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + if let Some(player) = &mut self.player { + ui.allocate_ui(vec2(video_width, video_height), |ui| player.ui(ui)); + } + ui.add_space(10.); + StreamTitle::new(&event).render(ui, services); + }); + ui.allocate_ui(vec2(chat_w, max_h), |ui| { + ui.vertical(|ui| { + let chat_h = 60.0; + if let Some(c) = self.chat.as_mut() { + ui.allocate_ui(vec2(chat_w, max_h - chat_h), |ui| { + c.render(ui, services); + if ui.available_height().is_finite() { + ui.add_space(ui.available_height() - chat_h); + } + }); + } else { + ui.label("Loading.."); + } + ui.allocate_ui(vec2(chat_w, chat_h), |ui| { + self.new_msg.render(ui, services); + }); + }) + }); + }); + + ui.response() + } } impl NostrWidget for StreamPage { @@ -56,36 +131,17 @@ impl NostrWidget for StreamPage { } } - if let Some(player) = &mut self.player { - player.ui(ui); - } - StreamTitle::new(&event).render(ui, services); - if self.chat.is_none() { let ok = OwnedNote(event.key().unwrap().as_u64()); let chat = Chat::new(self.link.clone(), ok, services.ndb, services.tx); self.chat = Some(chat); } - let chat_h = 60.0; - let w = ui.available_width(); - let h = ui - .available_height() - .max(ui.available_rect_before_wrap().height()) - .max(chat_h); - ui.allocate_ui(Vec2::new(w, h - chat_h), |ui| { - if let Some(c) = self.chat.as_mut() { - c.render(ui, services); - } else { - ui.label("Loading.."); - } - // consume rest of space - if ui.available_height().is_finite() { - ui.add_space(ui.available_height()); - } - }); - ui.allocate_ui(Vec2::new(w, chat_h), |ui| self.new_msg.render(ui, services)) - .response + if ui.available_width() < 720.0 { + self.render_mobile(&event, ui, services) + } else { + self.render_desktop(&event, ui, services) + } } else { ui.label("Loading..") } diff --git a/src/widgets/avatar.rs b/src/widgets/avatar.rs index 04787e7..2a18bf5 100644 --- a/src/widgets/avatar.rs +++ b/src/widgets/avatar.rs @@ -1,7 +1,7 @@ use crate::route::RouteServices; use crate::services::image_cache::ImageCache; use crate::services::ndb_wrapper::SubWrapper; -use egui::{Color32, Image, Pos2, Response, Rounding, Sense, Ui, Vec2, Widget}; +use egui::{vec2, Color32, Image, Pos2, Response, Rounding, Sense, Ui, Vec2, Widget}; use nostrdb::NdbProfile; pub struct Avatar<'a> { @@ -49,26 +49,31 @@ impl<'a> Avatar<'a> { self.size = Some(size); self } + + fn placeholder(ui: &mut Ui, size: f32) -> Response { + let (response, painter) = ui.allocate_painter(vec2(size, size), Sense::click()); + painter.circle_filled( + Pos2::new(size / 2., size / 2.), + size / 2., + Color32::from_rgb(200, 200, 200), + ); + response + } } impl<'a> Widget for Avatar<'a> { fn ui(self, ui: &mut Ui) -> Response { let size_v = self.size.unwrap_or(40.); let size = Vec2::new(size_v, size_v); + if !ui.is_rect_visible(ui.cursor()) { + return Self::placeholder(ui, size_v); + } match self.image { 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), - ); - response - } + None => Self::placeholder(ui, size_v), } } } diff --git a/src/widgets/chat.rs b/src/widgets/chat.rs index 9ecc1e1..31a5830 100644 --- a/src/widgets/chat.rs +++ b/src/widgets/chat.rs @@ -2,7 +2,6 @@ use crate::link::NostrLink; use crate::note_util::OwnedNote; use crate::route::RouteServices; use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper}; -use crate::stream_info::StreamInfo; use crate::widgets::chat_message::ChatMessage; use crate::widgets::NostrWidget; use egui::{Frame, Margin, Response, ScrollArea, Ui, Widget}; @@ -71,7 +70,6 @@ impl NostrWidget for Chat { 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); } diff --git a/src/widgets/stream_list.rs b/src/widgets/stream_list.rs index fed3d6a..b406902 100644 --- a/src/widgets/stream_list.rs +++ b/src/widgets/stream_list.rs @@ -2,35 +2,72 @@ use crate::note_store::NoteStore; use crate::route::RouteServices; use crate::stream_info::StreamInfo; use crate::widgets::stream_tile::StreamEvent; -use egui::{Frame, Margin, Response, Ui, Widget}; +use egui::{vec2, Frame, Grid, Margin, Response, Ui, Widget, WidgetText}; use itertools::Itertools; pub struct StreamList<'a> { + id: egui::Id, streams: &'a NoteStore<'a>, services: &'a RouteServices<'a>, + heading: Option, } impl<'a> StreamList<'a> { - pub fn new(streams: &'a NoteStore<'a>, services: &'a RouteServices) -> Self { - Self { streams, services } + pub fn new( + id: egui::Id, + streams: &'a NoteStore<'a>, + services: &'a RouteServices, + heading: Option>, + ) -> Self { + Self { + id, + streams, + services, + heading: heading.map(Into::into), + } } } impl Widget for StreamList<'_> { fn ui(self, ui: &mut Ui) -> Response { + let cols = match ui.available_width() as u16 { + 720..1080 => 2, + 1080..1300 => 3, + 1300..1500 => 4, + 1500..2000 => 5, + 2000.. => 6, + _ => 1, + }; + + let grid_padding = 20.; + let frame_margin = 16.0; Frame::none() - .inner_margin(Margin::symmetric(16., 8.)) + .inner_margin(Margin::symmetric(frame_margin, 0.)) .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()) - }) { - ui.add(StreamEvent::new(event, self.services)); - } - }) + let grid_spacing_consumed = (cols - 1) as f32 * grid_padding; + let g_w = (ui.available_width() - grid_spacing_consumed) / cols as f32; + if let Some(heading) = self.heading { + ui.label(heading); + } + Grid::new(self.id) + .spacing(vec2(grid_padding, grid_padding)) + .show(ui, |ui| { + let mut ctr = 0; + for event in self.streams.iter().sorted_by(|a, b| { + a.status() + .cmp(&b.status()) + .then(a.starts().cmp(&b.starts()).reverse()) + }) { + ui.add_sized( + vec2(g_w, (g_w / 16.0) * 9.0), + StreamEvent::new(event, self.services), + ); + ctr += 1; + if ctr % cols == 0 { + ui.end_row(); + } + } + }) }) .response }