Compare commits
13
Commits
dd9aab94f0
..
v0.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf1be2cb9 | ||
|
|
f721bdcd12 | ||
|
|
a854ac2e9d | ||
|
|
f7dafc843f | ||
|
|
1789e7b552 | ||
|
|
ff6bc6ab4c | ||
|
|
68bc9f6a8a | ||
|
|
e4efa28647 | ||
|
|
f681982fe5 | ||
|
|
3c28d3b09c | ||
|
|
f51fd27c09 | ||
|
|
cc687eb159 | ||
|
|
afe2e6d375 |
@@ -0,0 +1,58 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: |
|
||||
x86_64-unknown-linux-gnu
|
||||
x86_64-pc-windows-gnu
|
||||
|
||||
- uses: https://gitea.com/actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
pkg-config \
|
||||
libasound2-dev \
|
||||
gcc-mingw-w64-x86-64
|
||||
|
||||
- name: Build Linux
|
||||
run: cargo build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Build Windows
|
||||
env:
|
||||
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
|
||||
run: cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
mkdir artifacts
|
||||
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
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: ncmprs
|
||||
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: ncmprs.exe
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libasound2-dev pkg-config
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: target/${{ matrix.target }}/release/${{ matrix.binary }}
|
||||
@@ -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.
|
||||
+101
-84
@@ -42,6 +42,7 @@ pub struct App {
|
||||
pub song_state: ListState,
|
||||
pub queue_state: ListState,
|
||||
pub focus: Focus,
|
||||
pub control_popup: bool,
|
||||
|
||||
pub current_song: Option<usize>,
|
||||
pub paused_at: i64,
|
||||
@@ -82,6 +83,7 @@ impl App {
|
||||
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,
|
||||
@@ -131,7 +133,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 +149,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(),
|
||||
@@ -187,50 +201,6 @@ impl App {
|
||||
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];
|
||||
@@ -257,51 +227,28 @@ impl App {
|
||||
}
|
||||
|
||||
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 song_idx = match self.current_song {
|
||||
Some(idx) => idx,
|
||||
None => return,
|
||||
};
|
||||
|
||||
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 song = &self.library.songs[song_idx];
|
||||
let duration = song.duration.unwrap_or(1.0) as i64;
|
||||
let title = song.title.clone();
|
||||
let artist = song.artist.clone();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if !self.player.is_paused() {
|
||||
self.player.pause();
|
||||
self.paused_at = self.player.get_pos().as_secs() as i64;
|
||||
self.update_discord_rpc(true, title, artist, duration);
|
||||
} else {
|
||||
self.player.play();
|
||||
self.update_discord_rpc(false, title, artist, duration);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -398,6 +345,76 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_discord_rpc(
|
||||
&mut self,
|
||||
is_paused: bool,
|
||||
title: String,
|
||||
artist: String,
|
||||
duration: i64,
|
||||
) {
|
||||
let client = match self.discord_client.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut activity = activity::Activity::new()
|
||||
.name("NCMPRS")
|
||||
.details(title)
|
||||
.activity_type(activity::ActivityType::Listening)
|
||||
.assets(Assets::new().large_image(
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/navidrome.png",
|
||||
));
|
||||
|
||||
if is_paused {
|
||||
activity = activity.state("Paused");
|
||||
} else {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let start_time = now - self.paused_at;
|
||||
|
||||
activity = activity.state(artist).timestamps(
|
||||
Timestamps::new()
|
||||
.start(start_time)
|
||||
.end(start_time + duration),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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,12 @@ 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}",
|
||||
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);
|
||||
|
||||
@@ -89,6 +100,7 @@ impl Widget for &mut App {
|
||||
Color::Gray
|
||||
};
|
||||
|
||||
// Artists
|
||||
let artists: Vec<ListItem> = self
|
||||
.library
|
||||
.artists
|
||||
@@ -100,7 +112,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 +123,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 +143,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 +155,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 +184,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 +212,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