use super::{
config, utils::construct_and_render_block, Alignment, Borders, Constraint, Frame, Gauge,
Layout, Line, LineGauge, Modifier, Paragraph, PlaybackMetadata, Rect, SharedState, Span, Style,
Text, UIStateGuard, Wrap,
};
#[cfg(feature = "image")]
use crate::state::ImageRenderInfo;
use crate::{
state::Track,
ui::utils::{format_genres, to_bidi_string},
};
#[cfg(feature = "image")]
use anyhow::{Context, Result};
use rspotify::model::Id;
pub fn render_playback_window(
frame: &mut Frame,
state: &SharedState,
ui: &mut UIStateGuard,
rect: Rect,
) -> Rect {
let (rect, other_rect) = split_rect_for_playback_window(state, rect);
let rect = construct_and_render_block("Playback", &ui.theme, Borders::ALL, frame, rect);
let player = state.player.read();
if let Some(ref playback) = player.playback {
if let Some(item) = &playback.item {
#[cfg(feature = "streaming")]
let (rect, vis_rect) = {
let configs = config::get_config();
if configs.app_config.enable_audio_visualization
&& state.is_local_streaming_active()
{
let chunks = Layout::vertical([
Constraint::Fill(0),
Constraint::Length(super::streaming::VIS_HEIGHT),
])
.split(rect);
(chunks[0], Some(chunks[1]))
} else {
(rect, None)
}
};
let (metadata_rect, progress_bar_rect) = {
#[cfg(feature = "image")]
{
let configs = config::get_config();
let (metadata_rect, cover_img_rect, progress_bar_rect) =
match configs.app_config.progress_bar_position {
config::ProgressBarPosition::Bottom => {
let ver_chunks = split_rect_for_progress_bar(rect); let hor_chunks = split_rect_for_cover_img(ver_chunks.0); (hor_chunks.1, hor_chunks.0, ver_chunks.1)
}
config::ProgressBarPosition::Right => {
let hor_chunks = split_rect_for_cover_img(rect); let ver_chunks = split_rect_for_progress_bar(hor_chunks.1); (ver_chunks.0, hor_chunks.0, ver_chunks.1)
}
};
let url = match item {
rspotify::model::PlayableItem::Track(track) => {
crate::utils::get_track_album_image_url(track).map(String::from)
}
rspotify::model::PlayableItem::Episode(episode) => {
crate::utils::get_episode_show_image_url(episode).map(String::from)
}
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_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:#}"
);
}
}
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);
}
}
}
}
(metadata_rect, progress_bar_rect)
}
#[cfg(not(feature = "image"))]
{
let chunks = split_rect_for_progress_bar(rect);
(chunks.0, chunks.1)
}
};
if let Some(ref playback) = player.buffered_playback {
let playback_text = construct_playback_text(ui, state, item, playback);
let playback_desc = Paragraph::new(playback_text);
frame.render_widget(playback_desc, metadata_rect);
}
let duration = match item {
rspotify::model::PlayableItem::Track(track) => track.duration,
rspotify::model::PlayableItem::Episode(episode) => episode.duration,
rspotify::model::PlayableItem::Unknown(item) => {
log::warn!("Unknown playback item: {item:?}");
return other_rect;
}
};
let progress = std::cmp::min(
player.playback_progress().expect("non-empty playback"),
duration,
);
render_playback_progress_bar(frame, ui, progress, duration, progress_bar_rect);
#[cfg(feature = "streaming")]
if let Some(vis_r) = vis_rect {
super::streaming::render_audio_visualization(frame, state, vis_r);
}
return other_rect;
}
}
#[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();
}
}
if player.playback_last_updated_time.is_none() {
const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame_idx = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
/ 100) as usize
% SPINNER_FRAMES.len();
let vertical_chunks = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(rect);
frame.render_widget(
Paragraph::new(format!("{} Loading...", SPINNER_FRAMES[frame_idx]))
.style(ui.theme.playback_metadata())
.alignment(Alignment::Center),
vertical_chunks[1],
);
} else {
frame.render_widget(
Paragraph::new(
"No playback found. Please start a new playback.\n \
Make sure there is a running Spotify device and try to connect to one using the `SwitchDevice` command."
)
.wrap(Wrap { trim: true }),
rect,
);
}
other_rect
}
fn split_rect_for_progress_bar(rect: Rect) -> (Rect, Rect) {
let chunks = Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]).split(rect);
(chunks[0], chunks[1])
}
#[cfg(feature = "image")]
fn split_rect_for_cover_img(rect: Rect) -> (Rect, Rect) {
let configs = config::get_config();
let hor_chunks = Layout::horizontal([
Constraint::Length(configs.app_config.cover_img_length as u16),
Constraint::Fill(0), ])
.spacing(1)
.split(rect);
let ver_chunks = Layout::vertical([
Constraint::Length(configs.app_config.cover_img_width as u16), ])
.split(hor_chunks[0]);
(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,
playable: &rspotify::model::PlayableItem,
playback: &PlaybackMetadata,
) -> Text<'static> {
let configs = config::get_config();
let format_str = &configs.app_config.playback_format;
let data = state.data.read();
let mut playback_text = Text::default();
let mut spans = vec![];
let re = regex::Regex::new(r"\{.*?\}|\n").unwrap();
let mut ptr = 0;
for m in re.find_iter(format_str) {
let s = m.start();
let e = m.end();
if ptr < s {
spans.push(Span::raw(format_str[ptr..s].to_string()));
}
ptr = e;
let (text, style) = match m.as_str() {
"\n" => {
let mut tmp = vec![];
std::mem::swap(&mut tmp, &mut spans);
playback_text.lines.push(Line::from(tmp));
continue;
}
"{status}" => (
if playback.is_playing {
&configs.app_config.play_icon
} else {
&configs.app_config.pause_icon
}
.to_owned(),
ui.theme.playback_status(),
),
"{liked}" => match playable {
rspotify::model::PlayableItem::Track(track) => match &track.id {
Some(id) => {
if data.user_data.saved_tracks.contains_key(&id.uri()) {
(configs.app_config.liked_icon.clone(), ui.theme.like())
} else {
continue;
}
}
None => continue,
},
rspotify::model::PlayableItem::Episode(_)
| rspotify::model::PlayableItem::Unknown(_) => continue,
},
"{track}" => match playable {
rspotify::model::PlayableItem::Track(track) => (
{
let track = Track::try_from_full_track(track.clone()).unwrap();
to_bidi_string(&track.display_name())
},
ui.theme.playback_track(),
),
rspotify::model::PlayableItem::Episode(episode) => (
{
let bidi_string = to_bidi_string(&episode.name);
if episode.explicit {
format!("{bidi_string} (E)")
} else {
bidi_string
}
},
ui.theme.playback_track(),
),
rspotify::model::PlayableItem::Unknown(_) => {
continue;
}
},
"{track_number}" => match playable {
rspotify::model::PlayableItem::Track(track) => (
{ to_bidi_string(&track.track_number.to_string()) },
ui.theme.playback_track(),
),
rspotify::model::PlayableItem::Episode(_)
| rspotify::model::PlayableItem::Unknown(_) => {
continue;
}
},
"{artists}" => match playable {
rspotify::model::PlayableItem::Track(track) => (
to_bidi_string(&crate::utils::map_join(&track.artists, |a| &a.name, ", ")),
ui.theme.playback_artists(),
),
rspotify::model::PlayableItem::Episode(episode) => {
(episode.show.publisher.clone(), ui.theme.playback_artists())
}
rspotify::model::PlayableItem::Unknown(_) => {
continue;
}
},
"{album}" => match playable {
rspotify::model::PlayableItem::Track(track) => {
(to_bidi_string(&track.album.name), ui.theme.playback_album())
}
rspotify::model::PlayableItem::Episode(episode) => (
to_bidi_string(&episode.show.name),
ui.theme.playback_album(),
),
rspotify::model::PlayableItem::Unknown(_) => {
continue;
}
},
"{genres}" => match playable {
rspotify::model::PlayableItem::Track(full_track) => {
let genre = match data.caches.genres.get(&full_track.artists[0].name) {
Some(genres) => &format_genres(genres, configs.app_config.genre_num),
None => "no genre",
};
(to_bidi_string(genre), ui.theme.playback_genres())
}
rspotify::model::PlayableItem::Episode(_) => {
(to_bidi_string("no genre"), ui.theme.playback_genres())
}
rspotify::model::PlayableItem::Unknown(_) => {
continue;
}
},
"{metadata}" => {
let repeat_value = <&'static str>::from(playback.repeat_state).to_string();
let volume_value = if let Some(volume) = playback.mute_state {
format!("{volume}% (muted)")
} else {
format!("{}%", playback.volume.unwrap_or_default())
};
let mut parts = vec![];
for field in &configs.app_config.playback_metadata_fields {
match field.as_str() {
"repeat" => parts.push(format!("repeat: {repeat_value}")),
"shuffle" => parts.push(format!("shuffle: {}", playback.shuffle_state)),
"volume" => parts.push(format!("volume: {volume_value}")),
"device" => parts.push(format!("device: {}", playback.device_name)),
_ => {}
}
}
let metadata_str = parts.join(" | ");
(metadata_str, ui.theme.playback_metadata())
}
_ => continue,
};
spans.push(Span::styled(text, style));
}
if ptr < format_str.len() {
spans.push(Span::raw(format_str[ptr..].to_string()));
}
if !spans.is_empty() {
playback_text.lines.push(Line::from(spans));
}
playback_text
}
fn render_playback_progress_bar(
frame: &mut Frame,
ui: &mut UIStateGuard,
progress: chrono::Duration,
duration: chrono::Duration,
rect: Rect,
) {
let ratio = (progress.num_seconds() as f64 / duration.num_seconds() as f64).clamp(0.0, 1.0);
match config::get_config().app_config.progress_bar_type {
config::ProgressBarType::Line => frame.render_widget(
LineGauge::default()
.filled_style(ui.theme.playback_progress_bar())
.unfilled_style(ui.theme.playback_progress_bar_unfilled())
.ratio(ratio)
.label(Span::styled(
format!(
"{}/{}",
crate::utils::format_duration(&progress),
crate::utils::format_duration(&duration),
),
Style::default().add_modifier(Modifier::BOLD),
)),
rect,
),
config::ProgressBarType::Rectangle => frame.render_widget(
Gauge::default()
.gauge_style(ui.theme.playback_progress_bar())
.ratio(ratio)
.label(Span::styled(
format!(
"{}/{}",
crate::utils::format_duration(&progress),
crate::utils::format_duration(&duration),
),
Style::default().add_modifier(Modifier::BOLD),
)),
rect,
),
}
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;
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(())
}
#[allow(unused_variables)]
fn split_rect_for_playback_window(state: &SharedState, rect: Rect) -> (Rect, Rect) {
let configs = config::get_config();
let playback_width = configs.app_config.layout.playback_window_height;
#[cfg(feature = "image")]
let playback_width = std::cmp::max(configs.app_config.cover_img_width + 1, playback_width);
#[cfg(feature = "streaming")]
let playback_width = playback_width
+ if configs.app_config.enable_audio_visualization && state.is_local_streaming_active() {
super::streaming::VIS_HEIGHT as usize
} else {
0
};
let num_lines = match configs.app_config.progress_bar_position {
config::ProgressBarPosition::Bottom => 2,
config::ProgressBarPosition::Right => 1,
};
let playback_width = (playback_width + num_lines) as u16;
match configs.app_config.layout.playback_window_position {
config::Position::Top => {
let chunks =
Layout::vertical([Constraint::Length(playback_width), Constraint::Fill(0)])
.split(rect);
(chunks[0], chunks[1])
}
config::Position::Bottom => {
let chunks =
Layout::vertical([Constraint::Fill(0), Constraint::Length(playback_width)])
.split(rect);
(chunks[1], chunks[0])
}
}
}