8 Commits
Author SHA1 Message Date
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 222 additions and 90 deletions
+10
View File
@@ -18,6 +18,16 @@ jobs:
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
+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::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;
-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> {
let mut songs: Vec<PathBuf> = WalkDir::new(library)
.into_iter()
+76 -10
View File
@@ -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())