This commit is contained in:
nicknase27
2026-07-17 12:46:42 +02:00
commit 3c2fa4012f
9 changed files with 4248 additions and 0 deletions
+412
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+224
View File
@@ -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)
}