├── .gitignore ├── docs ├── header.png ├── theme_examples │ ├── Gruvbox [No borders].toml │ ├── Cherry Blossom.toml │ ├── Concertus Transparent.toml │ ├── Monokai Classic.toml │ ├── Concertus Classic.toml │ └── Monokai Vivid.toml ├── keymaps.md └── themes.md ├── src ├── main.rs ├── ui_state │ ├── settings │ │ ├── mod.rs │ │ └── root_mgmt.rs │ ├── playback │ │ ├── mod.rs │ │ ├── progress_display.rs │ │ └── progress_view.rs │ ├── domain │ │ ├── mod.rs │ │ ├── pane.rs │ │ ├── table_sort.rs │ │ ├── album_sort.rs │ │ └── mode.rs │ ├── theme │ │ ├── mod.rs │ │ ├── gradients.rs │ │ ├── color_utils.rs │ │ ├── display_theme.rs │ │ ├── theme_utils.rs │ │ ├── theme_import.rs │ │ ├── theme_config.rs │ │ └── theme_manager.rs │ ├── mod.rs │ ├── popup.rs │ ├── search_state.rs │ ├── ui_state.rs │ ├── ui_snapshot.rs │ └── playlist.rs ├── tui │ ├── widgets │ │ ├── popups │ │ │ ├── mod.rs │ │ │ ├── theme_popup.rs │ │ │ ├── error.rs │ │ │ ├── root_manager.rs │ │ │ └── playlist_popup.rs │ │ ├── sidebar │ │ │ ├── handler.rs │ │ │ ├── mod.rs │ │ │ ├── playlist_sidebar.rs │ │ │ └── album_sidebar.rs │ │ ├── song_window.rs │ │ ├── mod.rs │ │ ├── progress │ │ │ ├── mod.rs │ │ │ ├── timer.rs │ │ │ ├── progress_bar.rs │ │ │ ├── oscilloscope.rs │ │ │ └── waveform.rs │ │ ├── search.rs │ │ ├── popup.rs │ │ ├── tracklist │ │ │ ├── generic_tracklist.rs │ │ │ ├── search_results.rs │ │ │ └── album_tracklist.rs │ │ └── buffer_line.rs │ ├── mod.rs │ ├── renderer.rs │ └── layout.rs ├── library │ └── mod.rs ├── app_core │ └── mod.rs ├── player │ ├── mod.rs │ ├── state.rs │ ├── tapped_source.rs │ ├── controller.rs │ └── player.rs ├── domain │ ├── album.rs │ ├── mod.rs │ ├── queue_song.rs │ ├── playlist.rs │ ├── filetype.rs │ ├── simple_song.rs │ └── long_song.rs ├── database │ ├── snapshot.rs │ ├── tables.rs │ ├── playlists.rs │ ├── queries.rs │ └── worker.rs ├── key_handler │ └── mod.rs └── lib.rs ├── Cargo.toml └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /docs/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jaxx497/concertus/HEAD/docs/header.png -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() -> anyhow::Result<()> { 2 | unsafe { std::env::set_var("RUST_BACKTRACE", "1") }; 3 | concertus::app_core::Concertus::new().run()?; 4 | Ok(()) 5 | } 6 | -------------------------------------------------------------------------------- /src/ui_state/settings/mod.rs: -------------------------------------------------------------------------------- 1 | mod root_mgmt; 2 | 3 | #[derive(Default, PartialEq, Clone)] 4 | pub enum SettingsMode { 5 | #[default] 6 | ViewRoots, 7 | AddRoot, 8 | RemoveRoot, 9 | } 10 | -------------------------------------------------------------------------------- /src/ui_state/playback/mod.rs: -------------------------------------------------------------------------------- 1 | mod playback; 2 | mod progress_display; 3 | mod progress_view; 4 | 5 | pub use playback::PlaybackCoordinator; 6 | pub use progress_display::ProgressDisplay; 7 | pub use progress_view::PlaybackView; 8 | -------------------------------------------------------------------------------- /src/ui_state/domain/mod.rs: -------------------------------------------------------------------------------- 1 | mod album_sort; 2 | mod mode; 3 | mod pane; 4 | mod table_sort; 5 | 6 | pub use album_sort::AlbumSort; 7 | pub use mode::{LibraryView, Mode}; 8 | pub use pane::Pane; 9 | pub use table_sort::TableSort; 10 | -------------------------------------------------------------------------------- /src/tui/widgets/popups/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod playlist_popup; 3 | mod root_manager; 4 | mod theme_popup; 5 | 6 | pub use error::ErrorMsg; 7 | pub use playlist_popup::PlaylistPopup; 8 | pub use root_manager::RootManager; 9 | pub use theme_popup::ThemeManager; 10 | -------------------------------------------------------------------------------- /src/library/mod.rs: -------------------------------------------------------------------------------- 1 | mod library; 2 | 3 | pub use library::Library; 4 | 5 | static LEGAL_EXTENSION: std::sync::LazyLock> = 6 | std::sync::LazyLock::new(|| { 7 | std::collections::HashSet::from(["mp3", "m4a", "flac", "ogg", "wav"]) 8 | }); 9 | -------------------------------------------------------------------------------- /src/app_core/mod.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | pub use app::Concertus; 3 | 4 | pub enum LibraryRefreshProgress { 5 | Scanning { 6 | progress: u8, 7 | }, 8 | Processing { 9 | progress: u8, 10 | current: usize, 11 | total: usize, 12 | }, 13 | UpdatingDatabase { 14 | progress: u8, 15 | }, 16 | Rebuilding { 17 | progress: u8, 18 | }, 19 | Complete(crate::Library), 20 | Error(String), 21 | } 22 | -------------------------------------------------------------------------------- /src/player/mod.rs: -------------------------------------------------------------------------------- 1 | mod controller; 2 | mod player; 3 | mod state; 4 | mod tapped_source; 5 | 6 | pub use controller::PlayerController; 7 | pub use player::Player; 8 | pub use state::{PlaybackState, PlayerState}; 9 | pub use tapped_source::TappedSource; 10 | 11 | use crate::domain::QueueSong; 12 | use std::sync::Arc; 13 | 14 | pub const OSCILLO_BUFFER_CAPACITY: usize = 2048; 15 | 16 | pub enum PlayerCommand { 17 | Play(Arc), 18 | TogglePlayback, 19 | SeekForward(usize), 20 | SeekBack(usize), 21 | Stop, 22 | } 23 | -------------------------------------------------------------------------------- /src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | mod layout; 2 | mod renderer; 3 | mod widgets; 4 | 5 | use crate::ui_state::UiState; 6 | use ratatui::{ 7 | style::Stylize, 8 | widgets::{Block, Widget}, 9 | }; 10 | 11 | pub use layout::AppLayout; 12 | pub use renderer::render; 13 | pub use widgets::{ErrorMsg, Progress, SearchBar, SideBarHandler as SideBar, SongTable}; 14 | 15 | pub fn render_bg(state: &UiState, f: &mut ratatui::Frame) { 16 | Block::new() 17 | .bg(state.theme_manager.active.surface_global) 18 | .render(f.area(), f.buffer_mut()); 19 | } 20 | -------------------------------------------------------------------------------- /src/tui/widgets/sidebar/handler.rs: -------------------------------------------------------------------------------- 1 | use super::{SideBarAlbum, SideBarPlaylist}; 2 | use crate::ui_state::{LibraryView, UiState}; 3 | use ratatui::widgets::StatefulWidget; 4 | 5 | pub struct SideBarHandler; 6 | impl StatefulWidget for SideBarHandler { 7 | type State = UiState; 8 | 9 | fn render( 10 | self, 11 | area: ratatui::prelude::Rect, 12 | buf: &mut ratatui::prelude::Buffer, 13 | state: &mut Self::State, 14 | ) { 15 | match state.get_sidebar_view() { 16 | LibraryView::Albums => SideBarAlbum.render(area, buf, state), 17 | LibraryView::Playlists => SideBarPlaylist.render(area, buf, state), 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/album.rs: -------------------------------------------------------------------------------- 1 | use super::SimpleSong; 2 | use std::sync::Arc; 3 | 4 | #[derive(Default, Clone)] 5 | pub struct Album { 6 | pub id: i64, 7 | pub title: Arc, 8 | pub artist: Arc, 9 | pub year: Option, 10 | pub tracklist: Arc<[Arc]>, 11 | } 12 | 13 | impl Album { 14 | pub fn from_aa(id: i64, title: Arc, artist: Arc) -> Self { 15 | Album { 16 | id, 17 | 18 | title, 19 | artist, 20 | year: None, 21 | tracklist: Arc::new([]), 22 | } 23 | } 24 | 25 | pub fn get_tracklist(&self) -> Vec> { 26 | self.tracklist.to_vec() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ui_state/playback/progress_display.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Eq)] 2 | pub enum ProgressDisplay { 3 | Waveform, 4 | ProgressBar, 5 | Oscilloscope, 6 | } 7 | 8 | impl ProgressDisplay { 9 | pub fn from_str(s: &str) -> Self { 10 | match s { 11 | "progress_bar" => Self::ProgressBar, 12 | "oscilloscope" => Self::Oscilloscope, 13 | _ => Self::Waveform, 14 | } 15 | } 16 | } 17 | 18 | impl std::fmt::Display for ProgressDisplay { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | match self { 21 | ProgressDisplay::Waveform => write!(f, "waveform"), 22 | ProgressDisplay::ProgressBar => write!(f, "progress_bar"), 23 | ProgressDisplay::Oscilloscope => write!(f, "oscilloscope"), 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /docs/theme_examples/Gruvbox [No borders].toml: -------------------------------------------------------------------------------- 1 | [colors] 2 | surface_global = "#1C1D21" 3 | surface_active = "#282828" 4 | surface_inactive = "#1d2021" 5 | surface_error = "#fb4934" 6 | 7 | text_primary = "#ebdbb2" 8 | text_secondary = "#fabd2f" 9 | text_secondary_in = "#d79921" 10 | text_selection = "#1d2021" 11 | text_muted = "#928374" 12 | 13 | border_active = "#b8bb26" 14 | border_inactive = "#504945" 15 | 16 | selection = "#b8bb26" 17 | selection_inactive = "#7c6f64" 18 | 19 | accent = "#83a598" 20 | accent_inactive = "#6a7d76" 21 | 22 | progress = ["#fabd2f", "#fb4934", "#fdf6f8"] 23 | # progress_i = "still" 24 | progress_speed = 6 25 | 26 | [borders] 27 | border_display = "none" 28 | border_type = "rounded" 29 | 30 | [extras] 31 | decorator = "󰯆" 32 | 33 | -------------------------------------------------------------------------------- /src/tui/widgets/song_window.rs: -------------------------------------------------------------------------------- 1 | use super::tracklist::{AlbumView, StandardTable}; 2 | use crate::{ 3 | tui::widgets::tracklist::GenericView, 4 | ui_state::{LibraryView, Mode, UiState}, 5 | }; 6 | use ratatui::widgets::StatefulWidget; 7 | 8 | pub struct SongTable; 9 | impl StatefulWidget for SongTable { 10 | type State = UiState; 11 | 12 | fn render( 13 | self, 14 | area: ratatui::prelude::Rect, 15 | buf: &mut ratatui::prelude::Buffer, 16 | state: &mut Self::State, 17 | ) { 18 | match state.get_mode() { 19 | &Mode::Library(LibraryView::Albums) => AlbumView.render(area, buf, state), 20 | &Mode::Library(LibraryView::Playlists) | &Mode::Queue => { 21 | GenericView.render(area, buf, state) 22 | } 23 | _ => StandardTable.render(area, buf, state), 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /docs/theme_examples/Cherry Blossom.toml: -------------------------------------------------------------------------------- 1 | [colors] 2 | surface_global = "#f6e9ec" 3 | surface_active = "#fdf6f8" 4 | surface_inactive = "#f6e9ec" 5 | surface_error = "#d14d4d" 6 | 7 | text_primary = "#4a2f33" 8 | text_secondary = "#d45a8a" 9 | text_secondary_in = "#e989a9" 10 | text_selection = "#fdf6f8" 11 | text_muted = "#B3B3B6" 12 | 13 | border_active = "#d45a8a" 14 | border_inactive = "#f8dbe4" 15 | 16 | selection = "#d45a8a" 17 | selection_inactive = "#e989a9" 18 | 19 | accent = "#3E3D32" 20 | accent_inactive = "" 21 | 22 | progress = ["#d45a8a", "#e989a9"] 23 | progress_i = "dimmed" 24 | progress_speed = 1 25 | 26 | [borders] 27 | border_display = "all" 28 | border_type = "rounded" 29 | 30 | [extras] 31 | is_dark = false 32 | decorator = "󱁂" 33 | -------------------------------------------------------------------------------- /src/tui/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | mod buffer_line; 2 | mod popup; 3 | mod popups; 4 | mod progress; 5 | mod search; 6 | mod sidebar; 7 | mod song_window; 8 | mod tracklist; 9 | 10 | pub use buffer_line::BufferLine; 11 | pub use popup::PopupManager; 12 | pub use popups::{ErrorMsg, PlaylistPopup, RootManager, ThemeManager}; 13 | pub use progress::Progress; 14 | pub use search::SearchBar; 15 | pub use sidebar::SideBarHandler; 16 | pub use song_window::SongTable; 17 | 18 | const DUR_WIDTH: u16 = 5; 19 | const PAUSE_ICON: &str = "󰏤"; 20 | const SELECTOR: &str = "⮞ "; 21 | const QUEUE_ICON: &str = "󰐑"; 22 | const MUSIC_NOTE: &str = "♫"; 23 | const QUEUED: &str = ""; 24 | const SELECTED: &str = "󱕣"; 25 | const WAVEFORM_WIDGET_HEIGHT: f64 = 50.0; 26 | 27 | static POPUP_PADDING: ratatui::widgets::Padding = ratatui::widgets::Padding { 28 | left: 5, 29 | right: 5, 30 | top: 2, 31 | bottom: 2, 32 | }; 33 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "concertus" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | 8 | anyhow = "1.0.100" 9 | bincode = "2.0.1" 10 | dirs = "6.0.0" 11 | fuzzy-matcher = "0.3.7" 12 | indexmap = "2.12.0" 13 | lofty = "0.22.4" 14 | nohash-hasher = "0.2.0" 15 | rand = "0.9.2" 16 | ratatui = {version = "*", features = ["serde"]} 17 | rayon = "1.11.0" 18 | rodio = { git = "https://github.com/RustAudio/rodio", branch = "feat/comprehensive-seeking-and-bit-depth", features = ["default", 19 | "symphonia-aac", 20 | "symphonia-alac", 21 | "symphonia-mp3", 22 | "symphonia-isomp4"]} 23 | rusqlite = { version = "0.37.0", features = ["bundled"] } 24 | serde = { version = "1.0.228", features = ["derive"]} 25 | toml = "0.9.8" 26 | tui-textarea = "0.7.0" 27 | walkdir = "2.5.0" 28 | unicode-normalization = "0.1.24" 29 | xxhash-rust = { version = "0.8.15", features = ["xxh3"] } 30 | -------------------------------------------------------------------------------- /docs/theme_examples/Concertus Transparent.toml: -------------------------------------------------------------------------------- 1 | [colors] 2 | surface_global = "" 3 | surface_active = "" 4 | surface_inactive = "" 5 | surface_error = "" 6 | 7 | text_primary = "#d2d2d7" 8 | text_secondary = "#ff4646" 9 | text_secondary_in = "#a62e2e" 10 | text_selection = "#dcdc64" 11 | text_muted = "#666669" 12 | 13 | border_active = "#dcdc64" 14 | border_inactive = "#343438" 15 | 16 | selection = "" 17 | selection_inactive = "" 18 | 19 | accent = "#dcdc64" 20 | accent_inactive = "#82823C" 21 | 22 | progress = [ "#444446", 23 | "#ff4646", 24 | "#FFFFFF" ] 25 | 26 | progress_i = "dimmed" 27 | progress_speed = 2 28 | 29 | [borders] 30 | border_display = "all" 31 | border_type = "rounded" 32 | 33 | [extras] 34 | decorator = "" 35 | 36 | -------------------------------------------------------------------------------- /src/ui_state/theme/mod.rs: -------------------------------------------------------------------------------- 1 | mod color_utils; 2 | mod display_theme; 3 | mod gradients; 4 | mod theme_config; 5 | mod theme_import; 6 | mod theme_manager; 7 | mod theme_utils; 8 | 9 | pub use color_utils::{SHARP_FACTOR, fade_color, get_gradient_color}; 10 | pub use display_theme::DisplayTheme; 11 | pub use gradients::{InactiveGradient, ProgressGradient}; 12 | pub use theme_config::ThemeConfig; 13 | pub use theme_manager::ThemeManager; 14 | 15 | use ratatui::style::Color; 16 | 17 | const DARK_WHITE: Color = Color::Rgb(210, 210, 213); 18 | const MID_GRAY: Color = Color::Rgb(100, 100, 103); 19 | const DARK_GRAY: Color = Color::Rgb(25, 25, 28); 20 | const DARK_GRAY_FADED: Color = Color::Rgb(15, 15, 18); 21 | const GOOD_RED: Color = Color::Rgb(255, 70, 70); 22 | const GOOD_RED_DARK: Color = Color::Rgb(180, 30, 30); 23 | const GOLD: Color = Color::Rgb(220, 220, 100); 24 | const GOLD_FADED: Color = Color::Rgb(130, 130, 60); 25 | -------------------------------------------------------------------------------- /docs/theme_examples/Monokai Classic.toml: -------------------------------------------------------------------------------- 1 | [colors] 2 | surface_global = "#272822" 3 | surface_active = "#3E3D32" 4 | surface_inactive = "#2C2B26" 5 | surface_error = "#F92672" 6 | 7 | text_primary = "#F8F8F2" 8 | text_secondary = "#FD971F" 9 | text_secondary_in = "#C57B13" 10 | text_selection = "#272822" 11 | text_muted = "#95917E" 12 | 13 | border_active = "#A6E22E" 14 | border_inactive = "#49483E" 15 | 16 | selection = "#A6E22E" 17 | selection_inactive = "#75715E" 18 | 19 | accent = "#FD971F" 20 | accent_inactive = "#C57B13" 21 | 22 | 23 | progress = [ "#F8F8F2", 24 | "#FD971F", 25 | "#C57B13" ] 26 | 27 | progress_i = "still" 28 | 29 | [borders] 30 | border_display = "all" 31 | border_type = "rounded" 32 | 33 | [extras] 34 | decorator = "󰓏" 35 | 36 | -------------------------------------------------------------------------------- /docs/theme_examples/Concertus Classic.toml: -------------------------------------------------------------------------------- 1 | [colors] 2 | surface_global = "#0a0a0d" 3 | surface_active = "#19191c" 4 | surface_inactive = "#0a0a0d" 5 | surface_error = "#e03e3e" 6 | 7 | text_primary = "#d2d2d7" 8 | text_secondary = "#ff4646" 9 | text_secondary_in = "#a62e2e" 10 | text_selection = "#0a0a0d" 11 | text_muted = "#646468" 12 | 13 | border_active = "#dcdc64" 14 | border_inactive = "#0a0a0d" 15 | 16 | selection = "#dcdc64" 17 | selection_inactive = "#82823C" 18 | 19 | accent = "#dcdc64" 20 | accent_inactive = "#82823C" 21 | 22 | progress = [ "#444446", 23 | "#ff4646", 24 | "#d2d2d7" ] 25 | 26 | progress_speed = 2 27 | 28 | [borders] 29 | border_display = "all" 30 | border_type = "rounded" 31 | 32 | [extras] 33 | is_dark = true 34 | decorator = "☆" 35 | 36 | -------------------------------------------------------------------------------- /docs/theme_examples/Monokai Vivid.toml: -------------------------------------------------------------------------------- 1 | [colors] 2 | surface_global = "#1e1e1e" 3 | surface_active = "#2e2e2e" 4 | surface_inactive = "#1e1e1e" 5 | surface_error = "#ff0055" 6 | 7 | text_primary = "#ffffff" 8 | text_secondary = "#ffeb3b" 9 | text_secondary_in = "#b8a82b" 10 | text_selection = "#1e1e1e" 11 | text_muted = "#888888" 12 | 13 | border_active = "#00ff41" 14 | border_inactive = "#6e8e5c" 15 | 16 | # selection = "#a9dc76" 17 | # selection_inactive = "#6e8e5c" 18 | 19 | selection = "#00ff41" 20 | selection_inactive = "#00a329" 21 | 22 | accent = "#00ff41" 23 | accent_inactive = "#00a329" 24 | 25 | progress = [ "#ffffff", 26 | "#00ff41", 27 | "#ffffff" ] 28 | 29 | [borders] 30 | border_display = "all" 31 | border_type = "plain" 32 | 33 | [extras] 34 | decorator = "" 35 | 36 | -------------------------------------------------------------------------------- /src/ui_state/mod.rs: -------------------------------------------------------------------------------- 1 | mod display_state; 2 | mod domain; 3 | mod multi_select; 4 | mod playback; 5 | mod playlist; 6 | mod popup; 7 | mod search_state; 8 | mod settings; 9 | mod theme; 10 | mod ui_snapshot; 11 | mod ui_state; 12 | 13 | pub use display_state::DisplayState; 14 | pub use domain::{AlbumSort, LibraryView, Mode, Pane, TableSort}; 15 | pub use playback::{PlaybackView, ProgressDisplay}; 16 | pub use playlist::PlaylistAction; 17 | pub use popup::PopupType; 18 | pub use search_state::MatchField; 19 | pub use settings::SettingsMode; 20 | pub use theme::DisplayTheme; 21 | pub use ui_snapshot::UiSnapshot; 22 | pub use ui_state::UiState; 23 | 24 | pub use theme::*; 25 | 26 | fn new_textarea(placeholder: &str) -> tui_textarea::TextArea<'static> { 27 | let mut search = tui_textarea::TextArea::default(); 28 | search.set_cursor_line_style(ratatui::style::Style::default()); 29 | search.set_placeholder_text(format!(" {placeholder}: ")); 30 | 31 | search 32 | } 33 | -------------------------------------------------------------------------------- /src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | mod album; 2 | mod filetype; 3 | mod long_song; 4 | mod playlist; 5 | mod queue_song; 6 | mod simple_song; 7 | mod waveform; 8 | 9 | pub use album::Album; 10 | pub use filetype::FileType; 11 | pub use long_song::LongSong; 12 | pub use playlist::Playlist; 13 | pub use playlist::PlaylistSong; 14 | pub use queue_song::QueueSong; 15 | pub use simple_song::SimpleSong; 16 | pub use waveform::{generate_waveform, smooth_waveform}; 17 | 18 | pub trait SongInfo { 19 | fn get_id(&self) -> u64; 20 | fn get_title(&self) -> &str; 21 | fn get_artist(&self) -> &str; 22 | fn get_album(&self) -> &str; 23 | fn get_duration(&self) -> std::time::Duration; 24 | fn get_duration_f32(&self) -> f32; 25 | fn get_duration_str(&self) -> String; 26 | } 27 | 28 | pub trait SongDatabase { 29 | fn get_path(&self) -> anyhow::Result; 30 | fn update_play_count(&self) -> anyhow::Result<()>; 31 | fn get_waveform(&self) -> anyhow::Result>; 32 | fn set_waveform_db(&self, wf: &[f32]) -> anyhow::Result<()>; 33 | } 34 | -------------------------------------------------------------------------------- /src/ui_state/domain/pane.rs: -------------------------------------------------------------------------------- 1 | #[derive(Default, PartialEq, Eq, Clone)] 2 | pub enum Pane { 3 | SideBar, 4 | Search, 5 | Popup, 6 | #[default] 7 | TrackList, 8 | } 9 | 10 | impl PartialEq for &Pane { 11 | fn eq(&self, other: &Pane) -> bool { 12 | std::mem::discriminant(*self) == std::mem::discriminant(other) 13 | } 14 | } 15 | 16 | impl std::fmt::Display for Pane { 17 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 | match self { 19 | Pane::TrackList => write!(f, "tracklist"), 20 | Pane::SideBar => write!(f, "sidebar"), 21 | Pane::Search => write!(f, "search"), 22 | Pane::Popup => write!(f, "temp"), 23 | } 24 | } 25 | } 26 | 27 | impl Pane { 28 | pub fn from_str(s: &str) -> Self { 29 | match s { 30 | "tracklist" => Pane::TrackList, 31 | "sidebar" => Pane::SideBar, 32 | "search" => Pane::Search, 33 | _ => Pane::TrackList, 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ui_state/domain/table_sort.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq)] 2 | pub enum TableSort { 3 | Title, 4 | Artist, 5 | Album, 6 | Duration, 7 | } 8 | 9 | impl ToString for TableSort { 10 | fn to_string(&self) -> String { 11 | match self { 12 | TableSort::Title => "Title".into(), 13 | TableSort::Artist => "Artist".into(), 14 | TableSort::Album => "Album".into(), 15 | TableSort::Duration => "Duration".into(), 16 | } 17 | } 18 | } 19 | 20 | impl TableSort { 21 | pub fn next(&self) -> Self { 22 | match self { 23 | TableSort::Title => TableSort::Artist, 24 | TableSort::Artist => TableSort::Album, 25 | TableSort::Album => TableSort::Duration, 26 | TableSort::Duration => TableSort::Title, 27 | } 28 | } 29 | pub fn prev(&self) -> Self { 30 | match self { 31 | TableSort::Title => TableSort::Duration, 32 | TableSort::Artist => TableSort::Title, 33 | TableSort::Album => TableSort::Artist, 34 | TableSort::Duration => TableSort::Album, 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/database/snapshot.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use rusqlite::params; 3 | 4 | use crate::{ 5 | database::queries::{GET_UI_SNAPSHOT, SET_SESSION_STATE}, 6 | ui_state::UiSnapshot, 7 | Database, 8 | }; 9 | 10 | impl Database { 11 | pub fn save_ui_snapshot(&mut self, snapshot: UiSnapshot) -> Result<()> { 12 | let tx = self.conn.transaction()?; 13 | { 14 | let mut stmt = tx.prepare(SET_SESSION_STATE)?; 15 | for (key, value) in snapshot.to_pairs() { 16 | stmt.execute(params![key, value])?; 17 | } 18 | } 19 | tx.commit()?; 20 | Ok(()) 21 | } 22 | 23 | pub fn load_ui_snapshot(&mut self) -> Result> { 24 | let mut stmt = self.conn.prepare(GET_UI_SNAPSHOT)?; 25 | 26 | let values: Vec<(String, String)> = stmt 27 | .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? 28 | .filter_map(Result::ok) 29 | .collect(); 30 | 31 | match values.is_empty() { 32 | true => Ok(None), 33 | false => Ok(Some(UiSnapshot::from_values(values))), 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/player/state.rs: -------------------------------------------------------------------------------- 1 | use crate::{domain::SimpleSong, player::OSCILLO_BUFFER_CAPACITY}; 2 | use anyhow::Error; 3 | use std::{collections::VecDeque, sync::Arc, time::Duration}; 4 | 5 | pub struct PlayerState { 6 | pub now_playing: Option>, 7 | pub state: PlaybackState, 8 | pub elapsed: Duration, 9 | pub oscilloscope_buffer: VecDeque, 10 | 11 | pub last_elapsed_secs: u64, 12 | pub elapsed_display: String, 13 | pub duration_display: String, 14 | 15 | pub player_error: Option, 16 | } 17 | 18 | impl Default for PlayerState { 19 | fn default() -> Self { 20 | PlayerState { 21 | state: PlaybackState::Stopped, 22 | now_playing: None, 23 | elapsed: Duration::default(), 24 | oscilloscope_buffer: VecDeque::with_capacity(OSCILLO_BUFFER_CAPACITY), 25 | 26 | last_elapsed_secs: 0, 27 | elapsed_display: String::with_capacity(11), 28 | duration_display: String::with_capacity(11), 29 | 30 | player_error: None, 31 | } 32 | } 33 | } 34 | 35 | #[derive(PartialEq, Copy, Clone)] 36 | pub enum PlaybackState { 37 | Playing, 38 | Paused, 39 | Transitioning, 40 | Stopped, 41 | } 42 | -------------------------------------------------------------------------------- /src/tui/widgets/progress/mod.rs: -------------------------------------------------------------------------------- 1 | mod oscilloscope; 2 | mod progress_bar; 3 | mod timer; 4 | mod waveform; 5 | 6 | use crate::{ 7 | tui::widgets::progress::{ 8 | oscilloscope::Oscilloscope, progress_bar::ProgressBar, timer::Timer, waveform::Waveform, 9 | }, 10 | ui_state::{ProgressDisplay, UiState}, 11 | }; 12 | use ratatui::widgets::StatefulWidget; 13 | 14 | pub struct Progress; 15 | impl StatefulWidget for Progress { 16 | type State = UiState; 17 | fn render( 18 | self, 19 | area: ratatui::prelude::Rect, 20 | buf: &mut ratatui::prelude::Buffer, 21 | state: &mut Self::State, 22 | ) { 23 | if state.get_now_playing().is_some() { 24 | Timer.render(area, buf, state); 25 | match &state.get_progress_display() { 26 | ProgressDisplay::ProgressBar => ProgressBar.render(area, buf, state), 27 | ProgressDisplay::Waveform => match state.waveform_is_valid() { 28 | true => Waveform.render(area, buf, state), 29 | false => Oscilloscope.render(area, buf, state), 30 | }, 31 | ProgressDisplay::Oscilloscope => Oscilloscope.render(area, buf, state), 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tui/widgets/search.rs: -------------------------------------------------------------------------------- 1 | use crate::ui_state::{Pane, UiState}; 2 | use ratatui::{ 3 | style::Stylize, 4 | widgets::{Block, Borders, Padding, StatefulWidget, Widget}, 5 | }; 6 | 7 | pub struct SearchBar; 8 | 9 | impl StatefulWidget for SearchBar { 10 | type State = UiState; 11 | fn render( 12 | self, 13 | area: ratatui::prelude::Rect, 14 | buf: &mut ratatui::prelude::Buffer, 15 | state: &mut Self::State, 16 | ) { 17 | let focus = matches!(&state.get_pane(), Pane::Search); 18 | let theme = &state.theme_manager.get_display_theme(focus); 19 | 20 | let (pd_v, pd_h) = match theme.border_display { 21 | Borders::NONE => (2, 3), 22 | _ => (1, 2), 23 | }; 24 | 25 | let search = &mut state.search.input; 26 | search.set_block( 27 | Block::bordered() 28 | .borders(theme.border_display) 29 | .border_type(theme.border_type) 30 | .border_style(theme.border) 31 | .padding(Padding { 32 | left: pd_h, 33 | right: 0, 34 | top: pd_v, 35 | bottom: 0, 36 | }) 37 | .fg(theme.accent) 38 | .bg(theme.bg), 39 | ); 40 | 41 | search.render(area, buf); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui_state/domain/album_sort.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Eq, Clone, Copy)] 2 | pub enum AlbumSort { 3 | Artist, 4 | Title, 5 | Year, 6 | } 7 | 8 | impl ToString for AlbumSort { 9 | fn to_string(&self) -> String { 10 | match self { 11 | AlbumSort::Artist => "Artist".into(), 12 | AlbumSort::Title => "Title".into(), 13 | AlbumSort::Year => "Year".into(), 14 | } 15 | } 16 | } 17 | 18 | impl PartialEq for &AlbumSort { 19 | fn eq(&self, other: &AlbumSort) -> bool { 20 | std::mem::discriminant(*self) == std::mem::discriminant(other) 21 | } 22 | } 23 | 24 | impl AlbumSort { 25 | pub fn next(&self) -> AlbumSort { 26 | match self { 27 | AlbumSort::Artist => AlbumSort::Title, 28 | AlbumSort::Title => AlbumSort::Year, 29 | AlbumSort::Year => AlbumSort::Artist, 30 | } 31 | } 32 | 33 | pub fn prev(&self) -> AlbumSort { 34 | match self { 35 | AlbumSort::Artist => AlbumSort::Year, 36 | AlbumSort::Title => AlbumSort::Artist, 37 | AlbumSort::Year => AlbumSort::Title, 38 | } 39 | } 40 | 41 | pub fn from_str(s: &str) -> Self { 42 | match s { 43 | "Artist" => AlbumSort::Artist, 44 | "Title" => AlbumSort::Title, 45 | "Year" => AlbumSort::Year, 46 | _ => AlbumSort::Artist, 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/tui/widgets/progress/timer.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | tui::widgets::DUR_WIDTH, 3 | ui_state::{ProgressDisplay, UiState}, 4 | }; 5 | use ratatui::{ 6 | layout::Rect, 7 | style::Stylize, 8 | text::Text, 9 | widgets::{StatefulWidget, Widget}, 10 | }; 11 | 12 | pub struct Timer; 13 | impl StatefulWidget for Timer { 14 | type State = UiState; 15 | 16 | fn render( 17 | self, 18 | area: ratatui::prelude::Rect, 19 | buf: &mut ratatui::prelude::Buffer, 20 | state: &mut Self::State, 21 | ) { 22 | let x_pos = area.width - 8; 23 | let y_pos = match state.get_progress_display() { 24 | ProgressDisplay::Waveform => area.y + (area.height / 2), 25 | _ => area.y + area.height.saturating_sub(1), 26 | }; 27 | 28 | let text_color = state.theme_manager.active.text_muted; 29 | let player_state = state.playback.player_state.lock().unwrap(); 30 | { 31 | let elapsed_str = player_state.elapsed_display.as_str(); 32 | let duration_str = player_state.duration_display.as_str(); 33 | 34 | Text::from(elapsed_str) 35 | .fg(text_color) 36 | .right_aligned() 37 | .render(Rect::new(2, y_pos, DUR_WIDTH, 1), buf); 38 | 39 | Text::from(duration_str) 40 | .fg(text_color) 41 | .right_aligned() 42 | .render(Rect::new(x_pos, y_pos, DUR_WIDTH, 1), buf); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/tui/renderer.rs: -------------------------------------------------------------------------------- 1 | use super::{widgets::SongTable, AppLayout, Progress, SearchBar, SideBar}; 2 | use crate::{ 3 | tui::{ 4 | render_bg, 5 | widgets::{BufferLine, PopupManager}, 6 | }, 7 | ui_state::Mode, 8 | UiState, 9 | }; 10 | use ratatui::{ 11 | layout::{Constraint, Direction, Layout, Rect}, 12 | widgets::StatefulWidget, 13 | Frame, 14 | }; 15 | 16 | pub fn render(f: &mut Frame, state: &mut UiState) { 17 | if matches!(state.get_mode(), Mode::Fullscreen) { 18 | let [progress, bufferline] = get_fullscreen_layout(f.area()); 19 | 20 | Progress.render(progress, f.buffer_mut(), state); 21 | BufferLine.render(bufferline, f.buffer_mut(), state); 22 | 23 | return; 24 | } 25 | 26 | let layout = AppLayout::new(f.area(), state); 27 | render_bg(state, f); 28 | 29 | SearchBar.render(layout.search_bar, f.buffer_mut(), state); 30 | SideBar.render(layout.sidebar, f.buffer_mut(), state); 31 | SongTable.render(layout.song_window, f.buffer_mut(), state); 32 | Progress.render(layout.progress_bar, f.buffer_mut(), state); 33 | BufferLine.render(layout.buffer_line, f.buffer_mut(), state); 34 | 35 | if state.popup.is_open() { 36 | PopupManager.render(f.area(), f.buffer_mut(), state); 37 | } 38 | } 39 | 40 | fn get_fullscreen_layout(area: Rect) -> [Rect; 2] { 41 | Layout::default() 42 | .direction(Direction::Vertical) 43 | .constraints([Constraint::Percentage(99), Constraint::Length(1)]) 44 | .areas::<2>(area) 45 | } 46 | -------------------------------------------------------------------------------- /src/tui/widgets/popups/theme_popup.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::Alignment, 3 | style::Stylize, 4 | widgets::{Block, List, StatefulWidget}, 5 | }; 6 | 7 | use crate::{ 8 | tui::widgets::{POPUP_PADDING, SELECTOR}, 9 | ui_state::UiState, 10 | }; 11 | 12 | pub struct ThemeManager; 13 | impl StatefulWidget for ThemeManager { 14 | type State = UiState; 15 | 16 | fn render( 17 | self, 18 | area: ratatui::prelude::Rect, 19 | buf: &mut ratatui::prelude::Buffer, 20 | state: &mut Self::State, 21 | ) { 22 | let theme = &state.theme_manager.get_display_theme(true); 23 | 24 | let theme_names = state 25 | .theme_manager 26 | .theme_lib 27 | .iter() 28 | .map(|t| t.name.clone()) 29 | .collect::>(); 30 | 31 | let block = Block::bordered() 32 | .border_type(theme.border_type) 33 | .border_style(theme.border) 34 | .title(" Select Theme ") 35 | .title_bottom(" [Enter] / [Esc] ") 36 | .title_alignment(Alignment::Center) 37 | .padding(POPUP_PADDING) 38 | .bg(theme.bg); 39 | 40 | let list = List::new(theme_names) 41 | .block(block) 42 | .scroll_padding(area.height as usize - 3) 43 | .fg(theme.text_muted) 44 | .highlight_symbol(SELECTOR) 45 | .highlight_style(theme.accent); 46 | 47 | StatefulWidget::render(list, area, buf, &mut state.popup.selection); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/tui/widgets/popups/error.rs: -------------------------------------------------------------------------------- 1 | use crate::ui_state::UiState; 2 | use ratatui::{ 3 | crossterm::style::Color, 4 | layout::{Alignment, Constraint, Layout}, 5 | style::Stylize, 6 | widgets::{Block, Padding, Paragraph, StatefulWidget, Widget, Wrap}, 7 | }; 8 | 9 | static SIDE_PADDING: u16 = 5; 10 | static VERTICAL_PADDING: u16 = 1; 11 | 12 | static PADDING: Padding = Padding { 13 | left: SIDE_PADDING, 14 | right: SIDE_PADDING, 15 | top: VERTICAL_PADDING, 16 | bottom: VERTICAL_PADDING, 17 | }; 18 | 19 | pub struct ErrorMsg; 20 | impl StatefulWidget for ErrorMsg { 21 | type State = UiState; 22 | fn render( 23 | self, 24 | area: ratatui::prelude::Rect, 25 | buf: &mut ratatui::prelude::Buffer, 26 | state: &mut Self::State, 27 | ) { 28 | let theme = &state.theme_manager.get_display_theme(true); 29 | 30 | let block = Block::bordered() 31 | .border_type(theme.border_type) 32 | .title_bottom(" Press to clear ") 33 | .title_alignment(Alignment::Center) 34 | .padding(PADDING) 35 | .fg(Color::White) 36 | .bg(theme.bg_error); 37 | 38 | let inner = block.inner(area); 39 | block.render(area, buf); 40 | let chunks = Layout::vertical([ 41 | Constraint::Percentage(33), 42 | Constraint::Length(3), 43 | Constraint::Fill(1), 44 | ]) 45 | .split(inner); 46 | 47 | let err_str = state.get_error().unwrap_or("No error to display"); 48 | 49 | Paragraph::new(err_str) 50 | .wrap(Wrap { trim: true }) 51 | .centered() 52 | .render(chunks[1], buf); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ui_state/domain/mode.rs: -------------------------------------------------------------------------------- 1 | #[derive(Default, PartialEq, Eq, Clone, Copy)] 2 | pub enum LibraryView { 3 | #[default] 4 | Albums, 5 | Playlists, 6 | } 7 | 8 | #[derive(PartialEq, Eq, Clone)] 9 | pub enum Mode { 10 | Power, 11 | Library(LibraryView), 12 | Fullscreen, 13 | Queue, 14 | Search, 15 | QUIT, 16 | } 17 | 18 | impl Default for Mode { 19 | fn default() -> Self { 20 | Mode::Library(LibraryView::default()) 21 | } 22 | } 23 | 24 | impl PartialEq for &Mode { 25 | fn eq(&self, other: &Mode) -> bool { 26 | std::mem::discriminant(*self) == std::mem::discriminant(other) 27 | } 28 | } 29 | 30 | impl std::fmt::Display for Mode { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | match self { 33 | Mode::Power => write!(f, "power"), 34 | Mode::Library(LibraryView::Albums) => write!(f, "library_album"), 35 | Mode::Library(LibraryView::Playlists) => write!(f, "library_playlist"), 36 | Mode::Fullscreen => write!(f, "fullscreen"), 37 | Mode::Queue => write!(f, "queue"), 38 | Mode::Search => write!(f, "search"), 39 | Mode::QUIT => write!(f, "quit"), 40 | } 41 | } 42 | } 43 | 44 | impl Mode { 45 | pub fn from_str(s: &str) -> Self { 46 | match s { 47 | "power" => Mode::Power, 48 | "library_album" => Mode::Library(LibraryView::Albums), 49 | "library_playlist" => Mode::Library(LibraryView::Playlists), 50 | "queue" => Mode::Queue, 51 | "search" => Mode::Search, 52 | "quit" => Mode::QUIT, 53 | _ => Mode::Library(LibraryView::Albums), 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/tui/widgets/progress/progress_bar.rs: -------------------------------------------------------------------------------- 1 | use crate::{domain::SongInfo, ui_state::UiState}; 2 | use ratatui::{ 3 | style::Stylize, 4 | symbols::line, 5 | widgets::{Block, LineGauge, Padding, StatefulWidget, Widget}, 6 | }; 7 | 8 | pub struct ProgressBar; 9 | 10 | impl StatefulWidget for ProgressBar { 11 | type State = UiState; 12 | fn render( 13 | self, 14 | area: ratatui::prelude::Rect, 15 | buf: &mut ratatui::prelude::Buffer, 16 | state: &mut Self::State, 17 | ) { 18 | let theme = state.theme_manager.get_display_theme(true); 19 | 20 | let np = state 21 | .get_now_playing() 22 | .expect("Expected a song to be playing. [Widget: Progress Bar]"); 23 | let elapsed = state.get_playback_elapsed().as_secs_f32(); 24 | let duration = np.get_duration().as_secs_f32(); 25 | let progress_raw = elapsed / duration; 26 | 27 | // The program will crash if this hit's 1.0 28 | let ratio = match progress_raw { 29 | i if i < 1.0 => i, 30 | _ => 0.0, 31 | }; 32 | 33 | let fg = theme.get_focused_color(ratio, elapsed); 34 | 35 | let amp = 1.0; 36 | let bg = theme.get_inactive_color(ratio, elapsed, amp); 37 | 38 | let guage = LineGauge::default() 39 | .block(Block::new().bg(theme.bg_global).padding(Padding { 40 | left: 1, 41 | right: 2, 42 | top: (area.height / 2), 43 | bottom: 0, 44 | })) 45 | .filled_style(fg) 46 | .unfilled_style(bg) 47 | .line_set(line::THICK) 48 | .label("") 49 | .ratio(ratio as f64); 50 | 51 | guage.render(area, buf); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/domain/queue_song.rs: -------------------------------------------------------------------------------- 1 | use super::{SimpleSong, SongInfo}; 2 | use crate::{Database, domain::SongDatabase, get_readable_duration}; 3 | use anyhow::Result; 4 | use std::{sync::Arc, time::Duration}; 5 | 6 | pub struct QueueSong { 7 | pub meta: Arc, 8 | pub path: String, 9 | } 10 | 11 | impl SongInfo for QueueSong { 12 | fn get_id(&self) -> u64 { 13 | self.meta.id 14 | } 15 | 16 | fn get_title(&self) -> &str { 17 | &self.meta.title 18 | } 19 | 20 | fn get_artist(&self) -> &str { 21 | &self.meta.artist 22 | } 23 | 24 | fn get_album(&self) -> &str { 25 | &self.meta.album 26 | } 27 | 28 | fn get_duration(&self) -> Duration { 29 | self.meta.duration 30 | } 31 | 32 | fn get_duration_f32(&self) -> f32 { 33 | self.meta.duration.as_secs_f32() 34 | } 35 | 36 | fn get_duration_str(&self) -> String { 37 | get_readable_duration(self.meta.duration, crate::DurationStyle::Compact) 38 | } 39 | } 40 | 41 | impl SongDatabase for QueueSong { 42 | /// Returns the path of a song as a String 43 | fn get_path(&self) -> Result { 44 | let mut db = Database::open()?; 45 | db.get_song_path(self.meta.id) 46 | } 47 | 48 | /// Update the play_count of the song 49 | fn update_play_count(&self) -> Result<()> { 50 | let mut db = Database::open()?; 51 | db.update_play_count(self.meta.id) 52 | } 53 | 54 | /// Retrieve the waveform of a song 55 | /// returns Result> 56 | fn get_waveform(&self) -> Result> { 57 | let mut db = Database::open()?; 58 | db.get_waveform(self.meta.id) 59 | } 60 | 61 | /// Store the waveform of a song in the databse 62 | fn set_waveform_db(&self, wf: &[f32]) -> Result<()> { 63 | let mut db = Database::open()?; 64 | db.set_waveform(self.meta.id, wf) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/domain/playlist.rs: -------------------------------------------------------------------------------- 1 | use crate::{domain::SongInfo, get_readable_duration}; 2 | 3 | use super::SimpleSong; 4 | use std::{sync::Arc, time::Duration}; 5 | 6 | pub struct Playlist { 7 | pub id: i64, 8 | pub name: String, 9 | pub tracklist: Vec, 10 | length: Duration, 11 | } 12 | 13 | impl Playlist { 14 | pub fn new(id: i64, name: String, tracklist: Vec) -> Self { 15 | let length: Duration = tracklist.iter().map(|s| s.get_duration()).sum(); 16 | 17 | Playlist { 18 | id, 19 | name, 20 | tracklist, 21 | length, 22 | } 23 | } 24 | 25 | pub fn get_tracks(&self) -> Vec> { 26 | self.tracklist 27 | .iter() 28 | .map(|s| Arc::clone(&s.song)) 29 | .collect::>() 30 | } 31 | 32 | pub fn get_total_length(&self) -> Duration { 33 | self.length 34 | } 35 | 36 | pub fn len(&self) -> usize { 37 | self.tracklist.len() 38 | } 39 | 40 | pub fn update_length(&mut self) { 41 | self.length = self.tracklist.iter().map(|s| s.get_duration()).sum(); 42 | } 43 | } 44 | 45 | pub struct PlaylistSong { 46 | pub id: i64, 47 | pub song: Arc, 48 | } 49 | 50 | impl SongInfo for PlaylistSong { 51 | fn get_id(&self) -> u64 { 52 | self.song.id 53 | } 54 | 55 | fn get_title(&self) -> &str { 56 | &self.song.title 57 | } 58 | 59 | fn get_artist(&self) -> &str { 60 | &self.song.artist 61 | } 62 | 63 | fn get_album(&self) -> &str { 64 | &self.song.album 65 | } 66 | 67 | fn get_duration(&self) -> Duration { 68 | self.song.duration 69 | } 70 | 71 | fn get_duration_f32(&self) -> f32 { 72 | self.song.duration.as_secs_f32() 73 | } 74 | 75 | fn get_duration_str(&self) -> String { 76 | get_readable_duration(self.song.duration, crate::DurationStyle::Compact) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/player/tapped_source.rs: -------------------------------------------------------------------------------- 1 | use super::PlayerState; 2 | use anyhow::Result; 3 | use rodio::{ChannelCount, Source}; 4 | use std::{ 5 | num::NonZero, 6 | sync::{Arc, Mutex}, 7 | time::Duration, 8 | }; 9 | 10 | pub struct TappedSource { 11 | input: I, 12 | player_state: Arc>, 13 | } 14 | 15 | impl TappedSource { 16 | pub fn new(input: I, player_state: Arc>) -> Self { 17 | TappedSource { 18 | input, 19 | player_state, 20 | } 21 | } 22 | } 23 | 24 | impl Iterator for TappedSource 25 | where 26 | I: Source, 27 | { 28 | type Item = f32; 29 | 30 | fn next(&mut self) -> Option { 31 | if let Some(sample) = self.input.next() { 32 | if let Ok(mut state) = self.player_state.try_lock() { 33 | if state.oscilloscope_buffer.len() >= super::OSCILLO_BUFFER_CAPACITY { 34 | state.oscilloscope_buffer.pop_front(); 35 | } 36 | state.oscilloscope_buffer.push_back(sample); 37 | } 38 | Some(sample) 39 | } else { 40 | None 41 | } 42 | } 43 | } 44 | 45 | impl Source for TappedSource 46 | where 47 | I: Source, 48 | { 49 | fn channels(&self) -> ChannelCount { 50 | self.input.channels() 51 | } 52 | 53 | fn sample_rate(&self) -> NonZero { 54 | self.input.sample_rate() 55 | } 56 | 57 | fn total_duration(&self) -> Option { 58 | self.input.total_duration() 59 | } 60 | 61 | fn current_span_len(&self) -> Option { 62 | self.input.current_span_len() 63 | } 64 | 65 | fn bits_per_sample(&self) -> Option { 66 | self.input.bits_per_sample() 67 | } 68 | 69 | fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { 70 | self.input.try_seek(pos) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/tui/widgets/popup.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Layout, Rect}, 3 | widgets::{Clear, StatefulWidget, Widget}, 4 | }; 5 | 6 | use crate::{ 7 | tui::{ 8 | ErrorMsg, 9 | widgets::{PlaylistPopup, RootManager, ThemeManager}, 10 | }, 11 | ui_state::{PopupType, UiState}, 12 | }; 13 | 14 | pub struct PopupManager; 15 | impl StatefulWidget for PopupManager { 16 | type State = UiState; 17 | 18 | fn render( 19 | self, 20 | area: ratatui::prelude::Rect, 21 | buf: &mut ratatui::prelude::Buffer, 22 | state: &mut Self::State, 23 | ) { 24 | let popup_rect = match &state.popup.current { 25 | PopupType::Playlist(_) => centered_rect(35, 40, area), 26 | PopupType::Settings(_) => centered_rect(40, 40, area), 27 | PopupType::ThemeManager => centered_rect(40, 40, area), 28 | PopupType::Error(_) => centered_rect(40, 35, area), 29 | _ => return, 30 | }; 31 | 32 | Clear.render(popup_rect, buf); 33 | match &state.popup.current { 34 | PopupType::Playlist(_) => PlaylistPopup.render(popup_rect, buf, state), 35 | PopupType::Settings(_) => RootManager.render(popup_rect, buf, state), 36 | 37 | PopupType::ThemeManager => ThemeManager.render(popup_rect, buf, state), 38 | PopupType::Error(_) => ErrorMsg.render(popup_rect, buf, state), 39 | _ => unreachable!(), 40 | } 41 | } 42 | } 43 | 44 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 45 | let popup_layout = Layout::vertical([ 46 | Constraint::Percentage((100 - percent_y) / 2), 47 | Constraint::Percentage(percent_y), 48 | Constraint::Percentage((100 - percent_y) / 2), 49 | ]) 50 | .split(r); 51 | 52 | Layout::horizontal([ 53 | Constraint::Percentage((100 - percent_x) / 2), 54 | Constraint::Percentage(percent_x), 55 | Constraint::Percentage((100 - percent_x) / 2), 56 | ]) 57 | .split(popup_layout[1])[1] 58 | } 59 | -------------------------------------------------------------------------------- /src/domain/filetype.rs: -------------------------------------------------------------------------------- 1 | use rusqlite::{ 2 | Result as RusqliteResult, ToSql, 3 | types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, Value, ValueRef}, 4 | }; 5 | use std::fmt::Display; 6 | 7 | #[allow(clippy::upper_case_acronyms)] 8 | #[derive(Default, Eq, PartialEq, Copy, Clone, Hash)] 9 | pub enum FileType { 10 | MP3 = 1, 11 | M4A = 2, 12 | OGG = 3, 13 | WAV = 4, 14 | FLAC = 5, 15 | #[default] 16 | ERR = 0, 17 | } 18 | 19 | impl From<&str> for FileType { 20 | fn from(str: &str) -> Self { 21 | match str { 22 | "mp3" => Self::MP3, 23 | "aac" | "m4a" => Self::M4A, 24 | "ogg" => Self::OGG, 25 | "flac" => Self::FLAC, 26 | "wav" => Self::WAV, 27 | _ => Self::ERR, 28 | } 29 | } 30 | } 31 | 32 | impl FromSql for FileType { 33 | fn column_result(value: ValueRef<'_>) -> FromSqlResult { 34 | match value { 35 | ValueRef::Integer(i) => Ok(FileType::from_i64(i)), 36 | _ => Err(FromSqlError::InvalidType), 37 | } 38 | } 39 | } 40 | 41 | impl ToSql for FileType { 42 | fn to_sql(&self) -> RusqliteResult> { 43 | Ok(ToSqlOutput::Owned(Value::Integer(self.to_i64()))) 44 | } 45 | } 46 | 47 | impl Display for FileType { 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | match *self { 50 | FileType::MP3 => write!(f, "ᵐᵖ³"), 51 | FileType::M4A => write!(f, "ᵐ⁴ᵃ"), 52 | FileType::OGG => write!(f, "ᵒᵍᵍ"), 53 | FileType::WAV => write!(f, "ʷᵃᵛ"), 54 | FileType::FLAC => write!(f, "ᶠˡᵃᶜ"), 55 | FileType::ERR => write!(f, "ERR"), 56 | } 57 | } 58 | } 59 | 60 | impl FileType { 61 | pub fn from_i64(value: i64) -> Self { 62 | match value { 63 | 1 => Self::MP3, 64 | 2 => Self::M4A, 65 | 3 => Self::OGG, 66 | 4 => Self::WAV, 67 | 5 => Self::FLAC, 68 | _ => Self::ERR, 69 | } 70 | } 71 | 72 | pub fn to_i64(&self) -> i64 { 73 | *self as i64 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/tui/widgets/tracklist/generic_tracklist.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | domain::SongInfo, 3 | tui::widgets::tracklist::{CellFactory, create_standard_table, get_title}, 4 | ui_state::{Pane, UiState}, 5 | }; 6 | use ratatui::{ 7 | style::Stylize, 8 | widgets::{Row, StatefulWidget}, 9 | }; 10 | 11 | pub struct GenericView; 12 | impl StatefulWidget for GenericView { 13 | type State = UiState; 14 | fn render( 15 | self, 16 | area: ratatui::prelude::Rect, 17 | buf: &mut ratatui::prelude::Buffer, 18 | state: &mut Self::State, 19 | ) { 20 | let focus = matches!(state.get_pane(), Pane::TrackList); 21 | let theme = &state.theme_manager.get_display_theme(focus); 22 | let songs = state.legal_songs.as_slice(); 23 | 24 | let rows = songs 25 | .iter() 26 | .enumerate() 27 | .map(|(idx, song)| { 28 | let is_multi_selected = state.get_multi_select_indices().contains(&idx); 29 | 30 | let index = CellFactory::index_cell(&theme, idx, is_multi_selected); 31 | let icon = CellFactory::status_cell(song, state, is_multi_selected); 32 | let title = CellFactory::title_cell(&theme, song.get_title(), is_multi_selected); 33 | let artist = CellFactory::artist_cell(&theme, song, is_multi_selected); 34 | let filetype = CellFactory::filetype_cell(&theme, song, is_multi_selected); 35 | let duration = CellFactory::duration_cell(&theme, song, is_multi_selected); 36 | 37 | match is_multi_selected { 38 | true => Row::new([index, icon, title, artist, filetype, duration]) 39 | .fg(theme.text_selected) 40 | .bg(state.theme_manager.active.selection_inactive), 41 | false => Row::new([index, icon, title, artist, filetype, duration]), 42 | } 43 | }) 44 | .collect::>(); 45 | 46 | let title = get_title(state, area); 47 | 48 | let table = create_standard_table(rows, title, state, theme); 49 | StatefulWidget::render(table, area, buf, &mut state.display_state.table_pos); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ui_state/theme/gradients.rs: -------------------------------------------------------------------------------- 1 | use crate::ui_state::theme::{theme_import::ProgressGradientRaw, theme_utils::parse_color}; 2 | use anyhow::Result; 3 | use ratatui::style::Color; 4 | use std::sync::Arc; 5 | 6 | #[derive(Clone)] 7 | pub enum ProgressGradient { 8 | Static(Color), 9 | Gradient(Arc<[Color]>), 10 | } 11 | 12 | #[derive(Clone)] 13 | pub enum InactiveGradient { 14 | Dimmed, 15 | Still, 16 | Static(Color), 17 | Gradient(Arc<[Color]>), 18 | } 19 | 20 | impl ProgressGradient { 21 | pub(super) fn from_raw(raw: &ProgressGradientRaw) -> Result { 22 | match raw { 23 | ProgressGradientRaw::Single(c) => Ok(ProgressGradient::Static(parse_color(&c)?)), 24 | ProgressGradientRaw::Gradient(colors) => { 25 | if colors.len() == 1 { 26 | return Ok(ProgressGradient::Static(parse_color(&colors[0])?)); 27 | } 28 | 29 | let gradient = colors 30 | .iter() 31 | .map(|c| parse_color(&c)) 32 | .collect::>>()?; 33 | 34 | Ok(ProgressGradient::Gradient(gradient.into())) 35 | } 36 | } 37 | } 38 | } 39 | 40 | impl InactiveGradient { 41 | pub(super) fn from_raw(raw: &ProgressGradientRaw) -> Result { 42 | match raw { 43 | ProgressGradientRaw::Single(s) if s.to_lowercase().as_str() == "dimmed" => { 44 | Ok(InactiveGradient::Dimmed) 45 | } 46 | ProgressGradientRaw::Single(s) if s.to_lowercase().as_str() == "still" => { 47 | Ok(InactiveGradient::Still) 48 | } 49 | ProgressGradientRaw::Single(s) => { 50 | let color = parse_color(&s)?; 51 | Ok(InactiveGradient::Static(color)) 52 | } 53 | ProgressGradientRaw::Gradient(colors) => { 54 | let gradient = colors 55 | .iter() 56 | .map(|c| parse_color(&c)) 57 | .collect::>>()?; 58 | 59 | Ok(InactiveGradient::Gradient(gradient.into())) 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/tui/widgets/sidebar/mod.rs: -------------------------------------------------------------------------------- 1 | mod album_sidebar; 2 | mod handler; 3 | mod playlist_sidebar; 4 | 5 | pub use album_sidebar::SideBarAlbum; 6 | pub use handler::SideBarHandler; 7 | pub use playlist_sidebar::SideBarPlaylist; 8 | use ratatui::{ 9 | layout::Rect, 10 | style::{Style, Stylize}, 11 | text::Line, 12 | widgets::{Block, HighlightSpacing, List, ListItem, Padding}, 13 | }; 14 | 15 | use crate::ui_state::{LibraryView, Pane, UiState}; 16 | 17 | const PADDING: Padding = Padding { 18 | left: 3, 19 | right: 2, 20 | top: 1, 21 | bottom: 1, 22 | }; 23 | 24 | pub fn create_standard_list<'a>( 25 | list_items: Vec>, 26 | titles: (Line<'static>, Line<'static>), 27 | state: &UiState, 28 | area: Rect, 29 | ) -> List<'a> { 30 | let focus = matches!(&state.get_pane(), Pane::SideBar); 31 | let theme = state.theme_manager.get_display_theme(focus); 32 | 33 | let keymaps = if state.get_pane() == Pane::SideBar { 34 | match state.display_state.sidebar_view { 35 | LibraryView::Albums => Line::from(" [q] Queue Album ") 36 | .centered() 37 | .fg(theme.text_muted), 38 | LibraryView::Playlists => { 39 | let playlist_keymaps = " [c]reate 󰲸 | [^D]elete 󰐓 "; 40 | match area.width as usize + 2 < playlist_keymaps.len() { 41 | true => Line::default(), 42 | false => Line::from(playlist_keymaps).centered().fg(theme.text_muted), 43 | } 44 | } 45 | } 46 | } else { 47 | Line::default() 48 | }; 49 | 50 | let block = Block::bordered() 51 | .borders(theme.border_display) 52 | .border_type(theme.border_type) 53 | .border_style(theme.border) 54 | .bg(theme.bg) 55 | .title_top(titles.0) 56 | .title_top(titles.1) 57 | .title_bottom(Line::from(keymaps).centered().fg(theme.text_muted)) 58 | .padding(PADDING); 59 | 60 | List::new(list_items) 61 | .block(block) 62 | .highlight_style(Style::new().fg(theme.text_selected).bg(theme.selection)) 63 | .scroll_padding((area.height as f32 * 0.15) as usize) 64 | .highlight_spacing(HighlightSpacing::Always) 65 | } 66 | -------------------------------------------------------------------------------- /src/tui/layout.rs: -------------------------------------------------------------------------------- 1 | use crate::ui_state::{Mode, ProgressDisplay, UiState}; 2 | use ratatui::layout::{Constraint, Direction, Layout, Rect}; 3 | 4 | pub struct AppLayout { 5 | pub sidebar: Rect, 6 | pub search_bar: Rect, 7 | pub song_window: Rect, 8 | pub progress_bar: Rect, 9 | pub buffer_line: Rect, 10 | } 11 | 12 | impl AppLayout { 13 | pub fn new(area: Rect, state: &mut UiState) -> Self { 14 | let prog_height = if state.display_progress() { 15 | match (state.get_progress_display(), area.height > 20) { 16 | (ProgressDisplay::Waveform | ProgressDisplay::Oscilloscope, true) => 6, 17 | _ => 3, 18 | } 19 | } else { 20 | 0 21 | }; 22 | 23 | let search_height = match state.get_mode() == Mode::Search { 24 | true => 5, 25 | false => 0, 26 | }; 27 | 28 | let buffer_line_height = match state.is_playing() 29 | || !state.multi_select_empty() 30 | || state.get_library_refresh_progress().is_some() 31 | { 32 | true => 1, 33 | false => 0, 34 | }; 35 | 36 | let [upper_block, progress_bar, buffer_line] = Layout::default() 37 | .direction(Direction::Vertical) 38 | .constraints([ 39 | Constraint::Min(16), 40 | Constraint::Length(prog_height), 41 | Constraint::Length(buffer_line_height), 42 | ]) 43 | .areas(area); 44 | 45 | let [sidebar, _, upper_block] = Layout::default() 46 | .direction(Direction::Horizontal) 47 | .constraints([ 48 | Constraint::Percentage(state.display_state.sidebar_percent), 49 | Constraint::Length(0), 50 | Constraint::Fill(1), 51 | ]) 52 | .areas(upper_block); 53 | 54 | let [search_bar, song_window] = Layout::default() 55 | .direction(Direction::Vertical) 56 | .constraints([Constraint::Length(search_height), Constraint::Fill(100)]) 57 | .areas(upper_block); 58 | 59 | AppLayout { 60 | sidebar, 61 | search_bar, 62 | song_window, 63 | progress_bar, 64 | buffer_line, 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ui_state/theme/color_utils.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | 3 | pub const SHARP_FACTOR: f32 = 2.0; 4 | 5 | pub fn get_gradient_color(gradient: &[Color], position: f32, time: f32) -> Color { 6 | if gradient.is_empty() { 7 | return Color::Reset; 8 | } 9 | 10 | let t = ((position + time) % 1.0).abs(); 11 | 12 | let segment_count = gradient.len(); 13 | let segment_f = t * segment_count as f32; 14 | let segment = (segment_f as usize).min(segment_count - 1); 15 | let local_t = segment_f - segment as f32; 16 | 17 | let next_segment = (segment + 1) % segment_count; 18 | let sharp = sharpen_interpolation(local_t, SHARP_FACTOR); 19 | 20 | interpolate_color(gradient[segment], gradient[next_segment], sharp) 21 | } 22 | 23 | fn interpolate_color(c1: Color, c2: Color, t: f32) -> Color { 24 | match (c1, c2) { 25 | (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb( 26 | (r1 as f32 + (r2 as f32 - r1 as f32) * t) as u8, 27 | (g1 as f32 + (g2 as f32 - g1 as f32) * t) as u8, 28 | (b1 as f32 + (b2 as f32 - b1 as f32) * t) as u8, 29 | ), 30 | _ => c1, 31 | } 32 | } 33 | 34 | fn sharpen_interpolation(t: f32, power: f32) -> f32 { 35 | if t < 0.5 { 36 | (t * 2.0).powf(power) / 2.0 37 | } else { 38 | 1.0 - ((1.0 - t) * 2.0).powf(power) / 2.0 39 | } 40 | } 41 | 42 | pub fn fade_color(is_dark: bool, color: Color, factor: f32) -> Color { 43 | match is_dark { 44 | true => dim_color(color, factor), 45 | false => brighten_color(color, factor), 46 | } 47 | } 48 | 49 | fn dim_color(color: Color, factor: f32) -> Color { 50 | match color { 51 | Color::Rgb(r, g, b) => Color::Rgb( 52 | (r as f32 * factor) as u8, 53 | (g as f32 * factor) as u8, 54 | (b as f32 * factor) as u8, 55 | ), 56 | other => other, 57 | } 58 | } 59 | 60 | fn brighten_color(color: Color, factor: f32) -> Color { 61 | let factor = 1.0 - factor; 62 | match color { 63 | Color::Rgb(r, g, b) => Color::Rgb( 64 | (r as f32 + (255.0 - r as f32) * factor) as u8, 65 | (g as f32 + (255.0 - g as f32) * factor) as u8, 66 | (b as f32 + (255.0 - b as f32) * factor) as u8, 67 | ), 68 | other => other, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/tui/widgets/sidebar/playlist_sidebar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::Alignment, 3 | style::Stylize, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, ListItem, Padding, Paragraph, StatefulWidget, Widget, Wrap}, 6 | }; 7 | 8 | use crate::{ 9 | tui::widgets::sidebar::create_standard_list, 10 | ui_state::{Pane, UiState}, 11 | }; 12 | 13 | pub struct SideBarPlaylist; 14 | impl StatefulWidget for SideBarPlaylist { 15 | type State = UiState; 16 | 17 | fn render( 18 | self, 19 | area: ratatui::prelude::Rect, 20 | buf: &mut ratatui::prelude::Buffer, 21 | state: &mut Self::State, 22 | ) { 23 | let focus = matches!(&state.get_pane(), Pane::SideBar); 24 | let theme = state.theme_manager.get_display_theme(focus); 25 | let playlists = &state.playlists; 26 | 27 | if playlists.is_empty() { 28 | Widget::render( 29 | Paragraph::new("Create some playlists!\n\nPress [c] to get started!") 30 | .block(Block::new().borders(Borders::NONE).padding(Padding { 31 | left: 2, 32 | right: 2, 33 | top: 5, 34 | bottom: 0, 35 | })) 36 | .alignment(Alignment::Center) 37 | .wrap(Wrap { trim: true }) 38 | .fg(theme.text_primary), 39 | area, 40 | buf, 41 | ); 42 | } 43 | 44 | let list_items = playlists 45 | .iter() 46 | .map(|p| { 47 | ListItem::new( 48 | Line::from_iter([ 49 | Span::from(p.name.as_str()).fg(theme.text_secondary), 50 | format!("{:>5} ", format!("[{}]", p.tracklist.len())) 51 | .fg(theme.text_secondary) 52 | .into(), 53 | ]) 54 | .right_aligned(), 55 | ) 56 | }) 57 | .collect(); 58 | 59 | let title = Line::from(format!(" ⟪ {} Playlists ⟫ ", playlists.len())) 60 | .left_aligned() 61 | .fg(theme.accent); 62 | 63 | StatefulWidget::render( 64 | create_standard_list(list_items, (title, Line::default()), state, area), 65 | area, 66 | buf, 67 | &mut state.display_state.playlist_pos, 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ui_state/theme/display_theme.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use crate::ui_state::{ 4 | ProgressGradient, UiState, 5 | theme::{ 6 | color_utils::{fade_color, get_gradient_color}, 7 | gradients::InactiveGradient, 8 | }, 9 | }; 10 | use ratatui::{ 11 | style::Color, 12 | widgets::{BorderType, Borders}, 13 | }; 14 | 15 | pub struct DisplayTheme { 16 | pub dark: bool, 17 | pub bg: Color, 18 | pub bg_global: Color, 19 | pub bg_error: Color, 20 | 21 | pub text_primary: Color, 22 | pub text_secondary: Color, 23 | pub text_muted: Color, 24 | pub text_selected: Color, 25 | 26 | pub accent: Color, 27 | pub selection: Color, 28 | 29 | pub border: Color, 30 | pub border_display: Borders, 31 | pub border_type: BorderType, 32 | 33 | pub progress_complete: ProgressGradient, 34 | pub progress_incomplete: InactiveGradient, 35 | pub progress_speed: f32, 36 | } 37 | 38 | impl UiState { 39 | pub fn get_decorator(&self) -> Rc { 40 | Rc::clone(&self.theme_manager.active.decorator) 41 | } 42 | } 43 | 44 | impl DisplayTheme { 45 | pub fn get_focused_color(&self, position: f32, time: f32) -> Color { 46 | match &self.progress_complete { 47 | ProgressGradient::Static(c) => *c, 48 | ProgressGradient::Gradient(g) => { 49 | get_gradient_color(&g, position, time * self.progress_speed) 50 | } 51 | } 52 | } 53 | 54 | pub fn get_inactive_color(&self, position: f32, time: f32, amp: f32) -> Color { 55 | let brightness = match &self.progress_complete { 56 | ProgressGradient::Static(_) => 0.4, 57 | ProgressGradient::Gradient(g) if g.len() == 1 => 0.4, 58 | _ => 0.08 + (amp * 0.4), 59 | }; 60 | 61 | match &self.progress_incomplete { 62 | InactiveGradient::Static(c) => *c, 63 | InactiveGradient::Gradient(g) => { 64 | get_gradient_color(g, position, time * self.progress_speed) 65 | } 66 | InactiveGradient::Dimmed => { 67 | let now_color = self.get_focused_color(position, time); 68 | fade_color(self.dark, now_color, brightness) 69 | } 70 | InactiveGradient::Still => { 71 | let now_color = self.get_focused_color(position, 0.0); // 0 to prevent movement 72 | fade_color(self.dark, now_color, brightness) 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/database/tables.rs: -------------------------------------------------------------------------------- 1 | pub const CREATE_TABLES: &str = r" 2 | CREATE TABLE IF NOT EXISTS roots( 3 | id INTEGER PRIMARY KEY, 4 | path TEXT UNIQUE NOT NULL 5 | ); 6 | 7 | CREATE TABLE IF NOT EXISTS songs( 8 | id BLOB PRIMARY KEY, 9 | title TEXT NOT NULL, 10 | year INTEGER, 11 | path TEXT UNIQUE NOT NULL, 12 | artist_id INTEGER, 13 | album_id INTEGER, 14 | track_no INTEGER, 15 | disc_no INTEGER, 16 | duration REAL, 17 | channels INTEGER, 18 | bit_rate INTEGER, 19 | sample_rate INTEGER, 20 | format INTEGER, 21 | FOREIGN KEY(artist_id) REFERENCES artists(id), 22 | FOREIGN KEY(album_id) REFERENCES albums(id) 23 | ); 24 | 25 | CREATE TABLE IF NOT EXISTS artists( 26 | id INTEGER PRIMARY KEY, 27 | name TEXT UNIQUE NOT NULL 28 | ); 29 | 30 | CREATE TABLE IF NOT EXISTS albums( 31 | id INTEGER PRIMARY KEY, 32 | title TEXT NOT NULL, 33 | artist_id INTEGER, 34 | FOREIGN KEY(artist_id) REFERENCES artists(id), 35 | UNIQUE (title, artist_id) 36 | ); 37 | 38 | CREATE TABLE IF NOT EXISTS waveforms( 39 | song_id BLOB PRIMARY KEY, 40 | waveform BLOB, 41 | FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE 42 | ); 43 | 44 | CREATE TABLE IF NOT EXISTS history( 45 | id INTEGER PRIMARY KEY, 46 | song_id BLOB NOT NULL, 47 | timestamp INTEGER NOT NULL, 48 | FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE 49 | ); 50 | 51 | CREATE TABLE IF NOT EXISTS plays( 52 | song_id BLOB PRIMARY KEY, 53 | count INTEGER, 54 | FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE 55 | ); 56 | 57 | CREATE TABLE IF NOT EXISTS session_state( 58 | key TEXT PRIMARY KEY, 59 | value TEXT NOT NULL 60 | ); 61 | 62 | CREATE TABLE IF NOT EXISTS playlists( 63 | id INTEGER PRIMARY KEY, 64 | name TEXT UNIQUE NOT NULL, 65 | updated_at INTEGER NOT NULL 66 | ); 67 | 68 | CREATE TABLE IF NOT EXISTS playlist_songs( 69 | id INTEGER PRIMARY KEY, 70 | song_id BLOB NOT NULL, 71 | playlist_id INTEGER NOT NULL, 72 | position INTEGER NOT NULL, 73 | FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE, 74 | FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, 75 | UNIQUE(playlist_id, position) 76 | ); 77 | "; 78 | -------------------------------------------------------------------------------- /src/tui/widgets/progress/oscilloscope.rs: -------------------------------------------------------------------------------- 1 | use crate::ui_state::{DisplayTheme, UiState}; 2 | use ratatui::{ 3 | style::Stylize, 4 | widgets::{ 5 | Block, Padding, StatefulWidget, Widget, 6 | canvas::{Canvas, Context, Line}, 7 | }, 8 | }; 9 | 10 | pub struct Oscilloscope; 11 | 12 | impl StatefulWidget for Oscilloscope { 13 | type State = UiState; 14 | 15 | fn render( 16 | self, 17 | area: ratatui::prelude::Rect, 18 | buf: &mut ratatui::prelude::Buffer, 19 | state: &mut Self::State, 20 | ) { 21 | let theme = &state.theme_manager.get_display_theme(true); 22 | let samples = state.get_oscilloscope_data(); 23 | 24 | if samples.is_empty() { 25 | return; 26 | } 27 | 28 | let v_marg = match area.height > 20 { 29 | true => ((area.height as f32) * 0.25) as u16, 30 | false => 0, 31 | }; 32 | 33 | let elapsed = state.get_playback_elapsed().as_secs_f32(); 34 | 35 | Canvas::default() 36 | .x_bounds([0.0, samples.len() as f64]) 37 | .y_bounds([-1.0, 1.0]) 38 | .paint(|ctx| { 39 | draw_oscilloscope(ctx, &samples, elapsed, &theme); 40 | }) 41 | .background_color(theme.bg_global) 42 | .block(Block::new().bg(theme.bg_global).padding(Padding { 43 | left: 1, 44 | right: 1, 45 | top: v_marg, 46 | bottom: v_marg, 47 | })) 48 | .render(area, buf); 49 | } 50 | } 51 | 52 | fn draw_oscilloscope(ctx: &mut Context, samples: &[f32], time: f32, theme: &DisplayTheme) { 53 | let peak = samples 54 | .iter() 55 | .map(|s| s.abs()) 56 | .max_by(|a, b| a.partial_cmp(b).unwrap()) 57 | .unwrap_or(1.0); 58 | 59 | let scale = if peak > 1.0 { 1.0 / peak } else { 1.0 }; 60 | 61 | for (i, window) in samples.windows(2).enumerate() { 62 | let x1 = i as f64; 63 | let y1 = (window[0] * scale) as f64; 64 | let x2 = (i + 1) as f64; 65 | let y2 = (window[1] * scale) as f64; 66 | 67 | let progress = i as f32 / samples.len() as f32; 68 | 69 | let time = time / 4.0; // Slow down gradient scroll substantially 70 | let color = theme.get_focused_color(progress, time); 71 | 72 | ctx.draw(&Line { 73 | x1, 74 | y1, 75 | x2, 76 | y2, 77 | color, 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/tui/widgets/tracklist/search_results.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | domain::SongInfo, 3 | tui::widgets::tracklist::{CellFactory, create_standard_table}, 4 | ui_state::{MatchField, Pane, UiState, fade_color}, 5 | }; 6 | use ratatui::{ 7 | style::Stylize, 8 | text::Line, 9 | widgets::{StatefulWidget, *}, 10 | }; 11 | 12 | pub struct StandardTable; 13 | impl StatefulWidget for StandardTable { 14 | type State = UiState; 15 | fn render( 16 | self, 17 | area: ratatui::prelude::Rect, 18 | buf: &mut ratatui::prelude::Buffer, 19 | state: &mut Self::State, 20 | ) { 21 | let focus = matches!(state.get_pane(), Pane::TrackList | Pane::Search); 22 | let theme = &state.theme_manager.get_display_theme(focus); 23 | 24 | let songs = state.legal_songs.as_slice(); 25 | let song_len = songs.len(); 26 | let search_len = state.get_search_len(); 27 | 28 | let title = match state.get_mode() { 29 | _ => match search_len > 1 { 30 | true => format!(" Search Results: {} Songs ", song_len), 31 | false => format!(" Total: {} Songs ", song_len), 32 | }, 33 | }; 34 | 35 | let inactive = fade_color(theme.dark, theme.text_primary, 0.6); 36 | let rows = songs 37 | .iter() 38 | .map(|song| { 39 | let symbol = CellFactory::status_cell(song, state, true); 40 | let mut title_col = Cell::from(song.get_title()).fg(inactive); 41 | let mut artist_col = Cell::from(song.get_artist()).fg(inactive); 42 | let mut album_col = Cell::from(song.get_album()).fg(inactive); 43 | let dur_col = 44 | Cell::from(Line::from(song.get_duration_str()).right_aligned()).fg(inactive); 45 | 46 | if let Some(field) = state.get_match_fields(song.id) { 47 | match field { 48 | MatchField::Title => title_col = title_col.fg(theme.text_secondary), 49 | MatchField::Artist => artist_col = artist_col.fg(theme.text_secondary), 50 | MatchField::Album => album_col = album_col.fg(theme.text_secondary), 51 | } 52 | } 53 | 54 | Row::new([symbol, title_col, artist_col, album_col, dur_col]) 55 | }) 56 | .collect::>(); 57 | 58 | let table = create_standard_table(rows, title.into(), state, theme); 59 | 60 | StatefulWidget::render(table, area, buf, &mut state.display_state.table_pos); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ui_state/theme/theme_utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Ok, Result, anyhow, bail}; 2 | use ratatui::{ 3 | style::Color, 4 | widgets::{BorderType, Borders}, 5 | }; 6 | 7 | pub(super) fn parse_color(s: &str) -> Result { 8 | match s { 9 | s if s.starts_with('#') => parse_hex(s), 10 | s if s.starts_with("rgb(") => parse_rgb(s), 11 | _ => try_from_str(s.trim()), 12 | } 13 | } 14 | 15 | pub(super) fn parse_hex(s: &str) -> Result { 16 | let hex = s.trim_start_matches('#'); 17 | if hex.len() != 6 { 18 | bail!("Invalid hex input: {s}\nExpected format\"#FF20D5\""); 19 | } 20 | 21 | let r = u8::from_str_radix(&hex[0..2], 16)?; 22 | let g = u8::from_str_radix(&hex[2..4], 16)?; 23 | let b = u8::from_str_radix(&hex[4..], 16)?; 24 | 25 | Ok(Color::Rgb(r, g, b)) 26 | } 27 | 28 | pub(super) fn parse_rgb(s: &str) -> Result { 29 | if s.ends_with(')') { 30 | let inner = &s[4..s.len() - 1]; 31 | let parts = inner.split(',').collect::>(); 32 | if parts.len() == 3 { 33 | let r = parts[0].trim().parse::()?; 34 | let g = parts[1].trim().parse::()?; 35 | let b = parts[2].trim().parse::()?; 36 | return Ok(Color::Rgb(r, g, b)); 37 | } 38 | } 39 | Err(anyhow!( 40 | "Invalid rgb input: {s}\nExpected ex: \"rgb(255, 50, 120)\"" 41 | )) 42 | } 43 | 44 | pub(super) fn try_from_str(s: &str) -> Result { 45 | match s.to_lowercase().as_str() { 46 | "" | "none" => Ok(Color::default()), 47 | "black" => Ok(Color::Black), 48 | "red" => Ok(Color::Red), 49 | "green" => Ok(Color::Green), 50 | "yellow" => Ok(Color::Yellow), 51 | "blue" => Ok(Color::Blue), 52 | "magenta" => Ok(Color::Magenta), 53 | "cyan" => Ok(Color::Cyan), 54 | "white" => Ok(Color::White), 55 | "gray" | "grey" => Ok(Color::Gray), 56 | "darkgray" | "darkgrey" => Ok(Color::DarkGray), 57 | "lightred" => Ok(Color::LightRed), 58 | "lightgreen" => Ok(Color::LightGreen), 59 | "lightyellow" => Ok(Color::LightYellow), 60 | "lightblue" => Ok(Color::LightBlue), 61 | "lightmagenta" => Ok(Color::LightMagenta), 62 | "lightcyan" => Ok(Color::LightCyan), 63 | _ => Err(anyhow!("Invalid input: {}", s)), 64 | } 65 | } 66 | 67 | pub(super) fn parse_border_type(s: &str) -> BorderType { 68 | match s.trim().to_lowercase().as_str() { 69 | "plain" => BorderType::Plain, 70 | "double" => BorderType::Double, 71 | "thick" => BorderType::Thick, 72 | _ => BorderType::Rounded, 73 | } 74 | } 75 | 76 | pub(super) fn parse_borders(s: &str) -> Borders { 77 | match s.to_lowercase().trim() { 78 | "" | "none" => Borders::NONE, 79 | _ => Borders::ALL, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Concertus - v0.1.0 2 | 3 | Concertus is a lightweight, plug and play, TUI music player written in Rust. 4 | 5 | ![concertus.png](./docs/header.png) 6 | 7 | ## Usage 8 | 9 | To try Concertus, do the following: 10 | 11 | ```bash 12 | git clone https://github.com/Jaxx497/concertus/ 13 | cd concertus 14 | cargo run --release 15 | 16 | # to intall globally: 17 | cargo install --path . 18 | ``` 19 | 20 | Begin by assigning one or more root directories when promted. The root 21 | management window can be accessed by pressing the ``` ` ``` key at any time. 22 | Concertus will walk through the supplied folder(s), and create a virtual 23 | library based on any valid files it finds. 24 | 25 | It's recommended that users have ffmpeg installed for waveform visualization. 26 | This dependency however is not mandatory. 27 | 28 | Concertus aims to create an experience where no task is more than a keystroke 29 | or two away. Those familiar with vim-like bindings should pick up the 30 | keybindings quickly. Use `hjkl`/`d`/`u` for navigation, `n`/`p` for seeking, and `/` 31 | for searching. 32 | 33 | For the full list of keymaps, refer to the [keymaps 34 | documentation](./docs/keymaps.md). \ 35 | For information on custom themeing, refer to the [themeing 36 | specification](./docs/themes.md). 37 | 38 | Currently, concertus supports the following filetypes: ```mp3, m4a, flac, ogg, wav``` 39 | 40 | ## Disclaimers 41 | 42 | Concertus never writes to user files and does not have any online capabilities. 43 | The program does however rely on accurate tagging. It's strongly recommended 44 | that users ensure their libraries are properly tagged with a tool like 45 | [MP3Tag](https://www.mp3tag.de/en/). 46 | 47 | > **Tip:** Concertus supports hot reloading by pressing `Ctrl+u` or `F5` at any 48 | > point during runtime. 49 | 50 | ## Known bugs 51 | 52 | 1. Symphonia/Rodio Related* 53 | 1. There are no reliable Rodio compatible OPUS decoders. 54 | 1. Seeking can be potentially unstable. 55 | 1. Gapless playback is not viable for the time being. 56 | 57 | > **Note:** This project is heavily reliant on the Symphonia and Rodio crates. 58 | Many of the playback related issues are due to upstream issues in the 59 | aforementioned libraries. Following several QOL additons, I intend to explore 60 | new backend options. 61 | 62 | ## TODO 63 | 64 | - Display more song info in window (user controlled) 65 | - Improved testing for various formats 66 | - Implement a secondary backend (likely mpv) [Finally, OPUS support!!!] 67 | 68 | ## Other 69 | 70 | Concertus is a hobby project primary written for educational purposes. This 71 | project seeks to demonstrate my understanding of a series of programming 72 | fundamentals, including but not limited to multi-threading, atomics, string 73 | interning, database integration, de/serialization, memory management, integrity 74 | hashing, session persistence, OS operations, modular design, view models, 75 | state management, user customization, and much more. 76 | -------------------------------------------------------------------------------- /src/domain/simple_song.rs: -------------------------------------------------------------------------------- 1 | use super::{FileType, SongInfo}; 2 | use crate::{Database, domain::SongDatabase, get_readable_duration}; 3 | use anyhow::Result; 4 | use std::{sync::Arc, time::Duration}; 5 | 6 | #[derive(Default, Hash, Eq, PartialEq)] 7 | pub struct SimpleSong { 8 | pub(crate) id: u64, 9 | pub(crate) title: String, 10 | pub(crate) artist: Arc, 11 | pub(crate) year: Option, 12 | pub(crate) album: Arc, 13 | pub(crate) album_id: i64, 14 | pub(crate) album_artist: Arc, 15 | pub(crate) track_no: Option, 16 | pub(crate) disc_no: Option, 17 | pub(crate) duration: Duration, 18 | pub(crate) filetype: FileType, 19 | } 20 | 21 | /// DATABASE RELATED METHODS 22 | impl SongDatabase for SimpleSong { 23 | /// Returns the path of a song as a String 24 | fn get_path(&self) -> Result { 25 | let mut db = Database::open()?; 26 | db.get_song_path(self.id) 27 | } 28 | 29 | /// Update the play_count of the song 30 | fn update_play_count(&self) -> Result<()> { 31 | let mut db = Database::open()?; 32 | db.update_play_count(self.id) 33 | } 34 | 35 | /// Retrieve the waveform of a song 36 | /// returns Result> 37 | fn get_waveform(&self) -> Result> { 38 | let mut db = Database::open()?; 39 | db.get_waveform(self.id) 40 | } 41 | 42 | /// Store the waveform of a song in the databse 43 | fn set_waveform_db(&self, wf: &[f32]) -> Result<()> { 44 | let mut db = Database::open()?; 45 | db.set_waveform(self.id, wf) 46 | } 47 | } 48 | 49 | /// Generic getter methods 50 | impl SongInfo for SimpleSong { 51 | fn get_id(&self) -> u64 { 52 | self.id 53 | } 54 | 55 | fn get_title(&self) -> &str { 56 | &self.title 57 | } 58 | 59 | fn get_artist(&self) -> &str { 60 | &self.artist 61 | } 62 | 63 | fn get_album(&self) -> &str { 64 | &self.album 65 | } 66 | 67 | fn get_duration(&self) -> Duration { 68 | self.duration 69 | } 70 | 71 | fn get_duration_f32(&self) -> f32 { 72 | self.duration.as_secs_f32() 73 | } 74 | 75 | fn get_duration_str(&self) -> String { 76 | get_readable_duration(self.duration, crate::DurationStyle::Compact) 77 | } 78 | } 79 | 80 | impl SongInfo for Arc { 81 | fn get_id(&self) -> u64 { 82 | self.as_ref().get_id() 83 | } 84 | 85 | fn get_title(&self) -> &str { 86 | self.as_ref().get_title() 87 | } 88 | 89 | fn get_artist(&self) -> &str { 90 | self.as_ref().get_artist() 91 | } 92 | 93 | fn get_album(&self) -> &str { 94 | self.as_ref().get_album() 95 | } 96 | 97 | fn get_duration(&self) -> Duration { 98 | self.as_ref().get_duration() 99 | } 100 | 101 | fn get_duration_f32(&self) -> f32 { 102 | self.as_ref().get_duration_f32() 103 | } 104 | 105 | fn get_duration_str(&self) -> String { 106 | self.as_ref().get_duration_str() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/tui/widgets/tracklist/album_tracklist.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | domain::SongInfo, 3 | truncate_at_last_space, 4 | tui::widgets::tracklist::{CellFactory, create_empty_block, create_standard_table}, 5 | ui_state::{Pane, UiState}, 6 | }; 7 | use ratatui::{ 8 | style::Stylize, 9 | text::{Line, Span}, 10 | widgets::{Row, StatefulWidget, Widget}, 11 | }; 12 | 13 | pub struct AlbumView; 14 | impl StatefulWidget for AlbumView { 15 | type State = UiState; 16 | fn render( 17 | self, 18 | area: ratatui::prelude::Rect, 19 | buf: &mut ratatui::prelude::Buffer, 20 | state: &mut Self::State, 21 | ) { 22 | let focus = matches!(state.get_pane(), Pane::TrackList); 23 | let theme = &state.theme_manager.get_display_theme(focus); 24 | 25 | if state.albums.is_empty() { 26 | create_empty_block(theme, "0 Songs").render(area, buf); 27 | return; 28 | } 29 | 30 | let album = state.get_selected_album().unwrap_or(&state.albums[0]); 31 | let album_title = truncate_at_last_space(&album.title, (area.width / 3) as usize); 32 | 33 | let rows = album 34 | .tracklist 35 | .iter() 36 | .enumerate() 37 | .map(|(idx, song)| { 38 | let is_m_selected = state.get_multi_select_indices().contains(&idx); 39 | 40 | let track_no = CellFactory::get_track_discs(theme, song, is_m_selected); 41 | let icon = CellFactory::status_cell(song, state, is_m_selected); 42 | let title = CellFactory::title_cell(theme, song.get_title(), is_m_selected); 43 | let artist = CellFactory::artist_cell(theme, song, is_m_selected); 44 | let format = CellFactory::filetype_cell(theme, song, is_m_selected); 45 | let duration = CellFactory::duration_cell(theme, song, is_m_selected); 46 | 47 | match is_m_selected { 48 | true => Row::new([track_no, icon, title.into(), artist, format, duration]) 49 | .bg(state.theme_manager.active.selection_inactive), 50 | false => Row::new([track_no, icon, title.into(), artist, format, duration]), 51 | } 52 | }) 53 | .collect::>(); 54 | 55 | let decorator = &state.get_decorator(); 56 | 57 | let year_str = album 58 | .year 59 | .filter(|y| *y != 0) 60 | .map_or(String::new(), |y| format!("[{y}]")); 61 | 62 | let title = Line::from_iter([ 63 | Span::from(format!(" {} ", album_title)) 64 | .fg(theme.text_secondary) 65 | .italic(), 66 | Span::from(year_str).fg(theme.text_muted), 67 | Span::from(format!(" {decorator} ")).fg(theme.text_muted), 68 | Span::from(album.artist.to_string()).fg(theme.accent), 69 | Span::from(format!(" [{} Songs] ", album.tracklist.len())).fg(theme.text_muted), 70 | ]); 71 | 72 | let table = create_standard_table(rows, title, state, theme); 73 | StatefulWidget::render(table, area, buf, &mut state.display_state.table_pos); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ui_state/popup.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{crossterm::event::KeyEvent, widgets::ListState}; 2 | use tui_textarea::TextArea; 3 | 4 | use crate::{ 5 | get_random_playlist_idea, 6 | ui_state::{Pane, SettingsMode, UiState, new_textarea, playlist::PlaylistAction}, 7 | }; 8 | 9 | #[derive(PartialEq, Clone)] 10 | pub enum PopupType { 11 | None, 12 | Error(String), 13 | Settings(SettingsMode), 14 | Playlist(PlaylistAction), 15 | ThemeManager, 16 | } 17 | 18 | pub struct PopupState { 19 | pub current: PopupType, 20 | pub input: TextArea<'static>, 21 | pub selection: ListState, 22 | pub cached: Pane, 23 | } 24 | 25 | impl PopupState { 26 | pub(crate) fn new() -> PopupState { 27 | PopupState { 28 | current: PopupType::None, 29 | input: new_textarea(""), 30 | selection: ListState::default(), 31 | cached: Pane::Popup, 32 | } 33 | } 34 | 35 | fn open(&mut self, popup: PopupType) { 36 | match &popup { 37 | PopupType::Playlist(PlaylistAction::Rename) 38 | | PopupType::Playlist(PlaylistAction::Create) 39 | | PopupType::Playlist(PlaylistAction::CreateWithSongs) => { 40 | let placeholder = get_random_playlist_idea(); 41 | 42 | self.input.set_placeholder_text(format!(" {placeholder} ")); 43 | self.input.select_all(); 44 | self.input.cut(); 45 | } 46 | PopupType::Settings(SettingsMode::ViewRoots) => { 47 | self.input.select_all(); 48 | self.input.cut(); 49 | } 50 | PopupType::Settings(SettingsMode::AddRoot) => { 51 | self.input 52 | .set_placeholder_text(" Enter path to directory: "); 53 | self.input.select_all(); 54 | self.input.cut(); 55 | } 56 | 57 | _ => (), 58 | } 59 | self.current = popup 60 | } 61 | 62 | pub fn is_open(&self) -> bool { 63 | self.current != PopupType::None 64 | } 65 | 66 | fn close(&mut self) -> Pane { 67 | self.current = PopupType::None; 68 | self.input.select_all(); 69 | self.input.cut(); 70 | 71 | self.cached.clone() 72 | } 73 | 74 | fn set_cached_pane(&mut self, pane: Pane) { 75 | self.cached = pane 76 | } 77 | } 78 | 79 | impl UiState { 80 | pub fn show_popup(&mut self, popup: PopupType) { 81 | self.popup.open(popup); 82 | if self.popup.cached == Pane::Popup { 83 | let current_pane = self.get_pane().clone(); 84 | self.popup.set_cached_pane(current_pane); 85 | self.set_pane(Pane::Popup); 86 | } 87 | } 88 | 89 | pub fn get_popup_string(&self) -> String { 90 | self.popup.input.lines()[0].trim().to_string() 91 | } 92 | 93 | pub fn close_popup(&mut self) { 94 | let pane = self.popup.close(); 95 | self.popup.cached = Pane::Popup; 96 | self.set_pane(pane); 97 | } 98 | 99 | pub fn process_popup_input(&mut self, key: &KeyEvent) { 100 | self.popup.input.input(*key); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/tui/widgets/progress/waveform.rs: -------------------------------------------------------------------------------- 1 | use crate::{domain::SongInfo, tui::widgets::WAVEFORM_WIDGET_HEIGHT, ui_state::UiState}; 2 | use ratatui::{ 3 | style::{Color, Stylize}, 4 | widgets::{ 5 | Block, Padding, StatefulWidget, Widget, 6 | canvas::{Canvas, Context, Line, Rectangle}, 7 | }, 8 | }; 9 | 10 | pub struct Waveform; 11 | impl StatefulWidget for Waveform { 12 | type State = UiState; 13 | 14 | fn render( 15 | self, 16 | area: ratatui::prelude::Rect, 17 | buf: &mut ratatui::prelude::Buffer, 18 | state: &mut Self::State, 19 | ) { 20 | let theme = state.theme_manager.get_display_theme(true); 21 | 22 | let padding_vertical = match area.height { 23 | 0..=6 => 0, 24 | 7..=20 => (area.height as f32 * 0.15) as u16 - 1, 25 | 21..=40 => (area.height as f32 * 0.25) as u16, 26 | _ => (area.height as f32 * 0.35) as u16, 27 | }; 28 | 29 | let np = state 30 | .get_now_playing() 31 | .expect("Expected a song to be playing. [Widget: Waveform]"); 32 | 33 | let waveform = state.get_waveform_visual().to_vec(); 34 | let wf_len = waveform.len(); 35 | let duration_f32 = &np.get_duration_f32(); 36 | 37 | Canvas::default() 38 | .x_bounds([0.0, wf_len as f64]) 39 | .y_bounds([WAVEFORM_WIDGET_HEIGHT * -1.0, WAVEFORM_WIDGET_HEIGHT]) 40 | .paint(|ctx| { 41 | let elapsed = state.get_playback_elapsed().as_secs_f32(); 42 | let progress = elapsed / duration_f32; 43 | 44 | for (idx, amp) in waveform.iter().enumerate() { 45 | let hgt = (*amp as f64 * WAVEFORM_WIDGET_HEIGHT).round(); 46 | let position = idx as f32 / wf_len as f32; 47 | 48 | let color = match position < progress { 49 | true => theme.get_focused_color(position, elapsed), 50 | false => theme.get_inactive_color(position, elapsed, *amp), 51 | }; 52 | 53 | match area.width < 170 { 54 | true => draw_waveform_line(ctx, idx as f64, hgt, color), 55 | false => draw_waveform_rect(ctx, idx as f64, hgt, color), 56 | } 57 | } 58 | }) 59 | .background_color(theme.bg_global) 60 | .block(Block::new().bg(theme.bg_global).padding(Padding { 61 | left: 10, 62 | right: 10, 63 | top: padding_vertical + 1, 64 | bottom: padding_vertical, 65 | })) 66 | .render(area, buf) 67 | } 68 | } 69 | 70 | /// Lines create a more detailed and cleaner look 71 | /// especially when seen in smaller windows 72 | fn draw_waveform_line(ctx: &mut Context, idx: f64, hgt: f64, color: Color) { 73 | ctx.draw(&Line { 74 | x1: idx, 75 | x2: idx, 76 | y1: hgt, 77 | y2: hgt * -1.0, 78 | color, 79 | }) 80 | } 81 | 82 | /// Rectangles cleanly extend the waveform when in 83 | /// full-screen view 84 | fn draw_waveform_rect(ctx: &mut Context, idx: f64, hgt: f64, color: Color) { 85 | ctx.draw(&Rectangle { 86 | x: idx, 87 | y: hgt * -1.0, 88 | width: 0.5, // This makes the waveform cleaner on resize 89 | height: hgt * 2.0, // Rectangles are drawn from the bottom 90 | color, 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /src/ui_state/theme/theme_import.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | use serde::{Deserialize, Deserializer}; 3 | use std::str::FromStr; 4 | 5 | #[derive(Deserialize)] 6 | pub struct ThemeImport { 7 | pub colors: ColorScheme, 8 | pub borders: BorderScheme, 9 | #[serde(default = "default_extras")] 10 | pub extras: ExtraScheme, 11 | } 12 | 13 | #[derive(Deserialize)] 14 | pub struct ColorScheme { 15 | pub surface_global: ThemeColor, 16 | pub surface_active: ThemeColor, 17 | pub surface_inactive: ThemeColor, 18 | pub surface_error: ThemeColor, 19 | 20 | // Text colors 21 | pub text_primary: ThemeColor, 22 | pub text_secondary: ThemeColor, 23 | pub text_secondary_in: ThemeColor, 24 | pub text_selection: ThemeColor, 25 | pub text_muted: ThemeColor, 26 | 27 | // Border colors 28 | pub border_active: ThemeColor, 29 | pub border_inactive: ThemeColor, 30 | 31 | // Accent 32 | pub accent: ThemeColor, 33 | pub accent_inactive: ThemeColor, 34 | 35 | // Selection colors 36 | pub selection: ThemeColor, 37 | pub selection_inactive: ThemeColor, 38 | 39 | pub progress: ProgressGradientRaw, 40 | 41 | #[serde(default = "default_inactive")] 42 | pub progress_i: ProgressGradientRaw, 43 | 44 | #[serde(default = "default_speed")] 45 | pub progress_speed: f32, 46 | } 47 | 48 | #[derive(Deserialize)] 49 | pub struct BorderScheme { 50 | pub border_display: String, 51 | // pub border_display: Borders, 52 | pub border_type: String, 53 | } 54 | 55 | #[derive(Deserialize)] 56 | pub struct ExtraScheme { 57 | #[serde(default = "default_dark")] 58 | pub is_dark: bool, 59 | #[serde(default = "default_decorator")] 60 | pub decorator: String, 61 | } 62 | 63 | #[derive(Deserialize)] 64 | #[serde(untagged)] 65 | pub enum ProgressGradientRaw { 66 | Single(String), 67 | Gradient(Vec), 68 | } 69 | 70 | #[derive(Clone, Copy, Debug)] 71 | pub struct ThemeColor(pub Color); 72 | 73 | impl<'de> Deserialize<'de> for ThemeColor { 74 | fn deserialize(deserializer: D) -> Result 75 | where 76 | D: Deserializer<'de>, 77 | { 78 | let s = String::deserialize(deserializer)?; 79 | 80 | // Handle transparent 81 | match s.to_lowercase().as_str() { 82 | "" | "none" => return Ok(ThemeColor(Color::Reset)), 83 | _ => {} 84 | } 85 | 86 | Color::from_str(&s) 87 | .map(ThemeColor) 88 | .map_err(serde::de::Error::custom) 89 | } 90 | } 91 | 92 | impl std::ops::Deref for ThemeColor { 93 | type Target = Color; 94 | fn deref(&self) -> &Self::Target { 95 | &self.0 96 | } 97 | } 98 | 99 | impl From for Color { 100 | fn from(tc: ThemeColor) -> Self { 101 | tc.0 102 | } 103 | } 104 | 105 | fn default_inactive() -> ProgressGradientRaw { 106 | ProgressGradientRaw::Single("dimmed".to_string()) 107 | } 108 | 109 | const DEFAULT_SPEED: f32 = 6.0; 110 | fn default_speed() -> f32 { 111 | DEFAULT_SPEED 112 | } 113 | 114 | const DECORATOR: &str = "✧"; 115 | fn default_decorator() -> String { 116 | DECORATOR.to_string() 117 | } 118 | 119 | fn default_dark() -> bool { 120 | true 121 | } 122 | 123 | fn default_extras() -> ExtraScheme { 124 | ExtraScheme { 125 | is_dark: default_dark(), 126 | decorator: default_decorator(), 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/key_handler/mod.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | 3 | use std::cell::RefCell; 4 | use std::collections::HashSet; 5 | use std::sync::LazyLock; 6 | use std::time::Duration; 7 | use std::time::Instant; 8 | 9 | pub use action::handle_key_event; 10 | pub use action::next_event; 11 | use ratatui::crossterm::event::KeyEvent; 12 | use ratatui::crossterm::event::KeyModifiers; 13 | 14 | use crate::ui_state::Mode; 15 | use crate::ui_state::Pane; 16 | use crate::ui_state::PopupType; 17 | use crate::ui_state::ProgressDisplay; 18 | 19 | static ILLEGAL_CHARS: LazyLock> = LazyLock::new(|| HashSet::from([';'])); 20 | 21 | const X: KeyModifiers = KeyModifiers::NONE; 22 | const S: KeyModifiers = KeyModifiers::SHIFT; 23 | const C: KeyModifiers = KeyModifiers::CONTROL; 24 | 25 | const SEEK_SMALL: usize = 5; 26 | const SEEK_LARGE: usize = 30; 27 | const SCROLL_MID: usize = 5; 28 | const SCROLL_XTRA: usize = 20; 29 | const SIDEBAR_INCREMENT: isize = 1; 30 | 31 | #[derive(PartialEq, Eq)] 32 | pub enum Action { 33 | // Player Controls 34 | Play, 35 | Stop, 36 | TogglePause, 37 | PlayNext, 38 | PlayPrev, 39 | SeekForward(usize), 40 | SeekBack(usize), 41 | 42 | // Queue & Playlist Actions 43 | QueueSong, 44 | QueueEntity, 45 | ShuffleEntity, 46 | RemoveSong, 47 | 48 | AddToPlaylist, 49 | AddToPlaylistConfirm, 50 | 51 | CreatePlaylistWithSongs, 52 | CreatePlaylistWithSongsConfirm, 53 | 54 | // Updating App State 55 | UpdateLibrary, 56 | SendSearch, 57 | UpdateSearch(KeyEvent), 58 | SortColumnsNext, 59 | SortColumnsPrev, 60 | ToggleAlbumSort(bool), 61 | ChangeMode(Mode), 62 | ChangePane(Pane), 63 | GoToAlbum, 64 | Scroll(Director), 65 | 66 | MultiSelect, 67 | MultiSelectAll, 68 | ClearMultiSelect, 69 | 70 | // Playlists 71 | CreatePlaylist, 72 | CreatePlaylistConfirm, 73 | 74 | DeletePlaylist, 75 | DeletePlaylistConfirm, 76 | 77 | RenamePlaylist, 78 | RenamePlaylistConfirm, 79 | 80 | ShiftPosition(MoveDirection), 81 | ShuffleElements, 82 | 83 | // Display 84 | CycleTheme(MoveDirection), 85 | ThemeManager, 86 | ThemeRefresh, 87 | 88 | IncrementWFSmoothness(MoveDirection), 89 | IncrementSidebarSize(isize), 90 | 91 | SetProgressDisplay(ProgressDisplay), 92 | ToggleProgressDisplay, 93 | SetFullscreen(ProgressDisplay), 94 | RevertFullscreen, 95 | 96 | PopupScrollUp, 97 | PopupScrollDown, 98 | PopupInput(KeyEvent), 99 | 100 | ClosePopup, 101 | 102 | // Errors, Convenience & Other 103 | ViewSettings, 104 | RootAdd, 105 | RootRemove, 106 | RootConfirm, 107 | 108 | HandleErrors, 109 | SoftReset, 110 | QUIT, 111 | } 112 | 113 | pub enum InputContext { 114 | AlbumView, 115 | PlaylistView, 116 | TrackList(Mode), 117 | Fullscreen, 118 | Search, 119 | Queue, 120 | Popup(PopupType), 121 | } 122 | 123 | #[derive(PartialEq, Eq)] 124 | pub enum Director { 125 | Up(usize), 126 | Down(usize), 127 | Top, 128 | Bottom, 129 | } 130 | 131 | #[derive(PartialEq, Eq)] 132 | pub enum MoveDirection { 133 | Up, 134 | Down, 135 | } 136 | 137 | thread_local! { 138 | static LAST_KEY_TIME: RefCell> = RefCell::new(None); 139 | } 140 | 141 | const PASTE_THRESHOLD: Duration = Duration::from_millis(10); 142 | 143 | pub fn is_likely_paste() -> bool { 144 | LAST_KEY_TIME.with(|last_time| { 145 | let mut last = last_time.borrow_mut(); 146 | 147 | let is_paste = match *last { 148 | Some(prev) => prev.elapsed() < PASTE_THRESHOLD, 149 | None => false, 150 | }; 151 | *last = Some(Instant::now()); 152 | is_paste 153 | }) 154 | } 155 | -------------------------------------------------------------------------------- /src/ui_state/settings/root_mgmt.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | Library, 3 | app_core::Concertus, 4 | ui_state::{PopupType, SettingsMode, UiState}, 5 | }; 6 | use anyhow::{Result, bail}; 7 | use std::sync::Arc; 8 | 9 | impl UiState { 10 | pub fn get_settings_mode(&self) -> Option<&SettingsMode> { 11 | match &self.popup.current { 12 | PopupType::Settings(mode) => Some(mode), 13 | _ => None, 14 | } 15 | } 16 | 17 | pub fn get_roots(&self) -> Vec { 18 | let mut roots: Vec = self 19 | .library 20 | .roots 21 | .iter() 22 | .map(|p| p.display().to_string()) 23 | .collect(); 24 | roots.sort(); 25 | roots 26 | } 27 | 28 | pub fn add_root(&mut self, path: &str) -> Result<()> { 29 | let mut lib = Library::init(); 30 | lib.add_root(path)?; 31 | self.library = Arc::new(lib); 32 | 33 | Ok(()) 34 | } 35 | 36 | pub fn remove_root(&mut self) -> Result<()> { 37 | if let Some(selected) = self.popup.selection.selected() { 38 | let roots = self.get_roots(); 39 | if selected >= roots.len() { 40 | bail!("Invalid root index!"); 41 | } 42 | 43 | let mut lib = Library::init(); 44 | 45 | let bad_root = &roots[selected]; 46 | lib.delete_root(&bad_root)?; 47 | 48 | self.library = Arc::new(lib); 49 | } 50 | 51 | Ok(()) 52 | } 53 | 54 | pub fn enter_settings(&mut self) { 55 | if !self.get_roots().is_empty() { 56 | self.popup.selection.select(Some(0)); 57 | } 58 | 59 | self.show_popup(PopupType::Settings(SettingsMode::ViewRoots)); 60 | } 61 | } 62 | 63 | impl Concertus { 64 | pub(crate) fn settings_remove_root(&mut self) { 65 | if !self.ui.get_roots().is_empty() { 66 | self.ui 67 | .show_popup(PopupType::Settings(SettingsMode::RemoveRoot)); 68 | } 69 | } 70 | 71 | pub(crate) fn activate_settings(&mut self) { 72 | match self.ui.get_roots().is_empty() { 73 | true => self.ui.popup.selection.select(None), 74 | false => self.ui.popup.selection.select(Some(0)), 75 | } 76 | self.ui 77 | .show_popup(PopupType::Settings(SettingsMode::ViewRoots)) 78 | } 79 | 80 | pub(crate) fn settings_add_root(&mut self) { 81 | self.ui 82 | .show_popup(PopupType::Settings(SettingsMode::AddRoot)); 83 | } 84 | 85 | pub(crate) fn settings_root_confirm(&mut self) -> anyhow::Result<()> { 86 | match self.ui.popup.current { 87 | PopupType::Settings(SettingsMode::AddRoot) => { 88 | let path = self.ui.get_popup_string(); 89 | if !path.is_empty() { 90 | match self.ui.add_root(&path) { 91 | Err(e) => self.ui.set_error(e), 92 | Ok(_) => { 93 | self.update_library()?; 94 | self.ui.close_popup(); 95 | } 96 | } 97 | } 98 | } 99 | PopupType::Settings(SettingsMode::RemoveRoot) => { 100 | if let Err(e) = self.ui.remove_root() { 101 | self.ui.set_error(e); 102 | } else { 103 | self.ui 104 | .show_popup(PopupType::Settings(SettingsMode::ViewRoots)); 105 | self.ui.popup.selection.select(Some(0)); 106 | self.update_library()?; 107 | self.ui.close_popup(); 108 | } 109 | } 110 | _ => {} 111 | } 112 | Ok(()) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/player/controller.rs: -------------------------------------------------------------------------------- 1 | use super::{PlaybackState, Player, PlayerCommand, PlayerState}; 2 | use crate::{ 3 | domain::{QueueSong, SimpleSong}, 4 | REFRESH_RATE, 5 | }; 6 | use anyhow::Result; 7 | use std::{ 8 | sync::{ 9 | mpsc::{self, Sender}, 10 | Arc, Mutex, 11 | }, 12 | thread::{self, JoinHandle}, 13 | time::Duration, 14 | }; 15 | 16 | pub struct PlayerController { 17 | sender: Sender, 18 | shared_state: Arc>, 19 | _thread_handle: JoinHandle<()>, 20 | } 21 | 22 | impl PlayerController { 23 | pub fn new() -> Self { 24 | let (sender, reciever) = mpsc::channel(); 25 | let shared_state = Arc::new(Mutex::new(PlayerState::default())); 26 | let shared_state_clone = Arc::clone(&shared_state); 27 | 28 | let thread_handle = thread::spawn(move || { 29 | let mut player = Player::new(shared_state_clone); 30 | 31 | loop { 32 | if let Ok(message) = reciever.try_recv() { 33 | match message { 34 | PlayerCommand::Play(song) => { 35 | if let Err(e) = player.play_song(&song) { 36 | let mut state = player.shared_state.lock().unwrap(); 37 | 38 | state.player_error = Some(e) 39 | } 40 | } 41 | PlayerCommand::TogglePlayback => player.toggle_playback(), 42 | PlayerCommand::SeekForward(secs) => { 43 | player 44 | .seek_forward(secs) 45 | .unwrap_or_else(|e| eprintln!("Error: {e}")); 46 | } 47 | PlayerCommand::SeekBack(secs) => player.seek_back(secs), 48 | PlayerCommand::Stop => player.stop(), 49 | }; 50 | } 51 | 52 | match player.sink_is_empty() { 53 | true => player.stop(), 54 | false => player.update_elapsed(), 55 | } 56 | // Lessen cpu intensity, but avoid stutters between songs 57 | thread::sleep(REFRESH_RATE) 58 | } 59 | }); 60 | 61 | PlayerController { 62 | sender, 63 | shared_state, 64 | _thread_handle: thread_handle, 65 | } 66 | } 67 | 68 | pub fn play_song(&self, song: Arc) -> Result<()> { 69 | self.sender.send(PlayerCommand::Play(song))?; 70 | Ok(()) 71 | } 72 | 73 | pub fn toggle_playback(&self) -> Result<()> { 74 | self.sender.send(PlayerCommand::TogglePlayback)?; 75 | Ok(()) 76 | } 77 | 78 | pub fn stop(&self) -> Result<()> { 79 | self.sender.send(PlayerCommand::Stop)?; 80 | Ok(()) 81 | } 82 | 83 | pub fn seek_forward(&self, s: usize) -> Result<()> { 84 | self.sender.send(PlayerCommand::SeekForward(s))?; 85 | Ok(()) 86 | } 87 | 88 | pub fn seek_back(&self, s: usize) -> Result<()> { 89 | self.sender.send(PlayerCommand::SeekBack(s))?; 90 | Ok(()) 91 | } 92 | 93 | pub fn get_now_playing(&self) -> Option> { 94 | let state = self.shared_state.lock().unwrap(); 95 | state.now_playing.clone() 96 | } 97 | 98 | /// Get the elapsed time of a song 99 | pub fn get_elapsed(&self) -> Duration { 100 | let state = self.shared_state.lock().unwrap(); 101 | state.elapsed 102 | } 103 | 104 | pub fn is_paused(&self) -> bool { 105 | let state = self.shared_state.lock().unwrap(); 106 | state.state == PlaybackState::Paused 107 | } 108 | 109 | pub fn sink_is_empty(&self) -> bool { 110 | let state = self.shared_state.lock().unwrap(); 111 | state.now_playing.is_none() || state.state == PlaybackState::Stopped 112 | } 113 | 114 | pub fn get_shared_state(&self) -> Arc> { 115 | Arc::clone(&self.shared_state) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /docs/keymaps.md: -------------------------------------------------------------------------------- 1 | Because Concertus is a modal program, keymaps depend on the specific context in 2 | which they are used. Contexts are defined by a combination of the mode (e.g. 3 | Playlist, Queue, Album, Search) and the Pane (e.g. Main pane, sidebar, popup, 4 | etc.). Global keymaps and playback keymaps will work in almost every context, 5 | with the exception of searching as not to affect a user's search query. 6 | 7 | **Keymaps are case sensitive.** 8 | 9 | ## Global Keymaps 10 | 11 | ##### Navigation 12 | | Action | Keymap | 13 | | ----------- | ----------- | 14 | | Select / Confirm | `Enter`| 15 | | Scroll Up 1 Item | `k` `↑` | 16 | | Scroll Down 1 Item | `j` `↓` | 17 | | Scroll Down (5 / 25 Items) | `d` `D`| 18 | | Scroll Up (5 / 25 Items) | `u` `U`| 19 | | Go to Top / Bottom | `g` `G` | 20 | 21 | ##### Views 22 | | Action | Keymap | 23 | | ----------- | ----------- | 24 | | Album View | `1` \| `Ctrl` + `a`| 25 | | Playlist View| `2` \| `Ctrl` + `t`| 26 | | Queue View | `3` \| `Ctrl` + `q`| 27 | | Change Sidebar Size | `[` `]` | 28 | | Smooth Waveform | `{` `}` | 29 | | Fullscreen Progress View | `f` | 30 | | Oscilloscope View | `o` `O` | 31 | | Waveform View | `w` `W` | 32 | | ProgressBar View | `b` `B` | 33 | 34 | ##### General 35 | | Action | Keymap | 36 | | ----------- | ----------- | 37 | | Search | `\` 38 | | Open Settings | ``` ` ``` | 39 | | Clear Popup / Exit Search | `Esc` | 40 | | Update Library | `F5` \| `Ctrl` + `u` | 41 | | Hot Reload Current Theme | `F6` | 42 | | Open Theme Manager | `C`| 43 | | Cycle Themes | `<` `>`| 44 | | Quit | `Ctrl` + `c`| 45 | 46 | > **Note:** The update logic is currently handled in the main thread meaning 47 | > the UI will hang until the update is complete. This will be addressed in 48 | > future versions. 49 | 50 | ## Playback Keymaps 51 | These keymaps will work in most contexts. 52 | 53 | | Action | Keymap | 54 | | ----------- | ----------- | 55 | | Toggle Pause | `Space` | 56 | | Seek Forward (5s / 30s)| `n` `N` | 57 | | Seek Back (5s / 30s)| `p` `P` | 58 | | Play Next in Queue | `Ctrl` + `n`| 59 | | Play Prev in History | `Ctrl` + `p`| 60 | | Stop & Clear Queue | `Ctrl` + `s`| 61 | 62 | > **Tip:** To toggle pause while searching or in a popup, use `Ctrl` + `Space` 63 | 64 | ## Main Pane Keymaps 65 | The main pane is defined as the larger pane on the right where individual songs 66 | are displayed. 67 | 68 | | Action | Keymap | 69 | | ----------- | ----------- | 70 | | Play Song | `Enter` | 71 | | Queue Song | `q` | 72 | | Add to Playlist | `a` | 73 | | Go to Album | `Ctrl` + `a` | 74 | | Go back to Sidebar | `h` `←`| 75 | > **Add to Playlist Shortcut:** Press `aa` on a song (or selection) to add it to the 76 | > most recently modified playlist, bypassing the popup. 77 | 78 | ##### Multi-Selection 79 | 80 | | Action | Keymap | 81 | | ----------- | ----------- | 82 | | Toggle Multi-Selection | `v` | 83 | | Toggle Multi-Selection on all Relevant Items | `V` | 84 | | Clear Multi-Selection | `Ctrl` + `v` | 85 | 86 | > **Multi-selection** enables users to select multiple songs to add, queue, or 87 | > remove from a playlist or the queue. Selection order is preserved. 88 | 89 | ##### Playlist/Queue Specific 90 | 91 | | Action | Keymap | 92 | | ----------- | ----------- | 93 | | Remove Song | `x` | 94 | | Shift Song/Selection Down | `J` | 95 | | Shift Song/Selection Position Up | `K` | 96 | | Shuffle Queue (Queue Mode Only) | `s` | 97 | 98 | 99 | ## Sidebar (Album) Keymaps 100 | These keymaps apply when the album/playlist sidebar is focused. 101 | 102 | | Action | Keymap | 103 | | ----------- | ----------- | 104 | | Queue Full Entity | `q` | 105 | | Switch to Main Pane | `l` `→`
`Enter` | 106 | 107 | 108 | ##### Playlist-View Specific 109 | 110 | | Action | Keymap | 111 | | ----------- | ----------- | 112 | | Create New Playlist | `c` | 113 | | Rename Playlist | `r` | 114 | | Delete Playlist | `D` | 115 | 116 | 117 | ##### Album-View Specific 118 | 119 | | Action | Keymap | 120 | | ----------- | ----------- | 121 | | Toggle Album Sorting Key
`Artist` `Album Title` `Year` | `Ctrl` + `h`
`Ctrl` + `l` | 122 | 123 | > **Note:** Add an entire album or playlist to the queue by pressing `q` 124 | > directly from the sidebar pane. If nothing is playing, then the first element 125 | > of the selected entity will begin playing automatically. 126 | -------------------------------------------------------------------------------- /src/ui_state/playback/progress_view.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | domain::smooth_waveform, 3 | key_handler::MoveDirection, 4 | player::PlaybackState, 5 | ui_state::{ProgressDisplay, UiState}, 6 | }; 7 | use anyhow::anyhow; 8 | 9 | pub struct PlaybackView { 10 | pub waveform_raw: Vec, 11 | pub waveform_smooth: Vec, 12 | pub waveform_smoothing: f32, 13 | waveform_valid: bool, 14 | progress_display: ProgressDisplay, 15 | } 16 | 17 | impl PlaybackView { 18 | pub fn new() -> Self { 19 | PlaybackView { 20 | waveform_raw: Vec::new(), 21 | waveform_smooth: Vec::new(), 22 | waveform_smoothing: 1.0, 23 | waveform_valid: true, 24 | progress_display: ProgressDisplay::Oscilloscope, 25 | } 26 | } 27 | } 28 | 29 | impl UiState { 30 | pub fn get_waveform_visual(&self) -> &[f32] { 31 | self.playback_view.waveform_smooth.as_slice() 32 | } 33 | 34 | pub fn set_waveform_visual(&mut self, wf: Vec) { 35 | self.playback_view.waveform_raw = wf; 36 | self.playback_view.smooth_waveform(); 37 | } 38 | 39 | pub fn clear_waveform(&mut self) { 40 | self.playback_view.waveform_raw.clear(); 41 | self.playback_view.waveform_smooth.clear(); 42 | } 43 | 44 | pub fn display_progress(&self) -> bool { 45 | let state = self.playback.player_state.lock().unwrap(); 46 | state.state != PlaybackState::Stopped || !self.queue_is_empty() 47 | } 48 | 49 | pub fn set_waveform_valid(&mut self) { 50 | self.playback_view.waveform_valid = true 51 | } 52 | 53 | pub fn set_waveform_invalid(&mut self) { 54 | self.playback_view.waveform_valid = false; 55 | self.clear_waveform(); 56 | } 57 | 58 | pub fn waveform_is_valid(&self) -> bool { 59 | self.playback_view.waveform_valid 60 | } 61 | 62 | pub fn get_progress_display(&self) -> &ProgressDisplay { 63 | &self.playback_view.progress_display 64 | } 65 | 66 | pub fn set_progress_display(&mut self, display: ProgressDisplay) { 67 | self.playback_view.progress_display = match display { 68 | ProgressDisplay::Waveform => match !self.waveform_is_valid() { 69 | true => { 70 | self.set_error(anyhow!("Invalid waveform! \nFallback to Oscilloscope")); 71 | ProgressDisplay::Oscilloscope 72 | } 73 | false => display, 74 | }, 75 | ProgressDisplay::Oscilloscope => display, 76 | ProgressDisplay::ProgressBar => display, 77 | } 78 | } 79 | 80 | pub fn get_oscilloscope_data(&self) -> Vec { 81 | match self.playback.player_state.lock() { 82 | Ok(state) => state.oscilloscope_buffer.iter().copied().collect(), 83 | Err(_) => Vec::new(), 84 | } 85 | } 86 | 87 | pub fn next_progress_display(&mut self) { 88 | self.playback_view.progress_display = match self.playback_view.progress_display { 89 | ProgressDisplay::Waveform => ProgressDisplay::Oscilloscope, 90 | ProgressDisplay::Oscilloscope => ProgressDisplay::ProgressBar, 91 | ProgressDisplay::ProgressBar => { 92 | if !self.playback_view.waveform_valid { 93 | self.set_error(anyhow!("Invalid Waveform!\n")); 94 | ProgressDisplay::Oscilloscope 95 | } else { 96 | ProgressDisplay::Waveform 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | static WAVEFORM_STEP: f32 = 0.5; 104 | impl PlaybackView { 105 | pub fn increment_smoothness(&mut self, direction: MoveDirection) { 106 | match direction { 107 | MoveDirection::Up => { 108 | if self.waveform_smoothing < 3.9 { 109 | self.waveform_smoothing += WAVEFORM_STEP; 110 | self.smooth_waveform(); 111 | } 112 | } 113 | MoveDirection::Down => { 114 | if self.waveform_smoothing > 0.1 { 115 | self.waveform_smoothing -= WAVEFORM_STEP; 116 | self.smooth_waveform(); 117 | } 118 | } 119 | } 120 | } 121 | 122 | pub fn smooth_waveform(&mut self) { 123 | self.waveform_smooth = self.waveform_raw.clone(); 124 | smooth_waveform(&mut self.waveform_smooth, self.waveform_smoothing); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/ui_state/search_state.rs: -------------------------------------------------------------------------------- 1 | use super::{Pane, UiState, new_textarea}; 2 | use crate::domain::{SimpleSong, SongInfo}; 3 | use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; 4 | use ratatui::crossterm::event::KeyEvent; 5 | use std::{collections::HashMap, sync::Arc}; 6 | use tui_textarea::TextArea; 7 | 8 | const MATCH_THRESHOLD: i64 = 80; 9 | const MATCH_LIMIT: usize = 250; 10 | 11 | #[derive(Copy, Clone)] 12 | pub enum MatchField { 13 | Title, 14 | Artist, 15 | Album, 16 | } 17 | 18 | pub struct SearchState { 19 | pub input: TextArea<'static>, 20 | matcher: SkimMatcherV2, 21 | pub(super) match_fields: HashMap, 22 | } 23 | 24 | impl SearchState { 25 | pub fn new() -> Self { 26 | SearchState { 27 | input: new_textarea("Enter search query"), 28 | matcher: SkimMatcherV2::default(), 29 | match_fields: HashMap::new(), 30 | } 31 | } 32 | } 33 | 34 | impl UiState { 35 | // Algorithm looks at the title, artist, and album fields 36 | // and scores each attribute while applying a heavier 37 | // weight to the title field and returns the highest score. 38 | // Assuming the score is higher than the threshold, the 39 | // result is valid. Results are ordered by score. 40 | pub(crate) fn filter_songs_by_search(&mut self) { 41 | let query = self.read_search().to_lowercase(); 42 | 43 | let mut scored_songs: Vec<(Arc, i64)> = self 44 | .library 45 | .get_all_songs() 46 | .iter() 47 | .filter_map(|song| { 48 | let title_score = self 49 | .search 50 | .matcher 51 | .fuzzy_match(&song.get_title().to_lowercase(), &query) 52 | .unwrap_or(0) 53 | * 2; 54 | 55 | let artist_score = (self 56 | .search 57 | .matcher 58 | .fuzzy_match(&song.get_artist().to_lowercase(), &query) 59 | .unwrap_or(0) as f32 60 | * 1.5) as i64; 61 | 62 | let album_score = (self 63 | .search 64 | .matcher 65 | .fuzzy_match(&song.get_album().to_lowercase(), &query) 66 | .unwrap_or(0) as f32 67 | * 1.75) as i64; 68 | 69 | let weighted_score = [title_score, artist_score, album_score]; 70 | let best_score = weighted_score.iter().max().copied().unwrap_or(0); 71 | 72 | if best_score > MATCH_THRESHOLD { 73 | let match_field = if best_score == title_score { 74 | MatchField::Title 75 | } else if best_score == artist_score { 76 | MatchField::Artist 77 | } else { 78 | MatchField::Album 79 | }; 80 | self.search.match_fields.insert(song.get_id(), match_field); 81 | } 82 | 83 | (best_score > MATCH_THRESHOLD).then(|| (Arc::clone(&song), best_score)) 84 | }) 85 | .collect(); 86 | 87 | scored_songs.sort_by(|a, b| b.1.cmp(&a.1)); 88 | self.legal_songs = scored_songs 89 | .into_iter() 90 | .take(MATCH_LIMIT) 91 | .map(|i| i.0) 92 | .collect(); 93 | } 94 | 95 | pub fn get_search_widget(&mut self) -> &mut TextArea<'static> { 96 | &mut self.search.input 97 | } 98 | 99 | pub fn get_search_len(&self) -> usize { 100 | self.search.input.lines()[0].len() 101 | } 102 | 103 | pub fn send_search(&mut self) { 104 | match !self.legal_songs.is_empty() { 105 | true => self.set_pane(Pane::TrackList), 106 | false => self.soft_reset(), 107 | } 108 | } 109 | 110 | pub fn process_search(&mut self, k: KeyEvent) { 111 | self.search.input.input(k); 112 | self.set_legal_songs(); 113 | match self.legal_songs.is_empty() { 114 | true => self.display_state.table_pos.select(None), 115 | false => self.display_state.table_pos.select(Some(0)), 116 | } 117 | } 118 | 119 | pub fn read_search(&self) -> &str { 120 | &self.search.input.lines()[0] 121 | } 122 | 123 | pub fn get_match_fields(&self, song_id: u64) -> Option { 124 | self.search.match_fields.get(&song_id).copied() 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /docs/themes.md: -------------------------------------------------------------------------------- 1 | # Themeing 2 | 3 | This document is designed to layout the specifications of the Concertus 4 | themeing format. Should any changes be made, they should be reflected here, 5 | and laid out in the example below. 6 | 7 | Several examples of custom themes can be seen [here](./theme_examples/). 8 | 9 | Current theme specification: `v1.0` 10 | 11 | ### Hot keys 12 | 13 | The theme menu can be accessed via the `Ctrl`+`c` keymap. Attempt to hot reload 14 | the current them with `F6`. This will hot reload all themes, no need to reboot 15 | when testing changes. Themes can also be cycled through via the `Shift`+`<` and 16 | `Shift`+`>` keys. (Note: Using these keys does not reload recent changes, it 17 | only cycles through the most recently loaded themes). 18 | 19 | ### Theme Folder Location & Formatting 20 | 21 | Themes should be placed in the `$CONFIG/concertus/themes` directory. This 22 | folder will be autogenerated after running concertus for the first time. 23 | 24 | Any number of themes can be placed into the folder, there is no limit. Should a 25 | theme fail to follow the specification (i.e. has missing fields, contains 26 | typos, fail to comply with the TOML parser, etc.) the theme will not be loaded, 27 | nor will any errors be thrown. If a theme is not displaying, review the 28 | formatting and any potential typos (such as forgetting quotation marks, 29 | forgetting the `#`, or using colons instead of equal signs). 30 | 31 | Here is an example of a transparent theme: 32 | 33 | ```Toml 34 | # Concertus Transparent.toml 35 | 36 | # Theme specification v1.0 37 | [colors] 38 | surface_global = "" # Background of application [empty fields will attempt to render transparency] 39 | surface_active = "" # Background of selected pane 40 | surface_inactive = "" # Background of unselected areas 41 | surface_error = "#ff4646" # Color of error popup 42 | 43 | text_primary = "#d2d2d7" 44 | text_secondary = "#ff4646" 45 | text_secondary_in = "#a62e2e" # Secondary text in unfocused panes 46 | text_selection = "#0a0a0d" 47 | text_muted = "#646468" 48 | 49 | border_active = "#dcdc64" 50 | border_inactive = "#343438" 51 | 52 | selection = "#dcdc64" # Selected item 53 | selection_inactive = "#82823C" # Multi-selected & unfocused selections 54 | 55 | accent = "#dcdc64" 56 | accent_inactive = "#82823C" 57 | 58 | progress = ["#ff0000", "#ffffff", "#0000ff"] 59 | # A single color value is also allowed 60 | 61 | progress_i # [OPTIONAL] 62 | = "dimmed" 63 | # Other options values include 64 | # "dimmed" => a faded version of the above field [default] 65 | # "still" => a frozen version of the gradient 66 | # "#ff4646" => a solid single color 67 | # ["#ff0000", "#ffffff", "#0000ff" 68 | 69 | progress_speed = 8.0 # Defaults to 6.0 if field is not supplied. 70 | # Recommended to use an even value here. 71 | # 0 Will disable any scrolling, negative values will reverse direction 72 | 73 | [borders] # These fields are optional, defaults shown 74 | border_display = "all" 75 | border_type = "rounded" 76 | 77 | [extras] # These fields are optional, defaults shown 78 | is_dark = true # Defaults to true 79 | decorator = "✧" # Recommended to use a utf8 compliant character here 80 | ``` 81 | 82 | ### Acceptable Color Formats 83 | 84 | Colors should be formatted as their hexidecimal representations, enclosed in 85 | double quotes: `"#XXXXXX"` 86 | 87 | For those who wish to utilize transparency, simply leave the quotes empty or 88 | write "none" with the quotation marks. (Certain elements may not be able to be 89 | rendered as transparent. Your terminal may also play a role in what can/cannot 90 | be transparent.) 91 | 92 | ### Theme Tricks 93 | Users can create a borderless experience by setting the colors of the borders 94 | to the same values as the respective background panel colors. However, there is 95 | no way to set transparent borders. Users can also set border_display to "none", 96 | although this may affect the padding of some elements. 97 | 98 | The same principle applies to the selection colors- Instead of a highlighted 99 | column, try setting the highlight color to the same color as the background, 100 | and the highlighted text to a distinct color. Test the results! 101 | -------------------------------------------------------------------------------- /src/domain/long_song.rs: -------------------------------------------------------------------------------- 1 | use super::{FileType, SongInfo}; 2 | use crate::{ 3 | calculate_signature, database::Database, get_readable_duration, normalize_metadata_str as nms, 4 | }; 5 | use anyhow::{Result, bail}; 6 | use lofty::{ 7 | file::{AudioFile, TaggedFileExt}, 8 | read_from_path, 9 | tag::{Accessor, ItemKey}, 10 | }; 11 | 12 | use std::{ 13 | path::{Path, PathBuf}, 14 | sync::Arc, 15 | time::Duration, 16 | }; 17 | 18 | #[derive(Default)] 19 | pub struct LongSong { 20 | pub(crate) id: u64, 21 | pub(crate) title: String, 22 | pub(crate) year: Option, 23 | pub(crate) artist: Arc, 24 | pub(crate) album_artist: Arc, 25 | pub(crate) album: Arc, 26 | pub(crate) track_no: Option, 27 | pub(crate) disc_no: Option, 28 | pub(crate) duration: Duration, 29 | pub(crate) channels: Option, 30 | pub(crate) bit_rate: Option, 31 | pub(crate) sample_rate: Option, 32 | pub(crate) filetype: FileType, 33 | pub(crate) path: PathBuf, 34 | } 35 | 36 | impl LongSong { 37 | pub fn new(path: PathBuf) -> Self { 38 | LongSong { 39 | path, 40 | ..Default::default() 41 | } 42 | } 43 | 44 | pub fn build_song_lofty>(path_raw: P) -> Result { 45 | let path = path_raw.as_ref(); 46 | let mut song_info = LongSong::new(PathBuf::from(path)); 47 | 48 | song_info.id = calculate_signature(path)?; 49 | 50 | song_info.filetype = match path.extension() { 51 | Some(n) => FileType::from(n.to_str().unwrap()), 52 | None => bail!("Unsupported extension: {:?}", path.extension()), 53 | }; 54 | 55 | let tagged_file = read_from_path(path)?; 56 | let properties = tagged_file.properties(); 57 | 58 | song_info.duration = properties.duration(); 59 | song_info.channels = properties.channels(); 60 | song_info.sample_rate = properties.sample_rate(); 61 | song_info.bit_rate = properties.audio_bitrate(); 62 | 63 | song_info.title = tagged_file 64 | .primary_tag() 65 | .and_then(|tag| tag.title()) 66 | .map(|s| nms(&s)) 67 | .filter(|s| !s.is_empty()) 68 | .unwrap_or_else(|| { 69 | path.file_stem() 70 | .map(|stem| stem.to_string_lossy().into_owned()) 71 | .unwrap_or_default() 72 | }); 73 | 74 | if let Some(tag) = tagged_file.primary_tag() { 75 | song_info.album = Arc::new(tag.album().map(|s| nms(&s)).unwrap_or_default()); 76 | 77 | let artist = tag 78 | .artist() 79 | .map(|s| nms(&s)) 80 | .unwrap_or("[NO ARTIST!]".into()); 81 | 82 | let album_artist = tag 83 | .get_string(&ItemKey::AlbumArtist) 84 | .map(|s| nms(&s)) 85 | .filter(|s| !s.is_empty()) 86 | .unwrap_or_else(|| artist.to_string()); 87 | 88 | song_info.artist = Arc::new(artist); 89 | song_info.album_artist = Arc::new(album_artist); 90 | 91 | song_info.year = tag.year().or_else(|| { 92 | tag.get_string(&ItemKey::Year) 93 | .and_then(|s| { 94 | nms(&s) 95 | .split_once('-') 96 | .map(|(y, _)| y.to_string()) 97 | .or_else(|| Some(s.to_string())) 98 | }) 99 | .and_then(|s| s.parse::().ok()) 100 | }); 101 | 102 | song_info.track_no = tag.track(); 103 | song_info.disc_no = tag.disk(); 104 | } 105 | 106 | Ok(song_info) 107 | } 108 | 109 | pub fn get_path(&self, db: &mut Database) -> Result { 110 | db.get_song_path(self.id) 111 | } 112 | } 113 | 114 | impl SongInfo for LongSong { 115 | fn get_id(&self) -> u64 { 116 | self.id 117 | } 118 | 119 | fn get_title(&self) -> &str { 120 | &self.title 121 | } 122 | 123 | fn get_artist(&self) -> &str { 124 | &self.artist 125 | } 126 | 127 | fn get_album(&self) -> &str { 128 | &self.album 129 | } 130 | 131 | fn get_duration(&self) -> Duration { 132 | self.duration 133 | } 134 | 135 | fn get_duration_f32(&self) -> f32 { 136 | self.duration.as_secs_f32() 137 | } 138 | 139 | fn get_duration_str(&self) -> String { 140 | get_readable_duration(self.duration, crate::DurationStyle::Compact) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/tui/widgets/sidebar/album_sidebar.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | tui::widgets::sidebar::create_standard_list, 3 | ui_state::{AlbumSort, Pane, UiState}, 4 | }; 5 | use ratatui::{ 6 | style::{Style, Stylize}, 7 | text::{Line, Span}, 8 | widgets::{ListItem, ListState, StatefulWidget}, 9 | }; 10 | 11 | pub struct SideBarAlbum; 12 | impl StatefulWidget for SideBarAlbum { 13 | type State = UiState; 14 | 15 | fn render( 16 | self, 17 | area: ratatui::prelude::Rect, 18 | buf: &mut ratatui::prelude::Buffer, 19 | state: &mut Self::State, 20 | ) { 21 | let focus = matches!(&state.get_pane(), Pane::SideBar); 22 | let theme = &state.theme_manager.get_display_theme(focus); 23 | 24 | let albums = &state.albums; 25 | let pane_sort = state.get_album_sort_string(); 26 | let pane_sort = format!(" 󰒿 {pane_sort:5} "); 27 | let album_sort = state.get_album_sort(); 28 | 29 | let selected_album_idx = state.display_state.album_pos.selected(); 30 | let selected_artist = state.get_selected_album().map(|a| a.artist.as_str()); 31 | 32 | let mut list_items = Vec::with_capacity(albums.len()); 33 | let mut current_artist = None; 34 | let mut current_display_idx = 0; 35 | let mut selected_display_idx = None; 36 | 37 | for (idx, album) in albums.iter().enumerate() { 38 | // Add header if artist changed (only for Artist sort) 39 | 40 | if album_sort == AlbumSort::Artist { 41 | if current_artist.as_ref() != Some(&album.artist.as_str()) { 42 | let artist_str = album.artist.as_str(); 43 | let is_selected_artist = selected_artist == Some(artist_str); 44 | 45 | let header_style = match is_selected_artist { 46 | true => Style::default().fg(theme.text_secondary).underlined(), 47 | false => Style::default().fg(theme.text_secondary), 48 | }; 49 | 50 | list_items.push(ListItem::new(Span::from(artist_str).style(header_style))); 51 | 52 | current_artist = Some(artist_str); 53 | current_display_idx += 1; 54 | } 55 | } 56 | 57 | let year = album.year.map_or("----".to_string(), |y| format!("{y}")); 58 | let year_color = match album_sort { 59 | AlbumSort::Artist => theme.text_muted, 60 | _ => theme.text_secondary, 61 | }; 62 | 63 | let indent = match state.get_album_sort() == AlbumSort::Artist { 64 | true => " ", 65 | false => "", 66 | }; 67 | 68 | let is_selected = selected_album_idx == Some(idx); 69 | if is_selected { 70 | selected_display_idx = Some(current_display_idx); 71 | } 72 | let decorator = &state.get_decorator(); 73 | 74 | list_items.push(ListItem::new(Line::from_iter([ 75 | Span::from(format!("{}{: >4} ", indent, year)).fg(year_color), 76 | Span::from(format!("{decorator} ")).fg(theme.text_muted), 77 | Span::from(album.title.as_str()).fg(theme.text_primary), 78 | ]))); 79 | 80 | current_display_idx += 1; 81 | } 82 | 83 | // Temp state for rendering with display index 84 | let mut render_state = ListState::default(); 85 | render_state.select(selected_display_idx); 86 | 87 | // Sync offset to ensure selection is visible 88 | if let Some(idx) = selected_display_idx { 89 | let current_offset = state.display_state.album_pos.offset(); 90 | let visible_height = area.height.saturating_sub(4) as usize; 91 | 92 | if idx < current_offset { 93 | *render_state.offset_mut() = idx; 94 | } else if idx >= current_offset + visible_height { 95 | *render_state.offset_mut() = idx.saturating_sub(visible_height - 1); 96 | } else { 97 | *render_state.offset_mut() = current_offset; 98 | } 99 | } 100 | 101 | let title = Line::from(format!(" ⟪ {} Albums ⟫ ", albums.len())).fg(theme.accent); 102 | let sorting = Line::from(pane_sort) 103 | .right_aligned() 104 | .fg(theme.text_secondary); 105 | 106 | create_standard_list(list_items, (title, sorting), state, area).render( 107 | area, 108 | buf, 109 | &mut render_state, 110 | ); 111 | 112 | // Sync offset back 113 | *state.display_state.album_pos.offset_mut() = render_state.offset(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/database/playlists.rs: -------------------------------------------------------------------------------- 1 | use crate::{Database, database::queries::*}; 2 | use anyhow::Result; 3 | use indexmap::IndexMap; 4 | use rusqlite::params; 5 | 6 | impl Database { 7 | pub fn create_playlist(&mut self, name: &str) -> Result<()> { 8 | self.conn.execute(CREATE_NEW_PLAYLIST, params![name])?; 9 | 10 | Ok(()) 11 | } 12 | 13 | pub fn delete_playlist(&mut self, id: i64) -> Result<()> { 14 | self.conn.execute(DELETE_PLAYLIST, params![id])?; 15 | 16 | Ok(()) 17 | } 18 | 19 | pub fn rename_playlist(&mut self, new_name: &str, playlist_id: i64) -> Result<()> { 20 | let tx = self.conn.transaction()?; 21 | { 22 | tx.execute(RENAME_PLAYLIST, params![new_name, playlist_id])?; 23 | tx.execute(UPDATE_PLAYLIST, params![playlist_id])?; 24 | } 25 | tx.commit()?; 26 | 27 | Ok(()) 28 | } 29 | 30 | pub fn add_to_playlist(&mut self, song_id: u64, playlist_id: i64) -> Result<()> { 31 | let tx = self.conn.transaction()?; 32 | tx.execute( 33 | ADD_SONG_TO_PLAYLIST, 34 | params![song_id.to_le_bytes(), playlist_id], 35 | )?; 36 | tx.execute(UPDATE_PLAYLIST, params![playlist_id])?; 37 | 38 | tx.commit()?; 39 | 40 | Ok(()) 41 | } 42 | 43 | pub fn add_to_playlist_multi(&mut self, songs: Vec, playlist_id: i64) -> Result<()> { 44 | let tx = self.conn.transaction()?; 45 | { 46 | let start_pos = tx 47 | .query_row(GET_PLAYLIST_POSITION_NEXT, params![playlist_id], |row| { 48 | row.get(0) 49 | }) 50 | .unwrap_or(0) 51 | + 1; 52 | 53 | let mut stmt = tx.prepare_cached(ADD_SONG_TO_PLAYLIST_WITH_POSITION)?; 54 | 55 | for (i, song) in songs.iter().enumerate() { 56 | stmt.execute(params![ 57 | song.to_le_bytes(), 58 | playlist_id, 59 | start_pos + i as i64 60 | ])?; 61 | } 62 | 63 | tx.execute(UPDATE_PLAYLIST, params![playlist_id])?; 64 | } 65 | tx.commit()?; 66 | 67 | Ok(()) 68 | } 69 | 70 | pub fn remove_from_playlist(&mut self, ps_id: &[i64]) -> Result<()> { 71 | let tx = self.conn.transaction()?; 72 | { 73 | let mut stmt = tx.prepare_cached(REMOVE_SONG_FROM_PLAYLIST)?; 74 | for id in ps_id { 75 | stmt.execute(params![id])?; 76 | } 77 | } 78 | 79 | tx.commit()?; 80 | Ok(()) 81 | } 82 | 83 | pub fn swap_position(&mut self, ps_id1: i64, ps_id2: i64, playlist_id: i64) -> Result<()> { 84 | let tx = self.conn.transaction()?; 85 | { 86 | let pos1: i64 = tx.query_row(GET_PLAYLIST_POS, params![ps_id1], |row| row.get(0))?; 87 | 88 | let pos2: i64 = tx.query_row(GET_PLAYLIST_POS, params![ps_id2], |row| row.get(0))?; 89 | 90 | // Three-step swap to avoid unique constraint violation 91 | tx.execute(UPDATE_PLAYLIST_POS, params![-1, ps_id1])?; 92 | tx.execute(UPDATE_PLAYLIST_POS, params![pos1, ps_id2])?; 93 | tx.execute(UPDATE_PLAYLIST_POS, params![pos2, ps_id1])?; 94 | 95 | tx.execute(UPDATE_PLAYLIST, params![playlist_id])?; 96 | } 97 | 98 | tx.commit()?; 99 | 100 | Ok(()) 101 | } 102 | 103 | pub fn build_playlists(&mut self) -> Result>> { 104 | let mut stmt = self.conn.prepare_cached(PLAYLIST_BUILDER)?; 105 | 106 | let rows = stmt.query_map([], |r| { 107 | let ps_id: Option = r.get("id")?; 108 | let name: String = r.get("name")?; 109 | let playlist_id: i64 = r.get("playlist_id")?; 110 | 111 | let song_id: Option = match r.get::<_, Option>>("song_id")? { 112 | Some(hash_bytes) => { 113 | let hash_array: [u8; 8] = hash_bytes.try_into().map_err(|_| { 114 | rusqlite::Error::InvalidColumnType( 115 | 2, 116 | "song_id".to_string(), 117 | rusqlite::types::Type::Blob, 118 | ) 119 | })?; 120 | Some(u64::from_le_bytes(hash_array)) 121 | } 122 | None => None, 123 | }; 124 | 125 | Ok((playlist_id, song_id, ps_id, name)) 126 | })?; 127 | 128 | let mut playlist_map: IndexMap<(i64, String), Vec<(i64, u64)>> = IndexMap::new(); 129 | 130 | for row in rows { 131 | let (playlist_id, song_id_opt, ps_id_opt, name) = row?; 132 | 133 | let entry = playlist_map 134 | .entry((playlist_id, name)) 135 | .or_insert_with(Vec::new); 136 | 137 | if let (Some(song_id), Some(ps_id)) = (song_id_opt, ps_id_opt) { 138 | entry.push((ps_id, song_id)) 139 | } 140 | } 141 | 142 | Ok(playlist_map) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/database/queries.rs: -------------------------------------------------------------------------------- 1 | pub const GET_WAVEFORM: &str = " 2 | SELECT waveform FROM waveforms 3 | WHERE song_id = ? 4 | "; 5 | 6 | pub const INSERT_WAVEFORM: &str = " 7 | INSERT or IGNORE INTO waveforms (song_id, waveform) 8 | VALUES (?1, ?2) 9 | "; 10 | 11 | pub const GET_ALL_SONGS: &str = " 12 | SELECT 13 | s.id, 14 | s.path, 15 | s.title, 16 | s.year, 17 | s.track_no, 18 | s.disc_no, 19 | s.duration, 20 | s.artist_id, 21 | s.album_id, 22 | s.format, 23 | a.title as album, 24 | a.artist_id as album_artist 25 | from songs s 26 | INNER JOIN albums a ON a.id = s.album_id 27 | ORDER BY 28 | album ASC, 29 | disc_no ASC, 30 | track_no ASC 31 | "; 32 | 33 | pub const INSERT_SONG: &str = " 34 | INSERT OR REPLACE INTO songs ( 35 | id, 36 | title, 37 | year, 38 | path, 39 | artist_id, 40 | album_id, 41 | track_no, 42 | disc_no, 43 | duration, 44 | channels, 45 | bit_rate, 46 | sample_rate, 47 | format 48 | ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 49 | )"; 50 | 51 | pub const INSERT_ARTIST: &str = " 52 | INSERT OR IGNORE INTO artists ( 53 | name 54 | ) VALUES (?1) 55 | "; 56 | 57 | pub const INSERT_ALBUM: &str = " 58 | INSERT OR IGNORE INTO albums ( 59 | title, 60 | artist_id 61 | ) VALUES (?1, ?2) 62 | "; 63 | 64 | pub const GET_PATH: &str = " 65 | SELECT path FROM songs 66 | WHERE id = ? 67 | "; 68 | 69 | pub const GET_ARTIST_MAP: &str = " 70 | SELECT id, name FROM artists 71 | "; 72 | 73 | pub const GET_ALBUM_MAP: &str = " 74 | SELECT id, title, artist_id FROM albums 75 | "; 76 | 77 | pub const ALBUM_BUILDER: &str = " 78 | SELECT 79 | id, artist_id 80 | FROM albums 81 | ORDER BY title 82 | "; 83 | 84 | pub const GET_ROOTS: &str = " 85 | SELECT path FROM roots 86 | "; 87 | 88 | pub const SET_ROOT: &str = " 89 | INSERT OR IGNORE INTO roots (path) VALUES (?) 90 | "; 91 | 92 | pub const DELETE_ROOT: &str = " 93 | DELETE FROM roots WHERE path = ? 94 | "; 95 | 96 | pub const GET_HASHES: &str = " 97 | SELECT id FROM songs 98 | "; 99 | 100 | pub const DELETE_SONGS: &str = " 101 | DELETE FROM songs WHERE id = ? 102 | "; 103 | 104 | pub const LOAD_HISTORY: &str = " 105 | SELECT song_id FROM history 106 | ORDER BY timestamp DESC 107 | LIMIT 50 108 | "; 109 | 110 | pub const INSERT_INTO_HISTORY: &str = " 111 | INSERT INTO history (song_id, timestamp) VALUES (?, ?)"; 112 | 113 | pub const DELETE_FROM_HISTORY: &str = " 114 | DELETE FROM history WHERE id NOT IN 115 | (SELECT id FROM history ORDER BY timestamp DESC LIMIT 50) 116 | "; 117 | 118 | pub const UPDATE_PLAY_COUNT: &str = " 119 | INSERT INTO plays 120 | (song_id, count) 121 | VALUES (?1, ?2) 122 | ON CONFLICT(song_id) DO UPDATE SET 123 | count = count + ?2 124 | WHERE song_id = ?1 125 | "; 126 | 127 | pub const GET_UI_SNAPSHOT: &str = " 128 | SELECT key, value 129 | FROM session_state 130 | WHERE key LIKE 'ui_%'"; 131 | 132 | pub const SET_SESSION_STATE: &str = " 133 | INSERT OR REPLACE INTO session_state (key, value) 134 | VALUES (?, ?) 135 | "; 136 | 137 | pub const CREATE_NEW_PLAYLIST: &str = " 138 | INSERT OR IGNORE INTO playlists (name, updated_at) 139 | VALUES (?, strftime('%s', 'now')) 140 | "; 141 | 142 | pub const UPDATE_PLAYLIST: &str = " 143 | UPDATE playlists 144 | SET updated_at = strftime('%s', 'now') 145 | WHERE id = ? 146 | "; 147 | 148 | pub const DELETE_PLAYLIST: &str = " 149 | DELETE FROM playlists 150 | WHERE id = ? 151 | "; 152 | 153 | pub const GET_PLAYLIST_POSITION_NEXT: &str = " 154 | SELECT COALESCE(MAX(position), 0) 155 | FROM playlist_songs WHERE playlist_id = ? 156 | "; 157 | 158 | pub const ADD_SONG_TO_PLAYLIST: &str = " 159 | INSERT INTO playlist_songs ( 160 | song_id, 161 | playlist_id, 162 | position) 163 | VALUES ( 164 | ?1, 165 | ?2, 166 | COALESCE((SELECT MAX(position) + 1 167 | FROM playlist_songs WHERE playlist_id = ?2), 1) 168 | ) 169 | "; 170 | 171 | pub const ADD_SONG_TO_PLAYLIST_WITH_POSITION: &str = " 172 | INSERT INTO playlist_songs ( 173 | song_id, 174 | playlist_id, 175 | position 176 | ) 177 | VALUES ( 178 | ?1, 179 | ?2, 180 | ?3 181 | ) 182 | 183 | "; 184 | 185 | pub const PLAYLIST_BUILDER: &str = " 186 | SELECT 187 | ps.id, 188 | ps.song_id, 189 | p.id as playlist_id, 190 | p.name 191 | FROM playlists p 192 | LEFT JOIN playlist_songs ps 193 | ON p.id = ps.playlist_id 194 | ORDER BY p.updated_at DESC, COALESCE(ps.position, 0) ASC 195 | "; 196 | 197 | pub const REMOVE_SONG_FROM_PLAYLIST: &str = " 198 | DELETE FROM playlist_songs 199 | WHERE id = ?; 200 | "; 201 | 202 | pub const GET_PLAYLIST_POS: &str = " 203 | SELECT position FROM playlist_songs WHERE id = ? 204 | "; 205 | 206 | pub const UPDATE_PLAYLIST_POS: &str = " 207 | UPDATE playlist_songs SET position = ? WHERE id = ? 208 | "; 209 | 210 | pub const RENAME_PLAYLIST: &str = " 211 | UPDATE playlists SET name = ? WHERE id = ? 212 | "; 213 | -------------------------------------------------------------------------------- /src/ui_state/ui_state.rs: -------------------------------------------------------------------------------- 1 | use super::{DisplayState, playback::PlaybackCoordinator, search_state::SearchState}; 2 | use crate::{ 3 | Library, 4 | database::DbWorker, 5 | domain::{Album, Playlist, SimpleSong}, 6 | key_handler::InputContext, 7 | player::PlayerState, 8 | ui_state::{ 9 | LibraryView, Mode, Pane, PlaybackView, PlaylistAction, SettingsMode, ThemeManager, 10 | popup::{PopupState, PopupType}, 11 | }, 12 | }; 13 | use anyhow::{Error, Result}; 14 | use std::sync::{Arc, Mutex}; 15 | 16 | pub struct UiState { 17 | // Backend Modules 18 | pub(super) library: Arc, 19 | pub(crate) db_worker: DbWorker, 20 | pub(crate) playback: PlaybackCoordinator, 21 | pub playback_view: PlaybackView, 22 | 23 | // Visual Elements 24 | pub(crate) theme_manager: ThemeManager, 25 | pub(crate) popup: PopupState, 26 | pub(crate) search: SearchState, 27 | pub(crate) display_state: DisplayState, 28 | 29 | // View models 30 | pub albums: Vec, 31 | pub legal_songs: Vec>, 32 | pub playlists: Vec, 33 | 34 | pub library_refresh_progress: Option, 35 | pub library_refresh_detail: Option, 36 | } 37 | 38 | impl UiState { 39 | pub fn new(library: Arc, player_state: Arc>) -> Self { 40 | UiState { 41 | library, 42 | db_worker: DbWorker::new() 43 | .expect("Could not establish connection to database for UiState!"), 44 | search: SearchState::new(), 45 | display_state: DisplayState::new(), 46 | playback: PlaybackCoordinator::new(player_state), 47 | playback_view: PlaybackView::new(), 48 | popup: PopupState::new(), 49 | theme_manager: ThemeManager::new(), 50 | albums: Vec::new(), 51 | legal_songs: Vec::new(), 52 | playlists: Vec::new(), 53 | 54 | library_refresh_progress: None, 55 | library_refresh_detail: None, 56 | } 57 | } 58 | } 59 | 60 | impl UiState { 61 | pub fn sync_library(&mut self, library: Arc) -> Result<()> { 62 | self.library = library; 63 | 64 | self.sort_albums(); 65 | match self.albums.is_empty() { 66 | true => self.display_state.album_pos.select(None), 67 | false => { 68 | let album_len = self.albums.len(); 69 | let current_selection = self.display_state.album_pos.selected().unwrap_or(0); 70 | 71 | if current_selection > album_len { 72 | self.display_state.album_pos.select(Some(album_len - 1)); 73 | } else if self.display_state.album_pos.selected().is_none() { 74 | self.display_state.album_pos.select(Some(0)); 75 | }; 76 | } 77 | } 78 | 79 | self.get_playlists()?; 80 | self.set_legal_songs(); 81 | 82 | Ok(()) 83 | } 84 | 85 | pub fn set_error(&mut self, e: Error) { 86 | self.show_popup(PopupType::Error(e.to_string())); 87 | } 88 | 89 | pub fn soft_reset(&mut self) { 90 | if self.popup.is_open() { 91 | self.close_popup(); 92 | } 93 | 94 | if self.get_mode() == Mode::Search { 95 | self.set_mode(Mode::Library(LibraryView::Albums)); 96 | } 97 | 98 | self.clear_multi_select(); 99 | self.search.input.select_all(); 100 | self.search.input.cut(); 101 | self.set_legal_songs(); 102 | } 103 | 104 | pub fn get_error(&self) -> Option<&str> { 105 | match &self.popup.current { 106 | PopupType::Error(e) => Some(e.as_str()), 107 | _ => None, 108 | } 109 | } 110 | 111 | pub fn get_input_context(&self) -> InputContext { 112 | if self.popup.is_open() { 113 | return InputContext::Popup(self.popup.current.clone()); 114 | } 115 | 116 | match (self.get_mode(), self.get_pane()) { 117 | (Mode::Fullscreen, _) => InputContext::Fullscreen, 118 | (Mode::Library(LibraryView::Albums), Pane::SideBar) => InputContext::AlbumView, 119 | (Mode::Library(LibraryView::Playlists), Pane::SideBar) => InputContext::PlaylistView, 120 | (Mode::Search, Pane::Search) => InputContext::Search, 121 | (mode, Pane::TrackList) => InputContext::TrackList(mode.clone()), 122 | (Mode::QUIT, _) => unreachable!(), 123 | _ => InputContext::TrackList(self.get_mode().clone()), 124 | } 125 | } 126 | 127 | pub fn is_text_input_active(&self) -> bool { 128 | matches!( 129 | (self.get_pane(), &self.popup.current), 130 | (Pane::Search, _) 131 | | (Pane::Popup, PopupType::Settings(SettingsMode::AddRoot)) 132 | | (Pane::Popup, PopupType::Playlist(PlaylistAction::Create)) 133 | | ( 134 | Pane::Popup, 135 | PopupType::Playlist(PlaylistAction::CreateWithSongs) 136 | ) 137 | | (Pane::Popup, PopupType::Playlist(PlaylistAction::Rename)) 138 | ) 139 | } 140 | } 141 | 142 | impl UiState { 143 | pub fn set_library_refresh_progress(&mut self, progress: Option) { 144 | self.library_refresh_progress = progress; 145 | } 146 | 147 | pub fn get_library_refresh_progress(&self) -> Option { 148 | self.library_refresh_progress 149 | } 150 | 151 | pub fn set_library_refresh_detail(&mut self, detail: Option) { 152 | self.library_refresh_detail = detail; 153 | } 154 | 155 | pub fn get_library_refresh_detail(&self) -> Option<&str> { 156 | self.library_refresh_detail.as_deref() 157 | } 158 | 159 | pub fn is_library_refreshing(&self) -> bool { 160 | self.library_refresh_progress.is_some() 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/database/worker.rs: -------------------------------------------------------------------------------- 1 | use crate::{database::Database, domain::SimpleSong, ui_state::UiSnapshot, SongMap}; 2 | use anyhow::{anyhow, Result}; 3 | use indexmap::IndexMap; 4 | use std::{ 5 | collections::{HashSet, VecDeque}, 6 | sync::{mpsc, Arc}, 7 | thread, 8 | }; 9 | 10 | pub enum DbMessage { 11 | Operation(Box), 12 | Shutdown, 13 | } 14 | 15 | pub struct DbWorker { 16 | sender: mpsc::Sender, 17 | pub handle: Option>, 18 | } 19 | 20 | impl DbWorker { 21 | pub fn new() -> Result { 22 | let (sender, receiver) = mpsc::channel::(); 23 | 24 | let handle = thread::spawn(move || { 25 | let mut db = match Database::open() { 26 | Ok(db) => db, 27 | Err(e) => { 28 | eprintln!("Failed to open database in worker: {}", e); 29 | return; 30 | } 31 | }; 32 | 33 | while let Ok(msg) = receiver.recv() { 34 | match msg { 35 | DbMessage::Operation(operation) => { 36 | operation(&mut db); 37 | } 38 | DbMessage::Shutdown => { 39 | break; 40 | } 41 | } 42 | } 43 | }); 44 | 45 | Ok(DbWorker { 46 | sender, 47 | handle: Some(handle), 48 | }) 49 | } 50 | 51 | // Fire and forget operation 52 | pub fn execute(&self, operation: F) 53 | where 54 | F: FnOnce(&mut Database) + Send + 'static, 55 | { 56 | let _ = self.sender.send(DbMessage::Operation(Box::new(operation))); 57 | } 58 | 59 | // Operations that need a response 60 | pub fn execute_sync(&self, operation: F) -> Result 61 | where 62 | F: FnOnce(&mut Database) -> Result + Send + 'static, 63 | T: Send + 'static, 64 | { 65 | let (result_tx, result_rx) = mpsc::channel(); 66 | 67 | self.execute(move |db| { 68 | let result = operation(db); 69 | let _ = result_tx.send(result); 70 | }); 71 | 72 | result_rx 73 | .recv() 74 | .map_err(|_| anyhow::anyhow!("Worker dropped"))? 75 | } 76 | 77 | pub fn shutdown(&mut self) -> Result<()> { 78 | self.sender 79 | .send(DbMessage::Shutdown) 80 | .expect("Could not shutdown dbworker"); 81 | 82 | if let Some(handle) = self.handle.take() { 83 | handle 84 | .join() 85 | .map_err(|_| anyhow!("Worker thread panicked!"))?; 86 | } 87 | 88 | Ok(()) 89 | } 90 | } 91 | 92 | // Convenience functions 93 | impl DbWorker { 94 | pub fn create_playlist(&self, name: String) -> Result<()> { 95 | self.execute_sync(move |db| db.create_playlist(&name)) 96 | } 97 | 98 | pub fn delete_playlist(&self, id: i64) -> Result<()> { 99 | self.execute_sync(move |db| db.delete_playlist(id)) 100 | } 101 | 102 | pub fn add_to_playlist(&self, song_id: u64, playlist_id: i64) -> Result<()> { 103 | self.execute_sync(move |db| db.add_to_playlist(song_id, playlist_id)) 104 | } 105 | 106 | pub fn add_to_playlist_multi(&self, song_ids: Vec, playlist_id: i64) -> Result<()> { 107 | self.execute_sync(move |db| db.add_to_playlist_multi(song_ids, playlist_id)) 108 | } 109 | 110 | pub fn rename_playlist(&self, id: i64, new_name: String) -> Result<()> { 111 | self.execute_sync(move |db| db.rename_playlist(&new_name, id)) 112 | } 113 | 114 | pub fn remove_from_playlist(&self, ps_ids: Vec) -> Result<()> { 115 | self.execute_sync(move |db| db.remove_from_playlist(&ps_ids)) 116 | } 117 | 118 | pub fn swap_position(&self, ps_id1: i64, ps_id2: i64, playlist_id: i64) -> Result<()> { 119 | self.execute_sync(move |db| db.swap_position(ps_id1, ps_id2, playlist_id)) 120 | } 121 | 122 | pub fn get_hashes(&self) -> Result> { 123 | self.execute_sync(move |db| db.get_hashes()) 124 | } 125 | 126 | pub fn build_playlists(&mut self) -> Result>> { 127 | self.execute_sync(move |db| db.build_playlists()) 128 | } 129 | 130 | pub fn save_history(&self, history: Vec) -> Result<()> { 131 | self.execute_sync(move |db| db.save_history_to_db(&history)) 132 | } 133 | 134 | pub fn save_ui_snapshot(&self, snapshot: UiSnapshot) -> Result<()> { 135 | self.execute_sync(move |db| db.save_ui_snapshot(snapshot)) 136 | } 137 | 138 | pub fn load_ui_snapshot(&self) -> Result> { 139 | self.execute_sync(move |db| db.load_ui_snapshot()) 140 | } 141 | 142 | pub fn get_all_songs(&self) -> Result { 143 | self.execute_sync(move |db| db.get_all_songs()) 144 | } 145 | 146 | pub fn import_history(&self, song_map: SongMap) -> Result>> { 147 | self.execute_sync(move |db| db.import_history(&song_map)) 148 | } 149 | 150 | pub fn save_history_to_db(&self, history: Vec) -> Result<()> { 151 | self.execute_sync(move |db| db.save_history_to_db(&history)) 152 | } 153 | 154 | pub fn get_song_path(&self, id: u64) -> Result { 155 | self.execute_sync(move |db| db.get_song_path(id)) 156 | } 157 | 158 | // Fire-and-forget operations 159 | pub fn update_play_count(&self, song_id: u64) { 160 | self.execute(move |db| { 161 | let _ = db.update_play_count(song_id); 162 | }); 163 | } 164 | 165 | pub fn set_waveform(&self, song_id: u64, waveform: Vec) { 166 | self.execute(move |db| { 167 | let _ = db.set_waveform(song_id, &waveform); 168 | }); 169 | } 170 | } 171 | 172 | impl Drop for DbWorker { 173 | fn drop(&mut self) { 174 | let _ = self.shutdown(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/ui_state/theme/theme_config.rs: -------------------------------------------------------------------------------- 1 | use crate::ui_state::{ 2 | ProgressGradient, 3 | theme::{ 4 | gradients::InactiveGradient, 5 | theme_import::ThemeImport, 6 | theme_utils::{parse_border_type, parse_borders}, 7 | }, 8 | }; 9 | use anyhow::{Result, anyhow}; 10 | use ratatui::{ 11 | style::Color, 12 | widgets::{BorderType, Borders}, 13 | }; 14 | use std::{path::Path, rc::Rc, sync::Arc}; 15 | 16 | #[derive(Clone)] 17 | pub struct ThemeConfig { 18 | pub name: String, 19 | pub is_dark: bool, 20 | 21 | // Surface Colors 22 | pub surface_global: Color, // Global bg 23 | pub surface_active: Color, // Focused pane 24 | pub surface_inactive: Color, // Inactive pane 25 | pub surface_error: Color, // Error popup bg 26 | 27 | // Text colors 28 | pub text_primary: Color, // Focused text 29 | pub text_secondary: Color, // Accented text 30 | pub text_secondary_in: Color, // Accented text 31 | pub text_muted: Color, // Inactive/quiet text 32 | pub text_selection: Color, // Text inside of selection bar 33 | 34 | // Border colors 35 | pub border_active: Color, // Border highlight 36 | pub border_inactive: Color, // Border Inactive 37 | 38 | // Selection colors 39 | pub selection: Color, // Selection Bar color 40 | pub selection_inactive: Color, // Selection inactive 41 | 42 | // Accent 43 | pub accent: Color, 44 | pub accent_inactive: Color, 45 | 46 | // Border configuration 47 | pub border_display: Borders, 48 | pub border_type: BorderType, 49 | 50 | pub progress: ProgressGradient, 51 | pub progress_i: InactiveGradient, 52 | pub progress_speed: f32, 53 | 54 | pub decorator: Rc, 55 | } 56 | 57 | impl ThemeConfig { 58 | pub fn load_from_file>(path: P) -> Result { 59 | let file_str = std::fs::read_to_string(&path.as_ref())?; 60 | let config = toml::from_str::(&file_str)?; 61 | let mut theme = Self::try_from(&config)?; 62 | 63 | theme.name = path 64 | .as_ref() 65 | .file_stem() 66 | .and_then(|s| s.to_str()) 67 | .ok_or(anyhow!("Could not identify theme name"))? 68 | .to_string(); 69 | 70 | Ok(theme) 71 | } 72 | } 73 | 74 | impl TryFrom<&ThemeImport> for ThemeConfig { 75 | type Error = anyhow::Error; 76 | 77 | fn try_from(config: &ThemeImport) -> anyhow::Result { 78 | let colors = &config.colors; 79 | 80 | let surface_global = *colors.surface_global; 81 | let surface_active = *colors.surface_active; 82 | let surface_inactive = *colors.surface_inactive; 83 | let surface_error = *colors.surface_error; 84 | 85 | let text_primary = *colors.text_primary; 86 | let text_secondary = *colors.text_secondary; 87 | let text_secondary_in = *colors.text_secondary_in; 88 | let text_selection = *colors.text_selection; 89 | let text_muted = *colors.text_muted; 90 | 91 | let border_active = *colors.border_active; 92 | let border_inactive = *colors.border_inactive; 93 | 94 | let accent = *colors.accent; 95 | let accent_inactive = *colors.accent_inactive; 96 | 97 | let selection = *colors.selection; 98 | let selection_inactive = *colors.selection_inactive; 99 | 100 | let progress = ProgressGradient::from_raw(&colors.progress)?; 101 | let progress_i = InactiveGradient::from_raw(&colors.progress_i)?; 102 | let progress_speed = colors.progress_speed / -10.0; 103 | 104 | let decorator = Rc::from(config.extras.decorator.to_owned()); 105 | let is_dark = config.extras.is_dark; 106 | 107 | Ok(ThemeConfig { 108 | name: String::new(), 109 | 110 | surface_global, 111 | surface_active, 112 | surface_inactive, 113 | surface_error, 114 | 115 | text_primary, 116 | text_secondary, 117 | text_secondary_in, 118 | text_muted, 119 | text_selection, 120 | 121 | border_active, 122 | border_inactive, 123 | 124 | selection, 125 | selection_inactive, 126 | 127 | accent, 128 | accent_inactive, 129 | 130 | border_display: parse_borders(&config.borders.border_display), 131 | border_type: parse_border_type(&config.borders.border_type), 132 | 133 | progress, 134 | progress_i, 135 | progress_speed, 136 | 137 | decorator, 138 | is_dark, 139 | }) 140 | } 141 | } 142 | 143 | impl Default for ThemeConfig { 144 | fn default() -> Self { 145 | use super::*; 146 | 147 | ThemeConfig { 148 | name: String::from("Concertus_Alpha"), 149 | is_dark: true, 150 | 151 | surface_global: DARK_GRAY_FADED, 152 | surface_active: DARK_GRAY, 153 | surface_inactive: DARK_GRAY_FADED, 154 | surface_error: GOOD_RED_DARK, 155 | 156 | text_primary: DARK_WHITE, 157 | text_muted: MID_GRAY, 158 | text_selection: DARK_GRAY, 159 | text_secondary: GOOD_RED, 160 | text_secondary_in: GOOD_RED_DARK, 161 | 162 | border_active: GOLD, 163 | border_inactive: DARK_GRAY_FADED, 164 | 165 | selection: GOLD, 166 | selection_inactive: GOLD_FADED, 167 | 168 | accent: GOLD, 169 | accent_inactive: GOLD_FADED, 170 | 171 | border_display: Borders::ALL, 172 | border_type: BorderType::Rounded, 173 | 174 | progress: ProgressGradient::Gradient(Arc::from([DARK_WHITE, GOOD_RED_DARK, DARK_GRAY])), 175 | progress_i: InactiveGradient::Dimmed, 176 | progress_speed: 2.0, 177 | 178 | decorator: Rc::from("✧".to_string()), 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/tui/widgets/buffer_line.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | domain::SongInfo, 3 | truncate_at_last_space, 4 | tui::widgets::{PAUSE_ICON, QUEUE_ICON, SELECTED}, 5 | ui_state::{DisplayTheme, UiState}, 6 | }; 7 | use ratatui::{ 8 | layout::{Constraint, Direction, Layout}, 9 | style::Stylize, 10 | text::{Line, Span}, 11 | widgets::{Block, Borders, Gauge, StatefulWidget, Widget}, 12 | }; 13 | 14 | pub struct BufferLine; 15 | 16 | impl StatefulWidget for BufferLine { 17 | type State = UiState; 18 | 19 | fn render( 20 | self, 21 | area: ratatui::prelude::Rect, 22 | buf: &mut ratatui::prelude::Buffer, 23 | state: &mut Self::State, 24 | ) { 25 | let theme = state.theme_manager.get_display_theme(true); 26 | 27 | Block::new().bg(theme.bg_global).render(area, buf); 28 | 29 | if let Some(progress) = state 30 | .get_library_refresh_progress() 31 | .filter(|p| *p > 1 && *p < 100) 32 | { 33 | let desc = state.get_library_refresh_detail().unwrap_or_default(); 34 | let label = format!("{desc} | {progress}%"); 35 | let guage = Gauge::default() 36 | .block(Block::new().borders(Borders::NONE)) 37 | .gauge_style(theme.selection) 38 | .fg(theme.text_selected) 39 | .label(label) 40 | .percent(progress as u16 - 1); 41 | 42 | guage.render(area, buf); 43 | return; 44 | } 45 | 46 | let [left, center, right] = Layout::default() 47 | .direction(Direction::Horizontal) 48 | .constraints([ 49 | Constraint::Percentage(30), 50 | Constraint::Percentage(40), 51 | Constraint::Percentage(30), 52 | ]) 53 | .areas(area); 54 | 55 | let selection_count = state.get_multi_select_indices().len(); 56 | 57 | get_multi_selection(selection_count, &theme).render(left, buf); 58 | playing_title(state, &theme, center.width as usize).render(center, buf); 59 | queue_display(state, &theme, right.width as usize).render(right, buf); 60 | } 61 | } 62 | 63 | const SEPARATOR_LEN: usize = 3; 64 | const MIN_TITLE_LEN: usize = 20; 65 | const MIN_ARTIST_LEN: usize = 15; 66 | 67 | fn playing_title(state: &UiState, theme: &DisplayTheme, width: usize) -> Option> { 68 | let song = state.get_now_playing()?; 69 | let decorator = &state.get_decorator(); 70 | 71 | let separator = match state.is_paused() { 72 | true => Span::from(format!(" {PAUSE_ICON} ")) 73 | .fg(theme.text_primary) 74 | .rapid_blink(), 75 | false => Span::from(format!(" {decorator} ")).fg(theme.text_muted), 76 | }; 77 | 78 | let title = song.get_title().to_string(); 79 | let artist = song.get_artist().to_string(); 80 | 81 | let title_len = title.chars().count(); 82 | let artist_len = artist.chars().count(); 83 | 84 | if width >= title_len + SEPARATOR_LEN + artist_len { 85 | Some( 86 | Line::from_iter([ 87 | Span::from(title).fg(theme.text_secondary), 88 | Span::from(separator), 89 | Span::from(artist).fg(theme.text_muted), 90 | ]) 91 | .centered(), 92 | ) 93 | } else if width >= MIN_TITLE_LEN + SEPARATOR_LEN + MIN_ARTIST_LEN { 94 | let available_space = width.saturating_sub(SEPARATOR_LEN); 95 | let title_space = (available_space * 2) / 3; 96 | let artist_space = available_space.saturating_sub(title_space); 97 | 98 | let truncated_title = truncate_at_last_space(&title, title_space); 99 | let truncated_artist = truncate_at_last_space(&artist, artist_space); 100 | 101 | Some( 102 | Line::from_iter([ 103 | Span::from(truncated_title).fg(theme.text_secondary), 104 | separator, 105 | Span::from(truncated_artist).fg(theme.text_muted), 106 | ]) 107 | .centered(), 108 | ) 109 | } else { 110 | match state.is_paused() { 111 | true => { 112 | let truncated_title = truncate_at_last_space(&title, title_len - SEPARATOR_LEN); 113 | Some( 114 | Line::from_iter([ 115 | separator, 116 | Span::from(truncated_title).fg(theme.text_secondary), 117 | ]) 118 | .centered(), 119 | ) 120 | } 121 | false => { 122 | let truncated_title = truncate_at_last_space(&title, width); 123 | Some(Line::from(Span::from(truncated_title).fg(theme.text_secondary)).centered()) 124 | } 125 | } 126 | } 127 | } 128 | 129 | fn get_multi_selection(size: usize, theme: &DisplayTheme) -> Option> { 130 | let output = match size { 131 | 0 => return None, 132 | x => format!("{x:>3} {} ", SELECTED) 133 | .fg(theme.accent) 134 | .into_left_aligned_line(), 135 | }; 136 | 137 | Some(output) 138 | } 139 | 140 | const BAD_WIDTH: usize = 22; 141 | fn queue_display(state: &UiState, theme: &DisplayTheme, width: usize) -> Option> { 142 | let up_next_str = state.peek_queue()?.get_title(); 143 | 144 | // [width - 5] should produce enough room to avoid overlapping with other displays 145 | let truncated = truncate_at_last_space(up_next_str, width - 5); 146 | 147 | let up_next_line = Span::from(truncated).fg(state.theme_manager.active.selection_inactive); 148 | 149 | let total = state.playback.queue.len(); 150 | let queue_total = format!(" [{total}] ").fg(theme.text_muted); 151 | 152 | match width < BAD_WIDTH { 153 | true => Some( 154 | Line::from_iter([Span::from(QUEUE_ICON).fg(theme.text_muted), queue_total]) 155 | .right_aligned(), 156 | ), 157 | 158 | false => Some( 159 | Line::from_iter([ 160 | Span::from(QUEUE_ICON).fg(theme.text_muted), 161 | " ".into(), 162 | up_next_line, 163 | queue_total, 164 | ]) 165 | .right_aligned(), 166 | ), 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/tui/widgets/popups/root_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | strip_win_prefix, 3 | tui::widgets::SELECTOR, 4 | ui_state::{SettingsMode, UiState}, 5 | }; 6 | use ratatui::{ 7 | layout::{Constraint, Layout}, 8 | style::{Style, Stylize}, 9 | text::{Line, Text}, 10 | widgets::{ 11 | Block, BorderType, HighlightSpacing, List, Padding, Paragraph, StatefulWidget, Widget, Wrap, 12 | }, 13 | }; 14 | 15 | pub struct RootManager; 16 | impl StatefulWidget for RootManager { 17 | type State = UiState; 18 | 19 | fn render( 20 | self, 21 | area: ratatui::prelude::Rect, 22 | buf: &mut ratatui::prelude::Buffer, 23 | state: &mut Self::State, 24 | ) { 25 | let settings_mode = state.get_settings_mode(); 26 | let theme = state.theme_manager.get_display_theme(true); 27 | 28 | let padding_h = (area.height as f32 * 0.2) as u16; 29 | let padding_w = (area.width as f32 * 0.2) as u16; 30 | 31 | let title = match settings_mode { 32 | Some(SettingsMode::ViewRoots) => " Settings - Music Library Roots ", 33 | Some(SettingsMode::AddRoot) => " Add New Root Directory ", 34 | Some(SettingsMode::RemoveRoot) => " Remove Root Directory ", 35 | None => return, 36 | }; 37 | 38 | let block = Block::bordered() 39 | .border_type(theme.border_type) 40 | .border_style(theme.border) 41 | .title(title) 42 | .title_bottom(get_keymaps(settings_mode)) 43 | .title_alignment(ratatui::layout::Alignment::Center) 44 | .padding(Padding { 45 | left: padding_w, 46 | right: padding_w, 47 | top: padding_h, 48 | bottom: 0, 49 | }) 50 | .bg(theme.bg); 51 | 52 | let inner = block.inner(area); 53 | block.render(area, buf); 54 | 55 | match settings_mode { 56 | Some(SettingsMode::ViewRoots) => render_roots_list(inner, buf, state), 57 | Some(SettingsMode::AddRoot) => render_add_root(inner, buf, state), 58 | Some(SettingsMode::RemoveRoot) => render_remove_root(inner, buf, state), 59 | None => (), 60 | } 61 | } 62 | } 63 | 64 | fn get_keymaps(mode: Option<&SettingsMode>) -> &'static str { 65 | if let Some(m) = mode { 66 | match m { 67 | SettingsMode::ViewRoots => " [a]dd / [d]elete / [Esc] close ", 68 | SettingsMode::AddRoot => " [Enter] confirm / [Esc] cancel ", 69 | SettingsMode::RemoveRoot => " [Enter] confirm / [Esc] cancel ", 70 | } 71 | } else { 72 | unreachable!() 73 | } 74 | } 75 | 76 | fn render_roots_list( 77 | area: ratatui::prelude::Rect, 78 | buf: &mut ratatui::prelude::Buffer, 79 | state: &mut UiState, 80 | ) { 81 | let roots = state.get_roots(); 82 | let theme = state.theme_manager.get_display_theme(true); 83 | 84 | if roots.is_empty() { 85 | Paragraph::new("No music library configured.\nPress 'a' to add a parent directory.") 86 | .wrap(Wrap { trim: true }) 87 | .centered() 88 | .render(area, buf); 89 | return; 90 | } 91 | 92 | let items: Vec = roots 93 | .iter() 94 | .map(|r| { 95 | let root = strip_win_prefix(r); 96 | Line::from(root) 97 | }) 98 | .collect(); 99 | 100 | let list = List::new(items) 101 | .fg(state.theme_manager.active.text_muted) 102 | .highlight_symbol(SELECTOR) 103 | .highlight_style(Style::new().fg(theme.selection)) 104 | .highlight_spacing(HighlightSpacing::Always); 105 | 106 | ratatui::prelude::StatefulWidget::render(list, area, buf, &mut state.popup.selection); 107 | } 108 | 109 | fn render_add_root( 110 | area: ratatui::prelude::Rect, 111 | buf: &mut ratatui::prelude::Buffer, 112 | state: &mut UiState, 113 | ) { 114 | let chunks = Layout::vertical([ 115 | Constraint::Max(3), 116 | Constraint::Length(3), 117 | Constraint::Fill(1), 118 | ]) 119 | .split(area); 120 | 121 | Paragraph::new("Enter the path to a directory containing music files:") 122 | .fg(state.theme_manager.active.accent) 123 | .wrap(Wrap { trim: true }) 124 | .render(chunks[0], buf); 125 | 126 | let theme = state.theme_manager.get_display_theme(true); 127 | 128 | state.popup.input.set_block( 129 | Block::bordered() 130 | .border_type(BorderType::Rounded) 131 | .fg(theme.accent) 132 | .padding(Padding { 133 | left: 1, 134 | right: 1, 135 | top: 0, 136 | bottom: 0, 137 | }), 138 | ); 139 | 140 | state 141 | .popup 142 | .input 143 | .set_style(Style::new().fg(theme.text_primary)); 144 | 145 | state.popup.input.render(chunks[1], buf); 146 | 147 | let example = Paragraph::new("Ex: C:\\Music or ~/music/albums") 148 | .fg(theme.text_muted) 149 | .centered(); 150 | example.render(chunks[2], buf); 151 | } 152 | 153 | fn render_remove_root( 154 | area: ratatui::prelude::Rect, 155 | buf: &mut ratatui::prelude::Buffer, 156 | state: &UiState, 157 | ) { 158 | let theme = state.theme_manager.get_display_theme(true); 159 | let roots = state.get_roots(); 160 | 161 | if roots.is_empty() { 162 | Paragraph::new("No root selected") 163 | .centered() 164 | .render(area, buf); 165 | return; 166 | } 167 | let selected_root = &roots[state.popup.selection.selected().unwrap()]; 168 | let selected_root = strip_win_prefix(&selected_root); 169 | 170 | let text = Text::from_iter([ 171 | Line::from("Are you sure you want to delete:"), 172 | Line::default(), 173 | selected_root.fg(theme.accent).into(), 174 | Line::default(), 175 | "This will remove all songs from this directory from your library." 176 | .fg(theme.text_muted) 177 | .into(), 178 | ]); 179 | 180 | let warning = Paragraph::new(text) 181 | .wrap(Wrap { trim: true }) 182 | .centered() 183 | .fg(theme.text_secondary); 184 | 185 | warning.render(area, buf); 186 | } 187 | -------------------------------------------------------------------------------- /src/ui_state/theme/theme_manager.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | 3 | use crate::{ 4 | CONFIG_DIRECTORY, THEME_DIRECTORY, 5 | key_handler::MoveDirection, 6 | ui_state::{DisplayTheme, PopupType, ThemeConfig, UiState, fade_color}, 7 | }; 8 | 9 | pub struct ThemeManager { 10 | pub active: ThemeConfig, 11 | pub cached_focused: DisplayTheme, 12 | pub cached_unfocused: DisplayTheme, 13 | 14 | pub theme_lib: Vec, 15 | } 16 | 17 | impl ThemeManager { 18 | pub fn new() -> Self { 19 | let theme_lib = Self::collect_themes(); 20 | let active = theme_lib.first().cloned().unwrap_or_default(); 21 | 22 | let cached_focused = Self::set_display_theme(&active, true); 23 | let cached_unfocused = Self::set_display_theme(&active, false); 24 | 25 | ThemeManager { 26 | active, 27 | theme_lib, 28 | cached_focused, 29 | cached_unfocused, 30 | } 31 | } 32 | 33 | pub fn set_theme(&mut self, theme: ThemeConfig) { 34 | self.cached_focused = Self::set_display_theme(&theme, true); 35 | self.cached_unfocused = Self::set_display_theme(&theme, false); 36 | self.active = theme; 37 | } 38 | 39 | pub fn get_display_theme(&self, focus: bool) -> &DisplayTheme { 40 | match focus { 41 | true => &self.cached_focused, 42 | false => &self.cached_unfocused, 43 | } 44 | } 45 | 46 | pub fn get_themes(&self) -> Vec { 47 | self.theme_lib.clone() 48 | } 49 | 50 | pub fn update_themes(&mut self) { 51 | let themes = Self::collect_themes(); 52 | self.theme_lib = themes 53 | } 54 | 55 | pub fn find_theme_by_name(&self, name: &str) -> Option<&ThemeConfig> { 56 | self.theme_lib.iter().find(|t| t.name == name) 57 | } 58 | 59 | pub fn get_current_theme_index(&self) -> Option { 60 | self.theme_lib 61 | .iter() 62 | .position(|t| t.name == self.active.name) 63 | } 64 | 65 | pub fn get_theme_at_index(&self, idx: usize) -> Option { 66 | self.theme_lib.get(idx).cloned() 67 | } 68 | 69 | fn collect_themes() -> Vec { 70 | let mut themes = vec![]; 71 | let theme_dir = 72 | dirs::config_dir().map(|dir| dir.join(CONFIG_DIRECTORY).join(THEME_DIRECTORY)); 73 | 74 | if let Some(ref theme_path) = theme_dir { 75 | let _ = std::fs::create_dir_all(theme_path); 76 | 77 | if let Ok(entries) = theme_path.read_dir() { 78 | for entry in entries.flatten() { 79 | let path = entry.path(); 80 | 81 | if path.extension().and_then(|s| s.to_str()) == Some("toml") { 82 | if let Ok(theme) = ThemeConfig::load_from_file(&path) { 83 | themes.push(theme); 84 | } 85 | } 86 | } 87 | } 88 | } 89 | themes 90 | } 91 | 92 | fn set_display_theme(theme: &ThemeConfig, focused: bool) -> DisplayTheme { 93 | let is_dark = theme.is_dark; 94 | 95 | match focused { 96 | true => DisplayTheme { 97 | dark: theme.is_dark, 98 | bg: theme.surface_active, 99 | bg_global: theme.surface_global, 100 | bg_error: theme.surface_error, 101 | 102 | text_primary: theme.text_primary, 103 | text_secondary: theme.text_secondary, 104 | text_muted: theme.text_muted, 105 | text_selected: theme.text_selection, 106 | 107 | selection: theme.selection, 108 | 109 | accent: theme.accent, 110 | 111 | border: theme.border_active, 112 | border_display: theme.border_display, 113 | border_type: theme.border_type, 114 | 115 | progress_complete: theme.progress.clone(), 116 | progress_incomplete: theme.progress_i.clone(), 117 | progress_speed: theme.progress_speed, 118 | }, 119 | 120 | false => DisplayTheme { 121 | dark: theme.is_dark, 122 | bg: theme.surface_inactive, 123 | bg_global: theme.surface_global, 124 | bg_error: theme.surface_error, 125 | 126 | text_primary: theme.text_muted, 127 | text_secondary: theme.text_secondary_in, 128 | text_muted: fade_color(is_dark, theme.text_muted, 0.7), 129 | text_selected: theme.text_selection, 130 | 131 | selection: theme.selection_inactive, 132 | accent: theme.accent_inactive, 133 | 134 | border: theme.border_inactive, 135 | border_display: theme.border_display, 136 | border_type: theme.border_type, 137 | 138 | progress_complete: theme.progress.clone(), 139 | progress_incomplete: theme.progress_i.clone(), 140 | progress_speed: theme.progress_speed, 141 | }, 142 | } 143 | } 144 | } 145 | 146 | impl UiState { 147 | pub fn refresh_current_theme(&mut self) { 148 | self.theme_manager.update_themes(); 149 | 150 | match self.theme_manager.get_current_theme_index() { 151 | Some(idx) => { 152 | let theme = self 153 | .theme_manager 154 | .get_theme_at_index(idx) 155 | .unwrap_or_default(); 156 | self.theme_manager.set_theme(theme); 157 | } 158 | _ => self.set_error(anyhow!( 159 | "Formatting error in theme!\n\nFalling back to last loaded" 160 | )), 161 | } 162 | } 163 | 164 | pub fn open_theme_manager(&mut self) { 165 | self.theme_manager.update_themes(); 166 | 167 | if let Some(idx) = self.theme_manager.get_current_theme_index() { 168 | let theme = self 169 | .theme_manager 170 | .get_theme_at_index(idx) 171 | .unwrap_or_default(); 172 | 173 | self.theme_manager.set_theme(theme); 174 | self.popup.selection.select(Some(idx)); 175 | } 176 | 177 | self.show_popup(PopupType::ThemeManager); 178 | } 179 | 180 | pub fn cycle_theme(&mut self, dir: MoveDirection) { 181 | let len = self.theme_manager.theme_lib.len(); 182 | if len < 2 { 183 | return; 184 | } 185 | 186 | let idx = self.theme_manager.get_current_theme_index().unwrap_or(0); 187 | let new_idx = match dir { 188 | MoveDirection::Up => (idx + len - 1) % len, 189 | MoveDirection::Down => (idx + 1) % len, 190 | }; 191 | 192 | self.theme_manager.set_theme( 193 | self.theme_manager 194 | .theme_lib 195 | .get(new_idx) 196 | .cloned() 197 | .unwrap_or_default(), 198 | ); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/ui_state/ui_snapshot.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::ui_state::ProgressDisplay; 4 | 5 | use super::{AlbumSort, Mode, Pane, UiState}; 6 | 7 | #[derive(Default)] 8 | pub struct UiSnapshot { 9 | pub mode: String, 10 | pub pane: String, 11 | pub album_sort: String, 12 | pub sidebar_percentage: u16, 13 | 14 | pub theme_name: String, 15 | 16 | pub song_selection: Option, 17 | pub album_selection: Option, 18 | pub playlist_selection: Option, 19 | 20 | pub song_sel_offset: usize, 21 | pub album_sel_offset: usize, 22 | pub playlist_sel_offset: usize, 23 | 24 | pub progress_display: String, 25 | pub smoothing_factor: f32, 26 | } 27 | 28 | impl UiSnapshot { 29 | pub fn to_pairs(&self) -> Vec<(&'static str, String)> { 30 | let mut pairs = vec![ 31 | ("ui_mode", self.mode.clone()), 32 | ("ui_pane", self.pane.clone()), 33 | ("ui_album_sort", self.album_sort.clone()), 34 | ("ui_theme", self.theme_name.clone()), 35 | ("ui_smooth", format!("{:.1}", self.smoothing_factor)), 36 | ("ui_sidebar_percent", self.sidebar_percentage.to_string()), 37 | ("ui_progress_display", self.progress_display.to_string()), 38 | ]; 39 | 40 | if let Some(pos) = self.album_selection { 41 | pairs.push(("ui_album_pos", pos.to_string())); 42 | pairs.push(("ui_album_offset", self.album_sel_offset.to_string())) 43 | } 44 | 45 | if let Some(pos) = self.playlist_selection { 46 | pairs.push(("ui_playlist_pos", pos.to_string())); 47 | pairs.push(("ui_playlist_offset", self.playlist_sel_offset.to_string())) 48 | } 49 | 50 | if let Some(pos) = self.song_selection { 51 | pairs.push(("ui_song_pos", pos.to_string())); 52 | pairs.push(("ui_song_offset", self.song_sel_offset.to_string())) 53 | } 54 | 55 | pairs 56 | } 57 | 58 | pub fn from_values(values: Vec<(String, String)>) -> Self { 59 | let mut snapshot = UiSnapshot::default(); 60 | 61 | for (key, value) in values { 62 | match key.as_str() { 63 | "ui_mode" => snapshot.mode = value, 64 | "ui_pane" => snapshot.pane = value, 65 | "ui_progress_display" => snapshot.progress_display = value, 66 | "ui_theme" => snapshot.theme_name = value, 67 | "ui_album_sort" => snapshot.album_sort = value, 68 | "ui_album_pos" => snapshot.album_selection = value.parse().ok(), 69 | "ui_playlist_pos" => snapshot.playlist_selection = value.parse().ok(), 70 | "ui_album_offset" => snapshot.album_sel_offset = value.parse().unwrap_or(0), 71 | "ui_playlist_offset" => snapshot.playlist_sel_offset = value.parse().unwrap_or(0), 72 | "ui_song_pos" => snapshot.song_selection = value.parse().ok(), 73 | "ui_song_offset" => snapshot.song_sel_offset = value.parse::().unwrap_or(0), 74 | "ui_smooth" => snapshot.smoothing_factor = value.parse::().unwrap_or(1.0), 75 | "ui_sidebar_percent" => { 76 | snapshot.sidebar_percentage = value.parse::().unwrap_or(30) 77 | } 78 | _ => {} 79 | } 80 | } 81 | 82 | snapshot 83 | } 84 | } 85 | 86 | impl UiState { 87 | pub fn create_snapshot(&self) -> UiSnapshot { 88 | let orig_pane = self.get_pane(); 89 | let pane = match orig_pane { 90 | Pane::Popup => &self.popup.cached, 91 | _ => orig_pane, 92 | }; 93 | 94 | UiSnapshot { 95 | mode: self.get_mode().to_string(), 96 | pane: pane.to_string(), 97 | album_sort: self.display_state.album_sort.to_string(), 98 | sidebar_percentage: self.display_state.sidebar_percent, 99 | 100 | theme_name: self.theme_manager.active.name.to_owned(), 101 | 102 | song_selection: self.display_state.table_pos.selected(), 103 | album_selection: self.display_state.album_pos.selected(), 104 | playlist_selection: self.display_state.playlist_pos.selected(), 105 | 106 | song_sel_offset: self.display_state.table_pos.offset(), 107 | album_sel_offset: self.display_state.album_pos.offset(), 108 | playlist_sel_offset: self.display_state.playlist_pos.offset(), 109 | 110 | progress_display: self.get_progress_display().to_string(), 111 | smoothing_factor: self.playback_view.waveform_smoothing, 112 | } 113 | } 114 | 115 | pub fn save_state(&self) -> Result<()> { 116 | let snapshot = self.create_snapshot(); 117 | self.db_worker.save_ui_snapshot(snapshot)?; 118 | Ok(()) 119 | } 120 | 121 | pub fn restore_state(&mut self) -> Result<()> { 122 | // The order of these function calls is particularly important 123 | if let Some(snapshot) = self.db_worker.load_ui_snapshot()? { 124 | self.display_state.album_sort = AlbumSort::from_str(&snapshot.album_sort); 125 | 126 | self.sort_albums(); 127 | 128 | if !snapshot.theme_name.is_empty() { 129 | if let Some(theme) = self.theme_manager.find_theme_by_name(&snapshot.theme_name) { 130 | self.theme_manager.set_theme(theme.clone()); 131 | } 132 | } 133 | 134 | if let Some(pos) = snapshot.album_selection { 135 | if pos < self.albums.len() { 136 | self.display_state.album_pos.select(Some(pos)); 137 | *self.display_state.album_pos.offset_mut() = snapshot.album_sel_offset 138 | } 139 | } 140 | 141 | if let Some(pos) = snapshot.playlist_selection { 142 | if pos < self.playlists.len() { 143 | self.display_state.playlist_pos.select(Some(pos)); 144 | *self.display_state.playlist_pos.offset_mut() = snapshot.playlist_sel_offset 145 | } 146 | } 147 | 148 | // Do not restore to queue or search mode 149 | let mode_to_restore = match snapshot.mode.as_str() { 150 | "search" | "queue" => "library_album", 151 | _ => &snapshot.mode, 152 | }; 153 | 154 | let pane_to_restore = match snapshot.pane.as_str() { 155 | "search" => "tracklist", 156 | _ => &snapshot.pane, 157 | }; 158 | 159 | self.set_mode(Mode::from_str(mode_to_restore)); 160 | self.set_pane(Pane::from_str(pane_to_restore)); 161 | 162 | self.playback_view.waveform_smoothing = snapshot.smoothing_factor; 163 | 164 | self.set_progress_display(ProgressDisplay::from_str(&snapshot.progress_display)); 165 | 166 | self.display_state.sidebar_percent = snapshot.sidebar_percentage; 167 | 168 | if let Some(pos) = snapshot.song_selection { 169 | if pos < self.legal_songs.len() { 170 | self.display_state.table_pos.select(Some(pos)); 171 | *self.display_state.table_pos.offset_mut() = snapshot.song_sel_offset 172 | } 173 | } 174 | } 175 | 176 | Ok(()) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/ui_state/playlist.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | domain::{Playlist, PlaylistSong}, 3 | ui_state::{LibraryView, PopupType, UiState}, 4 | }; 5 | use anyhow::{Result, anyhow, bail}; 6 | 7 | #[derive(PartialEq, Clone)] 8 | pub enum PlaylistAction { 9 | Create, 10 | AddSong, 11 | Delete, 12 | Rename, 13 | CreateWithSongs, 14 | } 15 | 16 | impl UiState { 17 | pub fn get_playlists(&mut self) -> Result<()> { 18 | let playlist_db = self.db_worker.build_playlists()?; 19 | let songs_map = self.library.get_songs_map(); 20 | 21 | self.playlists = playlist_db 22 | .iter() 23 | .map(|((id, name), track_ids)| { 24 | let tracklist = track_ids 25 | .iter() 26 | .filter_map(|&s_id| { 27 | let ps_id = s_id.0; 28 | let simple_song = songs_map.get(&s_id.1)?.clone(); 29 | 30 | Some(PlaylistSong { 31 | id: ps_id, 32 | song: simple_song, 33 | }) 34 | }) 35 | .collect::>(); 36 | 37 | Playlist::new(*id, name.to_string(), tracklist) 38 | }) 39 | .collect(); 40 | 41 | Ok(()) 42 | } 43 | 44 | pub fn create_playlist_popup(&mut self) { 45 | if self.get_sidebar_view() == &LibraryView::Playlists { 46 | self.show_popup(PopupType::Playlist(PlaylistAction::Create)); 47 | } 48 | } 49 | 50 | pub fn create_playlist(&mut self) -> Result<()> { 51 | let name = self.get_popup_string(); 52 | 53 | if name.is_empty() { 54 | bail!("Playlist name cannot be empty!"); 55 | } 56 | 57 | if self 58 | .playlists 59 | .iter() 60 | .any(|p| p.name.to_lowercase() == name.to_lowercase()) 61 | { 62 | bail!("Playlist name already exists!"); 63 | } 64 | 65 | self.db_worker.create_playlist(name)?; 66 | 67 | self.get_playlists()?; 68 | 69 | if !self.playlists.is_empty() { 70 | self.display_state.playlist_pos.select_first(); 71 | } 72 | 73 | self.set_legal_songs(); 74 | self.close_popup(); 75 | Ok(()) 76 | } 77 | 78 | pub fn rename_playlist_popup(&mut self) { 79 | if self.get_selected_playlist().is_some() { 80 | self.show_popup(PopupType::Playlist(PlaylistAction::Rename)); 81 | } 82 | } 83 | 84 | pub fn rename_playlist(&mut self) -> Result<()> { 85 | let playlist = self 86 | .get_selected_playlist() 87 | .ok_or_else(|| anyhow!("No playlist selected!"))?; 88 | 89 | let new_name = self.get_popup_string(); 90 | 91 | if new_name.is_empty() { 92 | bail!("Playlist name cannot be empty!"); 93 | } 94 | 95 | if self 96 | .playlists 97 | .iter() 98 | .filter(|p| p.id != playlist.id) 99 | .any(|p| p.name.to_lowercase() == new_name.to_lowercase()) 100 | { 101 | bail!("Playlist name already exists!"); 102 | } 103 | 104 | self.db_worker.rename_playlist(playlist.id, new_name)?; 105 | 106 | self.get_playlists()?; 107 | 108 | if !self.playlists.is_empty() { 109 | self.display_state.playlist_pos.select_first(); 110 | } 111 | 112 | self.close_popup(); 113 | Ok(()) 114 | } 115 | 116 | pub fn delete_playlist_popup(&mut self) { 117 | if self.get_selected_playlist().is_some() { 118 | self.show_popup(PopupType::Playlist(PlaylistAction::Delete)) 119 | } 120 | } 121 | 122 | pub fn delete_playlist(&mut self) -> Result<()> { 123 | let current_playlist = self.display_state.playlist_pos.selected(); 124 | // let playlist_len = 125 | 126 | if let Some(idx) = current_playlist { 127 | let playlist_id = self.playlists[idx].id; 128 | self.db_worker.delete_playlist(playlist_id)?; 129 | 130 | self.get_playlists()?; 131 | self.set_legal_songs(); 132 | } 133 | 134 | if self.playlists.is_empty() { 135 | self.display_state.playlist_pos.select(None); 136 | self.legal_songs.clear(); 137 | } 138 | 139 | self.close_popup(); 140 | 141 | Ok(()) 142 | } 143 | 144 | pub fn add_to_playlist_popup(&mut self) { 145 | if self.legal_songs.len() == 0 { 146 | return; 147 | } 148 | self.popup.selection.select_first(); 149 | self.show_popup(PopupType::Playlist(PlaylistAction::AddSong)); 150 | } 151 | 152 | pub fn add_to_playlist(&mut self) -> Result<()> { 153 | match self.popup.selection.selected() { 154 | Some(playlist_idx) => { 155 | let playlist_id = self.playlists.get(playlist_idx).unwrap().id; 156 | match self.multi_select_empty() { 157 | true => { 158 | let song_id = self.get_selected_song()?.id; 159 | 160 | self.db_worker.add_to_playlist(song_id, playlist_id)?; 161 | } 162 | false => { 163 | let song_ids = self 164 | .get_multi_select_songs() 165 | .iter() 166 | .map(|s| s.id) 167 | .collect::>(); 168 | 169 | self.db_worker 170 | .add_to_playlist_multi(song_ids, playlist_id)?; 171 | self.clear_multi_select(); 172 | } 173 | } 174 | self.close_popup() 175 | } 176 | None => bail!("Could not add to playlist"), 177 | }; 178 | 179 | self.get_playlists()?; 180 | self.set_legal_songs(); 181 | 182 | Ok(()) 183 | } 184 | 185 | pub fn create_playlist_with_songs_popup(&mut self) { 186 | self.show_popup(PopupType::Playlist(PlaylistAction::CreateWithSongs)); 187 | } 188 | 189 | pub fn create_playlist_with_songs(&mut self) -> Result<()> { 190 | let name = self.get_popup_string(); 191 | 192 | if name.is_empty() { 193 | bail!("Playlist name cannot be empty!"); 194 | } 195 | 196 | if self 197 | .playlists 198 | .iter() 199 | .any(|p| p.name.to_lowercase() == name.to_lowercase()) 200 | { 201 | bail!("Playlist name already exists!"); 202 | } 203 | 204 | self.db_worker.create_playlist(name)?; 205 | self.get_playlists()?; 206 | 207 | if let Some(new_playlist) = self.playlists.first() { 208 | let playlist_id = new_playlist.id; 209 | 210 | if !self.multi_select_empty() { 211 | let song_ids = self 212 | .get_multi_select_songs() 213 | .iter() 214 | .map(|s| s.id) 215 | .collect::>(); 216 | self.db_worker 217 | .add_to_playlist_multi(song_ids, playlist_id)?; 218 | self.clear_multi_select(); 219 | } else if let Ok(song) = self.get_selected_song() { 220 | self.db_worker.add_to_playlist(song.id, playlist_id)?; 221 | } 222 | 223 | self.get_playlists()?; 224 | } 225 | 226 | self.set_legal_songs(); 227 | self.close_popup(); 228 | Ok(()) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/tui/widgets/popups/playlist_popup.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | tui::widgets::POPUP_PADDING, 3 | ui_state::{Pane, PlaylistAction, PopupType, UiState, fade_color}, 4 | }; 5 | use ratatui::{ 6 | layout::{Alignment, Constraint, Layout}, 7 | style::{Style, Stylize}, 8 | text::{Line, Span, Text}, 9 | widgets::{Block, BorderType, List, Padding, Paragraph, StatefulWidget, Widget, Wrap}, 10 | }; 11 | 12 | pub struct PlaylistPopup; 13 | impl StatefulWidget for PlaylistPopup { 14 | type State = UiState; 15 | 16 | fn render( 17 | self, 18 | area: ratatui::prelude::Rect, 19 | buf: &mut ratatui::prelude::Buffer, 20 | state: &mut Self::State, 21 | ) { 22 | if let PopupType::Playlist(action) = &state.popup.current { 23 | match action { 24 | PlaylistAction::Create | PlaylistAction::CreateWithSongs => { 25 | render_create_popup(area, buf, state) 26 | } 27 | PlaylistAction::AddSong => render_add_song_popup(area, buf, state), 28 | PlaylistAction::Delete => render_delete_popup(area, buf, state), 29 | PlaylistAction::Rename => render_rename_popup(area, buf, state), 30 | } 31 | } 32 | } 33 | } 34 | 35 | fn render_create_popup( 36 | area: ratatui::prelude::Rect, 37 | buf: &mut ratatui::prelude::Buffer, 38 | state: &mut UiState, 39 | ) { 40 | let focus = matches!(state.get_pane(), Pane::Popup); 41 | let theme = state.theme_manager.get_display_theme(focus); 42 | let padding_h = (area.height as f32 * 0.3) as u16; 43 | let padding_w = (area.width as f32 * 0.2) as u16; 44 | 45 | let block = Block::bordered() 46 | .border_type(theme.border_type) 47 | .border_style(theme.border) 48 | .title(" Create New Playlist ") 49 | .title_bottom(" [Enter] confirm / [Esc] cancel ") 50 | .title_alignment(ratatui::layout::Alignment::Center) 51 | .padding(Padding { 52 | left: padding_w, 53 | right: padding_w, 54 | top: padding_h, 55 | bottom: 0, 56 | }) 57 | .fg(theme.accent) 58 | .bg(theme.bg); 59 | 60 | let inner = block.inner(area); 61 | block.render(area, buf); 62 | 63 | let chunks = Layout::vertical([Constraint::Max(2), Constraint::Length(3)]).split(inner); 64 | 65 | Paragraph::new("Enter playlist title: ") 66 | .centered() 67 | .render(chunks[0], buf); 68 | 69 | state.popup.input.set_block( 70 | Block::bordered() 71 | .border_type(BorderType::Rounded) 72 | .padding(Padding::horizontal(2)), 73 | ); 74 | state 75 | .popup 76 | .input 77 | .set_style(Style::new().fg(theme.text_primary)); 78 | state.popup.input.render(chunks[1], buf); 79 | } 80 | 81 | fn render_add_song_popup( 82 | area: ratatui::prelude::Rect, 83 | buf: &mut ratatui::prelude::Buffer, 84 | state: &mut UiState, 85 | ) { 86 | let focus = matches!(state.get_pane(), Pane::Popup); 87 | let theme = state.theme_manager.get_display_theme(focus); 88 | let list_items = state 89 | .playlists 90 | .iter() 91 | .map(|p| { 92 | let playlist_name = p.name.to_string(); 93 | Line::from(playlist_name) 94 | .fg(fade_color(theme.dark, theme.text_muted, 0.85)) 95 | .centered() 96 | }) 97 | .collect::>(); 98 | 99 | let block = Block::bordered() 100 | .border_type(theme.border_type) 101 | .border_style(theme.border) 102 | .title(" Add To Playlist ") 103 | .title_bottom(" [Enter] / [c]reate playlist / [Esc] ") 104 | .title_alignment(ratatui::layout::Alignment::Center) 105 | .padding(POPUP_PADDING) 106 | .bg(theme.bg); 107 | 108 | if list_items.is_empty() { 109 | state.popup.selection.select(None); 110 | return Paragraph::new("\nThere are no playlists!\n\nCreate a playlist by pressing [c]") 111 | .alignment(Alignment::Center) 112 | .wrap(Wrap { trim: true }) 113 | .block(block.clone()) 114 | .fg(theme.accent) 115 | .render(area, buf); 116 | } 117 | 118 | let list = List::new(list_items) 119 | .block(block) 120 | .scroll_padding(area.height as usize - 5) 121 | .highlight_style(theme.selection); 122 | 123 | StatefulWidget::render(list, area, buf, &mut state.popup.selection); 124 | } 125 | 126 | fn render_delete_popup( 127 | area: ratatui::prelude::Rect, 128 | buf: &mut ratatui::prelude::Buffer, 129 | state: &mut UiState, 130 | ) { 131 | let focus = matches!(state.get_pane(), Pane::Popup); 132 | let theme = state.theme_manager.get_display_theme(focus); 133 | let block = Block::bordered() 134 | .border_type(theme.border_type) 135 | .border_style(theme.border) 136 | .title(format!(" Delete Playlist ")) 137 | .title_bottom(" [Enter] confirm / [Esc] cancel ") 138 | .title_alignment(ratatui::layout::Alignment::Center) 139 | .padding(Padding { 140 | left: 5, 141 | right: 5, 142 | top: (area.height as f32 * 0.35) as u16, 143 | bottom: 0, 144 | }) 145 | .fg(theme.text_primary) 146 | .bg(theme.bg); 147 | 148 | if let Some(p) = state.get_selected_playlist() { 149 | let p_name = Line::from_iter([p.name.as_str(), " ?".into()]); 150 | let warning = Paragraph::new(Text::from_iter([ 151 | format!("Are you sure you want to delete\n").into(), 152 | p_name, 153 | ])) 154 | .block(block) 155 | .wrap(Wrap { trim: true }) 156 | .centered(); 157 | warning.render(area, buf); 158 | }; 159 | } 160 | 161 | fn render_rename_popup( 162 | area: ratatui::prelude::Rect, 163 | buf: &mut ratatui::prelude::Buffer, 164 | state: &mut UiState, 165 | ) { 166 | let focus = matches!(state.get_pane(), Pane::Popup); 167 | let theme = state.theme_manager.get_display_theme(focus); 168 | let padding_h = (area.height as f32 * 0.25) as u16; 169 | let padding_w = (area.width as f32 * 0.2) as u16; 170 | 171 | let block = Block::bordered() 172 | .title(" Rename Playlist ") 173 | .title_bottom(" [Enter] confirm / [Esc] cancel ") 174 | .title_alignment(Alignment::Center) 175 | .border_type(theme.border_type) 176 | .border_style(theme.border) 177 | .fg(theme.text_primary) 178 | .bg(theme.bg) 179 | .padding(Padding { 180 | left: padding_w, 181 | right: padding_w, 182 | top: padding_h, 183 | bottom: 0, 184 | }); 185 | 186 | let inner = block.inner(area); 187 | block.render(area, buf); 188 | 189 | let chunks = Layout::vertical([Constraint::Max(3), Constraint::Length(3)]).split(inner); 190 | 191 | if let Some(playlist) = state.get_selected_playlist() { 192 | let p_name = Span::from(playlist.name.as_str()); 193 | Paragraph::new(Text::from_iter([ 194 | format!("Enter a new name for\n").into(), 195 | p_name, 196 | ])) 197 | .centered() 198 | .render(chunks[0], buf); 199 | 200 | state.popup.input.set_block( 201 | Block::bordered() 202 | .border_type(BorderType::Rounded) 203 | .padding(Padding::horizontal(2)), 204 | ); 205 | 206 | state 207 | .popup 208 | .input 209 | .set_style(Style::new().fg(theme.text_primary)); 210 | state.popup.input.render(chunks[1], buf); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow, bail}; 2 | use indexmap::IndexMap; 3 | use nohash_hasher::BuildNoHashHasher; 4 | use ratatui::crossterm::{ 5 | ExecutableCommand, 6 | cursor::MoveToColumn, 7 | style::Print, 8 | terminal::{Clear, ClearType}, 9 | }; 10 | use std::{ 11 | fs, 12 | io::Write, 13 | path::{Path, PathBuf}, 14 | sync::Arc, 15 | time::{Duration, UNIX_EPOCH}, 16 | }; 17 | use ui_state::UiState; 18 | use unicode_normalization::UnicodeNormalization; 19 | use xxhash_rust::xxh3::xxh3_64; 20 | 21 | pub mod app_core; 22 | pub mod database; 23 | pub mod domain; 24 | pub mod key_handler; 25 | pub mod library; 26 | pub mod player; 27 | pub mod tui; 28 | pub mod ui_state; 29 | 30 | pub use database::Database; 31 | pub use library::Library; 32 | pub use player::Player; 33 | 34 | use crate::domain::SimpleSong; 35 | 36 | pub type SongMap = IndexMap, BuildNoHashHasher>; 37 | 38 | // ~120fps 39 | pub const REFRESH_RATE: Duration = Duration::from_millis(8); 40 | pub const CONFIG_DIRECTORY: &'static str = "concertus"; 41 | pub const THEME_DIRECTORY: &'static str = "themes"; 42 | pub const DATABASE_FILENAME: &'static str = "concertus.db"; 43 | 44 | /// Create a hash based on... 45 | /// - date of last modification (millis) 46 | /// - file size (bytes) 47 | /// - path as str as bytes 48 | pub fn calculate_signature>(path: P) -> anyhow::Result { 49 | let metadata = fs::metadata(&path)?; 50 | 51 | let last_mod = metadata.modified()?.duration_since(UNIX_EPOCH)?.as_millis() as i64; 52 | let size = metadata.len(); 53 | 54 | let mut data = Vec::with_capacity(path.as_ref().as_os_str().len() + 16); 55 | 56 | data.extend_from_slice(path.as_ref().as_os_str().as_encoded_bytes()); 57 | data.extend_from_slice(&last_mod.to_le_bytes()); 58 | data.extend_from_slice(&size.to_le_bytes()); 59 | 60 | Ok(xxh3_64(&data)) 61 | } 62 | 63 | pub enum DurationStyle { 64 | Clean, 65 | CleanMillis, 66 | Compact, 67 | CompactMillis, 68 | } 69 | 70 | pub fn get_readable_duration(duration: Duration, style: DurationStyle) -> String { 71 | let mut secs = duration.as_secs(); 72 | let millis = duration.subsec_millis() % 100; 73 | let hours = secs / 3600; 74 | let mins = (secs % 3600) / 60; 75 | secs %= 60; 76 | 77 | match style { 78 | DurationStyle::Clean => match hours { 79 | 0 => match mins { 80 | 0 => format!("{secs:02}s"), 81 | _ => format!("{mins}m {secs:02}s"), 82 | }, 83 | _ => format!("{hours}h {mins}m {secs:02}s"), 84 | }, 85 | DurationStyle::CleanMillis => match hours { 86 | 0 => match mins { 87 | 0 => format!("{secs:02}s {millis:03}ms"), 88 | _ => format!("{mins}m {secs:02}sec {millis:02}ms"), 89 | }, 90 | _ => format!("{hours}h {mins}m {secs:02}sec {millis:02}ms"), 91 | }, 92 | DurationStyle::Compact => match hours { 93 | 0 => format!("{mins}:{secs:02}"), 94 | _ => format!("{hours}:{mins:02}:{secs:02}"), 95 | }, 96 | DurationStyle::CompactMillis => match hours { 97 | 0 => format!("{mins}:{secs:02}.{millis:02}"), 98 | _ => format!("{hours}:{mins:02}:{secs:02}.{millis:02}"), 99 | }, 100 | } 101 | } 102 | 103 | fn truncate_at_last_space(s: &str, limit: usize) -> String { 104 | if s.chars().count() <= limit { 105 | return s.to_string(); 106 | } 107 | 108 | let byte_limit = s 109 | .char_indices() 110 | .map(|(i, _)| i) 111 | .nth(limit) 112 | .unwrap_or(s.len()); 113 | 114 | match s[..byte_limit].rfind(' ') { 115 | Some(last_space) => { 116 | let mut truncated = s[..last_space].to_string(); 117 | truncated.push('…'); 118 | truncated 119 | } 120 | None => { 121 | let char_boundary = s[..byte_limit] 122 | .char_indices() 123 | .map(|(i, _)| i) 124 | .last() 125 | .unwrap_or(0); 126 | 127 | let mut truncated = s[..char_boundary].to_string(); 128 | truncated.push('…'); 129 | truncated 130 | } 131 | } 132 | } 133 | 134 | pub fn normalize_metadata_str(s: &str) -> String { 135 | s.nfc() 136 | .filter(|c| match c { 137 | '\0' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{00AD}' | '\u{2028}' | '\u{2029}' => { 138 | false 139 | } 140 | '\n' | '\t' => true, 141 | c if c.is_control() => false, 142 | _ => true, 143 | }) 144 | .collect::() 145 | .trim() // Only once! 146 | .to_string() 147 | } 148 | 149 | pub fn strip_win_prefix(path: &str) -> String { 150 | let path_str = path.to_string(); 151 | path_str 152 | .strip_prefix(r"\\?\") 153 | .unwrap_or(&path_str) 154 | .to_string() 155 | } 156 | 157 | pub fn overwrite_line(message: &str) { 158 | let mut stdout = std::io::stdout(); 159 | stdout 160 | .execute(MoveToColumn(0)) 161 | .unwrap() 162 | .execute(Clear(ClearType::CurrentLine)) 163 | .unwrap() 164 | .execute(Print(message)) 165 | .unwrap(); 166 | stdout.flush().unwrap(); 167 | } 168 | 169 | pub fn expand_tilde>(path: P) -> Result { 170 | let path = path.as_ref(); 171 | let path_str = path.to_string_lossy(); 172 | 173 | if !path_str.starts_with('~') { 174 | return Ok(path.to_path_buf()); 175 | } 176 | 177 | if path_str == "~" { 178 | bail!( 179 | "Setting the home directory would read every file in your system. Please provide a more specific path!" 180 | ); 181 | } 182 | 183 | if path_str.starts_with("~") || path_str.starts_with("~\\") { 184 | let home = 185 | dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory!"))?; 186 | return Ok(home.join(&path_str[2..])); 187 | } 188 | 189 | Err(anyhow!("Error reading directory with tilde (~)")) 190 | } 191 | 192 | pub fn get_random_playlist_idea() -> &'static str { 193 | use rand::seq::IndexedRandom; 194 | 195 | match PLAYLIST_IDEAS.choose(&mut rand::rng()) { 196 | Some(s) => s, 197 | None => "", 198 | } 199 | } 200 | 201 | const PLAYLIST_IDEAS: [&str; 46] = [ 202 | "A Lantern in the Dark", 203 | "A Map Without Places", 204 | "After the Rain Ends", 205 | "Background Music for Poor Decisions", 206 | "Beats Me, Literally", 207 | "Certified Hood Classics (But It’s Just Me Singing)", 208 | "Chordially Yours", 209 | "Clouds Made of Static", 210 | "Coffee Shop Apocalypse", 211 | "Ctrl Alt Repeat", 212 | "Dancing on the Edge of Stillness", 213 | "Drifting Into Tomorrow", 214 | "Echoes Between Stars", 215 | "Existential Karaoke", 216 | "Fragments of a Dream", 217 | "Frequencies Between Worlds", 218 | "Ghosts of Tomorrow’s Sunlight", 219 | "Horizons That Never End", 220 | "I Liked It Before It Was Cool", 221 | "In Treble Since Birth", 222 | "Key Changes and Life Changes", 223 | "Liminal Grooves", 224 | "Low Effort High Vibes", 225 | "Major Minor Issues", 226 | "Melancholy But Make It Funky", 227 | "Microwave Symphony", 228 | "Midnight Conversations", 229 | "Music to Stare Dramatically Out the Window To", 230 | "Neon Memories in Sepia", 231 | "Note to Self", 232 | "Notes From Another Dimension", 233 | "Off-Brand Emotions™", 234 | "Rhythm & Clues", 235 | "Sharp Notes Only", 236 | "Silence Speaks Louder", 237 | "Songs Stuck Between Pages", 238 | "Songs That Owe Me Rent", 239 | "Soundtrack for Imaginary Films", 240 | "Tempo Tantrums", 241 | "Temporary Eternity", 242 | "The Shape of Sound to Come", 243 | "The Weight of Quiet", 244 | "Untranslatable Feelings", 245 | "Vinyl Countdown", 246 | "Waiting for the Beat to Drop (Forever)", 247 | "When the World Pauses", 248 | ]; 249 | -------------------------------------------------------------------------------- /src/player/player.rs: -------------------------------------------------------------------------------- 1 | use super::{PlaybackState, PlayerState}; 2 | use crate::{ 3 | domain::QueueSong, 4 | get_readable_duration, 5 | player::{OSCILLO_BUFFER_CAPACITY, TappedSource}, 6 | }; 7 | use anyhow::Result; 8 | use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, decoder::builder::SeekMode}; 9 | use std::{ 10 | collections::VecDeque, 11 | fs::File, 12 | io::BufReader, 13 | ops::Sub, 14 | path::PathBuf, 15 | sync::{Arc, Mutex}, 16 | time::Duration, 17 | }; 18 | 19 | pub struct Player { 20 | sink: Sink, 21 | pub shared_state: Arc>, 22 | pub oscillo_buffer: Arc>>, 23 | _stream: OutputStream, 24 | } 25 | 26 | impl Player { 27 | pub(crate) fn new(shared_state: Arc>) -> Self { 28 | let _stream = OutputStreamBuilder::open_default_stream().expect("Cannot open stream"); 29 | let sink = Sink::connect_new(_stream.mixer()); 30 | 31 | Player { 32 | sink, 33 | shared_state, 34 | oscillo_buffer: Arc::new(Mutex::new(VecDeque::with_capacity(OSCILLO_BUFFER_CAPACITY))), 35 | _stream, 36 | } 37 | } 38 | 39 | pub(crate) fn play_song(&mut self, song: &Arc) -> Result<()> { 40 | let source = decode(song)?; 41 | 42 | let tapped_source = TappedSource::new(source, Arc::clone(&self.shared_state)); 43 | 44 | self.sink.clear(); 45 | self.sink.append(tapped_source); 46 | self.sink.play(); 47 | 48 | let mut player_state = self 49 | .shared_state 50 | .lock() 51 | .expect("Failed to unwrap mutex in music player"); 52 | 53 | player_state.state = PlaybackState::Playing; 54 | player_state.now_playing = Some(Arc::clone(&song.meta)); 55 | player_state.elapsed = Duration::default(); 56 | player_state.duration_display = 57 | get_readable_duration(song.meta.duration, crate::DurationStyle::Compact); 58 | player_state.elapsed_display = "0:00".to_string(); 59 | 60 | Ok(()) 61 | } 62 | 63 | pub(crate) fn toggle_playback(&mut self) { 64 | let (now_playing, playback_state) = { 65 | let state = self 66 | .shared_state 67 | .lock() 68 | .expect("Failed to unwrap mutex in music player"); 69 | (state.now_playing.is_none(), state.state) 70 | }; 71 | 72 | let mut state = self 73 | .shared_state 74 | .lock() 75 | .expect("Failed to unwrap mutex in music player"); 76 | match (now_playing, playback_state) { 77 | (true, _) => state.state = PlaybackState::Stopped, 78 | 79 | // RESUMING PLAYBACK 80 | (false, PlaybackState::Paused) => { 81 | self.sink.play(); 82 | state.state = PlaybackState::Playing; 83 | } 84 | 85 | // PAUSING THE SINK 86 | (false, _) => { 87 | self.sink.pause(); 88 | state.state = PlaybackState::Paused; 89 | } 90 | } 91 | } 92 | 93 | pub(crate) fn stop(&mut self) { 94 | self.sink.clear(); 95 | 96 | let mut state = self 97 | .shared_state 98 | .lock() 99 | .expect("Failed to unwrap mutex in music player"); 100 | state.now_playing = None; 101 | state.elapsed = Duration::default(); 102 | state.state = PlaybackState::Stopped; 103 | } 104 | 105 | /// Fast forwards playback 5 seconds 106 | /// Will skip to next track if in last 5 seconds 107 | pub(crate) fn seek_forward(&mut self, secs: usize) -> Result<()> { 108 | let (now_playing, playback_state) = { 109 | let state = self 110 | .shared_state 111 | .lock() 112 | .expect("Failed to unwrap mutex in music player"); 113 | (state.now_playing.clone(), state.state) 114 | }; 115 | 116 | if playback_state != PlaybackState::Stopped 117 | && playback_state != PlaybackState::Transitioning 118 | { 119 | let elapsed = self.sink.get_pos(); 120 | let duration = &now_playing.unwrap().duration; 121 | 122 | let mut state = self 123 | .shared_state 124 | .lock() 125 | .expect("Failed to unwrap mutex in music player"); 126 | 127 | // This prevents skiping into the next song's playback 128 | if duration.sub(elapsed) > Duration::from_secs_f32(secs as f32 + 0.5) { 129 | let new_time = elapsed + Duration::from_secs(secs as u64); 130 | if let Err(_) = self.sink.try_seek(new_time) { 131 | self.sink.clear(); 132 | state.state = PlaybackState::Stopped; 133 | } else { 134 | state.elapsed = self.sink.get_pos(); 135 | state.elapsed_display = 136 | get_readable_duration(state.elapsed, crate::DurationStyle::Compact); 137 | } 138 | } else { 139 | self.sink.clear(); 140 | state.state = PlaybackState::Stopped; 141 | } 142 | } 143 | Ok(()) 144 | } 145 | 146 | pub(crate) fn seek_back(&mut self, secs: usize) { 147 | let playback_state = { 148 | let state = self 149 | .shared_state 150 | .lock() 151 | .expect("Failed to unwrap mutex in music player"); 152 | state.state 153 | }; 154 | 155 | if playback_state != PlaybackState::Stopped 156 | && playback_state != PlaybackState::Transitioning 157 | { 158 | let elapsed = self.sink.get_pos(); 159 | 160 | match elapsed < Duration::from_secs(secs as u64) { 161 | true => { 162 | let _ = self.sink.try_seek(Duration::from_secs(0)); 163 | } 164 | false => { 165 | let new_time = elapsed.sub(Duration::from_secs(secs as u64)); 166 | let _ = self.sink.try_seek(new_time); 167 | } 168 | } 169 | 170 | let mut state = self 171 | .shared_state 172 | .lock() 173 | .expect("Failed to unwrap mutex in music player"); 174 | state.elapsed = self.sink.get_pos(); 175 | state.elapsed_display = 176 | get_readable_duration(state.elapsed, crate::DurationStyle::Compact); 177 | } 178 | } 179 | 180 | pub(crate) fn update_elapsed(&self) { 181 | if let Ok(mut state) = self.shared_state.lock() { 182 | if state.state == PlaybackState::Playing { 183 | let new_elapsed = self.sink.get_pos(); 184 | state.elapsed = new_elapsed; 185 | 186 | let secs = new_elapsed.as_secs(); 187 | if secs != state.last_elapsed_secs { 188 | state.last_elapsed_secs = secs; 189 | state.elapsed_display = 190 | get_readable_duration(new_elapsed, crate::DurationStyle::Compact); 191 | } 192 | } 193 | } 194 | } 195 | 196 | pub(crate) fn sink_is_empty(&self) -> bool { 197 | self.sink.empty() 198 | } 199 | } 200 | 201 | fn decode(song: &Arc) -> Result>> { 202 | let path = PathBuf::from(&song.path); 203 | let file = std::fs::File::open(&song.path)?; 204 | let len = file.metadata()?.len(); 205 | 206 | let mut builder = Decoder::builder() 207 | .with_data(BufReader::new(file)) 208 | .with_byte_len(len) 209 | .with_seek_mode(SeekMode::Fastest) 210 | .with_seekable(true); 211 | 212 | if let Some(ext) = path.extension().and_then(|e| e.to_str()) { 213 | let hint = match ext { 214 | "adif" | "adts" => "aac", 215 | "caf" => "audio/x-caf", 216 | "m4a" | "m4b" | "m4p" | "m4r" | "mp4" => "audio/mp4", 217 | "bit" | "mpga" => "mp3", 218 | "mka" | "mkv" => "audio/matroska", 219 | "oga" | "ogm" | "ogv" | "ogx" | "spx" => "audio/ogg", 220 | "wave" => "wav", 221 | _ => ext, 222 | }; 223 | builder = builder.with_hint(hint); 224 | } 225 | 226 | Ok(builder.build()?) 227 | } 228 | --------------------------------------------------------------------------------