diff --git a/src/rust/amadeus-rs/.gitignore b/src/rust/amadeus-rs/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ea8c4bf7f35f6f77f75d92ad8ce8349f6e81ddba --- /dev/null +++ b/src/rust/amadeus-rs/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src/rust/amadeus-rs/.gitlab-ci.yml b/src/rust/amadeus-rs/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d9669e1ad5d4dc7c17b7a827ec4dc0f04b0dd16 --- /dev/null +++ b/src/rust/amadeus-rs/.gitlab-ci.yml @@ -0,0 +1,10 @@ +image: "rust:latest" + +before_script: + - apt-get update -yqq + - apt-get install -yqq --no-install-recommends build-essential libxcb-render0-dev libxcb-render-util0-dev libxcb-shape0-dev libxcb-xfixes0-dev + +test:cargo: + script: + - rustc --version && cargo --version + - cargo test --verbose diff --git a/src/rust/amadeus-rs/Cargo.toml b/src/rust/amadeus-rs/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..e07989ee38057ea5d190a53a831bc7eaf76f1f76 --- /dev/null +++ b/src/rust/amadeus-rs/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = [ + "amadeus", + "amadeus-macro", + "lkt-rs", + "lkt-lib" +] \ No newline at end of file diff --git a/src/rust/amadeus-rs/LICENSE b/src/rust/amadeus-rs/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..14d093867d2df165a8eb9060c47071f471d171e6 --- /dev/null +++ b/src/rust/amadeus-rs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Maël MARTIN + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/rust/amadeus-rs/README.md b/src/rust/amadeus-rs/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8544a75dcf2ebfa29a438bc2a3475b68ee01e49a --- /dev/null +++ b/src/rust/amadeus-rs/README.md @@ -0,0 +1,10 @@ +# Amadeus RS + +Amadeus, the rust version. + +## Build + +You need a rust toolchain. When cloned just enter `cargo build` to build the +application. You will need some XCB libraries on Linux. On Ubuntu you will need +the following packets : `libxcb-render0-dev`, `libxcb-render-util0-dev`, +`libxcb-shape0-dev`, `libxcb-xfixes0-dev` diff --git a/src/rust/amadeus-rs/amadeus-macro/Cargo.toml b/src/rust/amadeus-rs/amadeus-macro/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7671b6f7e25463f99d7aea99467a1542185a926e --- /dev/null +++ b/src/rust/amadeus-rs/amadeus-macro/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "amadeus_macro" +version = "0.1.0" +edition = "2021" +license = "MIT" \ No newline at end of file diff --git a/src/rust/amadeus-rs/amadeus-macro/src/lib.rs b/src/rust/amadeus-rs/amadeus-macro/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..7e6a7e6107c33f1c24d0414d852f628a64e36443 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus-macro/src/lib.rs @@ -0,0 +1,19 @@ +// https://doc.rust-lang.org/reference/macros-by-example.html + +#[macro_export] +macro_rules! either { + ($test:expr => $true_expr:expr; $false_expr:expr) => { + if $test { + $true_expr + } else { + $false_expr + } + }; +} + +#[macro_export] +macro_rules! lkt_command_from_str { + ($lit:literal) => { + concat!($lit, '\n').to_owned() + }; +} diff --git a/src/rust/amadeus-rs/amadeus/Cargo.toml b/src/rust/amadeus-rs/amadeus/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..cdea7bab694b64ad3539838cf85cf70e0b2e1eef --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "amadeus" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +lkt_lib = { path = "../lkt-lib" } +amadeus_macro = { path = "../amadeus-macro" } +serde = { version = "1", default-features = false, features = [ "derive", "std" ] } +serde_json = { version = "1", default-features = false, features = [ "std" ] } +eframe = { version = "0.17.0", features = [ "persistence" ] } +image = { version = "^0.24", default-features = false, features = [ "jpeg", "ico", "png" ] } +egui = { version = "0.17.0", features = [ "extra_debug_asserts", "extra_asserts", "serde", "persistence" ] } +epi = { version = "0.17.0", features = [ "persistence" ] } +log = { version = "0.4" } +lazy_static = "1" \ No newline at end of file diff --git a/src/rust/amadeus-rs/amadeus/src/action/action.rs b/src/rust/amadeus-rs/amadeus/src/action/action.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b9931314cc398672b9e53815dbf47e132a2909e --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/action/action.rs @@ -0,0 +1,99 @@ +use std::fmt; + +#[derive(Clone)] +pub enum Action { + /// Resume playback from that kara. + PlayFromKara, + + /// Pop the kara out of the queue. + DeleteKaraFromQueue, + + /// Insert the kara in the queue. The kara will be inserted at the top of + /// the queue, but after all the other inserted karas. + InsertKaraInQueue, + + /// Add the kara at the end of the playlist. + AddKaraToQueue, + + /// Add the kara to a playlist. The playlist will be selected by the user in + /// a pop-up or something like that. + AddKaraToPlaylist, + + /// Action to delete a kara from a playlist. The passed `u64` is the unique + /// id of the playlist, the internal one. + DeleteKaraFromPlaylist(u64), + + /// Open the playlist in a window for the user to browse. + OpenPlaylist, + + /// Add all the content of a playlist to the queue. + AddPlaylistToQueue, + + /// Insert all the content of a playlist in the queue. + InsertPlaylistToQueue, + + /// Clear the content of the playlist, delete all the contained karas. + ClearPlaylistContent, + + /// Connect to lektord. + ConnectToLektord, + + /// Disconnect from lektord. + DisconnectFromLektord, + + PlaybackPrevious, + PlaybackPlay, + PlaybackPause, + PlaybackNext, +} + +impl fmt::Debug for Action { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PlayFromKara => write!(f, "PlayFromKara"), + Self::DeleteKaraFromQueue => write!(f, "DeleteKaraFromQueue"), + Self::InsertKaraInQueue => write!(f, "InsertKaraInQueue"), + Self::AddKaraToQueue => write!(f, "AddKaraToQueue"), + Self::AddKaraToPlaylist => write!(f, "AddKaraToPlaylist"), + Self::DeleteKaraFromPlaylist(arg0) => { + f.debug_tuple("DeleteKaraFromPlaylist").field(arg0).finish() + } + Self::OpenPlaylist => write!(f, "OpenPlaylist"), + Self::AddPlaylistToQueue => write!(f, "AddPlaylistToQueue"), + Self::InsertPlaylistToQueue => write!(f, "InsertPlaylistToQueue"), + Self::ClearPlaylistContent => write!(f, "ClearPlaylistContent"), + Self::ConnectToLektord => write!(f, "ConnectToLektord"), + Self::DisconnectFromLektord => write!(f, "DisconnectFromLektord"), + Self::PlaybackPrevious => write!(f, "PlaybackPrevious"), + Self::PlaybackPlay => write!(f, "PlaybackPlay"), + Self::PlaybackPause => write!(f, "PlaybackPause"), + Self::PlaybackNext => write!(f, "PlaybackNext"), + } + } +} + +pub fn get_card_action_name(act: &Action) -> &'static str { + use Action::*; + return match act { + PlayFromKara => "Play from that kara", + DeleteKaraFromQueue => "Delete from the queue", + + InsertKaraInQueue => "Insert in the queue", + AddKaraToQueue => "Add to the queue", + AddKaraToPlaylist => "Add to playlist", + + DeleteKaraFromPlaylist(_) => "Delete from playlist", + OpenPlaylist => "Open playlist", + AddPlaylistToQueue => "Add playlist to the queue", + InsertPlaylistToQueue => "Insert the playlist in the queue", + ClearPlaylistContent => "Clear the playlist content", + + ConnectToLektord => "Connect to lektord", + DisconnectFromLektord => "Disconnect from lektord", + + PlaybackPrevious => "Previous", + PlaybackPlay => "Play", + PlaybackPause => "Pause", + PlaybackNext => "Next", + }; +} diff --git a/src/rust/amadeus-rs/amadeus/src/action/menu.rs b/src/rust/amadeus-rs/amadeus/src/action/menu.rs new file mode 100644 index 0000000000000000000000000000000000000000..828d4057e836ff20ee2420af295fb63112afddbb --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/action/menu.rs @@ -0,0 +1,23 @@ +use super::action::get_card_action_name; +use super::Action; +use crate::utils; + +pub fn render_action_menu( + ui: &mut egui::Ui, + actions: &Vec<Action>, + menu_name: impl Into<egui::WidgetText>, + fullfilled: &mut Vec<Action>, +) { + ui.menu_button(menu_name, |ui| { + ui.style_mut().override_text_style = Some(utils::font::body()); + for act in actions { + let button = egui::Button::new(get_card_action_name(act)) + .wrap(false) + .frame(false); + if ui.add(button).clicked() { + fullfilled.push(act.clone()); + ui.close_menu(); + } + } + }); +} diff --git a/src/rust/amadeus-rs/amadeus/src/action/mod.rs b/src/rust/amadeus-rs/amadeus/src/action/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..c24c5a8375a960113676566586e00b3f8d05dc3e --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/action/mod.rs @@ -0,0 +1,5 @@ +mod action; +mod menu; + +pub use action::*; +pub use menu::render_action_menu; diff --git a/src/rust/amadeus-rs/amadeus/src/amadeus.rs b/src/rust/amadeus-rs/amadeus/src/amadeus.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3c857bb7c9c2cfeff4efb062a1a9f7684358e30 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/amadeus.rs @@ -0,0 +1,528 @@ +// https://docs.rs/epi/0.17.0/epi/trait.App.html +// https://docs.rs/egui/0.17.0/egui/ +// https://docs.rs/egui/0.17.0/egui/struct.FontDefinitions.html + +use crate::{ + action, + cards::*, + constants, playlists, + utils::{self, deamon::Deamon}, + widgets, +}; +use amadeus_macro::either; +use eframe::{ + egui, + epi::{self, App}, +}; +use lkt_lib::{ + query::LektorQuery, + response::LektorFormatedResponse, + types::{LektorState, Playlist}, +}; +use log::debug; +use std::{ + sync::mpsc::{Receiver, Sender}, + time, +}; + +pub struct Amadeus<'a> { + config: utils::AmadeusConfig, + has_config_changed: bool, + need_about_window: bool, + need_settings_window: bool, + actions: Vec<action::Action>, + + last_render_instant: time::SystemTime, + begin_render_instant: time::SystemTime, + + deamon: Option<( + (Sender<LektorQuery>, Receiver<LektorFormatedResponse>), + utils::deamon::CommandDeamon, + )>, + status_deamon: Option<( + (Receiver<utils::deamon::StatusDeamonMessageType>,), + utils::deamon::StatusDeamon, + )>, + + lektord_current_kara: Option<KaraCard>, + lektord_queue: KaraCardCollection<'a>, + lektord_historic: KaraCardCollection<'a>, + + lektord_search_results: KaraCardCollection<'a>, + lektord_search_query: String, + lektord_updated_query: bool, + + playlist_store: playlists::PlaylistsStore, + + lektord_state: LektorState, + + amadeus_logo_texture: Option<egui::TextureHandle>, +} + +impl Default for Amadeus<'_> { + fn default() -> Self { + Self { + last_render_instant: time::SystemTime::now(), + begin_render_instant: time::SystemTime::UNIX_EPOCH, + + lektord_queue: KaraCardCollection::new("Queue".to_owned()) + .add_action(action::Action::PlayFromKara) + .add_action(action::Action::DeleteKaraFromQueue) + .add_action(action::Action::AddKaraToPlaylist), + lektord_historic: KaraCardCollection::new("Historic".to_owned()) + .add_action(action::Action::AddKaraToQueue) + .add_action(action::Action::InsertKaraInQueue) + .add_action(action::Action::DeleteKaraFromQueue) + .add_action(action::Action::AddKaraToPlaylist), + lektord_search_results: KaraCardCollection::new("Search results".to_owned()) + .add_action(action::Action::AddKaraToQueue) + .add_action(action::Action::InsertKaraInQueue) + .add_action(action::Action::DeleteKaraFromQueue) + .add_action(action::Action::AddKaraToPlaylist), + lektord_search_query: String::new(), + + actions: Vec::with_capacity(10), + playlist_store: Default::default(), + config: Default::default(), + has_config_changed: Default::default(), + need_about_window: Default::default(), + need_settings_window: Default::default(), + deamon: Default::default(), + status_deamon: Default::default(), + lektord_current_kara: Default::default(), + lektord_updated_query: Default::default(), + lektord_state: Default::default(), + amadeus_logo_texture: Default::default(), + } + } +} + +impl Amadeus<'_> { + pub fn create() -> Box<Self> { + Box::new(Amadeus::default()) + } + + fn load_amadeus_logo(&mut self, ctx: &egui::Context) { + let (logo_buffer, [size_x, size_y]) = utils::get_icon_as_dynamic_image(); + let pixels = logo_buffer.as_flat_samples(); + let logo_texture = egui::ColorImage::from_rgba_unmultiplied( + [size_x as usize, size_y as usize], + pixels.as_slice(), + ); + self.amadeus_logo_texture = Some(ctx.load_texture("amadeus-logo", logo_texture)); + } + + fn collect_fulfilled_actions(&mut self) -> Vec<(u64, action::Action)> { + let mut ret = self.lektord_queue.fulfilled_actions(); + ret.extend(self.lektord_historic.fulfilled_actions()); + ret.extend(self.lektord_search_results.fulfilled_actions()); + ret.extend(self.playlist_store.fulfilled_actions()); + return ret; + } + + fn render_side_panel_main_view_button( + &mut self, + ui: &mut egui::Ui, + title: &str, + view: utils::AmadeusMainView, + ) { + ui.add_space(constants::PADDING); + let selected = view == self.config.main_panel_view; + if selected { + ui.colored_label(constants::get_text_color(self.config.dark_mode), title); + } else { + let the_btn = egui::Button::new(title).frame(false); + if ui.add(the_btn).clicked() { + self.config.main_panel_view = view; + } + } + } + + fn render_side_panel(&mut self, ctx: &egui::Context, _frame: &epi::Frame) { + if self.config.side_panel_show { + egui::SidePanel::left("LEFT_PANEL") + .resizable(true) + .show(ctx, |ui| { + ui.vertical(|ui| { + use utils::AmadeusMainView::*; + ui.style_mut().override_text_style = Some(utils::font::heading1()); + self.render_side_panel_main_view_button(ui, "🎵 Queue", Queue); + self.render_side_panel_main_view_button(ui, "📚 Historic", Historic); + self.render_side_panel_main_view_button(ui, "🔍 Search", SearchResults); + ui.add_space(constants::PADDING); + ui.style_mut().override_text_style = Some(utils::font::body()); + ui.separator(); + }); + self.playlist_store.render(ui, self.config.dark_mode); + }); + } + } + + fn render_central_panel(&mut self, ctx: &egui::Context, _frame: &epi::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + use utils::AmadeusMainView::*; + match self.config.main_panel_view { + Queue => self.lektord_queue.render(ui, self.config.dark_mode), + Historic => self.lektord_historic.render(ui, self.config.dark_mode), + SearchResults => { + ui.style_mut().override_text_style = Some(utils::font::heading1()); + ui.label("Database search query"); + ui.add_space(constants::PADDING); + + let response = ui.add( + egui::TextEdit::singleline(&mut self.lektord_search_query) + .desired_width(f32::INFINITY), + ); + self.lektord_updated_query |= + response.lost_focus() || ui.input().key_pressed(egui::Key::Enter); + ui.add_space(constants::PADDING * 2.); + + self.lektord_search_results + .render(ui, self.config.dark_mode); + } + } + }); + } + + fn render_bottom_panel(&mut self, ctx: &egui::Context, _frame: &epi::Frame) { + let seconds = time::SystemTime::now() + .duration_since(time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + % 90; + let progress = seconds as f32 / 90 as f32; + let text_duration = { + let duration = time::Duration::from_secs(seconds); + let mins = duration.as_secs() / 60; + let secs = duration.as_secs() % 60; + format!("{:02}:{:02}", mins, secs) + }; + + egui::TopBottomPanel::bottom("FOOTER") + .max_height(constants::BOTTOM_PANEL_MAX_SIZE) + .min_height(constants::BOTTOM_PANEL_MAX_SIZE) + .resizable(false) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if let Some(current_kara) = &self.lektord_current_kara { + current_kara.render_compact(ui, self.config.dark_mode); + } + ui.with_layout(egui::Layout::right_to_left(), |ui| { + ui.style_mut().override_text_style = Some(utils::font::heading3()); + ui.add_space(constants::PADDING * 2.); + ui.label(text_duration); + }); + }); + let style = ui.style().clone(); + ui.style_mut().override_text_style = Some(utils::font::small()); + ui.style_mut().visuals.override_text_color = + Some(constants::get_text_color(self.config.dark_mode)); + ui.add(widgets::progress_bar(self.config.dark_mode, progress)); + ui.set_style(style); + }); + } + + fn render_top_panel(&mut self, ctx: &egui::Context, frame: &epi::Frame) { + egui::TopBottomPanel::top("MENU").show(ctx, |ui| { + ui.add_space(constants::TOP_PANEL_PADDING * 2.); + egui::menu::bar(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(), |ui| { + ui.style_mut().override_text_style = Some(utils::font::heading1()); + if ui.add(egui::Button::new("Amadeus").frame(false)).clicked() { + self.config.side_panel_show = !self.config.side_panel_show; + } + + ui.add(egui::Separator::default()); + egui::menu::menu_button(ui, "⚡", |ui| { + ui.style_mut().override_text_style = Some(utils::font::body()); + ui.add(egui::Button::new("Connect lektord").wrap(false)) + .clicked() + .then(|| { + self.actions.push(action::Action::ConnectToLektord); + ui.close_menu(); + }); + ui.add(egui::Button::new("Disconnect lektord").wrap(false)) + .clicked() + .then(|| { + self.actions.push(action::Action::DisconnectFromLektord); + ui.close_menu(); + }); + }) + .response + .on_hover_text("Action menu"); + ui.add(egui::Separator::default()); + + ui.button("⚙") + .on_hover_text("Settings window") + .clicked() + .then(|| self.need_settings_window = true); + + ui.button("☕") + .on_hover_text("About window") + .clicked() + .then(|| self.need_about_window = true); + + ui.add(egui::Separator::default()); + ui.button("⏪") + .on_hover_text("Previous") + .clicked() + .then(|| self.actions.push(action::Action::PlaybackPrevious)); + match self.lektord_state { + LektorState::Stopped | LektorState::Pause(_) => ui + .button("▶") + .on_hover_text("Play") + .clicked() + .then(|| self.actions.push(action::Action::PlaybackPlay)), + LektorState::Play(_) => ui + .button("⏸") + .on_hover_text("Pause") + .clicked() + .then(|| self.actions.push(action::Action::PlaybackPause)), + }; + ui.button("⏩") + .on_hover_text("Next") + .clicked() + .then(|| self.actions.push(action::Action::PlaybackNext)); + }); + + ui.with_layout(egui::Layout::right_to_left(), |ui| { + ui.style_mut().override_text_style = Some(utils::font::body()); + if ui.add(egui::Button::new("❌")).clicked() { + frame.quit(); + } + + ui.style_mut().override_text_style = Some(utils::font::small_body()); + let frame_duration = self + .begin_render_instant + .duration_since(self.last_render_instant) + .unwrap_or_default() + .as_millis(); + let fps = 1000. / frame_duration as f64; + ui.label(format!("{fps:02.1}fps | {frame_duration:02}ms")); + let (drag_id, drag_space) = ui.allocate_space(ui.available_size()); + ui.interact(drag_space, drag_id, egui::Sense::drag()) + .dragged() + .then(|| frame.drag_window()); + }); + }); + ui.add_space(constants::TOP_PANEL_PADDING); + }); + } + + fn set_visuals(&mut self, ctx: &egui::Context) { + ctx.request_repaint(); + let mut visuals = + either!(self.config.dark_mode => egui::Visuals::dark(); egui::Visuals::light()); + + visuals.window_rounding = egui::Rounding::none(); + visuals.widgets.active.rounding = egui::Rounding::none(); + visuals.widgets.noninteractive.rounding = egui::Rounding::none(); + visuals.widgets.inactive.rounding = egui::Rounding::none(); + visuals.widgets.hovered.rounding = egui::Rounding::none(); + visuals.widgets.open.rounding = egui::Rounding::none(); + + visuals.hyperlink_color = constants::get_accent_color(self.config.dark_mode); + + ctx.set_visuals(visuals); + } + + fn handle_action(&mut self) { + use action::Action::*; + + // Handle actions on lektor items + for (id, act) in self.collect_fulfilled_actions() { + match act { + OpenPlaylist => self.playlist_store.show_playlist(id), + ClearPlaylistContent => self.playlist_store.clear_playlist(id), + _ => debug!("Execute action {act:?} on lektor item with id {id}"), + } + } + + // Handle top level actions + for act in self.actions.drain(..) { + match act { + ConnectToLektord => { + if !self.status_deamon.is_some() { + let connexion = utils::deamon::StatusDeamon::spawn( + self.config.lektord_hostname.clone(), + self.config.lektord_port.as_integer() as i16, + ); + if let Ok(connexion) = connexion { + self.status_deamon = Some(connexion); + } + } + if !self.deamon.is_some() { + let connexion = utils::deamon::CommandDeamon::spawn( + self.config.lektord_hostname.clone(), + self.config.lektord_port.as_integer() as i16, + ); + if let Ok(connexion) = connexion { + self.deamon = Some(connexion); + } + } + } + DisconnectFromLektord => { + if let Some((_, status_deamon)) = &self.status_deamon { + status_deamon.quit(); + } + if let Some((_, deamon)) = &self.deamon { + deamon.quit(); + } + } + _ => debug!("Execute action {act:?} on lektor"), + } + } + + // Handle the deamon closing process. + if let Some((_, status_deamon)) = &self.status_deamon { + if status_deamon.should_joined() { + status_deamon.join(); + self.status_deamon = None; + } + }; + if let Some((_, deamon)) = &self.deamon { + if deamon.should_joined() { + deamon.join(); + self.deamon = None; + } + }; + } + + fn apply_settings(&mut self) { + self.lektord_queue + .with_max_content(self.config.ui_max_queue_items.as_integer()); + self.lektord_historic + .with_max_content(self.config.ui_max_history_items.as_integer()); + self.lektord_search_results + .with_max_content(self.config.ui_max_search_items.as_integer()); + } + + fn fill_debug_values(&mut self) { + let default_kara_card = KaraCard::new(lkt_lib::types::Kara { + id: 1000, + source_name: "Totoro".to_owned(), + song_type: "OP".to_owned(), + language: "jp".to_owned(), + category: "vo".to_owned(), + title: "Totoro no Sampo".to_owned(), + song_number: Some(1), + author: "Geralt".to_owned(), + is_available: false, + }); + + self.lektord_current_kara = Some(KaraCard::new(lkt_lib::types::Kara { + id: 3000, + source_name: "Umineko no Naku Koro ni Saku".to_owned(), + song_type: "ED".to_owned(), + language: "jp".to_owned(), + category: "cdg".to_owned(), + title: "Birth of a New Witdh".to_owned(), + song_number: Some(6), + author: "Kubat".to_owned(), + is_available: true, + })); + + self.lektord_queue + .add_card(default_kara_card.clone()) + .add_card(default_kara_card.clone()); + for _i in 1..=50 { + self.lektord_queue.add_card(default_kara_card.clone()); + } + + let mut some_playlist = KaraCardCollection::new("@Kubat".to_string()); + some_playlist.add_card(default_kara_card.clone()); + + self.playlist_store.create(Playlist { + id: 1, + name: "@Kubat".to_owned(), + }); + self.playlist_store.create(Playlist { + id: 2, + name: "@Geralt".to_owned(), + }); + self.playlist_store.create(Playlist { + id: 3, + name: "@Elliu".to_owned(), + }); + self.playlist_store.add("@Kubat", default_kara_card.inner); + } +} + +impl App for Amadeus<'_> { + fn update(&mut self, ctx: &egui::Context, frame: &epi::Frame) { + self.begin_render_instant = time::SystemTime::now(); + ctx.request_repaint(); + self.set_visuals(ctx); + + self.render_top_panel(ctx, frame); + self.render_side_panel(ctx, frame); + self.render_central_panel(ctx, frame); + self.render_bottom_panel(ctx, frame); + + self.playlist_store + .render_playlist_contents(ctx, self.config.dark_mode); + widgets::render_about_window( + &mut self.need_about_window, + self.config.dark_mode, + ctx, + &self.amadeus_logo_texture.as_ref().unwrap(), + ); + self.config.render_settings_window( + ctx, + &mut self.need_settings_window, + &mut self.has_config_changed, + ); + + self.last_render_instant = self.begin_render_instant; + + self.handle_action(); + + if self.has_config_changed { + self.apply_settings(); + } + } + + fn setup( + &mut self, + ctx: &egui::Context, + _frame: &epi::Frame, + maybe_storage: Option<&dyn epi::Storage>, + ) { + if let Some(storage) = maybe_storage { + self.config = eframe::epi::get_value(storage, "amadeus-rs-config").unwrap_or_default(); + } + + ctx.set_fonts(utils::font::get_font_definitions()); + self.load_amadeus_logo(ctx); + + let mut style = (*ctx.style()).clone(); + style.text_styles = utils::font::get_font_styles(); + ctx.set_style(style); + + self.apply_settings(); + self.fill_debug_values(); + } + + fn save(&mut self, storage: &mut dyn eframe::epi::Storage) { + if self.has_config_changed { + eframe::epi::set_value(storage, "amadeus-rs-config", &self.config); + self.has_config_changed = false; + } + } + + fn persist_egui_memory(&self) -> bool { + true + } + + fn persist_native_window(&self) -> bool { + true + } + + fn auto_save_interval(&self) -> time::Duration { + time::Duration::from_secs(1) + } + + fn name(&self) -> &str { + "Amadeus" + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/cards/card.rs b/src/rust/amadeus-rs/amadeus/src/cards/card.rs new file mode 100644 index 0000000000000000000000000000000000000000..b3645f253037ba95fbe53e2f24464b742b21e84f --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/cards/card.rs @@ -0,0 +1,224 @@ +use crate::{action, constants, utils}; +use amadeus_macro::either; +use lkt_lib::types::*; + +/// A simple card trait +pub trait Card<'a, LktType: LektorType<'a>>: ToString + Clone { + /// Create an instance of the card from the lektor type + fn new(lkt_type: LktType) -> Self; + + /// Render the card to the screen + fn render(self: &mut Self, ui: &mut egui::Ui, dark_mode: bool, actions: &Vec<action::Action>); + + /// Render the card to the screen. Variant to try to render the element in a + /// more compact way. + fn render_compact(self: &Self, ui: &mut egui::Ui, dark_mode: bool); + + /// Returns the Ids of the activated actions. + fn fulfilled_actions(&mut self) -> Vec<action::Action>; + + /// Get the unique id from the inner lkt type. + fn unique_id(&self) -> u64; + + /// The card can indicate if it need to be separator by separators + const NEED_SEPARATOR: bool; + + /// Indicate if there is a need to have a separator between the header and + /// the first row. + const NEED_HEADER_SEPARATOR: bool; + + /// Set the spacing to insert after the last card is rendered. + const BOTTOM_SPACE: Option<f32>; +} + +#[derive(Clone)] +pub struct KaraCard { + pub inner: Kara, + actions: Vec<action::Action>, +} + +#[derive(Clone)] +pub struct PlaylistCard { + pub inner: Playlist, + actions: Vec<action::Action>, +} + +impl ToString for KaraCard { + fn to_string(&self) -> String { + format!( + "{} - {} / {} - {}{} - {} [{}]{}", + self.inner.category, + self.inner.language, + self.inner.source_name, + self.inner.song_type, + match self.inner.song_number { + Some(num) => num.to_string(), + None => "".to_string(), + }, + self.inner.title, + self.inner.author, + either!(self.inner.is_available => ""; " (unavailable)") + ) + } +} + +impl ToString for PlaylistCard { + fn to_string(&self) -> String { + format!("{} [#{}]", self.inner.name, self.inner.id) + } +} + +impl Card<'_, Kara> for KaraCard { + const NEED_SEPARATOR: bool = true; + const NEED_HEADER_SEPARATOR: bool = true; + const BOTTOM_SPACE: Option<f32> = Some(constants::BOTTOM_PANEL_MAX_SIZE); + + fn new(inner: Kara) -> Self { + let mut actions = Vec::new(); + actions.reserve(5); + Self { inner, actions } + } + + fn render(&mut self, ui: &mut egui::Ui, dark_mode: bool, actions: &Vec<action::Action>) { + ui.add_space(constants::PADDING); + static MIN_WIDTH_FOR_ADDITIONAL_INFOS: f32 = 1024.; + let song = format!( + "{} - {}{} - {}", + self.inner.source_name, + self.inner.song_type, + match self.inner.song_number { + Some(num) => num.to_string(), + None => "".to_string(), + }, + self.inner.title + ); + ui.horizontal(|ui| { + ui.add_space(constants::PADDING * 3.); + let left_space = ui.available_width(); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::body()); + ui.colored_label( + constants::get_text_color(dark_mode), + format!("{} - {}", self.inner.category, self.inner.language), + ); + if left_space >= MIN_WIDTH_FOR_ADDITIONAL_INFOS { + ui.with_layout(egui::Layout::right_to_left(), |ui| { + ui.label(format!("kara by {}", self.inner.author)); + }); + } + }); + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::heading2()); + action::render_action_menu(ui, actions, "▶", &mut self.actions); + ui.colored_label(constants::get_text_color(dark_mode), song); + ui.style_mut().override_text_style = Some(utils::font::body()); + if left_space >= MIN_WIDTH_FOR_ADDITIONAL_INFOS { + ui.with_layout(egui::Layout::right_to_left(), |ui| { + if !self.inner.is_available { + ui.colored_label( + constants::get_accent_color(dark_mode), + " (unavailable)", + ); + } + ui.label(format!(" #{}", self.inner.id)); + }); + } + }); + }); + }); + ui.add_space(constants::PADDING); + } + + fn render_compact(self: &Self, ui: &mut egui::Ui, dark_mode: bool) { + let header = format!( + "{} - {} by {}", + self.inner.category, self.inner.language, self.inner.author + ); + let song = format!( + "{} - {}{} - {}", + self.inner.source_name, + self.inner.song_type, + match self.inner.song_number { + Some(num) => num.to_string(), + None => "".to_string(), + }, + self.inner.title + ); + ui.horizontal(|ui| { + ui.add_space(constants::PADDING * 3.); + ui.vertical(|ui| { + ui.add_space(constants::PADDING * 2.); + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::heading3()); + ui.colored_label(constants::get_text_color(dark_mode), header); + ui.style_mut().override_text_style = Some(utils::font::body()); + ui.label(egui::RichText::new(format!("#{}", self.inner.id))); + }); + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::heading1()); + ui.colored_label(constants::get_text_color(dark_mode), song); + if !self.inner.is_available { + ui.colored_label(constants::get_accent_color(dark_mode), " (unavailable)"); + } + }); + }); + }); + } + + fn fulfilled_actions(&mut self) -> Vec<action::Action> { + let ret = self.actions.clone(); + self.actions.clear(); + return ret; + } + + fn unique_id(&self) -> u64 { + return self.inner.unique_id(); + } +} + +impl Card<'_, Playlist> for PlaylistCard { + const NEED_SEPARATOR: bool = false; + const NEED_HEADER_SEPARATOR: bool = false; + const BOTTOM_SPACE: Option<f32> = None; + + fn new(inner: Playlist) -> Self { + let mut actions = Vec::new(); + actions.reserve(5); + Self { inner, actions } + } + + fn render(&mut self, ui: &mut egui::Ui, dark_mode: bool, actions: &Vec<action::Action>) { + ui.horizontal(|ui| { + ui.add_space(constants::PADDING * 2.); + ui.style_mut().override_text_style = Some(utils::font::body()); + action::render_action_menu(ui, actions, "▶", &mut self.actions); + ui.colored_label( + constants::get_text_color(dark_mode), + format!("{}", self.inner.name), + ); + ui.label(format!(" #{}", self.inner.id)); + }); + ui.add_space(constants::PADDING); + } + + fn render_compact(self: &Self, ui: &mut egui::Ui, dark_mode: bool) { + ui.horizontal(|ui| { + ui.colored_label( + constants::get_text_color(dark_mode), + format!("{}", self.inner.name), + ); + ui.label(format!(" #{}", self.inner.id)); + }); + } + + fn fulfilled_actions(&mut self) -> Vec<action::Action> { + let ret = self.actions.clone(); + self.actions.clear(); + return ret; + } + + fn unique_id(&self) -> u64 { + return self.inner.unique_id(); + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/cards/collection.rs b/src/rust/amadeus-rs/amadeus/src/cards/collection.rs new file mode 100644 index 0000000000000000000000000000000000000000..228bc9260db86b1b0893c572fa64d7e484ead129 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/cards/collection.rs @@ -0,0 +1,131 @@ +use crate::{action, cards::card::*, constants, utils}; +use amadeus_macro::either; +use lkt_lib::types::{Kara, LektorType}; +use std::{borrow::Borrow, cmp::min, marker::PhantomData}; + +pub type KaraCardCollection<'a> = CardCollection<'a, KaraCard, Kara>; + +/// Struct to hold a collection of cards +pub struct CardCollection<'a, CardType, LktType> +where + CardType: Card<'a, LktType>, + LktType: LektorType<'a>, +{ + name: String, + content: Vec<CardType>, + actions: Vec<action::Action>, + max_content: Option<usize>, + phantom: PhantomData<&'a LktType>, +} + +impl<'a, CardType: Card<'a, LktType>, LktType: LektorType<'a>> ToString + for CardCollection<'a, CardType, LktType> +{ + fn to_string(&self) -> String { + format!( + "Collection {}: {}", + self.name, + self.content + .iter() + .map(|card| card.to_string()) + .fold(String::new(), |a, b| a + ", " + b.borrow()) + ) + } +} + +impl<'a, CardType: Card<'a, LktType>, LktType: LektorType<'a>> + CardCollection<'a, CardType, LktType> +{ + pub fn new(name: String) -> Self { + Self { + name, + max_content: None, + content: vec![], + actions: vec![], + phantom: PhantomData, + } + } + + pub fn with_max_content(&mut self, max_content: usize) -> &mut Self { + self.max_content = either!(max_content != 0 => Some(max_content); None); + return self; + } + + pub fn add_card(&mut self, card: CardType) -> &mut Self { + self.content.push(card); + return self; + } + + pub fn add_action(mut self, act: action::Action) -> Self { + self.actions.push(act); + return self; + } + + pub fn empty(self: &Self) -> bool { + return self.content.len() == 0; + } + + pub fn fulfilled_actions(&mut self) -> Vec<(u64, action::Action)> { + let mut ret = Vec::new(); + for card in &mut self.content { + let actions = card.fulfilled_actions(); + let actions_count = actions.len(); + if actions_count != 0 { + let id = card.unique_id(); + ret.reserve(ret.len() + actions_count); + for action in actions { + ret.push((id, action)); + } + } + } + return ret; + } + + pub fn render(self: &mut Self, ui: &mut egui::Ui, dark_mode: bool) { + ui.vertical(|ui| { + ui.add_space(constants::PADDING); + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::heading1()); + ui.colored_label( + constants::get_text_color(dark_mode), + format!("{}", self.name), + ); + ui.style_mut().override_text_style = Some(utils::font::small_body()); + ui.label(format!(" ({} elements)", self.content.len())); + }); + if CardType::NEED_HEADER_SEPARATOR { + ui.add_space(constants::PADDING); + ui.add(egui::Separator::default()); + } + if self.empty() { + ui.vertical_centered_justified(|ui| { + ui.add_space(constants::PADDING * 2.); + ui.add(egui::Spinner::new()); + ui.allocate_space(ui.available_size()) + }); + } else { + egui::ScrollArea::vertical() + .hscroll(false) + .always_show_scroll(false) + .max_width(f32::INFINITY) + .show(ui, |ui| { + ui.horizontal(|ui| ui.add_space(ui.available_width())); + let upper_bound = match self.max_content { + None => self.content.len(), + Some(x) => min(self.content.len(), x), + }; + for card in &mut self.content[0..upper_bound] { + card.render(ui, dark_mode, &self.actions); + if CardType::NEED_SEPARATOR { + ui.add(egui::Separator::default()); + } + } + if let Some(space) = CardType::BOTTOM_SPACE { + ui.add_space(space); + } + }); + ui.allocate_space(ui.available_size()); + } + }); + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/cards/mod.rs b/src/rust/amadeus-rs/amadeus/src/cards/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..46282020633d111cf7b9702b37782456608d4027 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/cards/mod.rs @@ -0,0 +1,9 @@ +mod card; +mod collection; + +pub use card::Card; +pub use card::KaraCard; +pub use card::PlaylistCard; + +pub use collection::CardCollection; +pub use collection::KaraCardCollection; diff --git a/src/rust/amadeus-rs/amadeus/src/constants.rs b/src/rust/amadeus-rs/amadeus/src/constants.rs new file mode 100644 index 0000000000000000000000000000000000000000..9580093768b1372303b43f9dcb5f0332b2099899 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/constants.rs @@ -0,0 +1,41 @@ +use amadeus_macro::either; +use eframe::egui::Color32; + +pub const PADDING: f32 = 6.0; +pub const TOP_PANEL_PADDING: f32 = 0.5; +pub const BOTTOM_PANEL_MAX_SIZE: f32 = 90.; +const WHITE: Color32 = Color32::from_rgb(255, 255, 255); +const BLACK: Color32 = Color32::from_rgb(0, 0, 0); +const CYAN: Color32 = Color32::from_rgb(0, 255, 255); +const RED: Color32 = Color32::from_rgb(255, 0, 0); + +pub fn get_fill_color(dark_mode: bool) -> Color32 { + let base_color = get_accent_color(dark_mode); + if dark_mode { + return base_color; + } else { + let factor = 0.69 as f32; + return Color32::from_rgb( + (base_color.r() as f32 * factor) as u8, + (base_color.g() as f32 * factor) as u8, + (base_color.b() as f32 * factor) as u8, + ); + } +} + +pub fn get_text_color(dark_mode: bool) -> Color32 { + return either!(dark_mode => WHITE; BLACK); +} + +pub fn get_accent_color(dark_mode: bool) -> Color32 { + return either!(dark_mode => get_darker_color(CYAN); RED); +} + +fn get_darker_color(color: Color32) -> Color32 { + let factor = 0.69 as f32; + return Color32::from_rgb( + (color.r() as f32 * factor) as u8, + (color.g() as f32 * factor) as u8, + (color.b() as f32 * factor) as u8, + ); +} diff --git a/src/rust/amadeus-rs/amadeus/src/logger.rs b/src/rust/amadeus-rs/amadeus/src/logger.rs new file mode 100644 index 0000000000000000000000000000000000000000..e5bb72b86c8bc6da134f91ae6bbc926d41d6312f --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/logger.rs @@ -0,0 +1,58 @@ +use lazy_static::lazy_static; +use log::{Level, Metadata, Record, SetLoggerError}; +use std::sync::{Arc, Mutex}; + +struct SimpleLogger { + level: Arc<Mutex<Level>>, +} + +#[repr(transparent)] +struct SimpleLoggerRef { + pub inner: SimpleLogger, +} + +lazy_static! { + static ref LOGGER: SimpleLoggerRef = SimpleLoggerRef { + inner: SimpleLogger { + level: Arc::new(Mutex::new(Level::Debug)), + } + }; +} + +impl log::Log for SimpleLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + match self.level.lock() { + Ok(level) => metadata.level() <= *level, + Err(e) => panic!("Failed to lock the log level mutex: {e}"), + } + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + eprintln!("{} - {}", record.level(), record.args()); + } + } + + fn flush(&self) {} +} + +pub fn init() -> Result<(), SetLoggerError> { + let default_level = match LOGGER.inner.level.lock() { + Ok(lvl) => lvl, + Err(e) => panic!("Failed to lock the log level mutex: {e}"), + }; + + log::set_logger(&LOGGER.inner).map(|()| { + log::set_max_level(default_level.to_level_filter()); + }) +} + +pub fn set_level(level: Level) { + match LOGGER.inner.level.lock() { + Ok(mut inner_level) => { + *inner_level = level; + log::set_max_level(level.to_level_filter()); + } + Err(e) => panic!("Failed to lock the log level mutex: {e}"), + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/main.rs b/src/rust/amadeus-rs/amadeus/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..23ee924e005b8b70d3b83ba65d0d127348b0af38 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/main.rs @@ -0,0 +1,30 @@ +mod action; +mod amadeus; +mod cards; +mod constants; +mod logger; +mod playlists; +mod utils; +mod widgets; + +use eframe::egui::Vec2; + +fn main() { + if let Err(e) = logger::init() { + panic!("Failed to install logger: {e}") + } + logger::set_level(log::Level::Debug); + + eframe::run_native( + amadeus::Amadeus::create(), + eframe::NativeOptions { + maximized: false, + decorated: true, + drag_and_drop_support: true, + resizable: true, + initial_window_size: Some(Vec2::new(800., 600.)), + icon_data: Some(utils::get_icon_data()), + ..Default::default() + }, + ); +} diff --git a/src/rust/amadeus-rs/amadeus/src/playlists.rs b/src/rust/amadeus-rs/amadeus/src/playlists.rs new file mode 100644 index 0000000000000000000000000000000000000000..2fdf70f2541a90b74ae706a0e77b498c7d2263e0 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/playlists.rs @@ -0,0 +1,225 @@ +use lkt_lib::types::{Kara, LektorType, Playlist}; +use std::collections::{HashMap, HashSet}; + +use crate::{action, constants, utils, widgets}; + +static DEFAULT_HASHSET_SIZE: usize = 10; + +pub struct PlaylistsStore { + /// The list of all playlists + playlists: HashMap<String, u64>, + + /// The kara that are contained in the playlists. They are indexed by the + /// playlist's unique id. The not ordered and unique kara per playlist + /// aspects of playlists are enforced by the HashSet. + contents: HashMap<u64, HashSet<Kara>>, + + /// Store the playlists to show. + playlists_to_show: HashMap<u64, bool>, + + /// The fullfilled actions in this frame. + actions: Vec<(u64, action::Action)>, + + /// A temp buffer for handling actions on a specific item, where we know the + /// id to put but not the render function. + temp_actions: Vec<action::Action>, +} + +impl Default for PlaylistsStore { + fn default() -> Self { + Self { + playlists: HashMap::with_capacity(DEFAULT_HASHSET_SIZE), + contents: HashMap::with_capacity(DEFAULT_HASHSET_SIZE), + playlists_to_show: HashMap::with_capacity(DEFAULT_HASHSET_SIZE), + actions: Vec::new(), + temp_actions: Vec::new(), + } + } +} + +impl PlaylistsStore { + pub fn fulfilled_actions(&mut self) -> Vec<(u64, action::Action)> { + if self.actions.len() != 0 { + let ret = self.actions.clone(); + self.actions.clear(); + return ret; + } else { + return vec![]; + } + } + + /// Render the opened playlists. + pub fn render_playlist_contents(&mut self, ctx: &egui::Context, dark_mode: bool) { + let to_show = self + .playlists_to_show + .iter() + .filter(|(_, flag)| **flag) + .map(|(id, _)| *id) + .collect::<Vec<u64>>(); + for id in to_show { + let name = self + .playlists + .iter() + .filter(|(_, plt_id)| **plt_id == id) + .map(|(name, _)| name.as_str()) + .next() + .unwrap(); + let flag = self.playlists_to_show.get_mut(&id).unwrap(); + let karas = self.contents.get(&id).unwrap(); + let actions = widgets::WindowBuilder::new(name, dark_mode) + .with_resizable(false) + .with_default_size(egui::vec2(200., 500.)) + .with_actions(vec![ + action::Action::AddPlaylistToQueue, + action::Action::InsertPlaylistToQueue, + ]) + .with_warning_actions(vec![action::Action::ClearPlaylistContent]) + .show(flag, ctx, |ui| { + egui::ScrollArea::vertical() + .hscroll(false) + .always_show_scroll(false) + .max_width(f32::INFINITY) + .show(ui, |ui| { + if karas.len() == 0 { + ui.horizontal(|ui| { + ui.label("Empty playlist"); + ui.add_space(constants::PADDING); + ui.add(egui::Spinner::new()); + }); + } else { + for kara in karas { + ui.horizontal(|ui| { + ui.style_mut().override_text_style = + Some(utils::font::body()); + action::render_action_menu( + ui, + &vec![ + action::Action::AddKaraToQueue, + action::Action::InsertKaraInQueue, + action::Action::AddKaraToPlaylist, + action::Action::DeleteKaraFromPlaylist(id), + action::Action::DeleteKaraFromQueue, + ], + widgets::get_label_text_for_kara(kara), + &mut self.temp_actions, + ); + self.temp_actions + .iter() + .map(|act| (id, act.clone())) + .for_each(|fullfilled| self.actions.push(fullfilled)); + self.temp_actions.clear(); + }); + } + } + }); + }); + if let Some(actions) = actions { + actions + .iter() + .map(|act| (id, act.clone())) + .for_each(|fullfilled| self.actions.push(fullfilled)); + } + } + } + + /// Render the list of playlists. Also register some of the user's actions + /// like opening a playlist, etc. + pub fn render(&mut self, ui: &mut egui::Ui, dark_mode: bool) { + ui.vertical(|ui| { + ui.add_space(constants::PADDING); + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::heading1()); + ui.colored_label(constants::get_text_color(dark_mode), "🗀 Playlists"); + ui.style_mut().override_text_style = Some(utils::font::small_body()); + }); + ui.style_mut().override_text_style = Some(utils::font::body()); + if self.playlists.is_empty() { + ui.vertical_centered_justified(|ui| { + ui.add(egui::Spinner::new()); + ui.allocate_space(ui.available_size()) + }); + } else { + egui::ScrollArea::vertical() + .hscroll(false) + .always_show_scroll(false) + .max_width(f32::INFINITY) + .show(ui, |ui| { + ui.horizontal(|ui| ui.add_space(ui.available_width())); + for (name, id) in &self.playlists { + let plt_size = self.playlist_size(name).unwrap(); + ui.horizontal(|ui| { + ui.add_space(constants::PADDING); + action::render_action_menu( + ui, + &vec![ + action::Action::OpenPlaylist, + action::Action::AddPlaylistToQueue, + action::Action::InsertPlaylistToQueue, + ], + "▶", + &mut self.temp_actions, + ); + self.temp_actions + .iter() + .map(|act| (*id, act.clone())) + .for_each(|fullfilled| self.actions.push(fullfilled)); + self.temp_actions.clear(); + ui.colored_label(constants::get_text_color(dark_mode), name); + ui.style_mut().override_text_style = + Some(utils::font::small_body()); + ui.label(format!("{plt_size} karas")); + }); + } + }); + ui.allocate_space(ui.available_size()); + } + }); + } + + fn get_playlist_by_name(&self, name: &str) -> Option<(&str, u64)> { + return self + .playlists + .iter() + .filter(|(plt_name, _)| *plt_name == name) + .map(|(name, id)| (name.as_str(), id.clone())) + .next(); + } + + pub fn show_playlist(&mut self, id: u64) { + if let Some(flag) = self.playlists_to_show.get_mut(&id) { + *flag = true; + } + } + + pub fn clear_playlist(&mut self, id: u64) { + if let Some(content) = self.contents.get_mut(&id) { + content.clear(); + } + } + + pub fn create(&mut self, plt: Playlist) { + self.contents.insert( + plt.unique_id(), + HashSet::with_capacity(DEFAULT_HASHSET_SIZE), + ); + let id = plt.unique_id(); + self.playlists.insert(plt.name, id); + self.playlists_to_show.insert(id, false); + } + + pub fn add(&mut self, name: &str, kara: Kara) { + if let Some((_, id)) = self.get_playlist_by_name(name) { + if let Some(plt_content) = self.contents.get_mut(&id) { + plt_content.insert(kara); + } else { + panic!("The playlist {name} has no content set, it should have been created when the playlist was created") + } + } + } + + pub fn playlist_size(&self, name: &str) -> Option<usize> { + let (_, id) = self.get_playlist_by_name(name)?; + let content = self.contents.get(&id)?; + return Some(content.len()); + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/utils/config.rs b/src/rust/amadeus-rs/amadeus/src/utils/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..75e8e9e957388242e40406da9e0261430e92a1cb --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/utils/config.rs @@ -0,0 +1,130 @@ +use crate::widgets; + +/// Indicate which view should be displayed in the central panel of Amadeus' +/// main window. +#[derive(serde::Serialize, serde::Deserialize, PartialEq)] +pub enum AmadeusMainView { + /// The main panel should show the queue of lektord, i.e. the kara that will + /// be played. + Queue, + + /// The main panel should show the kara that where played by lektord. + Historic, + + /// The main panel should show the search view. + SearchResults, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct AmadeusConfig { + pub dark_mode: bool, + pub admin_password: String, + pub lektord_hostname: String, + pub lektord_port: super::NetworkPort, + pub lektord_auto_reconnect: bool, + pub main_panel_view: AmadeusMainView, + pub side_panel_show: bool, + pub ui_max_queue_items: super::IntegerTextBuffer<usize>, + pub ui_max_search_items: super::IntegerTextBuffer<usize>, + pub ui_max_history_items: super::IntegerTextBuffer<usize>, +} + +impl Default for AmadeusMainView { + fn default() -> Self { + Self::Queue + } +} + +impl Default for AmadeusConfig { + fn default() -> Self { + Self { + dark_mode: true, + admin_password: "".to_owned(), + lektord_hostname: "localhost".to_owned(), + lektord_port: super::NetworkPort::from("6600"), + lektord_auto_reconnect: false, + main_panel_view: Default::default(), + side_panel_show: true, + ui_max_queue_items: super::IntegerTextBuffer::from("100"), + ui_max_search_items: super::IntegerTextBuffer::from("100"), + ui_max_history_items: super::IntegerTextBuffer::from("100"), + } + } +} + +impl AmadeusConfig { + pub fn render_settings_window( + &mut self, + ctx: &egui::Context, + show: &mut bool, + changed: &mut bool, + ) { + widgets::WindowBuilder::new("Settings", self.dark_mode).show(show, ctx, |ui| { + ui.separator(); + widgets::add_heading2_label(ui, "☰ Lektord deamon"); + widgets::add_labelled_edit_line( + ui, + "Hostname ", + &mut self.lektord_hostname, + changed, + ); + widgets::add_labelled_edit_line( + ui, + "Port ", + &mut self.lektord_port, + changed, + ); + widgets::add_labelled_toggle_switch( + ui, + self.dark_mode, + "Auto-reconnect", + &mut self.lektord_auto_reconnect, + changed, + ); + + ui.separator(); + widgets::add_heading2_label(ui, "☰ Admin user"); + widgets::add_labelled_password( + ui, + "Admin pwd ", + &mut self.admin_password, + changed, + ); + + ui.separator(); + widgets::add_heading2_label(ui, "☰ UI"); + widgets::add_labelled_toggle_switch( + ui, + self.dark_mode, + "Dark theme", + &mut self.dark_mode, + changed, + ); + widgets::add_labelled_toggle_switch( + ui, + self.dark_mode, + "Show side panel", + &mut self.side_panel_show, + changed, + ); + widgets::add_labelled_edit_line( + ui, + "Max queue size ", + &mut self.ui_max_queue_items, + changed, + ); + widgets::add_labelled_edit_line( + ui, + "Max search size ", + &mut self.ui_max_search_items, + changed, + ); + widgets::add_labelled_edit_line( + ui, + "Max history size ", + &mut self.ui_max_history_items, + changed, + ); + }); + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/utils/deamon.rs b/src/rust/amadeus-rs/amadeus/src/utils/deamon.rs new file mode 100644 index 0000000000000000000000000000000000000000..33363d7b63d3efb2b552fed178942807365ed4bc --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/utils/deamon.rs @@ -0,0 +1,203 @@ +#![allow(dead_code)] + +use lkt_lib::{query::LektorQuery, response::LektorFormatedResponse}; +use log::error; +use std::{ + cell::Cell, + io, + sync::{ + atomic::{self, AtomicBool}, + mpsc::{channel, Receiver, Sender}, + Arc, Mutex, + }, + thread, time, +}; + +pub trait Deamon: Sized { + #[must_use] + type Channels; + + /// Quit the deamon + fn quit(&self); + + /// Spawn a deamon + #[must_use] + fn spawn(hostname: String, port: i16) -> io::Result<(Self::Channels, Self)>; + + /// Returns true when the thread has terminated. + fn should_joined(&self) -> bool; + + /// Join the deamon. + fn join(&self); +} + +pub struct CommandDeamon { + thread: Arc<Mutex<Cell<Option<thread::JoinHandle<()>>>>>, + quit: Arc<AtomicBool>, + joined: Arc<AtomicBool>, +} + +pub type StatusDeamonMessageType = ( + lkt_lib::response::PlaybackStatus, + Option<lkt_lib::response::CurrentKara>, +); + +pub struct StatusDeamon { + thread: Arc<Mutex<Cell<Option<thread::JoinHandle<()>>>>>, + quit: Arc<AtomicBool>, + joined: Arc<AtomicBool>, +} + +macro_rules! return_when_flagged { + ($arc_atomic: expr, $joined_deamon: expr) => { + if $arc_atomic.load(atomic::Ordering::SeqCst) { + $joined_deamon.store(true, atomic::Ordering::SeqCst); + return; + } + }; +} + +macro_rules! implement_deamon_quit { + () => { + fn quit(&self) { + self.quit.store(true, atomic::Ordering::SeqCst); + } + }; +} + +macro_rules! implement_deamon_joined { + () => { + fn should_joined(&self) -> bool { + return self.joined.load(atomic::Ordering::SeqCst); + } + + fn join(&self) { + let locked = self.thread.lock(); + if locked.is_err() { + error!("Failed to lock the mutex that has the join handler",) + } + let locked = locked.unwrap(); + let thread = locked.replace(None); + match thread { + Some(thread) => { + let _ = thread.join(); + } + None => error!("Nothing to join!"), + } + } + }; +} + +impl Deamon for CommandDeamon { + type Channels = (Sender<LektorQuery>, Receiver<LektorFormatedResponse>); + implement_deamon_quit!(); + implement_deamon_joined!(); + + fn spawn(hostname: String, port: i16) -> io::Result<(Self::Channels, Self)> { + let mut connexion = lkt_lib::connexion::LektorConnexion::new(hostname, port)?; + + let (responses_send, responses_recv) = channel::<LektorFormatedResponse>(); + let (commands_send, commands_recv) = channel::<LektorQuery>(); + let quit = Arc::<AtomicBool>::new(AtomicBool::default()); + let joined = Arc::<AtomicBool>::new(AtomicBool::default()); + quit.store(false, atomic::Ordering::SeqCst); + joined.store(false, atomic::Ordering::SeqCst); + let quit_deamon = quit.clone(); + let joined_deamon = joined.clone(); + + let thread = thread::spawn(move || loop { + return_when_flagged!(quit_deamon, joined_deamon); + match commands_recv.recv() { + Ok(command) => { + let maybe_res = connexion.send_query(command); + if maybe_res.is_err() { + error!("Failed to send query to lektor: {}", maybe_res.unwrap_err()); + continue; + } + let res = maybe_res.unwrap(); + let response = lkt_lib::response::LektorFormatedResponse::from(res); + if let Err(e) = responses_send.send(response) { + error!("Failed to send response to amadeus: {e}") + } + } + Err(e) => error!("Failed to get command from amadeus: {e}"), + }; + }); + + return Ok(( + (commands_send, responses_recv), + Self { + thread: Arc::new(Mutex::new(Cell::new(Some(thread)))), + quit, + joined, + }, + )); + } +} + +impl Deamon for StatusDeamon { + type Channels = (Receiver<StatusDeamonMessageType>,); + implement_deamon_quit!(); + implement_deamon_joined!(); + + fn spawn(hostname: String, port: i16) -> io::Result<(Self::Channels, StatusDeamon)> { + let mut connexion = lkt_lib::connexion::LektorConnexion::new(hostname, port)?; + + let (responses_send, responses_recv) = channel(); + let quit = Arc::<AtomicBool>::new(AtomicBool::default()); + let joined = Arc::<AtomicBool>::new(AtomicBool::default()); + quit.store(false, atomic::Ordering::SeqCst); + joined.store(false, atomic::Ordering::SeqCst); + let quit_deamon = quit.clone(); + let joined_deamon = joined.clone(); + + let thread = thread::spawn(move || loop { + return_when_flagged!(quit_deamon, joined_deamon); + thread::sleep(time::Duration::from_secs(1)); + return_when_flagged!(quit_deamon, joined_deamon); + + let status = { + let maybe_res = connexion.send_query(lkt_lib::query::LektorQuery::PlaybackStatus); + if maybe_res.is_err() { + error!( + "Failed to send the playback status command to lektor: {}", + maybe_res.unwrap_err() + ); + continue; + } + let res = maybe_res.unwrap(); + let mut response = lkt_lib::response::LektorFormatedResponse::from(res); + lkt_lib::response::PlaybackStatus::consume(&mut response) + }; + + let current = if status.state != lkt_lib::types::LektorState::Stopped { + let maybe_res = connexion.send_query(lkt_lib::query::LektorQuery::CurrentKara); + if maybe_res.is_err() { + error!( + "Failed to send the current kara command to lektor: {}", + maybe_res.unwrap_err() + ); + continue; + } + let res = maybe_res.unwrap(); + let mut response = lkt_lib::response::LektorFormatedResponse::from(res); + Some(lkt_lib::response::CurrentKara::consume(&mut response)) + } else { + None + }; + + if let Err(e) = responses_send.send((status, current)) { + error!("Failed to send a status response to amadeus: {}", e); + } + }); + + return Ok(( + (responses_recv,), + Self { + thread: Arc::new(Mutex::new(Cell::new(Some(thread)))), + quit, + joined, + }, + )); + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/utils/font.rs b/src/rust/amadeus-rs/amadeus/src/utils/font.rs new file mode 100644 index 0000000000000000000000000000000000000000..c14df9f72b464fc057a75ac7bcdff7ddbac7ff8f --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/utils/font.rs @@ -0,0 +1,100 @@ +use std::collections::BTreeMap; + +use egui::{ + FontData, FontDefinitions, + FontFamily::{Monospace, Proportional}, + FontId, TextStyle, +}; + +#[inline] +pub fn heading1() -> TextStyle { + TextStyle::Heading +} + +#[inline] +pub fn heading2() -> TextStyle { + TextStyle::Name("Heading2".into()) +} + +#[inline] +pub fn heading3() -> TextStyle { + TextStyle::Name("ContextHeading".into()) +} + +#[inline] +pub fn body() -> TextStyle { + TextStyle::Body +} + +#[inline] +pub fn small_body() -> TextStyle { + TextStyle::Name("SmallBody".into()) +} + +#[inline] +pub fn monospace() -> TextStyle { + TextStyle::Monospace +} + +#[inline] +pub fn button() -> TextStyle { + TextStyle::Button +} + +#[inline] +pub fn small() -> TextStyle { + TextStyle::Small +} + +pub fn get_font_definitions() -> FontDefinitions { + static FONT_NAME: &str = "UbuntuMono"; + static FONT_DATA: &[u8] = include_bytes!("../../../rsc/UbuntuMono-Regular.ttf"); + static CJK_NAME: &str = "IPAMincho"; + static CJK_DATA: &[u8] = include_bytes!("../../../rsc/IPAMincho.ttf"); + let mut fonts = FontDefinitions::default(); + + fonts + .font_data + .insert(FONT_NAME.to_owned(), FontData::from_static(FONT_DATA)); + fonts + .font_data + .insert(CJK_NAME.to_owned(), FontData::from_static(CJK_DATA)); + + fonts + .families + .get_mut(&Proportional) + .unwrap() + .insert(0, FONT_NAME.to_owned()); + fonts + .families + .get_mut(&Proportional) + .unwrap() + .push(CJK_NAME.to_owned()); + + fonts + .families + .get_mut(&Monospace) + .unwrap() + .insert(0, FONT_NAME.to_owned()); + fonts + .families + .get_mut(&Monospace) + .unwrap() + .push(CJK_NAME.to_owned()); + + return fonts; +} + +pub fn get_font_styles() -> BTreeMap<TextStyle, FontId> { + let styles = [ + (heading1(), FontId::new(30.0, Proportional)), + (heading2(), FontId::new(25.0, Proportional)), + (heading3(), FontId::new(23.0, Proportional)), + (body(), FontId::new(18.0, Proportional)), + (monospace(), FontId::new(14.0, Proportional)), + (small_body(), FontId::new(14.0, Proportional)), + (button(), FontId::new(14.0, Proportional)), + (small(), FontId::new(10.0, Proportional)), + ]; + return styles.into(); +} diff --git a/src/rust/amadeus-rs/amadeus/src/utils/int_text_buffer.rs b/src/rust/amadeus-rs/amadeus/src/utils/int_text_buffer.rs new file mode 100644 index 0000000000000000000000000000000000000000..7bcdc8a356d31e5e400339618ab113e4217d9fba --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/utils/int_text_buffer.rs @@ -0,0 +1,132 @@ +use egui::TextBuffer; +use serde::{Deserialize, Serialize}; +use std::{fmt, marker::PhantomData, str::FromStr}; + +#[derive(Serialize, Deserialize)] +pub struct IntegerTextBuffer<T: private::Unsigned + private::Zero<T>> { + buffer: String, + phantom: PhantomData<T>, +} + +impl<T: private::Unsigned + private::Zero<T>> IntegerTextBuffer<T> { + pub fn as_integer(&self) -> T + where + <T as FromStr>::Err: std::fmt::Display, + { + return if self.buffer.len() == 0 { + T::ZERO + } else { + match self.buffer.parse::<T>() { + Ok(int) => int, + Err(e) => panic!("Failed to parse integer: {}", e), + } + }; + } + + fn remove_all_non_digit_chars(&mut self) { + self.buffer = self.buffer.chars().filter(|c| c.is_digit(10)).collect(); + } +} + +impl<T: private::Unsigned + private::Zero<T>> fmt::Display for IntegerTextBuffer<T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.buffer) + } +} + +impl<T: private::Unsigned + private::Zero<T>> From<&str> for IntegerTextBuffer<T> { + fn from(text: &str) -> Self { + Self { + buffer: text.to_string(), + phantom: PhantomData, + } + } +} + +impl<T: private::Unsigned + private::Zero<T>> AsRef<str> for IntegerTextBuffer<T> { + fn as_ref(&self) -> &str { + return self.buffer.as_str(); + } +} + +impl<T: private::Unsigned + private::Zero<T>> TextBuffer for IntegerTextBuffer<T> { + fn is_mutable(&self) -> bool { + true + } + + fn as_str(&self) -> &str { + self.as_ref() + } + + fn char_range(&self, char_range: std::ops::Range<usize>) -> &str { + assert!(char_range.start <= char_range.end); + let start_byte = self.byte_index_from_char_index(char_range.start); + let end_byte = self.byte_index_from_char_index(char_range.end); + &self.as_str()[start_byte..end_byte] + } + + fn insert_text(&mut self, text: &str, char_index: usize) -> usize { + let ret = self.buffer.insert_text(text, char_index); + self.remove_all_non_digit_chars(); + return ret; + } + + fn delete_char_range(&mut self, char_range: std::ops::Range<usize>) { + self.buffer.delete_char_range(char_range); + } + + fn clear(&mut self) { + self.delete_char_range(0..self.as_ref().len()); + } + + fn replace(&mut self, text: &str) { + self.clear(); + self.insert_text(text, 0); + } + + fn take(&mut self) -> String { + let s = self.as_ref().to_owned(); + self.clear(); + return s; + } +} + +mod private { + use std::str::FromStr; + + pub trait Unsigned: FromStr {} + impl Unsigned for u8 {} + impl Unsigned for u16 {} + impl Unsigned for u32 {} + impl Unsigned for u64 {} + impl Unsigned for u128 {} + impl Unsigned for usize {} + + pub trait Zero<T: Unsigned> { + const ZERO: T; + } + + impl Zero<u8> for u8 { + const ZERO: u8 = 0u8; + } + + impl Zero<u16> for u16 { + const ZERO: u16 = 0u16; + } + + impl Zero<u32> for u32 { + const ZERO: u32 = 0u32; + } + + impl Zero<u64> for u64 { + const ZERO: u64 = 0u64; + } + + impl Zero<u128> for u128 { + const ZERO: u128 = 0u128; + } + + impl Zero<usize> for usize { + const ZERO: usize = 0usize; + } +} diff --git a/src/rust/amadeus-rs/amadeus/src/utils/mod.rs b/src/rust/amadeus-rs/amadeus/src/utils/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..8cb073149ccca5b9d67f44a1154c1ff2292c9cc4 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/utils/mod.rs @@ -0,0 +1,29 @@ +pub(crate) mod deamon; +pub(crate) mod font; + +mod config; +pub(crate) use config::AmadeusConfig; +pub(crate) use config::AmadeusMainView; + +mod int_text_buffer; +pub(crate) use int_text_buffer::IntegerTextBuffer; +pub(crate) type NetworkPort = IntegerTextBuffer<u16>; + +pub(crate) fn get_icon_as_dynamic_image() -> (image::RgbaImage, [u32; 2]) { + static LOGO_SIDE: u32 = 48u32; + let logo_data = image::load_from_memory(include_bytes!("../../../rsc/AmadeusLogo.jpg")) + .expect("Failed to load Amadeus Logo") + .thumbnail(LOGO_SIDE, LOGO_SIDE); + let size = [logo_data.width(), logo_data.height()]; + let logo_buffer = logo_data.to_rgba8(); + return (logo_buffer, size); +} + +pub(crate) fn get_icon_data() -> epi::IconData { + let (logo_buffer, [size_x, size_y]) = get_icon_as_dynamic_image(); + return epi::IconData { + rgba: logo_buffer.to_vec(), + height: size_y, + width: size_x, + }; +} diff --git a/src/rust/amadeus-rs/amadeus/src/widgets/about_window.rs b/src/rust/amadeus-rs/amadeus/src/widgets/about_window.rs new file mode 100644 index 0000000000000000000000000000000000000000..1a8d18a22776c57e06905f67caf75238fe7bfbc1 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/widgets/about_window.rs @@ -0,0 +1,92 @@ +use crate::{constants, widgets}; + +static THIRD_PARTIE_FONTS: [(&str, &str, Option<&str>); 2] = [ + ( + "UbuntuMono", + "https://design.ubuntu.com/font/", + Some("Dalton Maag (under the Ubuntu font licence)"), + ), + ( + "IPAMincho", + "http://ossipedia.ipa.go.jp/ipafont/", + Some("Information-technology Promotion Agency, Japan (under IPA licence)"), + ), +]; + +static THIRD_PARTIE_LIBS: [(&str, &str, Option<&str>); 6] = [ + ( + "serde", + "https://github.com/serde-rs/serde", + Some("(under MIT or APACHE-2.0)"), + ), + ( + "serde_json", + "https://github.com/serde-rs/json", + Some("(under MIT or APACHE-2.0)"), + ), + ( + "egui", + "https://github.com/emilk/egui", + Some("(under MIT or APACHE-2.0)"), + ), + ( + "lazy_static", + "https://github.com/rust-lang-nursery/lazy-static.rs", + Some("(under MIT or APACHE-2.0)"), + ), + ( + "regex", + "https://github.com/rust-lang/regex", + Some("(under MIT or APACHE-2.0)"), + ), + ( + "image", + "https://github.com/image-rs/image", + Some("(under MIT)"), + ), +]; + +pub fn render_about_window( + show: &mut bool, + dark_mode: bool, + ctx: &egui::Context, + logo: &egui::TextureHandle, +) { + widgets::WindowBuilder::new("About", dark_mode) + .with_resizable(true) + .show(show, ctx, |ui| { + { + ui.separator(); + widgets::add_heading2_label(ui, "☰ Amadeus RS"); + ui.style_mut().override_text_style = Some(crate::utils::font::body()); + ui.horizontal_top(|ui| { + ui.image(logo, logo.size_vec2()); + ui.add_space(constants::PADDING); + ui.add( + egui::Label::new(concat!( + "Amadeus RS is a rewrite of the Amadeus front end to lektord.\n", + "Amadeus RS is under the MIT licence." + )) + .wrap(true), + ); + }); + ui.add_space(constants::PADDING); + } + + { + ui.separator(); + widgets::add_heading2_label(ui, "☰ Third party libraries and fonts"); + ui.style_mut().override_text_style = Some(crate::utils::font::body()); + ui.label("The followinf thrid party libraries where used:"); + for (label, url, after) in THIRD_PARTIE_LIBS { + widgets::add_bullet_hyperlink(ui, label, url, after); + } + ui.add_space(constants::PADDING); + ui.label("The following third party fonts where used:"); + for (label, url, after) in THIRD_PARTIE_FONTS { + widgets::add_bullet_hyperlink(ui, label, url, after); + } + ui.add_space(constants::PADDING); + } + }); +} diff --git a/src/rust/amadeus-rs/amadeus/src/widgets/mod.rs b/src/rust/amadeus-rs/amadeus/src/widgets/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..2bc632de7aaa183d402ab91cb446ed8b1e864f46 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/widgets/mod.rs @@ -0,0 +1,79 @@ +mod about_window; +mod progress_bar; +mod toggle_switch; +mod window; + +pub use about_window::render_about_window; +pub use progress_bar::progress_bar; +pub use toggle_switch::toggle_switch; +pub use window::WindowBuilder; + +use crate::{constants, utils}; + +pub fn add_bullet_hyperlink(ui: &mut egui::Ui, label: &str, url: &str, after: Option<&str>) { + ui.horizontal(|ui| { + ui.label(" -"); + ui.hyperlink_to(label, url); + if let Some(text) = after { + ui.add(egui::Label::new(text).wrap(true)); + } + }); +} + +pub fn add_heading2_label(ui: &mut egui::Ui, text: &str) { + ui.style_mut().override_text_style = Some(utils::font::heading2()); + ui.add(egui::Label::new(text).wrap(false)); + ui.add_space(constants::PADDING); +} + +pub fn add_labelled_edit_line( + ui: &mut egui::Ui, + before: &str, + text: &mut dyn egui::TextBuffer, + changed: &mut bool, +) { + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::body()); + ui.add(egui::Label::new(before)); + let response = ui.add(egui::TextEdit::singleline(text)); + *changed |= response.lost_focus(); + }); +} + +pub fn add_labelled_toggle_switch( + ui: &mut egui::Ui, + dark_mode: bool, + before: &str, + switch: &mut bool, + changed: &mut bool, +) { + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::body()); + ui.add(egui::Label::new(before)); + ui.with_layout(egui::Layout::right_to_left(), |ui| { + let response = ui.add(toggle_switch(dark_mode, switch)); + *changed |= response.changed(); + }); + }); +} + +pub fn add_labelled_password( + ui: &mut egui::Ui, + before: &str, + text: &mut dyn egui::TextBuffer, + changed: &mut bool, +) { + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(utils::font::body()); + ui.add(egui::Label::new(before)); + let response = ui.add(egui::TextEdit::singleline(text).password(true)); + *changed |= response.lost_focus(); + }); +} + +pub fn get_label_text_for_kara(kara: &lkt_lib::types::Kara) -> String { + format!( + "{} - {} / {} - {} - {} [{}]", + kara.category, kara.language, kara.source_name, kara.song_type, kara.title, kara.author + ) +} diff --git a/src/rust/amadeus-rs/amadeus/src/widgets/progress_bar.rs b/src/rust/amadeus-rs/amadeus/src/widgets/progress_bar.rs new file mode 100644 index 0000000000000000000000000000000000000000..f4df388394148de7f1a4ff878bc3c20b59ebf4ff --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/widgets/progress_bar.rs @@ -0,0 +1,24 @@ +pub fn progress_bar(dark_mode: bool, progress: f32) -> impl egui::Widget + 'static { + return move |ui: &mut egui::Ui| { + let progress = progress.clamp(0.0, 1.0); + let fill_color = crate::constants::get_fill_color(dark_mode); + let desired_width = egui::NumExt::at_least(ui.available_size_before_wrap().x, 96.0); + let height = ui.spacing().interact_size.y; + let (outer_rect, response) = + ui.allocate_exact_size(egui::vec2(desired_width, height), egui::Sense::hover()); + + if ui.is_rect_visible(response.rect) { + let rect = egui::Rect::from_min_size( + outer_rect.min, + egui::vec2( + egui::NumExt::at_least(outer_rect.width() * progress, outer_rect.height()), + outer_rect.height(), + ), + ); + ui.painter() + .rect(rect, 0.0, fill_color, egui::Stroke::none()); + } + + return response; + }; +} diff --git a/src/rust/amadeus-rs/amadeus/src/widgets/toggle_switch.rs b/src/rust/amadeus-rs/amadeus/src/widgets/toggle_switch.rs new file mode 100644 index 0000000000000000000000000000000000000000..7a16d52b77e3136b26a76db0886904a1e7cf197a --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/widgets/toggle_switch.rs @@ -0,0 +1,44 @@ +use crate::constants; +use amadeus_macro::either; + +pub fn toggle_switch(dark_mode: bool, on: &mut bool) -> impl egui::Widget + '_ { + return move |ui: &mut egui::Ui| { + let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0); + let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + if response.clicked() { + *on = !*on; + response.mark_changed(); + } + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, *on, "")); + + if ui.is_rect_visible(rect) { + let (bg_fill, bg_stroke, fg_stroke, expansion) = { + let visuals = ui.style().interact_selectable(&response, *on); + ( + visuals.bg_fill, + visuals.bg_stroke, + visuals.fg_stroke, + visuals.expansion, + ) + }; + let fill_fore = constants::get_accent_color(dark_mode); + let fill_back = constants::get_fill_color(dark_mode); + let rect = rect.expand(expansion); + let radius = 0.5 * rect.height(); + + ui.painter() + .rect(rect, radius, either!(*on => fill_back; bg_fill), bg_stroke); + + let circle_x = egui::lerp( + (rect.left() + radius)..=(rect.right() - radius), + ui.ctx().animate_bool(response.id, *on), + ); + let center = egui::pos2(circle_x, rect.center().y); + + ui.painter() + .circle(center, 0.75 * radius, fill_fore, fg_stroke); + } + + return response; + }; +} diff --git a/src/rust/amadeus-rs/amadeus/src/widgets/window.rs b/src/rust/amadeus-rs/amadeus/src/widgets/window.rs new file mode 100644 index 0000000000000000000000000000000000000000..c11e0b2329deb0d394d3aecf79aabb717eb01305 --- /dev/null +++ b/src/rust/amadeus-rs/amadeus/src/widgets/window.rs @@ -0,0 +1,131 @@ +use crate::{ + action::{self, Action}, + constants, +}; + +/// Structure used to hole informations about the window to create, we use a +/// builder pattern here. +pub struct WindowBuilder<'a> { + name: &'a str, + dark_mode: bool, + resizable: Option<bool>, + default_size: Option<egui::Vec2>, + actions: Option<Vec<Action>>, + warning_actions: Option<Vec<Action>>, +} + +impl<'a> WindowBuilder<'a> { + pub fn new(name: &'a str, dark_mode: bool) -> Self { + Self { + name, + dark_mode, + resizable: None, + default_size: None, + actions: None, + warning_actions: None, + } + } + + pub fn with_resizable(self, resizable: bool) -> Self { + Self { + name: self.name, + dark_mode: self.dark_mode, + resizable: Some(resizable), + default_size: self.default_size, + actions: self.actions, + warning_actions: self.warning_actions, + } + } + + pub fn with_default_size(self, default_size: egui::Vec2) -> Self { + Self { + name: self.name, + dark_mode: self.dark_mode, + resizable: self.resizable, + default_size: Some(default_size), + actions: self.actions, + warning_actions: self.warning_actions, + } + } + + pub fn with_actions(self, actions: Vec<Action>) -> Self { + Self { + name: self.name, + dark_mode: self.dark_mode, + resizable: self.resizable, + default_size: self.default_size, + actions: Some(actions), + warning_actions: self.warning_actions, + } + } + + pub fn with_warning_actions(self, actions: Vec<Action>) -> Self { + Self { + name: self.name, + dark_mode: self.dark_mode, + resizable: self.resizable, + default_size: self.default_size, + actions: self.actions, + warning_actions: Some(actions), + } + } + + pub fn show<R>( + self, + flag: &mut bool, + ctx: &egui::Context, + add_contents: impl FnOnce(&mut egui::Ui) -> R, + ) -> Option<Vec<Action>> { + let mut flag_copy = *flag; + let mut temp_actions = Vec::<Action>::new(); + + // Force the mut borrow of the flag to be dropped + { + let win = egui::Window::new(self.name.clone()) + .open(flag) + .title_bar(false); + let (win, resizable) = match self.resizable { + Some(resizable) => (win.resizable(resizable), resizable), + None => (win, false), + }; + let win = match self.default_size { + Some(sizes) => { + if resizable { + win.default_size(sizes).fixed_size(sizes) + } else { + win.default_size(sizes) + } + } + None => win, + }; + win.show(ctx, |ui| { + ui.horizontal(|ui| { + ui.style_mut().override_text_style = Some(super::utils::font::heading3()); + ui.add(egui::Button::new("❌")) + .on_hover_text("Close the window") + .clicked() + .then(|| flag_copy = false); + if let Some(actions) = self.actions { + action::render_action_menu(ui, &actions, "▶", &mut temp_actions); + }; + if let Some(actions) = self.warning_actions { + action::render_action_menu(ui, &actions, "⚠", &mut temp_actions); + }; + ui.colored_label(constants::get_text_color(self.dark_mode), self.name); + }); + ui.add_space(constants::PADDING / 2.); + add_contents(ui) + }); + } + + // See if we need to close the window + *flag = flag_copy; + + // Return the actions if needed + if temp_actions.is_empty() { + None + } else { + Some(temp_actions) + } + } +} diff --git a/src/rust/amadeus-rs/lkt-lib/Cargo.toml b/src/rust/amadeus-rs/lkt-lib/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..d28a58181390b0bbe7207150be48431dfcef0619 --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lkt_lib" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +amadeus_macro = { path = "../amadeus-macro" } +serde = { version = "1", default-features = false, features = [ "derive", "std" ] } +serde_json = { version = "1", default-features = false, features = [ "std" ] } +log = { version = "0.4" } +regex = "1" +lazy_static = "1" \ No newline at end of file diff --git a/src/rust/amadeus-rs/lkt-lib/src/connexion.rs b/src/rust/amadeus-rs/lkt-lib/src/connexion.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b63c4cc77b4dbbf650e8948e2a2b8c9c1ff0fbe --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/src/connexion.rs @@ -0,0 +1,124 @@ +use log::error; + +use crate::{constants, query::LektorQuery, query::LektorQueryLineType}; +use std::{ + fmt, + io::{self, BufRead, BufReader, Write}, + net::TcpStream, +}; + +pub struct LektorConnexion { + stream: TcpStream, + pub version: String, + + reader: BufReader<TcpStream>, +} + +impl fmt::Debug for LektorConnexion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LektorConnexion") + .field("stream", &self.stream) + .field("version", &self.version) + .field("reader", &self.reader) + .finish() + } +} + +impl LektorConnexion { + pub fn new(hostname: String, port: i16) -> io::Result<Self> { + let result = TcpStream::connect(format!("{}:{}", hostname, port)); + match result { + Ok(lektord) => { + let mut ret: Self = Self { + stream: lektord.try_clone().unwrap(), + version: constants::MPD_VERSION.to_owned(), + reader: BufReader::new(lektord), + }; + let mut daemon_version: String = String::new(); + ret.reader.read_line(&mut daemon_version).unwrap(); + assert_eq!( + daemon_version.trim(), + format!("OK MPD {}", ret.version), + "Expected MPD server version {}, but got version {}, abort!", + ret.version, + daemon_version + ); + return Ok(ret); + } + Err(e) => { + error!("Failed to connect to lektor: {e}"); + return Err(e); + } + } + } + + pub fn send_query(&mut self, query: LektorQuery) -> io::Result<Vec<String>> { + let mut res: Vec<String> = Vec::new(); + self.send_query_inner(query, &mut res)?; + return Ok(res); + } + + fn send_query_inner( + &mut self, + query: LektorQuery, + previous_ret: &mut Vec<String>, + ) -> io::Result<()> { + self.write_string(query.clone().to_string())?; + loop { + match self.read_replies() { + Ok((res, None)) => { + previous_ret.extend(res); + return Ok(()); + } + Ok((res, Some(cont))) => { + previous_ret.extend(res); + self.write_string( + LektorQuery::create_continuation(query.clone(), cont).to_string(), + )?; + } + Err(e) => return Err(e), + } + } + } + + fn read_replies(&mut self) -> io::Result<(Vec<String>, Option<usize>)> { + let error_return_value = io::Result::Err(io::Error::from(io::ErrorKind::Other)); + let mut ret: Vec<String> = Vec::new(); + let mut reply_line: String = String::new(); + loop { + reply_line.clear(); + match self.reader.read_line(&mut reply_line) { + Err(e) => return io::Result::Err(e), + Ok(size) => { + if size <= 0 { + panic!("Got nothing in the line... consider this to be an error") + } + let msg = reply_line.trim(); + match LektorQueryLineType::from_str(&msg) { + LektorQueryLineType::Ok => return Ok((ret, None)), + LektorQueryLineType::Ack => return error_return_value, + LektorQueryLineType::Data => ret.push(msg.to_string()), + LektorQueryLineType::Continuation(cont) => return Ok((ret, Some(cont))), + } + } + } + } + } + + fn write_string(self: &mut Self, buffer: String) -> io::Result<()> { + if buffer.len() >= constants::LKT_MESSAGE_MAX { + panic!( + "Try to write a string that is too long for MPD! String length is {}, max is {}", + buffer.len(), + constants::LKT_MESSAGE_MAX + ); + } + if buffer.chars().last() != Some('\n') { + panic!( + "A line to be send must end with a newline (\\n), it was not the case for: {}", + buffer + ); + } + self.stream.write_all(buffer.as_bytes()) + } +} diff --git a/src/rust/amadeus-rs/lkt-lib/src/constants.rs b/src/rust/amadeus-rs/lkt-lib/src/constants.rs new file mode 100644 index 0000000000000000000000000000000000000000..960c2792931d7c33d317828f06ba51caf8a19f1e --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/src/constants.rs @@ -0,0 +1,19 @@ +#![allow(dead_code)] + +/// MPD commands are at most 32 character long. +pub(crate) const LKT_COMMAND_LEN_MAX: usize = 32; + +/// At most 32 words in a command are supported. +pub(crate) const LKT_MESSAGE_ARGS_MAX: usize = 32; + +/// A message is at most <defined> chars long +pub(crate) const LKT_MESSAGE_MAX: usize = 2048; + +/// At most 64 commands per client. +pub(crate) const COMMAND_LIST_MAX: usize = 64; + +/// At most 16 messages per client. +pub(crate) const BUFFER_OUT_MAX: usize = 16; + +/// Expected version from the lektord daemin. +pub(crate) const MPD_VERSION: &str = "0.21.16"; diff --git a/src/rust/amadeus-rs/lkt-lib/src/lib.rs b/src/rust/amadeus-rs/lkt-lib/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..3975593c3a2061f61079af11887bd6ec93b3ae97 --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/src/lib.rs @@ -0,0 +1,6 @@ +pub mod connexion; +mod constants; +pub mod query; +pub mod response; +pub mod types; +pub mod uri; diff --git a/src/rust/amadeus-rs/lkt-lib/src/query.rs b/src/rust/amadeus-rs/lkt-lib/src/query.rs new file mode 100644 index 0000000000000000000000000000000000000000..ecc9278889c086501ab959d22565bad8169e0e0a --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/src/query.rs @@ -0,0 +1,100 @@ +use crate::uri::LektorUri; +use amadeus_macro::lkt_command_from_str; +use std::string::ToString; + +pub enum LektorQueryLineType { + Ok, + Ack, + Continuation(usize), + Data, +} + +#[derive(Debug, Clone)] +pub enum LektorQuery { + Ping, + Close, + KillServer, + ConnectAsUser(String), + + CurrentKara, + PlaybackStatus, + + PlayNext, + PlayPrevious, + ShuffleQueue, + + ListAllPlaylists, + ListPlaylist(String), + + FindAddKara(LektorUri), + InsertKara(LektorUri), + AddKara(LektorUri), + + Continuation(usize, Box<LektorQuery>), +} + +impl LektorQueryLineType { + pub fn from_str(line: &str) -> Self { + if Self::is_line_ok(&line) { + return Self::Ok; + } else if Self::is_line_ack(&line) { + return Self::Ack; + } else if let Some(cont) = Self::is_line_continuation(&line) { + return Self::Continuation(cont); + } else { + return Self::Data; + } + } + + fn is_line_continuation(line: &str) -> Option<usize> { + if line.starts_with("continue:") { + match line.trim_start_matches("continue:").trim().parse::<usize>() { + Ok(cont) => Some(cont), + Err(_) => None, + } + } else { + None + } + } + + fn is_line_ok(line: &str) -> bool { + return (line == "OK\n") || (line == "OK"); + } + + fn is_line_ack(line: &str) -> bool { + return line.starts_with("ACK: "); + } +} + +impl LektorQuery { + pub fn create_continuation(query: Self, cont: usize) -> Self { + Self::Continuation(cont, Box::new(query)) + } +} + +impl ToString for LektorQuery { + fn to_string(&self) -> String { + match self { + Self::Ping => lkt_command_from_str!("ping"), + Self::Close => lkt_command_from_str!("close"), + Self::KillServer => lkt_command_from_str!("kill"), + Self::ConnectAsUser(password) => format!("password {}\n", password), + + Self::CurrentKara => lkt_command_from_str!("currentsong"), + Self::PlaybackStatus => lkt_command_from_str!("status"), + + Self::PlayNext => lkt_command_from_str!("next"), + Self::PlayPrevious => lkt_command_from_str!("previous"), + Self::ShuffleQueue => lkt_command_from_str!("shuffle"), + + Self::ListAllPlaylists => lkt_command_from_str!("listplaylists"), + Self::ListPlaylist(plt_name) => format!("listplaylist {}\n", plt_name), + + Self::FindAddKara(uri) => format!("findadd {}\n", uri.to_string()), + Self::InsertKara(uri) => format!("__insert {}\n", uri.to_string()), + Self::AddKara(uri) => format!("add {}\n", uri.to_string()), + + Self::Continuation(cont, query) => format!("{} {}", cont, query.to_owned().to_string()), + } + } +} diff --git a/src/rust/amadeus-rs/lkt-lib/src/response.rs b/src/rust/amadeus-rs/lkt-lib/src/response.rs new file mode 100644 index 0000000000000000000000000000000000000000..fd4c97e3bafa46ab5a55dec0fdc6c7e5b43e8823 --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/src/response.rs @@ -0,0 +1,135 @@ +use crate::types::LektorState; +use std::collections::HashMap; + +#[derive(Debug)] +pub struct LektorFormatedResponse { + content: HashMap<String, String>, +} + +impl LektorFormatedResponse { + pub fn pop(&mut self, key: &str) -> String { + match self.content.remove(key) { + Some(x) => x, + None => panic!("Failed to find a value with the key {key} in the formated response"), + } + } +} + +impl From<Vec<String>> for LektorFormatedResponse { + fn from(vec: Vec<String>) -> Self { + Self { + content: vec + .into_iter() + .map(|line| { + let key_pair: Vec<&str> = line.splitn(2, ':').collect(); + if let [key, value] = key_pair[..] { + ( + key.trim().to_lowercase().to_owned(), + value.trim().to_owned(), + ) + } else { + panic!("Invalid line for formated response: {}", line); + } + }) + .collect(), + } + } +} + +#[derive(Debug)] +pub enum LektordResponse { + PlaybackStatus(PlaybackStatus), + CurrentKara(CurrentKara), +} + +#[derive(Debug)] +pub struct PlaybackStatus { + pub elapsed: usize, + pub songid: Option<usize>, + pub song: Option<usize>, + pub volume: usize, + pub state: LektorState, + pub duration: usize, + pub updating_db: usize, + pub playlistlength: usize, + pub random: bool, + pub consume: bool, + pub single: bool, + pub repeat: bool, +} + +impl PlaybackStatus { + pub fn consume(response: &mut LektorFormatedResponse) -> Self { + let mut ret = Self { + elapsed: match response.pop("elapsed").parse::<usize>() { + Ok(x) => x, + Err(_) => 0, + }, + songid: match response.pop("songid").parse::<isize>() { + Ok(x) if x <= 0 => None, + Ok(x) => Some(x as usize), + Err(_) => None, + }, + song: match response.pop("song").parse::<usize>() { + Ok(x) => Some(x), + Err(_) => None, + }, + volume: response + .pop("volume") + .parse::<usize>() + .unwrap() + .clamp(0, 100), + duration: response.pop("duration").parse::<usize>().unwrap(), + updating_db: response.pop("updating_db").parse::<usize>().unwrap(), + playlistlength: response.pop("playlistlength").parse::<usize>().unwrap(), + random: response.pop("random").parse::<usize>().unwrap() != 0, + consume: response.pop("consume").parse::<usize>().unwrap() != 0, + single: response.pop("single").parse::<usize>().unwrap() != 0, + repeat: response.pop("repeat").parse::<usize>().unwrap() != 0, + state: LektorState::Stopped, + }; + ret.state = match &response.pop("state")[..] { + "play" => LektorState::Play(ret.songid.unwrap()), + "pause" => LektorState::Pause(ret.songid.unwrap()), + _ => LektorState::Stopped, + }; + return ret; + } +} + +#[derive(Debug)] +pub struct CurrentKara { + pub title: String, + pub author: String, + pub source: String, + pub song_type: String, + pub song_number: Option<usize>, + pub category: String, + pub language: String, +} + +impl CurrentKara { + pub fn consume(response: &mut LektorFormatedResponse) -> Self { + let song_type_number = response.pop("type"); + let (song_type, song_number) = match song_type_number.find(char::is_numeric) { + Some(index) => ( + (&song_type_number[..index]).to_owned(), + match (&song_type_number[index..]).parse::<usize>() { + Ok(x) => Some(x), + Err(_) => None, + }, + ), + None => panic!("Oupsy"), + }; + + Self { + title: response.pop("title"), + author: response.pop("author"), + source: response.pop("source"), + category: response.pop("category"), + language: response.pop("language"), + song_type, + song_number, + } + } +} diff --git a/src/rust/amadeus-rs/lkt-lib/src/types.rs b/src/rust/amadeus-rs/lkt-lib/src/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ac21fcfe4b917a618133b8180cad982292ebd7f --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/src/types.rs @@ -0,0 +1,91 @@ +use std::hash::{Hash, Hasher}; + +/// Lektor Types are the Kara and the Playlist types. You should not implement +/// this type yourself. +pub trait LektorType<'a>: + Clone + serde::Serialize + serde::Deserialize<'a> + Hash + private::Sealed +{ + fn unique_id(&self) -> u64; +} + +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Kara { + pub id: u32, + pub source_name: String, + + #[serde(rename = "type")] + pub song_type: String, + + pub language: String, + pub category: String, + pub title: String, + pub song_number: Option<u32>, + pub author: String, + pub is_available: bool, +} + +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Playlist { + pub id: u32, + pub name: String, +} + +impl LektorType<'_> for Kara { + fn unique_id(&self) -> u64 { + return private::unique_id(self); + } +} + +impl LektorType<'_> for Playlist { + fn unique_id(&self) -> u64 { + return private::unique_id(self); + } +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum LektorState { + Stopped, + Play(usize), + Pause(usize), +} + +impl Default for LektorState { + fn default() -> Self { + Self::Stopped + } +} + +/// We seal the implementation only for supported types. +mod private { + use super::{Kara, Playlist}; + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + pub trait Sealed {} + + impl Sealed for Kara {} + impl Sealed for Playlist {} + + pub(crate) fn unique_id<T: Hash + Sealed>(object: &T) -> u64 { + let mut s = DefaultHasher::new(); + object.hash(&mut s); + return s.finish(); + } +} + +/// Sealed implementation of Hash for Kara. +impl Hash for Kara { + fn hash<H: Hasher>(&self, state: &mut H) { + ((self.id as usize) * 2).hash(state); + } +} + +/// Sealed implementation of Hash for Playlist. +impl Hash for Playlist { + fn hash<H: Hasher>(&self, state: &mut H) { + self.id.hash(state); + self.name.hash(state); + } +} diff --git a/src/rust/amadeus-rs/lkt-lib/src/uri.rs b/src/rust/amadeus-rs/lkt-lib/src/uri.rs new file mode 100644 index 0000000000000000000000000000000000000000..95892a1c798782fc79ac630f072a01b70c279a39 --- /dev/null +++ b/src/rust/amadeus-rs/lkt-lib/src/uri.rs @@ -0,0 +1,18 @@ +#[derive(Debug, Clone)] +pub enum LektorUri { + Id(i32), + Author(String), + Playlist(String), + Query(String), +} + +impl ToString for LektorUri { + fn to_string(&self) -> String { + match self { + Self::Id(id) => format!("id://{}", id), + Self::Author(author) => format!("author://{}", author), + Self::Playlist(plt_name) => format!("playlist://{}", plt_name), + Self::Query(sql_query) => format!("query://%{}%", sql_query), + } + } +} diff --git a/src/rust/amadeus-rs/lkt-rs/Cargo.toml b/src/rust/amadeus-rs/lkt-rs/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..89387867e53c6b24a6e3b80a1075e8c16a5658a1 --- /dev/null +++ b/src/rust/amadeus-rs/lkt-rs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lkt-rs" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +lkt_lib = { path = "../lkt-lib" } +amadeus_macro = { path = "../amadeus-macro" } +serde = { version = "1", features = [ "derive" ] } +serde_json = { version = "1", default-features = false, features = [ "std" ] } +log = { version = "0.4" } +lazy_static = "1" \ No newline at end of file diff --git a/src/rust/amadeus-rs/lkt-rs/src/main.rs b/src/rust/amadeus-rs/lkt-rs/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..9864808a0973d0e0def07693be61369ad86819e7 --- /dev/null +++ b/src/rust/amadeus-rs/lkt-rs/src/main.rs @@ -0,0 +1,24 @@ +use lkt_lib::{connexion::LektorConnexion, query::LektorQuery, response}; + +fn main() { + let mut lektor = LektorConnexion::new("localhost".to_owned(), 6600).unwrap(); + if let Ok(_) = lektor.send_query(LektorQuery::Ping) {} + + if let Ok(res) = lektor.send_query(LektorQuery::CurrentKara) { + let mut response = response::LektorFormatedResponse::from(res); + let current_kara = response::CurrentKara::consume(&mut response); + println!("CURRENT {:?}", current_kara); + } + + if let Ok(res) = lektor.send_query(LektorQuery::PlaybackStatus) { + let mut response = response::LektorFormatedResponse::from(res); + let playback_status = response::PlaybackStatus::consume(&mut response); + println!("PLAYBACK-STATUS {:?}", playback_status); + } + + if let Ok(res) = lektor.send_query(LektorQuery::ListAllPlaylists) { + for item in res { + println!("ALL PLAYLISTS {}", item); + } + } +} diff --git a/src/rust/amadeus-rs/rsc/AmadeusLogo.jpg b/src/rust/amadeus-rs/rsc/AmadeusLogo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..62b445c1c20d056de0b3badafe7d845733a78694 Binary files /dev/null and b/src/rust/amadeus-rs/rsc/AmadeusLogo.jpg differ diff --git a/src/rust/amadeus-rs/rsc/IPAMincho.ttf b/src/rust/amadeus-rs/rsc/IPAMincho.ttf new file mode 100644 index 0000000000000000000000000000000000000000..df7b361fc7e67658e9b86f3085c5273a51338adf Binary files /dev/null and b/src/rust/amadeus-rs/rsc/IPAMincho.ttf differ diff --git a/src/rust/amadeus-rs/rsc/UbuntuMono-Regular.ttf b/src/rust/amadeus-rs/rsc/UbuntuMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4977028d132b681c035d427748f74afab9fd70bf Binary files /dev/null and b/src/rust/amadeus-rs/rsc/UbuntuMono-Regular.ttf differ diff --git a/src/rust/amadeus-rs/rsc/UbuntuMono-UFL.txt b/src/rust/amadeus-rs/rsc/UbuntuMono-UFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..6e722c88daa5e66b0b037de6ffa1bda86cc21f6e --- /dev/null +++ b/src/rust/amadeus-rs/rsc/UbuntuMono-UFL.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE.