├── .gitignore ├── src ├── themes │ ├── mod.rs │ └── dialoguer.rs ├── database │ ├── mod.rs │ └── migrations │ │ ├── 0004_dislike_song.sql │ │ ├── 0005_clean_remote_artists.sql │ │ ├── 0002_missing_counters.sql │ │ ├── 0003_libraries.sql │ │ └── 0001_init.sql ├── extra │ └── jellyfin-tui.desktop ├── sort.rs ├── discord.rs ├── mpris.rs ├── main.rs ├── search.rs ├── helpers.rs ├── config.rs ├── playlists.rs ├── queue.rs └── help.rs ├── .github ├── popup.png ├── queue.png ├── screen.gif └── search.png ├── Cargo.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | config.yaml 3 | covers/ -------------------------------------------------------------------------------- /src/themes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dialoguer; 2 | pub mod theme; -------------------------------------------------------------------------------- /src/database/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod extension; 2 | pub mod database; 3 | -------------------------------------------------------------------------------- /.github/popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhonus/jellyfin-tui/HEAD/.github/popup.png -------------------------------------------------------------------------------- /.github/queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhonus/jellyfin-tui/HEAD/.github/queue.png -------------------------------------------------------------------------------- /.github/screen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhonus/jellyfin-tui/HEAD/.github/screen.gif -------------------------------------------------------------------------------- /.github/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhonus/jellyfin-tui/HEAD/.github/search.png -------------------------------------------------------------------------------- /src/database/migrations/0004_dislike_song.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys = OFF; 2 | 3 | ALTER TABLE tracks ADD COLUMN disliked INTEGER NOT NULL DEFAULT 0; 4 | 5 | PRAGMA foreign_keys = ON; 6 | -------------------------------------------------------------------------------- /src/database/migrations/0005_clean_remote_artists.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM artists 2 | WHERE id NOT IN ( 3 | SELECT id FROM artists 4 | WHERE json_extract(artist, '$.UserData.Key') LIKE 'Artist-%' 5 | ); 6 | -------------------------------------------------------------------------------- /src/extra/jellyfin-tui.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=jellyfin-tui 4 | GenericName=Music Player 5 | Comment=Modern music streaming client for the terminal. 6 | Exec=jellyfin-tui 7 | Terminal=true 8 | Categories=Audio;AudioVideo;Player;ConsoleOnly; 9 | Keywords=streaming;music;jellyfin; 10 | -------------------------------------------------------------------------------- /src/database/migrations/0002_missing_counters.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS missing_counters ( 2 | entity_type TEXT NOT NULL, -- 'album' | 'artist' | 'playlist' 3 | id TEXT NOT NULL, 4 | missing_seen_count INTEGER NOT NULL DEFAULT 1, 5 | last_checked_at INTEGER NOT NULL, -- unix seconds 6 | PRIMARY KEY (entity_type, id) 7 | ) WITHOUT ROWID; 8 | 9 | CREATE INDEX IF NOT EXISTS idx_missing_album ON missing_counters(entity_type, missing_seen_count); 10 | -------------------------------------------------------------------------------- /src/sort.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::{cmp::Ordering, sync::LazyLock}; 3 | 4 | static ARTICLE_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^(the |an |a )").unwrap()); 5 | 6 | pub(crate) fn compare(a: &str, b: &str) -> Ordering { 7 | fn strip_article(s: &str) -> String { 8 | let s = s.trim_start(); 9 | let stripped = ARTICLE_RE.replace(s, ""); 10 | stripped.trim_start().to_owned() 11 | } 12 | 13 | let a = strip_article(a); 14 | let b = strip_article(b); 15 | 16 | a.cmp(&b) 17 | } 18 | 19 | pub(crate) fn strip_article(s: &str) -> String { 20 | let s = s.trim_start(); 21 | let stripped = ARTICLE_RE.replace(s, ""); 22 | stripped.trim_start().to_owned() 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/database/migrations/0003_libraries.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF; 2 | 3 | CREATE TABLE IF NOT EXISTS libraries ( 4 | id TEXT PRIMARY KEY, 5 | name TEXT NOT NULL, 6 | collection_type TEXT, 7 | last_seen TIMESTAMP NOT NULL, 8 | selected INTEGER NOT NULL DEFAULT 1 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS album_artist ( 12 | album_id TEXT NOT NULL, 13 | artist_id TEXT NOT NULL, 14 | PRIMARY KEY (album_id, artist_id) 15 | ); 16 | 17 | ALTER TABLE albums ADD COLUMN library_id TEXT REFERENCES libraries(id); 18 | CREATE INDEX IF NOT EXISTS idx_albums_library_id ON albums(library_id); 19 | 20 | ALTER TABLE tracks ADD COLUMN library_id TEXT REFERENCES libraries(id); 21 | CREATE INDEX IF NOT EXISTS idx_tracks_library_id ON tracks(library_id); 22 | 23 | PRAGMA foreign_keys=ON; 24 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jellyfin-tui" 3 | version = "1.3.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | reqwest = { version = "*", default-features = false, features = ["json", "stream", "blocking", "rustls-tls"] } 8 | serde = { version = "1.0", features = ["derive"] } 9 | serde_json = "1.0" 10 | tokio = { version = "1", features = ["full"] } 11 | serde_yaml = "0.9.34" 12 | libmpv2 = { version = "5.0.1" } 13 | ratatui = { version = "0.29.0", default-features = false, features = ["serde"] } 14 | crossterm = "0.29.0" 15 | ratatui-image = { version = "8.0.1", default-features = false, features = ["crossterm"] } 16 | image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "webp"] } 17 | dirs = "6.0.0" 18 | chrono = "0.4.42" 19 | souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } 20 | color-thief = "0.2.2" 21 | rand = "0.9.2" 22 | sqlx = { version = "0.8.6", default-features = false, features = [ "runtime-tokio", "sqlite", "migrate", "macros" ] } 23 | random-string = "1.1.0" 24 | fs2 = "0.4.3" 25 | dialoguer = "0.12.0" 26 | flexi_logger = "0.31.7" 27 | log = "0.4.28" 28 | url = "2.5.7" 29 | fs_extra = "1.3.0" 30 | regex = "1.12.2" 31 | discord-rich-presence = "1.0.0" 32 | -------------------------------------------------------------------------------- /src/database/migrations/0001_init.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=ON; 2 | 3 | CREATE TABLE IF NOT EXISTS tracks ( 4 | id TEXT PRIMARY KEY, 5 | album_id TEXT NOT NULL, 6 | artist_items TEXT NOT NULL, 7 | download_status TEXT NOT NULL, 8 | download_size_bytes INTEGER, 9 | track TEXT NOT NULL, 10 | last_played TIMESTAMP, 11 | downloaded_at TIMESTAMP 12 | ); 13 | 14 | -- this client uses DiscographySong structs everywhere (track) 15 | -- to avoid dealing with json_set in every GET function, we update the JSON download_status 16 | -- at every change, avoiding inconsistent data 17 | CREATE TRIGGER IF NOT EXISTS update_json_download_status 18 | AFTER UPDATE OF download_status ON tracks 19 | FOR EACH ROW 20 | BEGIN 21 | UPDATE tracks 22 | SET track = json_set(track, '$.download_status', NEW.download_status) 23 | WHERE id = NEW.id; 24 | END; 25 | 26 | CREATE TABLE IF NOT EXISTS artists ( 27 | id TEXT PRIMARY KEY, 28 | artist TEXT NOT NULL 29 | ); 30 | 31 | CREATE TABLE IF NOT EXISTS albums ( 32 | id TEXT PRIMARY KEY, 33 | album TEXT NOT NULL 34 | ); 35 | 36 | CREATE TABLE IF NOT EXISTS playlists ( 37 | id TEXT PRIMARY KEY, 38 | playlist TEXT NOT NULL 39 | ); 40 | 41 | CREATE TABLE IF NOT EXISTS artist_membership ( 42 | artist_id TEXT NOT NULL, 43 | track_id TEXT NOT NULL, 44 | PRIMARY KEY (artist_id, track_id) 45 | ); 46 | 47 | CREATE TABLE IF NOT EXISTS playlist_membership ( 48 | playlist_id TEXT NOT NULL, 49 | track_id TEXT NOT NULL, 50 | position INTEGER NOT NULL DEFAULT 0, 51 | PRIMARY KEY (playlist_id, track_id) 52 | ); 53 | 54 | CREATE TABLE IF NOT EXISTS lyrics ( 55 | id TEXT PRIMARY KEY, 56 | lyric TEXT NOT NULL 57 | ); 58 | -------------------------------------------------------------------------------- /src/discord.rs: -------------------------------------------------------------------------------- 1 | use crate::tui::Song; 2 | use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient}; 3 | use std::sync::atomic::{AtomicBool, Ordering}; 4 | use std::sync::Arc; 5 | use tokio::sync::mpsc::Receiver; 6 | 7 | pub enum DiscordCommand { 8 | Playing { 9 | track: Song, 10 | percentage_played: f64, 11 | server_url: String, 12 | paused: bool, 13 | show_art: bool, 14 | }, 15 | Stopped, 16 | } 17 | 18 | pub fn t_discord(mut rx: Receiver, client_id: u64) { 19 | let mut drpc: Option = None; 20 | let should_reconnect = Arc::new(AtomicBool::new(false)); 21 | let reconnect_flag = should_reconnect.clone(); 22 | let reconnect_flag2 = should_reconnect.clone(); 23 | 24 | reconnect_loop(&mut drpc, client_id); 25 | 26 | while let Some(cmd) = rx.blocking_recv() { 27 | if should_reconnect.load(Ordering::SeqCst) { 28 | reconnect_loop(&mut drpc, client_id); 29 | should_reconnect.store(false, Ordering::SeqCst); 30 | } 31 | match cmd { 32 | DiscordCommand::Playing { 33 | track, 34 | percentage_played, 35 | server_url, 36 | paused, 37 | show_art, 38 | } => { 39 | let duration_secs = track.run_time_ticks as f64 / 10_000_000f64; 40 | let elapsed_secs = (duration_secs * percentage_played).round() as i64; 41 | let start_time = chrono::Local::now() - chrono::Duration::seconds(elapsed_secs); 42 | let end_time = start_time + chrono::Duration::seconds(duration_secs.round() as i64); 43 | 44 | // log::info!( 45 | // "Track duration: {:.2} seconds, Elapsed: {} seconds", 46 | // duration_secs, 47 | // elapsed_secs 48 | //); 49 | 50 | let mut state = format!("by {}", track.artist); 51 | state.truncate(128); 52 | 53 | let details = track.name.clone(); 54 | let album_text = format!("from {}", &track.album); 55 | 56 | // Note: Images cover-placeholder, paused and playing need to be registered 57 | // on Discord's dev portal to show up in the Rich Presence. 58 | let mut assets = activity::Assets::new(); 59 | 60 | let url = format!( 61 | "{}/Items/{}/Images/Primary?fillHeight=480&fillWidth=480", 62 | server_url, track.album_id 63 | ); 64 | assets = if show_art { 65 | assets.large_image(url.as_str()) 66 | } else { 67 | assets.large_image("cover-placeholder") 68 | } 69 | // This is supposed to only be shown when hovering over the large image in the status. 70 | // However, Discord also seems to show it as a third regular line of text now. 71 | .large_text(album_text.as_str()); 72 | 73 | assets = if paused { 74 | assets.small_image("paused").small_text("Paused") 75 | } else { 76 | assets.small_image("playing").small_text("Playing") 77 | }; 78 | 79 | let mut activity = activity::Activity::new() 80 | .activity_type(activity::ActivityType::Listening) 81 | .status_display_type(activity::StatusDisplayType::Details) 82 | .state(state.as_str()) 83 | .details(details.as_str()) 84 | .assets(assets); 85 | 86 | // Don't show timestamp if the song is paused, since Discord will continue counting up otherwise 87 | activity = if paused { 88 | activity 89 | } else { 90 | let ts = activity::Timestamps::new() 91 | .start(start_time.timestamp()) 92 | .end(end_time.timestamp()); 93 | activity.timestamps(ts) 94 | }; 95 | 96 | let send_result = drpc 97 | .as_mut() 98 | .ok_or_else(|| "Discord IPC not connected".to_string()) 99 | .and_then(|c| c.set_activity(activity).map_err(|e| e.to_string())); 100 | 101 | if let Err(e) = send_result { 102 | log::debug!("Failed to set Discord activity: {}", e); 103 | reconnect_flag.store(true, Ordering::SeqCst); 104 | reconnect_flag2.store(true, Ordering::SeqCst); 105 | } 106 | } 107 | DiscordCommand::Stopped => { 108 | let cleared = drpc.as_mut().map(|c| { 109 | c.clear_activity() 110 | .or_else(|_| c.set_activity(activity::Activity::new())) 111 | }); 112 | if let Some(Err(e)) = cleared { 113 | log::error!("Failed to clear Discord activity: {}", e); 114 | should_reconnect.store(true, Ordering::SeqCst); 115 | } 116 | } 117 | } 118 | } 119 | log::info!("Discord command receiver closed, stopping Discord RPC client."); 120 | if let Some(mut c) = drpc.take() { 121 | let _ = c.close(); 122 | } 123 | } 124 | 125 | fn reconnect_loop(drpc: &mut Option, client_id: u64) { 126 | log::debug!("Reconnecting to Discord RPC..."); 127 | if let Some(mut c) = drpc.take() { 128 | let _ = c.close(); 129 | } 130 | let app_id = client_id.to_string(); 131 | let mut client = DiscordIpcClient::new(&app_id); 132 | match client.connect() { 133 | Ok(()) => { 134 | *drpc = Some(client); 135 | log::info!("Discord RPC connected."); 136 | } 137 | Err(e) => { 138 | *drpc = None; 139 | log::debug!("Discord RPC connect failed: {e}, retrying in 5 seconds..."); 140 | std::thread::sleep(std::time::Duration::from_secs(5)); 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/mpris.rs: -------------------------------------------------------------------------------- 1 | use crate::tui::{App, MpvState}; 2 | #[cfg(target_os = "linux")] 3 | use souvlaki::PlatformConfig; 4 | use souvlaki::{MediaControlEvent, MediaControls, MediaPosition, SeekDirection}; 5 | use std::{ 6 | sync::{Arc, Mutex}, 7 | time::Duration, 8 | }; 9 | use crate::database::database::{Command, JellyfinCommand}; 10 | 11 | // linux only, macos requires a window and windows is unsupported 12 | pub fn mpris() -> Result> { 13 | #[cfg(not(target_os = "linux"))] 14 | { 15 | return Err("mpris is only supported on linux".into()); 16 | } 17 | 18 | #[cfg(target_os = "linux")] 19 | { 20 | let hwnd = None; 21 | 22 | let config = PlatformConfig { 23 | dbus_name: "jellyfin-tui", 24 | display_name: "jellyfin-tui", 25 | hwnd, 26 | }; 27 | 28 | Ok(MediaControls::new(config).unwrap()) 29 | } 30 | } 31 | 32 | impl App { 33 | /// Registers the media controls to the MpvState. Called after each mpv thread re-init. 34 | pub fn register_controls(controls: &mut MediaControls, mpv_state: Arc>) { 35 | if let Err(e) = controls 36 | .attach(move |event: MediaControlEvent| { 37 | let lock = mpv_state.clone(); 38 | let mut mpv = match lock.lock() { 39 | Ok(mpv) => mpv, 40 | Err(_) => { 41 | return; 42 | } 43 | }; 44 | 45 | mpv.mpris_events.push(event); 46 | 47 | drop(mpv); 48 | }) { 49 | log::error!("Failed to attach media controls: {:#?}", e); 50 | } 51 | } 52 | 53 | pub fn update_mpris_position(&mut self, secs: f64) -> Option<()> { 54 | let progress = MediaPosition( 55 | Duration::try_from_secs_f64(secs).unwrap_or(Duration::ZERO) 56 | ); 57 | 58 | let controls = self.controls.as_mut()?; 59 | 60 | controls 61 | .set_playback(if self.paused { 62 | souvlaki::MediaPlayback::Paused { progress: Some(progress) } 63 | } else { 64 | souvlaki::MediaPlayback::Playing { progress: Some(progress) } 65 | }) 66 | .ok()?; 67 | 68 | Some(()) 69 | } 70 | 71 | pub async fn handle_mpris_events(&mut self) { 72 | let lock = self.mpv_state.clone(); 73 | let mut mpv = lock.lock().unwrap(); 74 | 75 | let current_song = self.state.queue 76 | .get(self.state.current_playback_state.current_index as usize) 77 | .cloned() 78 | .unwrap_or_default(); 79 | 80 | for event in mpv.mpris_events.iter() { 81 | match event { 82 | MediaControlEvent::Toggle => { 83 | self.paused = mpv.mpv.get_property("pause").unwrap_or(false); 84 | if self.paused { 85 | let _ = mpv.mpv.set_property("pause", false); 86 | } else { 87 | let _ = mpv.mpv.set_property("pause", true); 88 | } 89 | self.paused = !self.paused; 90 | } 91 | MediaControlEvent::Next => { 92 | if self.client.is_some() { 93 | let _ = self 94 | .db 95 | .cmd_tx 96 | .send(Command::Jellyfin(JellyfinCommand::Stopped { 97 | id: Some(self.active_song_id.clone()), 98 | position_ticks: Some(self.state.current_playback_state.position as u64 99 | * 10_000_000 100 | ), 101 | })) 102 | .await; 103 | } 104 | let _ = mpv.mpv.command("playlist_next", &["force"]); 105 | if self.paused { 106 | let _ = mpv.mpv.set_property("pause", false); 107 | self.paused = false; 108 | } 109 | self.update_mpris_position(0.0); 110 | } 111 | MediaControlEvent::Previous => { 112 | if self.state.current_playback_state.position > 5.0 { 113 | let _ = mpv.mpv.command("seek", &["0.0", "absolute"]); 114 | } else { 115 | let _ = mpv.mpv.command("playlist_prev", &["force"]); 116 | } 117 | self.update_mpris_position(0.0); 118 | } 119 | MediaControlEvent::Stop => { 120 | let _ = mpv.mpv.command("stop", &["keep-playlist"]); 121 | } 122 | MediaControlEvent::Play => { 123 | let _ = mpv.mpv.set_property("pause", false); 124 | self.paused = false; 125 | let _ = self.report_progress_if_needed(¤t_song, true).await; 126 | } 127 | MediaControlEvent::Pause => { 128 | let _ = mpv.mpv.set_property("pause", true); 129 | self.paused = true; 130 | let _ = self.report_progress_if_needed(¤t_song, true).await; 131 | } 132 | MediaControlEvent::SeekBy(direction, duration) => { 133 | let rel = duration.as_secs_f64() 134 | * (if matches!(direction, SeekDirection::Forward) { 135 | 1.0 136 | } else { 137 | -1.0 138 | }); 139 | 140 | self.update_mpris_position(self.state.current_playback_state.position + rel); 141 | let _ = mpv.mpv.command("seek", &[&rel.to_string()]); 142 | } 143 | MediaControlEvent::SetPosition(position) => { 144 | let secs = position.0.as_secs_f64(); 145 | self.update_mpris_position(secs); 146 | 147 | let _ = mpv.mpv.command("seek", &[&secs.to_string(), "absolute"]); 148 | } 149 | MediaControlEvent::SetVolume(_volume) => { 150 | #[cfg(target_os = "linux")] 151 | { 152 | let volume = _volume.clamp(0.0, 1.5); 153 | let _ = mpv.mpv.set_property("volume", (volume * 100.0) as i64); 154 | self.state.current_playback_state.volume = (volume * 100.0) as i64; 155 | if let Some(ref mut controls) = self.controls { 156 | let _ = controls.set_volume(volume); 157 | } 158 | } 159 | } 160 | _ => {} 161 | } 162 | } 163 | mpv.mpris_events.clear(); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod config; 3 | mod database; 4 | mod discord; 5 | mod help; 6 | mod helpers; 7 | mod keyboard; 8 | mod library; 9 | mod mpris; 10 | mod playlists; 11 | mod popup; 12 | mod queue; 13 | mod search; 14 | mod sort; 15 | mod themes; 16 | mod tui; 17 | 18 | use std::env; 19 | use std::panic; 20 | use std::sync::atomic::{AtomicBool, Ordering}; 21 | use std::io::stdout; 22 | use std::fs::{File, OpenOptions}; 23 | use fs2::FileExt; 24 | use dirs::data_dir; 25 | use flexi_logger::{FileSpec, Logger}; 26 | 27 | use crossterm::{ 28 | execute, 29 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 30 | }; 31 | // keyboard enhancement flags are used to allow for certain normally blocked key combinations... e.g. ctrl+enter... 32 | use crossterm::event::{ 33 | KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, 34 | }; 35 | use libmpv2::{MPV_CLIENT_API_MAJOR, MPV_CLIENT_API_MINOR, MPV_CLIENT_API_VERSION}; 36 | use ratatui::prelude::{CrosstermBackend, Terminal}; 37 | 38 | #[tokio::main] 39 | async fn main() { 40 | 41 | let _lockfile = check_single_instance(); 42 | 43 | let version = env!("CARGO_PKG_VERSION"); 44 | 45 | let args = env::args().collect::>(); 46 | if args.len() > 1 { 47 | if args[1] == "--version" || args[1] == "-v" { 48 | println!( 49 | "jellyfin-tui {version} (libmpv {major}.{minor} {ver})", 50 | version = version, 51 | major = MPV_CLIENT_API_MAJOR, 52 | minor = MPV_CLIENT_API_MINOR, 53 | ver = MPV_CLIENT_API_VERSION 54 | ); 55 | return; 56 | } 57 | if args[1] == "--help" { 58 | print_help(); 59 | return; 60 | } 61 | } 62 | 63 | let offline = args.contains(&String::from("--offline")); 64 | let force_server_select = args.contains(&String::from("--select-server")); 65 | 66 | if !args.contains(&String::from("--no-splash")) { 67 | println!( 68 | " 69 | ⠀⠀⠀⠀⡴⠂⢩⡉⠉⠉⡖⢄⠀ 70 | ⠀⠀⠀⢸⠪⠄⠀⠀⠀⠀⠐⠂⢧⠀⠀⠀\x1b[94mjellyfin-tui\x1b[0m 71 | ⠀⠀⠀⠙⢳⣢⢬⣁⠀⠛⠀⠂⡞ 72 | ⠀⣀⡤⢔⠟⣌⠷⠡⢽⢭⠝⠭⠁⠀⠀⠀⠀-⠀version⠀{} 73 | ⡸⣡⠴⡫⢺⠏⡇⢰⠸⠘⡄⠀⠀⠀⠀⠀⠀-⠀libmpv {}.{} ({}) 74 | ⡽⠁⢸⠀⢸⡀⢣⠀⢣⠱⡈⢦⠀ 75 | ⡇⠀⠘⣆⠀⢣⡀⣇⠈⡇⢳⠀⢣ 76 | ⠰⠀⠀⠘⢆⠀⠑⢸⢀⠃⠈⡇⢸ 77 | ⠀⠀⠀⠀⠈⠣⠀⢸⠀⠀⢠⠇⠀⠀⠀⠀This is free software (GPLv3). 78 | ⠀⠀⠀⠀⠀⠀⢠⠃⠀⠔⠁⠀⠀ 79 | ", 80 | version, MPV_CLIENT_API_MAJOR, MPV_CLIENT_API_MINOR, MPV_CLIENT_API_VERSION 81 | ); 82 | } 83 | 84 | let panicked = std::sync::Arc::new(AtomicBool::new(false)); 85 | let panicked_clone = panicked.clone(); 86 | 87 | panic::set_hook(Box::new(move |info| { 88 | panicked_clone.store(true, Ordering::SeqCst); 89 | let _ = disable_raw_mode(); 90 | let _ = execute!(stdout(), PopKeyboardEnhancementFlags); 91 | let _ = execute!(stdout(), LeaveAlternateScreen); 92 | log::error!("Panic occurred: {}", info); 93 | eprintln!("\n ! (×_×) panik: {}", info); 94 | eprintln!(" ! If you think this is a bug, please report it at https://github.com/dhonus/jellyfin-tui/issues"); 95 | })); 96 | 97 | match config::prepare_directories() { 98 | Ok(_) => {} 99 | Err(e) => { 100 | println!(" ! Creating directories failed. This is a system error, please report your environment and the following error {}:", e); 101 | std::process::exit(1); 102 | } 103 | } 104 | 105 | let data_dir = dirs::data_dir() 106 | .expect("! Could not find data directory") 107 | .join("jellyfin-tui"); 108 | 109 | let _logger = Logger::try_with_str("info,zbus=error") 110 | .expect(" ! Failed to initialize logger") 111 | .log_to_file( 112 | FileSpec::default() 113 | .directory(data_dir.join("log")) 114 | .basename("jellyfin-tui") 115 | .suffix("log") 116 | ) 117 | .rotate( 118 | flexi_logger::Criterion::Age(flexi_logger::Age::Day), 119 | flexi_logger::Naming::Timestamps, 120 | flexi_logger::Cleanup::KeepLogFiles(3), 121 | ) 122 | .format( 123 | flexi_logger::detailed_format, 124 | ) 125 | .start(); 126 | 127 | log::info!("jellyfin-tui {} started", version); 128 | 129 | config::initialize_config(); 130 | 131 | let mut app = tui::App::new(offline, force_server_select).await; 132 | if let Err(e) = app.load_state().await { 133 | println!(" ! Error loading state: {}", e); 134 | } 135 | 136 | enable_raw_mode().unwrap(); 137 | execute!(stdout(), EnterAlternateScreen).unwrap(); 138 | 139 | let _ = execute!( 140 | stdout(), 141 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) 142 | ); 143 | 144 | let mut terminal = Terminal::new(CrosstermBackend::new(stdout())).unwrap(); 145 | 146 | terminal.clear().unwrap(); 147 | 148 | loop { 149 | // main event loop 150 | // run() polls events and updates the app state 151 | if let Err(e) = app.run().await { 152 | log::error!("Runtime error: {}", e); 153 | } 154 | if app.exit || panicked.load(Ordering::SeqCst) { 155 | let _ = disable_raw_mode(); 156 | let _ = execute!(stdout(), PopKeyboardEnhancementFlags); 157 | let _ = execute!(stdout(), LeaveAlternateScreen); 158 | break; 159 | } 160 | // draw() renders the app state to the terminal 161 | if let Err(e) = app.draw(&mut terminal).await { 162 | log::error!("Draw error: {}", e); 163 | } 164 | } 165 | if panicked.load(Ordering::SeqCst) { 166 | return; 167 | } 168 | println!(" - Exiting..."); 169 | } 170 | 171 | fn check_single_instance() -> File { 172 | let runtime_dir = match data_dir() { 173 | Some(dir) => dir.join("jellyfin-tui.lock"), 174 | None => { 175 | println!("Could not find runtime directory"); 176 | std::process::exit(1); 177 | } 178 | }; 179 | 180 | let file = match OpenOptions::new().read(true).write(true).create(true).open(&runtime_dir) { 181 | Ok(f) => f, 182 | Err(e) => { 183 | println!("Failed to open lock file: {}", e); 184 | std::process::exit(1); 185 | } 186 | }; 187 | 188 | if let Err(e) = file.try_lock_exclusive() { 189 | if e.kind() == std::io::ErrorKind::WouldBlock { 190 | println!("Another instance of jellyfin-tui is already running."); 191 | std::process::exit(0); 192 | } 193 | println!("Failed to lock the lockfile: {} ", e); 194 | println!("This should not happen, please report this issue."); 195 | std::process::exit(1); 196 | } 197 | 198 | file 199 | } 200 | 201 | fn print_help() { 202 | println!("jellyfin-tui {}", env!("CARGO_PKG_VERSION")); 203 | println!("Usage: jellyfin-tui [OPTIONS]"); 204 | println!("\nArguments:"); 205 | println!(" --version\t\tPrint version information"); 206 | println!(" --help\t\tPrint this help message"); 207 | println!(" --no-splash\t\tDo not show jellyfish splash screen"); 208 | println!(" --select-server\tForce server selection on startup"); 209 | println!(" --offline\t\tStart in offline mode"); 210 | 211 | println!("\nControls:"); 212 | println!(" For a list of controls, press '?' in the application."); 213 | } 214 | 215 | // fn seekable_ranges(demuxer_cache_state: &MpvNode) -> Option> { 216 | // let mut res = Vec::new(); 217 | // let props: HashMap<&str, MpvNode> = demuxer_cache_state.to_map()?.collect(); 218 | // let ranges = props.get("seekable-ranges")?.to_array()?; 219 | 220 | // for node in ranges { 221 | // let range: HashMap<&str, MpvNode> = node.to_map()?.collect(); 222 | // let start = range.get("start")?.to_f64()?; 223 | // let end = range.get("end")?.to_f64()?; 224 | // res.push((start, end)); 225 | // } 226 | 227 | // Some(res) 228 | // } 229 | -------------------------------------------------------------------------------- /src/themes/dialoguer.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use dialoguer::console::{style, Style, StyledObject}; 4 | use dialoguer::theme::Theme; 5 | 6 | /// A colorful theme 7 | pub struct DialogTheme { 8 | /// The style for default values 9 | pub defaults_style: Style, 10 | /// The style for prompt 11 | pub prompt_style: Style, 12 | /// Prompt prefix value and style 13 | pub prompt_prefix: StyledObject, 14 | /// Prompt suffix value and style 15 | pub prompt_suffix: StyledObject, 16 | /// Prompt on success prefix value and style 17 | pub success_prefix: StyledObject, 18 | /// Prompt on success suffix value and style 19 | pub success_suffix: StyledObject, 20 | /// Error prefix value and style 21 | pub error_prefix: StyledObject, 22 | /// The style for error message 23 | pub error_style: Style, 24 | /// The style for hints 25 | pub hint_style: Style, 26 | /// The style for values on prompt success 27 | pub values_style: Style, 28 | /// The style for active items 29 | pub active_item_style: Style, 30 | /// The style for inactive items 31 | pub inactive_item_style: Style, 32 | /// Active item in select prefix value and style 33 | pub active_item_prefix: StyledObject, 34 | /// Inctive item in select prefix value and style 35 | pub inactive_item_prefix: StyledObject, 36 | /// Checked item in multi select prefix value and style 37 | pub checked_item_prefix: StyledObject, 38 | /// Unchecked item in multi select prefix value and style 39 | pub unchecked_item_prefix: StyledObject, 40 | /// Picked item in sort prefix value and style 41 | pub picked_item_prefix: StyledObject, 42 | /// Unpicked item in sort prefix value and style 43 | pub unpicked_item_prefix: StyledObject, 44 | } 45 | 46 | impl Default for DialogTheme { 47 | fn default() -> DialogTheme { 48 | DialogTheme { 49 | defaults_style: Style::new().for_stderr().cyan(), 50 | prompt_style: Style::new().for_stderr().bold(), 51 | prompt_prefix: style("?".to_string()).for_stderr().yellow(), 52 | prompt_suffix: style("›".to_string()).for_stderr().black().bright(), 53 | success_prefix: style("-".to_string()).for_stderr().green(), // prev ✓ 54 | success_suffix: style("·".to_string()).for_stderr().black().bright(), 55 | error_prefix: style("✘".to_string()).for_stderr().red(), 56 | error_style: Style::new().for_stderr().red(), 57 | hint_style: Style::new().for_stderr().black().bright(), 58 | values_style: Style::new().for_stderr().green(), 59 | active_item_style: Style::new().for_stderr().cyan(), 60 | inactive_item_style: Style::new().for_stderr(), 61 | active_item_prefix: style(">".to_string()).for_stderr().green(), 62 | inactive_item_prefix: style(" ".to_string()).for_stderr(), 63 | checked_item_prefix: style("✓".to_string()).for_stderr().green(), 64 | unchecked_item_prefix: style("⬚".to_string()).for_stderr().magenta(), 65 | picked_item_prefix: style(">".to_string()).for_stderr().green(), 66 | unpicked_item_prefix: style(" ".to_string()).for_stderr(), 67 | } 68 | } 69 | } 70 | 71 | impl Theme for DialogTheme { 72 | /// Formats a prompt. 73 | fn format_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { 74 | if !prompt.is_empty() { 75 | write!( 76 | f, 77 | " {} {} ", 78 | &self.prompt_prefix, 79 | self.prompt_style.apply_to(prompt) 80 | )?; 81 | } 82 | 83 | write!(f, " {}", &self.prompt_suffix) 84 | } 85 | 86 | /// Formats an error 87 | fn format_error(&self, f: &mut dyn fmt::Write, err: &str) -> fmt::Result { 88 | write!( 89 | f, 90 | " {} {}", 91 | &self.error_prefix, 92 | self.error_style.apply_to(err) 93 | ) 94 | } 95 | 96 | /// Formats a confirm prompt. 97 | fn format_confirm_prompt( 98 | &self, 99 | f: &mut dyn fmt::Write, 100 | prompt: &str, 101 | default: Option, 102 | ) -> fmt::Result { 103 | if !prompt.is_empty() { 104 | write!( 105 | f, 106 | " {} {} ", 107 | &self.prompt_prefix, 108 | self.prompt_style.apply_to(prompt) 109 | )?; 110 | } 111 | 112 | match default { 113 | None => write!( 114 | f, 115 | " {} {}", 116 | self.hint_style.apply_to("(y/n)"), 117 | &self.prompt_suffix 118 | ), 119 | Some(true) => write!( 120 | f, 121 | " {} {}", 122 | self.defaults_style.apply_to("(Y/n)"), 123 | &self.prompt_suffix, 124 | ), 125 | Some(false) => write!( 126 | f, 127 | " {} {}", 128 | self.defaults_style.apply_to("(y/N)"), 129 | &self.prompt_suffix, 130 | ), 131 | } 132 | } 133 | 134 | /// Formats a confirm prompt after selection. 135 | fn format_confirm_prompt_selection( 136 | &self, 137 | f: &mut dyn fmt::Write, 138 | prompt: &str, 139 | selection: Option, 140 | ) -> fmt::Result { 141 | if !prompt.is_empty() { 142 | write!( 143 | f, 144 | " {} {} ", 145 | &self.success_prefix, 146 | self.prompt_style.apply_to(prompt) 147 | )?; 148 | } 149 | let selection = selection.map(|b| if b { "yes" } else { "no" }); 150 | 151 | match selection { 152 | Some(selection) => { 153 | write!( 154 | f, 155 | " {} {}", 156 | &self.success_suffix, 157 | self.values_style.apply_to(selection) 158 | ) 159 | } 160 | None => { 161 | write!(f, " {}", &self.success_suffix) 162 | } 163 | } 164 | } 165 | 166 | /// Formats an input prompt. 167 | fn format_input_prompt( 168 | &self, 169 | f: &mut dyn fmt::Write, 170 | prompt: &str, 171 | default: Option<&str>, 172 | ) -> fmt::Result { 173 | if !prompt.is_empty() { 174 | write!( 175 | f, 176 | " {} {} ", 177 | &self.prompt_prefix, 178 | self.prompt_style.apply_to(prompt) 179 | )?; 180 | } 181 | 182 | match default { 183 | Some(default) => write!( 184 | f, 185 | " {} {} ", 186 | self.hint_style.apply_to(&format!("({})", default)), 187 | &self.prompt_suffix 188 | ), 189 | None => write!(f, " {} ", &self.prompt_suffix), 190 | } 191 | } 192 | 193 | /// Formats an input prompt after selection. 194 | fn format_input_prompt_selection( 195 | &self, 196 | f: &mut dyn fmt::Write, 197 | prompt: &str, 198 | sel: &str, 199 | ) -> fmt::Result { 200 | if !prompt.is_empty() { 201 | write!( 202 | f, 203 | " {} {} ", 204 | &self.success_prefix, 205 | self.prompt_style.apply_to(prompt) 206 | )?; 207 | } 208 | 209 | write!( 210 | f, 211 | " {} {}", 212 | &self.success_suffix, 213 | self.values_style.apply_to(sel) 214 | ) 215 | } 216 | 217 | /// Formats a multi select prompt after selection. 218 | fn format_multi_select_prompt_selection( 219 | &self, 220 | f: &mut dyn fmt::Write, 221 | prompt: &str, 222 | selections: &[&str], 223 | ) -> fmt::Result { 224 | if !prompt.is_empty() { 225 | write!( 226 | f, 227 | " {} {} ", 228 | &self.success_prefix, 229 | self.prompt_style.apply_to(prompt) 230 | )?; 231 | } 232 | 233 | write!(f, "{} ", &self.success_suffix)?; 234 | 235 | for (idx, sel) in selections.iter().enumerate() { 236 | write!( 237 | f, 238 | " {}{}", 239 | if idx == 0 { "" } else { ", " }, 240 | self.values_style.apply_to(sel) 241 | )?; 242 | } 243 | 244 | Ok(()) 245 | } 246 | 247 | /// Formats a select prompt item. 248 | fn format_select_prompt_item( 249 | &self, 250 | f: &mut dyn fmt::Write, 251 | text: &str, 252 | active: bool, 253 | ) -> fmt::Result { 254 | let details = if active { 255 | ( 256 | &self.active_item_prefix, 257 | self.active_item_style.apply_to(text), 258 | ) 259 | } else { 260 | ( 261 | &self.inactive_item_prefix, 262 | self.inactive_item_style.apply_to(text), 263 | ) 264 | }; 265 | 266 | write!(f, " {} {}", details.0, details.1) 267 | } 268 | 269 | /// Formats a multi select prompt item. 270 | fn format_multi_select_prompt_item( 271 | &self, 272 | f: &mut dyn fmt::Write, 273 | text: &str, 274 | checked: bool, 275 | active: bool, 276 | ) -> fmt::Result { 277 | let details = match (checked, active) { 278 | (true, true) => ( 279 | &self.checked_item_prefix, 280 | self.active_item_style.apply_to(text), 281 | ), 282 | (true, false) => ( 283 | &self.checked_item_prefix, 284 | self.inactive_item_style.apply_to(text), 285 | ), 286 | (false, true) => ( 287 | &self.unchecked_item_prefix, 288 | self.active_item_style.apply_to(text), 289 | ), 290 | (false, false) => ( 291 | &self.unchecked_item_prefix, 292 | self.inactive_item_style.apply_to(text), 293 | ), 294 | }; 295 | 296 | write!(f, " {} {}", details.0, details.1) 297 | } 298 | 299 | /// Formats a sort prompt item. 300 | fn format_sort_prompt_item( 301 | &self, 302 | f: &mut dyn fmt::Write, 303 | text: &str, 304 | picked: bool, 305 | active: bool, 306 | ) -> fmt::Result { 307 | let details = match (picked, active) { 308 | (true, true) => ( 309 | &self.picked_item_prefix, 310 | self.active_item_style.apply_to(text), 311 | ), 312 | (false, true) => ( 313 | &self.unpicked_item_prefix, 314 | self.active_item_style.apply_to(text), 315 | ), 316 | (_, false) => ( 317 | &self.unpicked_item_prefix, 318 | self.inactive_item_style.apply_to(text), 319 | ), 320 | }; 321 | 322 | write!(f, " {} {}", details.0, details.1) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | /* -------------------------- 2 | Search tab rendering 3 | - The entry point is the render_search function, it runs at each frame and renders the search tab. 4 | - The search tab is split into 2 parts, the search area and the results area. 5 | - The results area contains 3 lists, artists, albums, and tracks. 6 | -------------------------- */ 7 | 8 | use crate::keyboard::*; 9 | use crate::tui::App; 10 | 11 | use ratatui::{ 12 | prelude::*, 13 | widgets::*, 14 | widgets::{Block, Borders, Paragraph}, 15 | Frame, 16 | }; 17 | use crate::helpers; 18 | 19 | impl App { 20 | pub fn render_search(&mut self, app_container: Rect, frame: &mut Frame) { 21 | // search bar up top, results in 3 lists. Artists, Albums, Tracks 22 | // split the app container into 2 parts 23 | let search_layout = Layout::default() 24 | .direction(Direction::Vertical) 25 | .constraints(vec![Constraint::Min(3), Constraint::Percentage(95)]) 26 | .split(app_container); 27 | 28 | let search_area = search_layout[0]; 29 | let results_area = search_layout[1]; 30 | 31 | let instructions = if self.searching { 32 | Line::from(vec![ 33 | " Search ".fg(self.theme.resolve(&self.theme.foreground)), 34 | "".fg(self.theme.primary_color).bold(), 35 | " Clear search ".fg(self.theme.resolve(&self.theme.foreground)), 36 | "".fg(self.theme.primary_color).bold(), 37 | " Cancel ".fg(self.theme.resolve(&self.theme.foreground)), 38 | " ".fg(self.theme.primary_color).bold(), 39 | ]) 40 | } else { 41 | Line::from(vec![ 42 | " Go ".fg(self.theme.resolve(&self.theme.foreground)), 43 | "".fg(self.theme.primary_color).bold(), 44 | " Search ".fg(self.theme.resolve(&self.theme.foreground)), 45 | "< / > ".fg(self.theme.primary_color).bold(), 46 | " Next Section ".fg(self.theme.resolve(&self.theme.foreground)), 47 | "".fg(self.theme.primary_color).bold(), 48 | " Previous Section ".fg(self.theme.resolve(&self.theme.foreground)), 49 | " ".fg(self.theme.primary_color).bold(), 50 | ]) 51 | }; 52 | 53 | let title_line = Line::from( 54 | if self.searching { 55 | "Search".to_string() 56 | } else { 57 | format!("Matching: {}", self.search_term_last) 58 | } 59 | ).fg(if self.searching { 60 | self.theme.primary_color 61 | } else { 62 | self.theme.resolve(&self.theme.section_title) 63 | }); 64 | 65 | let block = Block::default() 66 | .borders(Borders::ALL) 67 | .title(title_line) 68 | .title_bottom(instructions.alignment(Alignment::Center)) 69 | .border_type(self.border_type) 70 | .border_style(Style::default().fg(if self.searching { 71 | self.theme.primary_color 72 | } else { 73 | self.theme.resolve(&self.theme.border) 74 | })); 75 | 76 | let search_term = Paragraph::new(self.search_term.clone()) 77 | .block(block) 78 | .wrap(Wrap { trim: false }); 79 | 80 | frame.render_widget(search_term, search_area); 81 | 82 | // split results area into 3 parts 83 | let results_layout = Layout::default() 84 | .direction(Direction::Horizontal) 85 | .constraints(vec![ 86 | Constraint::Percentage(33), 87 | Constraint::Percentage(33), 88 | Constraint::Percentage(34), 89 | ]) 90 | .split(results_area); 91 | 92 | // render search results 93 | // 3 lists, artists, albums, tracks 94 | let artists = self 95 | .search_result_artists 96 | .iter() 97 | .map(|artist| artist.name.as_str()) 98 | .collect::>(); 99 | 100 | let albums = self 101 | .search_result_albums 102 | .iter() 103 | .map(|album| album.name.as_str()) 104 | .collect::>(); 105 | let tracks = self 106 | .search_result_tracks 107 | .iter() 108 | .map(|track| { 109 | let title = format!("{} - {}", track.name, track.album); 110 | // track.run_time_ticks is in microseconds 111 | let seconds = (track.run_time_ticks / 10_000_000) % 60; 112 | let minutes = (track.run_time_ticks / 10_000_000 / 60) % 60; 113 | let hours = (track.run_time_ticks / 10_000_000 / 60) / 60; 114 | let hours_optional_text = match hours { 115 | 0 => String::from(""), 116 | _ => format!("{}:", hours), 117 | }; 118 | 119 | let mut time_span_text = 120 | format!(" {}{:02}:{:02}", hours_optional_text, minutes, seconds); 121 | if track.has_lyrics { 122 | time_span_text.push_str(" ♪"); 123 | } 124 | 125 | if track.id == self.active_song_id { 126 | let mut time: Text = Text::from(Span::styled( 127 | title, 128 | Style::default().fg(self.theme.primary_color), // active title = primary 129 | )); 130 | time.push_span(Span::styled( 131 | time_span_text, 132 | Style::default() 133 | .fg(self.theme.resolve(&self.theme.foreground_dim)) 134 | .add_modifier(Modifier::ITALIC), 135 | )); 136 | ListItem::new(time) // no outer .style(...) 137 | } else { 138 | let mut time: Text = Text::from(Span::styled( 139 | title, 140 | Style::default().fg(self.theme.resolve(&self.theme.foreground)), 141 | )); 142 | time.push_span(Span::styled( 143 | time_span_text, 144 | Style::default() 145 | .fg(self.theme.resolve(&self.theme.foreground_dim)) 146 | .add_modifier(Modifier::ITALIC), 147 | )); 148 | ListItem::new(time) 149 | } 150 | }) 151 | .collect::>(); 152 | 153 | let artists_list = match self.state.search_section { 154 | SearchSection::Artists => List::new(artists) 155 | .block( 156 | Block::default() 157 | .borders(Borders::ALL) 158 | .border_style(self.theme.resolve(&self.theme.border_focused)) 159 | .border_type(self.border_type) 160 | .title("Artists"), 161 | ) 162 | .fg(self.theme.resolve(&self.theme.foreground)) 163 | .highlight_symbol(">>") 164 | .highlight_style( 165 | Style::default() 166 | .fg(self.theme.resolve(&self.theme.selected_active_foreground)) 167 | .bg(self.theme.resolve(&self.theme.selected_active_background)) 168 | .add_modifier(Modifier::BOLD) 169 | ) 170 | .scroll_padding(10) 171 | .repeat_highlight_symbol(true), 172 | _ => List::new(artists) 173 | .block( 174 | Block::default() 175 | .fg(self.theme.resolve(&self.theme.border)) 176 | .borders(Borders::ALL) 177 | .border_type(self.border_type) 178 | .title(Line::from("Artists").fg(self.theme.resolve(&self.theme.section_title))) 179 | ) 180 | .fg(self.theme.resolve(&self.theme.foreground)) 181 | .highlight_symbol(">>") 182 | .highlight_style( 183 | Style::default() 184 | .add_modifier(Modifier::BOLD) 185 | .fg(self.theme.resolve(&self.theme.selected_inactive_foreground)) 186 | .bg(self.theme.resolve(&self.theme.selected_inactive_background)) 187 | ) 188 | .scroll_padding(10) 189 | .repeat_highlight_symbol(true), 190 | }; 191 | 192 | let albums_list = match self.state.search_section { 193 | SearchSection::Albums => List::new(albums) 194 | .block( 195 | Block::default() 196 | .borders(Borders::ALL) 197 | .border_style(self.theme.resolve(&self.theme.border_focused)) 198 | .border_type(self.border_type) 199 | .title("Albums"), 200 | ) 201 | .fg(self.theme.resolve(&self.theme.foreground)) 202 | .highlight_symbol(">>") 203 | .highlight_style( 204 | Style::default() 205 | .fg(self.theme.resolve(&self.theme.selected_active_foreground)) 206 | .bg(self.theme.resolve(&self.theme.selected_active_background)) 207 | .add_modifier(Modifier::BOLD) 208 | ) 209 | .repeat_highlight_symbol(true), 210 | _ => List::new(albums) 211 | .block( 212 | Block::default() 213 | .fg(self.theme.resolve(&self.theme.border)) 214 | .borders(Borders::ALL) 215 | .border_type(self.border_type) 216 | .title(Line::from("Albums").fg(self.theme.resolve(&self.theme.section_title))) 217 | ) 218 | .fg(self.theme.resolve(&self.theme.foreground)) 219 | .highlight_symbol(">>") 220 | .highlight_style( 221 | Style::default() 222 | .add_modifier(Modifier::BOLD) 223 | .bg(self.theme.resolve(&self.theme.selected_inactive_background)) 224 | .fg(self.theme.resolve(&self.theme.selected_inactive_foreground)) 225 | ) 226 | .repeat_highlight_symbol(true), 227 | }; 228 | 229 | let tracks_list = match self.state.search_section { 230 | SearchSection::Tracks => List::new(tracks) 231 | .block( 232 | Block::default() 233 | .borders(Borders::ALL) 234 | .border_style(self.theme.resolve(&self.theme.border_focused)) 235 | .border_type(self.border_type) 236 | .title("Tracks"), 237 | ) 238 | .highlight_symbol(">>") 239 | .highlight_style( 240 | Style::default() 241 | .bg(self.theme.resolve(&self.theme.selected_active_background)) 242 | .fg(self.theme.resolve(&self.theme.selected_active_foreground)) 243 | .add_modifier(Modifier::BOLD) 244 | ) 245 | .repeat_highlight_symbol(true), 246 | _ => List::new(tracks) 247 | .block( 248 | Block::default() 249 | .fg(self.theme.resolve(&self.theme.border)) 250 | .borders(Borders::ALL) 251 | .border_type(self.border_type) 252 | .title(Line::from("Tracks").fg(self.theme.resolve(&self.theme.section_title))) 253 | ) 254 | .highlight_symbol(">>") 255 | .highlight_style( 256 | Style::default() 257 | .bg(self.theme.resolve(&self.theme.selected_inactive_background)) 258 | .fg(self.theme.resolve(&self.theme.selected_inactive_foreground)) 259 | .add_modifier(Modifier::BOLD) 260 | ) 261 | .repeat_highlight_symbol(true), 262 | }; 263 | 264 | // frame.render_widget(artists_list, results_layout[0]); 265 | frame.render_stateful_widget( 266 | artists_list, 267 | results_layout[0], 268 | &mut self.state.selected_search_artist, 269 | ); 270 | frame.render_stateful_widget( 271 | albums_list, 272 | results_layout[1], 273 | &mut self.state.selected_search_album, 274 | ); 275 | frame.render_stateful_widget( 276 | tracks_list, 277 | results_layout[2], 278 | &mut self.state.selected_search_track, 279 | ); 280 | 281 | helpers::render_scrollbar( 282 | frame, results_layout[0], 283 | &mut self.state.search_artist_scroll_state, 284 | &self.theme 285 | ); 286 | helpers::render_scrollbar( 287 | frame, results_layout[1], 288 | &mut self.state.search_album_scroll_state, 289 | &self.theme 290 | ); 291 | helpers::render_scrollbar( 292 | frame, results_layout[2], 293 | &mut self.state.search_track_scroll_state, 294 | &self.theme 295 | ); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jellyfin-tui 2 | 3 | Jellyfin-tui is a (music) streaming client for the Jellyfin media server. Inspired by CMUS and others, 4 | its goal is to offer a self-hosted, terminal music player with all the modern features you need. 5 | 6 | ### Features 7 | - stream your music from Jellyfin 8 | - sixel **cover image**, courtesy of [ratatui-image](https://github.com/benjajaja/ratatui-image) 9 | - lyrics with autoscroll (Jellyfin > 10.9) 10 | - custom themes, color extraction from album art + smooth interpolated transitions 11 | - spotify-like double queue with order control, etc. 12 | - full offline mode with metadata caching, track downloads, background updates and slow network fallback 13 | - last.fm scrobbling, you need [jellyfin-plugin-lastfm](https://github.com/jesseward/jellyfin-plugin-lastfm) 14 | - multi-library support 15 | - vim-style keybindings 16 | - MPRIS integration 17 | - playlists (play/create/edit) 18 | - transcoding, shuffle, repeat modes, the works 19 | - works over ssh 20 | - fast and just kind of nifty really 21 | 22 | ### Planned features 23 | - other media types (movies, tv shows) 24 | - jellyfin-wide remote control and much more 25 | - if there is a feature you'd like to see, please open an issue :) 26 | 27 | ### Screenshots 28 | ![image](.github/screen.gif) 29 | 30 | ### Installation 31 | #### Arch Linux 32 | [jellyfin-tui](https://aur.archlinux.org/packages/jellyfin-tui/) is available as a package in the [AUR](https://aur.archlinux.org). You can install it with your preferred [AUR helper](https://wiki.archlinux.org/title/AUR_helpers). Example: 33 | ```bash 34 | paru -S jellyfin-tui 35 | ``` 36 | 37 | #### Nix 38 | [jellyfin-tui](https://search.nixos.org/packages?channel=unstable&show=jellyfin-tui&from=0&size=50&sort=relevance&type=packages&query=jellyfin-tui) is available as a package in [Nixpkgs](https://search.nixos.org/packages). 39 | 40 | #### Alpine Linux 41 | [jellyfin-tui](https://pkgs.alpinelinux.org/package/edge/community/x86/jellyfin-tui) is available as a package in the Alpine Linux community repository. 42 | 43 | #### Other Linux 44 | Jellyfin-tui depends on **libmpv2** (audio playback) and **sqlite3** (offline caching), both of which should be available in your distribution's package manager. On Debian/Ubuntu based systems, you may need to install `libmpv-dev` and `libssl-dev` as well for building. 45 | ```bash 46 | # If you're new to rust: 47 | # install rust from https://rustup.rs and make sure ~/.cargo/bin is in your PATH (add this to ~/.bashrc or ~/.zshrc etc.) 48 | export PATH=$PATH:~/.cargo/bin/ 49 | 50 | # Arch 51 | sudo pacman -S mpv sqlite 52 | # Ubuntu/Debian 53 | sudo apt install mpv libmpv-dev sqlite3 libssl-dev 54 | ``` 55 | ```bash 56 | # clone this repository 57 | git clone https://github.com/dhonus/jellyfin-tui 58 | cd jellyfin-tui 59 | 60 | # optional: use latest tag 61 | git fetch --tags 62 | git checkout $(git tag | sort -V | tail -1) 63 | 64 | cargo install --path . 65 | ``` 66 | 67 | #### macOS 68 | ```bash 69 | brew install mpv 70 | git clone https://github.com/dhonus/jellyfin-tui 71 | cd jellyfin-tui 72 | # add exports to your shell profile (~/.zshrc etc.) 73 | export LIBRARY_PATH="$LIBRARY_PATH:$(brew --prefix)/lib" 74 | export PATH=$PATH:~/.cargo/bin/ 75 | cargo install --path . 76 | ``` 77 | 78 | --- 79 | 80 | ### Key bindings 81 | Press **`?`** to see the key bindings at any time. Some of the most important ones are: 82 | 83 |
84 | Key bindings 85 |
86 | 87 | |key|alt|action| 88 | |---|---|---| 89 | |space||play / pause| 90 | |enter||start playing selected| 91 | |up / down|k / j|navigate **up** / **down**| 92 | |tab||cycle between **Artist** & **Track** lists| 93 | |shift + tab||cycle further to **Lyrics** & **Queue**| 94 | |p||show **command prompt**| 95 | |a / A||skip to next / previous **album**, or next in Artists, alphabetically| 96 | |1,2,3,...|F1,F2,F3,...|switch tab >> F1 - **Library**, F2 - **Search**| 97 | |F1|ESC|return to **Library** tab| 98 | |left / right|r / s|seek +/- 5s| 99 | |. / ,|< / >|seek +/- 1m| 100 | |d||download track / album / playlist| 101 | |n||next track| 102 | |N||previous track; if over 5s plays current track from the start| 103 | |+ -||volume up / down| 104 | |ctrl + e|ctrl + enter|play next| 105 | |e|shift + enter|enqueue (play last)| 106 | |E||clear queue| 107 | |DELETE||remove from queue| 108 | |x||stop playback| 109 | |X||reset the program| 110 | |T||toggle transcode (applies to newly added songs, not whole queue)| 111 | |q|^C|quit| 112 | 113 |
114 | 115 | ### Configuration 116 | When you run jellyfin-tui for the first time, it will ask you for the server address, username and password and save them in the configuration file. 117 | 118 | The program **prints the config location** when run. On linux, the configuration file is located at `~/.config/jellyfin-tui/config.yaml`. Feel free to edit it manually if needed. 119 | ```yaml 120 | servers: 121 | - name: Main 122 | url: 'https://jellyfin.example.com' 123 | username: 'username' 124 | password: 'imcool123' 125 | default: true # Add to not ask to pick server. Use --select-server to override 126 | - name: Second Server 127 | url: 'http://localhost:8096' 128 | username: 'username' 129 | password: 'imcool123' 130 | - name: Third Server 131 | url: 'http:/jellyfin.example2.com' 132 | username: 'username' 133 | password_file: /home/myusername/.jellyfin-tui-password # use a file containing the password 134 | 135 | # All following settings are OPTIONAL. What you see here are the defaults. 136 | 137 | # Show album cover image 138 | art: true 139 | # Save and restore the state of the player (queue, volume, etc.) 140 | persist: true 141 | # Grab the primary color from the cover image (false => uses the current theme's `accent` instead) 142 | auto_color: true 143 | # Time in milliseconds to fade between colors when the track changes 144 | auto_color_fade_ms: 400 145 | # Always show the lyrics pane, even if no lyrics are available 146 | lyrics: 'always' # options: 'always', 'never', 'auto' 147 | 148 | rounded_corners: true 149 | 150 | transcoding: 151 | bitrate: 320 152 | # container: mp3 153 | 154 | # Discord Rich Presence. Shows your listening status on your Discord profile if Discord is running. 155 | discord: APPLICATION_ID 156 | # Displays album art on your Discord profile if enabled 157 | # !!CAUTION!! - Enabling this will expose the URL of your Jellyfin instance to all Discord users! 158 | discord_art: false 159 | 160 | # Customize the title of the terminal window 161 | window_title: true # default -> {title} – {artist} ({year}) 162 | # window_title: false # disable 163 | # Custom title: choose from current track's {title} {artist} {album} {year} 164 | # window_title: "\"{title}\" by {artist} ({year}) – jellyfin-tui" 165 | 166 | # Options specified here will be passed to mpv - https://mpv.io/manual/master/#options 167 | mpv: 168 | replaygain: album 169 | af: lavfi=[loudnorm=I=-23:TP=-1] 170 | no-config: true 171 | log-file: /tmp/mpv.log 172 | ``` 173 | ### Theming 174 |
175 | Click to reveal theming documentation 176 |
177 | 178 | Jellyfin-tui comes with several **built-in themes** in both light and dark variants. You can switch between themes in the **global popup**. 179 | 180 | You can also define your own **custom themes** in the config by selecting a **base theme** and *overriding* any colors you want. 181 | Custom themes are hot-reloaded when you save the config file. 182 | 183 | ##### Color formats 184 | * `"#rrggbb"` (hex) 185 | * `"red"`,`"white"`,`"gray"` (named) 186 | * `"auto"` → uses the extracted accent from album art 187 | * `"none"` → disables optional backgrounds (`background`,`album_header_background` only) 188 | 189 | #### Overridable keys 190 |
191 | Full list of keys 192 |
193 | 194 | | Key | Description | 195 | |-----|-----------------------------------------------------------------------------------------------------| 196 | | `background` | Main background color. Optional — `none` uses terminal bg. | 197 | | `foreground` | Primary text color. | 198 | | `foreground_secondary` | Secondary text (artists in player, ...). | 199 | | `foreground_dim` | Dimmed text for less important UI elements. | 200 | | `foreground_disabled` | Disabled or unavailable UI elements, disliked tracks. | 201 | | `section_title` | Titles of sections like *Albums*, *Artists*, etc. | 202 | | `accent` | Fallback color for `"auto"`, applied when album art isn't available or if `auto_color` is disabled. | 203 | | `border` | Normal border color. | 204 | | `border_focused` | Border color when a widget is focused. `"auto"` uses primary (album) color. | 205 | | `selected_active_background` | Background of the currently selected row the the active section. | 206 | | `selected_active_foreground` | Text color of the selected row in the active section. | 207 | | `selected_inactive_background` | Background of selected rows in inactive sections. | 208 | | `selected_inactive_foreground` | Foreground of selected rows in inactive sections. | 209 | | `scrollbar_thumb` | Scrollbar handle color. | 210 | | `scrollbar_track` | Scrollbar track color. | 211 | | `progress_fill` | Played/filled portion of progress bars. | 212 | | `progress_track` | Unfilled portion of progress bars. | 213 | | `tab_active_foreground` | Text color of the active tab. | 214 | | `tab_inactive_foreground` | Text color of inactive tabs. | 215 | | `album_header_background` | Background for album/artist header rows (optional). | 216 | | `album_header_foreground` | Foreground for album/artist header rows. | 217 | 218 |
219 | 220 | #### Example themes 221 | 222 | ```yaml 223 | themes: 224 | - name: "Transparent Light" 225 | base: "Light" 226 | 227 | # remove background 228 | background: "none" 229 | 230 | # make active tab text use album accent color 231 | tab_active_foreground: "auto" 232 | 233 | - name: "Monochrome Dark (Tweaked)" 234 | base: "Monochrome Dark" 235 | 236 | # remove background and album header backgrounds 237 | background: "none" 238 | album_header_background: "none" 239 | 240 | # make progress bar follow album accent 241 | progress_fill: "auto" 242 | 243 | # high contrast row selection 244 | selected_active_background: "#eeeeee" 245 | selected_active_foreground: "black" 246 | ``` 247 | 248 | The `"auto"` accent color is derived from album art by default. You can disable this by setting 249 | ```yaml 250 | auto_color: false 251 | ``` 252 | in the config file. This will use the `accent` color defined in the theme instead for all "`"auto"`" usages. 253 | 254 | --- 255 | 256 |
257 | 258 | ### Popup 259 | There are only so many keys to bind, so some actions are hidden behind a popup. Press `p` to open it and `ESC` to close it. The popup is context sensitive and will show different options depending on where you are in the program. 260 | 261 | ![image](.github/popup.png) 262 | 263 | ### Queue 264 | Jellyfin-tui has a double queue similar to Spotify. You can add songs to the queue by pressing `e` or `shift + enter`. Learn more about what you can do with the queue by pressing `?` and reading through the key bindings. 265 | 266 | ![image](.github/queue.png) 267 | 268 | ### MPRIS 269 | Jellyfin-tui registers itself as an MPRIS client, so you can control it with any MPRIS controller. For example, `playerctl`. 270 | 271 | ### Search 272 | 273 | In the Artists and Tracks lists you can search by pressing `/` and typing your query. The search is case-insensitive and will filter the results as you type. Pressing `ESC` will clear the search and keep the current item selected. 274 | 275 | You can search globally by switching to the Search tab. The search is case-insensitive and will search for artists, albums and tracks. It will pull **everything** without pagination, so it may take a while to load if you have a large library. This was done because jellyfin won't allow me to search for tracks without an artist or album assigned, which this client doesn't support. 276 | 277 | ![image](.github/search.png) 278 | 279 | ### Downloading media / offline mode 280 | 281 | Downloading music is very simple, just **press `d` on a track**, or album. More download options can be found in popups. 282 | 283 | You can launch jellyfin-tui in offline mode by passing the `--offline` flag. This will disable all network access and only play downloaded tracks. 284 | 285 | A local copy of commonly used data is stored in a local database. This speeds up load times and allows you to use the program fully offline. Also, playing a downloaded track will play the local copy instead of streaming it, saving bandwidth. 286 | > Your library is updated **in the background** every 10 minutes. You will be notified if anything changes. Track metadata updates whenever you open a discography/album/playlist view in-place. You can also force an update in the global popup menu. Jellyfin is the parent, if you delete music on the server, jellyfin-tui will also delete it including downloaded files. 287 | 288 | ### Recommendations 289 | Due to the nature of the project and jellyfin itself, there are some limitations and things to keep in mind: 290 | - jellyfin-tui assumes you correctly tag your music files. Please look at the [jellyfin documentation](https://jellyfin.org/docs/general/server/media/music/) on how to tag your music files. Before assuming the program is broken, verify that they show up correctly in Jellyfin itself. 291 | - **lyrics**: jellyfin-tui will show lyrics if they are available in jellyfin. To scroll automatically with the song, they need to contain timestamps. I recommend using the [LrcLib Jellyfin plugin](https://github.com/jellyfin/jellyfin-plugin-lrclib) and running `Download missing lyrics` directly **within jellyfin-tui** (Global Popup > Run scheduled task > Library: Download missing lyrics), or alternatively the desktop application [LRCGET](https://github.com/tranxuanthang/lrcget), both by by tranxuanthang. If you value their work, consider donating to keep this amazing free service running. 292 | 293 | ### Supported terminals 294 | Not all terminals have the features needed to cover every aspect of jellyfin-tui. While rare, some terminals lack sixel (or equivalent) support or have certain key event limitations. The following are tested and work well: 295 | - kitty (recommended) 296 | - iTerm2 (recommended) 297 | - ghostty 298 | - contour 299 | - wezterm 300 | - foot 301 | 302 | The following have issues 303 | - konsole, alacritty, gnome console, terminator (no sixel support and occasional strange behavior) 304 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use dirs::data_dir; 2 | use ratatui::widgets::{ListState, Scrollbar, ScrollbarOrientation, ScrollbarState, TableState}; 3 | use std::fs::OpenOptions; 4 | use ratatui::Frame; 5 | use ratatui::layout::{Margin, Rect}; 6 | use ratatui::style::Style; 7 | use crate::{ 8 | client::{Album, Artist, Playlist}, 9 | keyboard::{ActiveSection, ActiveTab, SearchSection}, 10 | popup::PopupMenu, 11 | tui::{Filter, MpvPlaybackState, Repeat, Song, Sort}, 12 | }; 13 | use crate::client::DiscographySong; 14 | use crate::themes::theme::Theme; 15 | 16 | pub fn find_all_subsequences(needle: &str, haystack: &str) -> Vec<(usize, usize)> { 17 | let mut ranges = Vec::new(); 18 | let mut needle_chars = needle.chars(); 19 | let mut current_needle_char = needle_chars.next(); 20 | 21 | let mut current_byte_index = 0; 22 | 23 | for haystack_char in haystack.chars() { 24 | if let Some(needle_char) = current_needle_char { 25 | if haystack_char == needle_char { 26 | ranges.push(( 27 | current_byte_index, 28 | current_byte_index + haystack_char.len_utf8(), 29 | )); 30 | current_needle_char = needle_chars.next(); 31 | } 32 | } 33 | current_byte_index += haystack_char.len_utf8(); 34 | } 35 | 36 | if current_needle_char.is_none() { 37 | ranges 38 | } else { 39 | Vec::new() 40 | } 41 | } 42 | 43 | /// Used because paths can contain spaces and other characters that need to be normalized. 44 | pub fn normalize_mpvsafe_url(raw: &str) -> Result { 45 | if raw.starts_with("http://") || raw.starts_with("https://") { 46 | Ok(raw.to_string()) 47 | } else { 48 | std::path::Path::new(raw) 49 | .canonicalize() 50 | .map_err(|e| format!("Failed to resolve path '{}': {:?}", raw, e)) 51 | .and_then(|path| { 52 | url::Url::from_file_path(&path) 53 | .map_err(|_| format!("Invalid file path: {}", path.display())) 54 | .map(|url| url.to_string()) 55 | }) 56 | } 57 | } 58 | 59 | /// Used to make random album order in the discography view reproducible. 60 | pub fn extract_album_order(tracks: &[DiscographySong]) -> Vec { 61 | tracks 62 | .iter() 63 | .filter_map(|t| { 64 | if let Some(rest) = t.id.strip_prefix("_album_") { 65 | Some(rest.to_string()) 66 | } else { 67 | None 68 | } 69 | }) 70 | .collect() 71 | } 72 | 73 | pub fn render_scrollbar<'a>( 74 | frame: &mut Frame, 75 | area: Rect, 76 | state: &'a mut ratatui::widgets::ScrollbarState, 77 | theme: &Theme, // pass only what you need 78 | ) { 79 | let scrollbar = Scrollbar::default() 80 | .orientation(ScrollbarOrientation::VerticalRight) 81 | .begin_symbol(Some("↑")) 82 | .end_symbol(Some("↓")) 83 | .begin_style(Style::default().fg(theme.resolve(&theme.foreground))) 84 | .end_style(Style::default().fg(theme.resolve(&theme.foreground))) 85 | .track_style(Style::default().fg(theme.resolve(&theme.scrollbar_track))) 86 | .thumb_style(Style::default().fg(theme.resolve(&theme.scrollbar_thumb))); 87 | 88 | frame.render_stateful_widget( 89 | scrollbar, 90 | area.inner(Margin { vertical: 1, horizontal: 1 }), 91 | state, 92 | ); 93 | } 94 | 95 | 96 | /// This struct should contain all the values that should **PERSIST** when the app is closed and reopened. 97 | /// This is PER SERVER, so if you have multiple servers, each will have its own state. 98 | /// 99 | #[derive(serde::Serialize, serde::Deserialize)] 100 | pub struct State { 101 | // (URL, Title, Artist, Album) 102 | #[serde(default)] 103 | pub queue: Vec, 104 | // Music - active section (Artists, Tracks, Queue) 105 | #[serde(default)] 106 | pub active_section: ActiveSection, // current active section (Artists, Tracks, Queue) 107 | #[serde(default)] 108 | pub last_section: ActiveSection, // last active section 109 | // Search - active section (Artists, Albums, Tracks) 110 | #[serde(default)] 111 | pub search_section: SearchSection, // current active section (Artists, Albums, Tracks) 112 | 113 | // active tab (Music, Search) 114 | #[serde(default)] 115 | pub active_tab: ActiveTab, 116 | #[serde(default)] 117 | pub current_artist: Artist, 118 | #[serde(default)] 119 | pub current_album: Album, 120 | #[serde(default)] 121 | pub current_playlist: Playlist, 122 | 123 | // ratatui list indexes 124 | #[serde(default)] 125 | pub selected_artist: ListState, 126 | #[serde(default)] 127 | pub selected_track: TableState, 128 | #[serde(default)] 129 | pub selected_album: ListState, 130 | #[serde(default)] 131 | pub selected_album_track: TableState, 132 | #[serde(default)] 133 | pub selected_playlist_track: TableState, 134 | #[serde(default)] 135 | pub selected_playlist: ListState, 136 | #[serde(default)] 137 | pub artists_scroll_state: ScrollbarState, 138 | #[serde(default)] 139 | pub tracks_scroll_state: ScrollbarState, 140 | #[serde(default)] 141 | pub albums_scroll_state: ScrollbarState, 142 | #[serde(default)] 143 | pub album_tracks_scroll_state: ScrollbarState, 144 | #[serde(default)] 145 | pub playlists_scroll_state: ScrollbarState, 146 | #[serde(default)] 147 | pub playlist_tracks_scroll_state: ScrollbarState, 148 | #[serde(default)] 149 | pub selected_queue_item: ListState, 150 | #[serde(default)] 151 | pub selected_queue_item_manual_override: bool, 152 | #[serde(default)] 153 | pub selected_lyric: ListState, 154 | #[serde(default)] 155 | pub selected_lyric_manual_override: bool, 156 | #[serde(default)] 157 | pub current_lyric: usize, 158 | #[serde(default)] 159 | pub selected_search_artist: ListState, 160 | #[serde(default)] 161 | pub selected_search_album: ListState, 162 | #[serde(default)] 163 | pub selected_search_track: ListState, 164 | 165 | #[serde(default)] 166 | pub artists_search_term: String, 167 | #[serde(default)] 168 | pub albums_search_term: String, 169 | #[serde(default)] 170 | pub album_tracks_search_term: String, 171 | #[serde(default)] 172 | pub tracks_search_term: String, 173 | #[serde(default)] 174 | pub playlist_tracks_search_term: String, 175 | #[serde(default)] 176 | pub playlists_search_term: String, 177 | 178 | // scrollbars for search results 179 | #[serde(default)] 180 | pub search_artist_scroll_state: ScrollbarState, 181 | #[serde(default)] 182 | pub search_album_scroll_state: ScrollbarState, 183 | #[serde(default)] 184 | pub search_track_scroll_state: ScrollbarState, 185 | 186 | #[serde(default)] 187 | pub shuffle: bool, 188 | 189 | #[serde(default)] 190 | pub current_playback_state: MpvPlaybackState, 191 | } 192 | 193 | impl State { 194 | pub fn new() -> State { 195 | Self { 196 | queue: vec![], 197 | active_section: ActiveSection::default(), 198 | last_section: ActiveSection::default(), 199 | search_section: SearchSection::default(), 200 | active_tab: ActiveTab::default(), 201 | current_artist: Artist::default(), 202 | current_album: Album::default(), 203 | current_playlist: Playlist::default(), 204 | selected_artist: ListState::default(), 205 | selected_track: TableState::default(), 206 | selected_album: ListState::default(), 207 | selected_album_track: TableState::default(), 208 | selected_playlist_track: TableState::default(), 209 | selected_playlist: ListState::default(), 210 | tracks_scroll_state: ScrollbarState::default(), 211 | albums_scroll_state: ScrollbarState::default(), 212 | album_tracks_scroll_state: ScrollbarState::default(), 213 | artists_scroll_state: ScrollbarState::default(), 214 | playlists_scroll_state: ScrollbarState::default(), 215 | playlist_tracks_scroll_state: ScrollbarState::default(), 216 | selected_queue_item: ListState::default(), 217 | selected_queue_item_manual_override: false, 218 | selected_lyric: ListState::default(), 219 | selected_lyric_manual_override: false, 220 | current_lyric: 0, 221 | 222 | selected_search_artist: ListState::default(), 223 | selected_search_album: ListState::default(), 224 | selected_search_track: ListState::default(), 225 | 226 | artists_search_term: String::from(""), 227 | albums_search_term: String::from(""), 228 | album_tracks_search_term: String::from(""), 229 | tracks_search_term: String::from(""), 230 | playlist_tracks_search_term: String::from(""), 231 | playlists_search_term: String::from(""), 232 | 233 | search_artist_scroll_state: ScrollbarState::default(), 234 | search_album_scroll_state: ScrollbarState::default(), 235 | search_track_scroll_state: ScrollbarState::default(), 236 | 237 | shuffle: false, 238 | 239 | current_playback_state: MpvPlaybackState { 240 | position: 0.0, 241 | duration: 0.0, 242 | current_index: 0, 243 | last_index: -1, 244 | volume: 100, 245 | audio_bitrate: 0, 246 | audio_samplerate: 0, 247 | file_format: String::from(""), 248 | hr_channels: String::from(""), 249 | }, 250 | } 251 | } 252 | 253 | /// Save the current state to a file. We keep separate files for offline and online states. 254 | /// 255 | pub fn save(&self, server_id: &String, offline: bool) -> Result<(), Box> { 256 | let data_dir = data_dir().unwrap(); 257 | let states_dir = data_dir.join("jellyfin-tui").join("states"); 258 | 259 | let filename = if offline { 260 | format!("offline_{}.json", server_id) 261 | } else { 262 | format!("{}.json", server_id) 263 | }; 264 | 265 | let final_path = states_dir.join(&filename); 266 | let tmp_path = states_dir.join(format!("{}.tmp", filename)); 267 | 268 | { 269 | let file = OpenOptions::new() 270 | .create(true) 271 | .write(true) 272 | .truncate(true) 273 | .open(&tmp_path)?; 274 | 275 | serde_json::to_writer(file, &self)?; 276 | } 277 | std::fs::rename(&tmp_path, &final_path)?; 278 | 279 | Ok(()) 280 | } 281 | 282 | 283 | /// Load the state from a file. We keep separate files for offline and online states. 284 | /// 285 | pub fn load(server_id: &String, is_offline: bool) -> Result> { 286 | let data_dir = data_dir().unwrap(); 287 | let states_dir = data_dir.join("jellyfin-tui").join("states"); 288 | match OpenOptions::new() 289 | .read(true) 290 | .open(states_dir 291 | .join(if is_offline { format!("offline_{}.json", server_id) } else { format!("{}.json", server_id) }) 292 | ) 293 | { 294 | Ok(file) => { 295 | let state: State = serde_json::from_reader(file)?; 296 | Ok(state) 297 | } 298 | Err(_) => Ok(State::new()), 299 | } 300 | } 301 | } 302 | 303 | 304 | /// This one is similar, but it's preferences independent of the server. Applies to ALL servers. 305 | /// 306 | #[derive(serde::Serialize, serde::Deserialize)] 307 | pub struct Preferences { 308 | // repeat mode 309 | #[serde(default)] 310 | pub repeat: Repeat, 311 | #[serde(default)] 312 | pub large_art: bool, 313 | 314 | #[serde(default)] 315 | pub transcoding: bool, 316 | 317 | 318 | #[serde(default)] 319 | pub artist_filter: Filter, 320 | #[serde(default)] 321 | pub artist_sort: Sort, 322 | #[serde(default)] 323 | pub album_filter: Filter, 324 | #[serde(default)] 325 | pub album_sort: Sort, 326 | #[serde(default)] 327 | pub playlist_filter: Filter, 328 | #[serde(default)] 329 | pub playlist_sort: Sort, 330 | #[serde(default = "Preferences::default_discography_track_sort")] 331 | pub tracks_sort: Sort, 332 | 333 | #[serde(default)] 334 | pub preferred_global_shuffle: Option, 335 | 336 | #[serde(default = "Preferences::default_theme")] 337 | pub theme: String, 338 | 339 | // here we define the preferred percentage splits for each section. Must add up to 100. 340 | #[serde(default = "Preferences::default_music_column_widths")] 341 | pub constraint_width_percentages_music: (u16, u16, u16), // (Artists, Albums, Tracks) 342 | } 343 | 344 | const MIN_WIDTH: u16 = 10; 345 | impl Preferences { 346 | pub fn new() -> Preferences { 347 | Self { 348 | repeat: Repeat::All, 349 | large_art: false, 350 | 351 | transcoding: false, 352 | 353 | artist_filter: Filter::default(), 354 | artist_sort: Sort::default(), 355 | album_filter: Filter::default(), 356 | album_sort: Sort::default(), 357 | playlist_filter: Filter::default(), 358 | playlist_sort: Sort::default(), 359 | tracks_sort: Sort::Descending, 360 | 361 | preferred_global_shuffle: Some(PopupMenu::GlobalShuffle { 362 | tracks_n: 100, 363 | only_played: true, 364 | only_unplayed: false, 365 | only_favorite: false, 366 | }), 367 | 368 | theme: String::from("Dark"), 369 | 370 | constraint_width_percentages_music: (22, 56, 22), 371 | } 372 | } 373 | 374 | pub fn default_music_column_widths() -> (u16, u16, u16) { 375 | (22, 56, 22) 376 | } 377 | 378 | 379 | fn default_theme() -> String { 380 | "Dark".to_string() 381 | } 382 | 383 | pub fn default_discography_track_sort() -> Sort { 384 | Sort::Descending 385 | } 386 | 387 | pub(crate) fn widen_current_pane( 388 | &mut self, 389 | active_section: &ActiveSection, 390 | up: bool, 391 | ) { 392 | let (a, b, c) = &mut self.constraint_width_percentages_music; 393 | 394 | match active_section { 395 | ActiveSection::List => { 396 | if up && *b > MIN_WIDTH { 397 | *a += 1; 398 | *b -= 1; 399 | } else if !up && *a > MIN_WIDTH { 400 | *a -= 1; 401 | *b += 1; 402 | } 403 | } 404 | ActiveSection::Tracks => { 405 | if up && *c > MIN_WIDTH { 406 | *b += 1; 407 | *c -= 1; 408 | } else if !up && *b > MIN_WIDTH { 409 | *b -= 1; 410 | *c += 1; 411 | } 412 | } 413 | ActiveSection::Lyrics | ActiveSection::Queue => { 414 | if up && *a > MIN_WIDTH { 415 | *c += 1; 416 | *a -= 1; 417 | } else if !up && *c > MIN_WIDTH { 418 | *c -= 1; 419 | *a += 1; 420 | } 421 | } 422 | _ => {} 423 | } 424 | 425 | Self::normalize(&mut self.constraint_width_percentages_music); 426 | } 427 | 428 | fn normalize(p: &mut (u16, u16, u16)) { 429 | let total = p.0 + p.1 + p.2; 430 | if total == 100 { 431 | return; 432 | } 433 | 434 | let excess = total as i16 - 100; 435 | let (i, max) = [p.0, p.1, p.2] 436 | .iter().cloned().enumerate().max_by_key(|(_, v)| *v).unwrap_or((0, 100)); 437 | 438 | match i { 439 | 0 => p.0 = (max as i16 - excess).clamp(MIN_WIDTH as i16, 100) as u16, 440 | 1 => p.1 = (max as i16 - excess).clamp(MIN_WIDTH as i16, 100) as u16, 441 | 2 => p.2 = (max as i16 - excess).clamp(MIN_WIDTH as i16, 100) as u16, 442 | _ => {} 443 | } 444 | } 445 | 446 | /// Save the current state to a file. We keep separate files for offline and online states. 447 | /// 448 | pub fn save(&self) -> Result<(), Box> { 449 | let data_dir = data_dir().unwrap(); 450 | let states_dir = data_dir.join("jellyfin-tui"); 451 | match OpenOptions::new() 452 | .create(true) 453 | .write(true) 454 | .truncate(true) 455 | .append(false) 456 | .open(states_dir.join("preferences.json")) 457 | { 458 | Ok(file) => { 459 | serde_json::to_writer(file, &self)?; 460 | } 461 | Err(_) => { 462 | return Err("Could not open state file".into()); 463 | } 464 | } 465 | Ok(()) 466 | } 467 | 468 | /// Load the state from a file. We keep separate files for offline and online states. 469 | /// 470 | pub fn load() -> Result> { 471 | let data_dir = data_dir().unwrap(); 472 | let states_dir = data_dir.join("jellyfin-tui"); 473 | match OpenOptions::new() 474 | .read(true) 475 | .open(states_dir.join("preferences.json")) 476 | { 477 | Ok(file) => { 478 | let prefs: Preferences = serde_json::from_reader(file)?; 479 | Ok(prefs) 480 | } 481 | Err(_) => Ok(Preferences::new()), 482 | } 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use dirs::{cache_dir, data_dir, config_dir}; 3 | use std::fs::OpenOptions; 4 | use std::io::Write; 5 | use std::os::unix::fs::OpenOptionsExt; 6 | use std::path::PathBuf; 7 | use dialoguer::{Confirm, Input, Password}; 8 | use crate::client::SelectedServer; 9 | use crate::themes::dialoguer::DialogTheme; 10 | 11 | #[derive(Debug, serde::Serialize, serde::Deserialize, Clone)] 12 | pub struct AuthEntry { 13 | pub known_urls: Vec, 14 | pub device_id: String, 15 | pub access_token: String, 16 | pub user_id: String, 17 | pub username: String, 18 | } 19 | // ServerId -> AuthEntry 20 | pub type AuthCache = HashMap; 21 | 22 | #[derive(Debug, Clone, Copy)] 23 | pub enum LyricsVisibility { 24 | Always, 25 | Auto, 26 | Never, 27 | } 28 | impl LyricsVisibility { 29 | pub fn from_config(val: &str) -> Self { 30 | match val { 31 | "auto" => Self::Auto, 32 | "never" => Self::Never, 33 | _ => Self::Always, 34 | } 35 | } 36 | } 37 | 38 | /// This makes sure all dirs are created before we do anything. 39 | /// Also makes unwraps on dirs::data_dir and config_dir safe to do. In theory ;) 40 | pub fn prepare_directories() -> Result<(), Box> { 41 | // these are the system-wide dirs like ~/.cache ~/.local/share and ~/config 42 | let cache_dir = cache_dir().expect(" ! Failed getting cache directory"); 43 | let data_dir = data_dir().expect(" ! Failed getting data directory"); 44 | let config_dir = config_dir().expect(" ! Failed getting config directory"); 45 | 46 | let j_cache_dir = cache_dir.join("jellyfin-tui"); 47 | let j_data_dir = data_dir.join("jellyfin-tui"); 48 | let j_config_dir = config_dir.join("jellyfin-tui"); 49 | 50 | std::fs::create_dir_all(&j_data_dir)?; 51 | std::fs::create_dir_all(&j_config_dir)?; 52 | 53 | // try to move existing files in cache to the data directory 54 | // it errors if nothing is in cache, so we explicitly ignore that 55 | // remove this and references to the cache dir at some point! 56 | match std::fs::rename(&j_cache_dir, &j_data_dir) { 57 | Ok(_) => (), 58 | Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => (), 59 | Err(ref e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => { 60 | println!(" ! Cache directory is not empty, please remove it manually: {}", j_cache_dir.display()); 61 | return Err(Box::new(std::io::Error::new(e.kind(), e.to_string()))); 62 | }, 63 | Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => { 64 | if std::fs::metadata(&j_cache_dir).is_ok() == true { 65 | fs_extra::dir::copy(&j_cache_dir, &j_data_dir, &fs_extra::dir::CopyOptions::new().content_only(true))?; 66 | std::fs::remove_dir_all(&j_cache_dir)?; 67 | } else { 68 | return Ok(()); 69 | } 70 | }, 71 | Err(e) => return Err(Box::new(e)) 72 | }; 73 | 74 | std::fs::create_dir_all(j_data_dir.join("log"))?; 75 | std::fs::create_dir_all(j_data_dir.join("covers"))?; 76 | std::fs::create_dir_all(j_data_dir.join("states"))?; 77 | std::fs::create_dir_all(j_data_dir.join("downloads"))?; 78 | std::fs::create_dir_all(j_data_dir.join("databases"))?; 79 | 80 | // deprecated files, remove this at some point! 81 | let _ = std::fs::remove_file(j_data_dir.join("state.json")); 82 | let _ = std::fs::remove_file(j_data_dir.join("offline_state.json")); 83 | let _ = std::fs::remove_file(j_data_dir.join("seen_artists")); 84 | let _ = std::fs::remove_file(j_data_dir.join("server_map.json")); 85 | 86 | Ok(()) 87 | } 88 | 89 | pub fn get_config() -> Result<(PathBuf, serde_yaml::Value), Box> { 90 | let config_dir = match config_dir() { 91 | Some(dir) => dir, 92 | None => { 93 | return Err("Could not find config directory".into()); 94 | } 95 | }; 96 | 97 | let config_file: PathBuf = config_dir.join("jellyfin-tui").join("config.yaml").into(); 98 | 99 | let f = std::fs::File::open(&config_file)?; 100 | let d = serde_yaml::from_reader(f)?; 101 | 102 | Ok((config_file, d)) 103 | } 104 | 105 | pub fn select_server(config: &serde_yaml::Value, force_server_select: bool) -> Option { 106 | 107 | // we now supposed servers as an array 108 | let servers = match config["servers"].as_sequence() { 109 | Some(s) => s, 110 | None => { 111 | println!(" ! Could not find servers in config file"); 112 | std::process::exit(1); 113 | } 114 | }; 115 | 116 | if servers.is_empty() { 117 | println!(" ! No servers configured in config file"); 118 | std::process::exit(1); 119 | } 120 | 121 | let selected_server = if servers.len() == 1 { 122 | // if there is only one server, we use that one 123 | servers[0].clone() 124 | } else { 125 | // server set to default skips the selection dialog :) 126 | if let Some(default_server) = servers.iter().find(|s| s.get("default").and_then(|v| v.as_bool()).unwrap_or(false)) { 127 | if !force_server_select { 128 | println!(" - Server: {} [{}] — use --select-server to switch.", 129 | default_server["name"].as_str().unwrap_or("Unnamed"), 130 | default_server["url"].as_str().unwrap_or("Unknown")); 131 | return Some(SelectedServer { 132 | url: default_server["url"].as_str().unwrap_or("").to_string(), 133 | name: default_server["name"].as_str().unwrap_or("Unnamed").to_string(), 134 | username: default_server["username"].as_str().unwrap_or("").to_string(), 135 | password: default_server["password"].as_str().unwrap_or("").to_string(), 136 | }); 137 | } 138 | } 139 | // otherwise if there are multiple servers, we ask the user to select one 140 | let server_names: Vec = servers 141 | .iter() 142 | // Name (URL) 143 | .filter_map(|s| format!("{} ({})", s["name"].as_str().unwrap_or("Unnamed"), s["url"].as_str().unwrap_or("Unknown")).into()) 144 | .collect(); 145 | if server_names.is_empty() { 146 | println!(" ! No servers configured in config file"); 147 | std::process::exit(1); 148 | } 149 | let selection = dialoguer::Select::with_theme(&DialogTheme::default()) 150 | .with_prompt("Which server would you like to use?") 151 | .items(&server_names) 152 | .default(0) 153 | .interact() 154 | .unwrap_or(0); 155 | servers[selection].clone() 156 | }; 157 | 158 | let url = match selected_server["url"].as_str() { 159 | Some(url) => { 160 | if url.ends_with('/') { 161 | println!(" ! URL ends with a trailing slash, please remove it."); 162 | std::process::exit(1); 163 | } else { 164 | url.to_string() 165 | } 166 | } 167 | None => { 168 | println!(" ! Selected server does not have a URL configured"); 169 | std::process::exit(1); 170 | } 171 | }; 172 | let name = match selected_server["name"].as_str() { 173 | Some(name) => name.to_string(), 174 | None => { 175 | println!(" ! Selected server does not have a name configured"); 176 | std::process::exit(1); 177 | } 178 | }; 179 | let username = match selected_server["username"].as_str() { 180 | Some(username) => username.to_string(), 181 | None => { 182 | println!(" ! Selected server does not have a username configured"); 183 | std::process::exit(1); 184 | } 185 | }; 186 | let password = match ( 187 | selected_server["password"].as_str(), 188 | selected_server["password_file"].as_str(), 189 | ) { 190 | (None, Some(password_file)) => match std::fs::read_to_string(password_file) { 191 | Ok(password_body) => password_body.trim_matches(&['\n', '\r']).to_string(), 192 | Err(err) => { 193 | println!( 194 | " ! error reading password file '{}': {}", 195 | password_file, err 196 | ); 197 | std::process::exit(1); 198 | } 199 | }, 200 | (Some(password), None) => password.to_string(), 201 | (Some(_), Some(_)) => { 202 | println!( 203 | " ! Selected server has password and password_file configured, only choose one" 204 | ); 205 | std::process::exit(1); 206 | } 207 | (None, None) => { 208 | println!(" ! Selected server does not have a password configured"); 209 | std::process::exit(1); 210 | } 211 | }; 212 | Some(SelectedServer { 213 | url, name, username, password 214 | }) 215 | } 216 | 217 | pub fn initialize_config() { 218 | let config_dir = match config_dir() { 219 | Some(dir) => dir, 220 | None => { 221 | println!(" ! Could not find config directory"); 222 | std::process::exit(1); 223 | } 224 | }; 225 | 226 | let config_file = config_dir.join("jellyfin-tui").join("config.yaml"); 227 | 228 | let mut updating = false; 229 | if config_file.exists() { 230 | 231 | // the config file changed this version. Let's check for a servers array, if it doesn't exist we do the following 232 | // 1. rename old config 233 | // 2. run the rest of this function to create a new config file and tell the user about it 234 | if let Ok(content) = std::fs::read_to_string(&config_file) { 235 | if !content.contains("servers:") && content.contains("server:") { 236 | updating = true; 237 | let old_config_file = config_file.with_extension("_old"); 238 | std::fs::rename(&config_file, &old_config_file).expect(" ! Could not rename old config file"); 239 | println!(" ! Your config file is outdated and has been backed up to: config_old.yaml"); 240 | println!(" ! A new config will now be created. Please go through the setup again."); 241 | println!(" ! This is done to support the new offline mode and multiple servers.\n"); 242 | } 243 | } 244 | if !updating { 245 | println!( 246 | " - Config loaded: {}", config_file.display() 247 | ); 248 | return; 249 | } 250 | } 251 | 252 | let mut server_name = String::new(); 253 | let mut server_url = String::new(); 254 | let mut username = String::new(); 255 | let mut password = String::new(); 256 | 257 | println!(" - Thank you for trying jellyfin-tui! <3\n"); 258 | println!(" - If you encounter issues or missing features, please report them here:"); 259 | println!(" - https://github.com/dhonus/jellyfin-tui/issues\n"); 260 | println!(" ! Configuration file not found. Please enter the following details:\n"); 261 | 262 | let http_client = reqwest::blocking::Client::new(); 263 | 264 | let mut ok = false; 265 | let mut counter = 0; 266 | while !ok { 267 | server_url = Input::with_theme(&DialogTheme::default()) 268 | .with_prompt("Server URL") 269 | .with_initial_text("https://") 270 | .validate_with({ 271 | move |input: &String| -> Result<(), &str> { 272 | if input.starts_with("http://") || input.starts_with("https://") && input != "http://" && input != "https://" { 273 | Ok(()) 274 | } else { 275 | Err("Please enter a valid URL including http or https") 276 | } 277 | } 278 | }) 279 | .interact_text() 280 | .unwrap(); 281 | 282 | if server_url.ends_with('/') { 283 | server_url.pop(); 284 | } 285 | 286 | server_name = Input::with_theme(&DialogTheme::default()) 287 | .with_prompt("Server name") 288 | .with_initial_text("Home Server") 289 | .interact_text() 290 | .unwrap(); 291 | 292 | username = Input::with_theme(&DialogTheme::default()) 293 | .with_prompt("Username") 294 | .interact_text() 295 | .unwrap(); 296 | 297 | password = Password::with_theme(&DialogTheme::default()) 298 | .allow_empty_password(true) 299 | .with_prompt("Password") 300 | .interact() 301 | .unwrap(); 302 | 303 | { 304 | let url: String = String::new() + &server_url + "/Users/authenticatebyname"; 305 | match http_client 306 | .post(url) 307 | .header("Content-Type", "text/json") 308 | .header("Authorization", format!("MediaBrowser Client=\"jellyfin-tui\", Device=\"jellyfin-tui\", DeviceId=\"jellyfin-tui\", Version=\"{}\"", env!("CARGO_PKG_VERSION"))) 309 | .json(&serde_json::json!({ 310 | "Username": &username, 311 | "Pw": &password, 312 | })) 313 | .send() { 314 | Ok(response) => { 315 | if !response.status().is_success() { 316 | println!(" ! Error authenticating: {}", response.status()); 317 | continue; 318 | } 319 | let value = match response.json::() { 320 | Ok(v) => v, 321 | Err(e) => { 322 | println!(" ! Error authenticating: {}", e); 323 | continue; 324 | } 325 | }; 326 | if value["AccessToken"].is_null() { 327 | println!(" ! Error authenticating: No access token received"); 328 | continue; 329 | } 330 | if value["ServerId"].is_null() { 331 | println!(" ! Error authenticating: No server ID received"); 332 | continue; 333 | } 334 | } 335 | Err(e) => { 336 | println!(" ! Error authenticating: {}", e); 337 | continue; 338 | } 339 | } 340 | } 341 | 342 | match Confirm::with_theme(&DialogTheme::default()) 343 | .with_prompt(format!("Success! Use server '{}' ({}) Username: '{}'?", server_name.trim(), server_url.trim(), username.trim())) 344 | .default(true) 345 | .wait_for_newline(true) 346 | .interact_opt() 347 | .unwrap() 348 | { 349 | Some(true) => { 350 | ok = true; 351 | } 352 | _ => { 353 | counter += 1; 354 | if counter >= 3 { 355 | println!(" 𝄆 I believe in you! You can do it! 𝄆"); 356 | } else { 357 | println!(" ! Let's try again.\n"); 358 | } 359 | } 360 | } 361 | } 362 | 363 | let default_config = serde_yaml::to_string(&serde_json::json!({ 364 | "servers": [ 365 | { 366 | "name": server_name.trim(), 367 | "url": server_url.trim(), 368 | "username": username.trim(), 369 | "password": password.trim(), 370 | } 371 | ], 372 | })).expect(" ! Could not serialize default configuration"); 373 | 374 | let mut file = OpenOptions::new() 375 | .write(true) 376 | .create_new(true) 377 | .mode(0o600) 378 | .open(&config_file) 379 | .expect(" ! Could not create config file"); 380 | file.write_all(default_config.as_bytes()) 381 | .expect(" ! Could not write default config"); 382 | 383 | println!( 384 | "\n - Created default config file at: {}", 385 | config_file 386 | .to_str() 387 | .expect(" ! Could not convert config path to string.") 388 | ); 389 | } 390 | 391 | pub fn load_auth_cache() -> Result> { 392 | let path = dirs::data_dir().unwrap().join("jellyfin-tui").join("auth_cache.json"); 393 | if !path.exists() { 394 | return Ok(HashMap::new()); 395 | } 396 | let content = std::fs::read_to_string(path)?; 397 | let cache: AuthCache = serde_json::from_str(&content)?; 398 | Ok(cache) 399 | } 400 | 401 | pub fn save_auth_cache(cache: &AuthCache) -> Result<(), Box> { 402 | let path = dirs::data_dir().unwrap().join("jellyfin-tui").join("auth_cache.json"); 403 | let json = serde_json::to_string_pretty(cache)?; 404 | 405 | let mut file = { 406 | let mut opts = OpenOptions::new(); 407 | opts.write(true).create(true).truncate(true); 408 | opts.mode(0o600); 409 | opts.open(&path)? 410 | }; 411 | 412 | file.write_all(json.as_bytes())?; 413 | Ok(()) 414 | } 415 | 416 | pub fn find_cached_auth_by_url<'a>( 417 | cache: &'a AuthCache, url: &str 418 | ) -> Option<(&'a String, &'a AuthEntry)> { 419 | for (server_id, entry) in cache { 420 | if entry.known_urls.contains(&url.to_string()) { 421 | return Some((server_id, entry)); 422 | } 423 | } 424 | None 425 | } 426 | 427 | /// This is called after a successful connection. 428 | /// Writes a mapping of (Server from config.yaml) -> (ServerId from Jellyfin), among other things, to a file. 429 | /// This is later used to show the server name when choosing an offline database. 430 | pub fn update_cache_with_new_auth( 431 | mut cache: AuthCache, 432 | selected_server: &SelectedServer, 433 | client: &crate::client::Client, 434 | ) -> AuthCache { 435 | let server_id = &client.server_id; 436 | 437 | let entry = cache.entry(server_id.clone()).or_insert(AuthEntry { 438 | known_urls: vec![], 439 | device_id: client.device_id.clone(), 440 | access_token: client.access_token.clone(), 441 | user_id: client.user_id.clone(), 442 | username: client.user_name.clone(), 443 | }); 444 | 445 | if !entry.known_urls.contains(&selected_server.url) { 446 | entry.known_urls.push(selected_server.url.clone()); 447 | } 448 | 449 | entry.access_token = client.access_token.clone(); 450 | entry.user_id = client.user_id.clone(); 451 | entry.username = client.user_name.clone(); 452 | 453 | cache 454 | } 455 | -------------------------------------------------------------------------------- /src/playlists.rs: -------------------------------------------------------------------------------- 1 | /* -------------------------- 2 | The playlists tab is rendered here. 3 | -------------------------- */ 4 | 5 | use crate::{client::Playlist, database::extension::DownloadStatus, helpers}; 6 | use crate::keyboard::*; 7 | use crate::tui::App; 8 | 9 | use ratatui::{ 10 | prelude::*, 11 | widgets::*, 12 | widgets::{Block, Borders}, 13 | Frame, 14 | }; 15 | use ratatui_image::{Resize, StatefulImage}; 16 | use crate::config::LyricsVisibility; 17 | 18 | impl App { 19 | pub fn render_playlists(&mut self, app_container: Rect, frame: &mut Frame) { 20 | let show_lyrics_column = !matches!(self.lyrics_visibility, LyricsVisibility::Never); 21 | 22 | let outer_layout = Layout::default() 23 | .direction(Direction::Horizontal) 24 | .constraints(vec![ 25 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.0), 26 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.1), 27 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.2), 28 | ]) 29 | .split(app_container); 30 | 31 | let left = if self.preferences.large_art { 32 | if let Some(cover_art) = self.cover_art.as_mut() { 33 | let outer_area = outer_layout[0]; 34 | let block = Block::default() 35 | .borders(Borders::ALL) 36 | .title(Line::from("Cover art").fg(self.theme.resolve(&self.theme.section_title)).left_aligned()) 37 | .fg(self.theme.resolve(&self.theme.section_title)) 38 | .border_type(self.border_type) 39 | .border_style(self.theme.resolve(&self.theme.border)); 40 | 41 | let chunk_area = block.inner(outer_area); 42 | let img_area = cover_art.size_for(Resize::Scale(None), chunk_area); 43 | 44 | let block_total_height = img_area.height + 2; 45 | let top_height = outer_area.height.saturating_sub(block_total_height); 46 | 47 | let layout = Layout::default() 48 | .direction(Direction::Vertical) 49 | .constraints(vec![ 50 | Constraint::Length(top_height), // playlist list area 51 | Constraint::Length(block_total_height), // image area 52 | ]) 53 | .split(outer_area); 54 | 55 | frame.render_widget(block, layout[1]); 56 | 57 | let inner_area = layout[1].inner(Margin { 58 | vertical: 1, 59 | horizontal: 1, 60 | }); 61 | let final_centered = Rect { 62 | x: inner_area.x + (inner_area.width.saturating_sub(img_area.width)) / 2, 63 | y: inner_area.y, 64 | width: img_area.width, 65 | height: img_area.height, 66 | }; 67 | 68 | let image = StatefulImage::default().resize(Resize::Scale(None)); 69 | frame.render_stateful_widget(image, final_centered, cover_art); 70 | 71 | layout 72 | } else { 73 | Layout::default() 74 | .direction(Direction::Vertical) 75 | .constraints(vec![Constraint::Percentage(100)]) 76 | .split(outer_layout[0]) 77 | } 78 | 79 | // these two should be the same 80 | } else { 81 | Layout::default() 82 | .direction(Direction::Vertical) 83 | .constraints(vec![Constraint::Percentage(100)]) 84 | .split(outer_layout[0]) 85 | }; 86 | 87 | // create a wrapper, to get the width. After that create the inner 'left' and split it 88 | let center = Layout::default() 89 | .direction(Direction::Vertical) 90 | .constraints(vec![ 91 | Constraint::Percentage(100), 92 | Constraint::Length( 93 | if self.preferences.large_art { 7 } else { 8 } 94 | ), 95 | ]) 96 | .split(outer_layout[1]); 97 | 98 | let has_lyrics = self.lyrics.as_ref() 99 | .is_some_and(|(_, l, _)| !l.is_empty()); 100 | 101 | let show_panel = match self.lyrics_visibility { 102 | LyricsVisibility::Auto => has_lyrics, 103 | LyricsVisibility::Always => true, 104 | LyricsVisibility::Never => false, 105 | }; 106 | 107 | let lyrics_slot_constraints = if show_panel { 108 | if has_lyrics && !self.lyrics.as_ref().map_or(true, |(_, l, _)| l.len() == 1) { 109 | vec![ 110 | Constraint::Percentage(68), 111 | Constraint::Percentage(32), 112 | Constraint::Min(if self.download_item.is_some() { 3 } else { 0 }), 113 | ] 114 | } else { 115 | vec![ 116 | Constraint::Min(3), 117 | Constraint::Percentage(100), 118 | Constraint::Min(if self.download_item.is_some() { 3 } else { 0 }), 119 | ] 120 | } 121 | } else { 122 | vec![ 123 | Constraint::Min(0), 124 | Constraint::Percentage(100), 125 | Constraint::Min(if self.download_item.is_some() { 3 } else { 0 }), 126 | ] 127 | }; 128 | 129 | let right = Layout::default() 130 | .direction(Direction::Vertical) 131 | .constraints(lyrics_slot_constraints) 132 | .split(outer_layout[2]); 133 | 134 | let playlist_block = match self.state.active_section { 135 | ActiveSection::List => Block::new() 136 | .borders(Borders::ALL) 137 | .border_style(self.theme.resolve(&self.theme.border_focused)), 138 | _ => Block::new() 139 | .borders(Borders::ALL) 140 | .border_style(self.theme.resolve(&self.theme.border)), 141 | }.border_type(self.border_type); 142 | 143 | let selected_playlist = self.get_id_of_selected(&self.playlists, Selectable::Playlist); 144 | let mut playlist_highlight_style = match self.state.active_section { 145 | ActiveSection::List => Style::default() 146 | .bg(self.theme.resolve(&self.theme.selected_active_background)) 147 | .fg(self.theme.resolve(&self.theme.selected_active_foreground)) 148 | .add_modifier(Modifier::BOLD), 149 | _ => Style::default() 150 | .add_modifier(Modifier::BOLD) 151 | .bg(self.theme.resolve(&self.theme.selected_inactive_background)) 152 | .fg(self.theme.resolve(&self.theme.selected_inactive_foreground)) 153 | .add_modifier(Modifier::BOLD), 154 | }; 155 | 156 | if self.state.current_playlist.id == selected_playlist { 157 | playlist_highlight_style = playlist_highlight_style.add_modifier(Modifier::ITALIC); 158 | } 159 | let playlists = search_results(&self.playlists, &self.state.playlists_search_term, true) 160 | .iter() 161 | .map(|id| { 162 | self.playlists 163 | .iter() 164 | .find(|playlist| playlist.id == *id) 165 | .unwrap() 166 | }) 167 | .collect::>(); 168 | 169 | let terminal_height = frame.area().height as usize; 170 | let selection = self.state.selected_playlist.selected().unwrap_or(0); 171 | 172 | // dynamic pageup/down height calc 173 | let playlist_block_inner_h = playlist_block.inner(left[0]).height as usize; 174 | self.left_list_height = playlist_block_inner_h.max(1); 175 | 176 | let items = playlists 177 | .iter() 178 | .enumerate() 179 | .map(|(i, playlist)| { 180 | if i < selection.saturating_sub(terminal_height) 181 | || i > selection + terminal_height 182 | { 183 | return ListItem::new(Text::raw("")); 184 | } 185 | let color = if playlist.id == self.state.current_playlist.id { 186 | self.theme.primary_color 187 | } else { 188 | self.theme.resolve(&self.theme.foreground) 189 | }; 190 | 191 | // underline the matching search subsequence ranges 192 | let mut item = Text::default(); 193 | let mut last_end = 0; 194 | 195 | if playlist.user_data.is_favorite { 196 | item.push_span(Span::styled("♥ ", Style::default().fg(self.theme.primary_color))); 197 | } 198 | 199 | let all_subsequences = crate::helpers::find_all_subsequences( 200 | &self.state.playlists_search_term.to_lowercase(), 201 | &playlist.name.to_lowercase(), 202 | ); 203 | for (start, end) in all_subsequences { 204 | if last_end < start { 205 | item.push_span(Span::styled( 206 | &playlist.name[last_end..start], 207 | Style::default().fg(color), 208 | )); 209 | } 210 | 211 | item.push_span(Span::styled( 212 | &playlist.name[start..end], 213 | Style::default().fg(color).underlined(), 214 | )); 215 | 216 | last_end = end; 217 | } 218 | 219 | if last_end < playlist.name.len() { 220 | item.push_span(Span::styled( 221 | &playlist.name[last_end..], 222 | Style::default().fg(color), 223 | )); 224 | } 225 | ListItem::new(item) 226 | }) 227 | .collect::>(); 228 | 229 | // color of the titles ("Playlists" and "Tracks" text in the borders) 230 | let [playlists_title_color, tracks_title_color] = match self.state.active_section { 231 | ActiveSection::List => [self.theme.primary_color, self.theme.resolve(&self.theme.section_title)], 232 | ActiveSection::Tracks => [self.theme.resolve(&self.theme.section_title), self.theme.primary_color], 233 | _ => [self.theme.resolve(&self.theme.section_title), self.theme.resolve(&self.theme.section_title)], 234 | }; 235 | 236 | let items_len = items.len(); 237 | let list = List::new(items) 238 | .block(if self.state.playlists_search_term.is_empty() { 239 | playlist_block 240 | .title_alignment(Alignment::Right) 241 | .title_top( 242 | Line::from("Playlists").fg(playlists_title_color).left_aligned() 243 | ) 244 | .title_top(Line::from(format!("({} playlists)", items_len)).fg(playlists_title_color).right_aligned()) 245 | .title_position(block::Position::Bottom) 246 | } else { 247 | playlist_block 248 | .title_alignment(Alignment::Right) 249 | .title_top( 250 | Line::from(format!("Matching: {}", self.state.playlists_search_term)) 251 | .fg(playlists_title_color) 252 | .left_aligned(), 253 | ) 254 | .title_top( 255 | Line::from(format!("({} playlists)", items_len)) 256 | .fg(playlists_title_color).right_aligned(), 257 | ) 258 | .title_position(block::Position::Bottom) 259 | }) 260 | .highlight_symbol(">>") 261 | .highlight_style(playlist_highlight_style) 262 | .scroll_padding(10) 263 | .repeat_highlight_symbol(true); 264 | 265 | frame.render_stateful_widget(list, left[0], &mut self.state.selected_playlist); 266 | 267 | helpers::render_scrollbar( 268 | frame, left[0], 269 | &mut self.state.playlists_scroll_state, 270 | &self.theme 271 | ); 272 | 273 | let track_block = match self.state.active_section { 274 | ActiveSection::Tracks => Block::new() 275 | .borders(Borders::ALL) 276 | .border_style(self.theme.resolve(&self.theme.border_focused)), 277 | _ => Block::new() 278 | .borders(Borders::ALL) 279 | .border_style(self.theme.resolve(&self.theme.border)), 280 | }.border_type(self.border_type); 281 | 282 | let track_highlight_style = match self.state.active_section { 283 | ActiveSection::Tracks => Style::default() 284 | .bg(self.theme.resolve(&self.theme.selected_active_background)) 285 | .fg(self.theme.resolve(&self.theme.selected_active_foreground)) 286 | .add_modifier(Modifier::BOLD), 287 | _ => Style::default() 288 | .bg(self.theme.resolve(&self.theme.selected_inactive_background)) 289 | .fg(self.theme.resolve(&self.theme.selected_inactive_foreground)) 290 | .add_modifier(Modifier::BOLD), 291 | }; 292 | 293 | let playlist_tracks = search_results( 294 | &self.playlist_tracks, 295 | &self.state.playlist_tracks_search_term, 296 | true, 297 | ) 298 | .iter() 299 | .map(|id| self.playlist_tracks.iter().find(|t| t.id == *id).unwrap()) 300 | .collect::>(); 301 | 302 | let terminal_height = frame.area().height as usize; 303 | let selection = self.state.selected_playlist_track.selected().unwrap_or(0); 304 | 305 | // dynamic pageup/down height calc 306 | let table_block_inner = track_block.inner(center[0]); 307 | let header_h: u16 = 1; 308 | let table_body_h = table_block_inner.height.saturating_sub(header_h) as usize; 309 | self.track_list_height = table_body_h.max(1); 310 | 311 | let items = playlist_tracks 312 | .iter() 313 | .enumerate() 314 | .map(|(i, track)| { 315 | if i < selection.saturating_sub(terminal_height) 316 | || i > selection + terminal_height 317 | { 318 | return Row::default(); 319 | } 320 | // track.run_time_ticks is in microseconds 321 | let seconds = (track.run_time_ticks / 10_000_000) % 60; 322 | let minutes = (track.run_time_ticks / 10_000_000 / 60) % 60; 323 | let hours = (track.run_time_ticks / 10_000_000 / 60) / 60; 324 | let hours_optional_text = match hours { 325 | 0 => String::from(""), 326 | _ => format!("{}:", hours), 327 | }; 328 | 329 | let all_subsequences = crate::helpers::find_all_subsequences( 330 | &self.state.playlist_tracks_search_term.to_lowercase(), 331 | &track.name.to_lowercase(), 332 | ); 333 | 334 | let mut title = vec![]; 335 | let mut last_end = 0; 336 | let color = if track.id == self.active_song_id { 337 | self.theme.primary_color 338 | } else if track.disliked { 339 | self.theme.resolve(&self.theme.foreground_dim) 340 | } else { 341 | self.theme.resolve(&self.theme.foreground) 342 | }; 343 | for (start, end) in &all_subsequences { 344 | if &last_end < start { 345 | title.push(Span::styled( 346 | &track.name[last_end..*start], 347 | Style::default().fg(color), 348 | )); 349 | } 350 | 351 | title.push(Span::styled( 352 | &track.name[*start..*end], 353 | Style::default().fg(color).underlined(), 354 | )); 355 | 356 | last_end = *end; 357 | } 358 | 359 | if last_end < track.name.len() { 360 | title.push(Span::styled( 361 | &track.name[last_end..], 362 | Style::default().fg(color), 363 | )); 364 | } 365 | 366 | let mut cells = vec![ 367 | // No. 368 | Cell::from(format!("{}.", i + 1)).style( 369 | if track.id == self.active_song_id { 370 | Style::default().fg(color) 371 | } else { 372 | Style::default().fg(Color::DarkGray) 373 | }, 374 | ), 375 | // title 376 | Cell::from(if all_subsequences.is_empty() { 377 | track.name.to_string().into() 378 | } else { 379 | Line::from(title) 380 | }), 381 | // artists 382 | Cell::from( 383 | track 384 | .album_artists 385 | .iter() 386 | .map(|artist| artist.name.clone()) 387 | .collect::>() 388 | .join(", "), 389 | ), 390 | Cell::from(track.album.clone()), 391 | // ⇊ 392 | Cell::from(match track.download_status { 393 | DownloadStatus::Downloaded => Line::from("⇊"), 394 | DownloadStatus::Queued => Line::from("◴"), 395 | DownloadStatus::Downloading => Line::from(self.spinner_stages[self.spinner]), 396 | DownloadStatus::NotDownloaded => Line::from(""), 397 | }), 398 | // ♥ 399 | Cell::from(if track.user_data.is_favorite { "♥" } else { "" }) 400 | .style(Style::default().fg(self.theme.primary_color)), 401 | ]; 402 | // ♪ 403 | if show_lyrics_column { 404 | cells.push(Cell::from(if track.has_lyrics { "♪" } else { "" })); 405 | } 406 | cells.push(Cell::from(format!("{}", track.user_data.play_count))); 407 | cells.push(Cell::from(format!( 408 | "{}{:02}:{:02}", 409 | hours_optional_text, 410 | minutes, 411 | seconds 412 | ))); 413 | 414 | Row::new(cells).style( 415 | if track.id == self.active_song_id { 416 | Style::default().fg(self.theme.primary_color).italic() 417 | } else if track.disliked { 418 | Style::default().fg(self.theme.resolve(&self.theme.foreground_dim)) 419 | } else { 420 | Style::default().fg(self.theme.resolve(&self.theme.foreground)) 421 | } 422 | ) 423 | }) 424 | .collect::>(); 425 | 426 | let track_instructions = Line::from(vec![ 427 | " Help ".fg(self.theme.resolve(&self.theme.section_title)), 428 | "".fg(self.theme.primary_color).bold(), 429 | " Quit ".fg(self.theme.resolve(&self.theme.section_title)), 430 | "<^C> ".fg(self.theme.primary_color).bold(), 431 | ]); 432 | let mut widths = vec![ 433 | Constraint::Length(items.len().to_string().len() as u16 + 2), 434 | Constraint::Percentage(50), // title and track even width 435 | Constraint::Percentage(25), 436 | Constraint::Percentage(25), 437 | Constraint::Length(1), 438 | Constraint::Length(1), 439 | ]; 440 | if show_lyrics_column { 441 | widths.push(Constraint::Length(1)); 442 | } 443 | widths.push(Constraint::Length(5)); 444 | widths.push(Constraint::Length(10)); 445 | 446 | if self.playlist_tracks.is_empty() { 447 | let message_paragraph = Paragraph::new(if self.state.current_playlist.id.is_empty() { 448 | "jellyfin-tui".to_string() 449 | } else { 450 | "No tracks in the current playlist".to_string() 451 | }) 452 | .fg(self.theme.resolve(&self.theme.foreground)) 453 | .block( 454 | track_block 455 | .title(Line::from("Tracks").fg(tracks_title_color).left_aligned()) 456 | .fg(self.theme.resolve(&self.theme.foreground)) 457 | .padding(Padding::new(0, 0, center[0].height / 2, 0)) 458 | .title_bottom(track_instructions.alignment(Alignment::Center)), 459 | ) 460 | .wrap(Wrap { trim: false }) 461 | .alignment(Alignment::Center); 462 | frame.render_widget(message_paragraph, center[0]); 463 | } else { 464 | let items_len = items.len(); 465 | let totaltime = self.state.current_playlist.run_time_ticks / 10_000_000; 466 | let seconds = totaltime % 60; 467 | let minutes = (totaltime / 60) % 60; 468 | let hours = totaltime / 60 / 60; 469 | let hours_optional_text = match hours { 470 | 0 => String::from(""), 471 | _ => format!("{}:", hours), 472 | }; 473 | let duration = format!("{}{:02}:{:02}", hours_optional_text, minutes, seconds); 474 | 475 | let mut header_cells = vec!["No.", "Title", "Artist", "Album", "⇊", "♥"]; 476 | if show_lyrics_column { 477 | header_cells.push("♪"); 478 | } 479 | header_cells.push("Plays"); 480 | header_cells.push("Duration"); 481 | 482 | let table = Table::new(items, widths) 483 | .block( 484 | if self.state.playlist_tracks_search_term.is_empty() 485 | && !self.state.current_playlist.name.is_empty() 486 | { 487 | track_block 488 | .title(Line::from(format!( 489 | "{}{}", 490 | self.state.current_playlist.name, 491 | if self.playlist_stale { 492 | format!(" {}", &self.spinner_stages[self.spinner]) 493 | } else { 494 | String::new() 495 | } 496 | )).fg(tracks_title_color).left_aligned()) 497 | .title_top( 498 | Line::from(format!( 499 | "({} tracks - {})", 500 | self.playlist_tracks.len(), 501 | duration 502 | )).fg(tracks_title_color) 503 | .right_aligned(), 504 | ) 505 | .title_top( 506 | Line::from( 507 | if self.playlist_incomplete { 508 | format!("{} Fetching remaining tracks", &self.spinner_stages[self.spinner]) 509 | } else { "".into() } 510 | ).fg(self.theme.resolve(&self.theme.section_title)).centered() 511 | ) 512 | .title_bottom(track_instructions.alignment(Alignment::Center)) 513 | } else { 514 | track_block 515 | .title( 516 | Line::from(format!("Matching: {}", self.state.playlist_tracks_search_term)) 517 | .fg(tracks_title_color) 518 | ) 519 | .title_top( 520 | Line::from(format!("({} tracks)", items_len)).fg(tracks_title_color).right_aligned() 521 | ) 522 | .title_bottom(track_instructions.alignment(Alignment::Center)) 523 | }, 524 | ) 525 | .row_highlight_style(track_highlight_style) 526 | .highlight_symbol(">>") 527 | .style(Style::default().bg(self.theme.resolve_opt(&self.theme.background).unwrap_or(Color::Reset))) 528 | .header( 529 | Row::new(header_cells) 530 | .style(Style::new().bold().fg(self.theme.resolve(&self.theme.foreground))) 531 | .bottom_margin(0), 532 | ); 533 | frame.render_widget(Clear, center[0]); 534 | frame.render_stateful_widget(table, center[0], &mut self.state.selected_playlist_track); 535 | } 536 | 537 | if self.locally_searching { 538 | let searching_instructions = Line::from(vec![ 539 | " Confirm ".fg(self.theme.resolve(&self.theme.section_title)), 540 | "".fg(self.theme.primary_color).bold(), 541 | " Clear and keep selection ".fg(self.theme.resolve(&self.theme.section_title)), 542 | " ".fg(self.theme.primary_color).bold(), 543 | ]); 544 | if self.state.active_section == ActiveSection::Tracks { 545 | frame.render_widget( 546 | Block::default() 547 | .borders(Borders::ALL) 548 | .title(format!( 549 | "Searching: {}", 550 | self.state.playlist_tracks_search_term 551 | )) 552 | .title_bottom(searching_instructions.alignment(Alignment::Center)) 553 | .border_type(self.border_type) 554 | .border_style(self.theme.resolve(&self.theme.border_focused)), 555 | center[0], 556 | ); 557 | } 558 | if self.state.active_section == ActiveSection::List { 559 | frame.render_widget( 560 | Block::default() 561 | .borders(Borders::ALL) 562 | .title(format!("Searching: {}", self.state.playlists_search_term)) 563 | .border_type(self.border_type) 564 | .border_style(self.theme.resolve(&self.theme.border_focused)), 565 | left[0], 566 | ); 567 | } 568 | } 569 | 570 | helpers::render_scrollbar( 571 | frame, center[0], 572 | &mut self.state.playlist_tracks_scroll_state, 573 | &self.theme 574 | ); 575 | 576 | self.render_player(frame, ¢er); 577 | self.render_library_right(frame, right); 578 | 579 | self.create_popup(frame); 580 | } 581 | } 582 | -------------------------------------------------------------------------------- /src/queue.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | /// This file has all the queue control functions 3 | /// the basic idea is keeping our queue in sync with mpv and doing some basic operations 4 | /// 5 | use crate::{client::DiscographySong, database::extension::DownloadStatus, helpers, tui::{App, Song}}; 6 | use rand::seq::SliceRandom; 7 | use crate::client::{Client, Transcoding}; 8 | use crate::database::database::{Command, UpdateCommand}; 9 | 10 | fn make_track( 11 | client: Option<&Arc>, 12 | downloads_dir: &std::path::PathBuf, 13 | track: &DiscographySong, 14 | is_in_queue: bool, 15 | transcoding: &Transcoding, 16 | ) -> Song { 17 | Song { 18 | id: track.id.clone(), 19 | url: match track.download_status { 20 | DownloadStatus::Downloaded => { 21 | format!("{}", downloads_dir 22 | .join(&track.server_id).join(&track.album_id).join(&track.id) 23 | .to_string_lossy() 24 | ) 25 | } 26 | _ => match &client { 27 | Some(client) => client.song_url_sync(&track.id, transcoding), 28 | None => "".to_string(), 29 | }, 30 | }, 31 | name: track.name.clone(), 32 | artist: track.album_artist.clone(), 33 | artist_items: track.album_artists.clone(), 34 | album: track.album.clone(), 35 | album_id: track.album_id.clone(), 36 | // parent_id: track.parent_id.clone(), 37 | production_year: track.production_year, 38 | is_in_queue, 39 | is_transcoded: transcoding.enabled && !matches!(track.download_status, DownloadStatus::Downloaded), 40 | is_favorite: track.user_data.is_favorite, 41 | original_index: 0, 42 | run_time_ticks: track.run_time_ticks, 43 | disliked: track.disliked, 44 | } 45 | } 46 | 47 | impl App { 48 | /// This is the main queue control function. It basically initiates a new queue when we play a song without modifiers 49 | /// 50 | pub async fn initiate_main_queue(&mut self, tracks: &[DiscographySong], skip: usize) { 51 | if tracks.is_empty() { 52 | return; 53 | } 54 | let selected_is_album = tracks 55 | .get(skip) 56 | .is_some_and(|t| t.id.starts_with("_album_")); 57 | 58 | // the playlist MPV will be getting 59 | self.state.queue = tracks 60 | .iter() 61 | .enumerate() 62 | .skip(skip) 63 | .filter(|(i, track)| { 64 | if *i == skip { 65 | true 66 | } else { 67 | !track.disliked 68 | } 69 | }) 70 | // if selected is an album, this will filter out all the tracks that are not part of the album 71 | .filter(|(_, track)| { 72 | !selected_is_album 73 | || track.parent_id == tracks.get(skip + 1).map_or("", |t| &t.parent_id) 74 | }) 75 | .filter(|(_, track)| !track.id.starts_with("_album_")) // and then we filter out the album itself 76 | .map(|(_, track)| make_track(self.client.as_ref(), &self.downloads_dir, track, false, &self.transcoding)) 77 | .collect(); 78 | 79 | for (i, s) in self.state.queue.iter_mut().enumerate() { 80 | s.original_index = i as i64; 81 | } 82 | 83 | if let Err(e) = self.mpv_start_playlist().await { 84 | log::error!("Failed to start playlist: {}", e); 85 | self.set_generic_message( 86 | "Failed to start playlist", &e.to_string(), 87 | ); 88 | return; 89 | } 90 | if self.state.shuffle { 91 | self.do_shuffle(true).await; 92 | // select the first song in the queue 93 | if let Ok(mpv) = self.mpv_state.lock() { 94 | let _ = mpv.mpv.command("playlist-play-index", &["0"]); 95 | self.state.selected_queue_item.select(Some(0)); 96 | } 97 | } 98 | 99 | let _ = self.db.cmd_tx 100 | .send(Command::Update(UpdateCommand::SongPlayed { 101 | track_id: self.state.queue[0].id.clone(), 102 | })) 103 | .await; 104 | } 105 | 106 | async fn initiate_main_queue_one_track(&mut self, tracks: &[DiscographySong], skip: usize) { 107 | if tracks.is_empty() { 108 | return; 109 | } 110 | 111 | let track = &tracks[skip]; 112 | if track.id.starts_with("_album_") { 113 | return; 114 | } 115 | 116 | self.state.queue = vec![ 117 | make_track( 118 | self.client.as_ref(), &self.downloads_dir, track, false, &self.transcoding 119 | ) 120 | ]; 121 | 122 | if let Err(e) = self.mpv_start_playlist().await { 123 | log::error!("Failed to start playlist: {}", e); 124 | self.set_generic_message( 125 | "Failed to start playlist", &e.to_string(), 126 | ); 127 | } 128 | } 129 | 130 | /// Append the tracks to the end of the queue 131 | /// 132 | pub async fn append_to_main_queue(&mut self, tracks: &[DiscographySong], skip: usize) { 133 | if self.state.queue.is_empty() { 134 | self.initiate_main_queue(tracks, skip).await; 135 | return; 136 | } 137 | let mut new_queue: Vec = Vec::new(); 138 | for (i, track) in tracks.iter().enumerate().skip(skip) { 139 | if track.id.starts_with("_album_") { 140 | continue; 141 | } 142 | if i != skip && track.disliked { 143 | continue; 144 | } 145 | new_queue.push( 146 | make_track( 147 | self.client.as_ref(), 148 | &self.downloads_dir, 149 | track, 150 | false, 151 | &self.transcoding 152 | ) 153 | ); 154 | } 155 | 156 | let max_original_index = self.state.queue.iter().map(|s| s.original_index).max().unwrap_or(0); 157 | for (i, s) in new_queue.iter_mut().enumerate() { 158 | s.original_index = max_original_index + 1 + i as i64; 159 | } 160 | 161 | if let Ok(mpv) = self.mpv_state.lock() { 162 | for song in &new_queue { 163 | match helpers::normalize_mpvsafe_url(&song.url) { 164 | Ok(safe_url) => { 165 | let _ = mpv.mpv.command("loadfile", &[safe_url.as_str(), "append"]); 166 | } 167 | Err(e) => { 168 | log::error!("Failed to normalize URL '{}': {:?}", song.url, e); 169 | if e.to_string().contains("No such file or directory") { 170 | let _ = self.db.cmd_tx.send(Command::Update(UpdateCommand::OfflineRepair)).await; 171 | } 172 | }, 173 | } 174 | } 175 | } 176 | 177 | self.state.queue.extend(new_queue); 178 | } 179 | 180 | /// Append the provided n tracks to the end of the queue 181 | /// 182 | pub async fn push_to_temporary_queue(&mut self, tracks: &[DiscographySong], skip: usize, n: usize) { 183 | if self.state.queue.is_empty() || tracks.is_empty() { 184 | self.initiate_main_queue_one_track(tracks, skip).await; 185 | return; 186 | } 187 | 188 | let mut songs: Vec = Vec::new(); 189 | for i in 0..n { 190 | let track = &tracks[skip + i]; 191 | if i != 0 && track.disliked { 192 | continue; 193 | } 194 | if track.id.starts_with("_album_") { 195 | self.push_album_to_temporary_queue(false).await; 196 | return; 197 | } 198 | let song = make_track( 199 | self.client.as_ref(), 200 | &self.downloads_dir, 201 | track, 202 | true, 203 | &self.transcoding 204 | ); 205 | 206 | songs.push(song); 207 | } 208 | 209 | let mut selected_queue_item = -1; 210 | for (i, song) in self.state.queue.iter().enumerate() { 211 | if song.is_in_queue { 212 | selected_queue_item = i as i64; 213 | } 214 | } 215 | 216 | if selected_queue_item == -1 { 217 | selected_queue_item = self.state.selected_queue_item.selected().unwrap_or(0) as i64; 218 | } 219 | 220 | let mpv = match self.mpv_state.lock() { 221 | Ok(state) => state, 222 | Err(_) => return, 223 | }; 224 | 225 | for song in songs.iter().rev() { 226 | match helpers::normalize_mpvsafe_url(&song.url) { 227 | Ok(safe_url) => { 228 | if let Ok(_) = mpv.mpv.command( 229 | "loadfile", 230 | &[ 231 | safe_url.as_str(), 232 | "insert-at", 233 | (selected_queue_item + 1).to_string().as_str(), 234 | ], 235 | ) { 236 | self.state.queue.insert((selected_queue_item + 1) as usize, song.clone()); 237 | } 238 | } 239 | Err(e) => { 240 | log::error!("Failed to normalize URL '{}': {:?}", song.url, e); 241 | if e.to_string().contains("No such file or directory") { 242 | let _ = self.db.cmd_tx.send(Command::Update(UpdateCommand::OfflineRepair)).await; 243 | } 244 | }, 245 | } 246 | } 247 | } 248 | 249 | /// Add a new song right after the currently playing song 250 | /// 251 | pub async fn push_next_to_temporary_queue(&mut self, tracks: &Vec, skip: usize) { 252 | if self.state.queue.is_empty() || tracks.is_empty() { 253 | self.initiate_main_queue_one_track(tracks, skip).await; 254 | return; 255 | } 256 | let selected_queue_item = self.state.selected_queue_item.selected().unwrap_or(0); 257 | // if we shift click we only appned the selected track to the playlist 258 | let track = &tracks[skip]; 259 | if track.id.starts_with("_album_") { 260 | self.push_album_to_temporary_queue(true).await; 261 | return; 262 | } 263 | 264 | let song = make_track( 265 | self.client.as_ref(), 266 | &self.downloads_dir, 267 | track, 268 | true, 269 | &self.transcoding 270 | ); 271 | 272 | let mpv = match self.mpv_state.lock() { 273 | Ok(state) => state, 274 | Err(_) => return, 275 | }; 276 | 277 | match helpers::normalize_mpvsafe_url(&song.url) { 278 | Ok(safe_url) => { 279 | if let Ok(_) = mpv.mpv.command("loadfile", &[safe_url.as_str(), "insert-next"]) { 280 | self.state.queue.insert(selected_queue_item + 1, song); 281 | } 282 | } 283 | Err(e) => { 284 | log::error!("Failed to normalize URL '{}': {:?}", song.url, e); 285 | if e.to_string().contains("No such file or directory") { 286 | let _ = self.db.cmd_tx.send(Command::Update(UpdateCommand::OfflineRepair)).await; 287 | } 288 | }, 289 | } 290 | 291 | // get the track-list 292 | // let count: i64 = mpv.mpv.get_property("playlist/count").unwrap_or(0); 293 | // let track_list: Vec = Vec::with_capacity(count as usize); 294 | // println!("{:?}", count); 295 | 296 | // let second: String = mpv.mpv.get_property("playlist/1/filename").unwrap_or("".to_string()); 297 | // println!("So these wont be the same sad sad {second}{:?}", self.state.queue.get(1).unwrap().url); 298 | // // compare the strings 299 | // println!("{:?}", self.state.queue.get(1).unwrap().url == second); 300 | } 301 | 302 | async fn push_album_to_temporary_queue(&mut self, start: bool) { 303 | let selected = self.state.selected_track.selected().unwrap_or(0); 304 | let album_id = self.tracks[selected].parent_id.clone(); 305 | let tracks = self 306 | .tracks 307 | .iter() 308 | .skip(selected + 1) 309 | .take_while(|t| t.parent_id == album_id) 310 | .collect::>(); 311 | 312 | let mut selected_queue_item = -1; 313 | for (i, song) in self.state.queue.iter().enumerate() { 314 | if song.is_in_queue && !start { 315 | selected_queue_item = i as i64; 316 | } 317 | } 318 | 319 | if selected_queue_item == -1 { 320 | selected_queue_item = self.state.selected_queue_item.selected().unwrap_or(0) as i64; 321 | } 322 | 323 | let mpv = match self.mpv_state.lock() { 324 | Ok(state) => state, 325 | Err(_) => return, 326 | }; 327 | 328 | for track in tracks.iter().rev() { 329 | let song = make_track( 330 | self.client.as_ref(), 331 | &self.downloads_dir, 332 | track, 333 | true, 334 | &self.transcoding 335 | ); 336 | 337 | if let Ok(_) = mpv.mpv.command( 338 | "loadfile", 339 | &[ 340 | song.url.as_str(), 341 | "insert-at", 342 | (selected_queue_item + 1).to_string().as_str(), 343 | ], 344 | ) { 345 | self.state 346 | .queue 347 | .insert((selected_queue_item + 1) as usize, song); 348 | } 349 | } 350 | } 351 | 352 | /// Remove the *selected* song from the queue 353 | /// 354 | pub async fn pop_from_queue(&mut self) { 355 | if self.state.queue.is_empty() { 356 | return; 357 | } 358 | 359 | let selected_queue_item = match self.state.selected_queue_item.selected() { 360 | Some(item) => item, 361 | None => return, 362 | }; 363 | 364 | let mpv = match self.mpv_state.lock() { 365 | Ok(state) => state, 366 | Err(_) => return, 367 | }; 368 | 369 | if let Ok(_) = mpv.mpv.command( 370 | "playlist-remove", 371 | &[selected_queue_item.to_string().as_str()], 372 | ) { 373 | self.state.queue.remove(selected_queue_item); 374 | } 375 | } 376 | 377 | pub async fn remove_from_queue_by_id( 378 | &mut self, 379 | id: String, 380 | ) { 381 | if self.state.queue.is_empty() { 382 | return; 383 | } 384 | 385 | let mpv = match self.mpv_state.lock() { 386 | Ok(state) => state, 387 | Err(_) => return, 388 | }; 389 | 390 | let mut to_remove = Vec::new(); 391 | for (i, song) in self.state.queue.iter().enumerate() { 392 | if song.id == id { 393 | to_remove.push(i); 394 | } 395 | } 396 | for i in to_remove.iter().rev() { 397 | if let Ok(_) = mpv.mpv.command("playlist-remove", &[i.to_string().as_str()]) { 398 | self.state.queue.remove(*i); 399 | } 400 | } 401 | } 402 | 403 | /// Clear the queue 404 | /// 405 | pub async fn clear_queue(&mut self) { 406 | if self.state.queue.is_empty() { 407 | return; 408 | } 409 | if let Ok(mpv) = self.mpv_state.lock() { 410 | for i in (0..self.state.queue.len()).rev() { 411 | if !self.state.queue[i].is_in_queue { 412 | continue; 413 | } 414 | if let Ok(_) = mpv 415 | .mpv 416 | .command("playlist-remove", &[i.to_string().as_str()]) 417 | { 418 | self.state.queue.remove(i); 419 | } 420 | } 421 | } 422 | } 423 | 424 | /// Essentially, because of the queue itself being temporary we need to handle interactions differently 425 | /// If we play a song *outside* the queue, we MOVE the queue to that new position (remove, insert there, play selected) 426 | /// If we play a song *inside* the queue, we just play it 427 | /// 428 | pub async fn relocate_queue_and_play(&mut self) { 429 | if self.state.queue.is_empty() { 430 | return; 431 | } 432 | if let Ok(mpv) = self.mpv_state.lock() { 433 | // get a list of all the songs in the queue 434 | let mut queue: Vec = self 435 | .state 436 | .queue 437 | .iter() 438 | .filter(|s| s.is_in_queue) 439 | .cloned() 440 | .collect(); 441 | let queue_len = queue.len(); 442 | 443 | let mut index = self.state.selected_queue_item.selected().unwrap_or(0); 444 | let after: bool = index >= self.state.current_playback_state.current_index as usize; 445 | 446 | // early return in case we're within queue bounds 447 | if self.state.queue[index].is_in_queue { 448 | let _ = mpv 449 | .mpv 450 | .command("playlist-play-index", &[&index.to_string()]); 451 | if self.paused { 452 | let _ = mpv.mpv.set_property("pause", false); 453 | self.paused = false; 454 | } 455 | self.state.selected_queue_item.select(Some(index)); 456 | return; 457 | } 458 | 459 | // Delete all songs before the selected song 460 | for i in (0..self.state.queue.len()).rev() { 461 | if let Some(song) = self.state.queue.get(i) { 462 | if song.is_in_queue { 463 | self.state.queue.remove(i); 464 | mpv.mpv.command("playlist_remove", &[&i.to_string()]).ok(); 465 | } 466 | } 467 | } 468 | 469 | if after { 470 | index -= queue_len; 471 | } 472 | self.state.selected_queue_item.select(Some(index)); 473 | 474 | // to put them back in the queue in the correct order 475 | queue.reverse(); 476 | 477 | for song in queue { 478 | match helpers::normalize_mpvsafe_url(&song.url) { 479 | Ok(safe_url) => { 480 | if (index + 1) > self.state.queue.len() { 481 | let _ = mpv.mpv.command("loadfile", &[safe_url.as_str(), "append"]); 482 | self.state.queue.push(song); 483 | } else { 484 | let _ = mpv.mpv.command( 485 | "loadfile", 486 | &[ 487 | safe_url.as_str(), 488 | "insert-at", 489 | (index + 1).to_string().as_str(), 490 | ], 491 | ); 492 | self.state.queue.insert(index + 1, song); 493 | } 494 | } 495 | Err(e) => log::error!("Failed to normalize URL '{}': {:?}", song.url, e), 496 | } 497 | } 498 | 499 | let _ = mpv 500 | .mpv 501 | .command("playlist-play-index", &[&index.to_string()]); 502 | if self.paused { 503 | let _ = mpv.mpv.set_property("pause", false); 504 | self.paused = false; 505 | } 506 | } 507 | } 508 | 509 | /// Swap the selected song with the one above it 510 | /// 511 | pub async fn move_queue_item_up(&mut self) { 512 | if self.state.queue.is_empty() { 513 | return; 514 | } 515 | if let Some(selected_queue_item) = self.state.selected_queue_item.selected() { 516 | if selected_queue_item == 0 { 517 | return; 518 | } 519 | 520 | if let Some(src) = self.state.queue.get(selected_queue_item) { 521 | if let Some(dst) = self.state.queue.get(selected_queue_item - 1) { 522 | if src.is_in_queue != dst.is_in_queue { 523 | return; 524 | } 525 | } 526 | } 527 | 528 | // i don't think i've ever disliked an API more 529 | if let Ok(mpv) = self.mpv_state.lock() { 530 | let _ = mpv 531 | .mpv 532 | .command( 533 | "playlist-move", 534 | &[ 535 | selected_queue_item.to_string().as_str(), 536 | (selected_queue_item - 1).to_string().as_str(), 537 | ], 538 | ) 539 | .map_err(|e| format!("Failed to move playlist item: {:?}", e)); 540 | } 541 | self.state 542 | .selected_queue_item 543 | .select(Some(selected_queue_item - 1)); 544 | 545 | self.state 546 | .queue 547 | .swap(selected_queue_item, selected_queue_item - 1); 548 | 549 | // if we moved the current song either directly or by moving the song above it 550 | // we need to update the current index 551 | if self.state.current_playback_state.current_index == selected_queue_item as i64 { 552 | self.state.current_playback_state.current_index -= 1; 553 | } else if self.state.current_playback_state.current_index 554 | == (selected_queue_item - 1) as i64 555 | { 556 | self.state.current_playback_state.current_index += 1; 557 | } 558 | 559 | // discard next poll 560 | let _ = self.receiver.try_recv(); 561 | 562 | #[cfg(debug_assertions)] 563 | { 564 | self.__debug_error_corrector_tm(); 565 | } 566 | } 567 | } 568 | 569 | /// Swap the selected song with the one below it 570 | /// 571 | pub async fn move_queue_item_down(&mut self) { 572 | if self.state.queue.is_empty() { 573 | return; 574 | } 575 | if let Some(selected_queue_item) = self.state.selected_queue_item.selected() { 576 | if selected_queue_item == self.state.queue.len() - 1 { 577 | return; 578 | } 579 | 580 | if let Some(src) = self.state.queue.get(selected_queue_item) { 581 | if let Some(dst) = self.state.queue.get(selected_queue_item + 1) { 582 | if src.is_in_queue != dst.is_in_queue { 583 | return; 584 | } 585 | } 586 | } 587 | 588 | if let Ok(mpv) = self.mpv_state.lock() { 589 | let _ = mpv 590 | .mpv 591 | .command( 592 | "playlist-move", 593 | &[ 594 | (selected_queue_item + 1).to_string().as_str(), 595 | selected_queue_item.to_string().as_str(), 596 | ], 597 | ) 598 | .map_err(|e| format!("Failed to move playlist item: {:?}", e)); 599 | } 600 | 601 | self.state 602 | .queue 603 | .swap(selected_queue_item, selected_queue_item + 1); 604 | 605 | // if we moved the current song either directly or by moving the song above it 606 | // we need to update the current index 607 | if self.state.current_playback_state.current_index == selected_queue_item as i64 { 608 | self.state.current_playback_state.current_index += 1; 609 | } else if self.state.current_playback_state.current_index 610 | == (selected_queue_item + 1) as i64 611 | { 612 | self.state.current_playback_state.current_index -= 1; 613 | } 614 | 615 | self.state 616 | .selected_queue_item 617 | .select(Some(selected_queue_item + 1)); 618 | 619 | // discard next poll 620 | let _ = self.receiver.try_recv(); 621 | 622 | #[cfg(debug_assertions)] 623 | { 624 | self.__debug_error_corrector_tm(); 625 | } 626 | } 627 | } 628 | 629 | /// Shuffles the queue 630 | /// 631 | pub async fn do_shuffle(&mut self, include_current: bool) { 632 | if let Ok(mpv) = self.mpv_state.lock() { 633 | let len = self.state.queue.len(); 634 | if len <= 1 { 635 | return; 636 | } 637 | 638 | let ci = match usize::try_from(self.state.current_playback_state.current_index) { 639 | Ok(i) if i < len => i, 640 | _ => 0, 641 | }; 642 | 643 | let start = if include_current { ci } else { ci.saturating_add(1) }; 644 | if start >= len { 645 | return; 646 | } 647 | 648 | // put temporary queue back after the start index 649 | let mut temp: Vec = Vec::new(); 650 | let mut rest: Vec = Vec::new(); 651 | for s in self.state.queue[start..].to_vec() { 652 | if s.is_in_queue { temp.push(s); } else { rest.push(s); } 653 | } 654 | let mut normalized_tail = Vec::with_capacity(len - start); 655 | normalized_tail.extend(temp.iter().cloned()); 656 | normalized_tail.extend(rest.iter().cloned()); 657 | 658 | for (i, target) in normalized_tail.iter().enumerate() { 659 | let g = start + i; 660 | if self.state.queue[g].id != target.id { 661 | if let Some(j_rel) = (start..len).position(|k| self.state.queue[k].id == target.id) { 662 | let from = start + j_rel; 663 | let to = g; 664 | let from_s = from.to_string(); 665 | let to_s = to.to_string(); 666 | let _ = mpv.mpv.command("playlist-move", &[from_s.as_str(), to_s.as_str()]); 667 | // do the same in local 668 | let moved = self.state.queue.remove(from); 669 | self.state.queue.insert(to, moved); 670 | } 671 | } 672 | } 673 | 674 | let temp_count = temp.len(); 675 | let shuffle_from = (start + temp_count).min(len); 676 | if shuffle_from >= len.saturating_sub(1) { 677 | // nothing to shuffle 678 | return; 679 | } 680 | 681 | let mut local_current: Vec = self.state.queue[shuffle_from..].to_vec(); 682 | let mut desired_order = local_current.clone(); 683 | desired_order.shuffle(&mut rand::rng()); 684 | 685 | for i in 0..desired_order.len() { 686 | if let Some(j) = local_current.iter().position(|s| s.id == desired_order[i].id) { 687 | if j != i { 688 | let from = shuffle_from + j; 689 | let to = shuffle_from + i; 690 | let from_s = from.to_string(); 691 | let to_s = to.to_string(); 692 | let _ = mpv.mpv.command("playlist-move", &[from_s.as_str(), to_s.as_str()]); 693 | // update local_current to reflect the move 694 | let item = local_current.remove(j); 695 | local_current.insert(i, item); 696 | } 697 | } 698 | } 699 | 700 | for (i, song) in local_current.into_iter().enumerate() { 701 | self.state.queue[shuffle_from + i] = song; 702 | } 703 | } 704 | } 705 | 706 | /// Attempts to unshuffle the queue 707 | /// 708 | pub async fn do_unshuffle(&mut self) { 709 | if let Ok(mpv) = self.mpv_state.lock() { 710 | let len = self.state.queue.len(); 711 | if len <= 1 { 712 | return; 713 | } 714 | 715 | let ci = match usize::try_from(self.state.current_playback_state.current_index) { 716 | Ok(i) if i < len => i, 717 | _ => 0, 718 | }; 719 | 720 | let start = ci.saturating_add(1).min(len); 721 | if start >= len { 722 | return; 723 | } 724 | 725 | let mut temp: Vec = Vec::new(); 726 | let mut rest: Vec = Vec::new(); 727 | for s in self.state.queue[start..].to_vec() { 728 | if s.is_in_queue { temp.push(s); } else { rest.push(s); } 729 | } 730 | let mut normalized_tail = Vec::with_capacity(len - start); 731 | normalized_tail.extend(temp.iter().cloned()); 732 | normalized_tail.extend(rest.iter().cloned()); 733 | 734 | for (i, target) in normalized_tail.iter().enumerate() { 735 | let g = start + i; 736 | if self.state.queue[g].id != target.id { 737 | if let Some(j_rel) = (start..len).position(|k| self.state.queue[k].id == target.id) { 738 | let from = start + j_rel; 739 | let to = g; 740 | let _ = mpv.mpv.command(&"playlist-move", &[&from.to_string(), &to.to_string()]); 741 | // do the same in local 742 | let moved = self.state.queue.remove(from); 743 | self.state.queue.insert(to, moved); 744 | } 745 | } 746 | } 747 | 748 | let temp_count = temp.len(); 749 | let sort_from = (start + temp_count).min(len); 750 | if sort_from >= len.saturating_sub(1) { return; } 751 | 752 | let mut desired_rest = self.state.queue[sort_from..].to_vec(); 753 | desired_rest.sort_by_key(|s| s.original_index); 754 | 755 | for i in 0..desired_rest.len() { 756 | let target_id = &desired_rest[i].id; 757 | let g = sort_from + i; 758 | if self.state.queue[g].id != *target_id { 759 | if let Some(j_rel) = (sort_from..len).position(|k| self.state.queue[k].id == *target_id) { 760 | let from = sort_from + j_rel; 761 | let to = g; 762 | let _ = mpv.mpv.command(&"playlist-move", &[&from.to_string(), &to.to_string()]); 763 | let moved = self.state.queue.remove(from); 764 | self.state.queue.insert(to, moved); 765 | } 766 | } 767 | } 768 | } 769 | } 770 | 771 | /// (debug) Sync the queue with mpv and scream about it. 772 | /// It is a patently stupid function that should not exist, but the mpv api is not great 773 | /// Can be removed from well tested code 774 | /// 775 | fn __debug_error_corrector_tm(&mut self) { 776 | let mut mpv_playlist = Vec::new(); 777 | 778 | if let Ok(mpv) = self.mpv_state.lock() { 779 | for (i, _) in self.state.queue.iter().enumerate() { 780 | let mpv_url = mpv 781 | .mpv 782 | .get_property(format!("playlist/{}/filename", i).as_str()) 783 | .unwrap_or("".to_string()); 784 | mpv_playlist.push(mpv_url); 785 | } 786 | let mut new_queue = Vec::new(); 787 | for mpv_url in mpv_playlist.iter() { 788 | for song in self.state.queue.iter() { 789 | if &song.url == mpv_url { 790 | new_queue.push(song.clone()); 791 | break; 792 | } 793 | } 794 | } 795 | for (i, song) in self.state.queue.iter().enumerate() { 796 | if song.url != mpv_playlist[i] { 797 | println!("[##] position changed {} != {}", song.url, mpv_playlist[i]); 798 | } 799 | } 800 | self.state.queue = new_queue; 801 | } 802 | } 803 | } 804 | -------------------------------------------------------------------------------- /src/help.rs: -------------------------------------------------------------------------------- 1 | /* -------------------------- 2 | Help page rendering functions 3 | - Pressing '?' in any tab should show the help page in its place 4 | - should of an equivalent layout 5 | -------------------------- */ 6 | use ratatui::{ 7 | Frame, 8 | prelude::*, 9 | widgets::*, 10 | }; 11 | 12 | impl crate::tui::App { 13 | pub fn render_home_help(&mut self, app_container: Rect, frame: &mut Frame) { 14 | let outer_layout = Layout::default() 15 | .direction(Direction::Horizontal) 16 | .constraints(vec![ 17 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.0), 18 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.1), 19 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.2), 20 | ]) 21 | .split(app_container); 22 | 23 | let left = outer_layout[0]; 24 | 25 | let center = Layout::default() 26 | .direction(Direction::Vertical) 27 | .constraints(vec![Constraint::Percentage(100), Constraint::Length(13)]) 28 | .split(outer_layout[1]); 29 | 30 | let right = Layout::default() 31 | .direction(Direction::Vertical) 32 | .constraints(vec![Constraint::Percentage(32), Constraint::Percentage(68)]) 33 | .split(outer_layout[2]); 34 | 35 | let artist_block = Block::new() 36 | .borders(Borders::ALL) 37 | .border_type(self.border_type) 38 | .border_style(self.theme.resolve(&self.theme.border)); 39 | 40 | let artist_help_text = vec![ 41 | Line::from("This is a list of all artists sorted alphabetically.").fg(self.theme.resolve(&self.theme.foreground)), 42 | Line::from(""), 43 | Line::from("Usage:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 44 | Line::from(vec![ 45 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)).bold(), 46 | "<↑/↓>".fg(self.theme.primary_color).bold(), 47 | " (j/k) to navigate".fg(self.theme.resolve(&self.theme.foreground)), 48 | ]), 49 | Line::from(vec![ 50 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 51 | "".fg(self.theme.primary_color).bold(), 52 | " to select".fg(self.theme.resolve(&self.theme.foreground)), 53 | ]), 54 | Line::from(vec![ 55 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 56 | "Tab".fg(self.theme.primary_color).bold(), 57 | " to switch to Tracks".fg(self.theme.resolve(&self.theme.foreground)), 58 | ]), 59 | Line::from(vec![ 60 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 61 | "Shift + Tab".fg(self.theme.primary_color).bold(), 62 | " to switch to Lyrics".fg(self.theme.resolve(&self.theme.foreground)), 63 | ]), 64 | Line::from(vec![ 65 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 66 | "a".fg(self.theme.primary_color).bold(), 67 | " to skip to next album".fg(self.theme.resolve(&self.theme.foreground)), 68 | ]), 69 | Line::from(vec![ 70 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 71 | "A".fg(self.theme.primary_color).bold(), 72 | " to skip to previous album".fg(self.theme.resolve(&self.theme.foreground)), 73 | ]), 74 | Line::from(vec![ 75 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 76 | "g".fg(self.theme.primary_color).bold(), 77 | " to skip to the top of the list".fg(self.theme.resolve(&self.theme.foreground)), 78 | ]), 79 | Line::from(vec![ 80 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 81 | "G".fg(self.theme.primary_color).bold(), 82 | " to skip to the bottom of the list".fg(self.theme.resolve(&self.theme.foreground)), 83 | ]), 84 | Line::from(vec![ 85 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 86 | "f".fg(self.theme.primary_color).bold(), 87 | " to favorite an artist".fg(self.theme.resolve(&self.theme.foreground)), 88 | ]), 89 | Line::from(""), 90 | Line::from("Searching:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 91 | Line::from(vec![ 92 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 93 | "/".fg(self.theme.primary_color).bold(), 94 | " to start searching".fg(self.theme.resolve(&self.theme.foreground)), 95 | ]), 96 | Line::from(vec![ 97 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 98 | "Esc".fg(self.theme.primary_color).bold(), 99 | " to clear search".fg(self.theme.resolve(&self.theme.foreground)), 100 | ]), 101 | Line::from(vec![ 102 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 103 | "Enter".fg(self.theme.primary_color).bold(), 104 | " to confirm search".fg(self.theme.resolve(&self.theme.foreground)), 105 | ]), 106 | ]; 107 | 108 | let artist_help = Paragraph::new(artist_help_text) 109 | .block(artist_block.title("Artists").fg(self.theme.resolve(&self.theme.section_title))) 110 | .wrap(Wrap { trim: false }) 111 | .alignment(Alignment::Left); 112 | 113 | frame.render_widget(artist_help, left); 114 | 115 | 116 | let track_block = Block::new() 117 | .borders(Borders::ALL) 118 | .border_type(self.border_type) 119 | .border_style(self.theme.resolve(&self.theme.border)); 120 | 121 | let track_help_text = vec![ 122 | Line::from(""), 123 | Line::from("jellyfin-tui Library help").centered().fg(self.theme.resolve(&self.theme.foreground)), 124 | Line::from("Here is a table of all tracks.").fg(self.theme.resolve(&self.theme.foreground)), 125 | Line::from(""), 126 | Line::from("Usage:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 127 | Line::from(vec![ 128 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 129 | "<↑/↓>".fg(self.theme.primary_color).bold(), 130 | " (j/k) to navigate".fg(self.theme.resolve(&self.theme.foreground)), 131 | ]), 132 | // " - Use Enter to play a song", 133 | Line::from(vec![ 134 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 135 | "".fg(self.theme.primary_color).bold(), 136 | " to play a song".fg(self.theme.resolve(&self.theme.foreground)), 137 | ]), 138 | Line::from(vec![ 139 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 140 | "Tab".fg(self.theme.primary_color).bold(), 141 | " to switch to Artists".fg(self.theme.resolve(&self.theme.foreground)), 142 | ]), 143 | Line::from(vec![ 144 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 145 | "Shift + Tab".fg(self.theme.primary_color).bold(), 146 | " to switch to Lyrics".fg(self.theme.resolve(&self.theme.foreground)), 147 | ]), 148 | Line::from(vec![ 149 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 150 | "g".fg(self.theme.primary_color).bold(), 151 | " to skip to the top of the list".fg(self.theme.resolve(&self.theme.foreground)), 152 | ]), 153 | Line::from(vec![ 154 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 155 | "G".fg(self.theme.primary_color).bold(), 156 | " to skip to the bottom of the list".fg(self.theme.resolve(&self.theme.foreground)), 157 | ]), 158 | Line::from(vec![ 159 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 160 | "a".fg(self.theme.primary_color).bold(), 161 | " to jump to next album".fg(self.theme.resolve(&self.theme.foreground)), 162 | ]), 163 | Line::from(vec![ 164 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 165 | "A".fg(self.theme.primary_color).bold(), 166 | " to jump to previous album, or start of current".fg(self.theme.resolve(&self.theme.foreground)), 167 | ]), 168 | Line::from(vec![ 169 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 170 | "f".fg(self.theme.primary_color).bold(), 171 | " to favorite a song".fg(self.theme.resolve(&self.theme.foreground)), 172 | ]), 173 | Line::from(vec![ 174 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 175 | "d".fg(self.theme.primary_color).bold(), 176 | " to download a song or album, press again to delete download".fg(self.theme.resolve(&self.theme.foreground)), 177 | ]), 178 | Line::from(""), 179 | Line::from("Searching:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 180 | Line::from(vec![ 181 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 182 | "/".fg(self.theme.primary_color).bold(), 183 | " to start searching".fg(self.theme.resolve(&self.theme.foreground)), 184 | ]), 185 | Line::from(vec![ 186 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 187 | "Esc".fg(self.theme.primary_color).bold(), 188 | " to clear search".fg(self.theme.resolve(&self.theme.foreground)), 189 | ]), 190 | Line::from(vec![ 191 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 192 | "Enter".fg(self.theme.primary_color).bold(), 193 | " to confirm search".fg(self.theme.resolve(&self.theme.foreground)), 194 | ]), 195 | Line::from(""), 196 | Line::from("General").underlined().fg(self.theme.resolve(&self.theme.foreground)), 197 | Line::from(vec![ 198 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 199 | "?".fg(self.theme.primary_color).bold(), 200 | " to show this help".fg(self.theme.resolve(&self.theme.foreground)), 201 | ]), 202 | Line::from(vec![ 203 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 204 | "F1..FX".fg(self.theme.primary_color).bold(), 205 | " or ".fg(self.theme.resolve(&self.theme.foreground)), 206 | "1..9".fg(self.theme.primary_color).bold(), 207 | " to switch tabs".fg(self.theme.resolve(&self.theme.foreground)), 208 | ]), 209 | Line::from(vec![ 210 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 211 | "q".fg(self.theme.primary_color).bold(), 212 | " or ".fg(self.theme.resolve(&self.theme.foreground)), 213 | "ctrl + c".fg(self.theme.primary_color).bold(), 214 | " to quit".fg(self.theme.resolve(&self.theme.foreground)), 215 | ]), 216 | ]; 217 | 218 | let track_help = Paragraph::new(track_help_text ) 219 | .block(track_block.title("Tracks").fg(self.theme.resolve(&self.theme.section_title))) 220 | .wrap(Wrap { trim: false }) 221 | .alignment(Alignment::Left); 222 | 223 | frame.render_widget(track_help, center[0]); 224 | 225 | let queue_block = Block::new() 226 | .borders(Borders::ALL) 227 | .border_type(self.border_type) 228 | .border_style(self.theme.resolve(&self.theme.border)); 229 | 230 | let queue_help_text = vec![ 231 | Line::from("This is the queue.").fg(self.theme.resolve(&self.theme.foreground)), 232 | Line::from(""), 233 | Line::from("Usage:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 234 | Line::from(vec![ 235 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 236 | "<↑/↓>".fg(self.theme.primary_color).bold(), 237 | " (j/k) to navigate".fg(self.theme.resolve(&self.theme.foreground)), 238 | ]), 239 | Line::from(vec![ 240 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 241 | "Shift + <↑/↓>".fg(self.theme.primary_color).bold(), 242 | " (J/K) to change order".fg(self.theme.resolve(&self.theme.foreground)), 243 | ]), 244 | Line::from(vec![ 245 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 246 | "".fg(self.theme.primary_color).bold(), 247 | " to play a song".fg(self.theme.resolve(&self.theme.foreground)), 248 | ]), 249 | Line::from(vec![ 250 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 251 | "Delete".fg(self.theme.primary_color).bold(), 252 | " to remove a song from the queue".fg(self.theme.resolve(&self.theme.foreground)), 253 | ]), 254 | Line::from(vec![ 255 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 256 | "x".fg(self.theme.primary_color).bold(), 257 | " to clear the queue and stop playback".fg(self.theme.resolve(&self.theme.foreground)), 258 | ]), 259 | Line::from(vec![ 260 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 261 | "X".fg(self.theme.primary_color).bold(), 262 | " to clear the queue and also unselect everything".fg(self.theme.resolve(&self.theme.foreground)), 263 | ]), 264 | Line::from(vec![ 265 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 266 | "f".fg(self.theme.primary_color).bold(), 267 | " to favorite a song".fg(self.theme.resolve(&self.theme.foreground)), 268 | ]), 269 | Line::from( 270 | vec![ 271 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 272 | "g".fg(self.theme.primary_color).bold(), 273 | " to skip to the top of the list".fg(self.theme.resolve(&self.theme.foreground)), 274 | ] 275 | ), 276 | Line::from( 277 | vec![ 278 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 279 | "G".fg(self.theme.primary_color).bold(), 280 | " to skip to the bottom of the list".fg(self.theme.resolve(&self.theme.foreground)), 281 | ] 282 | ), 283 | Line::from("Creation:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 284 | Line::from(" - jellyfin-tui has a double queue system. A main queue and temporary queue").fg(self.theme.resolve(&self.theme.foreground)), 285 | Line::from(""), 286 | Line::from(vec![ 287 | " - Playing a song with ".fg(self.theme.resolve(&self.theme.foreground)), 288 | "".fg(self.theme.primary_color).bold(), 289 | " will create a new main queue".fg(self.theme.resolve(&self.theme.foreground)), 290 | ]), 291 | Line::from(vec![ 292 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 293 | "e".fg(self.theme.primary_color).bold(), 294 | ", or ".fg(self.theme.resolve(&self.theme.foreground)), 295 | "shift + Enter".fg(self.theme.primary_color).bold(), 296 | " to enqueue a song (temporary queue)".fg(self.theme.resolve(&self.theme.foreground)), 297 | ]), 298 | Line::from(vec![ 299 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 300 | "ctrl + e".fg(self.theme.primary_color).bold(), 301 | ", or ".fg(self.theme.resolve(&self.theme.foreground)), 302 | "ctrl + Enter".fg(self.theme.primary_color).bold(), 303 | " play next in the queue (temporary queue)".fg(self.theme.resolve(&self.theme.foreground)), 304 | ]), 305 | Line::from(vec![ 306 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 307 | "E".fg(self.theme.primary_color).bold(), 308 | " to clear the temporary queue".fg(self.theme.resolve(&self.theme.foreground)), 309 | ]), 310 | ]; 311 | 312 | let queue_help = Paragraph::new(queue_help_text) 313 | .block(queue_block.title("Queue").fg(self.theme.resolve(&self.theme.section_title))) 314 | .wrap(Wrap { trim: false }) 315 | .alignment(Alignment::Left); 316 | 317 | frame.render_widget(queue_help, right[1]); 318 | 319 | let bottom = Block::default() 320 | .borders(Borders::ALL) 321 | .padding(Padding::new(0, 0, 0, 0)); 322 | 323 | // let inner = bottom.inner(center[1]); 324 | 325 | frame.render_widget(bottom, center[1]); 326 | 327 | // lyrics area 328 | let lyrics_block = Block::new() 329 | .borders(Borders::ALL) 330 | .border_type(self.border_type) 331 | .border_style(self.theme.resolve(&self.theme.border)); 332 | 333 | let lyrics_help_text = vec![ 334 | Line::from("This is the lyrics area.").fg(self.theme.resolve(&self.theme.foreground)), 335 | Line::from(""), 336 | Line::from("Usage:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 337 | Line::from(vec![ 338 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 339 | "<↑/↓>".fg(self.theme.primary_color).bold(), 340 | " (j/k) to navigate".fg(self.theme.resolve(&self.theme.foreground)), 341 | ]), 342 | Line::from(vec![ 343 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 344 | "".fg(self.theme.primary_color).bold(), 345 | " to jump to the current lyric".fg(self.theme.resolve(&self.theme.foreground)), 346 | ]), 347 | Line::from(vec![ 348 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 349 | "Tab".fg(self.theme.primary_color).bold(), 350 | " to switch to previous Pane".fg(self.theme.resolve(&self.theme.foreground)), 351 | ]), 352 | Line::from(vec![ 353 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 354 | "Shift + Tab".fg(self.theme.primary_color).bold(), 355 | " to switch to Queue".fg(self.theme.resolve(&self.theme.foreground)), 356 | ]), 357 | Line::from(vec![ 358 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 359 | "g".fg(self.theme.primary_color).bold(), 360 | " to select the first lyric".fg(self.theme.resolve(&self.theme.foreground)), 361 | ]), 362 | Line::from(vec![ 363 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 364 | "G".fg(self.theme.primary_color).bold(), 365 | " to select the last lyric".fg(self.theme.resolve(&self.theme.foreground)), 366 | ]), 367 | Line::from(""), 368 | ]; 369 | 370 | let lyrics_help = Paragraph::new(lyrics_help_text) 371 | .block(lyrics_block.title("Lyrics").fg(self.theme.resolve(&self.theme.section_title))) 372 | .wrap(Wrap { trim: false }) 373 | .alignment(Alignment::Left); 374 | 375 | frame.render_widget(lyrics_help, right[0]); 376 | 377 | // player area 378 | let player_block = Block::new() 379 | .borders(Borders::ALL) 380 | .border_type(self.border_type) 381 | .border_style(self.theme.resolve(&self.theme.border)); 382 | 383 | let player_help_text = vec![ 384 | Line::from("This is the player area.").fg(self.theme.resolve(&self.theme.foreground)), 385 | Line::from(""), 386 | Line::from("Usage:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 387 | Line::from(vec![ 388 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 389 | "Space".fg(self.theme.primary_color).bold(), 390 | " to play/pause".fg(self.theme.resolve(&self.theme.foreground)), 391 | "\t".into(), 392 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 393 | "r".fg(self.theme.primary_color).bold(), 394 | " to toggle Replay None->All(*)->One(1)".fg(self.theme.resolve(&self.theme.foreground)), 395 | ]), 396 | Line::from(vec![ 397 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 398 | "←/→".fg(self.theme.primary_color).bold(), 399 | " to seek 5s bck/fwd".fg(self.theme.resolve(&self.theme.foreground)), 400 | "\t".into(), 401 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 402 | "p".fg(self.theme.primary_color).bold(), 403 | " to open the command menu".fg(self.theme.resolve(&self.theme.foreground)), 404 | ]), 405 | Line::from(vec![ 406 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 407 | ",/.".fg(self.theme.primary_color).bold(), 408 | " to seek 1m bck/fwd".fg(self.theme.resolve(&self.theme.foreground)), 409 | "\t".into(), 410 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 411 | "P".fg(self.theme.primary_color).bold(), 412 | " to open the GLOBAL command menu".fg(self.theme.resolve(&self.theme.foreground)), 413 | ]), 414 | Line::from(vec![ 415 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 416 | "+/-".fg(self.theme.primary_color).bold(), 417 | " to change volume".fg(self.theme.resolve(&self.theme.foreground)), 418 | "\t".into(), 419 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 420 | "R".fg(self.theme.primary_color).bold(), 421 | " to toggle repeat".fg(self.theme.resolve(&self.theme.foreground)), 422 | ]), 423 | Line::from(vec![ 424 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 425 | "s".fg(self.theme.primary_color).bold(), 426 | " to toggle shuffle".fg(self.theme.resolve(&self.theme.foreground)), 427 | "\t".into(), 428 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 429 | "Ctrl+(Left/h)".fg(self.theme.primary_color).bold(), 430 | " shrink current section".fg(self.theme.resolve(&self.theme.foreground)), 431 | ]), 432 | Line::from(vec![ 433 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 434 | "Ctrl+s".fg(self.theme.primary_color).bold(), 435 | " to shuffle globally".fg(self.theme.resolve(&self.theme.foreground)), 436 | "\t".into(), 437 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 438 | "Ctrl+(Right/l)".fg(self.theme.primary_color).bold(), 439 | " expand current section".fg(self.theme.resolve(&self.theme.foreground)), 440 | ]), 441 | Line::from(vec![ 442 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 443 | "T".fg(self.theme.primary_color).bold(), 444 | " to toggle transcoding".fg(self.theme.resolve(&self.theme.foreground)), 445 | "\t".into() 446 | ]), 447 | ]; 448 | 449 | let player_help = Paragraph::new(player_help_text) 450 | .block(player_block.title("Player").fg(self.theme.resolve(&self.theme.section_title))) 451 | .fg(self.theme.resolve(&self.theme.foreground)) 452 | .wrap(Wrap { trim: false }) 453 | .alignment(Alignment::Left); 454 | 455 | frame.render_widget(player_help, center[1]); 456 | } 457 | 458 | pub fn render_playlists_help(&mut self, app_container: Rect, frame: &mut Frame) { 459 | let outer_layout = Layout::default() 460 | .direction(Direction::Horizontal) 461 | .constraints(vec![ 462 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.0), 463 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.1), 464 | Constraint::Percentage(self.preferences.constraint_width_percentages_music.2), 465 | ]) 466 | .split(app_container); 467 | 468 | let left = outer_layout[0]; 469 | 470 | let center = Layout::default() 471 | .direction(Direction::Vertical) 472 | .constraints(vec![Constraint::Percentage(100), Constraint::Length(10)]) 473 | .split(outer_layout[1]); 474 | 475 | let right = Layout::default() 476 | .direction(Direction::Vertical) 477 | .constraints(vec![Constraint::Percentage(32), Constraint::Percentage(68)]) 478 | .split(outer_layout[2]); 479 | 480 | let artist_block = Block::new() 481 | .borders(Borders::ALL) 482 | .border_type(self.border_type) 483 | .border_style(self.theme.resolve(&self.theme.border)); 484 | 485 | let artist_help_text = vec![ 486 | Line::from("This is a list of all playlists sorted alphabetically.").fg(self.theme.resolve(&self.theme.foreground)), 487 | Line::from(""), 488 | Line::from("Usage:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 489 | Line::from(vec![ 490 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 491 | "<↑/↓>".fg(self.theme.primary_color).bold(), 492 | " (j/k) to navigate".fg(self.theme.resolve(&self.theme.foreground)), 493 | ]), 494 | Line::from(vec![ 495 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 496 | "".fg(self.theme.primary_color).bold(), 497 | " to select".fg(self.theme.resolve(&self.theme.foreground)), 498 | ]), 499 | Line::from(vec![ 500 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 501 | "Tab".fg(self.theme.primary_color).bold(), 502 | " to switch to Tracks".fg(self.theme.resolve(&self.theme.foreground)), 503 | ]), 504 | Line::from(vec![ 505 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 506 | "Shift + Tab".fg(self.theme.primary_color).bold(), 507 | " to switch to Lyrics".fg(self.theme.resolve(&self.theme.foreground)), 508 | ]), 509 | Line::from(vec![ 510 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 511 | "a".fg(self.theme.primary_color).bold(), 512 | " to skip to alphabetically next playlist".fg(self.theme.resolve(&self.theme.foreground)), 513 | ]), 514 | Line::from(vec![ 515 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 516 | "A".fg(self.theme.primary_color).bold(), 517 | " to skip to alphabetically previous playlist".fg(self.theme.resolve(&self.theme.foreground)), 518 | ]), 519 | Line::from(vec![ 520 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 521 | "g".fg(self.theme.primary_color).bold(), 522 | " to skip to the top of the list".fg(self.theme.resolve(&self.theme.foreground)), 523 | ]), 524 | Line::from(vec![ 525 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 526 | "G".fg(self.theme.primary_color).bold(), 527 | " to skip to the bottom of the list".fg(self.theme.resolve(&self.theme.foreground)), 528 | ]), 529 | Line::from(vec![ 530 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 531 | "f".fg(self.theme.primary_color).bold(), 532 | " to favorite a playlist".fg(self.theme.resolve(&self.theme.foreground)), 533 | ]), 534 | Line::from(""), 535 | Line::from("Searching:").underlined(), 536 | Line::from(vec![ 537 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 538 | "/".fg(self.theme.primary_color).bold(), 539 | " to start searching".fg(self.theme.resolve(&self.theme.foreground)), 540 | ]), 541 | Line::from(vec![ 542 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 543 | "Esc".fg(self.theme.primary_color).bold(), 544 | " to clear search".fg(self.theme.resolve(&self.theme.foreground)), 545 | ]), 546 | Line::from(vec![ 547 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 548 | "Enter".fg(self.theme.primary_color).bold(), 549 | " to confirm search".fg(self.theme.resolve(&self.theme.foreground)), 550 | ]), 551 | ]; 552 | 553 | let artist_help = Paragraph::new(artist_help_text) 554 | .block(artist_block.title("Artists").fg(self.theme.resolve(&self.theme.section_title))) 555 | .wrap(Wrap { trim: false }) 556 | .alignment(Alignment::Left); 557 | 558 | frame.render_widget(artist_help, left); 559 | 560 | 561 | let track_block = Block::new() 562 | .borders(Borders::ALL) 563 | .border_type(self.border_type) 564 | .border_style(self.theme.resolve(&self.theme.border)); 565 | 566 | let track_help_text = vec![ 567 | Line::from(""), 568 | Line::from("jellyfin-tui Playlists help").centered().fg(self.theme.resolve(&self.theme.foreground)), 569 | Line::from("").centered(), 570 | Line::from("Here is a table of all tracks of a playlist. The controls are the same as for the Artists tab.").fg(self.theme.resolve(&self.theme.foreground)), 571 | Line::from(""), 572 | Line::from(concat!(r#"Most controls for playlists or their tracks are in the command menu."#, 573 | r#"You can rename, delete, or play a playlist from there."#, 574 | r#"The command menu you will see depends on which section you are in."#)).fg(self.theme.resolve(&self.theme.foreground)), 575 | Line::from(""), 576 | Line::from("Usage:").fg(self.theme.resolve(&self.theme.foreground)).underlined(), 577 | Line::from(vec![ 578 | " - Use ".fg(self.theme.resolve(&self.theme.foreground)), 579 | "p".fg(self.theme.primary_color).bold(), 580 | " to open a menu with commands to use".fg(self.theme.resolve(&self.theme.foreground)), 581 | ]), 582 | ]; 583 | 584 | let track_help = Paragraph::new(track_help_text ) 585 | .block(track_block.title("Tracks").fg(self.theme.resolve(&self.theme.section_title))) 586 | .wrap(Wrap { trim: false }) 587 | .alignment(Alignment::Left); 588 | 589 | frame.render_widget(track_help, center[0]); 590 | 591 | let queue_block = Block::new() 592 | .borders(Borders::ALL) 593 | .border_type(self.border_type) 594 | .border_style(self.theme.resolve(&self.theme.border)); 595 | 596 | let queue_help = Paragraph::new("") 597 | .block(queue_block.title("Queue").fg(self.theme.resolve(&self.theme.section_title))) 598 | .wrap(Wrap { trim: false }) 599 | .alignment(Alignment::Left); 600 | 601 | frame.render_widget(queue_help, right[1]); 602 | 603 | let bottom = Block::default() 604 | .borders(Borders::ALL) 605 | .padding(Padding::new(0, 0, 0, 0)); 606 | 607 | frame.render_widget(bottom, center[1]); 608 | 609 | // lyrics area 610 | let lyrics_block = Block::new() 611 | .borders(Borders::ALL) 612 | .border_type(self.border_type) 613 | .border_style(self.theme.resolve(&self.theme.border)); 614 | 615 | let lyrics_help = Paragraph::new("") 616 | .block(lyrics_block.title("Lyrics").fg(self.theme.resolve(&self.theme.section_title))) 617 | .wrap(Wrap { trim: false }) 618 | .alignment(Alignment::Left); 619 | 620 | frame.render_widget(lyrics_help, right[0]); 621 | 622 | // player area 623 | let player_block = Block::new() 624 | .borders(Borders::ALL) 625 | .border_type(self.border_type) 626 | .border_style(self.theme.resolve(&self.theme.border)); 627 | 628 | let player_help = Paragraph::new("") 629 | .block(player_block.title("Player").fg(self.theme.resolve(&self.theme.section_title))) 630 | .wrap(Wrap { trim: false }) 631 | .alignment(Alignment::Left); 632 | 633 | frame.render_widget(player_help, center[1]); 634 | } 635 | } 636 | --------------------------------------------------------------------------------