Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 216 additions & 95 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 3 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,18 +245,11 @@ To enable image rendering, build with the `image` feature (disabled by default):
cargo install spotify_player --features image
```

Full-resolution images are supported in [Kitty](https://sw.kovidgoyal.net/kitty/graphics-protocol/) and [iTerm2](https://iterm2.com/documentation-images.html). Other terminals display images as [block characters](https://en.wikipedia.org/wiki/Block_Elements).

To use sixel graphics, build with the `sixel` feature (also enables `image`):

```shell
cargo install spotify_player --features sixel
```
Image rendering is powered by [`ratatui-image`](https://github.com/benjajaja/ratatui-image), which auto-detects the terminal's graphics protocol (Kitty, iTerm2, Sixel) on startup. Terminals without any graphics protocol support fall back to [block characters](https://en.wikipedia.org/wiki/Block_Elements).

**Notes**:

- Not all terminals supported by [libsixel](https://github.com/saitoha/libsixel) are supported by `spotify_player` (see [viuer supported terminals](https://github.com/atanunq/viuer/blob/dc81f44a97727e04be0b000712e9233c92116ff8/src/printer/sixel.rs#L83-L95)).
- Sixel images may scale oddly; adjust `cover_img_scale` for best results.
- Protocol detection queries the terminal via stdio. In nested terminals (e.g. Neovim's floating terminal), the query does not reach the outer terminal emulator, so the protocol falls back to block characters.

Image rendering examples:

Expand All @@ -268,7 +261,7 @@ Image rendering examples:

![kitty](https://user-images.githubusercontent.com/40011582/172967028-8cfb2daa-1642-499a-a5bf-8ed77f2b3fac.png)

- Sixel (`foot` terminal, `cover_img_scale=1.8`):
- Sixel (`foot` terminal):

![sixel](https://user-images.githubusercontent.com/40011582/219880331-58ac1c30-bbb0-4c99-a6cc-e5b7c9c81455.png)

Expand Down
1 change: 0 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ spotify_player -o device.volume=80 -o theme=dracula
| `genre_num` | Max number of genres to display in playback text. | `2` |
| `cover_img_length` | Cover image length (requires `image` feature). | `9` |
| `cover_img_width` | Cover image width (requires `image` feature). | `5` |
| `cover_img_scale` | Cover image scale (requires `image` feature). | `1.0` |
| `cover_img_pixels` | Pixels per side for cover image (requires `pixelate` feature). | `16` |
| `seek_duration_secs` | Seek duration in seconds for seek commands. | `5` |
| `sort_artist_albums_by_type` | Sort albums by type on artist pages. | `false` |
Expand Down
7 changes: 3 additions & 4 deletions spotify_player/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
backtrace = "0.3.76"
souvlaki = { version = "0.8.3", optional = true }
# Upgrade viuer to the latest version. Need to resolve the freezing issue in https://github.com/aome510/spotify-player/issues/899 beforehand
viuer = { version = "=0.9.2", optional = true }
image = { version = "0.25.10", optional = true }
notify-rust = { version = "4.12.0", optional = true, default-features = false, features = [
"d",
Expand All @@ -63,6 +61,7 @@ unicode-bidi = "0.3.18"
futures = "0.3.32"
# fix for https://github.com/aome510/spotify-player/issues/914
vergen = "=9.0.6"
ratatui-image = { version = "10.0.6", optional = true, default-features = false, features = ["crossterm"] }

[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.winit]
version = "0.30.13"
Expand Down Expand Up @@ -92,8 +91,8 @@ sdl-backend = ["streaming", "librespot-playback/sdl-backend"]
gstreamer-backend = ["streaming", "librespot-playback/gstreamer-backend"]
streaming = ["librespot-playback", "librespot-connect", "rustfft"]
media-control = ["souvlaki", "winit", "windows"]
image = ["viuer", "dep:image"]
sixel = ["image", "viuer/sixel"]
image = ["ratatui-image", "dep:image"]
sixel = ["image"]
pixelate = ["image"]
notify = ["notify-rust"]
daemon = ["daemonize", "streaming"]
Expand Down
2 changes: 1 addition & 1 deletion spotify_player/src/cli/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ fn print_features() {
print_feature!("streaming");
print_feature!("media-control");
print_feature!("image");
print_feature!("viuer");
print_feature!("ratatui-image");
print_feature!("sixel");
print_feature!("pixelate");
print_feature!("notify");
Expand Down
4 changes: 0 additions & 4 deletions spotify_player/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,6 @@ pub struct AppConfig {
pub cover_img_length: usize,
#[cfg(feature = "image")]
pub cover_img_width: usize,
#[cfg(feature = "image")]
pub cover_img_scale: f32,
#[cfg(feature = "pixelate")]
pub cover_img_pixels: u32,

Expand Down Expand Up @@ -351,8 +349,6 @@ impl Default for AppConfig {
cover_img_length: 9,
#[cfg(feature = "image")]
cover_img_width: 5,
#[cfg(feature = "image")]
cover_img_scale: 1.0,
#[cfg(feature = "pixelate")]
cover_img_pixels: 16,

Expand Down
11 changes: 0 additions & 11 deletions spotify_player/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,6 @@ fn init_logging(

#[tokio::main]
async fn start_app(state: &state::SharedState) -> Result<()> {
if !state.is_daemon {
#[cfg(feature = "image")]
{
// initialize `viuer` supports for kitty, iterm2, and sixel
viuer::get_kitty_support();
viuer::is_iterm_supported();
#[cfg(feature = "sixel")]
viuer::is_sixel_supported();
}
}

// client channels
let (client_pub, client_sub) = flume::unbounded::<client::ClientRequest>();

Expand Down
26 changes: 23 additions & 3 deletions spotify_player/src/state/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use crate::{
utils::filtered_items_from_query,
};

#[cfg(feature = "image")]
use ratatui_image::{picker::Picker, protocol::StatefulProtocol};

pub type UIStateGuard<'a> = parking_lot::MutexGuard<'a, UIState>;

mod page;
Expand All @@ -13,13 +16,23 @@ mod popup;
pub use page::*;
pub use popup::*;

#[derive(Default, Debug)]
#[cfg(feature = "image")]
#[derive(Default)]
pub struct ImageRenderInfo {
pub url: String,
pub render_area: ratatui::layout::Rect,
/// indicates if the image is rendered
pub rendered: bool,
pub state: Option<StatefulProtocol>,
}

#[cfg(feature = "image")]
impl std::fmt::Debug for ImageRenderInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImageRenderInfo")
.field("url", &self.url)
.field("render_area", &self.render_area)
.field("state", &self.state.is_some())
.finish()
}
}

/// Application's UI state
Expand All @@ -42,6 +55,9 @@ pub struct UIState {

#[cfg(feature = "image")]
pub last_cover_image_render_info: ImageRenderInfo,

#[cfg(feature = "image")]
pub picker: Picker,
}

impl UIState {
Expand Down Expand Up @@ -111,6 +127,10 @@ impl Default for UIState {

#[cfg(feature = "image")]
last_cover_image_render_info: ImageRenderInfo::default(),

// Will be reinitialize later in ui/mod.rs after init_ui()
#[cfg(feature = "image")]
picker: Picker::halfblocks(),
}
}
}
14 changes: 14 additions & 0 deletions spotify_player/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ pub mod utils;

/// Run the application UI
pub fn run(state: &SharedState) -> Result<()> {
#[cfg(feature = "image")]
{
crossterm::terminal::enable_raw_mode()?;
let mut ui = state.ui.lock();
ui.picker = match ratatui_image::picker::Picker::from_query_stdio() {
Ok(p) => p,
Err(err) => {
tracing::warn!("Failed to initialize query_stdio picker, error: {err:#}");
ratatui_image::picker::Picker::halfblocks()
}
};
crossterm::terminal::disable_raw_mode()?;
}

let mut terminal = init_ui().context("failed to initialize the application's UI")?;

let ui_refresh_duration = std::time::Duration::from_millis(
Expand Down
119 changes: 19 additions & 100 deletions spotify_player/src/ui/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ use crate::{
state::Track,
ui::utils::{format_genres, to_bidi_string},
};
#[cfg(feature = "image")]
use anyhow::{Context, Result};
use rspotify::model::Id;

/// Render a playback window showing information about the current playback, which includes
Expand Down Expand Up @@ -79,49 +77,26 @@ pub fn render_playback_window(
rspotify::model::PlayableItem::Unknown(_) => None,
};
if let Some(url) = url {
let needs_clear = if ui.last_cover_image_render_info.url != url
|| ui.last_cover_image_render_info.render_area != cover_img_rect
{
ui.last_cover_image_render_info = ImageRenderInfo {
url,
render_area: cover_img_rect,
rendered: false,
};
true
} else {
false
};

if needs_clear {
// clear the image's both new and old areas to ensure no remaining artifacts before rendering the image
// See: https://github.com/aome510/spotify-player/issues/389
clear_area(
frame,
ui.last_cover_image_render_info.render_area,
&ui.theme,
);
clear_area(frame, cover_img_rect, &ui.theme);
} else {
if !ui.last_cover_image_render_info.rendered {
if let Err(err) = render_playback_cover_image(state, ui) {
tracing::error!(
"Failed to render playback's cover image: {err:#}"
);
}
let data = state.data.read();
if let Some(img) = data.caches.images.get(&url) {
if ui.last_cover_image_render_info.url != url
|| ui.last_cover_image_render_info.render_area != cover_img_rect
{
let protocol = ui.picker.new_resize_protocol(img.clone());
ui.last_cover_image_render_info = ImageRenderInfo {
url,
render_area: cover_img_rect,
state: Some(protocol),
};
}

// set the `skip` state of cells in the cover image area
// to prevent buffer from overwriting the image's rendered area
// NOTE: `skip` should not be set when clearing the render area.
// Otherwise, nothing will be clear as the buffer doesn't handle cells with `skip=true`.
for x in cover_img_rect.left()..cover_img_rect.right() {
for y in cover_img_rect.top()..cover_img_rect.bottom() {
frame
.buffer_mut()
.cell_mut((x, y))
.expect("invalid cell")
.set_skip(true);
}
if let Some(ref mut protocol) = ui.last_cover_image_render_info.state {
let image_widget = ratatui_image::StatefulImage::new();
frame.render_stateful_widget(
image_widget,
cover_img_rect,
protocol,
);
}
}
}
Expand Down Expand Up @@ -167,14 +142,7 @@ pub fn render_playback_window(
// clear the previous widget's area before rendering the text.
#[cfg(feature = "image")]
{
if ui.last_cover_image_render_info.rendered {
clear_area(
frame,
ui.last_cover_image_render_info.render_area,
&ui.theme,
);
ui.last_cover_image_render_info = ImageRenderInfo::default();
}
ui.last_cover_image_render_info = ImageRenderInfo::default();
}

if player.playback_last_updated_time.is_none() {
Expand Down Expand Up @@ -234,20 +202,6 @@ fn split_rect_for_cover_img(rect: Rect) -> (Rect, Rect) {
(ver_chunks[0], hor_chunks[1])
}

#[cfg(feature = "image")]
fn clear_area(frame: &mut Frame, rect: Rect, theme: &config::Theme) {
for x in rect.left()..rect.right() {
for y in rect.top()..rect.bottom() {
frame
.buffer_mut()
.cell_mut((x, y))
.expect("invalid cell")
.set_char(' ')
.set_style(theme.app());
}
}
}

fn construct_playback_text(
ui: &UIStateGuard,
state: &SharedState,
Expand Down Expand Up @@ -467,41 +421,6 @@ fn render_playback_progress_bar(
ui.playback_progress_bar_rect = rect;
}

#[cfg(feature = "image")]
fn render_playback_cover_image(state: &SharedState, ui: &mut UIStateGuard) -> Result<()> {
let data = state.data.read();
if let Some(image) = data.caches.images.get(&ui.last_cover_image_render_info.url) {
let rect = ui.last_cover_image_render_info.render_area;

// `viuer` renders image using `sixel` in a different scale compared to other methods.
// Scale the image to make the rendered image more fit if needed.
// This scaling factor is user configurable as the scale works differently
// with different fonts and terminals.
// For more context, see https://github.com/aome510/spotify-player/issues/122.
let scale = config::get_config().app_config.cover_img_scale;
let width = (f32::from(rect.width) * scale).round() as u32;
let height = (f32::from(rect.height) * scale).round() as u32;

viuer::print(
image,
&viuer::Config {
x: rect.x,
y: rect.y as i16,
width: Some(width),
height: Some(height),
restore_cursor: true,
transparent: true,
..Default::default()
},
)
.context("print image to the terminal")?;

ui.last_cover_image_render_info.rendered = true;
}

Ok(())
}

/// Split the given area into two, the first one for the playback window
/// and the second one for the main application's layout (popup, page, etc).
#[allow(unused_variables)]
Expand Down
Loading