9 Commits
Author SHA1 Message Date
nicknase27 6ba90774d4 change cache workflow
Build / build (push) Successful in 2m6s
2026-08-26 18:05:18 +02:00
nicknase27 c2bb24bde4 add sample rate support
Build / build (push) Canceled after 5m53s
2026-08-26 17:55:15 +02:00
nicknase27 089f215c42 remove old function from library.rs 2026-07-28 00:11:02 +02:00
nicknase27 b7f08cf6d7 add license 2026-07-27 19:46:17 +02:00
nicknase27 ebf1be2cb9 add help menu and remove unnecessary function
Build / build (push) Successful in 2m11s
2026-07-27 18:51:11 +02:00
nicknase27 f721bdcd12 fix readme? 2026-07-27 15:34:00 +02:00
nicknase27 a854ac2e9d update readme 2026-07-27 15:32:53 +02:00
nicknase27 f7dafc843f add instructions to readme 2026-07-27 15:30:24 +02:00
nicknase27 1789e7b552 Cache Cargo dependencies
Build / build (push) Successful in 3m48s
2026-07-26 11:40:39 +02:00
6 changed files with 215 additions and 91 deletions
+3 -1
View File
@@ -18,6 +18,8 @@ jobs:
x86_64-unknown-linux-gnu x86_64-unknown-linux-gnu
x86_64-pc-windows-gnu x86_64-pc-windows-gnu
- uses: Swatinem/rust-cache@v2
- name: Install dependencies - name: Install dependencies
run: | run: |
apt-get update apt-get update
@@ -41,7 +43,7 @@ jobs:
cp target/x86_64-pc-windows-gnu/release/ncmprs.exe artifacts/ncmprs.exe cp target/x86_64-pc-windows-gnu/release/ncmprs.exe artifacts/ncmprs.exe
- name: Create Release - name: Create Release
uses: akkuman/gitea-release-action@v1 uses: akkuman/gitea-release-action@v1
with: with:
files: | files: |
artifacts/ncmprs artifacts/ncmprs
+9
View File
@@ -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.
+39
View File
@@ -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
View File
@@ -5,14 +5,15 @@ use std::io::BufReader;
use crate::event::{AppEvent, Event, EventHandler}; use crate::event::{AppEvent, Event, EventHandler};
use crate::library::{Library, Song}; use crate::library::{Library, Song};
use audiotags::{MimeType, Tag}; use crossterm::event::MediaKeyCode::{self};
use crossterm::event::MediaKeyCode::{self, PlayPause};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use discord_rich_presence::activity::{Assets, Timestamps}; use discord_rich_presence::activity::{Assets, Timestamps};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient, activity}; use discord_rich_presence::{DiscordIpc, DiscordIpcClient, activity};
use ratatui::DefaultTerminal; use ratatui::DefaultTerminal;
use ratatui::widgets::{List, ListItem, ListState}; use ratatui::widgets::ListState;
use rodio::Decoder; use rodio::{
Decoder, DeviceSinkBuilder, DeviceSinkError, MixerDeviceSink, Player, SampleRate, Source,
};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
@@ -34,14 +35,17 @@ pub struct App {
pub queue: VecDeque<usize>, pub queue: VecDeque<usize>,
pub sink_handle: rodio::MixerDeviceSink, pub sink_handle: MixerDeviceSink,
pub player: rodio::Player, pub sample_rate: SampleRate,
pub sink_volume: f32,
pub player: Player,
pub artist_state: ListState, pub artist_state: ListState,
pub album_state: ListState, pub album_state: ListState,
pub song_state: ListState, pub song_state: ListState,
pub queue_state: ListState, pub queue_state: ListState,
pub focus: Focus, pub focus: Focus,
pub control_popup: bool,
pub current_song: Option<usize>, pub current_song: Option<usize>,
pub paused_at: i64, pub paused_at: i64,
@@ -56,9 +60,9 @@ impl App {
/// Constructs a new instance of [`App`]. /// Constructs a new instance of [`App`].
pub fn new() -> Self { pub fn new() -> Self {
let sink_handle = let sink_handle =
rodio::DeviceSinkBuilder::open_default_sink().expect("open default audio stream"); DeviceSinkBuilder::open_default_sink().expect("open default audio stream");
let player = rodio::Player::connect_new(sink_handle.mixer()); let player = Player::connect_new(sink_handle.mixer());
player.set_volume(1.0); // player.set_volume(1.0);
let discord_client = { let discord_client = {
let mut client = DiscordIpcClient::new("1525954755292299426"); let mut client = DiscordIpcClient::new("1525954755292299426");
@@ -75,18 +79,20 @@ impl App {
Self { Self {
running: true, running: true,
library: Library::new(), library: Library::new(),
//songs: get_songs(),
queue: vec![].into(), queue: vec![].into(),
artist_state: ListState::default().with_selected(Some(0)), artist_state: ListState::default().with_selected(Some(0)),
album_state: ListState::default().with_selected(Some(0)), album_state: ListState::default().with_selected(Some(0)),
song_state: ListState::default().with_selected(Some(0)), song_state: ListState::default().with_selected(Some(0)),
queue_state: ListState::default().with_selected(Some(0)), queue_state: ListState::default().with_selected(Some(0)),
focus: Focus::default(), focus: Focus::default(),
control_popup: false,
events: EventHandler::new(), events: EventHandler::new(),
current_song: None, current_song: None,
paused_at: 1, paused_at: 1,
sink_handle, sink_handle,
player, player,
sink_volume: 1.0,
sample_rate: SampleRate::new(44100).unwrap(),
discord_client, discord_client,
} }
} }
@@ -131,7 +137,13 @@ impl App {
/// Handles the key events and updates the state of [`App`]. /// Handles the key events and updates the state of [`App`].
pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
match key_event.code { 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 => { KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => {
self.events.send(AppEvent::Quit) self.events.send(AppEvent::Quit)
} }
@@ -141,10 +153,16 @@ impl App {
self.library.save(); self.library.save();
} }
KeyCode::Char('?') => {
self.control_popup = !self.control_popup;
}
KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => { KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => {
self.events.send(AppEvent::MoveRight) 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 { KeyCode::Char('j') | KeyCode::Down => match self.focus {
Focus::Artists => self.artist_state.select_next(), Focus::Artists => self.artist_state.select_next(),
@@ -212,49 +230,6 @@ impl App {
song_indexes.to_owned() 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) { pub fn play_pause(&mut self) {
let song_idx = match self.current_song { let song_idx = match self.current_song {
Some(idx) => idx, Some(idx) => idx,
@@ -281,7 +256,8 @@ impl App {
if vol >= 1.0 { if vol >= 1.0 {
self.player.set_volume(1.0); self.player.set_volume(1.0);
} else { } 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 { if vol <= 0.01 {
self.player.set_volume(0.0); self.player.set_volume(0.0);
} else { } 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); 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. /// Handles the tick event of the terminal.
/// ///
/// The tick event is where you can update the state of your application with any logic that /// 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) File::open(&self.library.songs[*self.queue.front().expect("Queue is empty")].path)
.unwrap(); .unwrap();
let source = Decoder::new(BufReader::new(file)).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.player.append(source);
self.current_song = Some(self.queue.front().unwrap().to_owned()); self.current_song = Some(self.queue.front().unwrap().to_owned());
self.update_rpc(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. /// Set running to false to quit the application.
pub fn quit(&mut self) { pub fn quit(&mut self) {
self.running = false; self.running = false;
-23
View File
@@ -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> { fn get_songs(library: &Path) -> Vec<PathBuf> {
let mut songs: Vec<PathBuf> = WalkDir::new(library) let mut songs: Vec<PathBuf> = WalkDir::new(library)
.into_iter() .into_iter()
+76 -10
View File
@@ -1,10 +1,16 @@
use ratatui::{ use ratatui::{
buffer::Buffer, layout::{ buffer::Buffer,
layout::{
Alignment, Constraint, Alignment, Constraint,
Direction::{self}, Direction::{self},
Layout, Rect, 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]); .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]); let inner = now_playing_block.inner(center_area[1]);
Widget::render(now_playing_block, center_area[1], buf); Widget::render(now_playing_block, center_area[1], buf);
@@ -89,6 +101,7 @@ impl Widget for &mut App {
Color::Gray Color::Gray
}; };
// Artists
let artists: Vec<ListItem> = self let artists: Vec<ListItem> = self
.library .library
.artists .artists
@@ -100,7 +113,8 @@ impl Widget for &mut App {
.block( .block(
Block::bordered() Block::bordered()
.title("Artists") .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()) .style(Style::new().white())
.highlight_symbol(">>") .highlight_symbol(">>")
@@ -110,6 +124,13 @@ impl Widget for &mut App {
StatefulWidget::render(artist_list, left_area[0], buf, &mut self.artist_state); 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 let albums: Vec<ListItem> = self
.library .library
.albums_for_artist( .albums_for_artist(
@@ -123,7 +144,9 @@ impl Widget for &mut App {
.block( .block(
Block::bordered() Block::bordered()
.title("Albums") .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()) .style(Style::new().white())
.highlight_symbol(">>") .highlight_symbol(">>")
@@ -133,18 +156,26 @@ impl Widget for &mut App {
StatefulWidget::render(album_list, center_area[0], buf, &mut self.album_state); StatefulWidget::render(album_list, center_area[0], buf, &mut self.album_state);
// Songs
let song_indexes: Vec<usize> = self.current_selected_song(); let song_indexes: Vec<usize> = self.current_selected_song();
let song_items: Vec<ListItem> = song_indexes let song_items: Vec<ListItem> = song_indexes
.iter() .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(); .collect();
let song_list = List::new(song_items) let song_list = List::new(song_items)
.block( .block(
Block::bordered() Block::bordered()
.title("Songs") .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()) .style(Style::new().white())
.highlight_symbol(">>") .highlight_symbol(">>")
@@ -154,14 +185,26 @@ impl Widget for &mut App {
StatefulWidget::render(song_list, right_area[0], buf, &mut self.song_state); StatefulWidget::render(song_list, right_area[0], buf, &mut self.song_state);
// Queue
let queue_items: Vec<ListItem> = self let queue_items: Vec<ListItem> = self
.queue .queue
.iter() .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(); .collect();
let queue_list = List::new(queue_items) 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()) .style(Style::new().white())
.highlight_symbol(">>") .highlight_symbol(">>")
.repeat_highlight_symbol(true) .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); 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() { if let Some(song) = self.current_song() {
let duration = song.duration.unwrap_or(1.0); let duration = song.duration.unwrap_or(1.0);
Paragraph::new(song.title.as_str()) Paragraph::new(song.title.as_str())