diff --git a/Cargo.lock b/Cargo.lock index 8385adc..31ee2d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,6 +1473,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ioctl-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607b0d5e3c8affe6744655ccd713c5d3763c09407e191cea94705f541fd45151" +dependencies = [ + "libc", +] + [[package]] name = "iovec" version = "0.1.4" @@ -1921,6 +1930,7 @@ dependencies = [ "fern", "futures 0.1.30", "futures 0.3.13", + "ioctl-rs", "lazy_static", "libc", "librespot-core", diff --git a/Cargo.toml b/Cargo.toml index d9ae345..677ff9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ strum = "0.20.0" strum_macros = "0.20.1" libc = "0.2" regex = "1" +ioctl-rs = { version = "0.2", optional = true } [dependencies.cursive] version = "0.16.3" @@ -61,6 +62,7 @@ portaudio_backend = ["librespot-playback/portaudio-backend"] termion_backend = ["cursive/termion-backend"] mpris = ["dbus", "dbus-tree"] notify = ["notify-rust"] +cover = ["ioctl-rs"] default = ["share_clipboard", "pulseaudio_backend", "mpris", "notify", "cursive/pancurses-backend"] [package.metadata.deb] diff --git a/README.md b/README.md index 651137f..3e7430e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ depending on your desktop environment settings. Have a look at the * `F2`: Search * `F3`: Library * `d` deletes the currently selected playlist + * `F8`: Album art (if compiled with the `cover` feature) * Tracks and playlists can be played using `Return` and queued using `Space` * `.` will play the selected item after the currently playing track * `p` will move to the currently playing track in the queue @@ -236,3 +237,13 @@ search_match = "light red" More examples can be found in pull request https://github.com/hrkfdn/ncspot/pull/40. + +### Cover Drawing + +When compiled with the `cover` feature, `ncspot` can draw the album art of the current track in a dedicated view (`:focus cover` or `F8` by default) using [Überzug](https://github.com/seebye/ueberzug). For more information on installation and terminal compatibility, consult that repository. + +To allow scaling the album art up beyond its resolution (640x640 for Spotify covers), use the config key `cover_max_scale`. This is especially useful for HiDPI displays: + +``` +cover_max_scale = 2 +``` diff --git a/src/commands.rs b/src/commands.rs index cf2e890..29c011b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -363,6 +363,8 @@ impl CommandManager { kb.insert("F1".into(), Command::Focus("queue".into())); kb.insert("F2".into(), Command::Focus("search".into())); kb.insert("F3".into(), Command::Focus("library".into())); + #[cfg(feature = "cover")] + kb.insert("F8".into(), Command::Focus("cover".into())); kb.insert("?".into(), Command::Help); kb.insert("Backspace".into(), Command::Back); diff --git a/src/config.rs b/src/config.rs index f69e743..89e8227 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,7 @@ pub struct ConfigValues { pub gapless: Option, pub shuffle: Option, pub repeat: Option, + pub cover_max_scale: Option, } #[derive(Serialize, Deserialize, Debug, Default, Clone)] diff --git a/src/main.rs b/src/main.rs index 97f2770..02fcb1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -257,6 +257,9 @@ fn main() { let queueview = ui::queue::QueueView::new(queue.clone(), library.clone()); + #[cfg(feature = "cover")] + let coverview = ui::cover::CoverView::new(queue.clone(), library.clone(), &cfg); + let status = ui::statusbar::StatusBar::new( queue.clone(), library, @@ -268,6 +271,9 @@ fn main() { .screen("library", libraryview.with_name("library")) .screen("queue", queueview); + #[cfg(feature = "cover")] + layout.add_screen("cover", coverview.with_name("cover")); + // initial screen is library layout.set_screen("library"); diff --git a/src/traits.rs b/src/traits.rs index 4d8e4d4..dae0476 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -55,6 +55,8 @@ pub trait ViewExt: View { "".into() } + fn on_leave(&self) {} + fn on_command(&mut self, _s: &mut Cursive, _cmd: &Command) -> Result { Ok(CommandResult::Ignored) } @@ -65,6 +67,10 @@ impl ViewExt for NamedView { self.with_view(|v| v.title()).unwrap_or_default() } + fn on_leave(&self) { + self.with_view(|v| v.on_leave()); + } + fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { self.with_view_mut(move |v| v.on_command(s, cmd)).unwrap() } diff --git a/src/ui/cover.rs b/src/ui/cover.rs new file mode 100644 index 0000000..bc823ec --- /dev/null +++ b/src/ui/cover.rs @@ -0,0 +1,302 @@ +use std::collections::HashSet; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Child, Stdio}; + +use std::sync::{Arc, RwLock}; + +use cursive::theme::{ColorStyle, ColorType, PaletteColor}; +use cursive::{Cursive, Printer, Vec2, View}; +use ioctl_rs::{ioctl, TIOCGWINSZ}; + +use crate::command::{Command, GotoMode}; +use crate::commands::CommandResult; +use crate::library::Library; +use crate::queue::Queue; +use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; +use crate::ui::album::AlbumView; +use crate::ui::artist::ArtistView; +use crate::Config; + +pub struct CoverView { + queue: Arc, + library: Arc, + loading: Arc>>, + last_size: RwLock, + drawn_url: RwLock>, + ueberzug: RwLock>, + font_size: Vec2, +} + +impl CoverView { + pub fn new(queue: Arc, library: Arc, config: &Config) -> Self { + // Determine size of window both in pixels and chars + let (rows, cols, mut xpixels, mut ypixels) = unsafe { + let query: (u16, u16, u16, u16) = (0, 0, 0, 0); + ioctl(1, TIOCGWINSZ, &query); + query + }; + + debug!( + "Determined window dimensions: {}x{}, {}x{}", + xpixels, ypixels, cols, rows + ); + + // Determine font size, considering max scale to prevent tiny covers on HiDPI screens + let scale = config.values().cover_max_scale.unwrap_or(1.0); + xpixels = ((xpixels as f32) / scale) as u16; + ypixels = ((ypixels as f32) / scale) as u16; + + let font_size = Vec2::new((xpixels / cols) as usize, (ypixels / rows) as usize); + + debug!("Determined font size: {}x{}", font_size.x, font_size.y); + + Self { + queue, + library, + ueberzug: RwLock::new(None), + loading: Arc::new(RwLock::new(HashSet::new())), + last_size: RwLock::new(Vec2::new(0, 0)), + drawn_url: RwLock::new(None), + font_size, + } + } + + fn draw_cover(&self, url: String, mut draw_offset: Vec2, draw_size: Vec2) { + if draw_size.x <= 1 || draw_size.y <= 1 { + return; + } + + let needs_redraw = { + let last_size = self.last_size.read().unwrap(); + let drawn_url = self.drawn_url.read().unwrap(); + *last_size != draw_size || drawn_url.as_ref() != Some(&url) + }; + + if !needs_redraw { + return; + } + + let path = match self.cache_path(url.clone()) { + Some(p) => p, + None => return, + }; + + let mut img_size = Vec2::new(640, 640); + + let draw_size_pxls = draw_size * self.font_size; + let ratio = f32::min( + f32::min( + draw_size_pxls.x as f32 / img_size.x as f32, + draw_size_pxls.y as f32 / img_size.y as f32, + ), + 1.0, + ); + + img_size = Vec2::new( + (ratio * img_size.x as f32) as usize, + (ratio * img_size.y as f32) as usize, + ); + + // Ueberzug takes an area given in chars and fits the image to + // that area (from the top left). Since we want to center the + // image at least horizontally, we need to fiddle around a bit. + let mut size = img_size / self.font_size; + + // Make sure there is equal space in chars on either side + if size.x % 2 != draw_size.x % 2 { + size.x -= 1; + } + + // Make sure x is the bottleneck so full width is used + size.y = std::cmp::min(draw_size.y, size.y + 1); + + // Round up since the bottom might have empty space within + // the designated box + draw_offset.x += (draw_size.x - size.x) / 2; + draw_offset.y += (draw_size.y - size.y) - (draw_size.y - size.y) / 2; + + let cmd = format!("{{\"action\":\"add\",\"scaler\":\"fit_contain\",\"identifier\":\"cover\",\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"path\":\"{}\"}}\n", + draw_offset.x, draw_offset.y, + size.x, size.y, + path.to_str().unwrap() + ); + + if let Err(e) = self.run_ueberzug_cmd(&cmd) { + error!("Failed to run Ueberzug: {}", e); + return; + } + + let mut last_size = self.last_size.write().unwrap(); + *last_size = draw_size; + + let mut drawn_url = self.drawn_url.write().unwrap(); + *drawn_url = Some(url); + } + + fn clear_cover(&self) { + let mut drawn_url = self.drawn_url.write().unwrap(); + *drawn_url = None; + + let cmd = "{\"action\": \"remove\", \"identifier\": \"cover\"}\n"; + if let Err(e) = self.run_ueberzug_cmd(cmd) { + error!("Failed to run Ueberzug: {}", e); + } + } + + fn run_ueberzug_cmd(&self, cmd: &str) -> Result<(), std::io::Error> { + let mut ueberzug = self.ueberzug.write().unwrap(); + + if ueberzug.is_none() { + *ueberzug = Some( + std::process::Command::new("ueberzug") + .args(&["layer", "--silent"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?, + ); + } + + let stdin = (*ueberzug).as_mut().unwrap().stdin.as_mut().unwrap(); + stdin.write_all(cmd.as_bytes())?; + + Ok(()) + } + + fn cache_path(&self, url: String) -> Option { + let mut path = crate::config::cache_path("covers"); + path.push(url.split("/").last().unwrap()); + + let mut loading = self.loading.write().unwrap(); + if loading.contains(&url) { + return None; + } + + if path.exists() { + return Some(path); + } + + loading.insert(url.clone()); + + let loading_thread = self.loading.clone(); + std::thread::spawn(move || { + if let Err(e) = download(url.clone(), path.clone()) { + error!("Failed to download cover: {}", e); + } + let mut loading = loading_thread.write().unwrap(); + loading.remove(&url.clone()); + }); + + None + } +} + +impl View for CoverView { + fn draw(&self, printer: &Printer<'_, '_>) { + // Completely blank out screen + let style = ColorStyle::new( + ColorType::Palette(PaletteColor::Background), + ColorType::Palette(PaletteColor::Background), + ); + printer.with_color(style, |printer| { + for i in 0..printer.size.y { + printer.print_hline((0, i), printer.size.x, " "); + } + }); + + let cover_url = self.queue.get_current().map(|t| t.cover_url()).flatten(); + + if let Some(url) = cover_url { + self.draw_cover(url, printer.offset, printer.size); + } else { + self.clear_cover(); + } + } + + fn required_size(&mut self, constraint: Vec2) -> Vec2 { + Vec2::new(constraint.x, 2) + } +} + +impl ViewExt for CoverView { + fn title(&self) -> String { + "Cover".to_string() + } + + fn on_leave(&self) { + self.clear_cover(); + } + + fn on_command(&mut self, _s: &mut Cursive, cmd: &Command) -> Result { + match cmd { + Command::Save => { + if let Some(mut track) = self.queue.get_current() { + track.save(self.library.clone()); + } + } + Command::Delete => { + if let Some(mut track) = self.queue.get_current() { + track.unsave(self.library.clone()); + } + } + Command::Share(_mode) => { + let url = self + .queue + .get_current() + .and_then(|t| t.as_listitem().share_url()); + + if let Some(url) = url { + #[cfg(feature = "share_clipboard")] + crate::sharing::write_share(url); + } + + return Ok(CommandResult::Consumed(None)); + } + Command::Goto(mode) => { + if let Some(track) = self.queue.get_current() { + let queue = self.queue.clone(); + let library = self.library.clone(); + + match mode { + GotoMode::Album => { + if let Some(album) = track.album(queue.clone()) { + let view = + AlbumView::new(queue, library, &album).as_boxed_view_ext(); + return Ok(CommandResult::View(view)); + } + } + GotoMode::Artist => { + if let Some(artists) = track.artists() { + return match artists.len() { + 0 => Ok(CommandResult::Consumed(None)), + // Always choose the first artist even with more because + // the cover image really doesn't play nice with the menu + _ => { + let view = ArtistView::new(queue, library, &artists[0]) + .as_boxed_view_ext(); + Ok(CommandResult::View(view)) + } + }; + } + } + } + } + } + _ => {} + }; + + Ok(CommandResult::Ignored) + } +} + +fn download(url: String, path: PathBuf) -> Result<(), std::io::Error> { + let mut resp = + reqwest::get(&url).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + std::fs::create_dir_all(path.parent().unwrap())?; + let mut file = File::create(path)?; + + std::io::copy(&mut resp, &mut file)?; + Ok(()) +} diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 49e65a9..0050d4f 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -70,6 +70,10 @@ impl Layout { } pub fn add_screen, T: IntoBoxedViewExt>(&mut self, id: S, view: T) { + if let Some(view) = self.get_top_view() { + view.on_leave(); + } + let s = id.into(); self.screens.insert(s.clone(), view.as_boxed_view_ext()); self.stack.insert(s.clone(), Vec::new()); @@ -82,6 +86,10 @@ impl Layout { } pub fn set_screen>(&mut self, id: S) { + if let Some(view) = self.get_top_view() { + view.on_leave(); + } + let s = id.into(); self.focus = Some(s); self.cmdline_focus = false; @@ -113,12 +121,20 @@ impl Layout { } pub fn push_view(&mut self, view: Box) { + if let Some(view) = self.get_top_view() { + view.on_leave(); + } + if let Some(stack) = self.get_focussed_stack_mut() { stack.push(view) } } pub fn pop_view(&mut self) { + if let Some(view) = self.get_top_view() { + view.on_leave(); + } + self.get_focussed_stack_mut().map(|stack| stack.pop()); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e6500a4..e4f7dd9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,3 +14,6 @@ pub mod search_results; pub mod show; pub mod statusbar; pub mod tabview; + +#[cfg(feature = "cover")] +pub mod cover;