init
This commit is contained in:
+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