diff --git a/Cargo.toml b/Cargo.toml index 3a90000..4959c4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,20 @@ repository = "https://git.cscherr.de/PlexSheep/beatbear" keywords = ["media", "sound", "music", "player", "jellyfin", "downloads"] [features] +default = ["backend-fs", "backend-jellyfin"] backend-fs = [] backend-jellyfin = [] -gui = [] -tui = [] [dependencies] +anyhow = "1.0.86" +clap = { version = "4.5.16", features = ["derive"] } +eframe = { version = "0.28.1", optional = false } +egui = { version = "0.28.1", optional = false } +human-panic = "2.0.1" +image = "0.25.2" libpt = { version = "0.6.0", features = ["cli", "full"] } +thiserror = "1.0.63" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } [lib] name = "beatbaer" diff --git a/assets/img/icon-256.jpg b/assets/img/icon-256.jpg new file mode 100644 index 0000000..cee54d4 Binary files /dev/null and b/assets/img/icon-256.jpg differ diff --git a/assets/img/icon-512.jpg b/assets/img/icon-512.jpg new file mode 100644 index 0000000..886bb74 Binary files /dev/null and b/assets/img/icon-512.jpg differ diff --git a/src/backend/mod.rs b/src/backend/mod.rs index e69de29..5f30493 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -0,0 +1 @@ +pub trait Backend {} diff --git a/src/error/mod.rs b/src/error/mod.rs new file mode 100644 index 0000000..cf846af --- /dev/null +++ b/src/error/mod.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Error in the player UI")] + UiError(#[from] eframe::Error), + #[error(transparent)] + Other(#[from] anyhow::Error), +} diff --git a/src/lib.rs b/src/lib.rs index 7b5f1f9..17a269d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,4 @@ pub mod backend; +pub mod error; pub mod music; +pub mod player; diff --git a/src/main.rs b/src/main.rs index e7a11a9..8eddfca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,31 @@ -fn main() { - println!("Hello, world!"); +use beatbaer::error::Error; +use beatbaer::player::Player; +use human_panic::{setup_panic, Metadata}; +use libpt::log::info; + +fn main() -> Result<(), Error> { + setup_panic!( + Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) + .authors(env!("CARGO_PKG_AUTHORS")) + .homepage(env!("CARGO_PKG_HOMEPAGE")) + .support(format!( + "Public issue tracker at: {}\nor alternatively email: {}", + env!("CARGO_PKG_REPOSITORY"), + "software@cscherr.de" + )) + ); + let mut player = Player::build()?; + info!("starting ui"); + + eframe::run_native( + beatbaer::player::TITLE, + player.gui_options.clone(), + Box::new(|cc| { + player.init(cc); + Ok(Box::new(player)) + }), + )?; + + info!("leaving ui"); + Ok(()) } diff --git a/src/player/mod.rs b/src/player/mod.rs new file mode 100644 index 0000000..6f9e43c --- /dev/null +++ b/src/player/mod.rs @@ -0,0 +1,59 @@ +use clap::Parser; +use eframe::CreationContext; +use libpt::cli::args::VerbosityLevel; +use libpt::log::{debug, info, trace}; + +use crate::error::Error; + +pub mod ui; + +pub const TITLE: &str = "Beatbär"; + +#[derive(Parser)] +pub struct Player { + #[command(flatten)] + verbosity: VerbosityLevel, + + #[clap(skip)] + pub gui_options: eframe::NativeOptions, + + #[clap(skip)] + show_info_window: bool, +} + +impl Player { + pub fn build() -> Result { + let mut app = Player::parse(); + + let filter = tracing_subscriber::EnvFilter::builder() + .with_default_directive(app.verbosity.level().into()) + .from_env() + .expect("could not init logger") + .add_directive( + format!("{}={}", env!("CARGO_PKG_NAME"), app.verbosity.level()) + .parse() + .expect("could not init logger"), + ); + + tracing_subscriber::fmt::Subscriber::builder() + .with_env_filter(filter) + .with_max_level(app.verbosity.level()) + .init(); + debug!("logging initialized!"); + debug!("level: {}", app.verbosity.level()); + + app.gui_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([400.0, 300.0]) + .with_min_inner_size([300.0, 220.0]) + .with_icon(Player::load_icon()), + ..Default::default() + }; + + info!("Player ready to start UI"); + Ok(app) + } + pub fn init(&mut self, _cc: &CreationContext) { + // we can use the creation context to do some customizing, but idc right now + } +} diff --git a/src/player/ui.rs b/src/player/ui.rs new file mode 100644 index 0000000..ad92e2b --- /dev/null +++ b/src/player/ui.rs @@ -0,0 +1,157 @@ +use egui::IconData; +use libpt::log::trace; + +use super::*; + +const ICON_RAW: &[u8; 36525] = include_bytes!("../../assets/img/icon-512.jpg"); + +impl Player { + fn top_panel(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + egui::menu::bar(ui, |ui| { + // NOTE: no File->Quit on web pages! + let is_web = cfg!(target_arch = "wasm32"); + if !is_web { + ui.menu_button("File", |ui| { + if ui.button("Info").clicked() { + self.show_info_window = true; + } + ui.separator(); + if ui.button("Quit").clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + ui.add_space(16.0); + } + if self.show_info_window { + self.info_diag(ctx); + } + + egui::widgets::global_dark_light_mode_buttons(ui); + }); + } + + fn info_diag(&mut self, ctx: &egui::Context) { + trace!("rendering info dialogue"); + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of(format!("{TITLE}: Information")), + egui::ViewportBuilder::default() + .with_title(format!("{TITLE}: Information")) + .with_inner_size([500.0, 200.0]), + |ctx, class| { + assert!( + class == egui::ViewportClass::Immediate, + "This egui backend doesn't support multiple viewports" + ); + + egui::CentralPanel::default().show(ctx, |ui| { + ui.label(format!("{TITLE} v{}", env!("CARGO_PKG_VERSION"))); + ui.hyperlink_to("Source Code\n", env!("CARGO_PKG_REPOSITORY")); + ui.label(format!("Author: {}", env!("CARGO_PKG_AUTHORS"))); + ui.label(format!("License: {}", env!("CARGO_PKG_LICENSE"))); + ui.label(format!( + "\n{TITLE} is free software. If you paid for this you were scammed.\n" + )); + + //////////////////////////////////////////////////////////////// + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label("Powered by "); + ui.hyperlink_to("egui", "https://github.com/emilk/egui"); + ui.label(" and "); + ui.hyperlink_to( + "eframe", + "https://github.com/emilk/egui/tree/master/crates/eframe", + ); + ui.label("."); + }); + + //////////////////////////////////////////////////////////////// + }); + if ctx.input(|i| i.viewport().close_requested()) { + // Tell parent viewport that we should not show next frame: + self.show_info_window = false; + } + }, + ); + } + + fn bottom_label(ui: &mut egui::Ui) { + ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label(format!("{TITLE} v{}", env!("CARGO_PKG_VERSION"))); + ui.label(" | Powered by "); + ui.hyperlink_to("egui", "https://github.com/emilk/egui"); + ui.label(" and "); + ui.hyperlink_to( + "eframe", + "https://github.com/emilk/egui/tree/master/crates/eframe", + ); + ui.label("."); + }); + egui::warn_if_debug_build(ui); + }); + } + + fn main_panel(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + ui.vertical_centered(|ui| { + for i in 0..100 { + ui.horizontal_wrapped(|ui| { + for j in 0..10 { + ui.label(format!("foo-{i}-{j}")); + } + }); + } + }); + } + + fn meta_panel(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + egui::menu::bar(ui, |ui| { + // NOTE: no File->Quit on web pages! + ui.menu_button("File", |ui| { + if ui.button("Info").clicked() { + self.show_info_window = true; + } + ui.separator(); + if ui.button("Quit").clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + ui.add_space(16.0); + }); + } + + pub(crate) fn load_icon() -> IconData { + let (icon_rgba, icon_width, icon_height) = { + let image = image::load_from_memory(ICON_RAW) + .expect("Failed to open icon path") + .into_rgba8(); + let (width, height) = image.dimensions(); + let rgba = image.into_raw(); + (rgba, width, height) + }; + + IconData { + rgba: icon_rgba, + width: icon_width, + height: icon_height, + } + } +} + +impl eframe::App for Player { + /// Called each time the UI needs repainting, which may be many times per second. + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { + self.top_panel(ui, ctx); + }); + egui::TopBottomPanel::top("top_panel2").show(ctx, |ui| { + self.meta_panel(ui, ctx); + }); + egui::CentralPanel::default().show(ctx, |ui| { + self.main_panel(ui, ctx); + }); + egui::TopBottomPanel::bottom("bot_panel").show(ctx, Self::bottom_label); + } +}