Compare commits
15
Commits
cc687eb159
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba90774d4
|
||
|
|
c2bb24bde4
|
||
|
|
089f215c42 | ||
|
|
b7f08cf6d7 | ||
|
|
ebf1be2cb9 | ||
|
|
f721bdcd12 | ||
|
|
a854ac2e9d | ||
|
|
f7dafc843f | ||
|
|
1789e7b552 | ||
|
|
ff6bc6ab4c | ||
|
|
68bc9f6a8a | ||
|
|
e4efa28647 | ||
|
|
f681982fe5 | ||
|
|
3c28d3b09c | ||
|
|
f51fd27c09 |
@@ -18,6 +18,8 @@ jobs:
|
||||
x86_64-unknown-linux-gnu
|
||||
x86_64-pc-windows-gnu
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -37,5 +39,12 @@ jobs:
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir artifacts
|
||||
cp target/x86_64-unknown-linux-gnu/release/ncmprs artifacts/ncmprs-linux
|
||||
cp target/x86_64-unknown-linux-gnu/release/ncmprs artifacts/ncmprs
|
||||
cp target/x86_64-pc-windows-gnu/release/ncmprs.exe artifacts/ncmprs.exe
|
||||
|
||||
- name: Create Release
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/ncmprs
|
||||
artifacts/ncmprs.exe
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2026 nicknase27
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Setup
|
||||
|
||||
`ncmprs` requires a `config.toml` file that points to your FLAC music library.
|
||||
|
||||
## Linux
|
||||
|
||||
1. Create the configuration directory:
|
||||
```sh
|
||||
mkdir -p "$XDG_CONFIG_HOME/ncmprs"
|
||||
```
|
||||
|
||||
2. Create the configuration file:
|
||||
```sh
|
||||
touch "$XDG_CONFIG_HOME/ncmprs/config.toml"
|
||||
```
|
||||
|
||||
3. Add the path to your music library:
|
||||
```toml
|
||||
library_path = "/path/to/your/music"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Windows
|
||||
|
||||
1. Create the configuration directory:
|
||||
```
|
||||
C:\Users\<YourUser>\AppData\Roaming\ncmprs\
|
||||
```
|
||||
|
||||
2. Create a file named `config.toml` inside that directory.
|
||||
|
||||
3. Add the path to your music library:
|
||||
```toml
|
||||
library_path = "Z:\\Users\\<YourUser>\\Music"
|
||||
```
|
||||
|
||||
> **Note:** Windows paths in TOML require backslashes to be escaped (`\\` instead of `\`).\
|
||||
> **Note:** The first time you start `ncmprs`, it will take some time to index your library and create it's own database.
|
||||
+88
-57
@@ -5,14 +5,15 @@ use std::io::BufReader;
|
||||
use crate::event::{AppEvent, Event, EventHandler};
|
||||
use crate::library::{Library, Song};
|
||||
|
||||
use audiotags::{MimeType, Tag};
|
||||
use crossterm::event::MediaKeyCode::{self, PlayPause};
|
||||
use crossterm::event::MediaKeyCode::{self};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use discord_rich_presence::activity::{Assets, Timestamps};
|
||||
use discord_rich_presence::{DiscordIpc, DiscordIpcClient, activity};
|
||||
use ratatui::DefaultTerminal;
|
||||
use ratatui::widgets::{List, ListItem, ListState};
|
||||
use rodio::Decoder;
|
||||
use ratatui::widgets::ListState;
|
||||
use rodio::{
|
||||
Decoder, DeviceSinkBuilder, DeviceSinkError, MixerDeviceSink, Player, SampleRate, Source,
|
||||
};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
@@ -34,14 +35,17 @@ pub struct App {
|
||||
|
||||
pub queue: VecDeque<usize>,
|
||||
|
||||
pub sink_handle: rodio::MixerDeviceSink,
|
||||
pub player: rodio::Player,
|
||||
pub sink_handle: MixerDeviceSink,
|
||||
pub sample_rate: SampleRate,
|
||||
pub sink_volume: f32,
|
||||
pub player: Player,
|
||||
|
||||
pub artist_state: ListState,
|
||||
pub album_state: ListState,
|
||||
pub song_state: ListState,
|
||||
pub queue_state: ListState,
|
||||
pub focus: Focus,
|
||||
pub control_popup: bool,
|
||||
|
||||
pub current_song: Option<usize>,
|
||||
pub paused_at: i64,
|
||||
@@ -56,9 +60,9 @@ 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);
|
||||
DeviceSinkBuilder::open_default_sink().expect("open default audio stream");
|
||||
let player = Player::connect_new(sink_handle.mixer());
|
||||
// player.set_volume(1.0);
|
||||
|
||||
let discord_client = {
|
||||
let mut client = DiscordIpcClient::new("1525954755292299426");
|
||||
@@ -75,18 +79,20 @@ impl App {
|
||||
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(),
|
||||
control_popup: false,
|
||||
events: EventHandler::new(),
|
||||
current_song: None,
|
||||
paused_at: 1,
|
||||
sink_handle,
|
||||
player,
|
||||
sink_volume: 1.0,
|
||||
sample_rate: SampleRate::new(44100).unwrap(),
|
||||
discord_client,
|
||||
}
|
||||
}
|
||||
@@ -131,7 +137,13 @@ impl App {
|
||||
/// 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::Esc | KeyCode::Char('q') => {
|
||||
if self.control_popup == false {
|
||||
self.events.send(AppEvent::Quit)
|
||||
} else {
|
||||
self.control_popup = !self.control_popup;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => {
|
||||
self.events.send(AppEvent::Quit)
|
||||
}
|
||||
@@ -141,10 +153,16 @@ impl App {
|
||||
self.library.save();
|
||||
}
|
||||
|
||||
KeyCode::Char('?') => {
|
||||
self.control_popup = !self.control_popup;
|
||||
}
|
||||
|
||||
KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => {
|
||||
self.events.send(AppEvent::MoveRight)
|
||||
}
|
||||
KeyCode::Char('h') | KeyCode::Left => self.events.send(AppEvent::MoveLeft),
|
||||
KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.events.send(AppEvent::MoveLeft)
|
||||
}
|
||||
|
||||
KeyCode::Char('j') | KeyCode::Down => match self.focus {
|
||||
Focus::Artists => self.artist_state.select_next(),
|
||||
@@ -212,49 +230,6 @@ impl App {
|
||||
song_indexes.to_owned()
|
||||
}
|
||||
|
||||
// pub fn play_pause(&mut self) {
|
||||
// if self.current_song().is_some() {
|
||||
// let song = &self.library.songs[self.current_song.unwrap()];
|
||||
// let duration = song.duration.unwrap_or(1.0) as i64;
|
||||
// let artist = &song.artist;
|
||||
// if let Some(client) = self.discord_client.as_mut() {
|
||||
// if !self.player.is_paused() {
|
||||
// self.player.pause();
|
||||
// self.paused_at = self.player.get_pos().as_secs() as i64;
|
||||
//
|
||||
// let asset = Assets::new();
|
||||
// let activity = activity::Activity::new()
|
||||
// .name("NCMPRS")
|
||||
// .details(song.title.clone())
|
||||
// .state("Paused")
|
||||
// .activity_type(activity::ActivityType::Listening)
|
||||
// .assets(asset.large_image("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/navidrome.png"));
|
||||
// let _ = client.set_activity(activity);
|
||||
// } else if self.player.is_paused() {
|
||||
// self.player.play();
|
||||
// let now = SystemTime::now()
|
||||
// .duration_since(UNIX_EPOCH)
|
||||
// .unwrap()
|
||||
// .as_secs() as i64;
|
||||
// let timestamps = Timestamps::new()
|
||||
// .start(now - self.paused_at)
|
||||
// .end((now - self.paused_at) + duration);
|
||||
// let asset = Assets::new();
|
||||
//
|
||||
// let payload = activity::Activity::new()
|
||||
// .name("NCMPRS")
|
||||
// .details(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);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
pub fn play_pause(&mut self) {
|
||||
let song_idx = match self.current_song {
|
||||
Some(idx) => idx,
|
||||
@@ -281,7 +256,8 @@ impl App {
|
||||
if vol >= 1.0 {
|
||||
self.player.set_volume(1.0);
|
||||
} else {
|
||||
self.player.set_volume(vol + 0.05);
|
||||
self.sink_volume = vol + 0.05;
|
||||
self.player.set_volume(self.sink_volume);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +266,8 @@ impl App {
|
||||
if vol <= 0.01 {
|
||||
self.player.set_volume(0.0);
|
||||
} else {
|
||||
self.player.set_volume(vol - 0.05);
|
||||
self.sink_volume = vol - 0.05;
|
||||
self.player.set_volume(self.sink_volume);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +390,37 @@ impl App {
|
||||
let _ = client.set_activity(activity);
|
||||
}
|
||||
|
||||
pub fn get_controls(&mut self) -> Vec<ratatui::widgets::Row<'_>> {
|
||||
let rows = [
|
||||
ratatui::widgets::Row::new(["", "General"]),
|
||||
ratatui::widgets::Row::new(["", "Q / Esc / Ctrl+C", "Quit"]),
|
||||
ratatui::widgets::Row::new(["", "R", "Rescan music library"]),
|
||||
ratatui::widgets::Row::new(["", "?", "Show the help menu"]),
|
||||
ratatui::widgets::Row::new(["", ""]),
|
||||
ratatui::widgets::Row::new(["", "Navigation"]),
|
||||
ratatui::widgets::Row::new(["", "H / Shift+Tab", "Left"]),
|
||||
ratatui::widgets::Row::new(["", "J", "Down"]),
|
||||
ratatui::widgets::Row::new(["", "K", "Up"]),
|
||||
ratatui::widgets::Row::new(["", "L / Tab", "Right"]),
|
||||
ratatui::widgets::Row::new(["", "g", "Jump to first item"]),
|
||||
ratatui::widgets::Row::new(["", "G", "Jump to last item"]),
|
||||
ratatui::widgets::Row::new(["", ""]),
|
||||
ratatui::widgets::Row::new(["", "Playback"]),
|
||||
ratatui::widgets::Row::new(["", "Enter", "Play selected song or album"]),
|
||||
ratatui::widgets::Row::new(["", "Space", "Play / Pause"]),
|
||||
ratatui::widgets::Row::new(["", "X", "Stop Playback"]),
|
||||
ratatui::widgets::Row::new(["", ">", "Skip current track"]),
|
||||
ratatui::widgets::Row::new(["", ""]),
|
||||
ratatui::widgets::Row::new(["", "Queue"]),
|
||||
ratatui::widgets::Row::new(["", "Delete", "Remove selected song from queue"]),
|
||||
ratatui::widgets::Row::new(["", ""]),
|
||||
ratatui::widgets::Row::new(["", "Volume"]),
|
||||
ratatui::widgets::Row::new(["", "+ / =", "Increase volume"]),
|
||||
ratatui::widgets::Row::new(["", "- / _", "Decrease volume"]),
|
||||
];
|
||||
return rows.to_vec();
|
||||
}
|
||||
|
||||
/// Handles the tick event of the terminal.
|
||||
///
|
||||
/// The tick event is where you can update the state of your application with any logic that
|
||||
@@ -423,6 +431,13 @@ impl App {
|
||||
File::open(&self.library.songs[*self.queue.front().expect("Queue is empty")].path)
|
||||
.unwrap();
|
||||
let source = Decoder::new(BufReader::new(file)).unwrap();
|
||||
|
||||
let rate: SampleRate = source.sample_rate();
|
||||
if rate != self.sample_rate {
|
||||
self.sample_rate = rate;
|
||||
let _ = self.rebuild_sink(self.sample_rate);
|
||||
}
|
||||
|
||||
self.player.append(source);
|
||||
self.current_song = Some(self.queue.front().unwrap().to_owned());
|
||||
self.update_rpc(self.queue.front().unwrap().to_owned());
|
||||
@@ -442,6 +457,22 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rebuild_sink(&mut self, rate: SampleRate) -> Result<(), DeviceSinkError> {
|
||||
self.sink_handle.log_on_drop(false);
|
||||
let mut sink = DeviceSinkBuilder::from_default_device()
|
||||
.unwrap()
|
||||
.with_sample_rate(rate)
|
||||
.open_stream()
|
||||
.unwrap();
|
||||
|
||||
sink.log_on_drop(false);
|
||||
self.sink_handle = sink;
|
||||
self.player = Player::connect_new(self.sink_handle.mixer());
|
||||
self.player.set_volume(self.sink_volume);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set running to false to quit the application.
|
||||
pub fn quit(&mut self) {
|
||||
self.running = false;
|
||||
|
||||
@@ -147,29 +147,6 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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()
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer, layout::{
|
||||
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,
|
||||
},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
symbols,
|
||||
text::Line,
|
||||
widgets::{
|
||||
Block, BorderType::Rounded, Clear, LineGauge, List, ListDirection, ListItem, Paragraph,
|
||||
Row, StatefulWidget, Table, Widget,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,7 +48,13 @@ impl Widget for &mut App {
|
||||
])
|
||||
.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 now_playing_block = Block::bordered()
|
||||
.title(format!(
|
||||
"Now Playing | Vol: {:.0} | Rate: {}Hz",
|
||||
self.player.volume() * 100.0,
|
||||
self.sample_rate
|
||||
))
|
||||
.border_type(Rounded);
|
||||
let inner = now_playing_block.inner(center_area[1]);
|
||||
Widget::render(now_playing_block, center_area[1], buf);
|
||||
|
||||
@@ -89,6 +101,7 @@ impl Widget for &mut App {
|
||||
Color::Gray
|
||||
};
|
||||
|
||||
// Artists
|
||||
let artists: Vec<ListItem> = self
|
||||
.library
|
||||
.artists
|
||||
@@ -100,7 +113,8 @@ impl Widget for &mut App {
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title("Artists")
|
||||
.border_style(Style::new().fg(artist_border_color)).border_type(Rounded),
|
||||
.border_style(Style::new().fg(artist_border_color))
|
||||
.border_type(Rounded),
|
||||
)
|
||||
.style(Style::new().white())
|
||||
.highlight_symbol(">>")
|
||||
@@ -110,6 +124,13 @@ impl Widget for &mut App {
|
||||
|
||||
StatefulWidget::render(artist_list, left_area[0], buf, &mut self.artist_state);
|
||||
|
||||
// Info
|
||||
let instructions = Line::from(vec![
|
||||
" Help ".gray().bold(),
|
||||
"<?> ".blue().bold(),
|
||||
]);
|
||||
|
||||
// Albums
|
||||
let albums: Vec<ListItem> = self
|
||||
.library
|
||||
.albums_for_artist(
|
||||
@@ -123,7 +144,9 @@ impl Widget for &mut App {
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title("Albums")
|
||||
.border_style(Style::new().fg(album_border_color)).border_type(Rounded),
|
||||
.title_bottom(instructions.right_aligned())
|
||||
.border_style(Style::new().fg(album_border_color))
|
||||
.border_type(Rounded),
|
||||
)
|
||||
.style(Style::new().white())
|
||||
.highlight_symbol(">>")
|
||||
@@ -133,18 +156,26 @@ impl Widget for &mut App {
|
||||
|
||||
StatefulWidget::render(album_list, center_area[0], buf, &mut self.album_state);
|
||||
|
||||
// Songs
|
||||
let song_indexes: Vec<usize> = self.current_selected_song();
|
||||
|
||||
let song_items: Vec<ListItem> = song_indexes
|
||||
.iter()
|
||||
.map(|&index| ListItem::new(format!("{} ({})", self.library.songs[index].title.clone(), format_time(self.library.songs[index].duration.unwrap()))))
|
||||
.map(|&index| {
|
||||
ListItem::new(format!(
|
||||
"{} ({})",
|
||||
self.library.songs[index].title.clone(),
|
||||
format_time(self.library.songs[index].duration.unwrap())
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let song_list = List::new(song_items)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title("Songs")
|
||||
.border_style(Style::new().fg(song_border_color)).border_type(Rounded),
|
||||
.border_style(Style::new().fg(song_border_color))
|
||||
.border_type(Rounded),
|
||||
)
|
||||
.style(Style::new().white())
|
||||
.highlight_symbol(">>")
|
||||
@@ -154,14 +185,26 @@ impl Widget for &mut App {
|
||||
|
||||
StatefulWidget::render(song_list, right_area[0], buf, &mut self.song_state);
|
||||
|
||||
// Queue
|
||||
let queue_items: Vec<ListItem> = self
|
||||
.queue
|
||||
.iter()
|
||||
.map(|&index| ListItem::new(format!("{} ({}))", self.library.songs[index].title.as_str(), format_time(self.library.songs[index].duration.unwrap()))))
|
||||
.map(|&index| {
|
||||
ListItem::new(format!(
|
||||
"{} ({}))",
|
||||
self.library.songs[index].title.as_str(),
|
||||
format_time(self.library.songs[index].duration.unwrap())
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let queue_list = List::new(queue_items)
|
||||
.block(Block::bordered().title("Queue").border_style(Style::new().fg(queue_border_color)).border_type(Rounded))
|
||||
.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)
|
||||
@@ -170,6 +213,29 @@ impl Widget for &mut App {
|
||||
|
||||
StatefulWidget::render(queue_list, right_area[1], buf, &mut self.queue_state);
|
||||
|
||||
// Help Menu
|
||||
if self.control_popup {
|
||||
let popup_block = Block::bordered().title("Controls").border_type(Rounded);
|
||||
Widget::render(Clear, center_area[0], buf);
|
||||
|
||||
let header = Row::new(["", "Key", "Function"])
|
||||
.style(Style::new().bold())
|
||||
.bottom_margin(1);
|
||||
|
||||
let rows = self.get_controls();
|
||||
|
||||
let widths = [Constraint::Percentage(10), Constraint::Percentage(45), Constraint::Percentage(45)];
|
||||
|
||||
let help_menu = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.column_spacing(1)
|
||||
.style(Color::White)
|
||||
.block(popup_block);
|
||||
|
||||
Widget::render(help_menu, center_area[0], buf);
|
||||
}
|
||||
|
||||
// Play area
|
||||
if let Some(song) = self.current_song() {
|
||||
let duration = song.duration.unwrap_or(1.0);
|
||||
Paragraph::new(song.title.as_str())
|
||||
|
||||
Reference in New Issue
Block a user