YrXtals/src/ui/app.rs

704 lines
25 KiB
Rust

use std::path::PathBuf;
use std::sync::Arc;
use iced_wgpu::core::{Element, Theme};
use iced_widget::scrollable::AbsoluteOffset;
use crate::analyzer::FrameData;
use crate::analyzer_worker::AnalyzerWorker;
use crate::engine::{AudioEngine, EngineEvent};
use crate::library::{self, Track};
use crate::library_worker::{LibraryUpdate, LibraryWorker};
use super::player;
/// owns the library, audio engine, background workers, and every UI-toggleable setting.
pub struct App {
pub library: Library,
pub selected_track: Option<usize>,
pub playing: bool,
pub immersive: bool,
pub engine: Option<AudioEngine>,
pub worker: AnalyzerWorker,
pub library_worker: LibraryWorker,
pub frame_data: Arc<Vec<FrameData>>,
pub current_palette: Option<Arc<Vec<[f32; 3]>>>,
pub settings: Settings,
pub show_settings: bool,
/// shell-side picker request flag drained by the iOS host once per tick.
pub pending_pick: u8,
/// monotonic id stamped onto every decode request, matched against returning results.
pub current_decode_id: u64,
pub next_decode_id: u64,
pub track_loading: bool,
/// running count and total of the iOS library import progress.
pub library_progress: Option<(u32, u32)>,
/// modal status message shown over the UI while waiting on iOS file-coordinator caching.
pub coordinating_message: Option<String>,
/// running count of decode failures since the last successful track load.
pub consecutive_failures: usize,
/// settings panel scroll offset mirrored from the on_scroll callback.
pub settings_scroll: AbsoluteOffset,
/// pending sidebar snap to the currently playing track row.
pub restore_sidebar_scroll: bool,
/// pending settings scroll restore against the live scrollable widget.
pub restore_settings_scroll: bool,
/// show_settings copy bridging the middle-tap collapse cycle.
pub saved_show_settings: bool,
}
/// every visualizer toggle, slider value, and DSP parameter the settings panel exposes.
#[derive(Debug, Clone, Copy)]
pub struct Settings {
pub glass: bool,
pub entropy_on: bool,
pub album_colors: bool,
pub mirrored: bool,
pub inverted: bool,
pub entropy_strength: f32,
pub hue: f32,
pub contrast: f32,
pub brightness: f32,
pub num_bins: u32,
pub fft: u32,
pub hop: u32,
pub granularity: i32,
pub detail: i32,
pub strength: f32,
/// fraction of FFT work routed to the GPU pipeline, blended against the CPU result.
pub gpu_blend: f32,
}
impl Default for Settings {
fn default() -> Self {
Self {
glass: true,
entropy_on: false,
album_colors: false,
mirrored: true,
inverted: false,
entropy_strength: 0.0,
hue: 0.9,
contrast: 1.0,
brightness: 1.0,
num_bins: 26,
fft: 16384,
hop: 4096,
granularity: 33,
detail: 50,
strength: 0.0,
gpu_blend: 0.7,
}
}
}
/// loaded track list paired with the folder path of origin.
#[derive(Default)]
pub struct Library {
pub folder: Option<PathBuf>,
pub tracks: Vec<Track>,
}
/// every UI event the player widget tree can emit.
#[derive(Debug, Clone)]
pub enum Message {
OpenFolder,
OpenFile,
SelectTrack(usize),
TogglePlayPause,
Next,
Prev,
Seek(f32),
ToggleImmersive,
ToggleChrome,
ToggleSettings,
NoOp,
SettingsScrolled(AbsoluteOffset),
SetGlass(bool),
SetEntropy(bool),
SetAlbumColors(bool),
SetMirrored(bool),
SetInverted(bool),
SetEntropyStrength(f32),
SetHue(f32),
SetContrast(f32),
SetBrightness(f32),
SetNumBins(u32),
SetFft(u32),
SetHop(u32),
SetGranularity(i32),
SetDetail(i32),
SetStrength(f32),
SetGpuBlend(f32),
PickedFolder(PathBuf),
PickedFile(PathBuf),
PickedFiles(Vec<PathBuf>),
}
impl App {
/// builds the analyzer worker, library worker, audio engine, and seeds defaults.
pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self {
let settings = Settings::default();
let worker = AnalyzerWorker::spawn(device, queue);
worker.set_num_bins(settings.num_bins as usize);
worker.set_dsp_params(settings.fft as usize, settings.hop as usize);
worker.set_smoothing(settings.granularity, settings.detail, settings.strength);
worker.set_gpu_blend(settings.gpu_blend);
let library_worker = LibraryWorker::spawn();
Self {
library: Library::default(),
selected_track: None,
playing: false,
immersive: false,
engine: AudioEngine::new()
.map_err(|e| eprintln!("yr_crystals: audio engine unavailable: {e}"))
.ok(),
worker,
library_worker,
frame_data: Arc::new(Vec::new()),
current_palette: None,
settings,
show_settings: false,
pending_pick: 0,
current_decode_id: 0,
next_decode_id: 0,
track_loading: false,
library_progress: None,
coordinating_message: None,
consecutive_failures: 0,
settings_scroll: AbsoluteOffset::default(),
restore_sidebar_scroll: false,
restore_settings_scroll: false,
saved_show_settings: false,
}
}
/// returns the picker flag and clears the slot in one step.
pub fn take_pending_pick(&mut self) -> u8 {
let p = self.pending_pick;
self.pending_pick = 0;
#[cfg(all(target_os = "ios", debug_assertions))]
if p != 0 {
eprintln!("[Rust.dbg] take_pending_pick -> {p}");
}
p
}
/// 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 {
return false;
}
x >= 0.0
&& x < player::SIDEBAR_W
&& y >= player::TOP_BAR_H
&& y < viewport_height - player::TRANSPORT_H
}
/// checks whether a logical-coords point falls inside the right-aligned settings overlay panel.
pub fn point_in_settings(&self, x: f32, _y: f32, viewport_width: f32) -> bool {
self.show_settings && x >= viewport_width - player::SETTINGS_W && x <= viewport_width
}
/// scans a folder, replaces the library, queues art, and starts decoding the first track.
fn apply_picked_folder(&mut self, folder: PathBuf) {
#[cfg(all(target_os = "ios", debug_assertions))]
eprintln!("[Rust.dbg] apply_picked_folder enter path={}", folder.display());
let tracks = library::scan_folder(&folder);
#[cfg(all(target_os = "ios", debug_assertions))]
{
eprintln!("[Rust.dbg] apply_picked_folder scan -> {} tracks", tracks.len());
for (i, t) in tracks.iter().take(5).enumerate() {
eprintln!("[Rust.dbg] track[{i}] title={:?} path={}", t.title, t.path.display());
}
}
self.library.folder = Some(folder);
self.library.tracks = tracks;
self.queue_art_for_all();
self.selected_track = self.library.tracks.first().map(|_| 0);
self.playing = self.selected_track.is_some();
if let Some(idx) = self.selected_track {
self.load_index(idx);
}
}
/// loads a single audio file as a one-track library and starts playback.
fn apply_picked_file(&mut self, path: PathBuf) {
match library::read_track_meta(&path) {
Ok(track) => {
self.library.folder = path.parent().map(|p| p.to_path_buf());
self.library.tracks = vec![track];
self.queue_art_for_all();
self.selected_track = Some(0);
self.playing = true;
self.load_index(0);
}
Err(e) => {
eprintln!("yr_crystals: read_track_meta failed: {}", e);
}
}
}
/// builds an ad-hoc library from a multi-file pick and queues meta and art lookups.
fn apply_picked_files(&mut self, paths: Vec<PathBuf>) {
let mut tracks = Vec::with_capacity(paths.len());
let mut folder = None;
for path in &paths {
if folder.is_none() {
folder = path.parent().map(|p| p.to_path_buf());
}
let title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
.to_string();
tracks.push(library::Track {
path: path.clone(),
title,
artist: None,
album: None,
track_number: None,
art: None,
palette: None,
exporting: false,
});
}
self.library.folder = folder;
self.library.tracks = tracks;
self.queue_meta_for_all();
self.queue_art_for_all();
self.selected_track = self.library.tracks.first().map(|_| 0);
self.playing = self.selected_track.is_some();
if let Some(idx) = self.selected_track {
self.load_index(idx);
}
}
/// seeds the sidebar with placeholder rows for tracks pending export from the iOS host.
pub fn set_pending_titles(&mut self, entries: Vec<(String, Option<u32>)>) {
let mut tracks: Vec<library::Track> = entries
.into_iter()
.map(|(title, track_number)| library::Track {
path: PathBuf::new(),
title,
artist: None,
album: None,
track_number,
art: None,
palette: None,
exporting: true,
})
.collect();
library::sort_tracks(&mut tracks);
self.library.folder = None;
self.library.tracks = tracks;
self.selected_track = self.library.tracks.first().map(|_| 0);
self.playing = self.selected_track.is_some();
self.track_loading = self.selected_track.is_some();
}
/// resolves a placeholder track to a real file path once iOS export finishes.
pub fn set_track_path(&mut self, idx: usize, path: PathBuf) {
let Some(track) = self.library.tracks.get_mut(idx) else { return };
track.path = path.clone();
track.exporting = false;
self.library_worker.request_meta(path.clone());
self.library_worker.request_art(path);
if self.selected_track == Some(idx) {
self.load_index(idx);
}
}
/// hands raw artwork bytes to the art worker.
pub fn set_track_art_bytes(&mut self, idx: usize, bytes: Vec<u8>) {
if idx >= self.library.tracks.len() || bytes.is_empty() {
return;
}
self.library_worker.request_art_bytes(idx, bytes);
}
/// queues artwork extraction across the whole library on the art worker.
fn queue_art_for_all(&self) {
for t in &self.library.tracks {
self.library_worker.request_art(t.path.clone());
}
}
/// queues tag reads across the whole library on the meta worker.
fn queue_meta_for_all(&self) {
for t in &self.library.tracks {
self.library_worker.request_meta(t.path.clone());
}
}
/// reports the engine playhead as a normalised 0..=1 fraction of the loaded track.
pub fn position(&self) -> f32 {
self.engine.as_ref().map(|e| e.position()).unwrap_or(0.0)
}
/// recomputes the active album palette from the currently selected track.
fn refresh_palette(&mut self) {
self.current_palette = self
.selected_track
.and_then(|i| self.library.tracks.get(i))
.and_then(|t| t.palette.clone());
}
/// drains worker updates, advances tracks, and refreshes analyzer frames each frame.
pub fn tick(&mut self) {
for upd in self.library_worker.drain_updates() {
match upd {
LibraryUpdate::Meta {
path,
title,
artist,
album,
track_number,
} => {
if let Some(t) = self.library.tracks.iter_mut().find(|t| t.path == path) {
if let Some(title) = title {
t.title = title;
}
if artist.is_some() {
t.artist = artist;
}
if album.is_some() {
t.album = album;
}
if let Some(tn) = track_number {
t.track_number = Some(tn);
}
}
}
LibraryUpdate::Art {
path,
art,
palette,
} => {
let match_idx = self
.library
.tracks
.iter()
.position(|t| t.path == path);
if let Some(idx) = match_idx {
if art.is_some() {
self.library.tracks[idx].art = art;
}
if palette.is_some() {
self.library.tracks[idx].palette = palette;
}
if self.selected_track == Some(idx) {
self.refresh_palette();
}
}
}
LibraryUpdate::ArtForIdx {
idx,
art,
palette,
} => {
if let Some(t) = self.library.tracks.get_mut(idx) {
t.art = art;
t.palette = palette;
if self.selected_track == Some(idx) {
self.refresh_palette();
}
}
}
LibraryUpdate::Decoded {
request_id,
path: _,
result,
} => {
if request_id != self.current_decode_id {
continue;
}
self.track_loading = false;
match result {
Ok(td) => {
if td.is_valid() {
self.consecutive_failures = 0;
}
if let Some(eng) = &self.engine {
eng.load(td.clone(), self.current_decode_id);
if self.playing {
eng.play();
} else {
eng.pause();
}
}
self.worker.set_track(td);
}
Err(e) => {
eprintln!("yr_crystals: decode failed: {e}");
self.note_failure_and_skip();
}
}
}
}
}
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);
}
/// counts a decode failure and skips past the failed track, halting once the whole library has cycled.
fn note_failure_and_skip(&mut self) {
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
if self.library.tracks.len() <= 1 || self.consecutive_failures >= self.library.tracks.len() {
self.playing = false;
if let Some(eng) = &self.engine { eng.pause(); }
return;
}
self.advance_to_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
.and_then(|i| self.library.tracks.get(i))
.map(|t| t.path.clone());
library::sort_tracks(&mut self.library.tracks);
if let Some(p) = selected_path {
self.selected_track = self.library.tracks.iter().position(|t| t.path == p);
}
}
/// dispatches a UI message into the matching state mutation and worker call.
pub fn update(&mut self, msg: Message) {
match msg {
Message::OpenFolder => {
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
{
if let Some(folder) = rfd::FileDialog::new().pick_folder() {
self.apply_picked_folder(folder);
}
}
#[cfg(any(target_os = "ios", target_os = "android"))]
{
#[cfg(all(target_os = "ios", debug_assertions))]
eprintln!("[Rust.dbg] Message::OpenFolder -> pending_pick=1");
self.pending_pick = 1;
}
}
Message::OpenFile => {
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
{
let file = rfd::FileDialog::new()
.add_filter(
"Audio",
&[
"mp3", "m4a", "m4b", "mp4", "flac", "wav", "ogg", "oga",
"opus", "aac", "aiff",
],
)
.pick_file();
if let Some(path) = file {
self.apply_picked_file(path);
}
}
#[cfg(any(target_os = "ios", target_os = "android"))]
{
#[cfg(all(target_os = "ios", debug_assertions))]
eprintln!("[Rust.dbg] Message::OpenFile -> pending_pick=2");
self.pending_pick = 2;
}
}
Message::PickedFolder(folder) => self.apply_picked_folder(folder),
Message::PickedFile(path) => self.apply_picked_file(path),
Message::PickedFiles(paths) => self.apply_picked_files(paths),
Message::SelectTrack(idx) => {
if idx < self.library.tracks.len() {
self.selected_track = Some(idx);
self.playing = true;
self.consecutive_failures = 0;
self.load_index(idx);
}
}
Message::TogglePlayPause => {
if self.selected_track.is_some() {
self.playing = !self.playing;
if let Some(eng) = &self.engine {
if self.playing { eng.play(); } else { eng.pause(); }
}
}
}
Message::Next => {
if let Some(i) = self.selected_track {
if i + 1 < self.library.tracks.len() {
let next = i + 1;
self.selected_track = Some(next);
self.load_index(next);
}
}
}
Message::Prev => {
if let Some(i) = self.selected_track {
if i > 0 {
let prev = i - 1;
self.selected_track = Some(prev);
self.load_index(prev);
}
}
}
Message::Seek(pos) => {
if let Some(eng) = &self.engine {
eng.seek_normalised(pos.clamp(0.0, 1.0));
}
}
Message::ToggleImmersive => self.immersive = !self.immersive,
Message::ToggleChrome => {
if self.immersive {
self.immersive = false;
self.restore_sidebar_scroll = true;
if self.saved_show_settings {
self.show_settings = true;
self.restore_settings_scroll = true;
}
} else {
self.saved_show_settings = self.show_settings;
self.show_settings = false;
self.immersive = true;
}
}
Message::ToggleSettings => {
self.show_settings = !self.show_settings;
if self.show_settings {
self.restore_settings_scroll = true;
}
}
Message::NoOp => {}
Message::SettingsScrolled(off) => self.settings_scroll = off,
Message::SetGlass(on) => self.settings.glass = on,
Message::SetEntropy(on) => self.settings.entropy_on = on,
Message::SetAlbumColors(on) => self.settings.album_colors = on,
Message::SetMirrored(on) => self.settings.mirrored = on,
Message::SetInverted(on) => self.settings.inverted = on,
Message::SetEntropyStrength(v) => self.settings.entropy_strength = v.clamp(-1.5, 1.5),
Message::SetHue(v) => self.settings.hue = v.clamp(0.0, 1.0),
Message::SetContrast(v) => self.settings.contrast = v.clamp(0.0, 2.0),
Message::SetBrightness(v) => self.settings.brightness = v.clamp(0.1, 2.0),
Message::SetNumBins(n) => {
let n = n.clamp(8, 256);
self.settings.num_bins = n;
self.worker.set_num_bins(n as usize);
}
Message::SetFft(n) => {
let fft = n.clamp(512, 65536).next_power_of_two();
let hop = self.settings.hop.min(fft / 2).max(64);
self.settings.fft = fft;
self.settings.hop = hop;
self.worker.set_dsp_params(fft as usize, hop as usize);
}
Message::SetHop(n) => {
let hop = n.clamp(64, self.settings.fft / 2);
self.settings.hop = hop;
self.worker
.set_dsp_params(self.settings.fft as usize, hop as usize);
}
Message::SetGranularity(v) => {
self.settings.granularity = v.clamp(1, 100);
self.worker.set_smoothing(
self.settings.granularity,
self.settings.detail,
self.settings.strength,
);
}
Message::SetDetail(v) => {
self.settings.detail = v.clamp(1, 100);
self.worker.set_smoothing(
self.settings.granularity,
self.settings.detail,
self.settings.strength,
);
}
Message::SetStrength(v) => {
self.settings.strength = v.clamp(0.0, 1.0);
self.worker.set_smoothing(
self.settings.granularity,
self.settings.detail,
self.settings.strength,
);
}
Message::SetGpuBlend(v) => {
self.settings.gpu_blend = v.clamp(0.0, 1.0);
self.worker.set_gpu_blend(self.settings.gpu_blend);
}
}
}
/// queues a fresh decode for the given track, stamping a new request id.
fn load_index(&mut self, idx: usize) {
let track = match self.library.tracks.get(idx) {
Some(t) => t,
None => return,
};
if track.exporting {
self.track_loading = true;
return;
}
let path = track.path.clone();
self.refresh_palette();
let Some(eng) = self.engine.as_ref() else {
return;
};
let target_sr = eng.output_sample_rate();
self.track_loading = true;
self.next_decode_id += 1;
self.current_decode_id = self.next_decode_id;
self.library_worker
.request_decode(self.current_decode_id, path, target_sr);
}
/// builds the iced widget tree for the current frame.
pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
player::view(self)
}
}