pub mod action;
pub mod command;
pub mod config;
pub mod render;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::widgets::ListState;
use rusqlite::Connection;
use crate::audio::{Cmd, Player, State, Status};
use crate::db::query::{self, Mark, Playlist};
use crate::db::Track;
use action::{Action, Keymap};
use command::{CommandLine, History};
use config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Library,
Selection,
Playlists,
}
impl View {
fn next(self) -> Self {
match self {
View::Library => View::Selection,
View::Selection => View::Playlists,
View::Playlists => View::Library,
}
}
fn title(self) -> &'static str {
match self {
View::Library => "Library",
View::Selection => "Selection",
View::Playlists => "Playlists",
}
}
}
pub enum Input {
None,
Search(String),
SavePlaylist(String),
RenamePlaylist {
from: Playlist,
name: String,
},
Confirm(Confirm),
Help,
CommandHelp,
Command(CommandLine),
}
pub enum Confirm {
DeletePlaylist(Playlist),
ReplacePlaylist(String),
ClearSelection(usize),
ClearMarks(usize),
}
impl Confirm {
pub fn prompt(&self) -> String {
match self {
Confirm::DeletePlaylist(p) => format!("delete playlist \"{}\"? (y/n)", p.name),
Confirm::ReplacePlaylist(name) => {
format!("replace playlist \"{name}\" with the selection? (y/n)")
}
Confirm::ClearSelection(n) => format!("clear all {n} tracks from the selection? (y/n)"),
Confirm::ClearMarks(n) => format!("clear all {n} marks from this track? (y/n)"),
}
}
}
pub struct App {
conn: Connection,
player: Player,
view: View,
all: Vec<Track>,
results: Option<Vec<Track>>,
library_state: ListState,
playing: Vec<Track>,
playing_source: Arc<[PathBuf]>,
selection: Vec<Track>,
selection_state: ListState,
playlists: Vec<Playlist>,
playlist_state: ListState,
input: Input,
history: History,
keys: Keymap,
help_scroll: usize,
message: Option<(String, Instant)>,
quit: bool,
seen_error: u64,
peak_hold: Option<(f32, Instant)>,
marks: Vec<Mark>,
marks_for: Option<PathBuf>,
snapshot: Snapshot,
}
pub struct Screen<'a> {
pub view: View,
pub snapshot: &'a Snapshot,
pub all: &'a [Track],
pub results: Option<&'a [Track]>,
pub playing: &'a [Track],
pub selection: &'a [Track],
pub playlists: &'a [Playlist],
pub input: &'a Input,
pub keys: &'a Keymap,
pub help_scroll: &'a mut usize,
pub message: Option<&'a str>,
pub library_state: &'a mut ListState,
pub selection_state: &'a mut ListState,
pub playlist_state: &'a mut ListState,
}
impl Screen<'_> {
pub fn visible(&self) -> &[Track] {
self.results.unwrap_or(self.all)
}
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub status: Status,
pub position: Duration,
pub volume: f32,
pub loudness: Option<f32>,
pub peak: Option<f32>,
pub marks: Vec<Duration>,
}
impl App {
pub fn new(conn: Connection, player: Player) -> Self {
Self::with_selection(conn, player, Vec::new())
}
pub fn with_selection(conn: Connection, player: Player, tracks: Vec<Track>) -> Self {
Self::configured(conn, player, tracks, Config::default())
}
pub fn configured(
conn: Connection,
player: Player,
tracks: Vec<Track>,
config: Config,
) -> Self {
let mut app = App {
conn,
player,
view: View::Library,
all: Vec::new(),
results: None,
library_state: ListState::default(),
playing: Vec::new(),
playing_source: Arc::default(),
selection: Vec::new(),
selection_state: ListState::default(),
playlists: Vec::new(),
playlist_state: ListState::default(),
input: Input::None,
history: History::default(),
keys: config.keys,
help_scroll: 0,
message: None,
quit: false,
seen_error: 0,
peak_hold: None,
marks: Vec::new(),
marks_for: None,
snapshot: Snapshot::default(),
};
app.player.send(Cmd::SetVolume(config.volume));
app.player.send(Cmd::SetMode(config.mode));
app.player.send(Cmd::SetSpeed(config.speed));
if !tracks.is_empty() {
app.selection = tracks.clone();
app.selection_state.select(Some(0));
app.play(tracks, 0);
app.view = View::Selection;
}
app.reload();
app
}
fn follow_player(&mut self) {
let current = self.player.queue();
if Arc::ptr_eq(¤t, &self.playing_source) {
return;
}
let known: HashMap<&str, &Track> = self
.playing
.iter()
.chain(&self.selection)
.chain(&self.all)
.map(|t| (t.path.as_str(), t))
.collect();
self.playing = current
.iter()
.map(|p| {
let path = p.to_string_lossy();
known
.get(path.as_ref())
.map(|t| (*t).clone())
.unwrap_or(Track {
path: path.into_owned(),
..Default::default()
})
})
.collect();
self.playing_source = current;
}
fn reload(&mut self) {
self.all = query::all(&self.conn).unwrap_or_default();
self.playlists = query::playlists(&self.conn).unwrap_or_default();
if !self.all.is_empty() && self.library_state.selected().is_none() {
self.library_state.select(Some(0));
}
if !self.playlists.is_empty() && self.playlist_state.selected().is_none() {
self.playlist_state.select(Some(0));
}
}
fn visible(&self) -> &[Track] {
self.results.as_deref().unwrap_or(&self.all)
}
pub fn screen(&mut self) -> Screen<'_> {
Screen {
view: self.view,
snapshot: &self.snapshot,
all: &self.all,
results: self.results.as_deref(),
playing: &self.playing,
selection: &self.selection,
playlists: &self.playlists,
input: &self.input,
keys: &self.keys,
help_scroll: &mut self.help_scroll,
message: self.message.as_ref().map(|(m, _)| m.as_str()),
library_state: &mut self.library_state,
selection_state: &mut self.selection_state,
playlist_state: &mut self.playlist_state,
}
}
pub fn run(mut self, terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<()> {
while !self.quit {
self.refresh();
terminal.draw(|f| render::draw(&mut self.screen(), f))?;
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.on_key(key);
}
}
}
if let Some((_, at)) = &self.message {
if at.elapsed() > Duration::from_secs(4) {
self.message = None;
}
}
}
Ok(())
}
pub fn refresh(&mut self) {
self.follow_player();
self.peak_hold = hold_peak(self.peak_hold, self.player.take_peak(), Instant::now());
self.snapshot = Snapshot {
status: self.player.status(),
position: self.player.position(),
volume: self.player.volume(),
loudness: self.player.loudness(),
peak: self.peak_hold.map(|(p, _)| 20.0 * p.log10()),
marks: Vec::new(),
};
let current = self.snapshot.status.current().cloned();
self.follow_marks(current.as_ref());
self.snapshot.marks = self.marks.iter().map(Mark::time).collect();
let seq = self.snapshot.status.error_seq;
if seq > self.seen_error {
let missed = seq - self.seen_error - 1;
self.seen_error = seq;
if let Some(e) = self.snapshot.status.error.clone() {
if missed > 0 {
self.notify(format!("{e} (and {missed} more)"));
} else {
self.notify(e);
}
}
}
}
fn notify(&mut self, msg: impl Into<String>) {
self.message = Some((msg.into(), Instant::now()));
}
pub fn quitting(&self) -> bool {
self.quit
}
pub fn on_key(&mut self, key: KeyEvent) {
self.follow_player();
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.quit = true;
return;
}
match &self.input {
Input::Confirm(_) => {
let Input::Confirm(action) = std::mem::replace(&mut self.input, Input::None) else {
unreachable!()
};
if typed(&key) == Some('y') {
self.confirm(action);
} else {
self.notify("cancelled");
}
return;
}
Input::Help | Input::CommandHelp => {
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.help_scroll += 1,
KeyCode::Char('k') | KeyCode::Up => {
self.help_scroll = self.help_scroll.saturating_sub(1)
}
KeyCode::PageDown => self.help_scroll += 10,
KeyCode::PageUp => self.help_scroll = self.help_scroll.saturating_sub(10),
_ => self.input = Input::None,
}
return;
}
Input::Command(line) => {
let line = line.clone();
return self.command_key(key, line);
}
Input::Search(buf) => {
let buf = buf.clone();
return self.search_key(key, buf);
}
Input::SavePlaylist(buf) => {
let buf = buf.clone();
return self.save_key(key, buf);
}
Input::RenamePlaylist { from, name } => {
let (from, name) = (from.clone(), name.clone());
return self.rename_key(key, from, name);
}
Input::None => {}
}
if let Some(action) = self.keys.lookup((&key).into(), self.view).cloned() {
self.perform(action);
}
}
pub fn perform(&mut self, action: Action) {
self.follow_player();
match action {
Action::Quit => self.quit = true,
Action::Help => {
self.help_scroll = 0;
self.input = Input::Help;
}
Action::CommandHelp => {
self.help_scroll = 0;
self.input = Input::CommandHelp;
}
Action::ShowView(view) => self.view = view,
Action::NextView => self.view = self.view.next(),
Action::Cursor(rows) => self.move_selection(rows),
Action::CursorFirst => self.select(0),
Action::CursorLast => self.select(self.len().saturating_sub(1)),
Action::StartSearch => self.input = Input::Search(String::new()),
Action::Search(query) => {
self.apply_search(&query);
if self.visible().is_empty() {
self.notify("no matches");
}
}
Action::ClearSearch => {
if self.results.take().is_some() {
self.library_state.select(Some(0));
}
}
Action::StartCommand => self.input = Input::Command(CommandLine::default()),
Action::Activate => self.activate(),
Action::Add => self.append_selection(),
Action::Remove => self.remove_from_selection(),
Action::MoveTrack(delta) => self.move_in_selection(delta),
Action::ClearSelection => {
if self.selection.is_empty() {
self.notify("selection is empty");
} else {
self.input = Input::Confirm(Confirm::ClearSelection(self.selection.len()));
}
}
Action::StartSave => {
if self.can_save() {
self.input = Input::SavePlaylist(String::new());
}
}
Action::SaveAs(name) => {
if self.can_save() {
self.save_as(&name);
}
}
Action::DeletePlaylist => self.delete_playlist(),
Action::StartRename => self.start_rename(),
Action::RenameTo(name) => match self.playlist_under_cursor() {
Some(from) => self.rename_to(&from, &name),
None => self.notify("no playlist under the cursor in the playlists view"),
},
Action::PlayPlaylist(name) => self.play_playlist_named(&name),
Action::TogglePause => self.player.send(Cmd::TogglePause),
Action::Next => self.player.send(Cmd::Next),
Action::Prev => self.player.send(Cmd::Prev),
Action::Stop => self.player.send(Cmd::Stop),
Action::SeekBy(seconds) => self.player.send(Cmd::SeekBy(seconds)),
Action::SeekTo(at) => self.player.send(Cmd::Seek(at)),
Action::VolumeBy(delta) => self.nudge_volume(delta),
Action::SetVolume(v) => self.player.send(Cmd::SetVolume(v)),
Action::SpeedBy(semitones) => self.player.send(Cmd::SpeedBy(semitones)),
Action::SetSpeed(semitones) => self.player.send(Cmd::SetSpeed(semitones)),
Action::CycleMode(forward) => self.cycle_mode(forward),
Action::SetMode(mode) => {
self.player.send(Cmd::SetMode(mode));
self.notify(format!("mode: {}", mode.name()));
}
Action::Mark => self.add_mark(None),
Action::MarkAt(at) => self.add_mark(Some(at)),
Action::UndoMark => self.undo_mark(),
Action::ClearMarks => self.ask_to_clear_marks(),
Action::NextMark => self.jump_to_mark(true),
Action::PrevMark => self.jump_to_mark(false),
Action::Map { view, key, action } => {
let shown = command::line(
&Action::Map {
view,
key,
action: action.clone(),
},
None,
);
self.keys.bind(view, key, action.map(|a| *a));
self.notify(shown);
}
Action::Unmap { view, key } => {
if self.keys.unbind(view, key) {
self.notify(format!("unmapped {key}"));
} else {
self.notify(format!("{key} has no binding {}", command::scope(view)));
}
}
}
}
fn can_save(&mut self) -> bool {
if self.selection.is_empty() {
self.notify("selection is empty");
false
} else if self.conn.path().is_none_or(str::is_empty) {
self.notify("no library to save to; `playr scan <dir>` creates one");
false
} else {
true
}
}
fn save_as(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() {
self.notify("playlist name cannot be empty");
} else if self.playlists.iter().any(|p| p.name == name) {
self.input = Input::Confirm(Confirm::ReplacePlaylist(name.to_string()));
} else {
self.save_selection(name);
}
}
fn playlist_under_cursor(&self) -> Option<Playlist> {
if self.view != View::Playlists {
return None;
}
self.playlist_state
.selected()
.and_then(|i| self.playlists.get(i))
.cloned()
}
fn play_playlist_named(&mut self, name: &str) {
let Some(pl) = query::find_playlist(&self.playlists, name.trim()).cloned() else {
return self.notify(format!("no single playlist named \"{}\"", name.trim()));
};
let tracks = query::playlist_tracks(&self.conn, pl.id).unwrap_or_default();
if tracks.is_empty() {
return self.notify("playlist is empty");
}
self.play(tracks, 0);
self.notify(format!("playing \"{}\"", pl.name));
}
fn command_key(&mut self, key: KeyEvent, mut line: CommandLine) {
match key.code {
KeyCode::Esc => return self.input = Input::None,
KeyCode::Enter => {
self.input = Input::None;
if line.text.trim().is_empty() {
return;
}
self.history.push(&line.text);
match command::parse(&line.text, self.view) {
Ok(action) => self.perform(action),
Err(e) => self.notify(e),
}
return;
}
KeyCode::Backspace if !line.pop() => return self.input = Input::None,
KeyCode::Tab | KeyCode::BackTab => {
let names: Vec<String> = self.playlists.iter().map(|p| p.name.clone()).collect();
line.complete(key.code == KeyCode::Tab, self.view, &names);
}
KeyCode::Up => line.recall(true, &self.history),
KeyCode::Down => line.recall(false, &self.history),
_ => {
if let Some(c) = typed(&key) {
line.push(c);
}
}
}
self.input = Input::Command(line);
}
fn search_key(&mut self, key: KeyEvent, mut buf: String) {
match key.code {
KeyCode::Esc => {
self.input = Input::None;
self.results = None;
}
KeyCode::Enter => {
self.input = Input::None;
if self.visible().is_empty() {
self.notify("no matches");
}
}
KeyCode::Backspace => {
buf.pop();
self.apply_search(&buf);
self.input = Input::Search(buf);
}
KeyCode::Char(c) if typed(&key).is_some() => {
buf.push(c);
self.apply_search(&buf);
self.input = Input::Search(buf);
}
_ => self.input = Input::Search(buf),
}
}
fn apply_search(&mut self, term: &str) {
self.view = View::Library;
if term.is_empty() {
self.results = None;
} else {
self.results = Some(query::search(&self.conn, term).unwrap_or_default());
}
self.library_state.select(if self.visible().is_empty() {
None
} else {
Some(0)
});
}
fn save_key(&mut self, key: KeyEvent, mut buf: String) {
match key.code {
KeyCode::Esc => self.input = Input::None,
KeyCode::Enter => {
self.input = Input::None;
self.save_as(&buf);
}
KeyCode::Backspace => {
buf.pop();
self.input = Input::SavePlaylist(buf);
}
KeyCode::Char(c) if typed(&key).is_some() => {
buf.push(c);
self.input = Input::SavePlaylist(buf);
}
_ => self.input = Input::SavePlaylist(buf),
}
}
fn start_rename(&mut self) {
let Some(from) = self.playlist_under_cursor() else {
return self.notify("no playlist under the cursor in the playlists view");
};
let name = from.name.clone();
self.input = Input::RenamePlaylist { from, name };
}
fn rename_key(&mut self, key: KeyEvent, from: Playlist, mut name: String) {
match key.code {
KeyCode::Esc => self.input = Input::None,
KeyCode::Enter => {
self.input = Input::None;
self.rename_to(&from, &name);
}
KeyCode::Backspace => {
name.pop();
self.input = Input::RenamePlaylist { from, name };
}
KeyCode::Char(c) if typed(&key).is_some() => {
name.push(c);
self.input = Input::RenamePlaylist { from, name };
}
_ => self.input = Input::RenamePlaylist { from, name },
}
}
fn rename_to(&mut self, from: &Playlist, name: &str) {
let name = name.trim();
if name.is_empty() {
self.notify("playlist name cannot be empty");
} else if name == from.name {
self.notify("name unchanged");
} else if self.playlists.iter().any(|p| p.name == name) {
self.notify(format!("a playlist named \"{name}\" already exists"));
} else {
self.rename_playlist(from, name);
}
}
fn rename_playlist(&mut self, from: &Playlist, name: &str) {
if let Err(e) = query::rename_playlist(&self.conn, from.id, name) {
return self.notify(format!("could not rename: {e}"));
}
self.playlists = query::playlists(&self.conn).unwrap_or_default();
let at = self.playlists.iter().position(|p| p.id == from.id);
self.playlist_state.select(at);
self.notify(format!("renamed \"{}\" to \"{name}\"", from.name));
}
fn save_selection(&mut self, name: &str) {
let ids: Vec<i64> = self
.selection
.iter()
.map(|t| t.id)
.filter(|id| *id != 0)
.collect();
match query::save_playlist(&mut self.conn, name, &ids) {
Ok(_) => {
let left_out = self.selection.len() - ids.len();
let note = if left_out > 0 {
format!(", {left_out} not in the library left out")
} else {
String::new()
};
self.notify(format!("saved \"{name}\" ({} tracks{note})", ids.len()));
self.playlists = query::playlists(&self.conn).unwrap_or_default();
}
Err(e) => self.notify(format!("could not save: {e}")),
}
}
fn confirm(&mut self, action: Confirm) {
match action {
Confirm::ReplacePlaylist(name) => self.save_selection(&name),
Confirm::ClearMarks(_) => {
let Some(path) = self.marks_for.clone() else {
return;
};
match query::clear_marks(&self.conn, &path.to_string_lossy()) {
Ok(_) => {
self.marks.clear();
self.notify("marks cleared");
}
Err(e) => self.notify(format!("could not clear marks: {e}")),
}
}
Confirm::ClearSelection(_) => {
self.selection.clear();
self.selection_state.select(None);
self.notify("selection cleared");
}
Confirm::DeletePlaylist(pl) => {
if query::delete_playlist(&self.conn, pl.id).is_ok() {
self.playlists = query::playlists(&self.conn).unwrap_or_default();
self.view = View::Playlists;
self.select(self.playlist_state.selected().unwrap_or(0));
self.notify(format!("deleted \"{}\"", pl.name));
}
}
}
}
fn len(&self) -> usize {
match self.view {
View::Library => self.visible().len(),
View::Selection => self.selection.len(),
View::Playlists => self.playlists.len(),
}
}
fn state_mut(&mut self) -> &mut ListState {
match self.view {
View::Library => &mut self.library_state,
View::Selection => &mut self.selection_state,
View::Playlists => &mut self.playlist_state,
}
}
fn select(&mut self, i: usize) {
let len = self.len();
if len == 0 {
self.state_mut().select(None);
} else {
self.state_mut().select(Some(i.min(len - 1)));
}
}
fn move_selection(&mut self, delta: i64) {
let len = self.len();
if len == 0 {
return;
}
let cur = self.state_mut().selected().unwrap_or(0) as i64;
let next = (cur + delta).clamp(0, len as i64 - 1) as usize;
self.state_mut().select(Some(next));
}
fn activate(&mut self) {
match self.view {
View::Library => {
let Some(i) = self.library_state.selected() else {
return;
};
let tracks = self.visible().to_vec();
if tracks.is_empty() {
return;
}
self.play(tracks, i);
}
View::Selection => {
if let Some(i) = self.selection_state.selected() {
self.play(self.selection.clone(), i);
}
}
View::Playlists => {
let Some(i) = self.playlist_state.selected() else {
return;
};
let Some(pl) = self.playlists.get(i) else {
return;
};
let tracks = query::playlist_tracks(&self.conn, pl.id).unwrap_or_default();
if tracks.is_empty() {
self.notify("playlist is empty");
return;
}
let name = pl.name.clone();
self.play(tracks, 0);
self.notify(format!("playing \"{name}\""));
}
}
}
fn play(&mut self, tracks: Vec<Track>, index: usize) {
let paths = tracks.iter().map(|t| PathBuf::from(&t.path)).collect();
self.player.send(Cmd::Play(paths, index));
self.playing = tracks;
self.playing_source = self.player.queue();
}
fn append_selection(&mut self) {
if self.view == View::Library {
return self.toggle_selected_track();
}
let added: Vec<Track> = match self.view {
View::Playlists => self
.playlist_state
.selected()
.and_then(|i| self.playlists.get(i))
.map(|pl| query::playlist_tracks(&self.conn, pl.id).unwrap_or_default())
.unwrap_or_default(),
View::Library | View::Selection => return,
};
if added.is_empty() {
return;
}
let selected: HashSet<&str> = self.selection.iter().map(|t| t.path.as_str()).collect();
let new: Vec<Track> = added
.into_iter()
.filter(|t| !selected.contains(t.path.as_str()))
.collect();
self.move_selection(1);
if new.is_empty() {
self.notify("already in selection");
return;
}
self.selection.extend(new);
if self.selection_state.selected().is_none() {
self.selection_state.select(Some(0));
}
self.notify("added to selection");
}
fn toggle_selected_track(&mut self) {
let Some(track) = self
.library_state
.selected()
.and_then(|i| self.visible().get(i).cloned())
else {
return;
};
if self.selection.iter().any(|t| t.path == track.path) {
self.selection.retain(|t| t.path != track.path);
let len = self.selection.len();
let cursor = self
.selection_state
.selected()
.map(|i| i.min(len.saturating_sub(1)));
self.selection_state
.select(if len == 0 { None } else { cursor });
self.notify("removed from selection");
} else {
self.selection.push(track);
if self.selection_state.selected().is_none() {
self.selection_state.select(Some(0));
}
self.notify("added to selection");
}
self.move_selection(1);
}
fn remove_from_selection(&mut self) {
let Some(i) = self
.selection_state
.selected()
.filter(|i| *i < self.selection.len())
else {
return;
};
let removed = self.selection.remove(i);
self.select(i);
self.notify(format!("removed \"{}\"", removed.display_title()));
}
fn move_in_selection(&mut self, delta: i64) {
let Some(i) = self
.selection_state
.selected()
.filter(|i| *i < self.selection.len())
else {
return;
};
let j = i as i64 + delta;
if j < 0 || j >= self.selection.len() as i64 {
return;
}
self.selection.swap(i, j as usize);
self.selection_state.select(Some(j as usize));
}
fn delete_playlist(&mut self) {
if self.view != View::Playlists {
return;
}
let Some(i) = self.playlist_state.selected() else {
return;
};
let Some(pl) = self.playlists.get(i).cloned() else {
return;
};
self.input = Input::Confirm(Confirm::DeletePlaylist(pl));
}
fn follow_marks(&mut self, path: Option<&PathBuf>) {
if self.marks_for.as_ref() == path {
return;
}
self.marks = path
.and_then(|p| query::marks(&self.conn, &p.to_string_lossy()).ok())
.unwrap_or_default();
self.marks_for = path.cloned();
}
fn playing_track(&mut self) -> Option<(PathBuf, u32)> {
let status = self.player.status();
let track = match (status.state, status.current(), status.source) {
(State::Playing | State::Paused, Some(path), Some(source)) => {
Some((path.clone(), source.rate))
}
_ => None,
};
if track.is_none() {
self.notify("nothing is playing");
}
track
}
fn add_mark(&mut self, at: Option<Duration>) {
let Some((path, rate)) = self.playing_track() else {
return;
};
self.follow_marks(Some(&path));
let at = at.unwrap_or_else(|| self.player.position());
if let Some(near) = self
.marks
.iter()
.find(|m| m.time().abs_diff(at) < MARK_NEAR)
{
return self.notify(format!("already marked at {}", fmt_time(near.time())));
}
let mark = Mark::at_time(at, rate);
if let Err(e) = query::add_mark(&self.conn, &path.to_string_lossy(), mark) {
return self.notify(format!("could not mark: {e}"));
}
self.marks.push(mark);
self.marks.sort_by_key(|m| m.frame);
let kept = if self.conn.path().is_none_or(str::is_empty) {
" (not kept: no library file)"
} else {
""
};
self.notify(format!("marked {}{kept}", fmt_time(at)));
}
fn undo_mark(&mut self) {
let Some((path, _)) = self.playing_track() else {
return;
};
self.follow_marks(Some(&path));
match query::remove_last_mark(&self.conn, &path.to_string_lossy()) {
Ok(Some(mark)) => {
self.marks.retain(|m| m.frame != mark.frame);
self.notify(format!("removed mark at {}", fmt_time(mark.time())));
}
Ok(None) => self.notify("no marks in this track"),
Err(e) => self.notify(format!("could not remove mark: {e}")),
}
}
fn ask_to_clear_marks(&mut self) {
let Some((path, _)) = self.playing_track() else {
return;
};
self.follow_marks(Some(&path));
if self.marks.is_empty() {
return self.notify("no marks in this track");
}
self.input = Input::Confirm(Confirm::ClearMarks(self.marks.len()));
}
fn jump_to_mark(&mut self, forward: bool) {
let Some((path, _)) = self.playing_track() else {
return;
};
self.follow_marks(Some(&path));
let at = self.player.position();
let mut times = self.marks.iter().map(Mark::time);
let target = if forward {
times.find(|t| *t > at + MARK_NEAR / 2)
} else {
times.rev().find(|t| *t + MARK_BACK < at)
};
match target {
Some(t) => {
self.player.send(Cmd::Seek(t));
self.notify(format!("mark at {}", fmt_time(t)));
}
None if forward => self.notify("no later mark"),
None => self.notify("no earlier mark"),
}
}
fn cycle_mode(&mut self, forward: bool) {
let current = self.player.mode();
let mode = if forward {
current.next()
} else {
current.prev()
};
self.player.send(Cmd::SetMode(mode));
self.notify(format!("mode: {}", mode.name()));
}
fn nudge_volume(&mut self, delta: f32) {
let v = (self.player.volume() + delta).clamp(0.0, 1.0);
self.player.send(Cmd::SetVolume(v));
}
}
const MARK_NEAR: Duration = Duration::from_millis(500);
const MARK_BACK: Duration = Duration::from_secs(1);
fn typed(key: &KeyEvent) -> Option<char> {
match key.code {
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
Some(c)
}
_ => None,
}
}
pub const PEAK_HOLD: Duration = Duration::from_millis(1500);
pub fn hold_peak(
held: Option<(f32, Instant)>,
reading: f32,
now: Instant,
) -> Option<(f32, Instant)> {
match held {
Some((level, at)) if level >= reading && now.duration_since(at) < PEAK_HOLD => held,
_ => (reading > 0.0).then_some((reading, now)),
}
}
pub fn fmt_time(d: Duration) -> String {
let total = d.as_secs();
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
pub fn state_glyph(s: State) -> &'static str {
match s {
State::Playing => ">",
State::Paused => "||",
State::Stopped => "#",
}
}