init
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+3232
File diff suppressed because it is too large
Load Diff
+30
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "ncmprs"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "An example generated using the event-driven-async template"
|
||||||
|
authors = ["nicknase27 <80787938+nicknase27@users.noreply.github.com>"]
|
||||||
|
license = "MIT"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||||
|
futures = "0.3.32"
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
|
color-eyre = "0.6.5"
|
||||||
|
dirs = "6.0.0"
|
||||||
|
rodio = "0.22.2"
|
||||||
|
walkdir = "2.5.0"
|
||||||
|
metaflac = "0.2.8"
|
||||||
|
audiotags = "0.5.0"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.150"
|
||||||
|
discord-rich-presence = "1.1.0"
|
||||||
|
config = "0.15.25"
|
||||||
|
|
||||||
|
# Read the optimization guideline for more details: https://ratatui.rs/recipes/apps/release-your-app/#optimizations
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
opt-level = "s"
|
||||||
|
strip = true
|
||||||
+412
@@ -0,0 +1,412 @@
|
|||||||
|
use std::fs::{File};
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::collections::{VecDeque};
|
||||||
|
|
||||||
|
use crate::event::{AppEvent, Event, EventHandler};
|
||||||
|
use crate::library::{Library, Song};
|
||||||
|
|
||||||
|
use crossterm::event::MediaKeyCode::{self, PlayPause};
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use discord_rich_presence::activity::{Assets, Timestamps};
|
||||||
|
use ratatui::DefaultTerminal;
|
||||||
|
use ratatui::widgets::{List, ListItem, ListState};
|
||||||
|
use rodio::{Decoder};
|
||||||
|
use audiotags::{MimeType, Tag};
|
||||||
|
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum Focus {
|
||||||
|
#[default]
|
||||||
|
Artists,
|
||||||
|
Albums,
|
||||||
|
Songs,
|
||||||
|
Queue,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application.
|
||||||
|
//#[derive(Debug)]
|
||||||
|
pub struct App {
|
||||||
|
/// Is the application running?
|
||||||
|
pub running: bool,
|
||||||
|
|
||||||
|
pub library: Library,
|
||||||
|
|
||||||
|
pub queue: VecDeque<usize>,
|
||||||
|
|
||||||
|
pub sink_handle: rodio::MixerDeviceSink,
|
||||||
|
pub player: rodio::Player,
|
||||||
|
|
||||||
|
pub artist_state: ListState,
|
||||||
|
pub album_state: ListState,
|
||||||
|
pub song_state: ListState,
|
||||||
|
pub queue_state: ListState,
|
||||||
|
pub focus: Focus,
|
||||||
|
|
||||||
|
pub current_song: Option<usize>,
|
||||||
|
|
||||||
|
pub discord_client: Option<DiscordIpcClient>,
|
||||||
|
|
||||||
|
/// Event handler.
|
||||||
|
pub events: EventHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Constructs a new instance of [`App`].
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let sink_handle = rodio::DeviceSinkBuilder::open_default_sink()
|
||||||
|
.expect("open default audio stream");
|
||||||
|
let player = rodio::Player::connect_new(sink_handle.mixer());
|
||||||
|
player.set_volume(1.0);
|
||||||
|
|
||||||
|
let discord_client = {
|
||||||
|
let mut client = DiscordIpcClient::new("1525954755292299426");
|
||||||
|
|
||||||
|
match client.connect() {
|
||||||
|
Ok(_) => Some(client),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Discord RPC unavailable: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
Self {
|
||||||
|
running: true,
|
||||||
|
library: Library::new(),
|
||||||
|
//songs: get_songs(),
|
||||||
|
queue: vec![].into(),
|
||||||
|
artist_state: ListState::default().with_selected(Some(0)),
|
||||||
|
album_state: ListState::default().with_selected(Some(0)),
|
||||||
|
song_state: ListState::default().with_selected(Some(0)),
|
||||||
|
queue_state: ListState::default().with_selected(Some(0)),
|
||||||
|
focus: Focus::default(),
|
||||||
|
events: EventHandler::new(),
|
||||||
|
current_song: None,
|
||||||
|
sink_handle,
|
||||||
|
player,
|
||||||
|
discord_client,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the application's main loop.
|
||||||
|
pub async fn run(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> {
|
||||||
|
while self.running {
|
||||||
|
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
|
||||||
|
match self.events.next().await? {
|
||||||
|
Event::Tick => self.tick(),
|
||||||
|
Event::Crossterm(event) => match event {
|
||||||
|
crossterm::event::Event::Key(key_event)
|
||||||
|
if key_event.kind == crossterm::event::KeyEventKind::Press =>
|
||||||
|
{
|
||||||
|
self.handle_key_events(key_event)?
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::App(app_event) => match app_event {
|
||||||
|
AppEvent::Play => match self.focus {
|
||||||
|
Focus::Artists => (),
|
||||||
|
Focus::Albums => self.enqueue_album(),
|
||||||
|
Focus::Songs => self.play_song(self.song_state.selected().expect("None")),
|
||||||
|
_ => (),
|
||||||
|
},
|
||||||
|
AppEvent::Skip => self.skip(),
|
||||||
|
AppEvent::PlayPause => self.play_pause(),
|
||||||
|
AppEvent::IncVolume => self.inc_volume(),
|
||||||
|
AppEvent::DecVolume => self.dec_volume(),
|
||||||
|
AppEvent::MoveRight => self.move_right(),
|
||||||
|
AppEvent::MoveLeft => self.move_left(),
|
||||||
|
AppEvent::RemoveFromQueue => self.remove_from_queue(),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
AppEvent::Quit => self.quit(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(self.discord_client);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the key events and updates the state of [`App`].
|
||||||
|
pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
|
||||||
|
match key_event.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit),
|
||||||
|
KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => {
|
||||||
|
self.events.send(AppEvent::Quit)
|
||||||
|
},
|
||||||
|
|
||||||
|
KeyCode::Char('R') => {
|
||||||
|
self.library = Library::scan();
|
||||||
|
self.library.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => self.events.send(AppEvent::MoveRight),
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => self.events.send(AppEvent::MoveLeft),
|
||||||
|
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => match self.focus {
|
||||||
|
Focus::Artists => self.artist_state.select_next(),
|
||||||
|
Focus::Albums => self.album_state.select_next(),
|
||||||
|
Focus::Songs => self.song_state.select_next(),
|
||||||
|
Focus::Queue => self.queue_state.select_next(),
|
||||||
|
},
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => match self.focus {
|
||||||
|
Focus::Artists => self.artist_state.select_previous(),
|
||||||
|
Focus::Albums => self.album_state.select_previous(),
|
||||||
|
Focus::Songs => self.song_state.select_previous(),
|
||||||
|
Focus::Queue => self.queue_state.select_previous(),
|
||||||
|
},
|
||||||
|
|
||||||
|
KeyCode::Char('g') => match self.focus {
|
||||||
|
Focus::Artists => self.artist_state.select_first(),
|
||||||
|
Focus::Albums => self.album_state.select_first(),
|
||||||
|
Focus::Songs => self.song_state.select_first(),
|
||||||
|
Focus::Queue => self.queue_state.select_first(),
|
||||||
|
},
|
||||||
|
KeyCode::Char('G') => match self.focus {
|
||||||
|
Focus::Artists => self.artist_state.select_last(),
|
||||||
|
Focus::Albums => self.album_state.select_last(),
|
||||||
|
Focus::Songs => self.song_state.select_last(),
|
||||||
|
Focus::Queue => self.queue_state.select_last(),
|
||||||
|
},
|
||||||
|
|
||||||
|
KeyCode::Enter => self.events.send(AppEvent::Play),
|
||||||
|
KeyCode::Char('x') => self.stop_playback(),
|
||||||
|
KeyCode::Char(' ') | KeyCode::Media(MediaKeyCode::PlayPause) => self.events.send(AppEvent::PlayPause),
|
||||||
|
KeyCode::Char('+') | KeyCode::Char('=') => self.events.send(AppEvent::IncVolume),
|
||||||
|
KeyCode::Char('-') | KeyCode::Char('_') => self.events.send(AppEvent::DecVolume),
|
||||||
|
KeyCode::Delete => self.events.send(AppEvent::RemoveFromQueue),
|
||||||
|
KeyCode::Char('>') => self.events.send(AppEvent::Skip),
|
||||||
|
// Other handlers you could add here.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
pub fn play(&mut self) {
|
||||||
|
let file = File::open(self.songs[self.list_state.selected().expect("None")].clone()).unwrap();
|
||||||
|
let source = Decoder::new(BufReader::new(file)).unwrap();
|
||||||
|
|
||||||
|
if self.player.empty() {
|
||||||
|
self.player.append(source);
|
||||||
|
} else {
|
||||||
|
self.player.stop();
|
||||||
|
self.player.append(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
pub fn play(&mut self) {
|
||||||
|
if !self.queue.is_empty() {
|
||||||
|
for _i in 0..self.queue.len() {
|
||||||
|
let file = File::open(self.library.songs[*self.queue.front().expect("None")].path.clone()).unwrap();
|
||||||
|
let source = Decoder::new(BufReader::new(file)).unwrap();
|
||||||
|
self.queue.pop_front();
|
||||||
|
self.player.append(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
let file = File::open(self.library.songs[self.artist_state.selected().expect("None")].path.clone()).unwrap();
|
||||||
|
let source = Decoder::new(BufReader::new(file)).unwrap();
|
||||||
|
if self.player.empty() {
|
||||||
|
self.player.append(source);
|
||||||
|
} else {
|
||||||
|
self.player.stop();
|
||||||
|
self.player.append(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_song(&mut self, index: usize) {
|
||||||
|
let song_indexes = self.current_selected_song();
|
||||||
|
let song_index = song_indexes[index];
|
||||||
|
|
||||||
|
self.queue.push_back(song_index);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enqueue_album(&mut self) {
|
||||||
|
let song_indexes = self.current_selected_song();
|
||||||
|
|
||||||
|
for &song in &song_indexes {
|
||||||
|
self.queue.push_back(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
let song = &self.library.songs[song_indexes[0]];
|
||||||
|
|
||||||
|
// We only need to read the tags if we might have to save the artwork.
|
||||||
|
let tag = Tag::default()
|
||||||
|
.read_from_path(&song.path)
|
||||||
|
.expect("Error");
|
||||||
|
|
||||||
|
if let Some(cover) = tag.album_cover() {
|
||||||
|
let extension = match cover.mime_type {
|
||||||
|
MimeType::Png => "png",
|
||||||
|
MimeType::Jpeg => "jpg",
|
||||||
|
_ => "bin",
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = format!("{} - {}.{}", song.artist, song.album, extension);
|
||||||
|
|
||||||
|
let path = dirs::cache_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join("ncmprs")
|
||||||
|
.join("art")
|
||||||
|
.join(filename);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&path, cover.data).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_selected_song(&mut self) -> Vec<usize> {
|
||||||
|
let selected_artist = &self.library.artists[self.artist_state.selected().unwrap()];
|
||||||
|
let albums = self.library.albums_for_artist(selected_artist);
|
||||||
|
let selected_album = &albums[self.album_state.selected().unwrap()];
|
||||||
|
let song_indexes = self.library.songs_for_album(selected_artist, selected_album);
|
||||||
|
song_indexes.to_owned()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_pause(&mut self) {
|
||||||
|
if !self.player.is_paused() {
|
||||||
|
self.player.pause();
|
||||||
|
} else if self.player.is_paused() {
|
||||||
|
self.player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inc_volume(&mut self) {
|
||||||
|
let vol = self.player.volume();
|
||||||
|
//self.player.set_volume(self.player.volume() + 0.1);
|
||||||
|
if vol >= 1.0 {
|
||||||
|
self.player.set_volume(1.0);
|
||||||
|
} else {
|
||||||
|
self.player.set_volume(vol + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dec_volume(&mut self) {
|
||||||
|
let vol = self.player.volume();
|
||||||
|
if vol <= 0.01 {
|
||||||
|
self.player.set_volume(0.0);
|
||||||
|
} else {
|
||||||
|
self.player.set_volume(vol - 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn skip(&mut self) {
|
||||||
|
self.player.skip_one();
|
||||||
|
if self.queue.is_empty() {
|
||||||
|
|
||||||
|
self.current_song = None;
|
||||||
|
|
||||||
|
if let Some(client) = self.discord_client.as_mut() {
|
||||||
|
let _ = client.clear_activity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_from_queue(&mut self) {
|
||||||
|
if self.queue_state.selected().is_some() {
|
||||||
|
self.queue.remove(self.queue_state.selected().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_playback(&mut self) {
|
||||||
|
self.queue.clear();
|
||||||
|
self.player.stop();
|
||||||
|
if let Some(client) = self.discord_client.as_mut() {
|
||||||
|
let _ = client.clear_activity();
|
||||||
|
}
|
||||||
|
self.current_song = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn move_right(&mut self) {
|
||||||
|
match self.focus {
|
||||||
|
Focus::Artists => self.focus = Focus::Albums,
|
||||||
|
Focus::Albums => self.focus = Focus::Songs,
|
||||||
|
Focus::Songs => self.focus = Focus::Queue,
|
||||||
|
Focus::Queue => self.focus = Focus::Artists,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_left(&mut self) {
|
||||||
|
match self.focus {
|
||||||
|
Focus::Artists => self.focus = Focus::Queue,
|
||||||
|
Focus::Albums => self.focus = Focus::Artists,
|
||||||
|
Focus::Songs => self.focus = Focus::Albums,
|
||||||
|
Focus::Queue => self.focus = Focus::Songs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_song(&self) -> Option<&Song> {
|
||||||
|
self.current_song.map(|i| &self.library.songs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_song_title(&self) -> &str {
|
||||||
|
self.current_song()
|
||||||
|
.map(|s| s.title.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_rpc(&mut self, index: usize) {
|
||||||
|
if let Some(client) = self.discord_client.as_mut() {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let song = &self.library.songs[index];
|
||||||
|
let duration = song.duration.unwrap_or(1.0) as i64;
|
||||||
|
let artist = &song.artist;
|
||||||
|
|
||||||
|
let timestamps = Timestamps::new()
|
||||||
|
.start(now)
|
||||||
|
.end(now + duration);
|
||||||
|
let asset = Assets::new();
|
||||||
|
|
||||||
|
|
||||||
|
let payload = activity::Activity::new()
|
||||||
|
.name(song.title.clone())
|
||||||
|
.state(artist)
|
||||||
|
.activity_type(activity::ActivityType::Listening)
|
||||||
|
.timestamps(timestamps)
|
||||||
|
.assets(asset.large_image("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/navidrome.png"));
|
||||||
|
|
||||||
|
let _ = client.set_activity(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the tick event of the terminal.
|
||||||
|
///
|
||||||
|
/// The tick event is where you can update the state of your application with any logic that
|
||||||
|
/// needs to be updated at a fixed frame rate. E.g. polling a server, updating an animation.
|
||||||
|
pub fn tick(&mut self) {
|
||||||
|
if self.player.empty() && !self.queue.is_empty() {
|
||||||
|
let file = File::open(&self.library.songs[*self.queue.front().expect("Queue is empty")].path).unwrap();
|
||||||
|
let source = Decoder::new(BufReader::new(file)).unwrap();
|
||||||
|
self.player.append(source);
|
||||||
|
self.current_song = Some(self.queue.front().unwrap().to_owned());
|
||||||
|
|
||||||
|
self.update_rpc(self.queue.front().unwrap().to_owned());
|
||||||
|
self.queue.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set running to false to quit the application.
|
||||||
|
pub fn quit(&mut self) {
|
||||||
|
self.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
use color_eyre::eyre::OptionExt;
|
||||||
|
use crossterm::event::Event as CrosstermEvent;
|
||||||
|
use futures::{FutureExt, StreamExt};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// The frequency at which tick events are emitted.
|
||||||
|
const TICK_FPS: f64 = 30.0;
|
||||||
|
|
||||||
|
/// Representation of all possible events.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Event {
|
||||||
|
/// An event that is emitted on a regular schedule.
|
||||||
|
///
|
||||||
|
/// Use this event to run any code which has to run outside of being a direct response to a user
|
||||||
|
/// event. e.g. polling exernal systems, updating animations, or rendering the UI based on a
|
||||||
|
/// fixed frame rate.
|
||||||
|
Tick,
|
||||||
|
/// Crossterm events.
|
||||||
|
///
|
||||||
|
/// These events are emitted by the terminal.
|
||||||
|
Crossterm(CrosstermEvent),
|
||||||
|
/// Application events.
|
||||||
|
///
|
||||||
|
/// Use this event to emit custom events that are specific to your application.
|
||||||
|
App(AppEvent),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application events.
|
||||||
|
///
|
||||||
|
/// You can extend this enum with your own custom events.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum AppEvent {
|
||||||
|
Play,
|
||||||
|
PlayPause,
|
||||||
|
Skip,
|
||||||
|
IncVolume,
|
||||||
|
DecVolume,
|
||||||
|
MoveRight,
|
||||||
|
MoveLeft,
|
||||||
|
RemoveFromQueue,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal event handler.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EventHandler {
|
||||||
|
/// Event sender channel.
|
||||||
|
sender: mpsc::UnboundedSender<Event>,
|
||||||
|
/// Event receiver channel.
|
||||||
|
receiver: mpsc::UnboundedReceiver<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHandler {
|
||||||
|
/// Constructs a new instance of [`EventHandler`] and spawns a new thread to handle events.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (sender, receiver) = mpsc::unbounded_channel();
|
||||||
|
let actor = EventTask::new(sender.clone());
|
||||||
|
tokio::spawn(async { actor.run().await });
|
||||||
|
Self { sender, receiver }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receives an event from the sender.
|
||||||
|
///
|
||||||
|
/// This function blocks until an event is received.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// This function returns an error if the sender channel is disconnected. This can happen if an
|
||||||
|
/// error occurs in the event thread. In practice, this should not happen unless there is a
|
||||||
|
/// problem with the underlying terminal.
|
||||||
|
pub async fn next(&mut self) -> color_eyre::Result<Event> {
|
||||||
|
self.receiver
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.ok_or_eyre("Failed to receive event")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an app event to be sent to the event receiver.
|
||||||
|
///
|
||||||
|
/// This is useful for sending events to the event handler which will be processed by the next
|
||||||
|
/// iteration of the application's event loop.
|
||||||
|
pub fn send(&mut self, app_event: AppEvent) {
|
||||||
|
// Ignore the result as the reciever cannot be dropped while this struct still has a
|
||||||
|
// reference to it
|
||||||
|
let _ = self.sender.send(Event::App(app_event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thread that handles reading crossterm events and emitting tick events on a regular schedule.
|
||||||
|
struct EventTask {
|
||||||
|
/// Event sender channel.
|
||||||
|
sender: mpsc::UnboundedSender<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventTask {
|
||||||
|
/// Constructs a new instance of [`EventThread`].
|
||||||
|
fn new(sender: mpsc::UnboundedSender<Event>) -> Self {
|
||||||
|
Self { sender }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the event thread.
|
||||||
|
///
|
||||||
|
/// This function emits tick events at a fixed rate and polls for crossterm events in between.
|
||||||
|
async fn run(self) -> color_eyre::Result<()> {
|
||||||
|
let tick_rate = Duration::from_secs_f64(1.0 / TICK_FPS);
|
||||||
|
let mut reader = crossterm::event::EventStream::new();
|
||||||
|
let mut tick = tokio::time::interval(tick_rate);
|
||||||
|
loop {
|
||||||
|
let tick_delay = tick.tick();
|
||||||
|
let crossterm_event = reader.next().fuse();
|
||||||
|
tokio::select! {
|
||||||
|
_ = self.sender.closed() => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = tick_delay => {
|
||||||
|
self.send(Event::Tick);
|
||||||
|
}
|
||||||
|
Some(Ok(evt)) = crossterm_event => {
|
||||||
|
self.send(Event::Crossterm(evt));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends an event to the receiver.
|
||||||
|
fn send(&self, event: Event) {
|
||||||
|
// Ignores the result because shutting down the app drops the receiver, which causes the send
|
||||||
|
// operation to fail. This is expected behavior and should not panic.
|
||||||
|
let _ = self.sender.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use audiotags::Tag;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
use config::{Config, File};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub library_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Song {
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub album: String,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Library {
|
||||||
|
pub songs: Vec<Song>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub artists: Vec<String>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub artist_albums: HashMap<String, BTreeSet<String>>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub album_songs: HashMap<(String, String), Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let lib_path = Self::library_path();
|
||||||
|
|
||||||
|
if lib_path.exists() {
|
||||||
|
Self::load()
|
||||||
|
} else {
|
||||||
|
let library = Self::scan();
|
||||||
|
library.save();
|
||||||
|
library
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan() -> Self {
|
||||||
|
let settings = Self::config();
|
||||||
|
let temp_songs = get_songs(&settings.library_path);
|
||||||
|
let mut songs: Vec<Song> = vec![];
|
||||||
|
|
||||||
|
for (index, song) in temp_songs.into_iter().enumerate() {
|
||||||
|
let tag = Tag::default().read_from_path(&song).expect("Error");
|
||||||
|
let song = Song {
|
||||||
|
title: tag.title().unwrap_or("Could not read title").to_string(),
|
||||||
|
artist: tag.artist().unwrap_or("Could not read artist").to_string(),
|
||||||
|
album: tag.album().unwrap().title.to_string(),
|
||||||
|
duration: if tag.duration().is_some() {
|
||||||
|
tag.duration()
|
||||||
|
} else {
|
||||||
|
Some(0.1)
|
||||||
|
},
|
||||||
|
path: song,
|
||||||
|
};
|
||||||
|
|
||||||
|
songs.push(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut library = Self {
|
||||||
|
songs,
|
||||||
|
artists: Vec::new(),
|
||||||
|
artist_albums: HashMap::new(),
|
||||||
|
album_songs: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
library.rebuild_indexes();
|
||||||
|
|
||||||
|
library
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let lib_path = Self::library_path();
|
||||||
|
let json = std::fs::read_to_string(lib_path).unwrap();
|
||||||
|
let mut library: Library = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
library.rebuild_indexes();
|
||||||
|
|
||||||
|
library
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn albums_for_artist(&self, artist: &String) -> Vec<String> {
|
||||||
|
self.artist_albums
|
||||||
|
.get(artist)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn songs_for_album(&self, artist: &String, album: &String) -> &Vec<usize> {
|
||||||
|
self.album_songs
|
||||||
|
.get(&(artist.clone(), album.clone()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let lib_path = Self::library_path();
|
||||||
|
std::fs::create_dir_all(lib_path.parent().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(self).unwrap();
|
||||||
|
std::fs::write(lib_path, json).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_indexes(&mut self) {
|
||||||
|
self.artists.clear();
|
||||||
|
self.artist_albums.clear();
|
||||||
|
self.album_songs.clear();
|
||||||
|
|
||||||
|
let mut artists = BTreeSet::new();
|
||||||
|
|
||||||
|
for (index, song) in self.songs.iter().enumerate() {
|
||||||
|
artists.insert(song.artist.clone());
|
||||||
|
|
||||||
|
self.artist_albums
|
||||||
|
.entry(song.artist.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(song.album.clone());
|
||||||
|
|
||||||
|
self.album_songs
|
||||||
|
.entry((song.artist.clone(), song.album.clone()))
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.artists = artists.into_iter().collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library_path() -> PathBuf {
|
||||||
|
dirs::cache_dir()
|
||||||
|
.expect("Cache directory does not exist")
|
||||||
|
.join("ncmprs")
|
||||||
|
.join("library.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config() -> Settings {
|
||||||
|
load_config().expect("Failed to load config.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
fn get_songs() -> Vec<PathBuf> {
|
||||||
|
let mut songs: Vec<PathBuf> = vec![];
|
||||||
|
|
||||||
|
|
||||||
|
let flacs = WalkDir::new("/mnt/media/music")
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|e| {
|
||||||
|
e.file_type().is_file() && e.path().extension().is_some_and(|ext| ext == "flac")
|
||||||
|
})
|
||||||
|
.map(|e| e.into_path());
|
||||||
|
|
||||||
|
for path in flacs {
|
||||||
|
// `path` is a PathBuf
|
||||||
|
//println!("{}", path.display());
|
||||||
|
songs.push(path);
|
||||||
|
}
|
||||||
|
songs.sort();
|
||||||
|
songs
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
fn get_songs(library: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut songs: Vec<PathBuf> = WalkDir::new(library)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|e| {
|
||||||
|
e.file_type().is_file()
|
||||||
|
&& e.path()
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext == "flac")
|
||||||
|
})
|
||||||
|
.map(|e| e.into_path())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
songs.sort();
|
||||||
|
songs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config() -> Result<Settings, config::ConfigError> {
|
||||||
|
let config_path = dirs::config_dir()
|
||||||
|
.expect("Couldn't determine config directory")
|
||||||
|
.join("ncmprs")
|
||||||
|
.join("config.toml");
|
||||||
|
|
||||||
|
Config::builder()
|
||||||
|
.add_source(File::from(config_path))
|
||||||
|
.build()?
|
||||||
|
.try_deserialize()
|
||||||
|
}
|
||||||
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod event;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod library;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> color_eyre::Result<()> {
|
||||||
|
color_eyre::install()?;
|
||||||
|
let terminal = ratatui::init();
|
||||||
|
let result = App::new().run(terminal).await;
|
||||||
|
ratatui::restore();
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer, layout::{
|
||||||
|
Alignment, Constraint,
|
||||||
|
Direction::{self},
|
||||||
|
Layout, Rect,
|
||||||
|
}, style::{Color, Modifier, Style}, symbols, widgets::{
|
||||||
|
Block, BorderType::{self, Rounded}, LineGauge, List, ListDirection, ListItem, Paragraph, StatefulWidget, Widget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::Focus;
|
||||||
|
|
||||||
|
impl Widget for &mut App {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let position = self.player.get_pos().as_secs_f64();
|
||||||
|
|
||||||
|
let outer_layout = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let left_area = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Fill(100)])
|
||||||
|
.split(outer_layout[0]);
|
||||||
|
|
||||||
|
let right_area = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||||
|
.split(outer_layout[2]);
|
||||||
|
|
||||||
|
let center_area = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(8), // Album list
|
||||||
|
Constraint::Length(6),
|
||||||
|
])
|
||||||
|
.split(outer_layout[1]);
|
||||||
|
|
||||||
|
let now_playing_block = Block::bordered().title(format!("Now Playing | Vol: {:.0}", self.player.volume() * 100.0)).border_type(Rounded);
|
||||||
|
let inner = now_playing_block.inner(center_area[1]);
|
||||||
|
Widget::render(now_playing_block, center_area[1], buf);
|
||||||
|
|
||||||
|
let player_area = Layout::vertical([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let now_playing = Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(player_area[1]);
|
||||||
|
|
||||||
|
let progress = Layout::horizontal([
|
||||||
|
Constraint::Length(3), // ▶
|
||||||
|
Constraint::Length(2), // %
|
||||||
|
Constraint::Fill(1), // bar
|
||||||
|
Constraint::Length(15), // time
|
||||||
|
])
|
||||||
|
.split(now_playing[2]);
|
||||||
|
// Dynamic coloring based on pane focus status
|
||||||
|
let artist_border_color = if self.focus == Focus::Artists {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::Gray
|
||||||
|
};
|
||||||
|
let album_border_color = if self.focus == Focus::Albums {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::Gray
|
||||||
|
};
|
||||||
|
let song_border_color = if self.focus == Focus::Songs {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::Gray
|
||||||
|
};
|
||||||
|
let queue_border_color = if self.focus == Focus::Queue {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::Gray
|
||||||
|
};
|
||||||
|
|
||||||
|
let artists: Vec<ListItem> = self
|
||||||
|
.library
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| ListItem::new(artist.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let artist_list = List::new(artists)
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title("Artists")
|
||||||
|
.border_style(Style::new().fg(artist_border_color)).border_type(Rounded),
|
||||||
|
)
|
||||||
|
.style(Style::new().white())
|
||||||
|
.highlight_symbol(">>")
|
||||||
|
.repeat_highlight_symbol(true)
|
||||||
|
.highlight_style(Style::new().reversed())
|
||||||
|
.direction(ListDirection::TopToBottom);
|
||||||
|
|
||||||
|
StatefulWidget::render(artist_list, left_area[0], buf, &mut self.artist_state);
|
||||||
|
|
||||||
|
let albums: Vec<ListItem> = self
|
||||||
|
.library
|
||||||
|
.albums_for_artist(
|
||||||
|
&self.library.artists[self.artist_state.selected().expect("None")].clone(),
|
||||||
|
)
|
||||||
|
.iter()
|
||||||
|
.map(|albums| ListItem::new(albums.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let album_list = List::new(albums)
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title("Albums")
|
||||||
|
.border_style(Style::new().fg(album_border_color)).border_type(Rounded),
|
||||||
|
)
|
||||||
|
.style(Style::new().white())
|
||||||
|
.highlight_symbol(">>")
|
||||||
|
.repeat_highlight_symbol(true)
|
||||||
|
.highlight_style(Style::new().reversed())
|
||||||
|
.direction(ListDirection::TopToBottom);
|
||||||
|
|
||||||
|
StatefulWidget::render(album_list, center_area[0], buf, &mut self.album_state);
|
||||||
|
|
||||||
|
let song_indexes: Vec<usize> = self.current_selected_song();
|
||||||
|
|
||||||
|
let song_items: Vec<ListItem> = song_indexes
|
||||||
|
.iter()
|
||||||
|
.map(|&index| ListItem::new(self.library.songs[index].title.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let song_list = List::new(song_items)
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title("Songs")
|
||||||
|
.border_style(Style::new().fg(song_border_color)).border_type(Rounded),
|
||||||
|
)
|
||||||
|
.style(Style::new().white())
|
||||||
|
.highlight_symbol(">>")
|
||||||
|
.repeat_highlight_symbol(true)
|
||||||
|
.highlight_style(Style::new().reversed())
|
||||||
|
.direction(ListDirection::TopToBottom);
|
||||||
|
|
||||||
|
StatefulWidget::render(song_list, right_area[0], buf, &mut self.song_state);
|
||||||
|
|
||||||
|
let queue_items: Vec<ListItem> = self
|
||||||
|
.queue
|
||||||
|
.iter()
|
||||||
|
.map(|&index| ListItem::new(self.library.songs[index].title.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let queue_list = List::new(queue_items)
|
||||||
|
.block(Block::bordered().title("Queue").border_style(Style::new().fg(queue_border_color)).border_type(Rounded))
|
||||||
|
.style(Style::new().white())
|
||||||
|
.highlight_symbol(">>")
|
||||||
|
.repeat_highlight_symbol(true)
|
||||||
|
.highlight_style(Style::new())
|
||||||
|
.direction(ListDirection::TopToBottom);
|
||||||
|
|
||||||
|
StatefulWidget::render(queue_list, right_area[1], buf, &mut self.queue_state);
|
||||||
|
|
||||||
|
if let Some(song) = self.current_song() {
|
||||||
|
let duration = song.duration.unwrap_or(1.0);
|
||||||
|
Paragraph::new(song.title.as_str())
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.render(now_playing[0], buf);
|
||||||
|
|
||||||
|
Paragraph::new(format!("› {}", song.artist))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().fg(Color::DarkGray))
|
||||||
|
.render(now_playing[1], buf);
|
||||||
|
|
||||||
|
let percent = position / duration;
|
||||||
|
|
||||||
|
let line_gauge = LineGauge::default()
|
||||||
|
.filled_style(Style::new().green().add_modifier(Modifier::BOLD))
|
||||||
|
.unfilled_style(
|
||||||
|
Style::new()
|
||||||
|
.gray()
|
||||||
|
.add_modifier(Modifier::BOLD | Modifier::DIM),
|
||||||
|
)
|
||||||
|
.label(format!("{:>3.0}%", percent * 100.0))
|
||||||
|
.ratio(percent.clamp(0.0, 1.0))
|
||||||
|
.filled_symbol(symbols::line::HORIZONTAL)
|
||||||
|
.unfilled_symbol(symbols::line::HORIZONTAL);
|
||||||
|
|
||||||
|
Widget::render(line_gauge, progress[2], buf);
|
||||||
|
|
||||||
|
Paragraph::new(format!(
|
||||||
|
" {} / {}",
|
||||||
|
format_time(position),
|
||||||
|
format_time(duration),
|
||||||
|
))
|
||||||
|
.render(progress[3], buf);
|
||||||
|
} else {
|
||||||
|
Paragraph::new("Nothing is playing").render(now_playing[0], buf);
|
||||||
|
|
||||||
|
let line_gauge = LineGauge::default()
|
||||||
|
.ratio(0.0)
|
||||||
|
.filled_symbol(symbols::line::HORIZONTAL)
|
||||||
|
.unfilled_symbol(symbols::line::HORIZONTAL);
|
||||||
|
|
||||||
|
Widget::render(line_gauge, progress[2], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(if self.player.is_paused() { "||" } else { ">" }).render(progress[0], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_time(seconds: f64) -> String {
|
||||||
|
let secs = seconds as u64;
|
||||||
|
format!("{}:{:02}", secs / 60, secs % 60)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user