Implement cover drawing as optional feature
This commit is contained in:
committed by
Henrik Friedrichsen
parent
dfb60ee4be
commit
df87ff9bdd
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
11
README.md
11
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
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
302
src/ui/cover.rs
Normal 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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,6 @@ pub mod search_results;
|
||||
pub mod show;
|
||||
pub mod statusbar;
|
||||
pub mod tabview;
|
||||
|
||||
#[cfg(feature = "cover")]
|
||||
pub mod cover;
|
||||
|
||||
Reference in New Issue
Block a user