diff --git a/audio_engine/src/lib.rs b/audio_engine/src/lib.rs index 96a222fd..2b184b56 100644 --- a/audio_engine/src/lib.rs +++ b/audio_engine/src/lib.rs @@ -7,4 +7,6 @@ mod sample_pack; pub use player::{PlayerHandle, SamplePlayer}; pub use sample::{SampleBuffer, TrackerSample}; pub use sample_pack::SamplePack; +pub use xmodits_lib::Sample as Metadata; pub use xmodits_lib::Sample; + diff --git a/src/screen/sample_player.rs b/src/screen/sample_player.rs index 9b992888..1cda912e 100644 --- a/src/screen/sample_player.rs +++ b/src/screen/sample_player.rs @@ -1,7 +1,7 @@ #[cfg(feature = "audio")] mod preview_manager; #[cfg(feature = "audio")] -mod preview_window; +mod instance; #[cfg(feature = "audio")] pub use preview_manager::*; diff --git a/src/screen/sample_player/instance.rs b/src/screen/sample_player/instance.rs new file mode 100644 index 00000000..e4ea27d1 --- /dev/null +++ b/src/screen/sample_player/instance.rs @@ -0,0 +1,411 @@ +mod sample; + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use audio_engine::{PlayerHandle, TrackerSample}; +use iced::widget::{button, checkbox, column, progress_bar, row, scrollable, slider, text, Space}; +use iced::{command, Alignment, Command, Length}; + +use crate::screen::main_panel::Entries; +use crate::widget::helpers::{centered_container, fill_container, warning}; +use crate::widget::waveform_view::{Marker, WaveData}; +use crate::widget::{Button, Collection, Container, Element, Row, WaveformViewer}; +use crate::{icon, theme}; + +use sample::{SamplePack, SampleResult}; + +const MAX_VOLUME: f32 = 1.25; +const MIN_VOLUME: f32 = 0.0; + +#[derive(Debug, Clone)] +pub enum Message { + Select(usize), + Play, + Pause, + Stop, + SetPlayOnSelection(bool), + SetVolume(f32), + AddEntry(PathBuf), + Loaded(Result), + Progress(Option), +} + +/// The state of the sample player +pub enum State { + /// Nothing has been loaded + None, + /// Currently loading + Loading, + /// Could not load samples + Failed { path: PathBuf, reason: String }, + /// Successfully loaded samples + Loaded { + selected: Option, + samples: SamplePack, + }, +} + +#[derive(Debug, Clone, Copy)] +pub struct MediaSettings { + pub volume: f32, + pub play_on_selection: bool, + pub enable_looping: bool, +} + +impl Default for MediaSettings { + fn default() -> Self { + Self { + volume: 1.0, + play_on_selection: true, + enable_looping: false, + } + } +} + +/// Sample player instance +pub struct Instance { + state: State, + player: PlayerHandle, + settings: MediaSettings, + pub hovered: bool, + progress: Option, +} + +impl Instance { + pub fn new(player: PlayerHandle, path: PathBuf) -> (Self, Command) { + let mut instance = Self::new_empty(player); + let command = instance.load_samples(path); + + (instance, command) + } + + pub fn new_empty(player: PlayerHandle) -> Self { + Self { + state: State::None, + player, + settings: MediaSettings::default(), + hovered: false, + progress: None, + } + } + + pub fn settings(mut self, settings: MediaSettings) -> Self { + self.settings = settings; + self + } + + pub fn update(&mut self, message: Message, entries: &mut Entries) -> Command { + match message { + Message::Select(index) => { + if let State::Loaded { selected, .. } = &mut self.state { + *selected = Some(index); + + self.player.stop(); + + if self.settings.play_on_selection { + return self.play_selected(); + } + } + } + Message::Play => return self.play_selected(), + Message::Pause => self.player.pause(), + Message::Stop => self.player.stop(), + Message::SetPlayOnSelection(toggle) => self.settings.play_on_selection = toggle, + Message::AddEntry(path) => entries.add(path), + Message::Loaded(result) => { + self.state = match result { + Ok(samples) => State::Loaded { + selected: None, + samples, + }, + Err((path, reason)) => State::Failed { path, reason }, + } + } + Message::SetVolume(volume) => { + self.player.set_volume(volume); + self.settings.volume = volume; + } + Message::Progress(p) => self.progress = p, + } + Command::none() + } + + pub fn view(&self, entries: &Entries) -> Element { + let info = fill_container(self.view_sample_info()) + .padding(8) + .style(theme::Container::Black); + + let top_left = column![info, self.media_buttons()].spacing(5).width(Length::Fill); + + let top_right_controls = { + let add_path_button = self.loaded_path().and_then(|path| { + let button = || button("Add to Entries").on_press(Message::AddEntry(path.to_owned())); + (!entries.contains(path)).then(button) + }); + + let no_button_spacing = add_path_button + .is_none() + .then_some(Space::with_height(Length::Fixed(27.0))); + + let play_on_selection_checkbox = checkbox( + "Play on Selection", + self.settings.play_on_selection, + Message::SetPlayOnSelection, + ) + .style(theme::CheckBox::Inverted); + + row![play_on_selection_checkbox, Space::with_width(Length::Fill)] + .push_maybe(no_button_spacing) + .push_maybe(add_path_button) + .spacing(5) + .align_items(Alignment::Center) + }; + + let sample_list = fill_container(self.view_samples()) + .padding(8) + .style(theme::Container::Black); + + let top_right = column![sample_list, top_right_controls] + .spacing(5) + .width(Length::Fill); + + let waveform_viewer = self + .view_waveform() + .marker_maybe(self.progress.map(Marker)) + .width(Length::Fill) + .height(Length::FillPortion(2)); + + let progress = progress_bar(0.0..=1.0, self.progress.unwrap_or_default()) + .height(5.0) + .style(theme::ProgressBar::Dark); + + let warning = warning(|| false, "WARNING - This sample is most likely static noise."); + + let top_half = row![top_left, top_right] + .height(Length::FillPortion(3)) + .spacing(5); + + let main = column![top_half, waveform_viewer, progress] + .push_maybe(warning) + .spacing(5); + + fill_container(main) + .style(theme::Container::Hovered(self.hovered)) + .padding(15) + .into() + } + + pub fn matches_path(&self, module_path: &Path) -> bool { + match &self.state { + State::Failed { path, .. } => path == module_path, + State::Loaded { samples, .. } => samples.path() == module_path, + _ => false, + } + } + + pub fn load_samples(&mut self, module_path: PathBuf) -> Command { + let load = |state: &mut State, path: PathBuf| { + *state = State::Loading; + self.player.stop(); + return load_samples(path); + }; + + match &self.state { + State::None => load(&mut self.state, module_path), + State::Loading => Command::none(), + _ => match self.matches_path(&module_path) { + true => Command::none(), + false => load(&mut self.state, module_path), + }, + } + } + + pub fn title(&self) -> String { + match &self.state { + State::None => "No samples loaded!".into(), + State::Loading => "Loading...".into(), + State::Failed { path, .. } => format!("Failed to open {}", path.display()), + State::Loaded { samples, .. } => format!("Loaded: \"{}\"", samples.name()), + } + } + + pub fn play_selected(&self) -> Command { + match &self.state { + State::Loaded { + selected, samples, .. + } => match selected.and_then(|index| samples.tracker_sample(index)) { + Some(sample) => play_sample(&self.player, sample), + None => Command::none(), + }, + _ => Command::none(), + } + } + + pub fn loaded_path(&self) -> Option<&Path> { + match &self.state { + State::Loaded { samples, .. } => Some(samples.path()), + _ => None, + } + } + + /// top left quadrant + fn view_sample_info(&self) -> Element { + match &self.state { + State::None => centered_container("Nothing Loaded...").into(), + State::Loading => centered_container("Loading...").into(), + State::Failed { reason, .. } => centered_container(text(reason)).into(), + State::Loaded { + selected, samples, .. + } => match selected { + Some(index) => samples.view_sample_info(*index), + None => centered_container("Nothing selected...").into(), + }, + } + } + + /// List out the samples + fn view_samples(&self) -> Element { + match &self.state { + State::None => centered_container("Drag and drop a module to preview").into(), + State::Loading => centered_container("Loading...").into(), + State::Failed { .. } => centered_container("ERROR").into(), + State::Loaded { samples, .. } => { + let samples = samples + .inner() + .iter() + .enumerate() + .map(|(index, result)| result.view_sample(index)) + .collect(); + + let content = column(samples).spacing(10).padding(4); + + scrollable(content).into() + } + } + } + + fn view_waveform(&self) -> WaveformViewer { + WaveformViewer::new_maybe(match &self.state { + State::Loaded { + selected, samples, .. + } => selected.and_then(|index| samples.waveform(index)), + _ => None, + }) + } + + fn media_buttons(&self) -> Element { + let media_controls = media_button([ + (icon::play().size(18), Message::Play), + (icon::stop().size(18), Message::Stop), + (icon::pause().size(18), Message::Pause), + // (icon::repeat().size(18), Message::Stop), + ]); + + let volume = text(format!("Volume: {}%", (self.settings.volume * 100.0).round())); + let volume_slider = column![volume] + .push(slider(MIN_VOLUME..=MAX_VOLUME, self.settings.volume, Message::SetVolume).step(0.01)) + .align_items(Alignment::Start); + + Container::new(row![media_controls, volume_slider].spacing(8)) + .padding(8) + .style(theme::Container::Black) + .width(Length::Fill) + .height(Length::Shrink) + .center_x() + .into() + } +} + +fn media_button<'a, Label, R, Message>(rows: R) -> Element<'a, Message> +where + Message: Clone + 'a, + Label: Into>, + R: IntoIterator, +{ + let mut media_row: Row<'a, Message> = Row::new().spacing(4.0); + let elements: Vec<(Label, Message)> = rows.into_iter().collect(); + let end_indx = elements.len() - 1; + + for (idx, (label, message)) in elements.into_iter().enumerate() { + let style = if idx == 0 { + theme::Button::MediaStart + } else if idx == end_indx { + theme::Button::MediaEnd + } else { + theme::Button::MediaMiddle + }; + let button = Button::new(label).padding(8.0).on_press(message).style(style); + media_row = media_row.push(button); + } + + media_row.into() +} + +const PLAY_CURSOR_FPS: f32 = 60.0; + +fn play_sample(handle: &PlayerHandle, source: TrackerSample) -> Command { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + handle.stop(); + handle.play_with_callback(source, move |sample: &TrackerSample, duration: &mut Instant| { + let fps_interval = Duration::from_millis(((1.0 / PLAY_CURSOR_FPS) * 1000.0).round() as u64); + + if duration.elapsed() > fps_interval { + *duration = Instant::now(); + let progress = sample.frame() as f32 / sample.buf.frames() as f32; + + let _ = sender.send(progress); + } + }); + + command::channel(256, |mut s| async move { + while let Some(new_progress) = receiver.recv().await { + let _ = s.try_send(Message::Progress(Some(new_progress))); + } + let _ = s.try_send(Message::Progress(None)); + }) +} + +fn load_samples(path: PathBuf) -> Command { + Command::perform( + async { + let path_copy = path.clone(); + + tokio::task::spawn_blocking(move || { + const MAX_SIZE: u64 = 40 * 1024 * 1024; + + let mut file = std::fs::File::open(&path)?; + + if file.metadata()?.len() > MAX_SIZE { + return Err(xmodits_lib::Error::io_error("File size exceeds 40 MB").unwrap_err()); + } + + let module = xmodits_lib::load_module(&mut file)?; + let sample_pack = audio_engine::SamplePack::build(&*module); + let name = sample_pack.name; + + let samples = sample_pack + .samples + .into_iter() + .map(|result| match result { + Ok((metadata, buffer)) => { + let peaks = buffer.buf.peaks(Duration::from_millis(5)); + let waveform = WaveData::from(peaks); + SampleResult::Valid { + metadata, + buffer, + waveform, + } + } + Err(error) => SampleResult::Invalid(error.to_string()), + }) + .collect(); + Ok(SamplePack::new(name, path, samples)) + }) + .await + .unwrap() + .map_err(|e| (path_copy, e.to_string())) + }, + Message::Loaded, + ) +} diff --git a/src/screen/sample_player/instance/sample.rs b/src/screen/sample_player/instance/sample.rs new file mode 100644 index 00000000..806a3c05 --- /dev/null +++ b/src/screen/sample_player/instance/sample.rs @@ -0,0 +1,182 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use crate::icon; +use crate::theme; +use crate::widget::helpers::centered_container; +use crate::widget::waveform_view::WaveData; +use crate::widget::{Button, Collection, Container, Element, Row}; + +use audio_engine; + +use audio_engine::TrackerSample; +use iced::widget::{button, horizontal_rule, row, text, Space, column}; +use iced::{Alignment, Length}; + +use super::Message; + + +#[derive(Debug, Clone)] +pub struct SamplePack { + name: String, + path: PathBuf, + samples: Vec +} + +impl SamplePack { + pub fn new(name: String, path: PathBuf, samples: Vec) -> Self { + Self { + name, + path, + samples + } + } + + pub fn inner(&self) -> &[SampleResult] { + &self.samples + } + + pub fn waveform(&self, index: usize) -> Option<&WaveData> { + self.samples.get(index).and_then(SampleResult::waveform) + } + + pub fn view_sample_info(&self, index: usize) -> Element { + self.inner()[index].view_sample_info() + } + + pub fn tracker_sample(&self, index: usize) -> Option { + self.inner().get(index).and_then(SampleResult::tracker_sample) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn name(&self) -> &str { + &self.name + } +} + +#[derive(Debug, Clone)] +pub enum SampleResult { + Invalid(String), + Valid { + metadata: audio_engine::Metadata, + buffer: TrackerSample, + waveform: WaveData, + }, +} + +impl SampleResult { + pub fn waveform(&self) -> Option<&WaveData> { + match self { + SampleResult::Invalid(_) => None, + SampleResult::Valid { waveform, .. } => Some(waveform), + } + } + + pub fn title(&self) -> String { + match self { + SampleResult::Invalid(_) => "ERROR".into(), + SampleResult::Valid { metadata, .. } => metadata.filename_pretty().into(), + } + } + + pub fn is_invalid(&self) -> bool { + matches!(self, Self::Invalid(_)) + } + + pub fn tracker_sample(&self) -> Option { + match &self { + SampleResult::Valid { buffer,.. } => Some(buffer.clone()), + _ => None + } + } + + pub fn view_sample(&self, index: usize) -> Element { + let error_icon = || { + row![] + .push(Space::with_width(Length::Fill)) + .push(icon::warning()) + .align_items(iced::Alignment::Center) + }; + + let title = row![] + .push(text(match self.title() { + title if title.is_empty() => format!("{}", index + 1), + title => format!("{} - {}", index + 1, title), + })) + .push_maybe(self.is_invalid().then_some(error_icon())) + .spacing(5); + + let theme = match self.is_invalid() { + true => theme::Button::EntryError, + false => theme::Button::Entry, + }; + + row![ + button(title) + .width(Length::Fill) + .style(theme) + .on_press(Message::Select(index)), + Space::with_width(15) + ] + .into() + } + + pub fn view_sample_info(&self) -> Element { + match self { + SampleResult::Invalid(reason) => centered_container(text(reason)).into(), + SampleResult::Valid { metadata, .. } => { + let smp = metadata; + + let sample_name = + (!smp.name.trim().is_empty()).then_some(text(format!("Name: {}", smp.name.trim()))); + + let sample_filename = smp + .filename + .as_ref() + .map(|s| s.trim()) + .and_then(|s| (!s.is_empty()).then_some(text(format!("File Name: {}", s)))); + + let metadata = text(format!( + "{} Hz, {}-bit ({}), {}", + smp.rate, + smp.bits(), + if smp.is_signed() { "Signed" } else { "Unsigned" }, + if smp.is_stereo() { "Stereo" } else { "Mono" }, + )); + + let round_100th = |x: f32| (x * 100.0).round() / 100.0; + + let duration = Duration::from_micros( + ((smp.length_frames() as f64 / smp.rate as f64) * 1_000_000.0) as u64, + ); + let duration_secs = round_100th(duration.as_secs_f32()); + let plural = if duration_secs == 1.0 { "" } else { "s" }; + let duration = text(format!("Duration: {} sec{plural}", duration_secs)); + + let size = match smp.length { + l if l < 1000 => format!("{} bytes", l), + l if l < 1_000_000 => format!("{} KB", round_100th(l as f32 / 1000.0)), + l => format!("{} MB", round_100th(l as f32 / 1_000_000.0)), + }; + + let info = column![] + .push_maybe(sample_name) + .push_maybe(sample_filename) + .push(duration) + .push(text(format!("Size: {}", size))) + .push(text(format!("Loop type: {:#?}", smp.looping.kind()))) + .push(text(format!("Internal Index: {}", smp.index_raw()))) + .push(horizontal_rule(1)) + .push(metadata) + .push(horizontal_rule(1)) + .spacing(5) + .align_items(Alignment::Center); + centered_container(info).into() + } + } + } +} diff --git a/src/screen/sample_player/preview_manager.rs b/src/screen/sample_player/preview_manager.rs index f9475474..db25d617 100644 --- a/src/screen/sample_player/preview_manager.rs +++ b/src/screen/sample_player/preview_manager.rs @@ -1,3 +1,5 @@ +use super::instance::{self, Instance, MediaSettings}; + use iced::window::{self, Id}; use iced::{Command, Size}; @@ -10,21 +12,20 @@ use crate::widget::Element; use audio_engine::SamplePlayer; -use super::preview_window::{self, SamplePreviewWindow}; - const WINDOW_SIZE: Size = Size::new(640.0, 500.0); #[derive(Debug, Clone)] pub enum Message { ResetEngine, - Window(Id, preview_window::Message), + Window(Id, instance::Message), } // #[derive(Default)] pub struct SamplePreview { audio_engine: SamplePlayer, - windows: HashMap, + windows: HashMap, singleton: bool, + default_settings: MediaSettings, } impl Default for SamplePreview { @@ -33,6 +34,7 @@ impl Default for SamplePreview { audio_engine: Default::default(), windows: Default::default(), singleton: false, + default_settings: Default::default(), } } } @@ -48,7 +50,7 @@ impl SamplePreview { pub fn update_window( &mut self, id: Id, - msg: preview_window::Message, + msg: instance::Message, entries: &mut Entries, ) -> Command { // If the window has closed, discard the message @@ -94,12 +96,14 @@ impl SamplePreview { ..Default::default() }); - self.windows.insert( - id, - SamplePreviewWindow::create(id, self.audio_engine.create_handle()), - ); + let (instance, load_samples) = Instance::new(self.audio_engine.create_handle(), path); - Command::batch([spawn_window, self.load_samples(id, path)]) + self.windows.insert(id, instance.settings(self.default_settings)); + + Command::batch([ + spawn_window, + load_samples.map(move |msg| Message::Window(id, msg)), + ]) } pub fn get_title(&self, id: Id) -> String { @@ -110,9 +114,10 @@ impl SamplePreview { self.get_window_mut(id).hovered = hovered; } - pub fn load_samples(&self, id: Id, path: PathBuf) -> Command { - self.get_window(id) - .load_sample_pack(path) + // TODO: flash window of already loaded sample if user attempts to load duplicate + pub fn load_samples(&mut self, id: Id, path: PathBuf) -> Command { + self.get_window_mut(id) + .load_samples(path) .map(move |result| Message::Window(id, result)) } @@ -124,11 +129,11 @@ impl SamplePreview { .copied() } - pub fn get_window(&self, id: Id) -> &SamplePreviewWindow { + pub fn get_window(&self, id: Id) -> &Instance { self.windows.get(&id).expect("View sample preview window") } - pub fn get_window_mut(&mut self, id: Id) -> &mut SamplePreviewWindow { + pub fn get_window_mut(&mut self, id: Id) -> &mut Instance { self.windows.get_mut(&id).expect("View sample preview window") } diff --git a/src/screen/sample_player/preview_window.rs b/src/screen/sample_player/preview_window.rs deleted file mode 100644 index b160bdcb..00000000 --- a/src/screen/sample_player/preview_window.rs +++ /dev/null @@ -1,424 +0,0 @@ -mod sample_info; -mod wave_cache; - -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use audio_engine::{PlayerHandle, Sample, SamplePack, TrackerSample}; -use iced::widget::{ - button, checkbox, column, horizontal_rule, progress_bar, row, scrollable, slider, text, Space, -}; -use iced::window::Id; -use iced::{command, Alignment, Command, Length}; -use tokio::sync::mpsc; - -use crate::screen::main_panel::Entries; -use crate::widget::helpers::{centered_container, fill_container, warning}; -use crate::widget::waveform_view::{Marker, WaveData, WaveformViewer}; -use crate::widget::{Button, Collection, Container, Element, Row}; -use crate::{icon, theme}; - -use sample_info::SampleInfo; -use wave_cache::WaveCache; - -const MAX_VOLUME: f32 = 1.25; -const MIN_VOLUME: f32 = 0.0; - -#[derive(Debug, Clone)] -pub enum Message { - Play, - Pause, - Stop, - Volume(f32), - Progress(Option), - Loaded(Arc>), - Load(PathBuf), - Info((usize, SampleInfo)), - SetPlayOnSelect(bool), - AddEntry(PathBuf), -} - -enum State { - Play, - Paused, -} - -pub struct SamplePreviewWindow { - id: Id, - state: State, - player: PlayerHandle, - sample_pack: Option, - selected: Option<(usize, SampleInfo)>, - pub hovered: bool, - play_on_select: bool, - wave_cache: WaveCache, - progress: Option, - volume: f32, -} - -impl SamplePreviewWindow { - pub fn create(id: Id, player: PlayerHandle) -> Self { - Self { - player, - id, - hovered: false, - sample_pack: None, - state: State::Play, - selected: None, - play_on_select: true, - wave_cache: WaveCache::default(), - progress: None, - volume: 1.0, - } - } - - pub fn play(&mut self) -> Command { - self.player.stop(); - - match self.get_selected() { - Some(sample) => play_sample(&self.player, sample), - None => Command::none(), - } - } - - pub fn update(&mut self, msg: Message, entries: &mut Entries) -> Command { - match msg { - Message::Play => return self.play(), - Message::Pause => self.player.pause(), - Message::Stop => self.player.stop(), - Message::Volume(vol) => { - self.volume = vol.clamp(MIN_VOLUME, MAX_VOLUME); - self.player.set_volume(self.volume) - } - Message::Load(path) => { - return match &self.sample_pack { - Some(f) if !f.matches_path(&path) => load_sample_pack(path), - _ => Command::none(), - } - } - Message::Loaded(result) => match Arc::into_inner(result).unwrap() { - Ok((sample_pack, wave_cache)) => { - self.player.stop(); - self.selected = None; - self.sample_pack = Some(sample_pack); - self.wave_cache = wave_cache; - } - Err(err) => tracing::error!("{}", err), - }, - Message::Info(smp) => { - self.selected = Some(smp); - - if self.play_on_select { - return self.play(); - } else { - self.player.stop(); - } - } - Message::SetPlayOnSelect(play_on_select) => self.play_on_select = play_on_select, - Message::Progress(progress) => self.progress = progress, - Message::AddEntry(path) => entries.add(path), - } - Command::none() - } - - pub fn view(&self, entries: &Entries) -> Element { - let media_controls = media_button([ - (icon::play().size(18), Message::Play), - (icon::stop().size(18), Message::Stop), - (icon::pause().size(18), Message::Pause), - // (icon::repeat().size(18), Message::Stop), - ]); - - let volume_slider = column![ - text(format!("Volume: {}%", (self.volume * 100.0).round())), - slider(MIN_VOLUME..=MAX_VOLUME, self.volume, Message::Volume).step(0.01) - ] - .align_items(Alignment::Start); - - let control_panel = Container::new(row![media_controls, volume_slider].spacing(8)) - .padding(8) - .style(theme::Container::Black) - .width(Length::Fill) - .height(Length::Shrink) - .center_x(); - - let top_left = column![ - fill_container(view_sample_info(self.get_selected_info())) - .padding(8) - .style(theme::Container::Black), - control_panel - ] - .spacing(5) - .width(Length::Fill); - - let sample_list = match &self.sample_pack { - Some(pack) => view_samples(&pack.samples), - None => centered_container("Loading...").into(), - }; - - let add_path_button = self.path().and_then(|path| { - let button = || button("Add to Entries").on_press(Message::AddEntry(path.to_owned())); - (!entries.contains(path)).then(button) - }); - - let no_button_spacing = add_path_button - .is_none() - .then_some(Space::with_height(Length::Fixed(27.0))); - - let top_right = column![ - fill_container(sample_list) - .padding(8) - .style(theme::Container::Black), - row![ - checkbox("Play on Selection", self.play_on_select, Message::SetPlayOnSelect) - .style(theme::CheckBox::Inverted), - Space::with_width(Length::Fill) - ] - .push_maybe(no_button_spacing) - .push_maybe(add_path_button) - .spacing(5) - .align_items(Alignment::Center) - ] - .spacing(5) - .width(Length::Fill); - - let waveform_viewer = WaveformViewer::new_maybe(self.wave_cache()) - .marker_maybe(self.progress.map(Marker)) - .width(Length::Fill) - .height(Length::FillPortion(2)); - - let warning = warning(|| false, "WARNING - This sample is most likely static noise."); - - let main = column![ - row![top_left, top_right] - .height(Length::FillPortion(3)) - .spacing(5), - waveform_viewer, - progress_bar(0.0..=1.0, self.progress.unwrap_or_default()) - .height(5.0) - .style(theme::ProgressBar::Dark) - ] - .push_maybe(warning) - .spacing(5); - - fill_container(main) - .style(theme::Container::Hovered(self.hovered)) - .padding(15) - .into() - } - - pub fn title(&self) -> String { - match &self.sample_pack { - Some(pack) => format!("Loaded: \"{}\"", pack.name), - None => "No samples loaded!".into(), - } - } - - pub fn matches_path(&self, path: &Path) -> bool { - self.path().is_some_and(|s| s == path) - } - - pub fn path(&self) -> Option<&Path> { - self.sample_pack.as_ref().and_then(|pack| pack.path.as_deref()) - } - - pub fn load_sample_pack(&self, path: PathBuf) -> Command { - match self.matches_path(&path) { - true => Command::none(), - false => load_sample_pack(path), - } - } - - fn wave_cache(&self) -> Option<&WaveData> { - self.selected - .as_ref() - .and_then(|(idx, _)| self.wave_cache.cache.get(idx)) - } - - fn get_selected(&self) -> Option { - let pack = self.sample_pack.as_ref()?; - let (index, _) = self.selected.as_ref()?; - pack.samples[*index] - .as_ref() - .ok() - .map(|(_, sample)| sample.clone()) - } - - fn get_selected_info(&self) -> Option<&SampleInfo> { - self.selected.as_ref().map(|(_, smp)| smp) - } -} - -fn media_button<'a, Label, R, Message>(rows: R) -> Element<'a, Message> -where - Message: Clone + 'a, - Label: Into>, - R: IntoIterator, -{ - let mut media_row: Row<'a, Message> = Row::new().spacing(4.0); - let elements: Vec<(Label, Message)> = rows.into_iter().collect(); - let end_indx = elements.len() - 1; - - for (idx, (label, message)) in elements.into_iter().enumerate() { - let style = if idx == 0 { - theme::Button::MediaStart - } else if idx == end_indx { - theme::Button::MediaEnd - } else { - theme::Button::MediaMiddle - }; - let button = Button::new(label).padding(8.0).on_press(message).style(style); - media_row = media_row.push(button); - } - - media_row.into() -} - -const PLAY_CURSOR_FPS: f32 = 60.0; - -fn play_sample(handle: &PlayerHandle, source: TrackerSample) -> Command { - let (sender, mut receiver) = mpsc::unbounded_channel::(); - - let callback = move |sample: &TrackerSample, duration: &mut Instant| { - let fps_interval = Duration::from_millis(((1.0 / PLAY_CURSOR_FPS) * 1000.0).round() as u64); - - if duration.elapsed() > fps_interval { - *duration = Instant::now(); - let progress = sample.frame() as f32 / sample.buf.frames() as f32; - let _ = sender.send(progress); - } - }; - - handle.play_with_callback(source, callback); - - command::channel(256, |mut s| async move { - while let Some(new_progress) = receiver.recv().await { - let _ = s.try_send(Message::Progress(Some(new_progress))); - } - let _ = s.try_send(Message::Progress(None)); - }) -} - -fn load_sample_pack(path: PathBuf) -> Command { - Command::perform( - async { - tokio::task::spawn_blocking(move || { - const MAX_SIZE: u64 = 40 * 1024 * 1024; - - let mut file = File::open(&path)?; - - if file.metadata()?.len() > MAX_SIZE { - return Err(xmodits_lib::Error::io_error("File size exceeds 40 MB").unwrap_err()); - } - - let module = xmodits_lib::load_module(&mut file)?; - let sample_pack = SamplePack::build(&*module).with_path(path); - let wave_cache = WaveCache::from_sample_pack(&sample_pack); - - Ok((sample_pack, wave_cache)) - }) - .await - .map(Arc::new) - .unwrap() - }, - Message::Loaded, - ) -} - -fn view_samples(a: &[Result<(Sample, TrackerSample), xmodits_lib::Error>]) -> Element { - let samples = a.iter().enumerate().map(view_sample).collect(); - scrollable(column(samples).spacing(10).padding(4)).into() -} - -fn view_sample( - (index, result): (usize, &Result<(Sample, TrackerSample), xmodits_lib::Error>), -) -> Element { - let info = SampleInfo::from(result); - - let error_icon = || { - row![] - .push(Space::with_width(Length::Fill)) - .push(icon::warning()) - .align_items(iced::Alignment::Center) - }; - - let title = row![] - .push(text(match info.title() { - title if title.is_empty() => format!("{}", index + 1), - title => format!("{} - {}", index + 1, title), - })) - .push_maybe(info.is_error().then_some(error_icon())) - .spacing(5); - - let theme = match info.is_error() { - true => theme::Button::EntryError, - false => theme::Button::Entry, - }; - - row![ - button(title) - .width(Length::Fill) - .style(theme) - .on_press(Message::Info((index, info))), - Space::with_width(15) - ] - .into() -} - -fn view_sample_info(info: Option<&SampleInfo>) -> Element { - match info { - None => centered_container("Nothing selected...").into(), - Some(info) => match info { - SampleInfo::Invalid { reason } => centered_container(text(reason)).into(), - SampleInfo::Sample(smp) => { - let sample_name = - (!smp.name.trim().is_empty()).then_some(text(format!("Name: {}", smp.name.trim()))); - - let sample_filename = smp - .filename - .as_ref() - .map(|s| s.trim()) - .and_then(|s| (!s.is_empty()).then_some(text(format!("File Name: {}", s)))); - - let metadata = text(format!( - "{} Hz, {}-bit ({}), {}", - smp.rate, - smp.bits(), - if smp.is_signed() { "Signed" } else { "Unsigned" }, - if smp.is_stereo() { "Stereo" } else { "Mono" }, - )); - - let round_100th = |x: f32| (x * 100.0).round() / 100.0; - - let duration = Duration::from_micros( - ((smp.length_frames() as f64 / smp.rate as f64) * 1_000_000.0) as u64, - ); - let duration_secs = round_100th(duration.as_secs_f32()); - let plural = if duration_secs == 1.0 { "" } else { "s" }; - let duration = text(format!("Duration: {} sec{plural}", duration_secs)); - - let size = match smp.length { - l if l < 1000 => format!("{} bytes", l), - l if l < 1_000_000 => format!("{} KB", round_100th(l as f32 / 1000.0)), - l => format!("{} MB", round_100th(l as f32 / 1_000_000.0)), - }; - - let info = column![] - .push_maybe(sample_name) - .push_maybe(sample_filename) - .push(duration) - .push(text(format!("Size: {}", size))) - .push(text(format!("Loop type: {:#?}", smp.looping.kind()))) - .push(text(format!("Internal Index: {}", smp.index_raw()))) - .push(horizontal_rule(1)) - .push(metadata) - .push(horizontal_rule(1)) - .spacing(5) - .align_items(Alignment::Center); - centered_container(info).into() - } - }, - } -} diff --git a/src/screen/sample_player/preview_window/sample_info.rs b/src/screen/sample_player/preview_window/sample_info.rs deleted file mode 100644 index c061df62..00000000 --- a/src/screen/sample_player/preview_window/sample_info.rs +++ /dev/null @@ -1,37 +0,0 @@ -use audio_engine::{Sample, TrackerSample}; - -#[derive(Debug, Clone)] -pub enum SampleInfo { - Invalid { reason: String }, - Sample(Sample), -} - -impl SampleInfo { - pub fn title(&self) -> String { - match &self { - Self::Sample(smp) => { - let filename = smp.filename_pretty(); - match filename.is_empty() { - true => smp.name_pretty().into(), - false => filename.into(), - } - }, - Self::Invalid { .. } => "ERROR".into(), - } - } - - pub fn is_error(&self) -> bool { - matches!(self, Self::Invalid { .. }) - } -} - -impl From<&Result<(Sample, TrackerSample), xmodits_lib::Error>> for SampleInfo { - fn from(value: &Result<(Sample, TrackerSample), xmodits_lib::Error>) -> Self { - match value { - Ok((smp, _)) => Self::Sample(smp.to_owned()), - Err(e) => Self::Invalid { - reason: e.to_string(), - }, - } - } -} \ No newline at end of file diff --git a/src/screen/sample_player/preview_window/wave_cache.rs b/src/screen/sample_player/preview_window/wave_cache.rs deleted file mode 100644 index ccdd623c..00000000 --- a/src/screen/sample_player/preview_window/wave_cache.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::collections::HashMap; -use std::time::Duration; - -use audio_engine::{SamplePack, TrackerSample}; - -use crate::widget::waveform_view::WaveData; - -#[derive(Debug, Default)] -pub struct WaveCache { - pub cache: HashMap, -} - -impl WaveCache { - pub fn from_sample_pack(sample_pack: &SamplePack) -> Self { - let mut wave_cache = Self::default(); - - for (idx, result) in sample_pack.samples.iter().enumerate() { - if let Ok((_, sample)) = result { - wave_cache.generate(idx, sample) - } - } - - wave_cache - } - - pub fn generate(&mut self, index: usize, sample: &TrackerSample) { - let peaks = sample.buf.peaks(Duration::from_millis(5)); - self.cache.insert(index, WaveData::from(peaks)); - } -} \ No newline at end of file diff --git a/src/widget.rs b/src/widget.rs index a2bfaf55..8980b8f7 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -25,4 +25,7 @@ pub type Row<'a, Message> = iced::widget::Row<'a, Message, Renderer>; pub type Text<'a> = iced::widget::Text<'a, Renderer>; pub type Container<'a, Message> = iced::widget::Container<'a, Message, Renderer>; pub type Button<'a, Message> = iced::widget::Button<'a, Message, Renderer>; -pub type PickList<'a, Message, T> = iced::widget::PickList<'a, T, Message, Renderer>; \ No newline at end of file +pub type PickList<'a, Message, T> = iced::widget::PickList<'a, T, Message, Renderer>; + +#[cfg(feature = "audio")] +pub type WaveformViewer<'a, Message> = waveform_view::WaveformViewer<'a, Message, Theme>; \ No newline at end of file