diff --git a/Cargo.lock b/Cargo.lock index 775eacbe..bd3956c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -594,6 +600,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e53693616d3075149f4ead59bdeecd204ac6b8192d8969757601b74bddf00f" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.0", +] + [[package]] name = "clang-sys" version = "1.7.0" @@ -940,6 +960,26 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30baa043103c9d0c2a57cf537cc2f35623889dc0d405e6c3cccfadbc81c71309" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1640,6 +1680,29 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "iced_core" version = "0.12.0" @@ -2031,6 +2094,16 @@ dependencies = [ "redox_syscall 0.4.1", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.4.2", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -2280,10 +2353,12 @@ dependencies = [ [[package]] name = "neothesia" -version = "0.1.0" +version = "0.1.8" dependencies = [ "async-thread", + "chrono", "cpal", + "dirs", "embed-resource", "env_logger", "fluidlite", @@ -2340,7 +2415,7 @@ dependencies = [ [[package]] name = "neothesia-iced-widgets" -version = "0.1.0" +version = "0.1.1" dependencies = [ "iced_core", "iced_graphics", @@ -2562,7 +2637,7 @@ version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52f0d54bde9774d3a51dcf281a5def240c71996bc6ca05d2c847ec8b2b216166" dependencies = [ - "libredox", + "libredox 0.0.2", ] [[package]] @@ -2988,6 +3063,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +dependencies = [ + "getrandom", + "libredox 0.1.3", + "thiserror", +] + [[package]] name = "regex" version = "1.10.3" diff --git a/Cargo.toml b/Cargo.toml index 7760dfa3..8c4792fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,4 +34,4 @@ iced_graphics = "0.12" iced_core = "0.12" iced_runtime = "0.12" iced_wgpu = { version = "0.12", features = ["image"] } -iced_widget = { version = "0.12", features = ["image"] } +iced_widget = { version = "0.12", features = ["image"] } diff --git a/README.md b/README.md index 9155643b..199ed8d8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,14 @@ -![Neothesia Baner](https://github.com/PolyMeilex/Neothesia/assets/20758186/ca9aa8ae-2a69-48de-92d6-97d7ea9e678d) +# MetaThesia + +### Status: Mostly inactive, but feature-complete + +MetaThesia is a fork of [Neothesia by PolyMeilex](https://github.com/PolyMeilex/neothesia), enhanced with a few additional scenes, including game stats and a MIDI folder viewer etc. For more details on what’s changed, check out the commits or changelog. + +### Project Status + +This project is mostly inactive due to limited time, and frankly, it works for my needs. Barring any major bugs that interfere with usability, there likely won’t be new updates. + +If you encounter bugs, you can try the [original project](https://github.com/PolyMeilex/neothesia), or feel free to open an issue here. With a bit of luck, we might be able to address it! # Neothesia diff --git a/midi-io/src/lib.rs b/midi-io/src/lib.rs index 87811330..d1c83bca 100644 --- a/midi-io/src/lib.rs +++ b/midi-io/src/lib.rs @@ -123,7 +123,7 @@ impl std::fmt::Display for MidiInputPort { write!(f, "{}", self.0) } } - +#[allow(dead_code)] pub struct MidiInputConnection(midir::MidiInputConnection<()>); pub struct MidiOutputConnection(midir::MidiOutputConnection); diff --git a/neothesia-core/src/config.rs b/neothesia-core/src/config.rs index 702d3f13..14e7b98a 100644 --- a/neothesia-core/src/config.rs +++ b/neothesia-core/src/config.rs @@ -37,6 +37,7 @@ pub struct Config { pub soundfont_path: Option, pub last_opened_song: Option, + pub song_directory: Option, #[serde(default = "default_piano_range")] pub piano_range: (u8, u8), @@ -78,6 +79,7 @@ impl Config { input: None, soundfont_path: None, last_opened_song: None, + song_directory: None, piano_range: default_piano_range(), }) } diff --git a/neothesia-core/src/gamesave.rs b/neothesia-core/src/gamesave.rs new file mode 100644 index 00000000..d24f9398 --- /dev/null +++ b/neothesia-core/src/gamesave.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; +use std::time::SystemTime; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SavedStats { + pub songs: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SongStats { + pub song_name: String, + pub correct_note_times: u32, + pub wrong_note_times: u32, + pub notes_missed: u32, + pub notes_hit: u32, + pub wrong_notes: u32, + pub date: SystemTime, +} + +impl SavedStats { + pub fn load() -> Option { + if let Some(path) = crate::utils::resources::gamestats_ron() { + if let Ok(file) = std::fs::read_to_string(&path) { + match ron::from_str(&file) { + Ok(stats) => Some(stats), + Err(err) => { + log::error!("Error loading game stats: {:#?}", err); + None + } + } + } else { + None + } + } else { + None + } + } + + pub fn load_for_song(songname: String) -> Vec { + if let Some(saved_stats) = SavedStats::load() { + // Filter stats for the current song + let filtered_stats: Vec = saved_stats + .songs + .iter() + .filter(|stats| stats.song_name == songname) + .cloned() + .collect(); + + // Sort stats using the defined scoring cooking logic + let mut sorted_stats = filtered_stats.clone(); + + sorted_stats.sort_by(|a, b| { + let score_a = SavedStats::score_cooking(a); + let score_b = SavedStats::score_cooking(b); + score_b.cmp(&score_a) + }); + + sorted_stats + } else { + vec![] + } + } + pub fn save(&self) { + if let Ok(s) = ron::ser::to_string_pretty(self, Default::default()) { + if let Some(path) = crate::utils::resources::gamestats_ron() { + std::fs::create_dir_all(path.parent().unwrap()).ok(); + std::fs::write(path, s).ok(); + } + } + } + pub fn score_cooking(stats: &SongStats) -> u32 { + let mut score = stats.notes_hit + stats.correct_note_times * 10; + // Apply penalties then give the bonus + score = + score.saturating_sub(stats.notes_missed + stats.wrong_notes) + stats.correct_note_times; + + score + } +} + +impl Default for SavedStats { + fn default() -> Self { + SavedStats { songs: Vec::new() } + } +} diff --git a/neothesia-core/src/lib.rs b/neothesia-core/src/lib.rs index d03d9599..2709ba4a 100644 --- a/neothesia-core/src/lib.rs +++ b/neothesia-core/src/lib.rs @@ -3,5 +3,6 @@ pub use wgpu_jumpstart::{Color, Gpu, TransformUniform, Uniform}; pub mod config; +pub mod gamesave; pub mod render; pub mod utils; diff --git a/neothesia-core/src/utils/resources.rs b/neothesia-core/src/utils/resources.rs index e4170bf4..bef4323c 100644 --- a/neothesia-core/src/utils/resources.rs +++ b/neothesia-core/src/utils/resources.rs @@ -61,6 +61,17 @@ pub fn settings_ron() -> Option { return bundled_resource_path("settings", "ron").map(PathBuf::from); } +pub fn gamestats_ron() -> Option { + #[cfg(all(target_family = "unix", not(target_os = "macos")))] + return xdg_config().map(|p| p.join("gamestats.ron")); + + #[cfg(target_os = "windows")] + return Some(PathBuf::from("./gamestats.ron")); + + #[cfg(target_os = "macos")] + return bundled_resource_path("gamestats", "ron").map(PathBuf::from); +} + #[cfg(target_os = "macos")] fn bundled_resource_path(name: &str, extension: &str) -> Option { use objc::runtime::{Class, Object}; diff --git a/neothesia-iced-widgets/Cargo.toml b/neothesia-iced-widgets/Cargo.toml index b6064c5f..92ecb9b0 100644 --- a/neothesia-iced-widgets/Cargo.toml +++ b/neothesia-iced-widgets/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "neothesia-iced-widgets" -version = "0.1.0" +version = "0.1.1" edition = "2021" [dependencies] @@ -10,4 +10,5 @@ iced_core.workspace = true iced_wgpu.workspace = true iced_widget.workspace = true + piano-math = { workspace = true } diff --git a/neothesia-iced-widgets/src/lib.rs b/neothesia-iced-widgets/src/lib.rs index ae80fff5..23352016 100644 --- a/neothesia-iced-widgets/src/lib.rs +++ b/neothesia-iced-widgets/src/lib.rs @@ -4,6 +4,7 @@ pub mod piano_range; pub mod preferences_group; pub mod scroll_listener; pub mod segment_button; +pub mod stats_container; pub mod track_card; pub mod wrap; @@ -13,6 +14,7 @@ pub use piano_range::PianoRange; pub use preferences_group::{ActionRow, PreferencesGroup}; pub use scroll_listener::ScrollListener; pub use segment_button::SegmentButton; +pub use stats_container::StatsContainer; pub use track_card::TrackCard; pub use wrap::Wrap; diff --git a/neothesia-iced-widgets/src/stats_container/img/first_place.png b/neothesia-iced-widgets/src/stats_container/img/first_place.png new file mode 100755 index 00000000..179661b4 Binary files /dev/null and b/neothesia-iced-widgets/src/stats_container/img/first_place.png differ diff --git a/neothesia-iced-widgets/src/stats_container/img/second_place.png b/neothesia-iced-widgets/src/stats_container/img/second_place.png new file mode 100755 index 00000000..e5a056ed Binary files /dev/null and b/neothesia-iced-widgets/src/stats_container/img/second_place.png differ diff --git a/neothesia-iced-widgets/src/stats_container/img/third_place.png b/neothesia-iced-widgets/src/stats_container/img/third_place.png new file mode 100755 index 00000000..889864c5 Binary files /dev/null and b/neothesia-iced-widgets/src/stats_container/img/third_place.png differ diff --git a/neothesia-iced-widgets/src/stats_container/img/trophy_placeholder.png b/neothesia-iced-widgets/src/stats_container/img/trophy_placeholder.png new file mode 100755 index 00000000..b957a6d6 Binary files /dev/null and b/neothesia-iced-widgets/src/stats_container/img/trophy_placeholder.png differ diff --git a/neothesia-iced-widgets/src/stats_container/mod.rs b/neothesia-iced-widgets/src/stats_container/mod.rs new file mode 100644 index 00000000..ea79c249 --- /dev/null +++ b/neothesia-iced-widgets/src/stats_container/mod.rs @@ -0,0 +1,176 @@ +use super::Element; +use iced_core::{image::Handle as ImageHandle, Alignment}; + +mod theme; + +pub struct StatsContainer<'a, MSG> { + image: Option, + date: String, + place: String, + score: String, + notes_hits: String, + notes_missed: String, + wrong_notes: String, + correct_notes_duration: String, + _marker: std::marker::PhantomData<&'a MSG>, // PhantomData to indicate usage of Msg + header: bool, +} + +impl<'a, MSG: 'a + Clone> Default for StatsContainer<'a, MSG> { + fn default() -> Self { + Self::new() + } +} + +impl<'a, MSG: 'a + Clone> StatsContainer<'a, MSG> { + pub fn new() -> Self { + Self { + image: None, + date: String::new(), + place: String::new(), + score: String::new(), + notes_hits: String::new(), + notes_missed: String::new(), + wrong_notes: String::new(), + correct_notes_duration: String::new(), + _marker: std::marker::PhantomData, + + header: false, + } + } + + pub fn image(mut self, image: u32) -> Self { + self.image = match image { + 0 => Some(ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/stats_container/img/trophy_placeholder.png" + )) + .to_vec(), + )), + 1 => Some(ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/stats_container/img/first_place.png" + )) + .to_vec(), + )), + 2 => Some(ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/stats_container/img/second_place.png" + )) + .to_vec(), + )), + 3 => Some(ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/stats_container/img/third_place.png" + )) + .to_vec(), + )), + _ => None, + }; + self + } + + pub fn date(mut self, date: impl ToString) -> Self { + self.date = date.to_string(); + self + } + + pub fn place(mut self, place: impl ToString) -> Self { + self.place = place.to_string(); + self + } + + pub fn score(mut self, score: impl ToString) -> Self { + self.score = score.to_string(); + self + } + + pub fn notes_hits(mut self, hits: impl ToString) -> Self { + self.notes_hits = hits.to_string(); + self + } + + pub fn notes_missed(mut self, missed: impl ToString) -> Self { + self.notes_missed = missed.to_string(); + self + } + + pub fn wrong_notes(mut self, wrong: impl ToString) -> Self { + self.wrong_notes = wrong.to_string(); + self + } + + pub fn correct_notes_duration(mut self, duration: impl ToString) -> Self { + self.correct_notes_duration = duration.to_string(); + self + } + + pub fn header(mut self, header: bool) -> Self { + self.header = header; + self + } +} + +impl<'a, M: Clone + 'a> From> for Vec> { + fn from(card: StatsContainer<'a, M>) -> Self { + let columns = vec![ + (card.place, 90), + (card.date, 190), + (card.score, 90), + (card.notes_hits, 90), + (card.notes_missed, 100), + (card.wrong_notes, 90), + (card.correct_notes_duration, 120), + ]; + + let header_row = columns + .iter() + .map(|(text, width)| { + let container = + iced_widget::container(iced_widget::text(text.clone()).size(12)).width(*width); + + // Set background color + + container.into() + }) + .collect::>(); + + let header = iced_widget::row(header_row) + .spacing(0) + .align_items(Alignment::Start); + + let image_container = if let Some(image) = card.image { + iced_widget::container(iced_widget::image(image).width(40)).into() + } else { + iced_widget::container(iced_widget::text("")) + .width(0) + .into() + }; + + let text_container = + iced_widget::container(iced_widget::column(vec![header.into()]).spacing(6)); + + let centered_container = iced_widget::container( + iced_widget::row(vec![image_container, text_container.padding(10).into()]) + .align_items(Alignment::Center), + ); + + let mut children = vec![]; + + if card.header { + let centered_with_style = iced_widget::container(centered_container) + .padding(10) + .style(theme::card()) + .into(); + children.push(centered_with_style); + } else { + children.push(centered_container.padding(8).into()); + } + + children + } +} diff --git a/neothesia-iced-widgets/src/stats_container/theme.rs b/neothesia-iced-widgets/src/stats_container/theme.rs new file mode 100644 index 00000000..b311f9bf --- /dev/null +++ b/neothesia-iced-widgets/src/stats_container/theme.rs @@ -0,0 +1,19 @@ +pub fn card() -> iced_style::theme::Container { + iced_style::theme::Container::Custom(Box::new(ContainerStyle)) +} + +struct ContainerStyle; + +impl iced_style::container::StyleSheet for ContainerStyle { + type Style = iced_style::Theme; + + fn appearance(&self, _style: &Self::Style) -> iced_style::container::Appearance { + iced_style::container::Appearance { + background: Some(iced_core::Background::from(iced_core::Color::from_rgba8( + 44, 59, 102, 1.0, + ))), + + ..Default::default() + } + } +} diff --git a/neothesia-iced-widgets/src/track_card/img/bells.png b/neothesia-iced-widgets/src/track_card/img/bells.png new file mode 100644 index 00000000..c5d48a7e Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/bells.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/brasses.png b/neothesia-iced-widgets/src/track_card/img/brasses.png new file mode 100755 index 00000000..8753c886 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/brasses.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/choirs.png b/neothesia-iced-widgets/src/track_card/img/choirs.png new file mode 100644 index 00000000..02e35329 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/choirs.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/flutes.png b/neothesia-iced-widgets/src/track_card/img/flutes.png new file mode 100644 index 00000000..5f3cd0d0 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/flutes.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/guitars.png b/neothesia-iced-widgets/src/track_card/img/guitars.png new file mode 100755 index 00000000..aad15c4b Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/guitars.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/percussions.png b/neothesia-iced-widgets/src/track_card/img/percussions.png new file mode 100755 index 00000000..a3404e1e Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/percussions.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/piano-left.png b/neothesia-iced-widgets/src/track_card/img/piano-left.png new file mode 100755 index 00000000..be1c0ba0 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/piano-left.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/piano-right.png b/neothesia-iced-widgets/src/track_card/img/piano-right.png new file mode 100755 index 00000000..294f23bf Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/piano-right.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/toggle_off.png b/neothesia-iced-widgets/src/track_card/img/toggle_off.png new file mode 100644 index 00000000..852c4b91 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/toggle_off.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/toggle_on.png b/neothesia-iced-widgets/src/track_card/img/toggle_on.png new file mode 100644 index 00000000..55cf0de2 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/toggle_on.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/uncategorized.png b/neothesia-iced-widgets/src/track_card/img/uncategorized.png new file mode 100755 index 00000000..7edd9e7c Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/uncategorized.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/violins.png b/neothesia-iced-widgets/src/track_card/img/violins.png new file mode 100644 index 00000000..e6284ae7 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/violins.png differ diff --git a/neothesia-iced-widgets/src/track_card/img/xylophones.png b/neothesia-iced-widgets/src/track_card/img/xylophones.png new file mode 100644 index 00000000..75a40d49 Binary files /dev/null and b/neothesia-iced-widgets/src/track_card/img/xylophones.png differ diff --git a/neothesia-iced-widgets/src/track_card/mod.rs b/neothesia-iced-widgets/src/track_card/mod.rs index 369a5adf..8077b8f7 100644 --- a/neothesia-iced-widgets/src/track_card/mod.rs +++ b/neothesia-iced-widgets/src/track_card/mod.rs @@ -1,14 +1,162 @@ +use std::usize; + use super::Element; -use iced_core::{Alignment, Color}; +use iced_core::{image::Handle as ImageHandle, Alignment, Color}; mod theme; +fn get_instrument_icon(index: usize) -> ImageHandle { + match index { + 797979 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/percussions.png" + )) + .to_vec(), + ), // Percussions + 696969 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/piano-right.png" + )) + .to_vec(), + ), // Pianos Right hand mark + 0..=7 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/piano-left.png" + )) + .to_vec(), + ), // Pianos, default will be left hand + 8..=15 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/xylophones.png" + )) + .to_vec(), + ), // Chromatic Percussion + 16..=23 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/brasses.png" + )) + .to_vec(), + ), // Organs + 24..=31 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/guitars.png" + )) + .to_vec(), + ), // Guitars + 32..=39 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/guitars.png" + )) + .to_vec(), + ), // Basses + 40..=47 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/violins.png" + )) + .to_vec(), + ), // Strings + 48..=51 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/uncategorized.png" + )) + .to_vec(), + ), // Ensemble + 52..=55 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/choirs.png" + )) + .to_vec(), + ), // choirs + 56..=63 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/brasses.png" + )) + .to_vec(), + ), // Brass + 64..=71 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/flutes.png" + )) + .to_vec(), + ), // Reeds + 72..=79 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/flutes.png" + )) + .to_vec(), + ), // Pipes + 80..=87 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/uncategorized.png" + )) + .to_vec(), + ), // Synth Leads + 88..=95 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/uncategorized.png" + )) + .to_vec(), + ), // Synth Pads + 96..=103 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/uncategorized.png" + )) + .to_vec(), + ), // Synth Effects + 104..=111 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/percussions.png" + )) + .to_vec(), + ), // Ethnic + 112..=119 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/percussions.png" + )) + .to_vec(), + ), // Percussive + 120..=127 => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/uncategorized.png" + )) + .to_vec(), + ), // Sound effects + _ => ImageHandle::from_memory( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/uncategorized.png" + )) + .to_vec(), + ), // Default to Uncategorized + } +} + pub struct TrackCard<'a, MSG> { title: String, subtitle: String, body: Option>, track_color: Color, on_icon_press: Option, + instrument_id: usize, } impl<'a, MSG: 'a + Clone> Default for TrackCard<'a, MSG> { @@ -23,6 +171,8 @@ impl<'a, MSG: 'a + Clone> TrackCard<'a, MSG> { title: String::new(), subtitle: String::new(), body: None, + instrument_id: 0, + track_color: Color::from_rgba8(210, 89, 222, 1.0), on_icon_press: None, } @@ -38,6 +188,11 @@ impl<'a, MSG: 'a + Clone> TrackCard<'a, MSG> { self } + pub fn instrument_id(mut self, instrument_id: usize) -> Self { + self.instrument_id = instrument_id; + self + } + pub fn track_color(mut self, color: Color) -> Self { self.track_color = color; self @@ -53,26 +208,51 @@ impl<'a, MSG: 'a + Clone> TrackCard<'a, MSG> { self } } - impl<'a, M: Clone + 'a> From> for Element<'a, M> { fn from(card: TrackCard<'a, M>) -> Self { - let header = { - iced_widget::row![ - iced_widget::button(iced_widget::text("")) - .width(40) - .height(40) - .style(theme::track_icon_button(card.track_color)) - .on_press_maybe(card.on_icon_press), - iced_widget::column(vec![ - iced_widget::text(card.title).size(16).into(), - iced_widget::text(card.subtitle).size(14).into(), - ]) - .spacing(4) - .align_items(Alignment::Start), - ] - .spacing(16) + let header_content = vec![ + iced_widget::text(card.title).size(16).width(187).into(), + iced_widget::text(card.subtitle).size(14).into(), + ]; + + let img = get_instrument_icon(card.instrument_id); + + let img_toggle = if card.track_color == iced_core::Color::from_rgb8(102, 102, 102) { + ImageHandle::from_memory(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/toggle_off.png" + ))) + } else { + ImageHandle::from_memory(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/track_card/img/toggle_on.png" + ))) }; + let on_press_clone = card.on_icon_press.clone(); + + let button1 = iced_widget::button(iced_widget::image(img).width(55)) + .width(55) + .height(55) + .style(theme::track_icon_button(iced_core::Color::TRANSPARENT)) + .on_press_maybe(card.on_icon_press); + + let button2 = iced_widget::button(iced_widget::image(img_toggle).width(40).height(15)) + .width(55) + .height(15) + .padding(0) + .style(theme::toggle_button(iced_core::Color::TRANSPARENT)) + .on_press_maybe(on_press_clone); + + let header = iced_widget::row![ + button1, + iced_widget::column(header_content) + .spacing(0) + .align_items(Alignment::Start), + button2, + ] + .spacing(8); + let mut children = vec![header.into()]; if let Some(body) = card.body { children.push(body); diff --git a/neothesia-iced-widgets/src/track_card/theme.rs b/neothesia-iced-widgets/src/track_card/theme.rs index ac2ba3a7..58111439 100644 --- a/neothesia-iced-widgets/src/track_card/theme.rs +++ b/neothesia-iced-widgets/src/track_card/theme.rs @@ -56,3 +56,36 @@ impl iced_style::button::StyleSheet for TrackIconButtonStyle { active } } +pub fn toggle_button(color: iced_core::Color) -> iced_style::theme::Button { + iced_style::theme::Button::Custom(Box::new(ToggleButtonStyle(color))) +} + +struct ToggleButtonStyle(iced_core::Color); + +impl iced_style::button::StyleSheet for ToggleButtonStyle { + type Style = iced_style::Theme; + + fn active(&self, _style: &Self::Style) -> iced_style::button::Appearance { + iced_style::button::Appearance { + background: Some(iced_core::Background::Color(self.0)), + border: Border { + radius: Radius::from(255.0), + ..Default::default() + }, + ..Default::default() + } + } + + /// Produces the hovered [`Appearance`] of a button. + fn hovered(&self, style: &Self::Style) -> iced_style::button::Appearance { + let mut active = self.active(style); + + if let Some(iced_core::Background::Color(ref mut color)) = active.background { + color.r = (color.r + 0.05).min(1.0); + color.g = (color.g + 0.05).min(1.0); + color.b = (color.b + 0.05).min(1.0); + } + + active + } +} diff --git a/neothesia/Cargo.toml b/neothesia/Cargo.toml index b1e413b6..05750a31 100644 --- a/neothesia/Cargo.toml +++ b/neothesia/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "neothesia" -version = "0.1.0" +version = "0.1.8" authors = ["Poly "] edition = "2021" default-run = "neothesia" @@ -23,6 +23,7 @@ neothesia-iced-widgets.workspace = true piano-math.workspace = true midi-file.workspace = true midi-io.workspace = true +dirs = "3.0" iced_style.workspace = true iced_graphics.workspace = true @@ -35,7 +36,7 @@ fps_ticker = "1" winit = { version = "0.29", features = ["rwh_05"] } rfd = "0.14" async-thread = "0.1" - +chrono = "0.4.19" cpal = { version = "0.15", optional = true } fluidlite = { version = "0.2", features = ["builtin"], optional = true } oxisynth = { version = "0.0.5", optional = true } diff --git a/neothesia/src/main.rs b/neothesia/src/main.rs index 8e6376df..acfa00d5 100644 --- a/neothesia/src/main.rs +++ b/neothesia/src/main.rs @@ -16,6 +16,7 @@ use iced_core::Renderer; use scene::{menu_scene, playing_scene, Scene}; use utils::window::WindowState; +use menu_scene::Step; use midi_file::midly::MidiMessage; use neothesia_core::{config, render}; use wgpu_jumpstart::Surface; @@ -30,7 +31,9 @@ pub enum NeothesiaEvent { /// Go to playing scene Play(song::Song), /// Go to main menu scene - MainMenu, + MainMenu { + page: Step, + }, MidiInput { /// The MIDI channel that this message is associated with. channel: u8, @@ -52,7 +55,7 @@ struct Neothesia { impl Neothesia { fn new(mut context: Context, surface: Surface) -> Self { - let game_scene = menu_scene::MenuScene::new(&mut context); + let game_scene = menu_scene::MenuScene::new(&mut context, Step::Main); context.resize(); context.gpu.submit(); @@ -144,10 +147,11 @@ impl Neothesia { let to = playing_scene::PlayingScene::new(&self.context, song); self.game_scene = Box::new(to); } - NeothesiaEvent::MainMenu => { - let to = menu_scene::MenuScene::new(&mut self.context); + NeothesiaEvent::MainMenu { page } => { + let to = menu_scene::MenuScene::new(&mut self.context, page); self.game_scene = Box::new(to); } + NeothesiaEvent::MidiInput { channel, message } => { self.game_scene .midi_event(&mut self.context, channel, &message); diff --git a/neothesia/src/scene/menu_scene/iced_menu/main.rs b/neothesia/src/scene/menu_scene/iced_menu/main.rs index c0554a58..a7ce25a8 100644 --- a/neothesia/src/scene/menu_scene/iced_menu/main.rs +++ b/neothesia/src/scene/menu_scene/iced_menu/main.rs @@ -49,8 +49,8 @@ impl Page for MainPage { fn view<'a>(data: &'a Data, ctx: &Context) -> neothesia_iced_widgets::Element<'a, Self::Event> { let buttons = column![ - NeoBtn::new_with_label("Select File") - .on_press(Event::MidiFilePicker(MidiFilePickerMessage::open())) + NeoBtn::new_with_label("Select Song") + .on_press(Event::GoToPage(Step::SelectsongPage)) .width(Length::Fill) .height(Length::Fixed(80.0)), NeoBtn::new_with_label("Settings") @@ -106,10 +106,12 @@ impl Page for MainPage { left: 0.0, }); + let song_name = Song::get_clean_songname(song.file.name.clone()); + layout = layout.bottom( BarLayout::new() .center( - text(&song.file.name) + text(song_name) .width(Length::Fill) .vertical_alignment(Vertical::Center) .horizontal_alignment(Horizontal::Center), diff --git a/neothesia/src/scene/menu_scene/iced_menu/mod.rs b/neothesia/src/scene/menu_scene/iced_menu/mod.rs index 9b060a9f..951e1044 100644 --- a/neothesia/src/scene/menu_scene/iced_menu/mod.rs +++ b/neothesia/src/scene/menu_scene/iced_menu/mod.rs @@ -23,13 +23,17 @@ use crate::{ mod exit; mod main; mod page; +mod selectsong; mod settings; +pub mod stats; mod theme; mod tracks; use exit::ExitPage; use page::Page; +use selectsong::SelectsongPage; use settings::SettingsPage; +use stats::StatsPage; use tracks::TracksPage; type InputDescriptor = midi_io::MidiInputPort; @@ -43,6 +47,8 @@ pub enum Message { ExitPage(::Event), SettingsPage(::Event), TracksPage(::Event), + StatsPage(::Event), + SelectsongPage(::Event), } pub struct Data { @@ -63,9 +69,9 @@ pub struct AppUi { } impl AppUi { - pub fn new(_ctx: &Context) -> Self { + pub fn new(_ctx: &Context, goto: Step) -> Self { let mut page_stack = VecDeque::new(); - page_stack.push_front(Step::Main); + page_stack.push_front(goto); Self { page_stack, @@ -135,6 +141,14 @@ impl Program for AppUi { let msg = TracksPage::update(&mut self.data, msg, ctx); return self.handle_page_msg(ctx, msg); } + Message::StatsPage(msg) => { + let msg = StatsPage::update(&mut self.data, msg, ctx); + return self.handle_page_msg(ctx, msg); + } + Message::SelectsongPage(msg) => { + let msg = SelectsongPage::update(&mut self.data, msg, ctx); + return self.handle_page_msg(ctx, msg); + } Message::ExitPage(msg) => { let msg = ExitPage::update(&mut self.data, msg, ctx); return self.handle_page_msg(ctx, msg); @@ -162,6 +176,8 @@ impl Program for AppUi { Step::Main => MainPage::keyboard_input(event, ctx), Step::Settings => SettingsPage::keyboard_input(event, ctx), Step::TrackSelection => TracksPage::keyboard_input(event, ctx), + Step::SelectsongPage => SelectsongPage::keyboard_input(event, ctx), + Step::Stats => StatsPage::keyboard_input(event, ctx), } } @@ -175,6 +191,10 @@ impl Program for AppUi { Step::Main => MainPage::view(&self.data, ctx).map(Message::MainPage), Step::Settings => SettingsPage::view(&self.data, ctx).map(Message::SettingsPage), Step::TrackSelection => TracksPage::view(&self.data, ctx).map(Message::TracksPage), + Step::SelectsongPage => { + SelectsongPage::view(&self.data, ctx).map(Message::SelectsongPage) + } + Step::Stats => StatsPage::view(&self.data, ctx).map(Message::StatsPage), } } @@ -215,7 +235,9 @@ pub enum Step { Exit, Main, Settings, + Stats, TrackSelection, + SelectsongPage, } fn play(data: &Data, ctx: &mut Context) { diff --git a/neothesia/src/scene/menu_scene/iced_menu/selectsong.rs b/neothesia/src/scene/menu_scene/iced_menu/selectsong.rs new file mode 100644 index 00000000..65296c97 --- /dev/null +++ b/neothesia/src/scene/menu_scene/iced_menu/selectsong.rs @@ -0,0 +1,317 @@ +use iced_core::{ + alignment::{Horizontal, Vertical}, + Alignment, Length, Padding, +}; +use iced_widget::{button, column as col, container, row, vertical_space}; + +use rfd::FileDialog; + +use super::{ + centered_text, + page::{Page, PageMessage}, + theme, Data, Message, +}; +use crate::{context::Context, scene::menu_scene::icons, song::Song}; +use neothesia_iced_widgets::{BarLayout, Element, Layout, NeoBtn}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub enum Event { + GoBack, + OpenFolderPicker, + SetSongPath { last_opened_song: Option }, + Play, +} + +use crate::menu_scene::Step; + +pub struct SelectsongPage; + +impl Page for SelectsongPage { + type Event = Event; + + fn update(data: &mut Data, event: Event, ctx: &mut Context) -> PageMessage { + match event { + Event::GoBack => PageMessage::go_to_page(Step::Main), + Event::Play => PageMessage::go_to_page(Step::TrackSelection), + Event::OpenFolderPicker => { + let mut page_message = PageMessage::none(); + data.is_loading = true; + if let Some(folder) = FileDialog::new().pick_folder() { + // Save the selected folder to the context or data as needed + ctx.config.song_directory = Some(folder); + page_message = PageMessage::go_to_page(Step::SelectsongPage); + } + data.is_loading = false; + page_message + } + Event::SetSongPath { last_opened_song } => { + // Handle the event here, update the context or data accordingly + + match last_opened_song { + Some(song_path) => { + ctx.config.last_opened_song = Some(song_path.clone()); + match midi_file::MidiFile::new(&song_path) { + Ok(midi) => { + ctx.song = Some(Song::new(midi)); + // Trigger navigation or any other necessary action + PageMessage::go_to_page(Step::SelectsongPage) + } + Err(err) => { + log::error!("Failed to load MIDI file: {}", err); + // Handle the error here + // For now, let's return None as a placeholder + PageMessage::go_to_page(Step::SelectsongPage) + } + } + } + None => { + ctx.config.last_opened_song = None; + + PageMessage::go_to_page(Step::SelectsongPage) + } + } + } + } + } + + fn view<'a>(_data: &'a Data, ctx: &Context) -> Element<'a, Event> { + let dir_path = match dirs::home_dir() { + Some(mut path) => { + if let Some(folder) = &ctx.config.song_directory { + path.push(folder); + } + path + } + None => { + println!("Unable to determine home directory"); + let mut path = PathBuf::new(); + path.push("~/Music"); + path + } + }; + + let mut song_file_name = String::new(); + + if let Some(path_buf) = &ctx.config.last_opened_song { + if let Some(file_name) = path_buf.file_name() { + if let Some(name) = file_name.to_str() { + song_file_name = Song::get_clean_songname(name.to_string()); + } + } + } + + let mut elements = Vec::new(); + if let Ok(entries) = fs::read_dir(&dir_path) { + for entry in entries { + if let Ok(entry) = entry { + if let Some(file_name) = entry.file_name().to_str() { + if let Some(extension) = Path::new(file_name).extension() { + if extension == "mid" || extension == "midi" { + let song_name = Song::get_clean_songname(file_name.to_string()); + let button_color = if song_file_name == song_name { + iced_core::Color::from_rgb8(106, 0, 163) + } else { + iced_core::Color::from_rgb8(54, 0, 107) + }; + // Create a button with the song name + let button = button(centered_text(&song_name)) + .on_press(Event::SetSongPath { + last_opened_song: Some(entry.path().clone()), + }) + .style(theme::filelist_button(button_color)) + .width(10000); + + elements.push(button.into()); + } + } + } + } + } + } + + // Add the list into another scrollable for a responsive UI + let inner_scrollable = iced_widget::Scrollable::new( + iced_widget::Column::with_children(elements) + .spacing(5) + .align_items(Alignment::Start), + ) + .height(ctx.window_state.logical_size.height as u16 - 400) + .width(ctx.window_state.logical_size.width as u16 - 421); + + let inner_scrollable_element: Element<'_, Event> = inner_scrollable.into(); + + let column = iced_widget::scrollable(iced_widget::column(vec![inner_scrollable_element])); + + let mut elements = Vec::new(); + + let center_text = centered_text("Song list") + .size(20) + .width(Length::Fill) + .horizontal_alignment(Horizontal::Center); + + let center_text_container = container(center_text) + .width(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .padding(Padding { + top: 25.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }); + elements.push(center_text_container.into()); + + let center_text = centered_text(format!("Selected song: {}", song_file_name)) + .size(12) + .width(Length::Fill) + .horizontal_alignment(Horizontal::Center); + + let center_text_container = container(center_text) + .width(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .padding(Padding { + top: 25.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }); + elements.push(center_text_container.into()); + + elements.push( + col![vertical_space().height(Length::Fixed(10.0)), column] + .align_items(Alignment::Center) + .width(Length::Fill) + .into(), + ); + + let mut song_directory = String::new(); + + if let Some(_path_buf) = &ctx.config.song_directory { + if let Some(path_buf) = &ctx.config.song_directory { + song_directory = path_buf.to_string_lossy().to_string(); + } + } + let center_text = centered_text(format!("Midi directory path: {}", song_directory)) + .size(12) + .width(Length::Fill) + .horizontal_alignment(Horizontal::Center); + + let center_text_container = container(center_text) + .width(Length::Fill) + .align_x(Horizontal::Left) + .align_y(Vertical::Bottom) + .padding(Padding { + top: 80.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }); + elements.push(center_text_container.into()); + + let column = iced_widget::scrollable(iced_widget::column(elements)); + + let right = { + let play = NeoBtn::new( + icons::play_icon() + .size(30.0) + .vertical_alignment(Vertical::Center) + .horizontal_alignment(Horizontal::Center), + ) + .height(Length::Fixed(60.0)) + .min_width(80.0) + .on_press(Event::Play); + + if ctx.song.is_some() { + row![play] + } else { + row![] + } + .spacing(10) + .width(Length::Shrink) + .align_items(Alignment::Center) + }; + + let right = container(right) + .width(Length::Fill) + .align_x(Horizontal::Right) + .align_y(Vertical::Center) + .padding(Padding { + top: 0.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }); + + let left = { + let back = NeoBtn::new( + icons::left_arrow_icon() + .size(30.0) + .vertical_alignment(Vertical::Center) + .horizontal_alignment(Horizontal::Center), + ) + .height(Length::Fixed(60.0)) + .min_width(80.0) + .on_press(Event::GoBack); + + row![back].align_items(Alignment::Start) + }; + + let left = container(left) + .width(Length::Fill) + .align_x(Horizontal::Left) + .align_y(Vertical::Center) + .padding(Padding { + top: 0.0, + right: 10.0, + bottom: 10.0, + left: 10.0, + }); + + let center = { + let folderbtn = NeoBtn::new( + icons::folder_icon() + .size(30.0) + .vertical_alignment(Vertical::Center) + .horizontal_alignment(Horizontal::Center), + ) + .height(Length::Fixed(60.0)) + .min_width(80.0) + .on_press(Event::OpenFolderPicker); + + container(folderbtn) + .width(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .padding(Padding { + top: 0.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }) + }; + + Layout::new() + .body(column) + .bottom(BarLayout::new().left(left).center(center).right(right)) + .into() + } + + fn keyboard_input(event: &iced_runtime::keyboard::Event, _ctx: &Context) -> Option { + use iced_runtime::keyboard::{key::Named, Event, Key}; + + match event { + Event::KeyPressed { + key: Key::Named(key), + .. + } => match key { + Named::Enter => Some(Message::SelectsongPage(self::Event::Play)), + Named::Escape => Some(Message::GoToPage(Step::Main)), + _ => None, + }, + _ => None, + } + } +} diff --git a/neothesia/src/scene/menu_scene/iced_menu/stats.rs b/neothesia/src/scene/menu_scene/iced_menu/stats.rs new file mode 100644 index 00000000..b91b14ed --- /dev/null +++ b/neothesia/src/scene/menu_scene/iced_menu/stats.rs @@ -0,0 +1,238 @@ +use crate::{context::Context, scene::menu_scene::icons, song::Song}; +use chrono::{DateTime, Local}; +use iced_core::{ + alignment::{Horizontal, Vertical}, + Alignment, Length, Padding, +}; +use iced_widget::{column as col, container, row, vertical_space}; +use neothesia_core::gamesave::SavedStats; +use neothesia_iced_widgets::{BarLayout, Element, Layout, NeoBtn}; + +use super::{ + centered_text, + page::{Page, PageMessage}, + Data, Message, +}; + +#[derive(Debug, Clone)] +pub enum Event { + GoBack, + Play, +} + +use crate::menu_scene::Step; +pub struct StatsPage; + +impl Page for StatsPage { + type Event = Event; + + fn update(data: &mut Data, event: Event, ctx: &mut Context) -> PageMessage { + match event { + Event::GoBack => return PageMessage::go_to_page(Step::Main), + Event::Play => { + super::play(data, ctx); + } + } + + PageMessage::none() + } + + fn view<'a>(_data: &'a Data, ctx: &Context) -> Element<'a, Event> { + let mut songhistory = Vec::new(); + + let mut songname = String::new(); + if let Some(song) = ctx.song.as_ref() { + songname = Song::get_clean_songname(song.file.name.clone()) + } + + // Load saved stats for the current song + let sorted_stats = SavedStats::load_for_song(songname.clone()); + + // Populate data into tracks + for (index, stats) in sorted_stats.iter().enumerate() { + let scores = SavedStats::score_cooking(stats); + + //Sort by score, higher score first + + let datetime: DateTime = stats.date.into(); + let score = (index + 1) as u32; + let trophy_image = if score <= 3 { score } else { 0 }; + let card = neothesia_iced_widgets::StatsContainer::new() + .image(trophy_image) + .date(datetime.format("%d/%m/%y %H:%M:%S").to_string()) + .place(&(index + 1).to_string()) // Index starts from 1 + .score(scores) + .notes_hits(stats.notes_hit) + .notes_missed(stats.notes_missed) + .wrong_notes(stats.wrong_notes) + .correct_notes_duration(stats.correct_note_times); + songhistory.push(Vec::>::from(card)); + } + + // Collect all elements from songhistory into a single Vec + let mut all_elements = Vec::new(); + + for children in songhistory { + all_elements.extend(children); + } + + // Create a Scrollable widget with the collected elements + let scrollable = iced_widget::Scrollable::new( + iced_widget::Column::with_children(all_elements) + .spacing(10) + .align_items(Alignment::Start), + ) + .height(ctx.window_state.logical_size.height as u16 - 250); + + let mut elements = Vec::new(); + let scrollable_element: Element<'_, Event> = scrollable.into(); + + // Insert the header of the "List" and the Scrollable element "items" into the elements vector for a more list-like responsive UI + let mut songhistory_header = Vec::new(); + let first_place_card = neothesia_iced_widgets::StatsContainer::new() + .image(0) + .date("Date") + .place("Place") + .score("Score") + .notes_hits("Good Hits") + .notes_missed("Slow Hits") + .wrong_notes("Wrong notes") + .correct_notes_duration("Good Durations") + .header(true); + songhistory_header.push(Vec::>::from( + first_place_card, + )); + + for children in songhistory_header { + elements.extend(children); + } + elements.push(scrollable_element.into()); + + let column = iced_widget::scrollable(iced_widget::column(elements)); + + let mut elements = Vec::new(); + + let center_text = centered_text(songname) + .size(20) + .width(Length::Fill) + .horizontal_alignment(Horizontal::Center); + + let center_text_container = container(center_text) + .width(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .padding(Padding { + top: 25.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }); + elements.push(center_text_container.into()); + + elements.push( + col![vertical_space().height(Length::Fixed(10.0)), column] + .align_items(Alignment::Center) + .width(Length::Fill) + .into(), + ); + + let column = iced_widget::scrollable(iced_widget::column(elements)); + + let right = { + let play = NeoBtn::new( + icons::play_icon() + .size(30.0) + .vertical_alignment(Vertical::Center) + .horizontal_alignment(Horizontal::Center), + ) + .height(Length::Fixed(60.0)) + .min_width(80.0) + .on_press(Event::Play); + + if ctx.song.is_some() { + row![play] + } else { + row![] + } + .spacing(10) + .width(Length::Shrink) + .align_items(Alignment::Center) + }; + + let right = container(right) + .width(Length::Fill) + .align_x(Horizontal::Right) + .align_y(Vertical::Center) + .padding(Padding { + top: 0.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }); + + let left = { + let back = NeoBtn::new( + icons::left_arrow_icon() + .size(30.0) + .vertical_alignment(Vertical::Center) + .horizontal_alignment(Horizontal::Center), + ) + .height(Length::Fixed(60.0)) + .min_width(80.0) + .on_press(Event::GoBack); + + row![back].align_items(Alignment::Start) + }; + + let left = container(left) + .width(Length::Fill) + .align_x(Horizontal::Left) + .align_y(Vertical::Center) + .padding(Padding { + top: 0.0, + right: 10.0, + bottom: 10.0, + left: 10.0, + }); + + let center = { + container( + centered_text("Hit enter to play again") + .size(20) + .width(Length::Fill) + .horizontal_alignment(Horizontal::Center) + .vertical_alignment(Vertical::Center), + ) + .width(Length::Fill) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .padding(Padding { + top: 0.0, + right: 10.0, + bottom: 10.0, + left: 0.0, + }) + }; + + Layout::new() + .body(column) + .bottom(BarLayout::new().left(left).center(center).right(right)) + .into() + } + + fn keyboard_input(event: &iced_runtime::keyboard::Event, _ctx: &Context) -> Option { + use iced_runtime::keyboard::{key::Named, Event, Key}; + + match event { + Event::KeyPressed { + key: Key::Named(key), + .. + } => match key { + Named::Enter => Some(Message::StatsPage(self::Event::Play)), + Named::Escape => Some(Message::GoToPage(Step::Main)), + _ => None, + }, + _ => None, + } + } +} diff --git a/neothesia/src/scene/menu_scene/iced_menu/theme.rs b/neothesia/src/scene/menu_scene/iced_menu/theme.rs index 4de0425c..ec0bd44e 100644 --- a/neothesia/src/scene/menu_scene/iced_menu/theme.rs +++ b/neothesia/src/scene/menu_scene/iced_menu/theme.rs @@ -139,7 +139,7 @@ impl iced_style::button::StyleSheet for RoundButtonStyle { pub fn _checkbox() -> iced_style::theme::Checkbox { iced_style::theme::Checkbox::Custom(Box::new(CheckboxStyle)) } - +#[allow(dead_code)] struct CheckboxStyle; impl iced_style::checkbox::StyleSheet for CheckboxStyle { @@ -224,3 +224,48 @@ impl iced_style::toggler::StyleSheet for TogglerStyle { } } } + +pub fn filelist_button(color: Color) -> iced_style::theme::Button { + iced_style::theme::Button::Custom(Box::new(FilelistButton::new(color))) +} + +struct FilelistButton { + color: Color, +} + +impl FilelistButton { + pub fn new(color: Color) -> Self { + Self { color } + } +} + +impl iced_style::button::StyleSheet for FilelistButton { + type Style = iced_style::Theme; + + fn active(&self, _style: &Self::Style) -> button::Appearance { + button::Appearance { + text_color: Color::WHITE, + border: Border { + width: 0.0, // + + radius: Radius::from(0.0), + color: Color::TRANSPARENT, + }, + + background: Some(iced_core::Background::Color(self.color)), + ..Default::default() + } + } + + fn hovered(&self, style: &Self::Style) -> button::Appearance { + let mut active = self.active(style); + + if let Some(iced_core::Background::Color(ref mut color)) = active.background { + color.r = (color.r + 0.05).min(1.0); // Slightly lighter on hover + color.g = (color.g + 0.05).min(1.0); + color.b = (color.b + 0.05).min(1.0); + } + + active + } +} diff --git a/neothesia/src/scene/menu_scene/iced_menu/tracks.rs b/neothesia/src/scene/menu_scene/iced_menu/tracks.rs index 8711a45e..b2f6473f 100644 --- a/neothesia/src/scene/menu_scene/iced_menu/tracks.rs +++ b/neothesia/src/scene/menu_scene/iced_menu/tracks.rs @@ -59,8 +59,16 @@ impl Page for TracksPage { fn view<'a>(_data: &'a Data, ctx: &Context) -> Element<'a, Event> { let mut tracks = Vec::new(); + if let Some(song) = ctx.song.as_ref() { - for track in song.file.tracks.iter().filter(|t| !t.notes.is_empty()) { + let mut piano_count = 0; + for track in song + .file + .tracks + .iter() + .filter(|t| !t.notes.is_empty()) + .rev() + { let config = &song.config.tracks[track.track_id]; let visible = config.visible; @@ -78,18 +86,36 @@ impl Page for TracksPage { let color = &ctx.config.color_schema[color_id].base; iced_core::Color::from_rgb8(color.0, color.1, color.2) }; - + let instrument_id = track + .programs + .last() + .map(|p| p.program as usize) + .unwrap_or(0); + let mut instrument_card_id = instrument_id; let name = if track.has_drums && !track.has_other_than_drums { + instrument_card_id = 797979; "Percussion" } else { - let instrument_id = track - .programs - .last() - .map(|p| p.program as usize) - .unwrap_or(0); midi_file::INSTRUMENT_NAMES[instrument_id] }; + // Check if the instrument is a Piano (id 0, 1, or 2) + let hand_info = if instrument_id <= 7 && name != "Percussion" { + // Increment the piano counter + piano_count += 1; + + // Determine if it's the first or second occurrence of Piano + if piano_count % 2 == 0 { + instrument_card_id = 696969; + "/ Right Hand" + } else { + "/ Left Hand" + } + } else { + // For other instruments, no hand information + "" + }; + let body = neothesia_iced_widgets::SegmentButton::new() .button( "Mute", @@ -108,8 +134,9 @@ impl Page for TracksPage { let card = neothesia_iced_widgets::TrackCard::new() .title(name) - .subtitle(format!("{} Notes", track.notes.len())) + .subtitle(format!("{} Notes {}", track.notes.len(), hand_info)) .track_color(color) + .instrument_id(instrument_card_id) .body(body); let card = if track.has_drums && !track.has_other_than_drums { diff --git a/neothesia/src/scene/menu_scene/icons.rs b/neothesia/src/scene/menu_scene/icons.rs index a0bb5a89..3b35cf0b 100644 --- a/neothesia/src/scene/menu_scene/icons.rs +++ b/neothesia/src/scene/menu_scene/icons.rs @@ -15,3 +15,6 @@ pub fn note_list_icon<'a>() -> iced_widget::Text<'a, Theme, Renderer> { pub fn left_arrow_icon<'a>() -> iced_widget::Text<'a, Theme, Renderer> { iced_widget::text('\u{f12f}').font(ICONS) } +pub fn folder_icon<'a>() -> iced_widget::Text<'a, Theme, Renderer> { + iced_widget::text('\u{F3D1}').font(ICONS) +} diff --git a/neothesia/src/scene/menu_scene/mod.rs b/neothesia/src/scene/menu_scene/mod.rs index bcff1588..aef12dd0 100644 --- a/neothesia/src/scene/menu_scene/mod.rs +++ b/neothesia/src/scene/menu_scene/mod.rs @@ -18,6 +18,7 @@ use crate::{ }, scene::Scene, }; +pub use iced_menu::Step; type Renderer = iced_wgpu::Renderer; @@ -30,8 +31,9 @@ pub struct MenuScene { } impl MenuScene { - pub fn new(ctx: &mut Context) -> Self { - let menu = AppUi::new(ctx); + pub fn new(ctx: &mut Context, goto: Step) -> Self { + let menu = AppUi::new(ctx, goto); + let iced_state = iced_state::State::new(menu, ctx.iced_manager.viewport.logical_size(), ctx); diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index c1f23a6b..0a25d88e 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -1,11 +1,12 @@ -use midi_file::midly::{num::u4, MidiMessage}; - use crate::{ output_manager::OutputConnection, song::{PlayerConfig, Song}, }; +use midi_file::midly::{num::u4, MidiMessage}; +use neothesia_core::gamesave::{SavedStats, SongStats}; +use std::time::SystemTime; use std::{ - collections::{HashSet, VecDeque}, + collections::HashMap, time::{Duration, Instant}, }; @@ -16,19 +17,56 @@ pub struct MidiPlayer { play_along: PlayAlong, } +pub struct NoteStats { + song_name: String, + notes_missed: usize, + notes_hit: usize, + wrong_notes: usize, + note_durations: Vec, +} +#[derive(Debug)] +pub struct NoteDurations { + user_note_dur: usize, + file_note_dur: usize, +} +impl Default for NoteStats { + fn default() -> Self { + NoteStats { + song_name: String::new(), + notes_missed: 0, + notes_hit: 0, + wrong_notes: 0, + note_durations: Vec::new(), + } + } +} + +impl Default for NoteDurations { + fn default() -> Self { + NoteDurations { + user_note_dur: 0, + file_note_dur: 0, + } + } +} + impl MidiPlayer { pub fn new( output: OutputConnection, song: Song, user_keyboard_range: piano_math::KeyboardRange, ) -> Self { + let mut user_stats = NoteStats::default(); + + user_stats.song_name = Song::get_clean_songname(song.file.name.clone()); + let mut player = Self { playback: midi_file::PlaybackState::new( Duration::from_secs(3), song.file.tracks.clone(), ), output, - play_along: PlayAlong::new(user_keyboard_range), + play_along: PlayAlong::new(user_keyboard_range, user_stats), song, }; // Let's reset programs, @@ -39,7 +77,12 @@ impl MidiPlayer { player } - + pub fn on_finish(&mut self, callback: F) + where + F: Fn() + 'static, + { + self.play_along.set_on_finish(callback); + } pub fn song(&self) -> &Song { &self.song } @@ -50,6 +93,9 @@ impl MidiPlayer { pub fn update(&mut self, delta: Duration) -> Vec<&midi_file::MidiEvent> { self.play_along.update(); + let playback_time = self.playback.time(); + let playback_length = self.playback.length(); + let events = self.playback.update(delta); events.iter().for_each(|event| { @@ -61,9 +107,6 @@ impl MidiPlayer { .midi_event(u4::new(event.channel), event.message); } PlayerConfig::Human => { - // Let's play the sound, in case the user does not want it they can just set - // no-output output in settings - // TODO: Perhaps play on midi-in instead self.output .midi_event(u4::new(event.channel), event.message); self.play_along @@ -73,6 +116,11 @@ impl MidiPlayer { } }); + // Check if the song has finished based on the playback time + if playback_time >= playback_length { + self.play_along.finished(); + } + events } @@ -198,69 +246,204 @@ pub enum MidiEventSource { struct UserPress { timestamp: Instant, note_id: u8, + time_key_up: Option, + occurrence: usize, } -#[derive(Debug)] pub struct PlayAlong { user_keyboard_range: piano_math::KeyboardRange, - required_notes: HashSet, - + required_notes: Vec, + finished: bool, // List of user key press events that happened in last 500ms, // used for play along leeway logic - user_pressed_recently: VecDeque, + user_pressed_recently: Vec, + user_stats: NoteStats, // struct to finalize the stats log + + user_notes: Vec, // log all user notes to get durrations + file_notes: Vec, // log all file notes to compare against user + occurrence: HashMap, // Keeping user to file log incremental pointer rewind immune + on_finish: Option>, } impl PlayAlong { - fn new(user_keyboard_range: piano_math::KeyboardRange) -> Self { + fn new(user_keyboard_range: piano_math::KeyboardRange, user_stats: NoteStats) -> Self { Self { user_keyboard_range, required_notes: Default::default(), user_pressed_recently: Default::default(), + + occurrence: HashMap::new(), + finished: Default::default(), + + user_notes: Default::default(), + file_notes: Default::default(), + on_finish: None, + user_stats, } } + pub fn set_on_finish(&mut self, callback: F) + where + F: Fn() + 'static, + { + self.on_finish = Some(Box::new(callback)); + } fn update(&mut self) { - // Instead of calling .elapsed() per item let's fetch `now` once, and subtract it ourselves let now = Instant::now(); + let threshold = Duration::from_millis(500); + + // Track the count of items before retain + let count_before = self.user_pressed_recently.len(); - while let Some(item) = self.user_pressed_recently.front_mut() { + // Retain only the items that are within the threshold + self.user_pressed_recently.retain(|item| { let elapsed = now - item.timestamp; + elapsed <= threshold + }); - // If older than 500ms - if elapsed.as_millis() > 500 { - self.user_pressed_recently.pop_front(); - } else { - // All subsequent items will by younger than front item, so we can break - break; - } + // Calculate the count of deleted items, + // Either pressed extremely too earlier than should have, or a wrong note, both cases is wrong note, we don't sub these. + let count_deleted = count_before - self.user_pressed_recently.len(); + if count_deleted > 0 { + self.user_stats.wrong_notes += count_deleted; } } fn user_press_key(&mut self, note_id: u8, active: bool) { let timestamp = Instant::now(); + let occurrence = self.occurrence.entry(note_id).or_insert(0); if active { - self.user_pressed_recently - .push_back(UserPress { timestamp, note_id }); - self.required_notes.remove(¬e_id); + // Check if note_id has reached required_notes, then remove it now, + + if let Some(index) = self + .required_notes + .iter() + .position(|item| item.note_id == note_id) + { + if timestamp + .duration_since(self.required_notes[index].timestamp) + .as_millis() + > 160 + { + //160 to forgive touching the bottom + + self.user_stats.notes_missed += 1; + } else { + self.user_stats.notes_hit += 1; + } + self.required_notes.remove(index); + + self.user_notes.push(UserPress { + timestamp, + note_id, + occurrence: *occurrence, + time_key_up: None, + }); + } else { + // Haven't reached required_notes yet, place a possible later validation in 'user_pressed_recently' / file_press_key() + if let Some(item) = self + .user_pressed_recently + .iter_mut() + .find(|item| item.note_id == note_id) + { + // already exists, update timestamp + item.timestamp = timestamp; + self.user_stats.wrong_notes += 1; + } else { + // Not found, push a new UserPress + self.user_pressed_recently.push(UserPress { + timestamp, + note_id, + occurrence: *occurrence, + time_key_up: None, + }); + } + } + } else { + // Update user_notes log time_key_up + if let Some(item) = self + .user_notes + .iter_mut() + .rev() + .find(|item| item.note_id == note_id && item.occurrence == *occurrence) + { + item.time_key_up = Some(Instant::now()); + } } } fn file_press_key(&mut self, note_id: u8, active: bool) { + let occurrence = self.occurrence.entry(note_id).or_insert(0); + let timestamp = Instant::now(); if active { - if let Some((id, _)) = self + *occurrence += 1; + + // Check if note got pressed earlier 500ms (user_pressed_recently) + if let Some(item) = self .user_pressed_recently .iter() - .enumerate() - .find(|(_, item)| item.note_id == note_id) + .find(|item| item.note_id == note_id) { - self.user_pressed_recently.remove(id); + // Note was pressed earlier, remove it from user_pressed_recently + self.user_stats.notes_hit += 1; + + // log user_note by user_pressed_recently.timestamp as keydown value, update occurence + self.user_notes.push(UserPress { + timestamp: item.timestamp, + note_id, + occurrence: *occurrence, + time_key_up: item.time_key_up, + }); + self.user_pressed_recently + .retain(|item| item.note_id != note_id); } else { - self.required_notes.insert(note_id); + // Player never pressed that note, let it reach required_notes, check if note_id already exists in required_notes, update timestamp else push. + // Catch possible clone-note velocity overlay, update the new occurence and exit the function + + if let Some(item) = self + .file_notes + .iter_mut() + .find(|item| item.note_id == note_id && item.time_key_up.is_none()) + { + item.occurrence = *occurrence; + return; // Everything bellow already done before by its clone + } + if let Some(user_press) = self + .required_notes + .iter_mut() + .find(|item| item.note_id == note_id) + { + // Update the timestamp of the existing note + user_press.timestamp = timestamp; + } else { + self.required_notes.push(UserPress { + timestamp, + note_id, + occurrence: *occurrence, + time_key_up: None, + }); + } } + + // Log the note + self.file_notes.push(UserPress { + timestamp, + note_id, + occurrence: *occurrence, // Set the occurrence count + time_key_up: None, + }); } else { - self.required_notes.remove(¬e_id); + // update time_key_up + if let Some(item) = self + .file_notes + .iter_mut() + .rev() + .find(|item| item.note_id == note_id && item.occurrence == *occurrence) + { + item.time_key_up = Some(timestamp); + } } } @@ -284,9 +467,94 @@ impl PlayAlong { } pub fn clear(&mut self) { - self.required_notes.clear() + self.required_notes.clear(); + // Remove from the file log, notes that left pressed down with no key up yet (rewinding a non-played part) + self.file_notes.retain(|item| item.time_key_up.is_some()); + self.user_pressed_recently.clear(); } + pub fn finished(&mut self) { + if !self.finished { + // Loop through user_notes and file_notes and match entries with the same occurrence[note] = num + for user_note in &self.user_notes { + for file_note in &self.file_notes { + if user_note.occurrence == file_note.occurrence + && user_note.note_id == file_note.note_id + { + // Subtract timestamp from time_key_up to get total seconds + let user_note_dur = match (user_note.timestamp, user_note.time_key_up) { + (start, Some(end)) => end.duration_since(start).as_secs(), + _ => 0, + }; + let file_note_dur = match (file_note.timestamp, file_note.time_key_up) { + (start, Some(end)) => end.duration_since(start).as_secs(), + _ => 0, + }; + + // Add this information to user_stats.note_durations + let note_duration = NoteDurations { + user_note_dur: user_note_dur as usize, + file_note_dur: file_note_dur as usize, + }; + self.user_stats.note_durations.push(note_duration); + } + } + } + + // Loop through user_stats.note_durations items, compare user_note_dur to file_note_dur + let mut correct_note_times = 0; + let mut wrong_note_times = 0; + // make it relaxed, Lower Bound: 87% of the file's note duration, Upper Bound: 108% of the file's note duration. + for duration in &self.user_stats.note_durations { + // Calculate the lower and upper bounds for a "correct" duration + let lower_bound = duration.file_note_dur as f64 * 0.87; + let upper_bound = duration.file_note_dur as f64 * 1.08; + + // Increment correctNoteTimes if it is within the bounds, otherwise increment wrongNoteTimes + if (duration.user_note_dur as f64) >= lower_bound + && (duration.user_note_dur as f64) <= upper_bound + { + correct_note_times += 1; + } else { + wrong_note_times += 1; + } + } + + // Save only if the user pressed something, it wasn't a full rewind OR [AUTO] + + if self.user_stats.notes_hit + + self.user_stats.notes_missed + + self.user_stats.wrong_notes + > 0 + { + let mut saved_stats = SavedStats::load().unwrap_or_default(); + + // Create the new stats object + let new_stats = SongStats { + song_name: self.user_stats.song_name.clone(), + correct_note_times, + wrong_note_times, + notes_missed: self.user_stats.notes_missed as u32, + notes_hit: self.user_stats.notes_hit as u32, + wrong_notes: self.user_stats.wrong_notes as u32, + date: SystemTime::now(), + }; + // + // Push the new stats object to the existing SavedStats + saved_stats.songs.push(new_stats); + + // Save the modified SavedStats object + saved_stats.save(); + } + + // better save right here keeping things simple, since stats could be loaded from song list when select folder for a file list is implemented + + if let Some(callback) = &self.on_finish { + callback(); // Call on finish callback + } + self.finished = true; + } + } pub fn are_required_keys_pressed(&self) -> bool { self.required_notes.is_empty() } diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index 446128d1..8cdc91ce 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -26,6 +26,7 @@ use toast_manager::ToastManager; mod animation; mod top_bar; +use crate::menu_scene::Step; const EVENT_CAPTURED: bool = true; const EVENT_IGNORED: bool = false; @@ -75,11 +76,17 @@ impl PlayingScene { keyboard_layout.clone(), ); - let player = MidiPlayer::new( + let mut player = MidiPlayer::new( ctx.output_manager.connection().clone(), song, keyboard_layout.range.clone(), ); + let weak_ctx = ctx.proxy.clone(); + player.on_finish(move || { + weak_ctx + .send_event(NeothesiaEvent::MainMenu { page: Step::Stats }) + .ok(); + }); waterfall.update(&ctx.gpu.queue, player.time_without_lead_in()); Self { @@ -222,7 +229,9 @@ fn handle_back_button(ctx: &Context, event: &WindowEvent) { ); if is_back_event { - ctx.proxy.send_event(NeothesiaEvent::MainMenu).ok(); + ctx.proxy + .send_event(NeothesiaEvent::MainMenu { page: Step::Main }) + .ok(); } } diff --git a/neothesia/src/scene/playing_scene/top_bar.rs b/neothesia/src/scene/playing_scene/top_bar.rs index 8d0058b2..04607734 100644 --- a/neothesia/src/scene/playing_scene/top_bar.rs +++ b/neothesia/src/scene/playing_scene/top_bar.rs @@ -18,8 +18,8 @@ use super::{ }; mod button; +use crate::menu_scene::Step; use button::Button; - #[derive(Default, Clone, Copy)] enum Element { StartTick, @@ -171,7 +171,9 @@ impl TopBar { scene.top_bar.settings_active = !scene.top_bar.settings_active; } Element::BackButton => { - ctx.proxy.send_event(NeothesiaEvent::MainMenu).ok(); + ctx.proxy + .send_event(NeothesiaEvent::MainMenu { page: Step::Main }) + .ok(); return EVENT_CAPTURED; } Element::StartTick | Element::EndTick => { diff --git a/neothesia/src/song.rs b/neothesia/src/song.rs index f9a6b173..f463a33f 100644 --- a/neothesia/src/song.rs +++ b/neothesia/src/song.rs @@ -49,4 +49,15 @@ impl Song { let config = SongConfig::new(&file.tracks); Self { file, config } } + pub fn get_clean_songname(filename: String) -> String { + let lower_filename = filename.to_lowercase(); + + if lower_filename.ends_with(".midi") { + filename[..filename.len() - 5].to_string() + } else if lower_filename.ends_with(".mid") { + filename[..filename.len() - 4].to_string() + } else { + filename.to_string() + } + } }