use std::path::PathBuf;
use std::time::Duration;
use playr_core::audio::Cmd;
use playr_core::db::query::Playlist;
use playr_core::db::Track;
use playr_core::event::JobId;
use playr_core::notice::{Notice, Outcome, Refusal};
use playr_core::samples::{Cut, Plan};
use playr_core::session::Session;
use playr_core::wave::Peaks;
use crate::action::{Action, Keymap, Slicing, Zoom};
use crate::message::Message;
use crate::sampler::{self, Sampler};
use crate::{Display, Theme, View};
#[derive(Debug, Clone, PartialEq)]
pub enum Confirm {
DeletePlaylist(Playlist),
ReplacePlaylist(String),
ClearSelection(usize),
Prune(PathBuf),
ClearMarks {
path: PathBuf,
count: usize,
},
}
impl Confirm {
pub fn question(&self) -> String {
match self {
Confirm::DeletePlaylist(p) => format!("delete playlist \"{}\"?", p.name),
Confirm::ReplacePlaylist(name) => {
format!("replace playlist \"{name}\" with the selection?")
}
Confirm::ClearSelection(n) => format!("clear all {n} tracks from the selection?"),
Confirm::Prune(dir) => format!(
"remove tracks and marks under {} whose files are gone?",
crate::message::home_as_tilde(dir)
),
Confirm::ClearMarks { path, count } => {
let name = path
.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy();
format!("clear all {count} marks from {name}?")
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Prompt {
Search,
Command,
Save,
Rename(Playlist),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Presentation {
Quit,
KeyList,
CommandList,
Zoom(Zoom),
Display(Option<Display>),
Theme(Theme),
}
pub trait Frontend {
fn session(&self) -> &Session;
fn session_mut(&mut self) -> &mut Session;
fn keys(&mut self) -> &mut Keymap;
fn view(&self) -> View;
fn set_view(&mut self, view: View);
fn cursor(&self, view: View) -> Option<usize>;
fn set_cursor(&mut self, view: View, row: Option<usize>);
fn listed(&self) -> &[Track];
fn set_results(&mut self, results: Option<Vec<Track>>) -> Option<Vec<Track>>;
fn onset_sensitivity(&self) -> f32;
fn notify(&mut self, message: Message);
fn confirm(&mut self, question: Confirm);
fn prompt(&mut self, prompt: Prompt);
fn present(&mut self, presentation: Presentation);
fn sampler(&self) -> &Sampler;
fn sampler_mut(&mut self) -> &mut Sampler;
fn planning(&mut self, job: JobId);
fn take_plan(&mut self) -> Option<Plan>;
}
pub fn dispatch(action: Action, f: &mut impl Frontend) {
match action {
Action::Quit => f.present(Presentation::Quit),
Action::Help => f.present(Presentation::KeyList),
Action::CommandHelp => f.present(Presentation::CommandList),
Action::ShowView(view) => f.set_view(view),
Action::NextView => {
let next = f.view().next();
f.set_view(next);
}
Action::Cursor(rows) => move_cursor(f, rows),
Action::CursorFirst => select(f, 0),
Action::CursorLast => {
let last = len(f, f.view()).saturating_sub(1);
select(f, last);
}
Action::StartSearch => f.prompt(Prompt::Search),
Action::Search(query) => {
search(f, &query);
if f.listed().is_empty() {
f.notify(Message::NoMatches);
}
}
Action::ClearSearch => {
if f.set_results(None).is_some() {
f.set_cursor(View::Library, Some(0));
}
}
Action::StartCommand => f.prompt(Prompt::Command),
Action::Activate => activate(f),
Action::Add => add(f),
Action::Remove => {
let Some(i) = f.cursor(View::Selection) else {
return;
};
if let Some(outcome) = f.session_mut().remove_from_selection(i) {
select(f, i);
f.notify(outcome.into());
}
}
Action::MoveTrack(by) => {
let Some(i) = f.cursor(View::Selection) else {
return;
};
if let Some(to) = f.session_mut().move_in_selection(i, by) {
f.set_cursor(View::Selection, Some(to));
}
}
Action::ClearSelection => match f.session().selection().len() {
0 => f.notify(Refusal::SelectionEmpty.into()),
n => f.confirm(Confirm::ClearSelection(n)),
},
Action::StartSave => match f.session().check_save() {
Ok(()) => f.prompt(Prompt::Save),
Err(refusal) => f.notify(refusal.into()),
},
Action::SaveAs(name) => match f.session().check_save() {
Ok(()) => save_as(f, &name),
Err(refusal) => f.notify(refusal.into()),
},
Action::DeletePlaylist => {
if let Some(pl) = playlist_under_cursor(f) {
f.confirm(Confirm::DeletePlaylist(pl));
}
}
Action::StartRename => match playlist_under_cursor(f) {
Some(pl) => f.prompt(Prompt::Rename(pl)),
None => f.notify(Message::NoPlaylistUnderCursor),
},
Action::RenameTo(name) => match playlist_under_cursor(f) {
Some(pl) => rename(f, &pl, &name),
None => f.notify(Message::NoPlaylistUnderCursor),
},
Action::Scan(dir) => match f.session_mut().scan(dir.clone()) {
Ok(_) => f.notify(Outcome::ScanStarted { dir }.into()),
Err(refusal) => f.notify(refusal.into()),
},
Action::Prune(dir) => match f.session().check_prune(&dir) {
Ok(()) => f.confirm(Confirm::Prune(dir)),
Err(refusal) => f.notify(refusal.into()),
},
Action::Open(paths) => {
f.session_mut().open(paths);
f.notify(Outcome::Opening.into());
}
Action::PlayPlaylist(name) => {
let notice = f.session_mut().play_playlist_named(&name);
f.notify(notice.into());
}
Action::TogglePause => f.session().send(Cmd::TogglePause),
Action::Next => f.session().send(Cmd::Next),
Action::Prev => f.session().send(Cmd::Prev),
Action::Stop => f.session().send(Cmd::Stop),
Action::SeekBy(seconds) => f.session().send(Cmd::SeekBy(seconds)),
Action::SeekTo(at) => {
let at = snapped(f, at);
f.session().send(Cmd::Seek(at));
}
Action::VolumeBy(delta) => f.session().volume_by(delta),
Action::SetVolume(v) => f.session().send(Cmd::SetVolume(v)),
Action::SpeedBy(semitones) => f.session().send(Cmd::SpeedBy(semitones)),
Action::SetSpeed(semitones) => f.session().send(Cmd::SetSpeed(semitones)),
Action::CycleMode(forward) => {
let notice = f.session().cycle_mode(forward);
f.notify(notice.into());
}
Action::SetMode(mode) => {
let notice = f.session().set_mode(mode);
f.notify(notice.into());
}
Action::Mark => mark(f, None),
Action::MarkAt(at) => mark(f, Some(at)),
Action::UndoMark => {
let notice = f.session_mut().undo_mark();
f.notify(notice.into());
}
Action::ClearMarks => match f.session_mut().marks_to_clear() {
Ok((path, count)) => f.confirm(Confirm::ClearMarks { path, count }),
Err(refusal) => f.notify(refusal.into()),
},
Action::NextMark => {
let notice = f.session_mut().seek_to_mark(true);
f.notify(notice.into());
}
Action::PrevMark => {
let notice = f.session_mut().seek_to_mark(false);
f.notify(notice.into());
}
Action::Slice(slicing) => slice(f, slicing),
Action::Zoom(zoom) => f.present(Presentation::Zoom(zoom)),
Action::Display(display) => f.present(Presentation::Display(display)),
Action::Theme(theme) => f.present(Presentation::Theme(theme)),
Action::Nudge(nudge) => match (peaks(f), f.sampler().scale) {
(Some(peaks), Some(scale)) => {
let from = sampler::frame_of(f.session().player().position(), peaks.rate);
let to = sampler::nudge(&peaks, from, scale.frames(nudge), f.sampler().snap);
f.session()
.send(Cmd::Seek(sampler::time_of(to, peaks.rate)));
}
_ => f.notify(Message::NoWaveform),
},
Action::Loop(on) => {
let status = f.session().player().status();
let on = on.unwrap_or(status.looping.is_none());
if !on {
f.session().send(Cmd::Loop(None));
return f.notify(Message::Loop(false));
}
let Some(range) = f.sampler().range(status.current()) else {
return f.notify(Message::NoRangeToLoop);
};
f.session().send(Cmd::Loop(Some(range)));
if status.state == playr_core::audio::State::Paused {
f.session().send(Cmd::TogglePause);
}
f.notify(Message::Loop(true));
}
Action::PickEdge(edge) => {
f.sampler_mut().edge = edge;
f.notify(Message::Edge(edge));
}
Action::MoveEdge(nudge) => {
let status = f.session().player().status();
let Some(path) = status.current().cloned() else {
return f.notify(Refusal::NothingPlaying.into());
};
let (Some(peaks), Some(scale)) = (peaks(f), f.sampler().scale) else {
return f.notify(Message::NoWaveform);
};
let edge = f.sampler().edge;
let (start, end) = f.sampler().range_ends(Some(&path));
let from = match edge {
sampler::Edge::Start => start,
sampler::Edge::End => end,
};
let Some(from) = from else {
return f.notify(Message::NoEdge(edge));
};
let to = sampler::nudge(&peaks, from, scale.frames(nudge), f.sampler().snap);
match edge {
sampler::Edge::Start => {
let to = end.map_or(to, |e| to.min(e.saturating_sub(1)));
f.sampler_mut().set_range_start(&path, to);
}
sampler::Edge::End => {
let to = start.map_or(to, |s| to.max(s + 1));
f.sampler_mut().set_range_end(&path, to);
}
}
follow_loop(f);
let (start, end) = f.sampler().range_ends(Some(&path));
f.notify(Message::Range {
start,
end,
rate: peaks.rate,
});
}
Action::Snap(on) => {
let on = on.unwrap_or(!f.sampler().snap);
f.sampler_mut().snap = on;
f.notify(Message::Snap(on));
}
Action::RangeIn | Action::RangeOut => {
let (path, rate) = match f.session().playing_track() {
Ok(track) => track,
Err(refusal) => return f.notify(refusal.into()),
};
let at = sampler::frame_of(snapped(f, f.session().player().position()), rate);
if action == Action::RangeIn {
f.sampler_mut().set_range_start(&path, at);
} else {
f.sampler_mut().set_range_end(&path, at);
}
let (start, end) = f.sampler().range_ends(Some(&path));
follow_loop(f);
f.notify(Message::Range { start, end, rate });
}
Action::SetRange(times) => {
let (path, rate) = match f.session().playing_track() {
Ok(track) => track,
Err(refusal) => return f.notify(refusal.into()),
};
let Some((a, b)) = times else {
f.sampler_mut().range = None;
follow_loop(f);
return f.notify(Message::Range {
start: None,
end: None,
rate,
});
};
let frame = |t| sampler::frame_of(snapped(f, t), rate);
let (a, b) = (frame(a), frame(b));
if a == b {
return f.notify(Message::EmptyRange);
}
f.sampler_mut().range = Some(sampler::Range {
path,
start: Some(a.min(b)),
end: Some(a.max(b)),
});
follow_loop(f);
f.notify(Message::Range {
start: Some(a.min(b)),
end: Some(a.max(b)),
rate,
});
}
Action::WriteSlices => match f.take_plan() {
Some(plan) => {
f.session_mut().write_slices(plan);
f.notify(Outcome::ExportStarted.into());
}
None => f.notify(Message::NoSlicesPlanned),
},
Action::DiscardSlices => match f.take_plan() {
Some(_) => f.notify(Message::SlicesDiscarded),
None if f.sampler().range.is_some() => dispatch(Action::SetRange(None), f),
None => f.notify(Message::NoSlicesPlanned),
},
Action::Map { view, key, action } => {
let shown = Action::Map {
view,
key,
action: action.clone(),
};
f.keys().bind(view, key, action.map(|a| *a));
f.notify(Message::Mapped(shown));
}
Action::Unmap { view, key } => {
if f.keys().unbind(view, key) {
f.notify(Message::Unmapped(key));
} else {
f.notify(Message::NotBound { key, view });
}
}
}
}
pub fn confirmed(question: Confirm, f: &mut impl Frontend) {
match question {
Confirm::ReplacePlaylist(name) => {
let notice = f.session_mut().save_selection(&name, true);
f.notify(notice.into());
}
Confirm::ClearMarks { path, .. } => {
if let Some(notice) = f.session_mut().clear_marks(&path) {
f.notify(notice.into());
}
}
Confirm::Prune(dir) => match f.session_mut().prune(dir.clone()) {
Ok(_) => f.notify(Outcome::PruneStarted { dir }.into()),
Err(refusal) => f.notify(refusal.into()),
},
Confirm::ClearSelection(_) => {
let outcome = f.session_mut().clear_selection();
f.set_cursor(View::Selection, None);
f.notify(outcome.into());
}
Confirm::DeletePlaylist(pl) => {
if let Some(notice) = f.session_mut().delete_playlist(pl.id) {
f.set_view(View::Playlists);
let row = f.cursor(View::Playlists).unwrap_or(0);
select(f, row);
f.notify(notice.into());
}
}
}
}
pub fn search(f: &mut impl Frontend, query: &str) {
f.set_view(View::Library);
let results = (!query.is_empty()).then(|| f.session().search(query));
f.set_results(results);
let row = (!f.listed().is_empty()).then_some(0);
f.set_cursor(View::Library, row);
}
pub fn save_as(f: &mut impl Frontend, name: &str) {
match f.session_mut().save_selection(name, false) {
Notice::Refused(Refusal::WouldReplace(name)) => f.confirm(Confirm::ReplacePlaylist(name)),
notice => f.notify(notice.into()),
}
}
pub fn rename(f: &mut impl Frontend, from: &Playlist, name: &str) {
let notice = f.session_mut().rename_playlist(from.id, name);
if matches!(notice, Notice::Done(_)) {
let at = f.session().playlists().iter().position(|p| p.id == from.id);
f.set_cursor(View::Playlists, at);
}
f.notify(notice.into());
}
fn len(f: &impl Frontend, view: View) -> usize {
match view {
View::Library => f.listed().len(),
View::Selection => f.session().selection().len(),
View::Playlists => f.session().playlists().len(),
View::Sampler => 0,
}
}
fn select(f: &mut impl Frontend, i: usize) {
let view = f.view();
if view == View::Sampler {
return;
}
let row = match len(f, view) {
0 => None,
n => Some(i.min(n - 1)),
};
f.set_cursor(view, row);
}
fn move_cursor(f: &mut impl Frontend, rows: i64) {
let view = f.view();
let n = len(f, view);
if n == 0 {
return;
}
let at = f.cursor(view).unwrap_or(0) as i64;
f.set_cursor(view, Some((at + rows).clamp(0, n as i64 - 1) as usize));
}
fn playlist_under_cursor(f: &impl Frontend) -> Option<Playlist> {
if f.view() != View::Playlists {
return None;
}
let row = f.cursor(View::Playlists)?;
f.session().playlists().get(row).cloned()
}
fn activate(f: &mut impl Frontend) {
match f.view() {
View::Library => {
let Some(i) = f.cursor(View::Library) else {
return;
};
let tracks = f.listed().to_vec();
if !tracks.is_empty() {
f.session_mut().play(&tracks, i);
}
}
View::Selection => {
if let Some(i) = f.cursor(View::Selection) {
let tracks = f.session().selection().to_vec();
f.session_mut().play(&tracks, i);
}
}
View::Playlists => {
if let Some(pl) = playlist_under_cursor(f) {
let notice = f.session_mut().play_playlist(pl.id);
f.notify(notice.into());
}
}
View::Sampler => {}
}
}
fn add(f: &mut impl Frontend) {
let outcome = match f.view() {
View::Library => {
let Some(i) = f.cursor(View::Library) else {
return;
};
let Some(track) = f.listed().get(i).cloned() else {
return;
};
f.session_mut().toggle_selected(track)
}
View::Playlists => {
let Some(pl) = playlist_under_cursor(f) else {
return;
};
let Some(outcome) = f.session_mut().add_playlist_to_selection(pl.id) else {
return;
};
outcome
}
View::Selection | View::Sampler => return,
};
let n = f.session().selection().len();
match outcome {
Outcome::RemovedFromSelection => {
let row = f
.cursor(View::Selection)
.map(|i| i.min(n.saturating_sub(1)));
f.set_cursor(View::Selection, if n == 0 { None } else { row });
}
Outcome::AddedToSelection if f.cursor(View::Selection).is_none() => {
f.set_cursor(View::Selection, Some(0));
}
_ => {}
}
move_cursor(f, 1);
f.notify(outcome.into());
}
fn peaks(f: &impl Frontend) -> Option<std::sync::Arc<Peaks>> {
let status = f.session().player().status();
sampler::peaks_of(f.sampler(), status.current()).ok()
}
fn snapped(f: &impl Frontend, at: Duration) -> Duration {
if f.view() != View::Sampler || !f.sampler().snap {
return at;
}
match peaks(f) {
Some(peaks) => sampler::time_of(
sampler::snap(&peaks, sampler::frame_of(at, peaks.rate)),
peaks.rate,
),
None => at,
}
}
fn follow_loop(f: &impl Frontend) {
let status = f.session().player().status();
if status.looping.is_some() {
let range = f.sampler().range(status.current());
f.session().send(Cmd::Loop(range));
}
}
fn mark(f: &mut impl Frontend, at: Option<Duration>) {
let notice = if f.view() == View::Sampler {
let at = at.unwrap_or_else(|| f.session().player().position());
let at = snapped(f, at);
f.session_mut().add_mark_within(Some(at), Duration::ZERO)
} else {
f.session_mut().add_mark(at)
};
f.notify(notice.into());
}
fn slice(f: &mut impl Frontend, slicing: Slicing) {
let range = {
let status = f.session().player().status();
f.sampler().range(status.current())
};
let cut = match slicing {
Slicing::Region => Cut::Region,
Slicing::Marks => Cut::Marks,
Slicing::Equal(n) => Cut::Equal(n),
Slicing::Onsets(s) => Cut::Onsets(s.unwrap_or(f.onset_sensitivity())),
};
if f.view() == View::Sampler {
match f.session_mut().plan_slices(cut, range) {
Ok(job) => {
f.planning(job);
f.notify(Outcome::PlanStarted.into());
}
Err(refusal) => f.notify(refusal.into()),
}
} else {
match f.session_mut().export(cut, range) {
Ok(_) => f.notify(Outcome::ExportStarted.into()),
Err(refusal) => f.notify(refusal.into()),
}
}
}