pub mod render;
use std::path::PathBuf;
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, Playlist};
use crate::db::Track;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Library,
Queue,
Playlists,
}
impl View {
fn next(self) -> Self {
match self {
View::Library => View::Queue,
View::Queue => View::Playlists,
View::Playlists => View::Library,
}
}
fn title(self) -> &'static str {
match self {
View::Library => "Library",
View::Queue => "Queue",
View::Playlists => "Playlists",
}
}
}
pub enum Input {
None,
Search(String),
SavePlaylist(String),
Confirm(Confirm),
}
pub enum Confirm {
DeletePlaylist(Playlist),
ReplacePlaylist(String),
}
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 queue? (y/n)")
}
}
}
}
pub struct App {
conn: Connection,
player: Player,
view: View,
all: Vec<Track>,
results: Option<Vec<Track>>,
library_state: ListState,
queue: Vec<Track>,
queue_state: ListState,
playlists: Vec<Playlist>,
playlist_state: ListState,
input: Input,
message: Option<(String, Instant)>,
quit: bool,
seen_error: u64,
followed: Option<usize>,
snapshot: Snapshot,
}
pub struct Screen<'a> {
pub view: View,
pub snapshot: &'a Snapshot,
pub all: &'a [Track],
pub results: Option<&'a [Track]>,
pub queue: &'a [Track],
pub playlists: &'a [Playlist],
pub input: &'a Input,
pub message: Option<&'a str>,
pub library_state: &'a mut ListState,
pub queue_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,
}
impl App {
pub fn new(conn: Connection, player: Player) -> Self {
Self::with_queue(conn, player, Vec::new())
}
pub fn with_queue(conn: Connection, player: Player, queue: Vec<Track>) -> Self {
let mut app = App {
conn,
player,
view: View::Library,
all: Vec::new(),
results: None,
library_state: ListState::default(),
queue,
queue_state: ListState::default(),
playlists: Vec::new(),
playlist_state: ListState::default(),
input: Input::None,
message: None,
quit: false,
seen_error: 0,
followed: None,
snapshot: Snapshot::default(),
};
if !app.queue.is_empty() {
app.queue_state.select(Some(0));
app.view = View::Queue;
}
app.reload();
app
}
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(),
queue: &self.queue,
playlists: &self.playlists,
input: &self.input,
message: self.message.as_ref().map(|(m, _)| m.as_str()),
library_state: &mut self.library_state,
queue_state: &mut self.queue_state,
playlist_state: &mut self.playlist_state,
}
}
pub fn run(mut self, terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<()> {
while !self.quit {
self.snapshot = Snapshot {
status: self.player.status(),
position: self.player.position(),
volume: self.player.volume(),
};
if self.snapshot.status.error_seq > self.seen_error {
self.seen_error = self.snapshot.status.error_seq;
if let Some(e) = self.snapshot.status.error.clone() {
self.notify(e);
}
}
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);
}
}
}
self.sync_queue();
if let Some((_, at)) = &self.message {
if at.elapsed() > Duration::from_secs(4) {
self.message = None;
}
}
}
Ok(())
}
fn sync_queue(&mut self) {
let status = &self.snapshot.status;
if status.queue.len() != self.queue.len() || self.queue.is_empty() {
return;
}
let playing = match status.state {
State::Playing | State::Paused => Some(status.index),
State::Stopped => None,
};
if let Some(target) = follow_target(self.followed, playing, self.queue_state.selected()) {
self.followed = Some(target);
self.queue_state.select(Some(target));
}
}
fn notify(&mut self, msg: impl Into<String>) {
self.message = Some((msg.into(), Instant::now()));
}
pub fn on_key(&mut self, key: KeyEvent) {
match &self.input {
Input::Confirm(_) => {
let Input::Confirm(action) = std::mem::replace(&mut self.input, Input::None) else {
unreachable!()
};
if key.code == KeyCode::Char('y') {
self.confirm(action);
} else {
self.notify("cancelled");
}
return;
}
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::None => {}
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.quit = true;
return;
}
match key.code {
KeyCode::Char('q') => self.quit = true,
KeyCode::Tab => self.view = self.view.next(),
KeyCode::Char('1') => self.view = View::Library,
KeyCode::Char('2') => self.view = View::Queue,
KeyCode::Char('3') => self.view = View::Playlists,
KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
KeyCode::PageDown => self.move_selection(10),
KeyCode::PageUp => self.move_selection(-10),
KeyCode::Char('g') | KeyCode::Home => self.select(0),
KeyCode::Char('G') | KeyCode::End => self.select(self.len().saturating_sub(1)),
KeyCode::Char(' ') => self.player.send(Cmd::TogglePause),
KeyCode::Char('n') => self.player.send(Cmd::Next),
KeyCode::Char('p') => self.player.send(Cmd::Prev),
KeyCode::Char('x') => self.player.send(Cmd::Stop),
KeyCode::Char('+') | KeyCode::Char('=') => self.nudge_volume(0.05),
KeyCode::Char('-') | KeyCode::Char('_') => self.nudge_volume(-0.05),
KeyCode::Right => self.player.send(Cmd::SeekBy(5)),
KeyCode::Left => self.player.send(Cmd::SeekBy(-5)),
KeyCode::Char(']') => self.player.send(Cmd::SpeedBy(1)),
KeyCode::Char('[') => self.player.send(Cmd::SpeedBy(-1)),
KeyCode::Char('\\') => self.player.send(Cmd::SpeedReset),
KeyCode::Char('/') => self.input = Input::Search(String::new()),
KeyCode::Esc => {
if self.results.take().is_some() {
self.library_state.select(Some(0));
}
}
KeyCode::Enter => self.activate(),
KeyCode::Char('a') => self.append_selection(),
KeyCode::Char('s') => {
if self.queue.is_empty() {
self.notify("queue is empty");
} else if self.conn.path().is_none_or(str::is_empty) {
self.notify("no library to save to; `playr scan <dir>` creates one");
} else {
self.input = Input::SavePlaylist(String::new());
}
}
KeyCode::Char('d') => self.delete_playlist(),
_ => {}
}
}
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) => {
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;
let name = buf.trim().to_string();
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));
} else {
self.save_queue(&name);
}
}
KeyCode::Backspace => {
buf.pop();
self.input = Input::SavePlaylist(buf);
}
KeyCode::Char(c) => {
buf.push(c);
self.input = Input::SavePlaylist(buf);
}
_ => self.input = Input::SavePlaylist(buf),
}
}
fn save_queue(&mut self, name: &str) {
let ids: Vec<i64> = self
.queue
.iter()
.map(|t| t.id)
.filter(|id| *id != 0)
.collect();
match query::save_playlist(&mut self.conn, name, &ids) {
Ok(_) => {
self.notify(format!("saved \"{name}\" ({} tracks)", 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_queue(&name),
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::Queue => self.queue.len(),
View::Playlists => self.playlists.len(),
}
}
fn state_mut(&mut self) -> &mut ListState {
match self.view {
View::Library => &mut self.library_state,
View::Queue => &mut self.queue_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::Queue => {
if let Some(i) = self.queue_state.selected() {
let paths = self.queue_paths();
self.player.send(Cmd::Play(paths, 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) {
self.queue = tracks;
self.queue_state.select(Some(index));
let paths = self.queue_paths();
self.player.send(Cmd::Play(paths, index));
}
fn queue_paths(&self) -> Vec<PathBuf> {
self.queue.iter().map(|t| PathBuf::from(&t.path)).collect()
}
fn append_selection(&mut self) {
let added: Vec<Track> = match self.view {
View::Library => self
.library_state
.selected()
.and_then(|i| self.visible().get(i).cloned())
.into_iter()
.collect(),
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::Queue => return,
};
if added.is_empty() {
return;
}
let paths: Vec<PathBuf> = added.iter().map(|t| PathBuf::from(&t.path)).collect();
self.queue.extend(added);
self.player.send(Cmd::Enqueue(paths.clone()));
self.notify(format!("queued {} track(s)", paths.len()));
}
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 nudge_volume(&mut self, delta: f32) {
let v = (self.snapshot.volume + delta).clamp(0.0, 1.0);
self.player.send(Cmd::SetVolume(v));
}
}
pub fn follow_target(
last_seen: Option<usize>,
playing: Option<usize>,
selected: Option<usize>,
) -> Option<usize> {
match playing {
None => None,
Some(now) if selected.is_none() => Some(now),
Some(now) if last_seen != Some(now) => Some(now),
_ => None,
}
}
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 => "#",
}
}