diff --git a/ios/Info.plist b/ios/Info.plist index 3fd5b81..2110b22 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -32,14 +32,14 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.2 + 1.0.3 CFBundleSupportedPlatforms iPhoneOS iPhoneSimulator CFBundleVersion - 1.0.2 + 1.0.3 ITSAppUsesNonExemptEncryption LSSupportsOpeningDocumentsInPlace diff --git a/src/engine.rs b/src/engine.rs index 90acf73..88799e8 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -10,10 +10,15 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{BufferSize, SampleFormat, SampleRate, Stream, StreamConfig}; #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] use cpal::SupportedBufferSize; -use crossbeam_channel::{unbounded, Sender}; +use crossbeam_channel::{unbounded, Receiver, Sender}; use crate::track::TrackData; +/// upstream notifications from the audio callback to the App tick loop. +pub enum EngineEvent { + TrackEnded { id: u64 }, +} + /// lazy-initialises the engine clock's reference instant. fn engine_clock() -> Instant { static START: OnceLock = OnceLock::new(); @@ -36,7 +41,7 @@ pub struct EngineState { /// transport messages crossing from the ui thread into the cpal callback. enum Cmd { - Load(Arc), + Load { td: Arc, id: u64 }, Play, Pause, Stop, @@ -47,6 +52,7 @@ enum Cmd { pub struct AudioEngine { pub state: Arc, cmd_tx: Sender, + event_rx: Receiver, #[cfg(not(target_os = "android"))] _stream: Stream, #[cfg(target_os = "android")] @@ -132,9 +138,12 @@ impl AudioEngine { last_callback_nanos: AtomicU64::new(0), }); let (cmd_tx, cmd_rx) = unbounded::(); + let (event_tx, event_rx) = unbounded::(); let cb_state = state.clone(); let mut current: Option> = None; + let mut current_id: u64 = 0; + let mut emitted_end: bool = false; let mut local_pos: u64 = 0; let mut local_playing = false; @@ -146,11 +155,13 @@ impl AudioEngine { move |out: &mut [f32], _info: &cpal::OutputCallbackInfo| { while let Ok(cmd) = cmd_rx.try_recv() { match cmd { - Cmd::Load(td) => { + Cmd::Load { td, id } => { cb_state .total_frames .store(td.total_samples() as u64, Ordering::Release); current = Some(td); + current_id = id; + emitted_end = false; local_pos = 0; cb_state.frame_pos.store(0, Ordering::Release); cb_state.last_callback_pos.store(0, Ordering::Release); @@ -213,6 +224,10 @@ impl AudioEngine { for f in 0..frames_out { let dst = f * device_ch; if local_pos >= total { + if !emitted_end { + let _ = event_tx.send(EngineEvent::TrackEnded { id: current_id }); + emitted_end = true; + } for c in 0..device_ch { out[dst + c] = 0.0; } @@ -259,6 +274,7 @@ impl AudioEngine { Ok(AudioEngine { state, cmd_tx, + event_rx, _stream: stream, }) } @@ -286,9 +302,12 @@ impl AudioEngine { last_callback_nanos: AtomicU64::new(0), }); let (cmd_tx, cmd_rx) = unbounded::(); + let (event_tx, event_rx) = unbounded::(); let cb_state = state.clone(); let mut current: Option> = None; + let mut current_id: u64 = 0; + let mut emitted_end: bool = false; let mut local_pos: u64 = 0; let mut local_playing = false; @@ -299,11 +318,13 @@ impl AudioEngine { move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| { while let Ok(cmd) = cmd_rx.try_recv() { match cmd { - Cmd::Load(td) => { + Cmd::Load { td, id } => { cb_state .total_frames .store(td.total_samples() as u64, Ordering::Release); current = Some(td); + current_id = id; + emitted_end = false; local_pos = 0; cb_state.frame_pos.store(0, Ordering::Release); cb_state.last_callback_pos.store(0, Ordering::Release); @@ -382,6 +403,10 @@ impl AudioEngine { for f in 0..frames { let dst = f * device_ch; if local_pos >= total { + if !emitted_end { + let _ = event_tx.send(EngineEvent::TrackEnded { id: current_id }); + emitted_end = true; + } for c in 0..device_ch { out[dst + c] = 0.0; } @@ -451,6 +476,7 @@ impl AudioEngine { Ok(AudioEngine { state, cmd_tx, + event_rx, _stream: stream, }) } @@ -460,9 +486,14 @@ impl AudioEngine { self.state.sample_rate.load(Ordering::Acquire) } - /// hands incoming pcm to the audio callback and rewinds the playhead to zero. - pub fn load(&self, track: Arc) { - let _ = self.cmd_tx.send(Cmd::Load(track)); + /// hands incoming pcm to the audio callback and rewinds the playhead to zero, tagged with a load id matched against TrackEnded events. + pub fn load(&self, track: Arc, id: u64) { + let _ = self.cmd_tx.send(Cmd::Load { td: track, id }); + } + + /// drains pending engine-to-app events. + pub fn drain_events(&self) -> Vec { + self.event_rx.try_iter().collect() } /// resumes output from the current frame position. diff --git a/src/ui/app.rs b/src/ui/app.rs index 07b15c2..fdd7341 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -6,7 +6,7 @@ use iced_widget::scrollable::AbsoluteOffset; use crate::analyzer::FrameData; use crate::analyzer_worker::AnalyzerWorker; -use crate::engine::AudioEngine; +use crate::engine::{AudioEngine, EngineEvent}; use crate::library::{self, Track}; use crate::library_worker::{LibraryUpdate, LibraryWorker}; @@ -41,13 +41,10 @@ pub struct App { /// modal status message shown over the UI while waiting on iOS file-coordinator caching. pub coordinating_message: Option, - /// sidebar scroll offset mirrored from the on_scroll callback. - pub sidebar_scroll: AbsoluteOffset, - /// settings panel scroll offset mirrored from the on_scroll callback. pub settings_scroll: AbsoluteOffset, - /// pending sidebar scroll restore against the live scrollable widget. + /// pending sidebar snap to the currently playing track row. pub restore_sidebar_scroll: bool, /// pending settings scroll restore against the live scrollable widget. @@ -86,7 +83,7 @@ impl Default for Settings { glass: true, entropy_on: false, album_colors: false, - mirrored: false, + mirrored: true, inverted: false, entropy_strength: 0.0, hue: 0.9, @@ -124,7 +121,7 @@ pub enum Message { ToggleImmersive, ToggleChrome, ToggleSettings, - SidebarScrolled(AbsoluteOffset), + NoOp, SettingsScrolled(AbsoluteOffset), SetGlass(bool), SetEntropy(bool), @@ -177,7 +174,6 @@ impl App { track_loading: false, library_progress: None, coordinating_message: None, - sidebar_scroll: AbsoluteOffset::default(), settings_scroll: AbsoluteOffset::default(), restore_sidebar_scroll: false, restore_settings_scroll: false, @@ -198,7 +194,7 @@ impl App { /// checks whether a logical-coords point falls inside the scrollable sidebar region. pub fn point_in_sidebar(&self, x: f32, y: f32, viewport_height: f32) -> bool { - if self.immersive || self.show_settings { + if self.immersive { return false; } x >= 0.0 @@ -360,7 +356,6 @@ impl App { /// drains worker updates, advances tracks, and refreshes analyzer frames each frame. pub fn tick(&mut self) { - let mut needs_resort = false; for upd in self.library_worker.drain_updates() { match upd { LibraryUpdate::Meta { @@ -382,10 +377,7 @@ impl App { t.album = album; } if let Some(tn) = track_number { - if t.track_number != Some(tn) { - t.track_number = Some(tn); - needs_resort = true; - } + t.track_number = Some(tn); } } } @@ -437,7 +429,7 @@ impl App { match result { Ok(td) => { if let Some(eng) = &self.engine { - eng.load(td.clone()); + eng.load(td.clone(), self.current_decode_id); if self.playing { eng.play(); } else { @@ -452,17 +444,37 @@ impl App { } } - if needs_resort { - self.resort_library(); - } - - self.advance_if_finished(); + self.handle_engine_events(); self.worker.publish_playhead(self.position()); self.frame_data = self.worker.latest_frames(); } + /// drains engine end-of-track notifications and advances when the id matches the current load. + fn handle_engine_events(&mut self) { + let Some(eng) = self.engine.as_ref() else { return }; + for ev in eng.drain_events() { + match ev { + EngineEvent::TrackEnded { id } => { + if id == self.current_decode_id && self.playing && !self.track_loading { + self.advance_to_next(); + } + } + } + } + } + + /// wraps the playhead onto the next track in the current sidebar order. + fn advance_to_next(&mut self) { + let Some(idx) = self.selected_track else { return }; + if self.library.tracks.is_empty() { return; } + let next = (idx + 1) % self.library.tracks.len(); + self.selected_track = Some(next); + self.load_index(next); + } + /// re-sorts the library on arrival of track-number tags and preserves the current selection. + #[allow(dead_code)] fn resort_library(&mut self) { let selected_path = self .selected_track @@ -474,27 +486,6 @@ impl App { } } - /// rolls onto the next track once the playhead reaches the end, pausing past the final entry. - fn advance_if_finished(&mut self) { - if !self.playing { - return; - } - let Some(idx) = self.selected_track else { return }; - if self.position() < 0.999 { - return; - } - let next = idx + 1; - if next >= self.library.tracks.len() { - self.playing = false; - if let Some(eng) = &self.engine { - eng.pause(); - } - return; - } - self.selected_track = Some(next); - self.load_index(next); - } - /// dispatches a UI message into the matching state mutation and worker call. pub fn update(&mut self, msg: Message) { match msg { @@ -541,6 +532,7 @@ impl App { Message::SelectTrack(idx) => { if idx < self.library.tracks.len() { self.selected_track = Some(idx); + self.playing = true; self.load_index(idx); } } @@ -596,7 +588,7 @@ impl App { self.restore_settings_scroll = true; } } - Message::SidebarScrolled(off) => self.sidebar_scroll = off, + Message::NoOp => {} Message::SettingsScrolled(off) => self.settings_scroll = off, Message::SetGlass(on) => self.settings.glass = on, Message::SetEntropy(on) => self.settings.entropy_on = on, diff --git a/src/ui/player.rs b/src/ui/player.rs index 62ae232..84951fe 100644 --- a/src/ui/player.rs +++ b/src/ui/player.rs @@ -28,7 +28,9 @@ use super::theme::palette; pub const SIDEBAR_W: f32 = 280.0; pub const TOP_BAR_H: f32 = 44.0; pub const TRANSPORT_H: f32 = 72.0; -const ROW_H: f32 = 56.0; +pub const ROW_H: f32 = 56.0; +pub const ROW_SPACING: f32 = 2.0; +pub const SIDEBAR_PADDING_TOP: f32 = 8.0; const THUMB: f32 = 40.0; /// stable id of the sidebar scrollable. @@ -180,14 +182,13 @@ fn sidebar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let selected = app.selected_track; let inner = lazy(key, move |_| { - let mut col = column![].spacing(2).padding(Padding::from([8, 8])); + let mut col = column![] + .spacing(ROW_SPACING) + .padding(Padding::from([SIDEBAR_PADDING_TOP as u16, 8])); for (i, t) in tracks.iter().enumerate() { col = col.push(track_row_owned(i, t.clone(), selected == Some(i))); } - scrollable(col) - .id(sidebar_scroll_id()) - .on_scroll(|vp| Message::SidebarScrolled(vp.absolute_offset())) - .height(Length::Fill) + scrollable(col).id(sidebar_scroll_id()).height(Length::Fill) }); container(inner) @@ -253,17 +254,11 @@ fn track_row_owned( .padding(0) .on_press(Message::SelectTrack(idx)) .width(Length::Fill) - .style(move |_t: &Theme, status: ButtonStatus| { - let bg = match (active, status) { - (true, _) => Some(Background::Color(Color { - a: 0.20, - ..palette::accent() - })), - (false, ButtonStatus::Hovered) => Some(Background::Color(Color { - a: 0.06, - ..palette::accent() - })), - _ => None, + .style(move |_t: &Theme, _status: ButtonStatus| { + let bg = if active { + Some(Background::Color(Color { a: 0.20, ..palette::playing() })) + } else { + None }; button::Style { background: bg, @@ -524,10 +519,13 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere .align_x(iced_wgpu::core::alignment::Horizontal::Right) .align_y(iced_wgpu::core::alignment::Vertical::Center); - let panel = container(stack![scroll, close]) - .width(Length::Fixed(SETTINGS_W)) - .height(Length::Fill) - .style(settings_panel_style); + let panel = mouse_area( + container(stack![scroll, close]) + .width(Length::Fixed(SETTINGS_W)) + .height(Length::Fill) + .style(settings_panel_style) + ) + .on_press(Message::NoOp); container(panel) .width(Length::Fill) diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 6777594..847b91e 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -10,6 +10,7 @@ pub mod palette { const TEXT_C: Color = Color::from_rgb(0.92, 0.92, 0.95); const TEXT_DIM_C: Color = Color::from_rgb(0.55, 0.55, 0.62); const ACCENT_C: Color = Color::from_rgb(0.78, 0.62, 0.95); + const PLAYING_C: Color = Color::from_rgb(0.40, 0.70, 1.00); pub fn bg() -> Color { BG_C } pub fn sidebar() -> Color { SIDEBAR_C } @@ -17,6 +18,7 @@ pub mod palette { pub fn text() -> Color { TEXT_C } pub fn text_dim() -> Color { TEXT_DIM_C } pub fn accent() -> Color { ACCENT_C } + pub fn playing() -> Color { PLAYING_C } } /// supplies the clear color handed to the wgpu compositor each frame. diff --git a/src/viewport.rs b/src/viewport.rs index c1eab4a..f19efb5 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -206,17 +206,17 @@ impl ViewportHandle { let logical = self.viewport.logical_size(); let h = logical.height; let w = logical.width; - self.touch_in_sidebar = self.state.point_in_sidebar(x, y, h); let in_settings = self.state.point_in_settings(x, y, w); + self.touch_in_sidebar = !in_settings && self.state.point_in_sidebar(x, y, h); self.touch_start = Some((x, y)); self.touch_drift = 0.0; self.last_touch = Some((x, y)); - if self.touch_in_sidebar { - self.push_mouse_move(x, y); - self.settings_touch = SettingsTouch::None; - } else if in_settings { + if in_settings { self.settings_touch = SettingsTouch::Pending; self.push_mouse_move(x, y); + } else if self.touch_in_sidebar { + self.push_mouse_move(x, y); + self.settings_touch = SettingsTouch::None; } else { self.settings_touch = SettingsTouch::None; self.push_mouse_button(x, y, 0, true); @@ -245,11 +245,9 @@ impl ViewportHandle { match self.settings_touch { SettingsTouch::Scroll => { if dy.abs() > 0.0 { - let p = Point::new(x, y); self.events.push(Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, })); - self.cursor = mouse::Cursor::Available(p); } } SettingsTouch::Drag => { @@ -257,13 +255,14 @@ impl ViewportHandle { } SettingsTouch::Pending => {} SettingsTouch::None => { - self.push_mouse_move(x, y); - if self.touch_in_sidebar && dy.abs() > 0.0 { - let p = Point::new(x, y); - self.events.push(Event::Mouse(mouse::Event::WheelScrolled { - delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, - })); - self.cursor = mouse::Cursor::Available(p); + if self.touch_in_sidebar { + if dy.abs() > 0.0 { + self.events.push(Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, + })); + } + } else { + self.push_mouse_move(x, y); } } } @@ -597,13 +596,24 @@ fn render(handle: &mut ViewportHandle) { /// drains scroll-restore flags from App state into a side-channel struct. fn take_pending_scroll_restores(state: &mut App) -> ScrollRestore { - let r = ScrollRestore { - sidebar: state.restore_sidebar_scroll.then_some(state.sidebar_scroll), - settings: state.restore_settings_scroll.then_some(state.settings_scroll), + let sidebar = if state.restore_sidebar_scroll { + Some(sidebar_snap_offset(state.selected_track)) + } else { + None }; + let settings = state.restore_settings_scroll.then_some(state.settings_scroll); state.restore_sidebar_scroll = false; state.restore_settings_scroll = false; - r + ScrollRestore { sidebar, settings } +} + +/// vertical offset placing the playing row at the top of the sidebar viewport. +fn sidebar_snap_offset(selected: Option) -> AbsoluteOffset { + let idx = selected.unwrap_or(0) as f32; + AbsoluteOffset { + x: 0.0, + y: player::SIDEBAR_PADDING_TOP + idx * (player::ROW_H + player::ROW_SPACING), + } } /// pushes the stored sidebar and settings scroll offsets onto the UserInterface.