fixed some bugs

This commit is contained in:
jess 2026-05-09 20:08:40 -07:00
parent 97d2d6bd75
commit 464aad571a
6 changed files with 123 additions and 90 deletions

View File

@ -32,14 +32,14 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.2</string> <string>1.0.3</string>
<key>CFBundleSupportedPlatforms</key> <key>CFBundleSupportedPlatforms</key>
<array> <array>
<string>iPhoneOS</string> <string>iPhoneOS</string>
<string>iPhoneSimulator</string> <string>iPhoneSimulator</string>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0.2</string> <string>1.0.3</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>

View File

@ -10,10 +10,15 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{BufferSize, SampleFormat, SampleRate, Stream, StreamConfig}; use cpal::{BufferSize, SampleFormat, SampleRate, Stream, StreamConfig};
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] #[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
use cpal::SupportedBufferSize; use cpal::SupportedBufferSize;
use crossbeam_channel::{unbounded, Sender}; use crossbeam_channel::{unbounded, Receiver, Sender};
use crate::track::TrackData; 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. /// lazy-initialises the engine clock's reference instant.
fn engine_clock() -> Instant { fn engine_clock() -> Instant {
static START: OnceLock<Instant> = OnceLock::new(); static START: OnceLock<Instant> = OnceLock::new();
@ -36,7 +41,7 @@ pub struct EngineState {
/// transport messages crossing from the ui thread into the cpal callback. /// transport messages crossing from the ui thread into the cpal callback.
enum Cmd { enum Cmd {
Load(Arc<TrackData>), Load { td: Arc<TrackData>, id: u64 },
Play, Play,
Pause, Pause,
Stop, Stop,
@ -47,6 +52,7 @@ enum Cmd {
pub struct AudioEngine { pub struct AudioEngine {
pub state: Arc<EngineState>, pub state: Arc<EngineState>,
cmd_tx: Sender<Cmd>, cmd_tx: Sender<Cmd>,
event_rx: Receiver<EngineEvent>,
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
_stream: Stream, _stream: Stream,
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
@ -132,9 +138,12 @@ impl AudioEngine {
last_callback_nanos: AtomicU64::new(0), last_callback_nanos: AtomicU64::new(0),
}); });
let (cmd_tx, cmd_rx) = unbounded::<Cmd>(); let (cmd_tx, cmd_rx) = unbounded::<Cmd>();
let (event_tx, event_rx) = unbounded::<EngineEvent>();
let cb_state = state.clone(); let cb_state = state.clone();
let mut current: Option<Arc<TrackData>> = None; let mut current: Option<Arc<TrackData>> = None;
let mut current_id: u64 = 0;
let mut emitted_end: bool = false;
let mut local_pos: u64 = 0; let mut local_pos: u64 = 0;
let mut local_playing = false; let mut local_playing = false;
@ -146,11 +155,13 @@ impl AudioEngine {
move |out: &mut [f32], _info: &cpal::OutputCallbackInfo| { move |out: &mut [f32], _info: &cpal::OutputCallbackInfo| {
while let Ok(cmd) = cmd_rx.try_recv() { while let Ok(cmd) = cmd_rx.try_recv() {
match cmd { match cmd {
Cmd::Load(td) => { Cmd::Load { td, id } => {
cb_state cb_state
.total_frames .total_frames
.store(td.total_samples() as u64, Ordering::Release); .store(td.total_samples() as u64, Ordering::Release);
current = Some(td); current = Some(td);
current_id = id;
emitted_end = false;
local_pos = 0; local_pos = 0;
cb_state.frame_pos.store(0, Ordering::Release); cb_state.frame_pos.store(0, Ordering::Release);
cb_state.last_callback_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 { for f in 0..frames_out {
let dst = f * device_ch; let dst = f * device_ch;
if local_pos >= total { 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 { for c in 0..device_ch {
out[dst + c] = 0.0; out[dst + c] = 0.0;
} }
@ -259,6 +274,7 @@ impl AudioEngine {
Ok(AudioEngine { Ok(AudioEngine {
state, state,
cmd_tx, cmd_tx,
event_rx,
_stream: stream, _stream: stream,
}) })
} }
@ -286,9 +302,12 @@ impl AudioEngine {
last_callback_nanos: AtomicU64::new(0), last_callback_nanos: AtomicU64::new(0),
}); });
let (cmd_tx, cmd_rx) = unbounded::<Cmd>(); let (cmd_tx, cmd_rx) = unbounded::<Cmd>();
let (event_tx, event_rx) = unbounded::<EngineEvent>();
let cb_state = state.clone(); let cb_state = state.clone();
let mut current: Option<Arc<TrackData>> = None; let mut current: Option<Arc<TrackData>> = None;
let mut current_id: u64 = 0;
let mut emitted_end: bool = false;
let mut local_pos: u64 = 0; let mut local_pos: u64 = 0;
let mut local_playing = false; let mut local_playing = false;
@ -299,11 +318,13 @@ impl AudioEngine {
move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| { move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| {
while let Ok(cmd) = cmd_rx.try_recv() { while let Ok(cmd) = cmd_rx.try_recv() {
match cmd { match cmd {
Cmd::Load(td) => { Cmd::Load { td, id } => {
cb_state cb_state
.total_frames .total_frames
.store(td.total_samples() as u64, Ordering::Release); .store(td.total_samples() as u64, Ordering::Release);
current = Some(td); current = Some(td);
current_id = id;
emitted_end = false;
local_pos = 0; local_pos = 0;
cb_state.frame_pos.store(0, Ordering::Release); cb_state.frame_pos.store(0, Ordering::Release);
cb_state.last_callback_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 { for f in 0..frames {
let dst = f * device_ch; let dst = f * device_ch;
if local_pos >= total { 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 { for c in 0..device_ch {
out[dst + c] = 0.0; out[dst + c] = 0.0;
} }
@ -451,6 +476,7 @@ impl AudioEngine {
Ok(AudioEngine { Ok(AudioEngine {
state, state,
cmd_tx, cmd_tx,
event_rx,
_stream: stream, _stream: stream,
}) })
} }
@ -460,9 +486,14 @@ impl AudioEngine {
self.state.sample_rate.load(Ordering::Acquire) self.state.sample_rate.load(Ordering::Acquire)
} }
/// hands incoming pcm to the audio callback and rewinds the playhead to zero. /// 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<TrackData>) { pub fn load(&self, track: Arc<TrackData>, id: u64) {
let _ = self.cmd_tx.send(Cmd::Load(track)); let _ = self.cmd_tx.send(Cmd::Load { td: track, id });
}
/// drains pending engine-to-app events.
pub fn drain_events(&self) -> Vec<EngineEvent> {
self.event_rx.try_iter().collect()
} }
/// resumes output from the current frame position. /// resumes output from the current frame position.

View File

@ -6,7 +6,7 @@ use iced_widget::scrollable::AbsoluteOffset;
use crate::analyzer::FrameData; use crate::analyzer::FrameData;
use crate::analyzer_worker::AnalyzerWorker; use crate::analyzer_worker::AnalyzerWorker;
use crate::engine::AudioEngine; use crate::engine::{AudioEngine, EngineEvent};
use crate::library::{self, Track}; use crate::library::{self, Track};
use crate::library_worker::{LibraryUpdate, LibraryWorker}; 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. /// modal status message shown over the UI while waiting on iOS file-coordinator caching.
pub coordinating_message: Option<String>, pub coordinating_message: Option<String>,
/// sidebar scroll offset mirrored from the on_scroll callback.
pub sidebar_scroll: AbsoluteOffset,
/// settings panel scroll offset mirrored from the on_scroll callback. /// settings panel scroll offset mirrored from the on_scroll callback.
pub settings_scroll: AbsoluteOffset, 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, pub restore_sidebar_scroll: bool,
/// pending settings scroll restore against the live scrollable widget. /// pending settings scroll restore against the live scrollable widget.
@ -86,7 +83,7 @@ impl Default for Settings {
glass: true, glass: true,
entropy_on: false, entropy_on: false,
album_colors: false, album_colors: false,
mirrored: false, mirrored: true,
inverted: false, inverted: false,
entropy_strength: 0.0, entropy_strength: 0.0,
hue: 0.9, hue: 0.9,
@ -124,7 +121,7 @@ pub enum Message {
ToggleImmersive, ToggleImmersive,
ToggleChrome, ToggleChrome,
ToggleSettings, ToggleSettings,
SidebarScrolled(AbsoluteOffset), NoOp,
SettingsScrolled(AbsoluteOffset), SettingsScrolled(AbsoluteOffset),
SetGlass(bool), SetGlass(bool),
SetEntropy(bool), SetEntropy(bool),
@ -177,7 +174,6 @@ impl App {
track_loading: false, track_loading: false,
library_progress: None, library_progress: None,
coordinating_message: None, coordinating_message: None,
sidebar_scroll: AbsoluteOffset::default(),
settings_scroll: AbsoluteOffset::default(), settings_scroll: AbsoluteOffset::default(),
restore_sidebar_scroll: false, restore_sidebar_scroll: false,
restore_settings_scroll: false, restore_settings_scroll: false,
@ -198,7 +194,7 @@ impl App {
/// checks whether a logical-coords point falls inside the scrollable sidebar region. /// 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 { 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; return false;
} }
x >= 0.0 x >= 0.0
@ -360,7 +356,6 @@ impl App {
/// drains worker updates, advances tracks, and refreshes analyzer frames each frame. /// drains worker updates, advances tracks, and refreshes analyzer frames each frame.
pub fn tick(&mut self) { pub fn tick(&mut self) {
let mut needs_resort = false;
for upd in self.library_worker.drain_updates() { for upd in self.library_worker.drain_updates() {
match upd { match upd {
LibraryUpdate::Meta { LibraryUpdate::Meta {
@ -382,10 +377,7 @@ impl App {
t.album = album; t.album = album;
} }
if let Some(tn) = track_number { if let Some(tn) = track_number {
if t.track_number != Some(tn) { t.track_number = Some(tn);
t.track_number = Some(tn);
needs_resort = true;
}
} }
} }
} }
@ -437,7 +429,7 @@ impl App {
match result { match result {
Ok(td) => { Ok(td) => {
if let Some(eng) = &self.engine { if let Some(eng) = &self.engine {
eng.load(td.clone()); eng.load(td.clone(), self.current_decode_id);
if self.playing { if self.playing {
eng.play(); eng.play();
} else { } else {
@ -452,17 +444,37 @@ impl App {
} }
} }
if needs_resort { self.handle_engine_events();
self.resort_library();
}
self.advance_if_finished();
self.worker.publish_playhead(self.position()); self.worker.publish_playhead(self.position());
self.frame_data = self.worker.latest_frames(); 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. /// re-sorts the library on arrival of track-number tags and preserves the current selection.
#[allow(dead_code)]
fn resort_library(&mut self) { fn resort_library(&mut self) {
let selected_path = self let selected_path = self
.selected_track .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. /// dispatches a UI message into the matching state mutation and worker call.
pub fn update(&mut self, msg: Message) { pub fn update(&mut self, msg: Message) {
match msg { match msg {
@ -541,6 +532,7 @@ impl App {
Message::SelectTrack(idx) => { Message::SelectTrack(idx) => {
if idx < self.library.tracks.len() { if idx < self.library.tracks.len() {
self.selected_track = Some(idx); self.selected_track = Some(idx);
self.playing = true;
self.load_index(idx); self.load_index(idx);
} }
} }
@ -596,7 +588,7 @@ impl App {
self.restore_settings_scroll = true; self.restore_settings_scroll = true;
} }
} }
Message::SidebarScrolled(off) => self.sidebar_scroll = off, Message::NoOp => {}
Message::SettingsScrolled(off) => self.settings_scroll = off, Message::SettingsScrolled(off) => self.settings_scroll = off,
Message::SetGlass(on) => self.settings.glass = on, Message::SetGlass(on) => self.settings.glass = on,
Message::SetEntropy(on) => self.settings.entropy_on = on, Message::SetEntropy(on) => self.settings.entropy_on = on,

View File

@ -28,7 +28,9 @@ use super::theme::palette;
pub const SIDEBAR_W: f32 = 280.0; pub const SIDEBAR_W: f32 = 280.0;
pub const TOP_BAR_H: f32 = 44.0; pub const TOP_BAR_H: f32 = 44.0;
pub const TRANSPORT_H: f32 = 72.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; const THUMB: f32 = 40.0;
/// stable id of the sidebar scrollable. /// 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 selected = app.selected_track;
let inner = lazy(key, move |_| { 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() { for (i, t) in tracks.iter().enumerate() {
col = col.push(track_row_owned(i, t.clone(), selected == Some(i))); col = col.push(track_row_owned(i, t.clone(), selected == Some(i)));
} }
scrollable(col) scrollable(col).id(sidebar_scroll_id()).height(Length::Fill)
.id(sidebar_scroll_id())
.on_scroll(|vp| Message::SidebarScrolled(vp.absolute_offset()))
.height(Length::Fill)
}); });
container(inner) container(inner)
@ -253,17 +254,11 @@ fn track_row_owned(
.padding(0) .padding(0)
.on_press(Message::SelectTrack(idx)) .on_press(Message::SelectTrack(idx))
.width(Length::Fill) .width(Length::Fill)
.style(move |_t: &Theme, status: ButtonStatus| { .style(move |_t: &Theme, _status: ButtonStatus| {
let bg = match (active, status) { let bg = if active {
(true, _) => Some(Background::Color(Color { Some(Background::Color(Color { a: 0.20, ..palette::playing() }))
a: 0.20, } else {
..palette::accent() None
})),
(false, ButtonStatus::Hovered) => Some(Background::Color(Color {
a: 0.06,
..palette::accent()
})),
_ => None,
}; };
button::Style { button::Style {
background: bg, 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_x(iced_wgpu::core::alignment::Horizontal::Right)
.align_y(iced_wgpu::core::alignment::Vertical::Center); .align_y(iced_wgpu::core::alignment::Vertical::Center);
let panel = container(stack![scroll, close]) let panel = mouse_area(
.width(Length::Fixed(SETTINGS_W)) container(stack![scroll, close])
.height(Length::Fill) .width(Length::Fixed(SETTINGS_W))
.style(settings_panel_style); .height(Length::Fill)
.style(settings_panel_style)
)
.on_press(Message::NoOp);
container(panel) container(panel)
.width(Length::Fill) .width(Length::Fill)

View File

@ -10,6 +10,7 @@ pub mod palette {
const TEXT_C: Color = Color::from_rgb(0.92, 0.92, 0.95); 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 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 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 bg() -> Color { BG_C }
pub fn sidebar() -> Color { SIDEBAR_C } pub fn sidebar() -> Color { SIDEBAR_C }
@ -17,6 +18,7 @@ pub mod palette {
pub fn text() -> Color { TEXT_C } pub fn text() -> Color { TEXT_C }
pub fn text_dim() -> Color { TEXT_DIM_C } pub fn text_dim() -> Color { TEXT_DIM_C }
pub fn accent() -> Color { ACCENT_C } pub fn accent() -> Color { ACCENT_C }
pub fn playing() -> Color { PLAYING_C }
} }
/// supplies the clear color handed to the wgpu compositor each frame. /// supplies the clear color handed to the wgpu compositor each frame.

View File

@ -206,17 +206,17 @@ impl ViewportHandle {
let logical = self.viewport.logical_size(); let logical = self.viewport.logical_size();
let h = logical.height; let h = logical.height;
let w = logical.width; 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); 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_start = Some((x, y));
self.touch_drift = 0.0; self.touch_drift = 0.0;
self.last_touch = Some((x, y)); self.last_touch = Some((x, y));
if self.touch_in_sidebar { if in_settings {
self.push_mouse_move(x, y);
self.settings_touch = SettingsTouch::None;
} else if in_settings {
self.settings_touch = SettingsTouch::Pending; self.settings_touch = SettingsTouch::Pending;
self.push_mouse_move(x, y); self.push_mouse_move(x, y);
} else if self.touch_in_sidebar {
self.push_mouse_move(x, y);
self.settings_touch = SettingsTouch::None;
} else { } else {
self.settings_touch = SettingsTouch::None; self.settings_touch = SettingsTouch::None;
self.push_mouse_button(x, y, 0, true); self.push_mouse_button(x, y, 0, true);
@ -245,11 +245,9 @@ impl ViewportHandle {
match self.settings_touch { match self.settings_touch {
SettingsTouch::Scroll => { SettingsTouch::Scroll => {
if dy.abs() > 0.0 { if dy.abs() > 0.0 {
let p = Point::new(x, y);
self.events.push(Event::Mouse(mouse::Event::WheelScrolled { self.events.push(Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy },
})); }));
self.cursor = mouse::Cursor::Available(p);
} }
} }
SettingsTouch::Drag => { SettingsTouch::Drag => {
@ -257,13 +255,14 @@ impl ViewportHandle {
} }
SettingsTouch::Pending => {} SettingsTouch::Pending => {}
SettingsTouch::None => { SettingsTouch::None => {
self.push_mouse_move(x, y); if self.touch_in_sidebar {
if self.touch_in_sidebar && dy.abs() > 0.0 { if dy.abs() > 0.0 {
let p = Point::new(x, y); self.events.push(Event::Mouse(mouse::Event::WheelScrolled {
self.events.push(Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy },
delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, }));
})); }
self.cursor = mouse::Cursor::Available(p); } 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. /// drains scroll-restore flags from App state into a side-channel struct.
fn take_pending_scroll_restores(state: &mut App) -> ScrollRestore { fn take_pending_scroll_restores(state: &mut App) -> ScrollRestore {
let r = ScrollRestore { let sidebar = if state.restore_sidebar_scroll {
sidebar: state.restore_sidebar_scroll.then_some(state.sidebar_scroll), Some(sidebar_snap_offset(state.selected_track))
settings: state.restore_settings_scroll.then_some(state.settings_scroll), } else {
None
}; };
let settings = state.restore_settings_scroll.then_some(state.settings_scroll);
state.restore_sidebar_scroll = false; state.restore_sidebar_scroll = false;
state.restore_settings_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<usize>) -> 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. /// pushes the stored sidebar and settings scroll offsets onto the UserInterface.