Implement cover drawing as optional feature

This commit is contained in:
KoffeinFlummi
2021-01-29 10:24:30 +01:00
committed by Henrik Friedrichsen
parent dfb60ee4be
commit df87ff9bdd
10 changed files with 359 additions and 0 deletions

10
Cargo.lock generated
View File

@@ -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",

View File

@@ -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]

View File

@@ -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
```

View File

@@ -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);

View File

@@ -28,6 +28,7 @@ pub struct ConfigValues {
pub gapless: Option<bool>,
pub shuffle: Option<bool>,
pub repeat: Option<queue::RepeatSetting>,
pub cover_max_scale: Option<f32>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]

View File

@@ -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");

View File

@@ -55,6 +55,8 @@ pub trait ViewExt: View {
"".into()
}
fn on_leave(&self) {}
fn on_command(&mut self, _s: &mut Cursive, _cmd: &Command) -> Result<CommandResult, String> {
Ok(CommandResult::Ignored)
}
@@ -65,6 +67,10 @@ impl<V: ViewExt> ViewExt for NamedView<V> {
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<CommandResult, String> {
self.with_view_mut(move |v| v.on_command(s, cmd)).unwrap()
}

302
src/ui/cover.rs Normal file
View File

@@ -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<Queue>,
library: Arc<Library>,
loading: Arc<RwLock<HashSet<String>>>,
last_size: RwLock<Vec2>,
drawn_url: RwLock<Option<String>>,
ueberzug: RwLock<Option<Child>>,
font_size: Vec2,
}
impl CoverView {
pub fn new(queue: Arc<Queue>, library: Arc<Library>, 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<PathBuf> {
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<CommandResult, String> {
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(())
}

View File

@@ -70,6 +70,10 @@ impl Layout {
}
pub fn add_screen<S: Into<String>, 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<S: Into<String>>(&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<dyn ViewExt>) {
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());
}

View File

@@ -14,3 +14,6 @@ pub mod search_results;
pub mod show;
pub mod statusbar;
pub mod tabview;
#[cfg(feature = "cover")]
pub mod cover;